feature/Add OpenAPI 3.1 YAML response

This commit is contained in:
Marko Milić 2025-12-17 14:51:00 +01:00
parent 8ea76746d2
commit 6a7a76b44f
4 changed files with 229 additions and 2 deletions

View File

@ -509,6 +509,12 @@
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.12.7.1</version>
</dependency>
<!-- https://mvnrepository.com/artifact/tools.jackson.dataformat/jackson-dataformat-yaml -->
<dependency>
<groupId>tools.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-yaml</artifactId>
<version>3.0.3</version>
</dependency>
<!-- https://mvnrepository.com/artifact/com.squareup.okhttp3/okhttp -->
<dependency>

View File

@ -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)
}
}
}
}
}

View File

@ -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

View File

@ -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 <http://www.gnu.org/licenses/>.
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"
}