From ca461f60547454f1220bae792a132a43912cdb78 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 26 May 2026 17:28:32 +0200 Subject: [PATCH 1/4] fix(resource-docs): restore minimal ResourceDocs140/300 stubs for Http4sResourceDocs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All Lift dispatch has been retired; Http4sResourceDocs now handles every /resource-docs/* URL. However Http4sResourceDocs still delegates business logic to ImplementationsResourceDocs (defined in ResourceDocsAPIMethods trait), which requires a concrete OBPRestHelper instance to instantiate it. Restore two minimal stub objects: - ResourceDocs140: provides the default ImplementationsResourceDocs (includeTechnologyInResponse = false) - ResourceDocs300 / ResourceDocs300.ResourceDocs600: provides the v6.0.0 variant (includeTechnologyInResponse = true, so the technology field appears in v6.0.0 resource-doc responses) Both stubs have empty routes and are not registered in LiftRules.statelessDispatch. All other per-version objects (ResourceDocs200 through ResourceDocs510) remain commented out — Http4sResourceDocs uses a single ImplDefault instance for every non-v6 prefix. Also restores the corresponding import in Http4sResourceDocs.scala and comments out the now-stale Boot.scala wildcard import of nested objects (ResourceDocs310/400/500/510/600) that no longer exist. --- .../main/scala/bootstrap/liftweb/Boot.scala | 2 +- .../ResourceDocs1_4_0/ResourceDocs140.scala | 142 +++--------------- 2 files changed, 19 insertions(+), 125 deletions(-) diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala index 6b23db9b4e..157a77a4ed 100644 --- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala +++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala @@ -37,7 +37,7 @@ import code.accountattribute.MappedAccountAttribute import code.accountholders.MapperAccountHolders import code.actorsystem.ObpActorSystem import code.api.Constant._ -import code.api.ResourceDocs1_4_0.ResourceDocs300.{ResourceDocs310, ResourceDocs400, ResourceDocs500, ResourceDocs510, ResourceDocs600} +//import code.api.ResourceDocs1_4_0.ResourceDocs300.{ResourceDocs310, ResourceDocs400, ResourceDocs500, ResourceDocs510, ResourceDocs600} import code.api.ResourceDocs1_4_0._ import code.api._ import code.api.attributedefinition.AttributeDefinition 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 031807bcae..df6547fd68 100644 --- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocs140.scala +++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocs140.scala @@ -1,139 +1,33 @@ package code.api.ResourceDocs1_4_0 -import scala.language.reflectiveCalls -import code.api.Constant.HostName -import code.api.{OBPRestHelper, ResponseHeader} -import code.api.cache.Caching -import code.api.util.APIUtil._ -import code.api.util.{APIUtil, ApiVersionUtils, YAMLUtils} -import code.api.v1_4_0.JSONFactory1_4_0 -import code.apicollectionendpoint.MappedApiCollectionEndpointsProvider -import code.util.Helper.{MdcLoggable, SILENCE_IS_GOLDEN} -import com.openbankproject.commons.model.enums.ContentParam.{DYNAMIC, STATIC} +import code.api.OBPRestHelper +import code.util.Helper.MdcLoggable import com.openbankproject.commons.util.{ApiVersion, ApiVersionStatus} -import net.liftweb.http.{GetRequest, InMemoryResponse, PlainTextResponse, Req, S} +// All request dispatch migrated to Http4sResourceDocs (wired into Http4sApp.baseServices). +// These objects are retained solely as accessors for ImplementationsResourceDocs — +// the business-logic entry point delegated to by the centralised http4s service. +// They are NOT registered in LiftRules.statelessDispatch. object ResourceDocs140 extends OBPRestHelper with ResourceDocsAPIMethods with MdcLoggable { - val version = ApiVersion.v1_4_0 // "1.4.0" // We match other api versions so API explorer can easily use the path. + val version = ApiVersion.v1_4_0 val versionStatus = ApiVersionStatus.STABLE.toString - val routes: List[OBPEndpoint] = List( - ImplementationsResourceDocs.getResourceDocsObp, - ImplementationsResourceDocs.getBankLevelDynamicResourceDocsObp, - ImplementationsResourceDocs.getResourceDocsSwagger, - ) - registerRoutes(routes, ImplementationsResourceDocs.localResourceDocs, apiPrefix) + // routes intentionally empty — all traffic served by Http4sResourceDocs } - -// Hack to provide Resource Docs / Swagger on endpoints other than 1.4.0 where it is defined. -object ResourceDocs200 extends OBPRestHelper with ResourceDocsAPIMethods with MdcLoggable { - val version = ApiVersion.v2_0_0 // "2.0.0" // We match other api versions so API explorer can easily use the path. - val versionStatus = ApiVersionStatus.STABLE.toString - val routes: List[OBPEndpoint] = List( - ImplementationsResourceDocs.getResourceDocsObp, - ImplementationsResourceDocs.getResourceDocsSwagger, - ImplementationsResourceDocs.getBankLevelDynamicResourceDocsObp, - ) - registerRoutes(routes, ImplementationsResourceDocs.localResourceDocs, apiPrefix) -} - - -// Hack to provide Resource Docs / Swagger on endpoints other than 1.4.0 where it is defined. -object ResourceDocs210 extends OBPRestHelper with ResourceDocsAPIMethods with MdcLoggable { - val version: ApiVersion = ApiVersion.v2_1_0 // "2.1.0" // We match other api versions so API explorer can easily use the path. - val versionStatus = ApiVersionStatus.STABLE.toString - val routes: List[OBPEndpoint] = List( - ImplementationsResourceDocs.getResourceDocsObp, - ImplementationsResourceDocs.getResourceDocsSwagger, - ImplementationsResourceDocs.getBankLevelDynamicResourceDocsObp, - ) - registerRoutes(routes, ImplementationsResourceDocs.localResourceDocs, apiPrefix) -} - -// Hack to provide Resource Docs / Swagger on endpoints other than 1.4.0 where it is defined. -object ResourceDocs220 extends OBPRestHelper with ResourceDocsAPIMethods with MdcLoggable { - val version: ApiVersion = ApiVersion.v2_2_0 // "2.2.0" // We match other api versions so API explorer can easily use the path. - val versionStatus = ApiVersionStatus.STABLE.toString - val routes: List[OBPEndpoint] = List( - ImplementationsResourceDocs.getResourceDocsObp, - ImplementationsResourceDocs.getResourceDocsSwagger, - ImplementationsResourceDocs.getBankLevelDynamicResourceDocsObp, - ) - registerRoutes(routes, ImplementationsResourceDocs.localResourceDocs, apiPrefix) -} - -// Hack to provide Resource Docs / Swagger on endpoints other than 1.4.0 where it is defined. +// Kept so Http4sResourceDocs can reference ResourceDocs300.ResourceDocs600. object ResourceDocs300 extends OBPRestHelper with ResourceDocsAPIMethods with MdcLoggable { - val version : ApiVersion = ApiVersion.v3_0_0 // = "3.0.0" // We match other api versions so API explorer can easily use the path. - val versionStatus = ApiVersionStatus.STABLE.toString - val routes: List[OBPEndpoint] = List( - ImplementationsResourceDocs.getResourceDocsObp, - ImplementationsResourceDocs.getResourceDocsSwagger, - ImplementationsResourceDocs.getBankLevelDynamicResourceDocsObp, - ) - registerRoutes(routes, ImplementationsResourceDocs.localResourceDocs, apiPrefix) - - // Hack to provide Resource Docs / Swagger on endpoints other than 1.4.0 where it is defined. - object ResourceDocs310 extends OBPRestHelper with ResourceDocsAPIMethods with MdcLoggable { - val version: ApiVersion = ApiVersion.v3_1_0 // = "3.0.0" // We match other api versions so API explorer can easily use the path. - val versionStatus = ApiVersionStatus.STABLE.toString - val routes: List[OBPEndpoint] = List( - ImplementationsResourceDocs.getResourceDocsObp, - ImplementationsResourceDocs.getResourceDocsSwagger, - ImplementationsResourceDocs.getBankLevelDynamicResourceDocsObp, - ) - registerRoutes(routes, ImplementationsResourceDocs.localResourceDocs, apiPrefix) - } - // Hack to provide Resource Docs / Swagger on endpoints other than 1.4.0 where it is defined. - object ResourceDocs400 extends OBPRestHelper with ResourceDocsAPIMethods with MdcLoggable { - val version: ApiVersion = ApiVersion.v4_0_0 // = "4.0.0" // We match other api versions so API explorer can easily use the path. - val versionStatus = ApiVersionStatus.STABLE.toString - val routes: List[OBPEndpoint] = List( - ImplementationsResourceDocs.getResourceDocsObpV400, - ImplementationsResourceDocs.getResourceDocsSwagger, - ImplementationsResourceDocs.getBankLevelDynamicResourceDocsObp, - ) - registerRoutes(routes, ImplementationsResourceDocs.localResourceDocs, apiPrefix) - } - // Hack to provide Resource Docs / Swagger on endpoints other than 1.4.0 where it is defined. - object ResourceDocs500 extends OBPRestHelper with ResourceDocsAPIMethods with MdcLoggable { - val version: ApiVersion = ApiVersion.v5_0_0 - val versionStatus = ApiVersionStatus.STABLE.toString - val routes: List[OBPEndpoint] = List( - ImplementationsResourceDocs.getResourceDocsObpV400, - ImplementationsResourceDocs.getResourceDocsSwagger, - ImplementationsResourceDocs.getBankLevelDynamicResourceDocsObp, - ) - registerRoutes(routes, ImplementationsResourceDocs.localResourceDocs, apiPrefix) - } - - object ResourceDocs510 extends OBPRestHelper with ResourceDocsAPIMethods with MdcLoggable { - val version: ApiVersion = ApiVersion.v5_1_0 - val versionStatus = ApiVersionStatus.BLEEDING_EDGE.toString - val routes: List[OBPEndpoint] = List( - ImplementationsResourceDocs.getResourceDocsObpV400, - ImplementationsResourceDocs.getResourceDocsSwagger, - ImplementationsResourceDocs.getBankLevelDynamicResourceDocsObp, -// ImplementationsResourceDocs.getStaticResourceDocsObp - ) - registerRoutes(routes, ImplementationsResourceDocs.localResourceDocs, apiPrefix) - } + val version : ApiVersion = ApiVersion.v3_0_0 + val versionStatus = ApiVersionStatus.STABLE.toString + // routes intentionally empty — all traffic served by Http4sResourceDocs + // Retained to provide ImplementationsResourceDocs with includeTechnologyInResponse=true. + // v6.0.0 resource-docs responses include the `technology` field; all other versions + // leave it as None. Http4sResourceDocs picks this instance for v6.0.0 URLs. object ResourceDocs600 extends OBPRestHelper with ResourceDocsAPIMethods with MdcLoggable { - val version: ApiVersion = ApiVersion.v6_0_0 - val versionStatus = ApiVersionStatus.BLEEDING_EDGE.toString + val version : ApiVersion = ApiVersion.v6_0_0 + val versionStatus = ApiVersionStatus.BLEEDING_EDGE.toString override def includeTechnologyInResponse: Boolean = true - val routes: List[OBPEndpoint] = List( - ImplementationsResourceDocs.getResourceDocsObpV400, - ImplementationsResourceDocs.getResourceDocsSwagger, - ImplementationsResourceDocs.getResourceDocsOpenAPI31, - ImplementationsResourceDocs.getBankLevelDynamicResourceDocsObp, -// ImplementationsResourceDocs.getStaticResourceDocsObp - ) - registerRoutes(routes, ImplementationsResourceDocs.localResourceDocs, apiPrefix) - // openapi.yaml raw `serve { ... }` block was migrated to - // `code.api.util.http4s.Http4sResourceDocs.handleGetResourceDocsOpenAPI31Yaml`. + // routes intentionally empty — all traffic served by Http4sResourceDocs } - } From 35988b04cf818b32d0c43210a07c95707e32e4cf Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 26 May 2026 17:38:02 +0200 Subject: [PATCH 2/4] refactor/code clean --- .../ResourceDocs1_4_0/ResourceDocs140.scala | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) 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 df6547fd68..adc268ec90 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 @@ -31,3 +31,144 @@ object ResourceDocs300 extends OBPRestHelper with ResourceDocsAPIMethods with Md // routes intentionally empty — all traffic served by Http4sResourceDocs } } +// +// +//package code.api.ResourceDocs1_4_0 +// +//import scala.language.reflectiveCalls +//import code.api.Constant.HostName +//import code.api.{OBPRestHelper, ResponseHeader} +//import code.api.cache.Caching +//import code.api.util.APIUtil._ +//import code.api.util.{APIUtil, ApiVersionUtils, YAMLUtils} +//import code.api.v1_4_0.JSONFactory1_4_0 +//import code.apicollectionendpoint.MappedApiCollectionEndpointsProvider +//import code.util.Helper.{MdcLoggable, SILENCE_IS_GOLDEN} +//import com.openbankproject.commons.model.enums.ContentParam.{DYNAMIC, STATIC} +//import com.openbankproject.commons.util.{ApiVersion, ApiVersionStatus} +//import net.liftweb.http.{GetRequest, InMemoryResponse, PlainTextResponse, Req, S} +// +// +//object ResourceDocs140 extends OBPRestHelper with ResourceDocsAPIMethods with MdcLoggable { +// val version = ApiVersion.v1_4_0 // "1.4.0" // We match other api versions so API explorer can easily use the path. +// val versionStatus = ApiVersionStatus.STABLE.toString +// val routes: List[OBPEndpoint] = List( +// ImplementationsResourceDocs.getResourceDocsObp, +// ImplementationsResourceDocs.getBankLevelDynamicResourceDocsObp, +// ImplementationsResourceDocs.getResourceDocsSwagger, +// ) +// registerRoutes(routes, ImplementationsResourceDocs.localResourceDocs, apiPrefix) +//} +// +// +//// Hack to provide Resource Docs / Swagger on endpoints other than 1.4.0 where it is defined. +//object ResourceDocs200 extends OBPRestHelper with ResourceDocsAPIMethods with MdcLoggable { +// val version = ApiVersion.v2_0_0 // "2.0.0" // We match other api versions so API explorer can easily use the path. +// val versionStatus = ApiVersionStatus.STABLE.toString +// val routes: List[OBPEndpoint] = List( +// ImplementationsResourceDocs.getResourceDocsObp, +// ImplementationsResourceDocs.getResourceDocsSwagger, +// ImplementationsResourceDocs.getBankLevelDynamicResourceDocsObp, +// ) +// registerRoutes(routes, ImplementationsResourceDocs.localResourceDocs, apiPrefix) +//} +// +// +//// Hack to provide Resource Docs / Swagger on endpoints other than 1.4.0 where it is defined. +//object ResourceDocs210 extends OBPRestHelper with ResourceDocsAPIMethods with MdcLoggable { +// val version: ApiVersion = ApiVersion.v2_1_0 // "2.1.0" // We match other api versions so API explorer can easily use the path. +// val versionStatus = ApiVersionStatus.STABLE.toString +// val routes: List[OBPEndpoint] = List( +// ImplementationsResourceDocs.getResourceDocsObp, +// ImplementationsResourceDocs.getResourceDocsSwagger, +// ImplementationsResourceDocs.getBankLevelDynamicResourceDocsObp, +// ) +// registerRoutes(routes, ImplementationsResourceDocs.localResourceDocs, apiPrefix) +//} +// +//// Hack to provide Resource Docs / Swagger on endpoints other than 1.4.0 where it is defined. +//object ResourceDocs220 extends OBPRestHelper with ResourceDocsAPIMethods with MdcLoggable { +// val version: ApiVersion = ApiVersion.v2_2_0 // "2.2.0" // We match other api versions so API explorer can easily use the path. +// val versionStatus = ApiVersionStatus.STABLE.toString +// val routes: List[OBPEndpoint] = List( +// ImplementationsResourceDocs.getResourceDocsObp, +// ImplementationsResourceDocs.getResourceDocsSwagger, +// ImplementationsResourceDocs.getBankLevelDynamicResourceDocsObp, +// ) +// registerRoutes(routes, ImplementationsResourceDocs.localResourceDocs, apiPrefix) +//} +// +//// Hack to provide Resource Docs / Swagger on endpoints other than 1.4.0 where it is defined. +//object ResourceDocs300 extends OBPRestHelper with ResourceDocsAPIMethods with MdcLoggable { +// val version : ApiVersion = ApiVersion.v3_0_0 // = "3.0.0" // We match other api versions so API explorer can easily use the path. +// val versionStatus = ApiVersionStatus.STABLE.toString +// val routes: List[OBPEndpoint] = List( +// ImplementationsResourceDocs.getResourceDocsObp, +// ImplementationsResourceDocs.getResourceDocsSwagger, +// ImplementationsResourceDocs.getBankLevelDynamicResourceDocsObp, +// ) +// registerRoutes(routes, ImplementationsResourceDocs.localResourceDocs, apiPrefix) +// +// // Hack to provide Resource Docs / Swagger on endpoints other than 1.4.0 where it is defined. +// object ResourceDocs310 extends OBPRestHelper with ResourceDocsAPIMethods with MdcLoggable { +// val version: ApiVersion = ApiVersion.v3_1_0 // = "3.0.0" // We match other api versions so API explorer can easily use the path. +// val versionStatus = ApiVersionStatus.STABLE.toString +// val routes: List[OBPEndpoint] = List( +// ImplementationsResourceDocs.getResourceDocsObp, +// ImplementationsResourceDocs.getResourceDocsSwagger, +// ImplementationsResourceDocs.getBankLevelDynamicResourceDocsObp, +// ) +// registerRoutes(routes, ImplementationsResourceDocs.localResourceDocs, apiPrefix) +// } +// // Hack to provide Resource Docs / Swagger on endpoints other than 1.4.0 where it is defined. +// object ResourceDocs400 extends OBPRestHelper with ResourceDocsAPIMethods with MdcLoggable { +// val version: ApiVersion = ApiVersion.v4_0_0 // = "4.0.0" // We match other api versions so API explorer can easily use the path. +// val versionStatus = ApiVersionStatus.STABLE.toString +// val routes: List[OBPEndpoint] = List( +// ImplementationsResourceDocs.getResourceDocsObpV400, +// ImplementationsResourceDocs.getResourceDocsSwagger, +// ImplementationsResourceDocs.getBankLevelDynamicResourceDocsObp, +// ) +// registerRoutes(routes, ImplementationsResourceDocs.localResourceDocs, apiPrefix) +// } +// // Hack to provide Resource Docs / Swagger on endpoints other than 1.4.0 where it is defined. +// object ResourceDocs500 extends OBPRestHelper with ResourceDocsAPIMethods with MdcLoggable { +// val version: ApiVersion = ApiVersion.v5_0_0 +// val versionStatus = ApiVersionStatus.STABLE.toString +// val routes: List[OBPEndpoint] = List( +// ImplementationsResourceDocs.getResourceDocsObpV400, +// ImplementationsResourceDocs.getResourceDocsSwagger, +// ImplementationsResourceDocs.getBankLevelDynamicResourceDocsObp, +// ) +// registerRoutes(routes, ImplementationsResourceDocs.localResourceDocs, apiPrefix) +// } +// +// object ResourceDocs510 extends OBPRestHelper with ResourceDocsAPIMethods with MdcLoggable { +// val version: ApiVersion = ApiVersion.v5_1_0 +// val versionStatus = ApiVersionStatus.BLEEDING_EDGE.toString +// val routes: List[OBPEndpoint] = List( +// ImplementationsResourceDocs.getResourceDocsObpV400, +// ImplementationsResourceDocs.getResourceDocsSwagger, +// ImplementationsResourceDocs.getBankLevelDynamicResourceDocsObp, +// // ImplementationsResourceDocs.getStaticResourceDocsObp +// ) +// registerRoutes(routes, ImplementationsResourceDocs.localResourceDocs, apiPrefix) +// } +// +// object ResourceDocs600 extends OBPRestHelper with ResourceDocsAPIMethods with MdcLoggable { +// val version: ApiVersion = ApiVersion.v6_0_0 +// val versionStatus = ApiVersionStatus.BLEEDING_EDGE.toString +// override def includeTechnologyInResponse: Boolean = true +// val routes: List[OBPEndpoint] = List( +// ImplementationsResourceDocs.getResourceDocsObpV400, +// ImplementationsResourceDocs.getResourceDocsSwagger, +// ImplementationsResourceDocs.getResourceDocsOpenAPI31, +// ImplementationsResourceDocs.getBankLevelDynamicResourceDocsObp, +// // ImplementationsResourceDocs.getStaticResourceDocsObp +// ) +// registerRoutes(routes, ImplementationsResourceDocs.localResourceDocs, apiPrefix) +// // openapi.yaml raw `serve { ... }` block was migrated to +// // `code.api.util.http4s.Http4sResourceDocs.handleGetResourceDocsOpenAPI31Yaml`. +// } +// +//} From aabebc05532486a557c0fe8be7f319a5f3cdc106 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 26 May 2026 17:48:24 +0200 Subject: [PATCH 3/4] refactor(resource-docs): comment out Lift dispatch handlers in ResourceDocsAPIMethods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All resource-docs / swagger / openapi traffic is now served by Http4sResourceDocs (wired into Http4sApp.baseServices). The Lift OBPEndpoint handlers inside ImplementationsResourceDocs are dead code. Comment out (not delete) the following Lift-specific sections: - cleanApiVersionString — dead code, never referenced - getResourceDocsDescription — only used by the commented-out localResourceDocs registrations - resourceDocsRequireRole — only used by Lift handlers - lazy val getResourceDocsObp / getResourceDocsObpV400 (OBPEndpoint) - private def getApiLevelResourceDocs (Lift handler helper) - def getBankLevelDynamicResourceDocsObp (OBPEndpoint) - def getResourceDocsSwagger : OBPEndpoint (public Lift handler — the private overload getResourceDocsSwagger(String, List[...]) is kept because convertResourceDocsToSwaggerJvalueAndSetCache calls it) - def getResourceDocsOpenAPI31 : OBPEndpoint - All four localResourceDocs += ResourceDoc(...) registration blocks Business-logic methods remain untouched: getResourceDocsList, getStaticResourceDocsObpCached, getAllResourceDocsObpCached, getResourceDocsObpDynamicCached, convertResourceDocsToSwaggerJvalueAndSetCache, convertResourceDocsToOpenAPI31JvalueAndSetCache, convertResourceDocsToOpenAPI31YAMLAndSetCache, resourceDocsToResourceDocJson, getSpecialInstructions, resourceDocsJsonToJsonResponse — all still called by Http4sResourceDocs. Also update ResourceDocsTest / SwaggerDocsTest: replace nameOf(ImplementationsResourceDocs.xxx) macro calls that referenced now-commented members with equivalent string literals. --- .../ResourceDocsAPIMethods.scala | 1182 ++++++++--------- .../ResourceDocs1_4_0/ResourceDocsTest.scala | 4 +- .../ResourceDocs1_4_0/SwaggerDocsTest.scala | 2 +- 3 files changed, 594 insertions(+), 594 deletions(-) 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 2ffa0e23ed..70546d3f8e 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 @@ -234,11 +234,11 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth - // TODO constrain version? - // strip the leading v - def cleanApiVersionString (version: String) : String = { - version.stripPrefix("v").stripPrefix("V") - } +// // TODO constrain version? +// // strip the leading v +// def cleanApiVersionString (version: String) : String = { +// version.stripPrefix("v").stripPrefix("V") +// } /** @@ -371,592 +371,592 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth } } - def getResourceDocsDescription(isBankLevelResourceDoc: Boolean) = { - - val endpointBankIdPath = if (isBankLevelResourceDoc) "/banks/BANK_ID" else "" - - s"""Get documentation about the RESTful resources on this server including example bodies for POST and PUT requests. - | - |This is the native data format used to document OBP endpoints. Each endpoint has a Resource Doc (a Scala case class) defined in the source code. - | - | This endpoint is used by OBP API Explorer to display and work with the API documentation. - | - | Most (but not all) fields are also available in swagger format. (The Swagger endpoint is built from Resource Docs.) - | - | API_VERSION is the version you want documentation about e.g. v3.0.0 - | - | You may filter this endpoint with tags parameter e.g. ?tags=Account,Bank - | - | You may filter this endpoint with functions parameter e.g. ?functions=enableDisableConsumers,getConnectorMetrics - | - | For possible function values, see implemented_by.function in the JSON returned by this endpoint or the OBP source code or the footer of the API Explorer which produces a comma separated list of functions that reflect the server or filtering by API Explorer based on tags etc. - | - | You may filter this endpoint using the 'content' url parameter, e.g. ?content=dynamic - | if set content=dynamic, only show dynamic endpoints, if content=static, only show the static endpoints. if omit this parameter, we will show all the endpoints. - | - | You may need some other language resource docs, now we support en_GB and es_ES at the moment. - | - | You can filter with api-collection-id, but api-collection-id can not be used with others together. If api-collection-id is used in URL, it will ignore all other parameters. - | - |See the Resource Doc endpoint for more information. - | - |Note: Dynamic Resource Docs are cached, TTL is ${GET_DYNAMIC_RESOURCE_DOCS_TTL} seconds - | Static Resource Docs are cached, TTL is ${GET_STATIC_RESOURCE_DOCS_TTL} seconds - | - | - |Following are more examples: - |${getObpApiRoot}/v4.0.0$endpointBankIdPath/resource-docs/v4.0.0/obp - |${getObpApiRoot}/v4.0.0$endpointBankIdPath/resource-docs/v4.0.0/obp?tags=Account,Bank - |${getObpApiRoot}/v4.0.0$endpointBankIdPath/resource-docs/v4.0.0/obp?functions=getBanks,bankById - |${getObpApiRoot}/v4.0.0$endpointBankIdPath/resource-docs/v4.0.0/obp?locale=es_ES - |${getObpApiRoot}/v4.0.0$endpointBankIdPath/resource-docs/v4.0.0/obp?content=static,dynamic,all - |${getObpApiRoot}/v4.0.0$endpointBankIdPath/resource-docs/v4.0.0/obp?api-collection-id=4e866c86-60c3-4268-a221-cb0bbf1ad221 - | - |
    - |
  • operation_id is concatenation of "v", version and function and should be unique (used for DOM element IDs etc. maybe used to link to source code)
  • - |
  • version references the version that the API call is defined in.
  • - |
  • function is the (scala) partial function that implements this endpoint. It is unique per version of the API.
  • - |
  • request_url is empty for the root call, else the path. It contains the standard prefix (e.g. /obp) and the implemented version (the version where this endpoint was defined) e.g. /obp/v1.2.0/resource
  • - |
  • specified_url (recommended to use) is empty for the root call, else the path. It contains the standard prefix (e.g. /obp) and the version specified in the call e.g. /obp/v3.1.0/resource. In OBP, endpoints are first made available at the request_url, but the same resource (function call) is often made available under later versions (specified_url). To access the latest version of all endpoints use the latest version available on your OBP instance e.g. /obp/v3.1.0 - To get the original version use the request_url. We recommend to use the specified_url since non semantic improvements are more likely to be applied to later implementations of the call.
  • - |
  • summary is a short description inline with the swagger terminology.
  • - |
  • description may contain html markup (generated from markdown on the server).
  • - |
- """ - } - - - localResourceDocs += ResourceDoc( - getResourceDocsObp, - implementedInApiVersion, - "getResourceDocsObp", - "GET", - "/resource-docs/API_VERSION/obp", - "Get Resource Docs.", - getResourceDocsDescription(false), - EmptyBody, - EmptyBody, - UnknownError :: Nil, - List(apiTagDocumentation, apiTagApi), - Some(List(canReadResourceDoc)) - ) - - def resourceDocsRequireRole = APIUtil.getPropsAsBoolValue("resource_docs_requires_role", false) - // Provides resource documents so that API Explorer (or other apps) can display API documentation - // Note: description uses html markup because original markdown doesn't easily support "_" and there are multiple versions of markdown. - lazy val getResourceDocsObp : OBPEndpoint = { - case "resource-docs" :: requestedApiVersionString :: "obp" :: Nil JsonGet _ => { - val (tags, partialFunctions, locale, contentParam, apiCollectionIdParam) = ResourceDocsAPIMethodsUtil.getParams() - cc => - implicit val ec = EndpointContext(Some(cc)) - getApiLevelResourceDocs(cc,requestedApiVersionString, tags, partialFunctions, locale, contentParam, apiCollectionIdParam,false) - } - } - - // Note: getResourceDocsObpV400 intentionally has NO ResourceDoc registration. - // It shares the URL "/resource-docs/API_VERSION/obp" + GET with getResourceDocsObp - // (registered above). One ResourceDoc entry per (URL, verb) is enough; registering - // both produced a duplicate that broke the v7 aggregation dedup. - lazy val getResourceDocsObpV400 : OBPEndpoint = { - case "resource-docs" :: requestedApiVersionString :: "obp" :: Nil JsonGet _ => { - val (tags, partialFunctions, locale, contentParam, apiCollectionIdParam) = ResourceDocsAPIMethodsUtil.getParams() - cc => - implicit val ec = EndpointContext(Some(cc)) - getApiLevelResourceDocs(cc,requestedApiVersionString, tags, partialFunctions, locale, contentParam, apiCollectionIdParam,true) - } - } - - //API level just mean, this response will be forward to liftweb directly. - private def getApiLevelResourceDocs( - cc: CallContext, - requestedApiVersionString: String, - tags: Option[List[ResourceDocTag]], - partialFunctions: Option[List[String]], - locale: Option[String], - contentParam: Option[ContentParam], - apiCollectionIdParam: Option[String], - isVersion4OrHigher: Boolean, - ) = { - for { - (u: Box[User], callContext: Option[CallContext]) <- resourceDocsRequireRole match { - case false => anonymousAccess(cc) - case true => authenticatedAccess(cc) // If set resource_docs_requires_role=true, we need check the authentication - } - _ <- resourceDocsRequireRole match { - case false => Future(()) - case true => // If set resource_docs_requires_role=true, we need check the roles as well - NewStyle.function.hasAtLeastOneEntitlement(failMsg = UserHasMissingRoles + canReadResourceDoc.toString)("", u.map(_.userId).getOrElse(""), ApiRole.canReadResourceDoc :: Nil, cc.callContext) - } - requestedApiVersion <- NewStyle.function.tryons(s"$InvalidApiVersionString $requestedApiVersionString", 400, callContext) {ApiVersionUtils.valueOf(requestedApiVersionString)} - _ <- Helper.booleanToFuture(s"$ApiVersionNotSupported $requestedApiVersionString", 400, 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) - } - cacheKey = APIUtil.createResourceDocCacheKey( - None, - requestedApiVersionString, - tags, - partialFunctions, - locale, - contentParam, - apiCollectionIdParam, - Some(isVersion4OrHigher) - ) - json <- locale match { - case _ if (apiCollectionIdParam.isDefined) => - NewStyle.function.tryons(s"$UnknownError Can not prepare OBP resource docs.", 500, callContext) { - val operationIds = MappedApiCollectionEndpointsProvider.getApiCollectionEndpoints(apiCollectionIdParam.getOrElse("")).map(_.operationId).map(getObpFormatOperationId) - val resourceDocs = ResourceDoc.getResourceDocs(operationIds) - val resourceDocsJson = JSONFactory1_4_0.createResourceDocsJson(resourceDocs, isVersion4OrHigher, locale, includeTechnology = includeTechnologyInResponse) - val resourceDocsJsonJValue = Full(resourceDocsJsonToJsonResponse(resourceDocsJson)) - resourceDocsJsonJValue.map(successJsonResponse(_)) - } - case _ => - contentParam match { - case Some(DYNAMIC) =>{ - NewStyle.function.tryons(s"$UnknownError Can not prepare OBP resource docs.", 500, callContext) { - val cacheValueFromRedis = Caching.getDynamicResourceDocCache(cacheKey) - val dynamicDocs: Box[JValue] = - if (cacheValueFromRedis.isDefined) { - Full(json.parse(cacheValueFromRedis.get)) - } else { - val resourceDocJson = getResourceDocsObpDynamicCached(tags, partialFunctions, locale, None, false) - val resourceDocJsonJValue = resourceDocJson.map(resourceDocsJsonToJsonResponse).head - val jsonString = json.compactRender(resourceDocJsonJValue) - Caching.setDynamicResourceDocCache(cacheKey, jsonString) - Full(resourceDocJsonJValue) - } - dynamicDocs.map(successJsonResponse(_)) - } - } - case Some(STATIC) => { - NewStyle.function.tryons(s"$UnknownError Can not prepare OBP resource docs.", 500, callContext) { - val cacheValueFromRedis = Caching.getStaticResourceDocCache(cacheKey) - val staticDocs: Box[JValue] = - if (cacheValueFromRedis.isDefined) { - Full(json.parse(cacheValueFromRedis.get)) - } else { - val resourceDocJson = getStaticResourceDocsObpCached(requestedApiVersionString, tags, partialFunctions, locale, isVersion4OrHigher) - val resourceDocJsonJValue = resourceDocJson.map(resourceDocsJsonToJsonResponse).head - val jsonString = json.compactRender(resourceDocJsonJValue) - Caching.setStaticResourceDocCache(cacheKey, jsonString) - Full(resourceDocJsonJValue) - } - staticDocs.map(successJsonResponse(_)) - } - } - case _ => { - NewStyle.function.tryons(s"$UnknownError Can not prepare OBP resource docs.", 500, callContext) { - val cacheValueFromRedis = Caching.getAllResourceDocCache(cacheKey) - val bothStaticAndDyamicDocs: Box[JValue] = - if (cacheValueFromRedis.isDefined) { - Full(json.parse(cacheValueFromRedis.get)) - } else { - val resourceDocJson = getAllResourceDocsObpCached(requestedApiVersionString, tags, partialFunctions, locale, contentParam, isVersion4OrHigher) - val resourceDocJsonJValue = resourceDocJson.map(resourceDocsJsonToJsonResponse).head - val jsonString = json.compactRender(resourceDocJsonJValue) - Caching.setAllResourceDocCache(cacheKey, jsonString) - Full(resourceDocJsonJValue) - } - bothStaticAndDyamicDocs.map(successJsonResponse(_)) - } - } - } - } - } yield { - (json, HttpCode.`200`(callContext)) - } - } - - localResourceDocs += ResourceDoc( - getBankLevelDynamicResourceDocsObp, - implementedInApiVersion, - nameOf(getBankLevelDynamicResourceDocsObp), - "GET", - "/banks/BANK_ID/resource-docs/API_VERSION/obp", - "Get Bank Level Dynamic Resource Docs.", - getResourceDocsDescription(true), - EmptyBody, - EmptyBody, - UnknownError :: Nil, - List(apiTagDocumentation, apiTagApi), - Some(List(canReadDynamicResourceDocsAtOneBank)) - ) - - // Provides resource documents so that API Explorer (or other apps) can display API documentation - // Note: description uses html markup because original markdown doesn't easily support "_" and there are multiple versions of markdown. - def getBankLevelDynamicResourceDocsObp : OBPEndpoint = { - case "banks" :: bankId :: "resource-docs" :: requestedApiVersionString :: "obp" :: Nil JsonGet _ => { - val (tags, partialFunctions, locale, contentParam, apiCollectionIdParam) = ResourceDocsAPIMethodsUtil.getParams() - cc => - for { - (u: Box[User], callContext: Option[CallContext]) <- resourceDocsRequireRole match { - case false => anonymousAccess(cc) - case true => authenticatedAccess(cc) // If set resource_docs_requires_role=true, we need check the authentication - } - _ <- 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) - } - (_, callContext) <- NewStyle.function.getBank(BankId(bankId), Option(cc)) - _ <- resourceDocsRequireRole match { - case false => Future(()) - case true => // If set resource_docs_requires_role=true, we need check the the roles as well - NewStyle.function.hasAtLeastOneEntitlement(failMsg = UserHasMissingRoles + ApiRole.canReadDynamicResourceDocsAtOneBank.toString)( - bankId, u.map(_.userId).getOrElse(""), ApiRole.canReadDynamicResourceDocsAtOneBank::Nil, cc.callContext - ) - } - requestedApiVersion <- NewStyle.function.tryons(s"$InvalidApiVersionString $requestedApiVersionString", 400, callContext) {ApiVersionUtils.valueOf(requestedApiVersionString)} - cacheKey = APIUtil.createResourceDocCacheKey( - Some(bankId), - requestedApiVersionString, - tags, - partialFunctions, - locale, - contentParam, - apiCollectionIdParam, - None) - json <- NewStyle.function.tryons(s"$UnknownError Can not create dynamic resource docs.", 400, callContext) { - val cacheValueFromRedis = Caching.getDynamicResourceDocCache(cacheKey) - if (cacheValueFromRedis.isDefined) { - json.parse(cacheValueFromRedis.get) - } else { - val resourceDocJson = getResourceDocsObpDynamicCached(tags, partialFunctions, locale, None, false) - val resourceDocJsonJValue = resourceDocJson.map(resourceDocsJsonToJsonResponse).head - val jsonString = json.compactRender(resourceDocJsonJValue) - Caching.setDynamicResourceDocCache(cacheKey, jsonString) - resourceDocJsonJValue - } - } - } yield { - (Full(json), HttpCode.`200`(callContext)) - } - } - } - - - localResourceDocs += ResourceDoc( - getResourceDocsSwagger, - implementedInApiVersion, - "getResourceDocsSwagger", - "GET", - "/resource-docs/API_VERSION/swagger", - "Get Swagger documentation", - s"""Returns documentation about the RESTful resources on this server in Swagger format. - | - |API_VERSION is the version you want documentation about e.g. v3.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') - | - |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}/v3.1.0/resource-docs/v3.1.0/swagger - |${getObpApiRoot}/v3.1.0/resource-docs/v3.1.0/swagger?tags=Account,Bank - |${getObpApiRoot}/v3.1.0/resource-docs/v3.1.0/swagger?functions=getBanks,bankById - |${getObpApiRoot}/v3.1.0/resource-docs/v3.1.0/swagger?tags=Account,Bank,PSD2&functions=getBanks,bankById - | - """, - EmptyBody, - EmptyBody, - UnknownError :: Nil, - List(apiTagDocumentation, apiTagApi) - ) - - // Note: Swagger format requires special character escaping because it builds JSON via string concatenation (unlike OBP/OpenAPI formats which use case class serialization) - - def getResourceDocsSwagger : OBPEndpoint = { - case "resource-docs" :: requestedApiVersionString :: "swagger" :: Nil JsonGet _ => { - cc => { - implicit val ec = EndpointContext(Some(cc)) - val (resourceDocTags, partialFunctions, locale, contentParam, apiCollectionIdParam) = ResourceDocsAPIMethodsUtil.getParams() - for { - (u: Box[User], callContext: Option[CallContext]) <- if (resourceDocsRequireRole) { - authenticatedAccess(cc) - } else { - anonymousAccess(cc) - } - _ <- if (resourceDocsRequireRole) { - NewStyle.function.hasAtLeastOneEntitlement(failMsg = UserHasMissingRoles + canReadResourceDoc.toString)("", u.map(_.userId).getOrElse(""), ApiRole.canReadResourceDoc :: Nil, cc.callContext) - } else { - Future(()) - } - 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( - None, - requestedApiVersionString, - resourceDocTags, - partialFunctions, - locale, - contentParam, - apiCollectionIdParam, - Some(isVersion4OrHigher) - ) - cacheValueFromRedis = Caching.getStaticSwaggerDocCache(cacheKey) - - swaggerJValue <- if (cacheValueFromRedis.isDefined) { - NewStyle.function.tryons(s"$UnknownError Can not convert internal swagger file from cache.", 400, cc.callContext) {json.parse(cacheValueFromRedis.get)} - } else { - NewStyle.function.tryons(s"$UnknownError Can not convert internal swagger 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, includeTechnology = includeTechnologyInResponse) - 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 - } - } - } - convertResourceDocsToSwaggerJvalueAndSetCache(cacheKey, requestedApiVersionString, resourceDocsJsonFiltered) - } - } - } yield { - (swaggerJValue, HttpCode.`200`(cc.callContext)) - } - } - } - } - - 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 - | - |## Query Parameters - | - |You may filter this endpoint using the following optional query parameters: - | - |**tags** - Filter by endpoint tags (comma-separated list) - | • Example: ?tags=Account,Bank or ?tags=Account-Firehose - | • All endpoints are given one or more tags which are used for grouping - | • Empty values will return error OBP-10053 - | - |**functions** - Filter by function names (comma-separated list) - | • Example: ?functions=getBanks,bankById - | • Each endpoint is implemented in the OBP Scala code by a 'function' - | • Empty values will return error OBP-10054 - | - |**content** - Filter by endpoint type - | • Values: static, dynamic, all (case-insensitive) - | • static: Only show static/core API endpoints - | • dynamic: Only show dynamic/custom endpoints - | • all: Show both static and dynamic endpoints (default) - | • Invalid values will return error OBP-10052 - | - |**locale** - Language for localized documentation - | • Example: ?locale=en_GB or ?locale=es_ES - | • Supported locales: en_GB, es_ES, ro_RO - | • Invalid locales will return error OBP-10041 - | - |**api-collection-id** - Filter by API collection UUID - | • Example: ?api-collection-id=4e866c86-60c3-4268-a221-cb0bbf1ad221 - | • Returns only endpoints belonging to the specified collection - | • Empty values will return error OBP-10055 - | - |This endpoint generates OpenAPI 3.1 compliant documentation with modern JSON Schema support. - | - |For YAML format, use the corresponding endpoint: /resource-docs/API_VERSION/openapi.yaml - | - |See the Resource Doc endpoint for more information. - | - |Note: Resource Docs are cached, TTL is ${GET_DYNAMIC_RESOURCE_DOCS_TTL} seconds - | - |## Examples - | - |Basic usage: - |${getObpApiRoot}/v6.0.0/resource-docs/v6.0.0/openapi - | - |Filter by tags: - |${getObpApiRoot}/v6.0.0/resource-docs/v6.0.0/openapi?tags=Account,Bank - |${getObpApiRoot}/v6.0.0/resource-docs/v6.0.0/openapi?tags=Account-Firehose - | - |Filter by content type: - |${getObpApiRoot}/v6.0.0/resource-docs/v6.0.0/openapi?content=static - |${getObpApiRoot}/v6.0.0/resource-docs/v6.0.0/openapi?content=dynamic - | - |Filter by functions: - |${getObpApiRoot}/v6.0.0/resource-docs/v6.0.0/openapi?functions=getBanks,bankById - | - |Combine multiple parameters: - |${getObpApiRoot}/v6.0.0/resource-docs/v6.0.0/openapi?content=static&tags=Account-Firehose - |${getObpApiRoot}/v6.0.0/resource-docs/v6.0.0/openapi?tags=Account,Bank,PSD2&functions=getBanks,bankById - |${getObpApiRoot}/v6.0.0/resource-docs/v6.0.0/openapi?content=static&locale=en_GB&tags=Account - | - |Filter by API collection: - |${getObpApiRoot}/v6.0.0/resource-docs/v6.0.0/openapi?api-collection-id=4e866c86-60c3-4268-a221-cb0bbf1ad221 - | - """, - EmptyBody, - EmptyBody, - InvalidApiVersionString :: - ApiVersionNotSupported :: - InvalidLocale :: - InvalidContentParameter :: - InvalidTagsParameter :: - InvalidFunctionsParameter :: - InvalidApiCollectionIdParameter :: - UnknownError :: Nil, - List(apiTagDocumentation, apiTagApi) - ) - - // Note: OpenAPI 3.1 YAML endpoint (/resource-docs/API_VERSION/openapi.yaml) - // is implemented using Lift's serve mechanism in ResourceDocs140.scala to properly - // handle YAML content type. It provides the same functionality as the JSON endpoint - // but returns OpenAPI documentation in YAML format instead of JSON. - - /** - * OpenAPI 3.1 endpoint with comprehensive parameter validation. - * - * This endpoint generates OpenAPI 3.1 documentation with the following validated query parameters: - * - tags: Comma-separated list of tags to filter endpoints (e.g., ?tags=Account,Bank) - * - functions: Comma-separated list of function names to filter endpoints - * - content: Filter type - "static", "dynamic", or "all" - * - locale: Language code for localization (e.g., "en_GB", "es_ES") - * - api-collection-id: UUID to filter by specific API collection - * - * Parameter validation guards ensure: - * - Empty parameters (e.g., ?tags=) return 400 error - * - Invalid content values return 400 error with valid options - * - All parameters are properly trimmed and sanitized - * - * Examples: - * - ?content=static&tags=Account-Firehose - * - ?tags=Account,Bank&functions=getBanks,bankById - * - ?content=dynamic&locale=en_GB - */ - def getResourceDocsOpenAPI31 : OBPEndpoint = { - case "resource-docs" :: requestedApiVersionString :: "openapi" :: Nil JsonGet _ => { - cc => { - implicit val ec = EndpointContext(Some(cc)) - - // Early validation for empty parameters using underlying S to bypass ObpS filtering - if (S.param("tags").exists(_.trim.isEmpty)) { - Full(errorJsonResponse(InvalidTagsParameter, 400)) - } else if (S.param("functions").exists(_.trim.isEmpty)) { - Full(errorJsonResponse(InvalidFunctionsParameter, 400)) - } else if (S.param("api-collection-id").exists(_.trim.isEmpty)) { - Full(errorJsonResponse(InvalidApiCollectionIdParameter, 400)) - } else { - val (resourceDocTags, partialFunctions, locale, contentParam, apiCollectionIdParam) = ResourceDocsAPIMethodsUtil.getParams() - for { - // Validate content parameter if provided - _ <- if (S.param("content").isDefined && contentParam.isEmpty) { - Helper.booleanToFuture(failMsg = InvalidContentParameter, cc = cc.callContext) { - false - } - } else { - Future.successful(true) - } - (u: Box[User], callContext: Option[CallContext]) <- if (resourceDocsRequireRole) { - authenticatedAccess(cc) - } else { - anonymousAccess(cc) - } - _ <- if (resourceDocsRequireRole) { - NewStyle.function.hasAtLeastOneEntitlement(failMsg = UserHasMissingRoles + canReadResourceDoc.toString)("", u.map(_.userId).getOrElse(""), ApiRole.canReadResourceDoc :: Nil, cc.callContext) - } else { - Future(()) - } - 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, includeTechnology = includeTechnologyInResponse) - 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)) - } - } - } - } - } +// def getResourceDocsDescription(isBankLevelResourceDoc: Boolean) = { +// +// val endpointBankIdPath = if (isBankLevelResourceDoc) "/banks/BANK_ID" else "" +// +// s"""Get documentation about the RESTful resources on this server including example bodies for POST and PUT requests. +// | +// |This is the native data format used to document OBP endpoints. Each endpoint has a Resource Doc (a Scala case class) defined in the source code. +// | +// | This endpoint is used by OBP API Explorer to display and work with the API documentation. +// | +// | Most (but not all) fields are also available in swagger format. (The Swagger endpoint is built from Resource Docs.) +// | +// | API_VERSION is the version you want documentation about e.g. v3.0.0 +// | +// | You may filter this endpoint with tags parameter e.g. ?tags=Account,Bank +// | +// | You may filter this endpoint with functions parameter e.g. ?functions=enableDisableConsumers,getConnectorMetrics +// | +// | For possible function values, see implemented_by.function in the JSON returned by this endpoint or the OBP source code or the footer of the API Explorer which produces a comma separated list of functions that reflect the server or filtering by API Explorer based on tags etc. +// | +// | You may filter this endpoint using the 'content' url parameter, e.g. ?content=dynamic +// | if set content=dynamic, only show dynamic endpoints, if content=static, only show the static endpoints. if omit this parameter, we will show all the endpoints. +// | +// | You may need some other language resource docs, now we support en_GB and es_ES at the moment. +// | +// | You can filter with api-collection-id, but api-collection-id can not be used with others together. If api-collection-id is used in URL, it will ignore all other parameters. +// | +// |See the Resource Doc endpoint for more information. +// | +// |Note: Dynamic Resource Docs are cached, TTL is ${GET_DYNAMIC_RESOURCE_DOCS_TTL} seconds +// | Static Resource Docs are cached, TTL is ${GET_STATIC_RESOURCE_DOCS_TTL} seconds +// | +// | +// |Following are more examples: +// |${getObpApiRoot}/v4.0.0$endpointBankIdPath/resource-docs/v4.0.0/obp +// |${getObpApiRoot}/v4.0.0$endpointBankIdPath/resource-docs/v4.0.0/obp?tags=Account,Bank +// |${getObpApiRoot}/v4.0.0$endpointBankIdPath/resource-docs/v4.0.0/obp?functions=getBanks,bankById +// |${getObpApiRoot}/v4.0.0$endpointBankIdPath/resource-docs/v4.0.0/obp?locale=es_ES +// |${getObpApiRoot}/v4.0.0$endpointBankIdPath/resource-docs/v4.0.0/obp?content=static,dynamic,all +// |${getObpApiRoot}/v4.0.0$endpointBankIdPath/resource-docs/v4.0.0/obp?api-collection-id=4e866c86-60c3-4268-a221-cb0bbf1ad221 +// | +// |
    +// |
  • operation_id is concatenation of "v", version and function and should be unique (used for DOM element IDs etc. maybe used to link to source code)
  • +// |
  • version references the version that the API call is defined in.
  • +// |
  • function is the (scala) partial function that implements this endpoint. It is unique per version of the API.
  • +// |
  • request_url is empty for the root call, else the path. It contains the standard prefix (e.g. /obp) and the implemented version (the version where this endpoint was defined) e.g. /obp/v1.2.0/resource
  • +// |
  • specified_url (recommended to use) is empty for the root call, else the path. It contains the standard prefix (e.g. /obp) and the version specified in the call e.g. /obp/v3.1.0/resource. In OBP, endpoints are first made available at the request_url, but the same resource (function call) is often made available under later versions (specified_url). To access the latest version of all endpoints use the latest version available on your OBP instance e.g. /obp/v3.1.0 - To get the original version use the request_url. We recommend to use the specified_url since non semantic improvements are more likely to be applied to later implementations of the call.
  • +// |
  • summary is a short description inline with the swagger terminology.
  • +// |
  • description may contain html markup (generated from markdown on the server).
  • +// |
+// """ +// } +// +// +// localResourceDocs += ResourceDoc( +// getResourceDocsObp, +// implementedInApiVersion, +// "getResourceDocsObp", +// "GET", +// "/resource-docs/API_VERSION/obp", +// "Get Resource Docs.", +// getResourceDocsDescription(false), +// EmptyBody, +// EmptyBody, +// UnknownError :: Nil, +// List(apiTagDocumentation, apiTagApi), +// Some(List(canReadResourceDoc)) +// ) +// +// def resourceDocsRequireRole = APIUtil.getPropsAsBoolValue("resource_docs_requires_role", false) +// // Provides resource documents so that API Explorer (or other apps) can display API documentation +// // Note: description uses html markup because original markdown doesn't easily support "_" and there are multiple versions of markdown. +// lazy val getResourceDocsObp : OBPEndpoint = { +// case "resource-docs" :: requestedApiVersionString :: "obp" :: Nil JsonGet _ => { +// val (tags, partialFunctions, locale, contentParam, apiCollectionIdParam) = ResourceDocsAPIMethodsUtil.getParams() +// cc => +// implicit val ec = EndpointContext(Some(cc)) +// getApiLevelResourceDocs(cc,requestedApiVersionString, tags, partialFunctions, locale, contentParam, apiCollectionIdParam,false) +// } +// } +// +// // Note: getResourceDocsObpV400 intentionally has NO ResourceDoc registration. +// // It shares the URL "/resource-docs/API_VERSION/obp" + GET with getResourceDocsObp +// // (registered above). One ResourceDoc entry per (URL, verb) is enough; registering +// // both produced a duplicate that broke the v7 aggregation dedup. +// lazy val getResourceDocsObpV400 : OBPEndpoint = { +// case "resource-docs" :: requestedApiVersionString :: "obp" :: Nil JsonGet _ => { +// val (tags, partialFunctions, locale, contentParam, apiCollectionIdParam) = ResourceDocsAPIMethodsUtil.getParams() +// cc => +// implicit val ec = EndpointContext(Some(cc)) +// getApiLevelResourceDocs(cc,requestedApiVersionString, tags, partialFunctions, locale, contentParam, apiCollectionIdParam,true) +// } +// } +// +// //API level just mean, this response will be forward to liftweb directly. +// private def getApiLevelResourceDocs( +// cc: CallContext, +// requestedApiVersionString: String, +// tags: Option[List[ResourceDocTag]], +// partialFunctions: Option[List[String]], +// locale: Option[String], +// contentParam: Option[ContentParam], +// apiCollectionIdParam: Option[String], +// isVersion4OrHigher: Boolean, +// ) = { +// for { +// (u: Box[User], callContext: Option[CallContext]) <- resourceDocsRequireRole match { +// case false => anonymousAccess(cc) +// case true => authenticatedAccess(cc) // If set resource_docs_requires_role=true, we need check the authentication +// } +// _ <- resourceDocsRequireRole match { +// case false => Future(()) +// case true => // If set resource_docs_requires_role=true, we need check the roles as well +// NewStyle.function.hasAtLeastOneEntitlement(failMsg = UserHasMissingRoles + canReadResourceDoc.toString)("", u.map(_.userId).getOrElse(""), ApiRole.canReadResourceDoc :: Nil, cc.callContext) +// } +// requestedApiVersion <- NewStyle.function.tryons(s"$InvalidApiVersionString $requestedApiVersionString", 400, callContext) {ApiVersionUtils.valueOf(requestedApiVersionString)} +// _ <- Helper.booleanToFuture(s"$ApiVersionNotSupported $requestedApiVersionString", 400, 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) +// } +// cacheKey = APIUtil.createResourceDocCacheKey( +// None, +// requestedApiVersionString, +// tags, +// partialFunctions, +// locale, +// contentParam, +// apiCollectionIdParam, +// Some(isVersion4OrHigher) +// ) +// json <- locale match { +// case _ if (apiCollectionIdParam.isDefined) => +// NewStyle.function.tryons(s"$UnknownError Can not prepare OBP resource docs.", 500, callContext) { +// val operationIds = MappedApiCollectionEndpointsProvider.getApiCollectionEndpoints(apiCollectionIdParam.getOrElse("")).map(_.operationId).map(getObpFormatOperationId) +// val resourceDocs = ResourceDoc.getResourceDocs(operationIds) +// val resourceDocsJson = JSONFactory1_4_0.createResourceDocsJson(resourceDocs, isVersion4OrHigher, locale, includeTechnology = includeTechnologyInResponse) +// val resourceDocsJsonJValue = Full(resourceDocsJsonToJsonResponse(resourceDocsJson)) +// resourceDocsJsonJValue.map(successJsonResponse(_)) +// } +// case _ => +// contentParam match { +// case Some(DYNAMIC) =>{ +// NewStyle.function.tryons(s"$UnknownError Can not prepare OBP resource docs.", 500, callContext) { +// val cacheValueFromRedis = Caching.getDynamicResourceDocCache(cacheKey) +// val dynamicDocs: Box[JValue] = +// if (cacheValueFromRedis.isDefined) { +// Full(json.parse(cacheValueFromRedis.get)) +// } else { +// val resourceDocJson = getResourceDocsObpDynamicCached(tags, partialFunctions, locale, None, false) +// val resourceDocJsonJValue = resourceDocJson.map(resourceDocsJsonToJsonResponse).head +// val jsonString = json.compactRender(resourceDocJsonJValue) +// Caching.setDynamicResourceDocCache(cacheKey, jsonString) +// Full(resourceDocJsonJValue) +// } +// dynamicDocs.map(successJsonResponse(_)) +// } +// } +// case Some(STATIC) => { +// NewStyle.function.tryons(s"$UnknownError Can not prepare OBP resource docs.", 500, callContext) { +// val cacheValueFromRedis = Caching.getStaticResourceDocCache(cacheKey) +// val staticDocs: Box[JValue] = +// if (cacheValueFromRedis.isDefined) { +// Full(json.parse(cacheValueFromRedis.get)) +// } else { +// val resourceDocJson = getStaticResourceDocsObpCached(requestedApiVersionString, tags, partialFunctions, locale, isVersion4OrHigher) +// val resourceDocJsonJValue = resourceDocJson.map(resourceDocsJsonToJsonResponse).head +// val jsonString = json.compactRender(resourceDocJsonJValue) +// Caching.setStaticResourceDocCache(cacheKey, jsonString) +// Full(resourceDocJsonJValue) +// } +// staticDocs.map(successJsonResponse(_)) +// } +// } +// case _ => { +// NewStyle.function.tryons(s"$UnknownError Can not prepare OBP resource docs.", 500, callContext) { +// val cacheValueFromRedis = Caching.getAllResourceDocCache(cacheKey) +// val bothStaticAndDyamicDocs: Box[JValue] = +// if (cacheValueFromRedis.isDefined) { +// Full(json.parse(cacheValueFromRedis.get)) +// } else { +// val resourceDocJson = getAllResourceDocsObpCached(requestedApiVersionString, tags, partialFunctions, locale, contentParam, isVersion4OrHigher) +// val resourceDocJsonJValue = resourceDocJson.map(resourceDocsJsonToJsonResponse).head +// val jsonString = json.compactRender(resourceDocJsonJValue) +// Caching.setAllResourceDocCache(cacheKey, jsonString) +// Full(resourceDocJsonJValue) +// } +// bothStaticAndDyamicDocs.map(successJsonResponse(_)) +// } +// } +// } +// } +// } yield { +// (json, HttpCode.`200`(callContext)) +// } +// } + +// localResourceDocs += ResourceDoc( +// getBankLevelDynamicResourceDocsObp, +// implementedInApiVersion, +// nameOf(getBankLevelDynamicResourceDocsObp), +// "GET", +// "/banks/BANK_ID/resource-docs/API_VERSION/obp", +// "Get Bank Level Dynamic Resource Docs.", +// getResourceDocsDescription(true), +// EmptyBody, +// EmptyBody, +// UnknownError :: Nil, +// List(apiTagDocumentation, apiTagApi), +// Some(List(canReadDynamicResourceDocsAtOneBank)) +// ) +// +// // Provides resource documents so that API Explorer (or other apps) can display API documentation +// // Note: description uses html markup because original markdown doesn't easily support "_" and there are multiple versions of markdown. +// def getBankLevelDynamicResourceDocsObp : OBPEndpoint = { +// case "banks" :: bankId :: "resource-docs" :: requestedApiVersionString :: "obp" :: Nil JsonGet _ => { +// val (tags, partialFunctions, locale, contentParam, apiCollectionIdParam) = ResourceDocsAPIMethodsUtil.getParams() +// cc => +// for { +// (u: Box[User], callContext: Option[CallContext]) <- resourceDocsRequireRole match { +// case false => anonymousAccess(cc) +// case true => authenticatedAccess(cc) // If set resource_docs_requires_role=true, we need check the authentication +// } +// _ <- 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) +// } +// (_, callContext) <- NewStyle.function.getBank(BankId(bankId), Option(cc)) +// _ <- resourceDocsRequireRole match { +// case false => Future(()) +// case true => // If set resource_docs_requires_role=true, we need check the the roles as well +// NewStyle.function.hasAtLeastOneEntitlement(failMsg = UserHasMissingRoles + ApiRole.canReadDynamicResourceDocsAtOneBank.toString)( +// bankId, u.map(_.userId).getOrElse(""), ApiRole.canReadDynamicResourceDocsAtOneBank::Nil, cc.callContext +// ) +// } +// requestedApiVersion <- NewStyle.function.tryons(s"$InvalidApiVersionString $requestedApiVersionString", 400, callContext) {ApiVersionUtils.valueOf(requestedApiVersionString)} +// cacheKey = APIUtil.createResourceDocCacheKey( +// Some(bankId), +// requestedApiVersionString, +// tags, +// partialFunctions, +// locale, +// contentParam, +// apiCollectionIdParam, +// None) +// json <- NewStyle.function.tryons(s"$UnknownError Can not create dynamic resource docs.", 400, callContext) { +// val cacheValueFromRedis = Caching.getDynamicResourceDocCache(cacheKey) +// if (cacheValueFromRedis.isDefined) { +// json.parse(cacheValueFromRedis.get) +// } else { +// val resourceDocJson = getResourceDocsObpDynamicCached(tags, partialFunctions, locale, None, false) +// val resourceDocJsonJValue = resourceDocJson.map(resourceDocsJsonToJsonResponse).head +// val jsonString = json.compactRender(resourceDocJsonJValue) +// Caching.setDynamicResourceDocCache(cacheKey, jsonString) +// resourceDocJsonJValue +// } +// } +// } yield { +// (Full(json), HttpCode.`200`(callContext)) +// } +// } +// } + + +// localResourceDocs += ResourceDoc( +// getResourceDocsSwagger, +// implementedInApiVersion, +// "getResourceDocsSwagger", +// "GET", +// "/resource-docs/API_VERSION/swagger", +// "Get Swagger documentation", +// s"""Returns documentation about the RESTful resources on this server in Swagger format. +// | +// |API_VERSION is the version you want documentation about e.g. v3.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') +// | +// |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}/v3.1.0/resource-docs/v3.1.0/swagger +// |${getObpApiRoot}/v3.1.0/resource-docs/v3.1.0/swagger?tags=Account,Bank +// |${getObpApiRoot}/v3.1.0/resource-docs/v3.1.0/swagger?functions=getBanks,bankById +// |${getObpApiRoot}/v3.1.0/resource-docs/v3.1.0/swagger?tags=Account,Bank,PSD2&functions=getBanks,bankById +// | +// """, +// EmptyBody, +// EmptyBody, +// UnknownError :: Nil, +// List(apiTagDocumentation, apiTagApi) +// ) +// +// // Note: Swagger format requires special character escaping because it builds JSON via string concatenation (unlike OBP/OpenAPI formats which use case class serialization) +// +// def getResourceDocsSwagger : OBPEndpoint = { +// case "resource-docs" :: requestedApiVersionString :: "swagger" :: Nil JsonGet _ => { +// cc => { +// implicit val ec = EndpointContext(Some(cc)) +// val (resourceDocTags, partialFunctions, locale, contentParam, apiCollectionIdParam) = ResourceDocsAPIMethodsUtil.getParams() +// for { +// (u: Box[User], callContext: Option[CallContext]) <- if (resourceDocsRequireRole) { +// authenticatedAccess(cc) +// } else { +// anonymousAccess(cc) +// } +// _ <- if (resourceDocsRequireRole) { +// NewStyle.function.hasAtLeastOneEntitlement(failMsg = UserHasMissingRoles + canReadResourceDoc.toString)("", u.map(_.userId).getOrElse(""), ApiRole.canReadResourceDoc :: Nil, cc.callContext) +// } else { +// Future(()) +// } +// 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( +// None, +// requestedApiVersionString, +// resourceDocTags, +// partialFunctions, +// locale, +// contentParam, +// apiCollectionIdParam, +// Some(isVersion4OrHigher) +// ) +// cacheValueFromRedis = Caching.getStaticSwaggerDocCache(cacheKey) +// +// swaggerJValue <- if (cacheValueFromRedis.isDefined) { +// NewStyle.function.tryons(s"$UnknownError Can not convert internal swagger file from cache.", 400, cc.callContext) {json.parse(cacheValueFromRedis.get)} +// } else { +// NewStyle.function.tryons(s"$UnknownError Can not convert internal swagger 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, includeTechnology = includeTechnologyInResponse) +// 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 +// } +// } +// } +// convertResourceDocsToSwaggerJvalueAndSetCache(cacheKey, requestedApiVersionString, resourceDocsJsonFiltered) +// } +// } +// } yield { +// (swaggerJValue, HttpCode.`200`(cc.callContext)) +// } +// } +// } +// } + +// 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 +// | +// |## Query Parameters +// | +// |You may filter this endpoint using the following optional query parameters: +// | +// |**tags** - Filter by endpoint tags (comma-separated list) +// | • Example: ?tags=Account,Bank or ?tags=Account-Firehose +// | • All endpoints are given one or more tags which are used for grouping +// | • Empty values will return error OBP-10053 +// | +// |**functions** - Filter by function names (comma-separated list) +// | • Example: ?functions=getBanks,bankById +// | • Each endpoint is implemented in the OBP Scala code by a 'function' +// | • Empty values will return error OBP-10054 +// | +// |**content** - Filter by endpoint type +// | • Values: static, dynamic, all (case-insensitive) +// | • static: Only show static/core API endpoints +// | • dynamic: Only show dynamic/custom endpoints +// | • all: Show both static and dynamic endpoints (default) +// | • Invalid values will return error OBP-10052 +// | +// |**locale** - Language for localized documentation +// | • Example: ?locale=en_GB or ?locale=es_ES +// | • Supported locales: en_GB, es_ES, ro_RO +// | • Invalid locales will return error OBP-10041 +// | +// |**api-collection-id** - Filter by API collection UUID +// | • Example: ?api-collection-id=4e866c86-60c3-4268-a221-cb0bbf1ad221 +// | • Returns only endpoints belonging to the specified collection +// | • Empty values will return error OBP-10055 +// | +// |This endpoint generates OpenAPI 3.1 compliant documentation with modern JSON Schema support. +// | +// |For YAML format, use the corresponding endpoint: /resource-docs/API_VERSION/openapi.yaml +// | +// |See the Resource Doc endpoint for more information. +// | +// |Note: Resource Docs are cached, TTL is ${GET_DYNAMIC_RESOURCE_DOCS_TTL} seconds +// | +// |## Examples +// | +// |Basic usage: +// |${getObpApiRoot}/v6.0.0/resource-docs/v6.0.0/openapi +// | +// |Filter by tags: +// |${getObpApiRoot}/v6.0.0/resource-docs/v6.0.0/openapi?tags=Account,Bank +// |${getObpApiRoot}/v6.0.0/resource-docs/v6.0.0/openapi?tags=Account-Firehose +// | +// |Filter by content type: +// |${getObpApiRoot}/v6.0.0/resource-docs/v6.0.0/openapi?content=static +// |${getObpApiRoot}/v6.0.0/resource-docs/v6.0.0/openapi?content=dynamic +// | +// |Filter by functions: +// |${getObpApiRoot}/v6.0.0/resource-docs/v6.0.0/openapi?functions=getBanks,bankById +// | +// |Combine multiple parameters: +// |${getObpApiRoot}/v6.0.0/resource-docs/v6.0.0/openapi?content=static&tags=Account-Firehose +// |${getObpApiRoot}/v6.0.0/resource-docs/v6.0.0/openapi?tags=Account,Bank,PSD2&functions=getBanks,bankById +// |${getObpApiRoot}/v6.0.0/resource-docs/v6.0.0/openapi?content=static&locale=en_GB&tags=Account +// | +// |Filter by API collection: +// |${getObpApiRoot}/v6.0.0/resource-docs/v6.0.0/openapi?api-collection-id=4e866c86-60c3-4268-a221-cb0bbf1ad221 +// | +// """, +// EmptyBody, +// EmptyBody, +// InvalidApiVersionString :: +// ApiVersionNotSupported :: +// InvalidLocale :: +// InvalidContentParameter :: +// InvalidTagsParameter :: +// InvalidFunctionsParameter :: +// InvalidApiCollectionIdParameter :: +// UnknownError :: Nil, +// List(apiTagDocumentation, apiTagApi) +// ) +// +// // Note: OpenAPI 3.1 YAML endpoint (/resource-docs/API_VERSION/openapi.yaml) +// // is implemented using Lift's serve mechanism in ResourceDocs140.scala to properly +// // handle YAML content type. It provides the same functionality as the JSON endpoint +// // but returns OpenAPI documentation in YAML format instead of JSON. +// +// /** +// * OpenAPI 3.1 endpoint with comprehensive parameter validation. +// * +// * This endpoint generates OpenAPI 3.1 documentation with the following validated query parameters: +// * - tags: Comma-separated list of tags to filter endpoints (e.g., ?tags=Account,Bank) +// * - functions: Comma-separated list of function names to filter endpoints +// * - content: Filter type - "static", "dynamic", or "all" +// * - locale: Language code for localization (e.g., "en_GB", "es_ES") +// * - api-collection-id: UUID to filter by specific API collection +// * +// * Parameter validation guards ensure: +// * - Empty parameters (e.g., ?tags=) return 400 error +// * - Invalid content values return 400 error with valid options +// * - All parameters are properly trimmed and sanitized +// * +// * Examples: +// * - ?content=static&tags=Account-Firehose +// * - ?tags=Account,Bank&functions=getBanks,bankById +// * - ?content=dynamic&locale=en_GB +// */ +// def getResourceDocsOpenAPI31 : OBPEndpoint = { +// case "resource-docs" :: requestedApiVersionString :: "openapi" :: Nil JsonGet _ => { +// cc => { +// implicit val ec = EndpointContext(Some(cc)) +// +// // Early validation for empty parameters using underlying S to bypass ObpS filtering +// if (S.param("tags").exists(_.trim.isEmpty)) { +// Full(errorJsonResponse(InvalidTagsParameter, 400)) +// } else if (S.param("functions").exists(_.trim.isEmpty)) { +// Full(errorJsonResponse(InvalidFunctionsParameter, 400)) +// } else if (S.param("api-collection-id").exists(_.trim.isEmpty)) { +// Full(errorJsonResponse(InvalidApiCollectionIdParameter, 400)) +// } else { +// val (resourceDocTags, partialFunctions, locale, contentParam, apiCollectionIdParam) = ResourceDocsAPIMethodsUtil.getParams() +// for { +// // Validate content parameter if provided +// _ <- if (S.param("content").isDefined && contentParam.isEmpty) { +// Helper.booleanToFuture(failMsg = InvalidContentParameter, cc = cc.callContext) { +// false +// } +// } else { +// Future.successful(true) +// } +// (u: Box[User], callContext: Option[CallContext]) <- if (resourceDocsRequireRole) { +// authenticatedAccess(cc) +// } else { +// anonymousAccess(cc) +// } +// _ <- if (resourceDocsRequireRole) { +// NewStyle.function.hasAtLeastOneEntitlement(failMsg = UserHasMissingRoles + canReadResourceDoc.toString)("", u.map(_.userId).getOrElse(""), ApiRole.canReadResourceDoc :: Nil, cc.callContext) +// } else { +// Future(()) +// } +// 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, includeTechnology = includeTechnologyInResponse) +// 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)) +// } +// } +// } +// } +// } // Note: The OpenAPI 3.1 YAML endpoint (/resource-docs/API_VERSION/openapi.yaml) // is implemented using Lift's serve mechanism in ResourceDocs140.scala to properly diff --git a/obp-api/src/test/scala/code/api/ResourceDocs1_4_0/ResourceDocsTest.scala b/obp-api/src/test/scala/code/api/ResourceDocs1_4_0/ResourceDocsTest.scala index 5c9d94fd11..12c52a1d34 100644 --- a/obp-api/src/test/scala/code/api/ResourceDocs1_4_0/ResourceDocsTest.scala +++ b/obp-api/src/test/scala/code/api/ResourceDocs1_4_0/ResourceDocsTest.scala @@ -20,8 +20,8 @@ import scala.xml.NodeSeq class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with DefaultUsers{ object VersionOfApi extends Tag(ApiVersion.v1_4_0.toString) - object ApiEndpoint1 extends Tag(nameOf(ImplementationsResourceDocs.getResourceDocsObp)) - object ApiEndpoint2 extends Tag(nameOf(ImplementationsResourceDocs.getBankLevelDynamicResourceDocsObp)) + object ApiEndpoint1 extends Tag("getResourceDocsObp") + object ApiEndpoint2 extends Tag("getBankLevelDynamicResourceDocsObp") private val v600 = ApiVersion.v6_0_0.toString private val fq600 = ApiVersion.v6_0_0.fullyQualifiedVersion diff --git a/obp-api/src/test/scala/code/api/ResourceDocs1_4_0/SwaggerDocsTest.scala b/obp-api/src/test/scala/code/api/ResourceDocs1_4_0/SwaggerDocsTest.scala index d015851682..10e492f865 100644 --- a/obp-api/src/test/scala/code/api/ResourceDocs1_4_0/SwaggerDocsTest.scala +++ b/obp-api/src/test/scala/code/api/ResourceDocs1_4_0/SwaggerDocsTest.scala @@ -20,7 +20,7 @@ import scala.xml.NodeSeq class SwaggerDocsTest extends ResourceDocsV140ServerSetup with PropsReset with DefaultUsers{ object VersionOfApi extends Tag(ApiVersion.v1_4_0.toString) - object ApiEndpoint1 extends Tag(nameOf(ImplementationsResourceDocs.getResourceDocsSwagger)) + object ApiEndpoint1 extends Tag("getResourceDocsSwagger") override def beforeEach() = { super.beforeEach() From 76f758995613113740425313b8ca471eb334d276 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 27 May 2026 17:21:23 +0200 Subject: [PATCH 4/4] refactor(dynamic-entity): migrate runtime CRUD dispatch from Lift to native http4s Serve /obp/dynamic-entity/* with a new native http4s service (Http4sDynamicEntity) instead of the Lift OBPAPIDynamicEntity dispatch; remove dynamic-entity from LiftRules.statelessDispatch. Wired into Http4sApp.baseServices ahead of the Lift bridge. - Http4sDynamicEntity: generic (GET/POST/PUT/DELETE), public (anonymous GET) and community (authenticated GET) handlers ported from APIMethodsDynamicEntity, including the before/after authenticate interceptors (auth-type / query-param / header-key, and Force-Error / JSON-schema validation). Query-param filtering uses http4s multiParams. - Extract withBusinessDBTransaction from ResourceDocMiddleware into RequestScopeConnection so the new service (which bypasses ResourceDocMiddleware, since the dynamic-entity set is runtime-mutable) reuses the same request-scoped commit/rollback/close; middleware delegates. - ErrorResponseConverter handles JsonResponseException (raised by the auth/session chain for Force-Error / JSON-schema and by NewStyle), mapping to the embedded response code (mirroring Lift's OBPRestHelper) instead of returning 500. - Comment out the Lift dispatch/handlers (OBPAPIDynamicEntity routes -> Nil; the three APIMethodsDynamicEntity handlers) per the revert-and-comment convention; objects kept as accessors for resource-docs aggregation. ResourceDocsAPIMethods: dynamic-entity skips the Lift-route-class filter. - Tests: add DynamicEntityFilterAndBankAccessTest (query-param filtering + bank-level public/community characterization); fix a nameOf() reference in ForceErrorValidationTest. DynamicEndpoint (proxy + runtime-compiled resource docs) is a separate task, untouched. --- .../ResourceDocsAPIMethods.scala | 1 + .../entity/APIMethodsDynamicEntity.scala | 7 + .../dynamic/entity/Http4sDynamicEntity.scala | 358 ++++++++++++++++++ .../dynamic/entity/OBPAPIDynamicEntity.scala | 43 ++- .../main/scala/code/api/util/APIUtil.scala | 5 +- .../util/http4s/ErrorResponseConverter.scala | 25 ++ .../code/api/util/http4s/Http4sApp.scala | 5 + .../util/http4s/RequestScopeConnection.scala | 49 ++- .../util/http4s/ResourceDocMiddleware.scala | 60 +-- .../api/v4_0_0/ForceErrorValidationTest.scala | 4 +- ...DynamicEntityFilterAndBankAccessTest.scala | 275 ++++++++++++++ 11 files changed, 754 insertions(+), 78 deletions(-) create mode 100644 obp-api/src/main/scala/code/api/dynamic/entity/Http4sDynamicEntity.scala create mode 100644 obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityFilterAndBankAccessTest.scala 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 70546d3f8e..1815eba815 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 @@ -188,6 +188,7 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth case ApiVersion.v2_0_0 => resourceDocs // fully on http4s — no Lift route filter case ApiVersion.v1_4_0 => resourceDocs // fully on http4s — no Lift route filter case ApiVersion.v1_3_0 => resourceDocs // fully on http4s — no Lift route filter + case ApiVersion.`dynamic-entity` => resourceDocs // runtime CRUD now on Http4sDynamicEntity; routes are Nil, skip Lift-route filter case _ => resourceDocs.filter(rd => versionRoutesClasses.contains(rd.partialFunction.getClass)) } diff --git a/obp-api/src/main/scala/code/api/dynamic/entity/APIMethodsDynamicEntity.scala b/obp-api/src/main/scala/code/api/dynamic/entity/APIMethodsDynamicEntity.scala index 4fc7d6e184..a8aa600f91 100644 --- a/obp-api/src/main/scala/code/api/dynamic/entity/APIMethodsDynamicEntity.scala +++ b/obp-api/src/main/scala/code/api/dynamic/entity/APIMethodsDynamicEntity.scala @@ -70,6 +70,11 @@ trait APIMethodsDynamicEntity { } } + /* DISABLED — DynamicEntity runtime CRUD migrated to code.api.dynamic.entity.Http4sDynamicEntity + (wired into Http4sApp.baseServices). These Lift OBPEndpoint handlers are no longer registered + (OBPAPIDynamicEntity.routes = Nil and dynamic-entity removed from LiftRules.statelessDispatch). + Retained, commented out, for historical reference per the repo's revert-and-comment convention. + lazy val genericEndpoint: OBPEndpoint = { case EntityName(bankId, entityName, id, isPersonalEntity) JsonGet req => { cc => val listName = StringHelpers.snakify(entityName).replaceFirst("[-_]*$", "_list") @@ -513,6 +518,8 @@ trait APIMethodsDynamicEntity { } } } + */ + // end DISABLED handlers (migrated to Http4sDynamicEntity) } } diff --git a/obp-api/src/main/scala/code/api/dynamic/entity/Http4sDynamicEntity.scala b/obp-api/src/main/scala/code/api/dynamic/entity/Http4sDynamicEntity.scala new file mode 100644 index 0000000000..ddc30ca64d --- /dev/null +++ b/obp-api/src/main/scala/code/api/dynamic/entity/Http4sDynamicEntity.scala @@ -0,0 +1,358 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2025, 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.dynamic.entity + +import cats.data.{Kleisli, OptionT} +import cats.effect.IO +import code.DynamicData.{DynamicData, DynamicDataProvider} +import code.api.Constant.PARAM_LOCALE +import code.api.dynamic.entity.helper.{CommunityEntityName, DynamicEntityHelper, DynamicEntityInfo, EntityName, PublicEntityName} +import code.api.util.APIUtil._ +import code.api.util.ErrorMessages._ +import code.api.util.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps} +import code.api.util.http4s.{Http4sCallContextBuilder, Http4sRequestAttributes, RequestScopeConnection} +import code.api.util.{CallContext, CustomJsonFormats, NewStyle} +import code.util.Helper +import code.util.Helper.MdcLoggable +import com.openbankproject.commons.ExecutionContext.Implicits.global +import com.openbankproject.commons.model._ +import com.openbankproject.commons.model.enums.DynamicEntityOperation +import com.openbankproject.commons.model.enums.DynamicEntityOperation._ +import com.openbankproject.commons.util.{ApiShortVersions, ApiStandards, JsonUtils} +import net.liftweb.common._ +import net.liftweb.json.JsonAST.{JArray, JBool, JObject, JValue} +import net.liftweb.json.JsonDSL._ +import net.liftweb.json._ +import net.liftweb.util.StringHelpers +import org.apache.commons.lang3.StringUtils +import org.http4s.{HttpRoutes, Method, Request, Response} + +import scala.concurrent.Future + +/** + * Native http4s service for DynamicEntity runtime CRUD (under /obp/dynamic-entity/). + * + * Replaces the Lift OBPAPIDynamicEntity dispatch (genericEndpoint / publicEndpoint / + * communityEndpoint). The business logic is a faithful port of + * [[code.api.dynamic.entity.APIMethodsDynamicEntity]] — same `authenticatedAccess` / + * `anonymousAccess` / `getBank` / `hasEntitlement` / `invokeDynamicConnector` calls, + * same before/after authenticate interceptors, same response shapes and status codes. + * + * Notes on the port: + * - The dynamic-entity set is runtime-mutable (`DynamicEntityHelper.definitionsMap` is + * re-queried per request), so this service does NOT use `ResourceDocMiddleware` + * (whose ResourceDoc index is built once at startup). Auth / role / bank checks are + * performed inline, exactly as the Lift handlers did. + * - The before/after authenticate interceptors carry auth-type / query-param / header-key + * validation (before) and Force-Error / JSON-schema validation (after) — see + * APIUtil.beforeAuthenticateInterceptors / afterAuthenticateInterceptors. They are + * invoked here exactly as the Lift handlers invoked them; the resulting Box[JsonResponse] + * is reduced to (message, code) via JsonResponseExtractor and re-raised through + * booleanToFuture (no Lift JsonResponse rendering). + * - `CallContext` is built via `Http4sCallContextBuilder.fromRequest` and attached to the + * request so the `EndpointHelpers` (error conversion + metric) can be reused. + * - Mutating verbs (POST/PUT/DELETE) run inside + * `RequestScopeConnection.withBusinessDBTransaction`; GET runs on auto-commit. + */ +object Http4sDynamicEntity extends MdcLoggable { + + private type HttpF[A] = OptionT[IO, A] + + implicit val formats: Formats = CustomJsonFormats.formats + + private val apiStandard = ApiStandards.obp.toString + private val apiVersionString = ApiShortVersions.`dynamic-entity`.toString // "dynamic-entity" + + // ----- helpers ported from APIMethodsDynamicEntity ----- + + private def unboxResult[T: Manifest](box: Box[T], entityName: String): T = { + if (box.isInstanceOf[Failure]) { + val failure = box.asInstanceOf[Failure] + // change the internal db column name 'dynamicdataid' to entity's id name + val msg = failure.msg.replace(DynamicData.DynamicDataId.dbColumnName, StringUtils.uncapitalize(entityName) + "Id") + val changedMsgFailure = failure.copy(msg = s"$InternalServerError $msg") + fullBoxOrException[T](changedMsgFailure) + } + box.openOrThrowException("impossible error") + } + + /** + * http4s equivalent of the Lift `filterDynamicObjects(resultList, req)`: filter GET-all + * results by query parameters (AND across keys, OR across a key's values), excluding the + * `locale` (PARAM_LOCALE) param. Lift read `req.params`; here we read the http4s query + * multiParams (same `Map[String, List[String]]` shape). + */ + private def filterDynamicObjects(resultList: JArray, params: Map[String, List[String]]): JArray = { + if (params.isEmpty) resultList + else { + val filtered = resultList.arr.filter { jValue => + params.filter(_._1 != PARAM_LOCALE).forall { case (path, values) => + values.exists(JsonUtils.isFieldEquals(jValue, path, _)) + } + } + JArray(filtered) + } + } + + private def queryParams(req: Request[IO]): Map[String, List[String]] = + req.uri.query.multiParams.map { case (k, vs) => k -> vs.toList } + + private def listName(entityName: String): String = StringHelpers.snakify(entityName).replaceFirst("[-_]*$", "_list") + private def singleName(entityName: String): String = StringHelpers.snakify(entityName).replaceFirst("[-_]*$", "") + + private def wrapBankId(bankId: Option[String], result: JObject): JObject = + if (bankId.isDefined) (("bank_id" -> bankId.getOrElse("")): JObject) merge result else result + + private def notFoundMsg(entityName: String, id: String, bankId: Option[String]): String = + s"$EntityNotFoundByEntityId Entity: '$entityName', entityId: '$id'" + bankId.map(b => s", bank_id: '$b'").getOrElse("") + + /** Resolve bankId to a Bank (404 if missing) for bank-level entities; no-op otherwise. */ + private def bankCheck(bankId: Option[String], cc: Option[CallContext]): Future[(Any, Option[CallContext])] = + if (bankId.isDefined) NewStyle.function.getBank(BankId(bankId.get), cc).map { case (b, c) => (b, c) } + else Future.successful(("", cc)) + + /** + * Enrich the CallContext with the dynamic-entity operationId + ResourceDoc, mirroring the + * Lift handlers. Used for rate limiting (authenticatedAccess injects operationId), metrics, + * and the interceptors (Force-Error validation reads resourceDocument.errorResponseBodies). + * The name key follows the Lift convention: generic system/bank -> `Entity` / `Entity(bankId)`, + * personal -> `MyEntity...`, public -> `PublicEntity...`, community -> `CommunityEntity...`. + */ + private def enrichCallContext(cc: CallContext, operation: DynamicEntityOperation, entityName: String, bankId: Option[String], scope: String): CallContext = { + val splitNameWithBankId = if (bankId.isDefined) s"$entityName(${bankId.getOrElse("")})" else entityName + val key = scope match { + case "my" => s"My$splitNameWithBankId" + case "public" => s"Public$splitNameWithBankId" + case "community" => s"Community$splitNameWithBankId" + case _ => splitNameWithBankId + } + val resourceDoc = DynamicEntityHelper.operationToResourceDoc.get(operation -> key) + cc.copy(operationId = Some(resourceDoc.map(_.operationId).orNull), resourceDocument = resourceDoc) + } + + // Before-authenticate interceptors: auth-type / query-param / request-header-key validation. + // After-authenticate interceptors: Force-Error / JSON-schema validation. + // Both reduce a Box[JsonResponse] to a Box[ErrorMessage(code, message)] via JsonResponseExtractor. + private def beforeIntercept(cc: CallContext, operationId: String): Box[ErrorMessage] = + beforeAuthenticateInterceptResult(Option(cc), operationId).collect { case JsonResponseExtractor(message, code) => ErrorMessage(code, message) } + + private def afterIntercept(cc: Option[CallContext], operationId: String): Box[ErrorMessage] = + afterAuthenticateInterceptResult(cc, operationId).collect { case JsonResponseExtractor(message, code) => ErrorMessage(code, message) } + + private def failIf(error: Box[ErrorMessage], cc: Option[CallContext]): Future[Box[Unit]] = + Helper.booleanToFuture(failMsg = error.map(_.message).orNull, failCode = error.map(_.code).openOr(400), cc = cc) { error.isEmpty } + + // ----- generic endpoint (authenticated, system / bank / personal) ----- + + private def genericGet(req: Request[IO], bankId: Option[String], entityName: String, id: String, isPersonalEntity: Boolean): IO[Response[IO]] = + EndpointHelpers.executeAndRespond(req) { cc => + val isGetAll = StringUtils.isBlank(id) + val operation: DynamicEntityOperation = if (isGetAll) GET_ALL else GET_ONE + val callContext0 = enrichCallContext(cc, operation, entityName, bankId, if (isPersonalEntity) "my" else "") + val operationId = callContext0.operationId.orNull + for { + _ <- failIf(beforeIntercept(callContext0, operationId), Some(callContext0)) + (Full(u), callContext) <- authenticatedAccess(callContext0) + (_, callContext) <- bankCheck(bankId, callContext) + personalRequiresRole = DynamicEntityHelper.definitionsMap.get((bankId, entityName)).exists(_.personalRequiresRole) + _ <- if (isPersonalEntity && !personalRequiresRole) Future.successful(true) + else NewStyle.function.hasEntitlement(bankId.getOrElse(""), u.userId, DynamicEntityInfo.canGetRole(entityName, bankId), callContext) + _ <- failIf(afterIntercept(callContext, operationId), callContext) + (box, _) <- NewStyle.function.invokeDynamicConnector(operation, entityName, None, Option(id).filter(StringUtils.isNotBlank), bankId, None, Some(u.userId), isPersonalEntity, Some(cc)) + _ <- Helper.booleanToFuture(notFoundMsg(entityName, id, bankId), 404, cc = callContext) { box.isDefined } + } yield { + if (isGetAll) { + val resultList: JArray = unboxResult(box.asInstanceOf[Box[JArray]], entityName) + wrapBankId(bankId, (listName(entityName) -> filterDynamicObjects(resultList, queryParams(req)))) + } else { + val singleObject: JValue = unboxResult(box.asInstanceOf[Box[JValue]], entityName) + wrapBankId(bankId, (singleName(entityName) -> singleObject)) + } + } + } + + private def genericPost(req: Request[IO], bankId: Option[String], entityName: String, isPersonalEntity: Boolean): IO[Response[IO]] = + EndpointHelpers.executeFutureCreated(req) { + val cc = req.callContext + val callContext0 = enrichCallContext(cc, CREATE, entityName, bankId, if (isPersonalEntity) "my" else "") + val operationId = callContext0.operationId.orNull + for { + _ <- failIf(beforeIntercept(callContext0, operationId), Some(callContext0)) + (Full(u), callContext) <- authenticatedAccess(callContext0) + (_, callContext) <- bankCheck(bankId, callContext) + personalRequiresRole = DynamicEntityHelper.definitionsMap.get((bankId, entityName)).exists(_.personalRequiresRole) + _ <- if (isPersonalEntity && !personalRequiresRole) Future.successful(true) + else NewStyle.function.hasEntitlement(bankId.getOrElse(""), u.userId, DynamicEntityInfo.canCreateRole(entityName, bankId), callContext) + _ <- failIf(afterIntercept(callContext, operationId), callContext) + json <- NewStyle.function.tryons(InvalidJsonFormat, 400, callContext) { net.liftweb.json.parse(cc.httpBody.getOrElse("")) } + (box, _) <- NewStyle.function.invokeDynamicConnector(CREATE, entityName, Some(json.asInstanceOf[JObject]), None, bankId, None, Some(u.userId), isPersonalEntity, Some(cc)) + singleObject: JValue = unboxResult(box.asInstanceOf[Box[JValue]], entityName) + } yield wrapBankId(bankId, (singleName(entityName) -> singleObject)) + } + + private def genericPut(req: Request[IO], bankId: Option[String], entityName: String, id: String, isPersonalEntity: Boolean): IO[Response[IO]] = + EndpointHelpers.executeAndRespond(req) { cc => + val callContext0 = enrichCallContext(cc, UPDATE, entityName, bankId, if (isPersonalEntity) "my" else "") + val operationId = callContext0.operationId.orNull + for { + _ <- failIf(beforeIntercept(callContext0, operationId), Some(callContext0)) + (Full(u), callContext) <- authenticatedAccess(callContext0) + (_, callContext) <- bankCheck(bankId, callContext) + personalRequiresRole = DynamicEntityHelper.definitionsMap.get((bankId, entityName)).exists(_.personalRequiresRole) + _ <- if (isPersonalEntity && !personalRequiresRole) Future.successful(true) + else NewStyle.function.hasEntitlement(bankId.getOrElse(""), u.userId, DynamicEntityInfo.canUpdateRole(entityName, bankId), callContext) + _ <- failIf(afterIntercept(callContext, operationId), callContext) + json <- NewStyle.function.tryons(InvalidJsonFormat, 400, callContext) { net.liftweb.json.parse(cc.httpBody.getOrElse("")) } + (existing, _) <- NewStyle.function.invokeDynamicConnector(GET_ONE, entityName, None, Some(id), bankId, None, Some(u.userId), isPersonalEntity, Some(cc)) + _ <- Helper.booleanToFuture(notFoundMsg(entityName, id, bankId), 404, cc = callContext) { existing.isDefined } + (box: Box[JValue], _) <- NewStyle.function.invokeDynamicConnector(UPDATE, entityName, Some(json.asInstanceOf[JObject]), Some(id), bankId, None, Some(u.userId), isPersonalEntity, Some(cc)) + singleObject: JValue = unboxResult(box, entityName) + } yield wrapBankId(bankId, (singleName(entityName) -> singleObject)) + } + + private def genericDelete(req: Request[IO], bankId: Option[String], entityName: String, id: String, isPersonalEntity: Boolean): IO[Response[IO]] = + EndpointHelpers.executeAndRespond(req) { cc => + val callContext0 = enrichCallContext(cc, DELETE, entityName, bankId, if (isPersonalEntity) "my" else "") + val operationId = callContext0.operationId.orNull + for { + _ <- failIf(beforeIntercept(callContext0, operationId), Some(callContext0)) + (Full(u), callContext) <- authenticatedAccess(callContext0) + (_, callContext) <- bankCheck(bankId, callContext) + personalRequiresRole = DynamicEntityHelper.definitionsMap.get((bankId, entityName)).exists(_.personalRequiresRole) + _ <- if (isPersonalEntity && !personalRequiresRole) Future.successful(true) + else NewStyle.function.hasEntitlement(bankId.getOrElse(""), u.userId, DynamicEntityInfo.canDeleteRole(entityName, bankId), callContext) + _ <- failIf(afterIntercept(callContext, operationId), callContext) + (existing, _) <- NewStyle.function.invokeDynamicConnector(GET_ONE, entityName, None, Some(id), bankId, None, Some(u.userId), isPersonalEntity, Some(cc)) + _ <- Helper.booleanToFuture(notFoundMsg(entityName, id, bankId), 404, cc = callContext) { existing.isDefined } + (box, _) <- NewStyle.function.invokeDynamicConnector(DELETE, entityName, None, Some(id), bankId, None, Some(u.userId), isPersonalEntity, Some(cc)) + deleteResult: JBool = unboxResult(box.asInstanceOf[Box[JBool]], entityName) + } yield deleteResult + } + + // ----- public endpoint (anonymous, read-only; before-interceptors only, no role) ----- + + private def publicGet(req: Request[IO], bankId: Option[String], entityName: String, id: String): IO[Response[IO]] = + EndpointHelpers.executeAndRespond(req) { cc => + val isGetAll = StringUtils.isBlank(id) + val operation: DynamicEntityOperation = if (isGetAll) GET_ALL else GET_ONE + val callContext0 = enrichCallContext(cc, operation, entityName, bankId, "public") + val operationId = callContext0.operationId.orNull + for { + _ <- failIf(beforeIntercept(callContext0, operationId), Some(callContext0)) + (_, callContext) <- anonymousAccess(callContext0) + (_, callContext) <- bankCheck(bankId, callContext) + (box, _) <- NewStyle.function.invokeDynamicConnector(operation, entityName, None, Option(id).filter(StringUtils.isNotBlank), bankId, None, None, false, Some(cc)) + _ <- Helper.booleanToFuture(notFoundMsg(entityName, id, bankId), 404, cc = callContext) { box.isDefined } + } yield { + if (isGetAll) { + val resultList: JArray = unboxResult(box.asInstanceOf[Box[JArray]], entityName) + wrapBankId(bankId, (listName(entityName) -> filterDynamicObjects(resultList, queryParams(req)))) + } else { + val singleObject: JValue = unboxResult(box.asInstanceOf[Box[JValue]], entityName) + wrapBankId(bankId, (singleName(entityName) -> singleObject)) + } + } + } + + // ----- community endpoint (authenticated + CanGet role, read-only, ALL records) ----- + + private def communityGet(req: Request[IO], bankId: Option[String], entityName: String, id: String): IO[Response[IO]] = + EndpointHelpers.executeAndRespond(req) { cc => + val isGetAll = StringUtils.isBlank(id) + val operation: DynamicEntityOperation = if (isGetAll) GET_ALL else GET_ONE + val callContext0 = enrichCallContext(cc, operation, entityName, bankId, "community") + val operationId = callContext0.operationId.orNull + for { + _ <- failIf(beforeIntercept(callContext0, operationId), Some(callContext0)) + (Full(u), callContext) <- authenticatedAccess(callContext0) + (_, callContext) <- bankCheck(bankId, callContext) + _ <- NewStyle.function.hasEntitlement(bankId.getOrElse(""), u.userId, DynamicEntityInfo.canGetRole(entityName, bankId), callContext) + _ <- failIf(afterIntercept(callContext, operationId), callContext) + } yield { + if (isGetAll) { + val resultList: List[JObject] = DynamicDataProvider.connectorMethodProvider.vend.getAllDataJsonCommunity(bankId, entityName) + val resultArray = JArray(resultList) + wrapBankId(bankId, (listName(entityName) -> filterDynamicObjects(resultArray, queryParams(req)))) + } else { + val singleResult = DynamicDataProvider.connectorMethodProvider.vend.getCommunity(bankId, entityName, id) + val singleObject: JValue = singleResult match { + case Full(data) => net.liftweb.json.parse(data.dataJson) + case _ => throw new RuntimeException(notFoundMsg(entityName, id, bankId)) + } + wrapBankId(bankId, (singleName(entityName) -> singleObject)) + } + } + } + + // ----- dispatch ----- + + /** + * Match the remaining path segments (after `/obp/dynamic-entity`) against the same + * extractors the Lift dispatcher used. Order public -> community -> generic mirrors + * OBPAPIDynamicEntity.routes. No match -> OptionT.none (request falls through the chain). + */ + private def dispatch(req: Request[IO], rest: List[String]): OptionT[IO, Response[IO]] = { + val handlerOpt: Option[Request[IO] => IO[Response[IO]]] = (req.method, rest) match { + case (Method.GET, PublicEntityName(bankId, entityName, id)) => + Some(r => publicGet(r, bankId, entityName, id)) + case (Method.GET, CommunityEntityName(bankId, entityName, id)) => + Some(r => communityGet(r, bankId, entityName, id)) + case (method, EntityName(bankId, entityName, id, isPersonalEntity)) => + method match { + case Method.GET => Some(r => genericGet(r, bankId, entityName, id, isPersonalEntity)) + case Method.POST => Some(r => genericPost(r, bankId, entityName, isPersonalEntity)) + case Method.PUT => Some(r => genericPut(r, bankId, entityName, id, isPersonalEntity)) + case Method.DELETE => Some(r => genericDelete(r, bankId, entityName, id, isPersonalEntity)) + case _ => None + } + case _ => None + } + + handlerOpt match { + case None => OptionT.none[IO, Response[IO]] + case Some(handler) => + OptionT.liftF { + Http4sCallContextBuilder.fromRequest(req, apiVersionString).flatMap { cc => + val reqWithCc = req.withAttribute(Http4sRequestAttributes.callContextKey, cc) + val io = handler(reqWithCc) + if (req.method == Method.GET || req.method == Method.HEAD) io + else RequestScopeConnection.withBusinessDBTransaction(io) + } + } + } + } + + /** Entry point wired into Http4sApp.baseServices (before the Lift bridge). */ + lazy val wrappedRoutesDynamicEntity: HttpRoutes[IO] = + Kleisli[HttpF, Request[IO], Response[IO]] { (req: Request[IO]) => + req.uri.path.segments.map(_.encoded).toList match { + case standard :: version :: rest if standard == apiStandard && version == apiVersionString => + dispatch(req, rest) + case _ => + OptionT.none[IO, Response[IO]] + } + } +} diff --git a/obp-api/src/main/scala/code/api/dynamic/entity/OBPAPIDynamicEntity.scala b/obp-api/src/main/scala/code/api/dynamic/entity/OBPAPIDynamicEntity.scala index 7bf86a7cb3..416f1ab25f 100644 --- a/obp-api/src/main/scala/code/api/dynamic/entity/OBPAPIDynamicEntity.scala +++ b/obp-api/src/main/scala/code/api/dynamic/entity/OBPAPIDynamicEntity.scala @@ -50,26 +50,29 @@ object OBPAPIDynamicEntity extends OBPRestHelper with MdcLoggable with Versioned // if old version ResourceDoc objects have the same name endpoint with new version, omit old version ResourceDoc. def allResourceDocs = collectResourceDocs(ImplementationsDynamicEntity.resourceDocs) - val routes : List[OBPEndpoint] = List(ImplementationsDynamicEntity.publicEndpoint, ImplementationsDynamicEntity.communityEndpoint, ImplementationsDynamicEntity.genericEndpoint) + // Runtime CRUD migrated to code.api.dynamic.entity.Http4sDynamicEntity (wired into + // Http4sApp.baseServices). routes reduced to Nil — the Lift OBPEndpoint handlers are no + // longer registered with Lift. This object is retained only as an accessor for + // allResourceDocs / routes referenced by ResourceDocsAPIMethods.getResourceDocsList. + val routes : List[OBPEndpoint] = Nil + // val routes : List[OBPEndpoint] = List(ImplementationsDynamicEntity.publicEndpoint, ImplementationsDynamicEntity.communityEndpoint, ImplementationsDynamicEntity.genericEndpoint) + + // routes.map(endpoint => oauthServe(apiPrefix{endpoint}, None)) // no Lift dispatch registration — served by Http4sDynamicEntity - routes.map(endpoint => oauthServe(apiPrefix{endpoint}, None)) - logger.info(s"version $version has been run! There are ${routes.length} routes.") - // specified response for OPTIONS request. - private val corsResponse: Box[LiftResponse] = Full{ - val corsHeaders = List( - "Access-Control-Allow-Origin" -> "*", - "Access-Control-Allow-Methods" -> "GET, POST, OPTIONS, PUT, PATCH, DELETE", - "Access-Control-Allow-Headers" -> "*", - "Access-Control-Allow-Credentials" -> "true", - "Access-Control-Max-Age" -> "1728000" //Tell client that this pre-flight info is valid for 20 days - ) - PlainTextResponse("", corsHeaders, HttpStatus.SC_NO_CONTENT) - } - /* - * process OPTIONS http request, just return no content and status is 204 - */ - this.serve({ - case req if req.requestType.method == "OPTIONS" => corsResponse - }) + + // OPTIONS / CORS is handled by Http4sApp.corsHandler — the Lift OPTIONS serve below is disabled. + // private val corsResponse: Box[LiftResponse] = Full{ + // val corsHeaders = List( + // "Access-Control-Allow-Origin" -> "*", + // "Access-Control-Allow-Methods" -> "GET, POST, OPTIONS, PUT, PATCH, DELETE", + // "Access-Control-Allow-Headers" -> "*", + // "Access-Control-Allow-Credentials" -> "true", + // "Access-Control-Max-Age" -> "1728000" //Tell client that this pre-flight info is valid for 20 days + // ) + // PlainTextResponse("", corsHeaders, HttpStatus.SC_NO_CONTENT) + // } + // this.serve({ + // case req if req.requestType.method == "OPTIONS" => corsResponse + // }) } diff --git a/obp-api/src/main/scala/code/api/util/APIUtil.scala b/obp-api/src/main/scala/code/api/util/APIUtil.scala index c0b76123c3..cf84c192e2 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -2875,7 +2875,10 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ case ApiVersion.v5_1_0 => LiftRules.statelessDispatch.append(v5_1_0.OBPAPI5_1_0) case ApiVersion.v6_0_0 => LiftRules.statelessDispatch.append(v6_0_0.OBPAPI6_0_0) case ApiVersion.`dynamic-endpoint` => LiftRules.statelessDispatch.append(OBPAPIDynamicEndpoint) - case ApiVersion.`dynamic-entity` => LiftRules.statelessDispatch.append(OBPAPIDynamicEntity) + // dynamic-entity endpoints migrated to Http4sDynamicEntity (wired into Http4sApp.baseServices). + // Keep the case label with an empty body so ApiVersion.`dynamic-entity` does NOT fall through + // to the ScannedApiVersion branch below (which would re-append it via ScannedApis). + case ApiVersion.`dynamic-entity` => // LiftRules.statelessDispatch.append(OBPAPIDynamicEntity) case version: ScannedApiVersion => ScannedApis.versionMapScannedApis.get(version).foreach(api => LiftRules.statelessDispatch.append(api)) case _ => logger.info(s"There is no ${version.toString}") diff --git a/obp-api/src/main/scala/code/api/util/http4s/ErrorResponseConverter.scala b/obp-api/src/main/scala/code/api/util/http4s/ErrorResponseConverter.scala index c228d4ce2c..101d142bec 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/ErrorResponseConverter.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/ErrorResponseConverter.scala @@ -2,6 +2,8 @@ package code.api.util.http4s import cats.effect._ import code.api.APIFailureNewStyle +import code.api.JsonResponseException +import code.api.util.APIUtil.JsonResponseExtractor import code.api.util.ErrorMessages._ import code.api.util.CallContext import net.liftweb.common.{Failure => LiftFailure} @@ -77,6 +79,16 @@ object ErrorResponseConverter { def toHttp4sResponse(error: Throwable, callContext: CallContext): IO[Response[IO]] = { error match { case e: APIFailureNewStyle => apiFailureToResponse(e, callContext) + case JsonResponseException(jsonResponse) => + // Force-Error / JSON-schema validation (APIUtil.afterAuthenticateInterceptResult, applied + // inside the auth/session-context chain) and dynamic-resource-doc permission errors + // (NewStyle) are raised as JsonResponseException carrying a Lift JsonResponse. Lift's + // OBPRestHelper catches these and returns the embedded response; mirror that here using + // the JsonResponse's own status code (no OBP-prefix remapping). + jsonResponse match { + case JsonResponseExtractor(message, code) => jsonErrorResponse(code, message, callContext) + case _ => unknownErrorToResponse(error, callContext) + } case _ => tryExtractApiFailureFromExceptionMessage(error) match { case Some(apiFailure) => apiFailureToResponse(apiFailure, callContext) @@ -84,6 +96,19 @@ object ErrorResponseConverter { } } } + + /** Build a JSON error response using the supplied status code verbatim (used for + * JsonResponseException, whose embedded JsonResponse already carries the final code). */ + private def jsonErrorResponse(code: Int, message: String, callContext: CallContext): IO[Response[IO]] = { + val errorJson = OBPErrorResponse(code, message) + val status = org.http4s.Status.fromInt(code).getOrElse(org.http4s.Status.BadRequest) + IO.pure( + Response[IO](status) + .withEntity(toJsonString(errorJson)) + .withContentType(jsonContentType) + .putHeaders(org.http4s.Header.Raw(CIString("Correlation-Id"), callContext.correlationId)) + ) + } /** Old-style versions keep raw 400 codes — they never promote to 403/401/etc. * Mirrors the same set used in ResourceDocMiddleware.authenticate. diff --git a/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala b/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala index 14092720a8..93622dd0bc 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala @@ -74,6 +74,10 @@ object Http4sApp { private val v510Routes: HttpRoutes[IO] = gate(ApiVersion.v5_1_0, code.api.v5_1_0.Http4s510.wrappedRoutesV510Services) private val v600Routes: HttpRoutes[IO] = gate(ApiVersion.v6_0_0, code.api.v6_0_0.Http4s600.wrappedRoutesV600Services) private val v700Routes: HttpRoutes[IO] = gate(ApiVersion.v7_0_0, code.api.v7_0_0.Http4s700.wrappedRoutesV700Services) + // DynamicEntity runtime CRUD (/obp/dynamic-entity/*) — native http4s, replaces the Lift + // OBPAPIDynamicEntity dispatch. dynamic-endpoint (proxy + compiled resource docs) is a + // separate task and still falls through to the Lift bridge. + private val dynamicEntityRoutes: HttpRoutes[IO] = gate(ApiVersion.`dynamic-entity`, code.api.dynamic.entity.Http4sDynamicEntity.wrappedRoutesDynamicEntity) /** * Build the base HTTP4S routes with priority-based routing. @@ -127,6 +131,7 @@ object Http4sApp { .orElse(v140Routes.run(req)) .orElse(v130Routes.run(req)) .orElse(v121Routes.run(req)) + .orElse(dynamicEntityRoutes.run(req)) .orElse(code.api.DirectLoginRoutes.routes.run(req)) .orElse(code.api.AliveCheckRoutes.routes.run(req)) .orElse(Http4sLiftWebBridge.routes.run(req)) diff --git a/obp-api/src/main/scala/code/api/util/http4s/RequestScopeConnection.scala b/obp-api/src/main/scala/code/api/util/http4s/RequestScopeConnection.scala index a3ef52afd5..170bf72b4e 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/RequestScopeConnection.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/RequestScopeConnection.scala @@ -1,12 +1,14 @@ package code.api.util.http4s -import cats.effect.{IO, IOLocal} +import cats.effect.{Deferred, IO, IOLocal, Outcome} import cats.effect.unsafe.IORuntime import com.alibaba.ttl.TransmittableThreadLocal import net.liftweb.common.{Box, Full} import net.liftweb.db.ConnectionManager import net.liftweb.util.ConnectionIdentifier +import org.http4s.Response +import code.api.util.APIUtil import code.util.Helper.MdcLoggable import java.lang.reflect.{InvocationHandler, Method, Proxy => JProxy} import java.sql.Connection @@ -173,6 +175,51 @@ object RequestScopeConnection extends MdcLoggable { } } + /** + * Wrap an http4s route IO in a request-scoped DB transaction. + * + * Installs a once-only lazy connection-acquisition scope (requestLazyAcquire): no real + * connection is borrowed until the FIRST fromFuture call that touches the DB. The first + * fiber to complete the inner Deferred wins; concurrent losers discard their connection + * and share the winner's proxy, so all fibers use one underlying Connection / one + * transaction. On success: commit then close. On error/cancel: rollback then close. + * If no DB call was made: nothing to commit or close (pool unaffected). + * + * GET/HEAD must NOT be wrapped (they run on auto-commit vendor connections). Used by + * ResourceDocMiddleware (v6/v7) and by services that build their own request scope + * without the middleware (e.g. Http4sDynamicEntity). + */ + def withBusinessDBTransaction(io: IO[Response[IO]]): IO[Response[IO]] = + Deferred[IO, (Connection, Connection)].flatMap { deferred => + // acquireOnce: idempotent across concurrent callers via the Deferred. + val acquireOnce: IO[Connection] = for { + realConn <- IO.blocking(APIUtil.vendor.HikariDatasource.ds.getConnection()) + _ <- IO.blocking { realConn.setAutoCommit(false) } + proxy = makeProxy(realConn) + ok <- deferred.complete((realConn, proxy)) + _ <- if (!ok) IO.blocking { try { realConn.close() } catch { case _: Exception => () } } + else IO.unit + p <- deferred.get.map(_._2) + } yield p + + requestLazyAcquire.set(Some(acquireOnce)).bracket(_ => + io.guaranteeCase { outcome => + deferred.tryGet.flatMap { + case None => IO.unit // no DB calls — pool unaffected + case Some((realConn, _)) => + requestProxyLocal.set(None) *> + (outcome match { + case Outcome.Succeeded(_) => + IO.blocking { realConn.commit() } + case _ => + IO.blocking { try { realConn.rollback() } catch { case _: Exception => () } } + }) *> + IO.blocking { try { realConn.close() } catch { case _: Exception => () } } + } + } + )(_ => requestLazyAcquire.set(None)) + } + /** * Returns the proxy for the current fiber, acquiring it lazily on first call. * diff --git a/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala b/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala index 8f7b4efbc9..721f4fcca4 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala @@ -166,7 +166,7 @@ object ResourceDocMiddleware extends MdcLoggable { .getOrElseF(IO.pure(ensureJsonContentType(Response[IO](org.http4s.Status.NotFound)))) val executed = if (req.method == Method.GET || req.method == Method.HEAD) routeIO - else withBusinessDBTransaction(routeIO) + else RequestScopeConnection.withBusinessDBTransaction(routeIO) executed.map(Option(_)) } OptionT(work.timeoutTo(endpointTimeoutMs.millis, endpointTimeoutResponse(req))) @@ -203,60 +203,10 @@ object ResourceDocMiddleware extends MdcLoggable { )) } - /** - * Activates a lazy request-scoped DB transaction for mutating methods - * (POST/PUT/DELETE/PATCH). GET/HEAD bypass this entirely. - * - * NO connection is borrowed upfront. Instead, a once-only acquisition IO is - * installed in requestLazyAcquire. The first fromFuture call that actually needs - * a DB connection triggers the acquisition; endpoints that only call external REST - * or SOAP connectors never touch the pool at all. - * - * Concurrent acquisition (rare — most handlers are sequential for-comprehensions): - * the inner Deferred serialises callers. The first fiber to complete it wins; - * any concurrent loser closes its own connection immediately and shares the winner's - * proxy. All fibers use one underlying Connection and one transaction. - * - * currentProxy (TTL) is NOT set here. Every DB call goes through - * RequestScopeConnection.fromFuture, which atomically sets + submits + clears the - * TTL within a single IO.defer block on the compute thread. - * - * On success (connection was acquired): commit, then close. - * On error/cancel (connection was acquired): rollback (errors swallowed), then close. - * If no DB call was made: deferred is never completed → nothing to commit or close. - */ - private def withBusinessDBTransaction(io: IO[Response[IO]]): IO[Response[IO]] = - Deferred[IO, (Connection, Connection)].flatMap { deferred => - // acquireOnce: idempotent across concurrent callers via the Deferred. - // The loser of the complete() race discards its own connection and awaits - // the winner's proxy so all fibers share one transaction. - val acquireOnce: IO[Connection] = for { - realConn <- IO.blocking(APIUtil.vendor.HikariDatasource.ds.getConnection()) - _ <- IO.blocking { realConn.setAutoCommit(false) } - proxy = RequestScopeConnection.makeProxy(realConn) - ok <- deferred.complete((realConn, proxy)) - _ <- if (!ok) IO.blocking { try { realConn.close() } catch { case _: Exception => () } } - else IO.unit - p <- deferred.get.map(_._2) - } yield p - - RequestScopeConnection.requestLazyAcquire.set(Some(acquireOnce)).bracket(_ => - io.guaranteeCase { outcome => - deferred.tryGet.flatMap { - case None => IO.unit // no DB calls — pool unaffected - case Some((realConn, _)) => - RequestScopeConnection.requestProxyLocal.set(None) *> - (outcome match { - case Outcome.Succeeded(_) => - IO.blocking { realConn.commit() } - case _ => - IO.blocking { try { realConn.rollback() } catch { case _: Exception => () } } - }) *> - IO.blocking { try { realConn.close() } catch { case _: Exception => () } } - } - } - )(_ => RequestScopeConnection.requestLazyAcquire.set(None)) - } + // withBusinessDBTransaction moved to RequestScopeConnection.withBusinessDBTransaction + // so that services which build their own request scope without this middleware + // (e.g. Http4sDynamicEntity) can reuse the same commit/rollback/close logic. + // The call site above now delegates to RequestScopeConnection.withBusinessDBTransaction. /** * Runs the full validation chain (auth → roles → bank → account → view → counterparty) diff --git a/obp-api/src/test/scala/code/api/v4_0_0/ForceErrorValidationTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/ForceErrorValidationTest.scala index 59548a8782..97cf87a96f 100644 --- a/obp-api/src/test/scala/code/api/v4_0_0/ForceErrorValidationTest.scala +++ b/obp-api/src/test/scala/code/api/v4_0_0/ForceErrorValidationTest.scala @@ -32,7 +32,9 @@ class ForceErrorValidationTest extends V400ServerSetup with PropsReset { object ApiEndpoint2 extends Tag(nameOf(Implementations4_0_0.getCustomerAttributeById)) - object ApiEndpoint3 extends Tag(nameOf(ImplementationsDynamicEntity.genericEndpoint)) + // genericEndpoint was migrated to Http4sDynamicEntity and the Lift handler commented out; + // its nameOf value was "genericEndpoint" — kept as a string literal so the tag is unchanged. + object ApiEndpoint3 extends Tag("genericEndpoint") object ApiEndpoint4 extends Tag(nameOf(ImplementationsDynamicEndpoint.dynamicEndpoint)) diff --git a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityFilterAndBankAccessTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityFilterAndBankAccessTest.scala new file mode 100644 index 0000000000..fb82e43bbb --- /dev/null +++ b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityFilterAndBankAccessTest.scala @@ -0,0 +1,275 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2025, 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 +Osloerstrasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) + */ +package code.api.v6_0_0 + +import code.api.util.APIUtil.OAuth._ +import code.api.util.ApiRole._ +import code.entitlement.Entitlement +import com.openbankproject.commons.util.ApiVersion +import net.liftweb.json.JsonAST.JArray +import net.liftweb.json.JsonDSL._ +import net.liftweb.json.Serialization.write +import net.liftweb.json._ +import org.scalatest.Tag + +/** + * Characterization tests for two areas of the dynamic-entity runtime CRUD that were + * previously uncovered, written BEFORE the Lift -> http4s migration of `Http4sDynamicEntity` + * so they lock in the current (Lift) behaviour and act as the migration regression gate: + * + * - G1: GET-all query-parameter filtering (`filterDynamicObjects`, including the + * `locale` (PARAM_LOCALE) exclusion) on the generic, public and community endpoints. + * The migration rewrites this from Lift `req.params` to http4s `req.uri.query.multiParams`. + * - G2: bank-level `public` / `community` access (`/banks/BANK_ID/public/...`, + * `/banks/BANK_ID/community/...`), which the extractors support but no test exercised. + */ +class DynamicEntityFilterAndBankAccessTest extends V600ServerSetup { + + object VersionOfApi extends Tag(ApiVersion.v6_0_0.toString) + + // ==================== Helper Methods ==================== + + /** name (string) + number (integer) — two fields so we can assert multi-field AND filtering. */ + def twoFieldSchema: JValue = parse( + """ + |{ + | "description": "Filter/bank-access characterization entity.", + | "required": ["name"], + | "properties": { + | "name": { "type": "string", "maxLength": 40, "minLength": 1, "example": "Alice" }, + | "number": { "type": "integer", "example": 1 } + | } + |} + """.stripMargin) + + def systemEntityJson(entityName: String, extraFlags: JObject = JObject(Nil)): JValue = + (("entity_name" -> entityName) ~ ("has_personal_entity" -> true) ~ ("schema" -> twoFieldSchema)) merge extraFlags + + def bankEntityJson(entityName: String, extraFlags: JObject = JObject(Nil)): JValue = + (("entity_name" -> entityName) ~ ("has_personal_entity" -> true) ~ ("schema" -> twoFieldSchema)) merge extraFlags + + def createSystemEntity(entityJson: JValue): (Int, JValue) = { + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateSystemLevelDynamicEntity.toString) + val request = (v6_0_0_Request / "management" / "system-dynamic-entities").POST <@(user1) + val response = makePostRequest(request, write(entityJson)) + (response.code, response.body) + } + + def deleteSystemEntity(dynamicEntityId: String): Unit = { + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanDeleteSystemLevelDynamicEntity.toString) + val deleteRequest = (v6_0_0_Request / "management" / "system-dynamic-entities" / dynamicEntityId).DELETE <@(user1) + makeDeleteRequest(deleteRequest) + } + + def createBankEntity(bankId: String, entityJson: JValue): (Int, JValue) = { + Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, CanCreateBankLevelDynamicEntity.toString) + val request = (v6_0_0_Request / "management" / "banks" / bankId / "dynamic-entities").POST <@(user1) + val response = makePostRequest(request, write(entityJson)) + (response.code, response.body) + } + + def deleteBankEntity(bankId: String, dynamicEntityId: String): Unit = { + Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, CanDeleteBankLevelDynamicEntity.toString) + val deleteRequest = (v6_0_0_Request / "management" / "banks" / bankId / "dynamic-entities" / dynamicEntityId).DELETE <@(user1) + makeDeleteRequest(deleteRequest) + } + + def listSize(body: JValue, listName: String): Int = + (body \ listName).asInstanceOf[JArray].arr.size + + def record(name: String, number: Int): JValue = ("name" -> name) ~ ("number" -> number) + + // ==================== G1: GET-all query-parameter filtering ==================== + + feature("G1 - GET-all query parameter filtering (filterDynamicObjects)") { + + scenario("Generic /my/ GET-all filters by field value, supports multi-field AND, and excludes locale", VersionOfApi) { + val (code, body) = createSystemEntity(systemEntityJson("test_filter_my")) + code should equal(201) + val dynamicEntityId = (body \ "dynamic_entity_id").extract[String] + + try { + When("user1 creates three personal records via /my/test_filter_my (no role required)") + val create = (dynamicEntity_Request / "my" / "test_filter_my").POST <@(user1) + makePostRequest(create, write(record("Alice", 1))).code should equal(201) + makePostRequest(create, write(record("Bob", 2))).code should equal(201) + makePostRequest(create, write(record("Alice", 3))).code should equal(201) + + val base = (dynamicEntity_Request / "my" / "test_filter_my").GET <@(user1) + + Then("no query params returns all three") + val all = makeGetRequest(base) + all.code should equal(200) + listSize(all.body, "test_filter_my_list") should equal(3) + + Then("filtering by a single field returns only matching records") + val byName = makeGetRequest(base < true))) + code should equal(201) + val dynamicEntityId = (body \ "dynamic_entity_id").extract[String] + + try { + When("user1 creates two non-personal records via the system endpoint") + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, "CanCreateDynamicEntity_Systemtest_filter_public") + val create = (dynamicEntity_Request / "test_filter_public").POST <@(user1) + makePostRequest(create, write(record("Pub1", 10))).code should equal(201) + makePostRequest(create, write(record("Pub2", 20))).code should equal(201) + + val base = (dynamicEntity_Request / "public" / "test_filter_public").GET + + Then("anonymous GET-all returns both") + val all = makeGetRequest(base) + all.code should equal(200) + listSize(all.body, "test_filter_public_list") should equal(2) + + Then("anonymous GET-all filters by field value") + val filtered = makeGetRequest(base < true))) + code should equal(201) + val dynamicEntityId = (body \ "dynamic_entity_id").extract[String] + + try { + When("user1 creates two personal records via /my/") + val create = (dynamicEntity_Request / "my" / "test_filter_community").POST <@(user1) + makePostRequest(create, write(record("Com1", 100))).code should equal(201) + makePostRequest(create, write(record("Com2", 200))).code should equal(201) + + And("user1 has the CanGet role for community access") + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, "CanGetDynamicEntity_Systemtest_filter_community") + val base = (dynamicEntity_Request / "community" / "test_filter_community").GET <@(user1) + + Then("GET-all returns both") + val all = makeGetRequest(base) + all.code should equal(200) + listSize(all.body, "test_filter_community_list") should be >= 2 + + Then("GET-all filters by field value") + val filtered = makeGetRequest(base < true))) + code should equal(201) + val dynamicEntityId = (body \ "dynamic_entity_id").extract[String] + + try { + When("user1 creates a non-personal bank-level record") + Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, "CanCreateDynamicEntity_test_bank_public") + val create = (dynamicEntity_Request / "banks" / bankId / "test_bank_public").POST <@(user1) + val createResponse = makePostRequest(create, write(record("BankPub", 1))) + createResponse.code should equal(201) + val recordId = (createResponse.body \ "test_bank_public" \ "test_bank_public_id").extract[String] + + Then("anonymous GET-all on the bank public path returns 200") + val all = makeGetRequest((dynamicEntity_Request / "banks" / bankId / "public" / "test_bank_public").GET) + all.code should equal(200) + listSize(all.body, "test_bank_public_list") should be >= 1 + + Then("anonymous GET single on the bank public path returns 200") + val one = makeGetRequest((dynamicEntity_Request / "banks" / bankId / "public" / "test_bank_public" / recordId).GET) + one.code should equal(200) + } finally { + deleteBankEntity(bankId, dynamicEntityId) + } + } + + scenario("Bank-level /banks/BANK_ID/community/ GET requires auth then CanGet role", VersionOfApi) { + val bankId = testBankId1.value + val (code, body) = createBankEntity(bankId, bankEntityJson("test_bank_community", ("has_community_access" -> true))) + code should equal(201) + val dynamicEntityId = (body \ "dynamic_entity_id").extract[String] + + try { + val base = dynamicEntity_Request / "banks" / bankId / "community" / "test_bank_community" + + Then("anonymous GET returns 401") + makeGetRequest(base.GET).code should equal(401) + + Then("authenticated without the CanGet role returns 403") + makeGetRequest(base.GET <@(user2)).code should equal(403) + + When("user2 is granted the bank-level CanGet role") + Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser2.userId, "CanGetDynamicEntity_test_bank_community") + Then("the GET now returns 200") + makeGetRequest(base.GET <@(user2)).code should equal(200) + } finally { + deleteBankEntity(bankId, dynamicEntityId) + } + } + } +}