Merge pull request #2641 from constantine2nd/develop

OpenAPI v3.1
This commit is contained in:
Simon Redfern 2025-12-05 10:18:17 +01:00 committed by GitHub
commit 96d3c1df0f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 1239 additions and 0 deletions

View File

@ -0,0 +1,713 @@
/**
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.ResourceDocs1_4_0
import code.api.util.APIUtil.{EmptyBody, JArrayBody, PrimaryDataBody, ResourceDoc}
import code.api.util.ErrorMessages._
import code.api.util._
import code.api.v1_4_0.JSONFactory1_4_0.ResourceDocJson
import com.openbankproject.commons.model.ListResult
import com.openbankproject.commons.util.{ApiVersion, JsonAble, JsonUtils, ReflectUtils}
import net.liftweb.json.JsonAST.{JArray, JObject, JValue}
import net.liftweb.json._
import net.liftweb.json.Extraction
import scala.collection.immutable.ListMap
import scala.reflect.runtime.universe._
import java.lang.{Boolean => XBoolean, Double => XDouble, Float => XFloat, Integer => XInt, Long => XLong, String => XString}
import java.math.{BigDecimal => JBigDecimal}
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import code.util.Helper.MdcLoggable
/**
* OpenAPI 3.1 JSON Factory for OBP API
*
* This factory generates OpenAPI 3.1 compliant JSON documentation
* from OBP ResourceDoc objects.
*/
object OpenAPI31JSONFactory extends MdcLoggable {
// OpenAPI 3.1 Root Object
case class OpenAPI31Json(
openapi: String = "3.1.0",
info: InfoJson,
servers: List[ServerJson],
paths: Map[String, PathItemJson],
components: ComponentsJson,
security: Option[List[Map[String, List[String]]]] = None,
tags: Option[List[TagJson]] = None,
externalDocs: Option[ExternalDocumentationJson] = None
)
// Info Object
case class InfoJson(
title: String,
version: String,
description: Option[String] = None,
termsOfService: Option[String] = None,
contact: Option[ContactJson] = None,
license: Option[LicenseJson] = None,
summary: Option[String] = None
)
case class ContactJson(
name: Option[String] = None,
url: Option[String] = None,
email: Option[String] = None
)
case class LicenseJson(
name: String,
identifier: Option[String] = None,
url: Option[String] = None
)
// Server Object
case class ServerJson(
url: String,
description: Option[String] = None,
variables: Option[Map[String, ServerVariableJson]] = None
)
case class ServerVariableJson(
enum: Option[List[String]] = None,
default: String,
description: Option[String] = None
)
// Components Object
case class ComponentsJson(
schemas: Option[Map[String, SchemaJson]] = None,
responses: Option[Map[String, ResponseJson]] = None,
parameters: Option[Map[String, ParameterJson]] = None,
examples: Option[Map[String, ExampleJson]] = None,
requestBodies: Option[Map[String, RequestBodyJson]] = None,
headers: Option[Map[String, HeaderJson]] = None,
securitySchemes: Option[Map[String, SecuritySchemeJson]] = None,
links: Option[Map[String, LinkJson]] = None,
callbacks: Option[Map[String, CallbackJson]] = None,
pathItems: Option[Map[String, PathItemJson]] = None
)
// Path Item Object
case class PathItemJson(
summary: Option[String] = None,
description: Option[String] = None,
get: Option[OperationJson] = None,
put: Option[OperationJson] = None,
post: Option[OperationJson] = None,
delete: Option[OperationJson] = None,
options: Option[OperationJson] = None,
head: Option[OperationJson] = None,
patch: Option[OperationJson] = None,
trace: Option[OperationJson] = None,
servers: Option[List[ServerJson]] = None,
parameters: Option[List[ParameterJson]] = None
)
// Operation Object
case class OperationJson(
tags: Option[List[String]] = None,
summary: Option[String] = None,
description: Option[String] = None,
externalDocs: Option[ExternalDocumentationJson] = None,
operationId: Option[String] = None,
parameters: Option[List[ParameterJson]] = None,
requestBody: Option[RequestBodyJson] = None,
responses: ResponsesJson,
callbacks: Option[Map[String, CallbackJson]] = None,
deprecated: Option[Boolean] = None,
security: Option[List[Map[String, List[String]]]] = None,
servers: Option[List[ServerJson]] = None
)
// Parameter Object
case class ParameterJson(
name: String,
in: String,
description: Option[String] = None,
required: Option[Boolean] = None,
deprecated: Option[Boolean] = None,
allowEmptyValue: Option[Boolean] = None,
style: Option[String] = None,
explode: Option[Boolean] = None,
allowReserved: Option[Boolean] = None,
schema: Option[SchemaJson] = None,
example: Option[JValue] = None,
examples: Option[Map[String, ExampleJson]] = None
)
// Request Body Object
case class RequestBodyJson(
description: Option[String] = None,
content: Map[String, MediaTypeJson],
required: Option[Boolean] = None
)
// Responses Object - simplified to avoid nesting
type ResponsesJson = Map[String, ResponseJson]
// Response Object
case class ResponseJson(
description: String,
headers: Option[Map[String, HeaderJson]] = None,
content: Option[Map[String, MediaTypeJson]] = None,
links: Option[Map[String, LinkJson]] = None
)
// Media Type Object
case class MediaTypeJson(
schema: Option[SchemaJson] = None,
example: Option[JValue] = None,
examples: Option[Map[String, ExampleJson]] = None,
encoding: Option[Map[String, EncodingJson]] = None
)
// Schema Object (JSON Schema 2020-12)
case class SchemaJson(
// Core vocabulary
`$schema`: Option[String] = None,
`$id`: Option[String] = None,
`$ref`: Option[String] = None,
`$defs`: Option[Map[String, SchemaJson]] = None,
// Type validation
`type`: Option[String] = None,
enum: Option[List[JValue]] = None,
const: Option[JValue] = None,
// Numeric validation
multipleOf: Option[BigDecimal] = None,
maximum: Option[BigDecimal] = None,
exclusiveMaximum: Option[BigDecimal] = None,
minimum: Option[BigDecimal] = None,
exclusiveMinimum: Option[BigDecimal] = None,
// String validation
maxLength: Option[Int] = None,
minLength: Option[Int] = None,
pattern: Option[String] = None,
// Array validation
maxItems: Option[Int] = None,
minItems: Option[Int] = None,
uniqueItems: Option[Boolean] = None,
maxContains: Option[Int] = None,
minContains: Option[Int] = None,
// Object validation
maxProperties: Option[Int] = None,
minProperties: Option[Int] = None,
required: Option[List[String]] = None,
dependentRequired: Option[Map[String, List[String]]] = None,
// Schema composition
allOf: Option[List[SchemaJson]] = None,
anyOf: Option[List[SchemaJson]] = None,
oneOf: Option[List[SchemaJson]] = None,
not: Option[SchemaJson] = None,
// Conditional schemas
`if`: Option[SchemaJson] = None,
`then`: Option[SchemaJson] = None,
`else`: Option[SchemaJson] = None,
// Array schemas
prefixItems: Option[List[SchemaJson]] = None,
items: Option[SchemaJson] = None,
contains: Option[SchemaJson] = None,
// Object schemas
properties: Option[Map[String, SchemaJson]] = None,
patternProperties: Option[Map[String, SchemaJson]] = None,
additionalProperties: Option[Either[Boolean, SchemaJson]] = None,
propertyNames: Option[SchemaJson] = None,
// Format
format: Option[String] = None,
// Metadata
title: Option[String] = None,
description: Option[String] = None,
default: Option[JValue] = None,
deprecated: Option[Boolean] = None,
readOnly: Option[Boolean] = None,
writeOnly: Option[Boolean] = None,
examples: Option[List[JValue]] = None
)
// Supporting objects
case class ExampleJson(
summary: Option[String] = None,
description: Option[String] = None,
value: Option[JValue] = None,
externalValue: Option[String] = None
)
case class EncodingJson(
contentType: Option[String] = None,
headers: Option[Map[String, HeaderJson]] = None,
style: Option[String] = None,
explode: Option[Boolean] = None,
allowReserved: Option[Boolean] = None
)
case class HeaderJson(
description: Option[String] = None,
required: Option[Boolean] = None,
deprecated: Option[Boolean] = None,
allowEmptyValue: Option[Boolean] = None,
style: Option[String] = None,
explode: Option[Boolean] = None,
allowReserved: Option[Boolean] = None,
schema: Option[SchemaJson] = None,
example: Option[JValue] = None,
examples: Option[Map[String, ExampleJson]] = None
)
case class SecuritySchemeJson(
`type`: String,
description: Option[String] = None,
name: Option[String] = None,
in: Option[String] = None,
scheme: Option[String] = None,
bearerFormat: Option[String] = None,
flows: Option[OAuthFlowsJson] = None,
openIdConnectUrl: Option[String] = None
)
case class OAuthFlowsJson(
`implicit`: Option[OAuthFlowJson] = None,
password: Option[OAuthFlowJson] = None,
clientCredentials: Option[OAuthFlowJson] = None,
authorizationCode: Option[OAuthFlowJson] = None
)
case class OAuthFlowJson(
authorizationUrl: Option[String] = None,
tokenUrl: Option[String] = None,
refreshUrl: Option[String] = None,
scopes: Map[String, String]
)
// Security requirements are just a map of scheme name to scopes
type SecurityRequirementJson = Map[String, List[String]]
case class TagJson(
name: String,
description: Option[String] = None,
externalDocs: Option[ExternalDocumentationJson] = None
)
case class ExternalDocumentationJson(
description: Option[String] = None,
url: String
)
case class LinkJson(
operationRef: Option[String] = None,
operationId: Option[String] = None,
parameters: Option[Map[String, JValue]] = None,
requestBody: Option[JValue] = None,
description: Option[String] = None,
server: Option[ServerJson] = None
)
case class CallbackJson(
expressions: Map[String, PathItemJson]
)
/**
* Creates an OpenAPI 3.1 document from a list of ResourceDoc objects
*/
def createOpenAPI31Json(
resourceDocs: List[ResourceDocJson],
requestedApiVersion: String,
hostname: String = "api.openbankproject.com"
): OpenAPI31Json = {
val timestamp = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
// Clean version string to avoid double 'v' prefix
val cleanVersion = if (requestedApiVersion.startsWith("v")) requestedApiVersion.substring(1) else requestedApiVersion
// Create Info object
val info = InfoJson(
title = s"Open Bank Project API v$cleanVersion",
version = cleanVersion,
description = Some(s"""The Open Bank Project API v$cleanVersion provides standardized banking APIs.
|
|This specification was automatically generated from the OBP API codebase.
|Generated on: $timestamp
|
|For more information, visit: https://github.com/OpenBankProject/OBP-API""".stripMargin),
contact = Some(ContactJson(
name = Some("Open Bank Project"),
url = Some("https://www.openbankproject.com"),
email = Some("contact@tesobe.com")
)),
license = Some(LicenseJson(
name = "AGPL v3",
url = Some("https://www.gnu.org/licenses/agpl-3.0.html")
))
)
// Create Servers
val servers = List(
ServerJson(
url = s"https://$hostname",
description = Some("Production server")
),
ServerJson(
url = "https://apisandbox.openbankproject.com",
description = Some("Sandbox server")
)
)
// Group resource docs by path and convert to operations
val pathGroups = resourceDocs.groupBy(_.request_url)
val paths = pathGroups.map { case (path, docs) =>
val openApiPath = convertPathToOpenAPI(path)
val pathItem = createPathItem(docs)
openApiPath -> pathItem
}
// Extract schemas from all request/response bodies
val schemas = extractSchemas(resourceDocs)
// Create security schemes
val securitySchemes = Map(
"DirectLogin" -> SecuritySchemeJson(
`type` = "apiKey",
description = Some("Direct Login token authentication"),
name = Some("Authorization"),
in = Some("header")
),
"GatewayLogin" -> SecuritySchemeJson(
`type` = "apiKey",
description = Some("Gateway Login token authentication"),
name = Some("Authorization"),
in = Some("header")
),
"OAuth2" -> SecuritySchemeJson(
`type` = "oauth2",
description = Some("OAuth2 authentication"),
flows = Some(OAuthFlowsJson(
authorizationCode = Some(OAuthFlowJson(
authorizationUrl = Some("/oauth/authorize"),
tokenUrl = Some("/oauth/token"),
scopes = Map.empty
))
))
)
)
// Create components
val components = ComponentsJson(
schemas = if (schemas.nonEmpty) Some(schemas) else None,
securitySchemes = Some(securitySchemes)
)
// Extract unique tags
val allTags = resourceDocs.flatMap(_.tags).distinct.map { tag =>
TagJson(
name = cleanTagName(tag),
description = Some(s"Operations related to ${cleanTagName(tag)}")
)
}
OpenAPI31Json(
info = info,
servers = servers,
paths = paths,
components = components,
tags = if (allTags.nonEmpty) Some(allTags) else None
)
}
/**
* Converts OBP path format to OpenAPI path format
*/
private def convertPathToOpenAPI(obpPath: String): String = {
// Handle paths that are already in OpenAPI format or convert from OBP format
if (obpPath.contains("{") && obpPath.contains("}")) {
// Already in OpenAPI format, return as-is
obpPath
} else {
// Convert OBP path parameters (BANK_ID) to OpenAPI format ({bankid})
val segments = obpPath.split("/")
segments.map { segment =>
if (segment.matches("[A-Z_]+")) {
s"{${segment.toLowerCase.replace("_", "")}}"
} else {
segment
}
}.mkString("/")
}
}
/**
* Creates a PathItem object from a list of ResourceDoc objects for the same path
*/
private def createPathItem(docs: List[ResourceDocJson]): PathItemJson = {
val operations = docs.map(createOperation).toMap
PathItemJson(
get = operations.get("GET"),
post = operations.get("POST"),
put = operations.get("PUT"),
delete = operations.get("DELETE"),
patch = operations.get("PATCH"),
options = operations.get("OPTIONS"),
head = operations.get("HEAD")
)
}
/**
* Creates an Operation object from a ResourceDoc
*/
private def createOperation(doc: ResourceDocJson): (String, OperationJson) = {
val method = doc.request_verb.toUpperCase
// Convert path to OpenAPI format and extract parameters
val openApiPath = convertPathToOpenAPI(doc.request_url)
val pathParams = extractOpenAPIPathParameters(openApiPath)
// Create parameters
val parameters = pathParams.map { paramName =>
ParameterJson(
name = paramName,
in = "path",
required = Some(true),
schema = Some(SchemaJson(`type` = Some("string"))),
description = Some(s"The ${paramName.toUpperCase} identifier")
)
}
// Create request body if needed
val requestBody = if (List("POST", "PUT", "PATCH").contains(method) && doc.typed_request_body != JNothing) {
Some(RequestBodyJson(
description = Some("Request body"),
content = Map(
"application/json" -> MediaTypeJson(
schema = Some(inferSchemaFromExample(doc.typed_request_body)),
example = Some(doc.typed_request_body)
)
),
required = Some(true)
))
} else None
// Create responses
val successResponse = ResponseJson(
description = "Successful operation",
content = if (doc.typed_success_response_body != JNothing) {
Some(Map(
"application/json" -> MediaTypeJson(
schema = Some(inferSchemaFromExample(doc.typed_success_response_body)),
example = Some(doc.typed_success_response_body)
)
))
} else None
)
val errorResponses = createErrorResponses(doc.error_response_bodies)
val responsesMap = Map("200" -> successResponse) ++ errorResponses
// Create tags
val tags = if (doc.tags.nonEmpty) {
Some(doc.tags.map(cleanTagName))
} else None
// Check if authentication is required
val security = if (requiresAuthentication(doc)) {
Some(List(
Map("DirectLogin" -> List.empty[String]),
Map("GatewayLogin" -> List.empty[String]),
Map("OAuth2" -> List.empty[String])
))
} else None
val operation = OperationJson(
summary = Some(doc.summary),
description = Some(doc.description),
operationId = Some(doc.operation_id),
tags = tags,
parameters = if (parameters.nonEmpty) Some(parameters) else None,
requestBody = requestBody,
responses = responsesMap,
security = security
)
method -> operation
}
/**
* Extracts path parameters from OpenAPI path format
*/
private def extractOpenAPIPathParameters(path: String): List[String] = {
val paramPattern = """\{([^}]+)\}""".r
paramPattern.findAllMatchIn(path).map(_.group(1)).toList
}
/**
* Infers a JSON Schema from an example JSON value
*/
private def inferSchemaFromExample(example: JValue): SchemaJson = {
example match {
case JObject(fields) =>
val properties = fields.map { case JField(name, value) =>
name -> inferSchemaFromExample(value)
}.toMap
val required = fields.collect {
case JField(name, value) if value != JNothing && value != JNull => name
}
SchemaJson(
`type` = Some("object"),
properties = Some(properties),
required = if (required.nonEmpty) Some(required) else None
)
case JArray(values) =>
val itemSchema = values.headOption.map(inferSchemaFromExample)
.getOrElse(SchemaJson(`type` = Some("object")))
SchemaJson(
`type` = Some("array"),
items = Some(itemSchema)
)
case JString(_) => SchemaJson(`type` = Some("string"))
case JInt(_) => SchemaJson(`type` = Some("integer"))
case JDouble(_) => SchemaJson(`type` = Some("number"))
case JBool(_) => SchemaJson(`type` = Some("boolean"))
case JNull => SchemaJson(`type` = Some("null"))
case JNothing => SchemaJson(`type` = Some("object"))
case _ => SchemaJson(`type` = Some("object"))
}
}
/**
* Extracts reusable schemas from all resource docs
*/
private def extractSchemas(resourceDocs: List[ResourceDocJson]): Map[String, SchemaJson] = {
// This could be enhanced to extract common schemas and create references
// For now, we'll return an empty map and inline schemas
Map.empty[String, SchemaJson]
}
/**
* Creates error response objects
*/
private def createErrorResponses(errorBodies: List[String]): Map[String, ResponseJson] = {
val commonErrors = Map(
"400" -> ResponseJson(description = "Bad Request"),
"401" -> ResponseJson(description = "Unauthorized"),
"403" -> ResponseJson(description = "Forbidden"),
"404" -> ResponseJson(description = "Not Found"),
"500" -> ResponseJson(description = "Internal Server Error")
)
// Always include common error responses for better API documentation
if (errorBodies.nonEmpty) {
commonErrors.filter { case (code, _) =>
errorBodies.exists(_.contains(code)) ||
errorBodies.exists(_.toLowerCase.contains("unauthorized")) && code == "401" ||
errorBodies.exists(_.toLowerCase.contains("not found")) && code == "404" ||
errorBodies.exists(_.toLowerCase.contains("bad request")) && code == "400" ||
code == "500" // Always include 500 for server errors
}
} else {
Map("500" -> ResponseJson(description = "Internal Server Error"))
}
}
/**
* Determines if an endpoint requires authentication
*/
private def requiresAuthentication(doc: ResourceDocJson): Boolean = {
doc.error_response_bodies.exists(_.contains("UserNotLoggedIn")) ||
doc.roles.nonEmpty ||
doc.description.toLowerCase.contains("authentication is required") ||
doc.description.toLowerCase.contains("user must be logged in")
}
/**
* Cleans tag names for better presentation
*/
private def cleanTagName(tag: String): String = {
tag.replaceFirst("^apiTag", "").replaceFirst("^tag", "")
}
/**
* Converts OpenAPI31Json to JValue for JSON output
*/
object OpenAPI31JsonFormats {
implicit val formats: Formats = DefaultFormats
def toJValue(openapi: OpenAPI31Json): JValue = {
val baseJson = Extraction.decompose(openapi)(formats)
// Transform to fix nested structures
transformJson(baseJson)
}
private def transformJson(json: JValue): JValue = {
json.transform {
// Fix responses structure - flatten nested responses
case JObject(fields) if fields.exists(_.name == "responses") =>
JObject(fields.map {
case JField("responses", JObject(responseFields)) =>
// If responses contains another responses field, flatten it
responseFields.find(_.name == "responses") match {
case Some(JField(_, JObject(innerResponses))) =>
JField("responses", JObject(innerResponses))
case _ =>
JField("responses", JObject(responseFields))
}
case other => other
})
// Fix security structure - remove requirements wrapper
case JObject(fields) if fields.exists(_.name == "security") =>
JObject(fields.map {
case JField("security", JArray(securityItems)) =>
val fixedSecurity = securityItems.map {
case JObject(List(JField("requirements", securityObj))) => securityObj
case other => other
}
JField("security", JArray(fixedSecurity))
case other => other
})
}
}
}
}

View File

@ -143,6 +143,7 @@ object ResourceDocs300 extends OBPRestHelper with ResourceDocsAPIMethods with Md
val routes = List(
ImplementationsResourceDocs.getResourceDocsObpV400,
ImplementationsResourceDocs.getResourceDocsSwagger,
ImplementationsResourceDocs.getResourceDocsOpenAPI31,
ImplementationsResourceDocs.getBankLevelDynamicResourceDocsObp,
// ImplementationsResourceDocs.getStaticResourceDocsObp
)

View File

@ -54,6 +54,9 @@ import code.util.Helper.booleanToBox
import com.openbankproject.commons.ExecutionContext.Implicits.global
trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMethods210 with APIMethods200 with APIMethods140 with APIMethods130 with APIMethods121{
//needs to be a RestHelper to get access to JsonGet, JsonPost, etc.
// We add previous APIMethods so we have access to the Resource Docs
@ -721,6 +724,118 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth
}
}
localResourceDocs += ResourceDoc(
getResourceDocsOpenAPI31,
implementedInApiVersion,
"getResourceDocsOpenAPI31",
"GET",
"/resource-docs/API_VERSION/openapi",
"Get OpenAPI 3.1 documentation",
s"""Returns documentation about the RESTful resources on this server in OpenAPI 3.1 format.
|
|API_VERSION is the version you want documentation about e.g. v6.0.0
|
|You may filter this endpoint using the 'tags' url parameter e.g. ?tags=Account,Bank
|
|(All endpoints are given one or more tags which for used in grouping)
|
|You may filter this endpoint using the 'functions' url parameter e.g. ?functions=getBanks,bankById
|
|(Each endpoint is implemented in the OBP Scala code by a 'function')
|
|This endpoint generates OpenAPI 3.1 compliant documentation with modern JSON Schema support.
|
|See the Resource Doc endpoint for more information.
|
| Note: Resource Docs are cached, TTL is ${GET_DYNAMIC_RESOURCE_DOCS_TTL} seconds
|
|Following are more examples:
|${getObpApiRoot}/v6.0.0/resource-docs/v6.0.0/openapi
|${getObpApiRoot}/v6.0.0/resource-docs/v6.0.0/openapi?tags=Account,Bank
|${getObpApiRoot}/v6.0.0/resource-docs/v6.0.0/openapi?functions=getBanks,bankById
|${getObpApiRoot}/v6.0.0/resource-docs/v6.0.0/openapi?tags=Account,Bank,PSD2&functions=getBanks,bankById
|
""",
EmptyBody,
EmptyBody,
UnknownError :: Nil,
List(apiTagDocumentation, apiTagApi)
)
def getResourceDocsOpenAPI31 : OBPEndpoint = {
case "resource-docs" :: requestedApiVersionString :: "openapi" :: Nil JsonGet _ => {
cc => {
implicit val ec = EndpointContext(Some(cc))
val (resourceDocTags, partialFunctions, locale, contentParam, apiCollectionIdParam) = ResourceDocsAPIMethodsUtil.getParams()
for {
requestedApiVersion <- NewStyle.function.tryons(s"$InvalidApiVersionString Current Version is $requestedApiVersionString", 400, cc.callContext) {
ApiVersionUtils.valueOf(requestedApiVersionString)
}
_ <- Helper.booleanToFuture(failMsg = s"$ApiVersionNotSupported Current Version is $requestedApiVersionString", cc=cc.callContext) {
versionIsAllowed(requestedApiVersion)
}
_ <- if (locale.isDefined) {
Helper.booleanToFuture(failMsg = s"$InvalidLocale Current Locale is ${locale.get}" intern(), cc = cc.callContext) {
APIUtil.obpLocaleValidation(locale.get) == SILENCE_IS_GOLDEN
}
} else {
Future.successful(true)
}
isVersion4OrHigher = true
cacheKey = APIUtil.createResourceDocCacheKey(
Some("openapi31"),
requestedApiVersionString,
resourceDocTags,
partialFunctions,
locale,
contentParam,
apiCollectionIdParam,
Some(isVersion4OrHigher)
)
cacheValueFromRedis = Caching.getStaticSwaggerDocCache(cacheKey)
openApiJValue <- if (cacheValueFromRedis.isDefined) {
NewStyle.function.tryons(s"$UnknownError Can not convert internal openapi file from cache.", 400, cc.callContext) {json.parse(cacheValueFromRedis.get)}
} else {
NewStyle.function.tryons(s"$UnknownError Can not convert internal openapi file.", 400, cc.callContext) {
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 _ =>
contentParam match {
case Some(DYNAMIC) =>
getResourceDocsObpDynamicCached(resourceDocTags, partialFunctions, locale, None, isVersion4OrHigher).head.resource_docs
case Some(STATIC) => {
getStaticResourceDocsObpCached(requestedApiVersionString, resourceDocTags, partialFunctions, locale, isVersion4OrHigher).head.resource_docs
}
case _ => {
getAllResourceDocsObpCached(requestedApiVersionString, resourceDocTags, partialFunctions, locale, contentParam, isVersion4OrHigher).head.resource_docs
}
}
}
convertResourceDocsToOpenAPI31JvalueAndSetCache(cacheKey, requestedApiVersionString, resourceDocsJsonFiltered)
}
}
} yield {
(openApiJValue, HttpCode.`200`(cc.callContext))
}
}
}
}
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 openApiDoc = code.api.ResourceDocs1_4_0.OpenAPI31JSONFactory.createOpenAPI31Json(resourceDocsJson, requestedApiVersionString)
val openApiJValue = code.api.ResourceDocs1_4_0.OpenAPI31JSONFactory.OpenAPI31JsonFormats.toJValue(openApiDoc)
val jsonString = json.compactRender(openApiJValue)
Caching.setStaticSwaggerDocCache(cacheKey, jsonString)
openApiJValue
}
private def convertResourceDocsToSwaggerJvalueAndSetCache(cacheKey: String, requestedApiVersionString: String, resourceDocsJson: List[JSONFactory1_4_0.ResourceDocJson]) : JValue = {
logger.debug(s"Generating Swagger-getResourceDocsSwaggerAndSetCache requestedApiVersion is $requestedApiVersionString")

View File

@ -0,0 +1,410 @@
#!/usr/bin/env scala
/**
* OpenAPI 3.1 Exporter for OBP API v6.0.0
*
* This script extracts API documentation from the OBP API v6.0.0 codebase
* and converts it to OpenAPI 3.1 format.
*
* Usage:
* scala OpenAPI31Exporter.scala [output_file]
*
* If no output file is specified, it writes to stdout.
*/
import scala.io.Source
import scala.util.matching.Regex
import java.io.{File, PrintWriter}
import scala.collection.mutable.ListBuffer
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
case class ApiEndpoint(
name: String,
method: String,
path: String,
summary: String,
description: String,
requestBody: Option[String],
responseBody: Option[String],
errorCodes: List[String],
tags: List[String],
roles: List[String] = List.empty
)
case class JsonSchema(
name: String,
properties: Map[String, Any],
required: List[String] = List.empty,
description: Option[String] = None
)
object OpenAPI31Exporter {
def main(args: Array[String]): Unit = {
val outputFile = if (args.length > 0) Some(args(0)) else None
val projectRoot = findProjectRoot()
println(s"Extracting API documentation from: $projectRoot")
val endpoints = extractEndpoints(projectRoot)
val schemas = extractSchemas(projectRoot)
val openApiYaml = generateOpenAPI31(endpoints, schemas)
outputFile match {
case Some(file) =>
val writer = new PrintWriter(new File(file))
try {
writer.write(openApiYaml)
println(s"OpenAPI 3.1 documentation written to: $file")
} finally {
writer.close()
}
case None =>
println(openApiYaml)
}
}
def findProjectRoot(): String = {
val currentDir = new File(".")
val candidates = List(
"./obp-api/src/main/scala/code/api/v6_0_0",
"../obp-api/src/main/scala/code/api/v6_0_0",
"./OBP-API/obp-api/src/main/scala/code/api/v6_0_0"
)
candidates.find(path => new File(path).exists()) match {
case Some(path) => new File(path).getParentFile.getParentFile.getParentFile.getParentFile.getParentFile.getAbsolutePath
case None =>
throw new RuntimeException("Could not find OBP API project root. Please run from the project directory.")
}
}
def extractEndpoints(projectRoot: String): List[ApiEndpoint] = {
val apiMethodsFile = new File(s"$projectRoot/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala")
val endpoints = ListBuffer[ApiEndpoint]()
if (!apiMethodsFile.exists()) {
throw new RuntimeException(s"APIMethods600.scala not found at: ${apiMethodsFile.getAbsolutePath}")
}
val content = Source.fromFile(apiMethodsFile).getLines().mkString("\n")
// Extract ResourceDoc definitions
val resourceDocPattern = """staticResourceDocs \+= ResourceDoc\(\s*([^,]+),\s*[^,]+,\s*[^,]+,\s*"([^"]+)",\s*"([^"]+)",\s*"([^"]+)",\s*s?"""([^"]*(?:"[^"]*"[^"]*)*?)""",\s*([^,]+),\s*([^,]+),\s*List\(([^)]*)\),\s*List\(([^)]*)\)(?:,\s*Some\(List\(([^)]*)\)))?\s*\)""".r
resourceDocPattern.findAllMatchIn(content).foreach { m =>
val endpointName = m.group(1).trim
val method = m.group(2).trim
val path = m.group(3).trim
val summary = m.group(4).trim
val description = cleanDescription(m.group(5))
val requestBodyRef = m.group(6).trim
val responseBodyRef = m.group(7).trim
val errorCodes = parseList(m.group(8))
val tags = parseList(m.group(9))
val roles = if (m.group(10) != null) parseList(m.group(10)) else List.empty
endpoints += ApiEndpoint(
name = endpointName,
method = method,
path = path,
summary = summary,
description = description,
requestBody = if (requestBodyRef != "EmptyBody") Some(requestBodyRef) else None,
responseBody = Some(responseBodyRef),
errorCodes = errorCodes,
tags = tags,
roles = roles
)
}
endpoints.toList
}
def extractSchemas(projectRoot: String): List[JsonSchema] = {
val jsonFactoryFile = new File(s"$projectRoot/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala")
val schemas = ListBuffer[JsonSchema]()
if (!jsonFactoryFile.exists()) {
println(s"Warning: JSONFactory6.0.0.scala not found at: ${jsonFactoryFile.getAbsolutePath}")
return schemas.toList
}
val content = Source.fromFile(jsonFactoryFile).getLines().mkString("\n")
// Extract case class definitions
val caseClassPattern = """case class ([A-Za-z0-9_]+)\(\s*(.*?)\s*\)""".r
caseClassPattern.findAllMatchIn(content).foreach { m =>
val className = m.group(1)
val fieldsStr = m.group(2)
val properties = parseFields(fieldsStr)
val required = properties.filter(_._2.asInstanceOf[Map[String, Any]].get("nullable").isEmpty).keys.toList
schemas += JsonSchema(
name = className,
properties = properties,
required = required,
description = Some(s"Schema for $className")
)
}
schemas.toList
}
def parseFields(fieldsStr: String): Map[String, Any] = {
val properties = scala.collection.mutable.Map[String, Any]()
if (fieldsStr.trim.nonEmpty) {
val fieldPattern = """([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*([^,]+)""".r
fieldPattern.findAllMatchIn(fieldsStr).foreach { m =>
val fieldName = m.group(1).trim
val fieldType = m.group(2).trim
val (schemaType, nullable) = mapScalaTypeToJsonSchema(fieldType)
val fieldSchema = scala.collection.mutable.Map[String, Any](
"type" -> schemaType
)
if (nullable) {
fieldSchema += "nullable" -> true
}
if (fieldType.contains("Date")) {
fieldSchema += "format" -> "date-time"
}
properties += fieldName -> fieldSchema.toMap
}
}
properties.toMap
}
def mapScalaTypeToJsonSchema(scalaType: String): (String, Boolean) = {
val cleanType = scalaType.replaceAll("Option\\[(.*)\\]", "$1").trim
val nullable = scalaType.contains("Option[")
val jsonType = cleanType match {
case t if t.startsWith("String") => "string"
case t if t.startsWith("Int") || t.startsWith("Long") => "integer"
case t if t.startsWith("Double") || t.startsWith("Float") || t.startsWith("BigDecimal") => "number"
case t if t.startsWith("Boolean") => "boolean"
case t if t.startsWith("List[") || t.startsWith("Array[") => "array"
case t if t.contains("Date") => "string"
case _ => "object"
}
(jsonType, nullable)
}
def cleanDescription(desc: String): String = {
desc.replaceAll("\\|", "")
.replaceAll("\\$\\{[^}]+\\}", "")
.replaceAll("\\s+", " ")
.trim
}
def parseList(listStr: String): List[String] = {
if (listStr.trim.isEmpty) List.empty
else listStr.split(",").map(_.trim.replaceAll("[\"$]", "")).filter(_.nonEmpty).toList
}
def generateOpenAPI31(endpoints: List[ApiEndpoint], schemas: List[JsonSchema]): String = {
val timestamp = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
val yaml = new StringBuilder()
// OpenAPI header
yaml.append("openapi: 3.1.0\n")
yaml.append("\n")
yaml.append("info:\n")
yaml.append(" title: Open Bank Project API v6.0.0\n")
yaml.append(" version: 6.0.0\n")
yaml.append(" description: |\n")
yaml.append(" The Open Bank Project API v6.0.0 provides standardized banking APIs.\n")
yaml.append(" \n")
yaml.append(" This specification was automatically generated from the OBP API codebase.\n")
yaml.append(s" Generated on: $timestamp\n")
yaml.append(" \n")
yaml.append(" For more information, visit: https://github.com/OpenBankProject/OBP-API\n")
yaml.append(" contact:\n")
yaml.append(" name: Open Bank Project\n")
yaml.append(" url: https://www.openbankproject.com\n")
yaml.append(" email: contact@tesobe.com\n")
yaml.append(" license:\n")
yaml.append(" name: AGPL v3\n")
yaml.append(" url: https://www.gnu.org/licenses/agpl-3.0.html\n")
yaml.append("\n")
// Servers
yaml.append("servers:\n")
yaml.append(" - url: https://api.openbankproject.com\n")
yaml.append(" description: Production server\n")
yaml.append(" - url: https://apisandbox.openbankproject.com\n")
yaml.append(" description: Sandbox server\n")
yaml.append("\n")
// Security schemes
yaml.append("components:\n")
yaml.append(" securitySchemes:\n")
yaml.append(" DirectLogin:\n")
yaml.append(" type: apiKey\n")
yaml.append(" in: header\n")
yaml.append(" name: Authorization\n")
yaml.append(" description: Direct Login token authentication\n")
yaml.append(" GatewayLogin:\n")
yaml.append(" type: apiKey\n")
yaml.append(" in: header\n")
yaml.append(" name: Authorization\n")
yaml.append(" description: Gateway Login token authentication\n")
yaml.append(" OAuth2:\n")
yaml.append(" type: oauth2\n")
yaml.append(" flows:\n")
yaml.append(" authorizationCode:\n")
yaml.append(" authorizationUrl: /oauth/authorize\n")
yaml.append(" tokenUrl: /oauth/token\n")
yaml.append(" scopes: {}\n")
yaml.append("\n")
// Schemas
if (schemas.nonEmpty) {
yaml.append(" schemas:\n")
schemas.foreach { schema =>
yaml.append(s" ${schema.name}:\n")
yaml.append(" type: object\n")
if (schema.description.isDefined) {
yaml.append(s" description: ${schema.description.get}\n")
}
if (schema.required.nonEmpty) {
yaml.append(" required:\n")
schema.required.foreach { field =>
yaml.append(s" - $field\n")
}
}
if (schema.properties.nonEmpty) {
yaml.append(" properties:\n")
schema.properties.foreach { case (name, propSchema) =>
val props = propSchema.asInstanceOf[Map[String, Any]]
yaml.append(s" $name:\n")
yaml.append(s" type: ${props("type")}\n")
props.get("format").foreach { format =>
yaml.append(s" format: $format\n")
}
props.get("nullable").foreach { _ =>
yaml.append(" nullable: true\n")
}
}
}
yaml.append("\n")
}
}
// Paths
yaml.append("paths:\n")
val groupedEndpoints = endpoints.groupBy(_.path)
groupedEndpoints.toSeq.sortBy(_._1).foreach { case (path, pathEndpoints) =>
val openApiPath = convertPathToOpenAPI(path)
yaml.append(s" $openApiPath:\n")
pathEndpoints.sortBy(_.method).foreach { endpoint =>
val method = endpoint.method.toLowerCase
yaml.append(s" $method:\n")
yaml.append(s" summary: ${endpoint.summary}\n")
yaml.append(s" operationId: ${endpoint.name}\n")
if (endpoint.description.nonEmpty) {
yaml.append(" description: |\n")
endpoint.description.split("\n").foreach { line =>
yaml.append(s" $line\n")
}
}
if (endpoint.tags.nonEmpty) {
yaml.append(" tags:\n")
endpoint.tags.foreach { tag =>
yaml.append(s" - ${tag.replaceAll("apiTag", "")}\n")
}
}
// Parameters (path parameters)
val pathParams = extractPathParameters(path)
if (pathParams.nonEmpty) {
yaml.append(" parameters:\n")
pathParams.foreach { param =>
yaml.append(s" - name: $param\n")
yaml.append(" in: path\n")
yaml.append(" required: true\n")
yaml.append(" schema:\n")
yaml.append(" type: string\n")
}
}
// Request body
if (endpoint.requestBody.isDefined && method != "get" && method != "delete") {
yaml.append(" requestBody:\n")
yaml.append(" required: true\n")
yaml.append(" content:\n")
yaml.append(" application/json:\n")
yaml.append(" schema:\n")
yaml.append(s" $$ref: '#/components/schemas/${endpoint.requestBody.get}'\n")
}
// Responses
yaml.append(" responses:\n")
yaml.append(" '200':\n")
yaml.append(" description: Success\n")
if (endpoint.responseBody.isDefined) {
yaml.append(" content:\n")
yaml.append(" application/json:\n")
yaml.append(" schema:\n")
yaml.append(s" $$ref: '#/components/schemas/${endpoint.responseBody.get}'\n")
}
// Error responses
if (endpoint.errorCodes.nonEmpty) {
endpoint.errorCodes.filter(_.contains("400")).foreach { _ =>
yaml.append(" '400':\n")
yaml.append(" description: Bad Request\n")
}
endpoint.errorCodes.filter(_.contains("401")).foreach { _ =>
yaml.append(" '401':\n")
yaml.append(" description: Unauthorized\n")
}
endpoint.errorCodes.filter(_.contains("404")).foreach { _ =>
yaml.append(" '404':\n")
yaml.append(" description: Not Found\n")
}
}
// Security
if (endpoint.roles.nonEmpty || !endpoint.errorCodes.exists(_.contains("UserNotLoggedIn"))) {
yaml.append(" security:\n")
yaml.append(" - DirectLogin: []\n")
yaml.append(" - GatewayLogin: []\n")
yaml.append(" - OAuth2: []\n")
}
yaml.append("\n")
}
}
yaml.toString()
}
def convertPathToOpenAPI(obpPath: String): String = {
obpPath.replaceAll("([A-Z_]+)", "{$1}")
.replaceAll("\\{([A-Z_]+)\\}", "{${1.toLowerCase}}")
.replaceAll("_", "-")
}
def extractPathParameters(path: String): List[String] = {
val paramPattern = """([A-Z_]+)""".r
paramPattern.findAllMatchIn(path).map(_.group(1).toLowerCase.replace("_", "-")).toList
}
}