mirror of
https://github.com/OpenBankProject/OBP-API.git
synced 2026-02-06 17:37:00 +00:00
commit
96d3c1df0f
@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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")
|
||||
|
||||
410
scripts/OpenAPI31Exporter.scala
Normal file
410
scripts/OpenAPI31Exporter.scala
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user