diff --git a/obp-api/pom.xml b/obp-api/pom.xml index 0ba0e454a..c5439fde4 100644 --- a/obp-api/pom.xml +++ b/obp-api/pom.xml @@ -509,6 +509,12 @@ com.fasterxml.jackson.core jackson-databind 2.12.7.1 + + + + tools.jackson.dataformat + jackson-dataformat-yaml + 3.0.3 diff --git a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocs140.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocs140.scala index 78b00a893..3845c33ea 100644 --- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocs140.scala +++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocs140.scala @@ -1,8 +1,16 @@ package code.api.ResourceDocs1_4_0 +import code.api.Constant.HostName import code.api.OBPRestHelper -import code.util.Helper.MdcLoggable +import code.api.cache.Caching +import code.api.util.APIUtil._ +import code.api.util.{APIUtil, ApiVersionUtils, YAMLUtils} +import code.api.v1_4_0.JSONFactory1_4_0 +import code.apicollectionendpoint.MappedApiCollectionEndpointsProvider +import code.util.Helper.{MdcLoggable, SILENCE_IS_GOLDEN} +import com.openbankproject.commons.model.enums.ContentParam.{DYNAMIC, STATIC} import com.openbankproject.commons.util.{ApiVersion, ApiVersionStatus} +import net.liftweb.http.{GetRequest, InMemoryResponse, PlainTextResponse, Req, S} object ResourceDocs140 extends OBPRestHelper with ResourceDocsAPIMethods with MdcLoggable { @@ -152,6 +160,85 @@ object ResourceDocs300 extends OBPRestHelper with ResourceDocsAPIMethods with Md route }) }) + + // Register YAML endpoint using standard RestHelper approach + serve { + case Req("obp" :: versionStr :: "resource-docs" :: requestedApiVersionString :: "openapi.yaml" :: Nil, _, GetRequest) if versionStr == version.toString => + val (resourceDocTags, partialFunctions, locale, contentParam, apiCollectionIdParam) = ResourceDocsAPIMethodsUtil.getParams() + + // Validate parameters + if (S.param("tags").exists(_.trim.isEmpty)) { + PlainTextResponse("Invalid tags parameter - empty values not allowed", 400) + } else if (S.param("functions").exists(_.trim.isEmpty)) { + PlainTextResponse("Invalid functions parameter - empty values not allowed", 400) + } else if (S.param("api-collection-id").exists(_.trim.isEmpty)) { + PlainTextResponse("Invalid api-collection-id parameter - empty values not allowed", 400) + } else if (S.param("content").isDefined && contentParam.isEmpty) { + PlainTextResponse("Invalid content parameter. Valid values: static, dynamic, all", 400) + } else { + try { + val requestedApiVersion = ApiVersionUtils.valueOf(requestedApiVersionString) + if (!versionIsAllowed(requestedApiVersion)) { + PlainTextResponse(s"API Version not supported: $requestedApiVersionString", 400) + } else if (locale.isDefined && APIUtil.obpLocaleValidation(locale.get) != SILENCE_IS_GOLDEN) { + PlainTextResponse(s"Invalid locale: ${locale.get}", 400) + } else { + val isVersion4OrHigher = true + val cacheKey = APIUtil.createResourceDocCacheKey( + Some("openapi31yaml"), + requestedApiVersionString, + resourceDocTags, + partialFunctions, + locale, + contentParam, + apiCollectionIdParam, + Some(isVersion4OrHigher) + ) + val cacheValueFromRedis = Caching.getStaticSwaggerDocCache(cacheKey) + + val yamlString = if (cacheValueFromRedis.isDefined) { + cacheValueFromRedis.get + } else { + // Generate OpenAPI JSON and convert to YAML + val openApiJValue = try { + val resourceDocsJsonFiltered = locale match { + case _ if (apiCollectionIdParam.isDefined) => + val operationIds = MappedApiCollectionEndpointsProvider.getApiCollectionEndpoints(apiCollectionIdParam.getOrElse("")).map(_.operationId).map(getObpFormatOperationId) + val resourceDocs = ResourceDoc.getResourceDocs(operationIds) + val resourceDocsJson = JSONFactory1_4_0.createResourceDocsJson(resourceDocs, isVersion4OrHigher, locale) + resourceDocsJson.resource_docs + case _ => + // Get all resource docs for the requested version + val allResourceDocs = ImplementationsResourceDocs.getResourceDocsList(requestedApiVersion).getOrElse(List.empty) + val filteredResourceDocs = ResourceDocsAPIMethodsUtil.filterResourceDocs(allResourceDocs, resourceDocTags, partialFunctions) + val resourceDocJson = JSONFactory1_4_0.createResourceDocsJson(filteredResourceDocs, isVersion4OrHigher, locale) + resourceDocJson.resource_docs + } + + val hostname = HostName + val openApiDoc = code.api.ResourceDocs1_4_0.OpenAPI31JSONFactory.createOpenAPI31Json(resourceDocsJsonFiltered, requestedApiVersionString, hostname) + code.api.ResourceDocs1_4_0.OpenAPI31JSONFactory.OpenAPI31JsonFormats.toJValue(openApiDoc) + } catch { + case e: Exception => + logger.error(s"Error generating OpenAPI JSON: ${e.getMessage}", e) + throw e + } + + val yamlResult = YAMLUtils.jValueToYAMLSafe(openApiJValue, s"# Error converting OpenAPI to YAML: ${openApiJValue.toString}") + Caching.setStaticSwaggerDocCache(cacheKey, yamlResult) + yamlResult + } + + val headers = List("Content-Type" -> YAMLUtils.getYAMLContentType) + val bytes = yamlString.getBytes("UTF-8") + InMemoryResponse(bytes, headers, Nil, 200) + } + } catch { + case _: Exception => + PlainTextResponse(s"Invalid API version: $requestedApiVersionString", 400) + } + } + } } } \ No newline at end of file diff --git a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocsAPIMethods.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocsAPIMethods.scala index 3d5aef287..9d5c894b3 100644 --- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocsAPIMethods.scala +++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocsAPIMethods.scala @@ -10,6 +10,7 @@ import code.api.util.ExampleValue.endpointMappingRequestBodyExample import code.api.util.FutureUtil.EndpointContext import code.api.util.NewStyle.HttpCode import code.api.util._ +import code.api.util.YAMLUtils import code.api.v1_4_0.JSONFactory1_4_0.ResourceDocsJson import code.api.v1_4_0.{APIMethods140, JSONFactory1_4_0, OBPAPI1_4_0} import code.api.v2_2_0.{APIMethods220, OBPAPI2_2_0} @@ -32,7 +33,7 @@ import com.openbankproject.commons.model.{BankId, ListResult, User} import com.openbankproject.commons.util.ApiStandards._ import com.openbankproject.commons.util.{ApiVersion, ScannedApiVersion} import net.liftweb.common.{Box, Empty, Full} -import net.liftweb.http.LiftRules +import net.liftweb.http.{InMemoryResponse, LiftRules, PlainTextResponse} import net.liftweb.json import net.liftweb.json.JsonAST.{JField, JString, JValue} import net.liftweb.json._ @@ -769,6 +770,8 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth | |This endpoint generates OpenAPI 3.1 compliant documentation with modern JSON Schema support. | + |For YAML format, use the corresponding endpoint: /resource-docs/API_VERSION/openapi.yaml + | |See the Resource Doc endpoint for more information. | |Note: Resource Docs are cached, TTL is ${GET_DYNAMIC_RESOURCE_DOCS_TTL} seconds @@ -811,6 +814,11 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth List(apiTagDocumentation, apiTagApi) ) + // Note: OpenAPI 3.1 YAML endpoint (/resource-docs/API_VERSION/openapi.yaml) + // is implemented using Lift's serve mechanism in ResourceDocs140.scala to properly + // handle YAML content type. It provides the same functionality as the JSON endpoint + // but returns OpenAPI documentation in YAML format instead of JSON. + /** * OpenAPI 3.1 endpoint with comprehensive parameter validation. * @@ -913,6 +921,25 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth } } + // Note: The OpenAPI 3.1 YAML endpoint (/resource-docs/API_VERSION/openapi.yaml) + // is implemented using Lift's serve mechanism in ResourceDocs140.scala to properly + // handle YAML content type and response format, rather than as a standard OBPEndpoint. + + + + + def convertResourceDocsToOpenAPI31YAMLAndSetCache(cacheKey: String, requestedApiVersionString: String, resourceDocsJson: List[JSONFactory1_4_0.ResourceDocJson]) : String = { + logger.debug(s"Generating OpenAPI 3.1 YAML-convertResourceDocsToOpenAPI31YAMLAndSetCache requestedApiVersion is $requestedApiVersionString") + val hostname = HostName + val openApiDoc = code.api.ResourceDocs1_4_0.OpenAPI31JSONFactory.createOpenAPI31Json(resourceDocsJson, requestedApiVersionString, hostname) + val openApiJValue = code.api.ResourceDocs1_4_0.OpenAPI31JSONFactory.OpenAPI31JsonFormats.toJValue(openApiDoc) + + val yamlString = YAMLUtils.jValueToYAMLSafe(openApiJValue, "# Error converting to YAML") + Caching.setStaticSwaggerDocCache(cacheKey, yamlString) + + yamlString + } + private def convertResourceDocsToOpenAPI31JvalueAndSetCache(cacheKey: String, requestedApiVersionString: String, resourceDocsJson: List[JSONFactory1_4_0.ResourceDocJson]) : JValue = { logger.debug(s"Generating OpenAPI 3.1-convertResourceDocsToOpenAPI31JvalueAndSetCache requestedApiVersion is $requestedApiVersionString") val hostname = HostName diff --git a/obp-api/src/main/scala/code/api/util/YAMLUtils.scala b/obp-api/src/main/scala/code/api/util/YAMLUtils.scala new file mode 100644 index 000000000..16714ee50 --- /dev/null +++ b/obp-api/src/main/scala/code/api/util/YAMLUtils.scala @@ -0,0 +1,107 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2024, TESOBE GmbH. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +Email: contact@tesobe.com +TESOBE GmbH. +Osloer Strasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) + +*/ +package code.api.util + +import com.fasterxml.jackson.databind.{JsonNode, ObjectMapper} +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory +import net.liftweb.json.JsonAST.JValue +import net.liftweb.json._ +import net.liftweb.json.compactRender +import code.util.Helper.MdcLoggable +import scala.util.{Try, Success, Failure} + +/** + * Utility object for YAML conversion operations + * + * This utility provides methods to convert Lift's JValue objects to YAML format + * using Jackson's YAML support. + */ +object YAMLUtils extends MdcLoggable { + + private val jsonMapper = new ObjectMapper() + private val yamlMapper = new ObjectMapper(new YAMLFactory()) + + /** + * Converts a JValue to YAML string + * + * @param jValue The Lift JValue to convert + * @return Try containing the YAML string or error + */ + def jValueToYAML(jValue: JValue): Try[String] = { + Try { + // First convert JValue to JSON string + val jsonString = compactRender(jValue) + + // Parse JSON string to Jackson JsonNode + val jsonNode: JsonNode = jsonMapper.readTree(jsonString) + + // Convert JsonNode to YAML string + yamlMapper.writeValueAsString(jsonNode) + }.recoverWith { + case ex: Exception => + logger.error(s"Failed to convert JValue to YAML: ${ex.getMessage}", ex) + Failure(new RuntimeException(s"YAML conversion failed: ${ex.getMessage}", ex)) + } + } + + /** + * Converts a JValue to YAML string with error handling that returns a default value + * + * @param jValue The Lift JValue to convert + * @param defaultValue Default value to return if conversion fails + * @return YAML string or default value + */ + def jValueToYAMLSafe(jValue: JValue, defaultValue: String = ""): String = { + jValueToYAML(jValue) match { + case Success(yamlString) => yamlString + case Failure(ex) => + logger.warn(s"YAML conversion failed, returning default value: ${ex.getMessage}") + defaultValue + } + } + + /** + * Checks if the given content type indicates YAML format + * + * @param contentType The content type to check + * @return true if the content type indicates YAML + */ + def isYAMLContentType(contentType: String): Boolean = { + val normalizedContentType = contentType.toLowerCase.trim + normalizedContentType.contains("application/x-yaml") || + normalizedContentType.contains("application/yaml") || + normalizedContentType.contains("text/yaml") || + normalizedContentType.contains("text/x-yaml") + } + + /** + * Gets the appropriate YAML content type + * + * @return Standard YAML content type + */ + def getYAMLContentType: String = "application/x-yaml" +} \ No newline at end of file