From 32007bb6f81d1f33d5e6a6ac5e772243e2347323 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Thu, 4 Dec 2025 16:07:53 +0100 Subject: [PATCH 1/2] feature/Generate OpenAPI 3.1 Spec --- .../OpenAPI31JSONFactory.scala | 669 ++++++++++++++++++ .../ResourceDocs1_4_0/ResourceDocs140.scala | 1 + .../ResourceDocsAPIMethods.scala | 115 +++ scripts/OpenAPI31Exporter.scala | 410 +++++++++++ 4 files changed, 1195 insertions(+) create mode 100644 obp-api/src/main/scala/code/api/ResourceDocs1_4_0/OpenAPI31JSONFactory.scala create mode 100644 scripts/OpenAPI31Exporter.scala diff --git a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/OpenAPI31JSONFactory.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/OpenAPI31JSONFactory.scala new file mode 100644 index 0000000000..316086661a --- /dev/null +++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/OpenAPI31JSONFactory.scala @@ -0,0 +1,669 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2024, TESOBE GmbH. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +Email: contact@tesobe.com +TESOBE GmbH. +Osloer Strasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) + + */ +package code.api.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[SecurityRequirementJson]] = 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[SecurityRequirementJson]] = 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 + case class ResponsesJson( + default: Option[ResponseJson] = None, + responses: Map[String, ResponseJson] = Map.empty + ) + + // 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] + ) + + case class SecurityRequirementJson( + requirements: 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) + + // Create Info object + val info = InfoJson( + title = s"Open Bank Project API v$requestedApiVersion", + version = requestedApiVersion, + description = Some(s"""The Open Bank Project API v$requestedApiVersion 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 = { + // 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 + + // Extract path parameters + val pathParams = extractPathParameters(doc.request_url) + + // Create parameters + val parameters = pathParams.map { paramName => + ParameterJson( + name = paramName.toLowerCase.replace("_", ""), + in = "path", + required = Some(true), + schema = Some(SchemaJson(`type` = Some("string"))), + description = Some(s"The $paramName 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 responses = ResponsesJson( + responses = 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( + SecurityRequirementJson(Map("DirectLogin" -> List.empty)), + SecurityRequirementJson(Map("GatewayLogin" -> List.empty)), + SecurityRequirementJson(Map("OAuth2" -> List.empty)) + )) + } 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 = responses, + security = security + ) + + method -> operation + } + + /** + * Extracts path parameters from OBP path format + */ + private def extractPathParameters(path: String): List[String] = { + val paramPattern = """([A-Z_]+)""".r + paramPattern.findAllIn(path).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") + ) + + // Filter to only include relevant error codes based on error bodies + 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" + } + } + + /** + * Determines if an endpoint requires authentication + */ + private def requiresAuthentication(doc: ResourceDocJson): Boolean = { + !doc.error_response_bodies.exists(_.contains("UserNotLoggedIn")) && + doc.roles.nonEmpty + } + + /** + * 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 = { + Extraction.decompose(openapi)(formats) + } + } +} \ No newline at end of file diff --git a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocs140.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocs140.scala index d7d3cc31a0..78b00a8937 100644 --- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocs140.scala +++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocs140.scala @@ -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 ) diff --git a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocsAPIMethods.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocsAPIMethods.scala index ed787fae26..8faa6e83ed 100644 --- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocsAPIMethods.scala +++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocsAPIMethods.scala @@ -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") diff --git a/scripts/OpenAPI31Exporter.scala b/scripts/OpenAPI31Exporter.scala new file mode 100644 index 0000000000..e79a2bab6a --- /dev/null +++ b/scripts/OpenAPI31Exporter.scala @@ -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 + } +} \ No newline at end of file From bb2f7b76b627dd6a10705b6d67d71f7629984971 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Thu, 4 Dec 2025 19:19:50 +0100 Subject: [PATCH 2/2] feature/Tweak OpenAPI 3.1 Spec --- .../OpenAPI31JSONFactory.scala | 136 ++++++++++++------ 1 file changed, 90 insertions(+), 46 deletions(-) diff --git a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/OpenAPI31JSONFactory.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/OpenAPI31JSONFactory.scala index 316086661a..6dcc228bd6 100644 --- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/OpenAPI31JSONFactory.scala +++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/OpenAPI31JSONFactory.scala @@ -59,7 +59,7 @@ object OpenAPI31JSONFactory extends MdcLoggable { servers: List[ServerJson], paths: Map[String, PathItemJson], components: ComponentsJson, - security: Option[List[SecurityRequirementJson]] = None, + security: Option[List[Map[String, List[String]]]] = None, tags: Option[List[TagJson]] = None, externalDocs: Option[ExternalDocumentationJson] = None ) @@ -142,7 +142,7 @@ object OpenAPI31JSONFactory extends MdcLoggable { responses: ResponsesJson, callbacks: Option[Map[String, CallbackJson]] = None, deprecated: Option[Boolean] = None, - security: Option[List[SecurityRequirementJson]] = None, + security: Option[List[Map[String, List[String]]]] = None, servers: Option[List[ServerJson]] = None ) @@ -169,11 +169,8 @@ object OpenAPI31JSONFactory extends MdcLoggable { required: Option[Boolean] = None ) - // Responses Object - case class ResponsesJson( - default: Option[ResponseJson] = None, - responses: Map[String, ResponseJson] = Map.empty - ) + // Responses Object - simplified to avoid nesting + type ResponsesJson = Map[String, ResponseJson] // Response Object case class ResponseJson( @@ -318,9 +315,8 @@ object OpenAPI31JSONFactory extends MdcLoggable { scopes: Map[String, String] ) - case class SecurityRequirementJson( - requirements: Map[String, List[String]] - ) + // Security requirements are just a map of scheme name to scopes + type SecurityRequirementJson = Map[String, List[String]] case class TagJson( name: String, @@ -357,11 +353,14 @@ object OpenAPI31JSONFactory extends MdcLoggable { 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$requestedApiVersion", - version = requestedApiVersion, - description = Some(s"""The Open Bank Project API v$requestedApiVersion provides standardized banking APIs. + 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 @@ -455,15 +454,21 @@ object OpenAPI31JSONFactory extends MdcLoggable { * Converts OBP path format to OpenAPI path format */ private def convertPathToOpenAPI(obpPath: String): String = { - // 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("/") + // 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("/") + } } /** @@ -489,17 +494,18 @@ object OpenAPI31JSONFactory extends MdcLoggable { private def createOperation(doc: ResourceDocJson): (String, OperationJson) = { val method = doc.request_verb.toUpperCase - // Extract path parameters - val pathParams = extractPathParameters(doc.request_url) + // 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.toLowerCase.replace("_", ""), + name = paramName, in = "path", required = Some(true), schema = Some(SchemaJson(`type` = Some("string"))), - description = Some(s"The $paramName identifier") + description = Some(s"The ${paramName.toUpperCase} identifier") ) } @@ -532,9 +538,7 @@ object OpenAPI31JSONFactory extends MdcLoggable { val errorResponses = createErrorResponses(doc.error_response_bodies) - val responses = ResponsesJson( - responses = Map("200" -> successResponse) ++ errorResponses - ) + val responsesMap = Map("200" -> successResponse) ++ errorResponses // Create tags val tags = if (doc.tags.nonEmpty) { @@ -544,9 +548,9 @@ object OpenAPI31JSONFactory extends MdcLoggable { // Check if authentication is required val security = if (requiresAuthentication(doc)) { Some(List( - SecurityRequirementJson(Map("DirectLogin" -> List.empty)), - SecurityRequirementJson(Map("GatewayLogin" -> List.empty)), - SecurityRequirementJson(Map("OAuth2" -> List.empty)) + Map("DirectLogin" -> List.empty[String]), + Map("GatewayLogin" -> List.empty[String]), + Map("OAuth2" -> List.empty[String]) )) } else None @@ -557,19 +561,21 @@ object OpenAPI31JSONFactory extends MdcLoggable { tags = tags, parameters = if (parameters.nonEmpty) Some(parameters) else None, requestBody = requestBody, - responses = responses, + responses = responsesMap, security = security ) method -> operation } + + /** - * Extracts path parameters from OBP path format + * Extracts path parameters from OpenAPI path format */ - private def extractPathParameters(path: String): List[String] = { - val paramPattern = """([A-Z_]+)""".r - paramPattern.findAllIn(path).toList + private def extractOpenAPIPathParameters(path: String): List[String] = { + val paramPattern = """\{([^}]+)\}""".r + paramPattern.findAllMatchIn(path).map(_.group(1)).toList } /** @@ -632,12 +638,17 @@ object OpenAPI31JSONFactory extends MdcLoggable { "500" -> ResponseJson(description = "Internal Server Error") ) - // Filter to only include relevant error codes based on error bodies - 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" + // 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")) } } @@ -645,8 +656,10 @@ object OpenAPI31JSONFactory extends MdcLoggable { * Determines if an endpoint requires authentication */ private def requiresAuthentication(doc: ResourceDocJson): Boolean = { - !doc.error_response_bodies.exists(_.contains("UserNotLoggedIn")) && - doc.roles.nonEmpty + 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") } /** @@ -663,7 +676,38 @@ object OpenAPI31JSONFactory extends MdcLoggable { implicit val formats: Formats = DefaultFormats def toJValue(openapi: OpenAPI31Json): JValue = { - Extraction.decompose(openapi)(formats) + 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 + }) + } } } } \ No newline at end of file