From 53cd143b0fdc6f562b1ceb41f3150ce442929d96 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 27 May 2026 23:59:09 +0200 Subject: [PATCH 01/14] Migrate dynamic-endpoint dispatch from Lift statelessDispatch to native http4s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace LiftRules.statelessDispatch registration of OBPAPIDynamicEndpoint with a dedicated in-process Lift adapter (Http4sDynamicEndpoint) wired into Http4sApp.baseServices, positioned ahead of the Lift bridge. Covers both runtime pieces: - Piece B (proxy): ImplementationsDynamicEndpoint.dynamicEndpoint, matched by DynamicReq.unapply and proxied to a backend connector / obp_mock. - Piece C (runtime-compiled): DynamicEndpoints.dynamicEndpoint, serving practise / dynamic-resource-doc endpoints compiled from user Scala via DynamicUtil.compileScalaCode[OBPEndpoint]. Piece C compiled artifacts are hardwired to Lift types (PartialFunction[Req, CallContext => Box[JsonResponse]]) and cannot be natively rewritten. The adapter runs the exact wrapped form Lift held in statelessDispatch — routes.map(apiPrefix andThen buildOAuthHandler) — inside S.init, preserving failIfBadAuthorizationHeader / failIfBadJSON semantics and endpoint metrics unchanged. Changes: - Http4sDynamicEndpoint.scala (new): in-process Lift adapter. Buffers body, builds Lift Req via buildLiftReq, enters S.init, collectFirst over wrappedRoutes, converts Box[LiftResponse] to http4s Response. Catches JsonResponseException (eager failIfBadAuthorizationHeader) and ContinuationException (async Lift). No withBusinessDBTransaction wrap (dynamic-endpoint wrote on autocommit connections via the bridge too). - Http4sLiftWebBridge.scala: promote buildLiftReq, liftResponseToHttp4s, resolveContinuation from private to public so Http4sDynamicEndpoint (different package) can reuse them. - OBPRestHelper.scala: extract buildOAuthHandler from oauthServe. Returns the identical PartialFunction[Req, () => Box[LiftResponse]] without registering into statelessDispatch, so the adapter can construct the exact wrapped form in-process. - Http4sApp.scala: add dynamicEndpointRoutes val (gate on ApiVersion.dynamic-endpoint) and wire into baseServices orElse chain after dynamicEntityRoutes, before Http4sLiftWebBridge. - APIUtil.scala: comment out LiftRules.statelessDispatch.append for ApiVersion.dynamic-endpoint; keep empty case label so it does not fall through to the ScannedApiVersion branch. - OBPAPIDynamicEndpoint.scala: comment out statelessDispatch self- registration and OPTIONS serve (CORS now handled globally by Http4sApp.corsHandler). Keep object / version / allResourceDocs / routes intact (adapter source list + resource-docs aggregation). Verified: 45 / 45 dynamic regression tests pass on JDK 11 (DynamicEndpointsTest 30, DynamicUtilTest 9, DynamicResourceDocTest 3, DynamicMessageDocTest 2, DynamicIntegrationTest 1). --- .../main/scala/code/api/OBPRestHelper.scala | 63 ++++--- .../endpoint/Http4sDynamicEndpoint.scala | 158 ++++++++++++++++++ .../endpoint/OBPAPIDynamicEndpoint.scala | 46 ++--- .../main/scala/code/api/util/APIUtil.scala | 5 +- .../code/api/util/http4s/Http4sApp.scala | 9 +- .../api/util/http4s/Http4sLiftWebBridge.scala | 10 +- 6 files changed, 239 insertions(+), 52 deletions(-) create mode 100644 obp-api/src/main/scala/code/api/dynamic/endpoint/Http4sDynamicEndpoint.scala diff --git a/obp-api/src/main/scala/code/api/OBPRestHelper.scala b/obp-api/src/main/scala/code/api/OBPRestHelper.scala index 378cd9655c..5886520b02 100644 --- a/obp-api/src/main/scala/code/api/OBPRestHelper.scala +++ b/obp-api/src/main/scala/code/api/OBPRestHelper.scala @@ -574,37 +574,46 @@ trait OBPRestHelper extends RestHelper with MdcLoggable { */ def oauthServe(handler: PartialFunction[Req, CallContext => Box[JsonResponse]], rd: Option[ResourceDoc] = None): Unit = { - val obpHandler : PartialFunction[Req, () => Box[LiftResponse]] = { - new PartialFunction[Req, () => Box[LiftResponse]] { - def apply(r : Req): () => Box[LiftResponse] = { - //check (in that order): - //if request is correct json - //if request matches PartialFunction cases for each defined url - //if request has correct oauth headers - val startTime = Helpers.now - val response = failIfBadAuthorizationHeader(rd) { - failIfBadJSON(r, handler) - } - val endTime = Helpers.now - WriteMetricUtil.writeEndpointMetric(startTime, endTime.getTime - startTime.getTime, rd) - response + serve(buildOAuthHandler(handler, rd)) + } + + /** + * Build the oauth-wrapped Lift handler that `oauthServe` would otherwise register directly into + * Lift's statelessDispatch. Extracted as a public method so the in-process Lift adapter in + * code.api.dynamic.endpoint.Http4sDynamicEndpoint can construct the exact same wrapped form + * (failIfBadAuthorizationHeader { failIfBadJSON } + endpoint metric) for the dynamic-endpoint + * routes and apply it directly — without registering into statelessDispatch. Behaviour for the + * normal oauthServe path is unchanged (oauthServe now just `serve(buildOAuthHandler(...))`). + */ + def buildOAuthHandler(handler: PartialFunction[Req, CallContext => Box[JsonResponse]], rd: Option[ResourceDoc] = None): PartialFunction[Req, () => Box[LiftResponse]] = { + new PartialFunction[Req, () => Box[LiftResponse]] { + def apply(r : Req): () => Box[LiftResponse] = { + //check (in that order): + //if request is correct json + //if request matches PartialFunction cases for each defined url + //if request has correct oauth headers + val startTime = Helpers.now + val response = failIfBadAuthorizationHeader(rd) { + failIfBadJSON(r, handler) } - def isDefinedAt(r : Req) = { - //if the content-type is json and json parsing failed, simply accept call but then fail in apply() before - //the url cases don't match because json failed - r.json_? match { - case true => - //Try to evaluate the json - r.json match { - case Failure(msg, _, _) => true - case _ => handler.isDefinedAt(r) - } - case false => handler.isDefinedAt(r) - } + val endTime = Helpers.now + WriteMetricUtil.writeEndpointMetric(startTime, endTime.getTime - startTime.getTime, rd) + response + } + def isDefinedAt(r : Req) = { + //if the content-type is json and json parsing failed, simply accept call but then fail in apply() before + //the url cases don't match because json failed + r.json_? match { + case true => + //Try to evaluate the json + r.json match { + case Failure(msg, _, _) => true + case _ => handler.isDefinedAt(r) + } + case false => handler.isDefinedAt(r) } } } - serve(obpHandler) } override protected def serve(handler: PartialFunction[Req, () => Box[LiftResponse]]) : Unit = { diff --git a/obp-api/src/main/scala/code/api/dynamic/endpoint/Http4sDynamicEndpoint.scala b/obp-api/src/main/scala/code/api/dynamic/endpoint/Http4sDynamicEndpoint.scala new file mode 100644 index 0000000000..4a0dab8e71 --- /dev/null +++ b/obp-api/src/main/scala/code/api/dynamic/endpoint/Http4sDynamicEndpoint.scala @@ -0,0 +1,158 @@ +/** +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.endpoint + +import cats.data.{Kleisli, OptionT} +import cats.effect.IO +import code.api.util.APIUtil +import code.api.util.ErrorMessages.{InvalidUri, UnknownError} +import code.api.util.http4s.Http4sLiftWebBridge +import code.api.{APIFailure, JsonResponseException} +import code.util.Helper.MdcLoggable +import com.openbankproject.commons.util.{ApiShortVersions, ApiStandards} +import net.liftweb.common.{Empty, Failure, Full, ParamFailure} +import net.liftweb.http.{LiftResponse, LiftRules, Req, S} +import org.http4s.{HttpRoutes, Request, Response} + +/** + * Native http4s entry point for the OBP dynamic-endpoint dispatch (under /obp/dynamic-endpoint/). + * + * Replaces the Lift `LiftRules.statelessDispatch` registration of [[OBPAPIDynamicEndpoint]] + * (see APIUtil.enableVersionIfAllowed, now commented for `dynamic-endpoint`). It covers BOTH + * runtime pieces that OBPAPIDynamicEndpoint.routes carries: + * + * - Piece B (proxy): `ImplementationsDynamicEndpoint.dynamicEndpoint`, matched by + * `DynamicEndpointHelper.DynamicReq` and proxied to a backend connector / obp_mock. + * - Piece C (runtime-compiled): `DynamicEndpoints.dynamicEndpoint`, serving the + * practise / dynamic-resource-doc endpoints compiled from user Scala via + * `DynamicUtil.compileScalaCode[OBPEndpoint]`. + * + * Why an in-process Lift adapter (not a native rewrite): + * - Piece C's compiled artifact has its type hard-wired to Lift + * (`PartialFunction[Req, CallContext => Box[JsonResponse]]`, generated code imports + * `net.liftweb.http.{JsonResponse, Req}`), so it can only be RUN, never natively rewritten. + * - dynamic-endpoint already ran through the http4s -> Lift bridge today (it was on + * statelessDispatch, which the bridge iterates), i.e. the Req was already built by + * `Http4sLiftWebBridge.buildLiftReq`. This migration does NOT change Req construction, + * body buffering, or the (auto-commit) transaction behaviour — it only relocates the + * `collectFirst` from the bridge's global statelessDispatch list into this dedicated, + * dynamic-endpoint-only service positioned ahead of the bridge. + * + * Mechanics (a faithful, narrowed copy of `Http4sLiftWebBridge.runLiftDispatch`): + * 1. Buffer the body and build a Lift `Req` with `buildLiftReq` (full uri `/obp/dynamic-endpoint/...` + * so `DynamicReq`'s prefix gate passes). + * 2. Inside `S.init` (required: failIfBadAuthorizationHeader reads `S.request`, and Lift + * `Req.body`/`json`/`testResponse_?` resolve only in that scope), `collectFirst` over the + * SAME wrapped form Lift registered — `routes.map(apiPrefix andThen buildOAuthHandler)` — and run it. + * 3. Reduce the `Box[LiftResponse]` exactly as runLiftDispatch does (Full / ParamFailure / + * Failure / Empty), catching `JsonResponseException` (force-error / json-schema / auth + * interceptors) and Lift `ContinuationException` (async). + * 4. No match -> `OptionT.none` so the request falls through the Http4sApp chain (eventually + * the bridge produces the final 404, just as before). + * + * No `withBusinessDBTransaction` wrap: the bridge path that served dynamic-endpoint until now did + * not wrap either (writes ran on auto-commit connections), so omitting it preserves behaviour. + */ +object Http4sDynamicEndpoint extends MdcLoggable { + + private type HttpF[A] = OptionT[IO, A] + + private val apiStandard = ApiStandards.obp.toString + private val apiVersionString = ApiShortVersions.`dynamic-endpoint`.toString // "dynamic-endpoint" + + /** + * The exact wrapped form Lift held in statelessDispatch for dynamic-endpoint: + * `routes.map(endpoint => oauthServe(apiPrefix{endpoint}, None))`. `oauthServe` registers, + * `buildOAuthHandler` returns the identical wrapped PF (failIfBadAuthorizationHeader { failIfBadJSON } + * + endpoint metric) without registering, so we can apply it in-process. Built once; the + * per-request DB lookups happen inside each route's `isDefinedAt`/`apply` + * (`DynamicReq.unapply` / `DynamicEndpoints.findEndpoint`), exactly as before. + */ + private lazy val wrappedRoutes: List[PartialFunction[Req, () => net.liftweb.common.Box[LiftResponse]]] = + OBPAPIDynamicEndpoint.routes.map(route => + OBPAPIDynamicEndpoint.buildOAuthHandler(OBPAPIDynamicEndpoint.apiPrefix(route), None)) + + /** Reduce a handler's `Box[LiftResponse]` to a `LiftResponse`, mirroring runLiftDispatch. */ + private def boxToLiftResponse(box: net.liftweb.common.Box[LiftResponse], liftReq: Req): LiftResponse = + box match { + case Full(resp) => resp + case ParamFailure(_, _, _, apiFailure: APIFailure) => + APIUtil.errorJsonResponse(apiFailure.msg, apiFailure.responseCode) + case Failure(msg, _, _) => + APIUtil.errorJsonResponse(msg) + case Empty => + val contentType = liftReq.request.headers("Content-Type").headOption.getOrElse("") + APIUtil.errorJsonResponse( + s"${InvalidUri}Current Url is (${liftReq.request.uri}), Current Content-Type Header is ($contentType)", 404) + } + + private def dispatch(req: Request[IO]): OptionT[IO, Response[IO]] = OptionT { + val io: IO[Option[LiftResponse]] = for { + bodyBytes <- req.body.compile.to(Array) + liftReq = Http4sLiftWebBridge.buildLiftReq(req, bodyBytes) + liftRespOpt <- IO { + val session = LiftRules.statelessSession.vend.apply(liftReq) + S.init(Full(liftReq), session) { + try { + // collectFirst's guard runs each route's isDefinedAt (per-request DB lookup); + // pf(liftReq) eagerly runs failIfBadAuthorizationHeader/failIfBadJSON, so a + // JsonResponseException (auth / interceptor) can surface here — hence the try wraps both. + wrappedRoutes.collectFirst { case pf if pf.isDefinedAt(liftReq) => pf(liftReq) } match { + case None => Option.empty[LiftResponse] + case Some(run) => Some(boxToLiftResponse(run(), liftReq)) + } + } catch { + case JsonResponseException(jsonResponse) => Some(jsonResponse) + case e if e.getClass.getName == "net.liftweb.http.rest.ContinuationException" => + Some(Http4sLiftWebBridge.resolveContinuation(e)) + } + } + } + } yield liftRespOpt + + io.flatMap { + case None => IO.pure(Option.empty[Response[IO]]) + case Some(lr) => Http4sLiftWebBridge.liftResponseToHttp4s(lr).map(Some(_)) + }.handleErrorWith { e => + // A matched dynamic-endpoint handler threw an unexpected (non-JsonResponse) exception. + // The Lift bridge converted such cases to a 500; do the same here so it does not escape + // as an unhandled IO failure. (No fall-through: a handler had claimed the request.) + logger.error(s"[Http4sDynamicEndpoint] uncaught exception dispatching ${req.method} ${req.uri.renderString}: ${e.getMessage}", e) + Http4sLiftWebBridge.liftResponseToHttp4s(APIUtil.errorJsonResponse(s"$UnknownError ${e.getMessage}", 500)).map(Some(_)) + } + } + + /** Entry point wired into Http4sApp.baseServices (before the Lift bridge). */ + lazy val wrappedRoutesDynamicEndpoint: HttpRoutes[IO] = + Kleisli[HttpF, Request[IO], Response[IO]] { (req: Request[IO]) => + req.uri.path.segments.map(_.encoded).toList match { + case standard :: version :: _ if standard == apiStandard && version == apiVersionString => + dispatch(req) + case _ => + OptionT.none[IO, Response[IO]] + } + } +} diff --git a/obp-api/src/main/scala/code/api/dynamic/endpoint/OBPAPIDynamicEndpoint.scala b/obp-api/src/main/scala/code/api/dynamic/endpoint/OBPAPIDynamicEndpoint.scala index 4be891c3f6..3c27f71af4 100644 --- a/obp-api/src/main/scala/code/api/dynamic/endpoint/OBPAPIDynamicEndpoint.scala +++ b/obp-api/src/main/scala/code/api/dynamic/endpoint/OBPAPIDynamicEndpoint.scala @@ -66,24 +66,32 @@ object OBPAPIDynamicEndpoint extends OBPRestHelper with MdcLoggable with Version DynamicEndpoints.dynamicEndpoint ) - routes.map(endpoint => oauthServe(apiPrefix{endpoint}, None)) - + // dynamic-endpoint dispatch migrated to native http4s (code.api.dynamic.endpoint.Http4sDynamicEndpoint). + // The Http4sDynamicEndpoint adapter rebuilds the wrapped form from `routes` directly + // (routes.map(apiPrefix andThen buildOAuthHandler)) and applies it in-process, so the Lift + // statelessDispatch self-registration below is no longer used. `routes` itself is kept — it is + // the adapter's source list and is also read by ResourceDocs aggregation. + // 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 for dynamic-endpoint is now handled globally by Http4sApp.corsHandler (which + // short-circuits all OPTIONS ahead of the version routes). The Lift OPTIONS serve below became + // dead once dynamic-endpoint left statelessDispatch — kept commented for reference. + // // 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 + // }) } 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 cf84c192e2..df593bbed8 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -2874,7 +2874,10 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ case ApiVersion.v5_0_0 => LiftRules.statelessDispatch.append(v5_0_0.OBPAPI5_0_0) 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) + // dynamic-endpoint dispatch migrated to Http4sDynamicEndpoint (wired into Http4sApp.baseServices). + // Keep the case label with an empty body so ApiVersion.`dynamic-endpoint` does NOT fall through + // to the ScannedApiVersion branch below (which would re-append it via ScannedApis). + case ApiVersion.`dynamic-endpoint` => // LiftRules.statelessDispatch.append(OBPAPIDynamicEndpoint) // 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). 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 93622dd0bc..a87d429772 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 @@ -75,9 +75,13 @@ object Http4sApp { 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. + // OBPAPIDynamicEntity dispatch. private val dynamicEntityRoutes: HttpRoutes[IO] = gate(ApiVersion.`dynamic-entity`, code.api.dynamic.entity.Http4sDynamicEntity.wrappedRoutesDynamicEntity) + // DynamicEndpoint dispatch (/obp/dynamic-endpoint/*) — proxy (DynamicReq) + runtime-compiled + // resource docs / practise. Runs the Lift OBPAPIDynamicEndpoint.routes in-process via an + // adapter, replacing their LiftRules.statelessDispatch registration. Must sit AHEAD of the + // Lift bridge (the bridge no longer carries dynamic-endpoint). + private val dynamicEndpointRoutes: HttpRoutes[IO] = gate(ApiVersion.`dynamic-endpoint`, code.api.dynamic.endpoint.Http4sDynamicEndpoint.wrappedRoutesDynamicEndpoint) /** * Build the base HTTP4S routes with priority-based routing. @@ -132,6 +136,7 @@ object Http4sApp { .orElse(v130Routes.run(req)) .orElse(v121Routes.run(req)) .orElse(dynamicEntityRoutes.run(req)) + .orElse(dynamicEndpointRoutes.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/Http4sLiftWebBridge.scala b/obp-api/src/main/scala/code/api/util/http4s/Http4sLiftWebBridge.scala index 2abdcbaf08..5677490019 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/Http4sLiftWebBridge.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/Http4sLiftWebBridge.scala @@ -324,7 +324,11 @@ object Http4sLiftWebBridge extends MdcLoggable { } } - private def resolveContinuation(exception: Throwable): LiftResponse = { + // Visibility raised from private to public so the in-process Lift adapter in + // code.api.dynamic.endpoint.Http4sDynamicEndpoint (a different package) can reuse the + // exact same Lift Req construction / response conversion / continuation resolution that + // this bridge uses. Signatures are unchanged. + def resolveContinuation(exception: Throwable): LiftResponse = { logger.debug(s"Resolving ContinuationException for async Lift handler") val func = ReflectUtils @@ -339,7 +343,7 @@ object Http4sLiftWebBridge extends MdcLoggable { } } - private def buildLiftReq(req: Request[IO], body: Array[Byte]): Req = { + def buildLiftReq(req: Request[IO], body: Array[Byte]): Req = { val headers = http4sHeadersToParams(req.headers.headers) val params = http4sParamsToParams(req.uri.query.multiParams.toList) val httpRequest = new Http4sLiftRequest( @@ -380,7 +384,7 @@ object Http4sLiftWebBridge extends MdcLoggable { } } - private def liftResponseToHttp4s(response: LiftResponse): IO[Response[IO]] = { + def liftResponseToHttp4s(response: LiftResponse): IO[Response[IO]] = { response.toResponse match { case InMemoryResponse(data, headers, _, code) => IO.pure(buildHttp4sResponse(code, data, headers)) From 655271a1dabd98fa0bca5fc524300136b879057e Mon Sep 17 00:00:00 2001 From: Hongwei Date: Thu, 28 May 2026 01:24:42 +0200 Subject: [PATCH 02/14] Serve dynamic-endpoint proxy (Piece B) natively on http4s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 1 of removing the Lift adapter from the dynamic-endpoint dispatch: the proxy path (DynamicReq-matched requests proxied to a backend connector or obp_mock) is now served by a native http4s handler instead of building a Lift Req via buildLiftReq and running inside S.init. - DynamicEndpointHelper: extract the framework-neutral core of DynamicReq.unapply into DynamicReq.resolveProxyTarget(method, partPath, query, body). The Lift unapply now delegates to it after its content-type/prefix gate; the native dispatcher calls the same method, so both build the identical proxy 9-tuple from the same DB lookup (dynamicEndpointInfos / findDynamicEndpoint). - APIMethodsDynamicEndpoint: extract proxyHandle(...) -> Future[(JValue, Int)], the framework-neutral proxy logic (before/after authenticate interceptors, authentication, entitlement check, dynamic-entity mapping branch or mock/connector proxy). The before interceptor is reduced to (message, code) via JsonResponseExtractor + booleanToFuture (mirroring the existing after interceptor and Http4sDynamicEntity) instead of returning a Lift JsonResponse directly. The Lift dynamicEndpoint handler now delegates to proxyHandle. - Http4sDynamicEndpoint: add native proxy(req) — builds CallContext via Http4sCallContextBuilder, matches via resolveProxyTarget, runs proxyHandle and renders the connector/mock status code through the new EndpointHelpers.executeFutureWithStatus. Tried ahead of the Lift adapter; a non-match falls through to the adapter, which still serves Piece C (runtime-compiled endpoints). Proxy writes stay on auto-commit (no withBusinessDBTransaction), matching the prior bridge/adapter behaviour. - Http4sSupport: add EndpointHelpers.executeFutureWithStatus for rendering a (result, statusCode) pair with a dynamic HTTP status + metric + error handling. The mock-response thread-local (MockResponseHolder) is read synchronously by the connector at Future-construction time, so wrapping the connector call in MockResponseHolder.init inside proxyHandle preserves behaviour on the cats-effect thread pool. Verified on JDK 11: 154 / 154 pass across DynamicEndpointsTest (proxy E2E), DynamicEndpointHelperTest, ForceError/JsonSchema/AuthenticationType validation (interceptor regression), DynamicResourceDocTest, DynamicMessageDocTest, DynamicIntegrationTest, DynamicUtilTest. --- .../endpoint/APIMethodsDynamicEndpoint.scala | 54 ++++++-- .../endpoint/Http4sDynamicEndpoint.scala | 86 +++++++++---- .../helper/DynamicEndpointHelper.scala | 119 ++++++++++-------- .../code/api/util/http4s/Http4sSupport.scala | 21 ++++ 4 files changed, 191 insertions(+), 89 deletions(-) diff --git a/obp-api/src/main/scala/code/api/dynamic/endpoint/APIMethodsDynamicEndpoint.scala b/obp-api/src/main/scala/code/api/dynamic/endpoint/APIMethodsDynamicEndpoint.scala index f6877bb604..17e8799a6d 100644 --- a/obp-api/src/main/scala/code/api/dynamic/endpoint/APIMethodsDynamicEndpoint.scala +++ b/obp-api/src/main/scala/code/api/dynamic/endpoint/APIMethodsDynamicEndpoint.scala @@ -59,21 +59,46 @@ trait APIMethodsDynamicEndpoint { box.openOrThrowException("impossible error") } - lazy val dynamicEndpoint: OBPEndpoint = { - case DynamicReq(url, json, method, params, pathParams, role, operationId, mockResponse, bankId) => { cc => - // process before authentication interceptor, get intercept result + /** + * Framework-neutral proxy logic for a matched dynamic-endpoint, shared by the Lift + * `dynamicEndpoint` handler (below) and the native http4s dispatcher + * (code.api.dynamic.endpoint.Http4sDynamicEndpoint). Runs the before/after authenticate + * interceptors, authentication, the entitlement check, and either the dynamic-entity mapping + * branch or the proxy/mock connector call. Returns the response body JValue paired with the + * HTTP status code carried by the connector/mock result (the Lift handler re-wraps it into a + * CallContext.httpCode; the http4s handler renders the status directly). + * + * The before-authenticate interceptor (which the Lift handler used to short-circuit by + * returning its JsonResponse directly) is reduced here to (message, code) via + * JsonResponseExtractor and re-raised through booleanToFuture, mirroring the after-interceptor + * handling below and Http4sDynamicEntity — same code/message, no Lift JsonResponse rendering. + */ + def proxyHandle( + url: String, + json: JValue, + method: org.apache.pekko.http.scaladsl.model.HttpMethod, + params: Map[String, List[String]], + pathParams: Map[String, String], + role: ApiRole, + operationId: String, + mockResponse: Option[(Int, JValue)], + bankId: Option[String], + cc: CallContext + ): Future[(JValue, Int)] = { val resourceDoc = DynamicEndpointHelper.doc.find(_.operationId == operationId) val callContext = cc.copy(operationId = Some(operationId), resourceDocument = resourceDoc) - val beforeInterceptResult: Box[JsonResponse] = beforeAuthenticateInterceptResult(Option(callContext), operationId) - if (beforeInterceptResult.isDefined) beforeInterceptResult - else for { + // process before authentication interceptor; a non-empty result short-circuits (rendered with its own code). + // Computed before the for-comprehension (a for-comprehension cannot begin with an `=` assignment). + val beforeJsonResponse: Box[ErrorMessage] = beforeAuthenticateInterceptResult(Option(callContext), operationId).collect({ + case JsonResponseExtractor(message, code) => ErrorMessage(code, message) + }) + for { + _ <- Helper.booleanToFuture(failMsg = beforeJsonResponse.map(_.message).orNull, failCode = beforeJsonResponse.map(_.code).openOr(400), cc = Option(callContext)) { + beforeJsonResponse.isEmpty + } (Full(u), callContext) <- authenticatedAccess(callContext) // Inject operationId into Call Context. It's used by Rate Limiting. _ <- NewStyle.function.hasEntitlement(bankId.getOrElse(""), u.userId, role, callContext) - // validate request json payload - httpRequestMethod = cc.verb - path = StringUtils.substringAfter(cc.url, DynamicEndpointHelper.urlPrefix) - // process after authentication interceptor, get intercept result jsonResponse: Box[ErrorMessage] = afterAuthenticateInterceptResult(callContext, operationId).collect({ case JsonResponseExtractor(message, code) => ErrorMessage(code, message) @@ -190,7 +215,7 @@ trait APIMethodsDynamicEndpoint { box match { case Full(v) => val code = (v \ "code").asInstanceOf[JInt].num.toInt - (v \ "value", callContext.map(_.copy(httpCode = Some(code)))) + (v \ "value", code) case e: Failure => val changedMsgFailure = e.copy(msg = s"$InternalServerError ${e.msg}") @@ -199,6 +224,13 @@ trait APIMethodsDynamicEndpoint { } } + } + + lazy val dynamicEndpoint: OBPEndpoint = { + case DynamicReq(url, json, method, params, pathParams, role, operationId, mockResponse, bankId) => { cc => + proxyHandle(url, json, method, params, pathParams, role, operationId, mockResponse, bankId, cc).map { + case (value, code) => (value, Option(cc.copy(httpCode = Some(code)))) + } } } } diff --git a/obp-api/src/main/scala/code/api/dynamic/endpoint/Http4sDynamicEndpoint.scala b/obp-api/src/main/scala/code/api/dynamic/endpoint/Http4sDynamicEndpoint.scala index 4a0dab8e71..b3188e5ecb 100644 --- a/obp-api/src/main/scala/code/api/dynamic/endpoint/Http4sDynamicEndpoint.scala +++ b/obp-api/src/main/scala/code/api/dynamic/endpoint/Http4sDynamicEndpoint.scala @@ -27,15 +27,22 @@ package code.api.dynamic.endpoint import cats.data.{Kleisli, OptionT} import cats.effect.IO +import code.api.dynamic.endpoint.helper.DynamicEndpointHelper import code.api.util.APIUtil +import code.api.util.CustomJsonFormats import code.api.util.ErrorMessages.{InvalidUri, UnknownError} import code.api.util.http4s.Http4sLiftWebBridge +import code.api.util.http4s.Http4sRequestAttributes.EndpointHelpers +import code.api.util.http4s.{Http4sCallContextBuilder, Http4sRequestAttributes} import code.api.{APIFailure, JsonResponseException} import code.util.Helper.MdcLoggable import com.openbankproject.commons.util.{ApiShortVersions, ApiStandards} import net.liftweb.common.{Empty, Failure, Full, ParamFailure} import net.liftweb.http.{LiftResponse, LiftRules, Req, S} +import net.liftweb.json.Formats +import net.liftweb.json.JsonAST.{JNothing, JValue} import org.http4s.{HttpRoutes, Request, Response} +import org.typelevel.ci.CIString /** * Native http4s entry point for the OBP dynamic-endpoint dispatch (under /obp/dynamic-endpoint/). @@ -50,39 +57,67 @@ import org.http4s.{HttpRoutes, Request, Response} * practise / dynamic-resource-doc endpoints compiled from user Scala via * `DynamicUtil.compileScalaCode[OBPEndpoint]`. * - * Why an in-process Lift adapter (not a native rewrite): - * - Piece C's compiled artifact has its type hard-wired to Lift - * (`PartialFunction[Req, CallContext => Box[JsonResponse]]`, generated code imports - * `net.liftweb.http.{JsonResponse, Req}`), so it can only be RUN, never natively rewritten. - * - dynamic-endpoint already ran through the http4s -> Lift bridge today (it was on - * statelessDispatch, which the bridge iterates), i.e. the Req was already built by - * `Http4sLiftWebBridge.buildLiftReq`. This migration does NOT change Req construction, - * body buffering, or the (auto-commit) transaction behaviour — it only relocates the - * `collectFirst` from the bridge's global statelessDispatch list into this dedicated, - * dynamic-endpoint-only service positioned ahead of the bridge. + * Stage 1 — Piece B is served NATIVELY by [[proxy]]: the request is matched by + * `DynamicEndpointHelper.DynamicReq.resolveProxyTarget` (the framework-neutral core of the Lift + * `DynamicReq` extractor) and run through the shared `APIMethodsDynamicEndpoint.proxyHandle` + * (auth / entitlement / before+after interceptors / mock-or-connector proxy). No Lift `Req`, + * `S.init`, `buildLiftReq` or `liftResponseToHttp4s` on this path. The dynamic status code carried + * by the connector / obp_mock result is rendered via `EndpointHelpers.executeFutureWithStatus`. + * Proxy writes run on auto-commit (no `withBusinessDBTransaction`), matching the prior bridge/adapter + * behaviour. * - * Mechanics (a faithful, narrowed copy of `Http4sLiftWebBridge.runLiftDispatch`): - * 1. Buffer the body and build a Lift `Req` with `buildLiftReq` (full uri `/obp/dynamic-endpoint/...` - * so `DynamicReq`'s prefix gate passes). - * 2. Inside `S.init` (required: failIfBadAuthorizationHeader reads `S.request`, and Lift - * `Req.body`/`json`/`testResponse_?` resolve only in that scope), `collectFirst` over the - * SAME wrapped form Lift registered — `routes.map(apiPrefix andThen buildOAuthHandler)` — and run it. - * 3. Reduce the `Box[LiftResponse]` exactly as runLiftDispatch does (Full / ParamFailure / - * Failure / Empty), catching `JsonResponseException` (force-error / json-schema / auth - * interceptors) and Lift `ContinuationException` (async). - * 4. No match -> `OptionT.none` so the request falls through the Http4sApp chain (eventually - * the bridge produces the final 404, just as before). - * - * No `withBusinessDBTransaction` wrap: the bridge path that served dynamic-endpoint until now did - * not wrap either (writes ran on auto-commit connections), so omitting it preserves behaviour. + * Piece C is STILL served by the in-process Lift adapter ([[dispatch]]) because its compiled + * artifact is hard-wired to Lift (`PartialFunction[Req, CallContext => Box[JsonResponse]]`, + * generated code imports `net.liftweb.http.{JsonResponse, Req}`) and can only be RUN, not natively + * rewritten. The native [[proxy]] is tried first; a non-match falls through to [[dispatch]], whose + * `collectFirst` over `OBPAPIDynamicEndpoint.routes` then serves Piece C. (Stage 2 will redefine the + * Piece C contract to native http4s and remove the adapter entirely.) */ object Http4sDynamicEndpoint extends MdcLoggable { private type HttpF[A] = OptionT[IO, A] + private implicit val formats: Formats = CustomJsonFormats.formats + private val apiStandard = ApiStandards.obp.toString private val apiVersionString = ApiShortVersions.`dynamic-endpoint`.toString // "dynamic-endpoint" + private def queryParams(req: Request[IO]): Map[String, List[String]] = + req.uri.query.multiParams.map { case (k, vs) => k -> vs.toList } + + // Mirror of the Lift DynamicReq gate `testResponse_?`: only treat the request as a dynamic-endpoint + // proxy candidate when it is JSON (Content-Type or Accept carries json). A non-JSON request returns + // OptionT.none so it falls through to the Piece C adapter / Http4sApp chain, exactly as before. + private def isJsonRequest(req: Request[IO]): Boolean = { + def header(name: String): String = req.headers.get(CIString(name)).map(_.head.value).getOrElse("") + header("Content-Type").toLowerCase.contains("json") || header("Accept").toLowerCase.contains("json") + } + + /** + * Native Piece B (proxy) handler. Matches via `DynamicEndpointHelper.DynamicReq.resolveProxyTarget` + * (same DB lookup the Lift `DynamicReq.unapply` used) and runs the shared, framework-neutral + * `APIMethodsDynamicEndpoint.proxyHandle`. The CallContext is built by `Http4sCallContextBuilder` + * and attached so `EndpointHelpers.executeFutureWithStatus` can reuse the error conversion + metric; + * auth / entitlement run inside `proxyHandle`. No match -> `OptionT.none` (fall through to [[dispatch]]). + */ + private def proxy(req: Request[IO]): OptionT[IO, Response[IO]] = + if (!isJsonRequest(req)) OptionT.none[IO, Response[IO]] + else OptionT { + val partPath = req.uri.path.segments.drop(2).map(_.encoded).toList // segments after obp/dynamic-endpoint + Http4sCallContextBuilder.fromRequest(req, apiVersionString).flatMap { cc0 => + val bodyJValue: JValue = cc0.httpBody.filter(_.nonEmpty).map(net.liftweb.json.parse).getOrElse(JNothing) + DynamicEndpointHelper.DynamicReq.resolveProxyTarget(req.method.name, partPath, queryParams(req), bodyJValue) match { + case None => IO.pure(Option.empty[Response[IO]]) + case Some((url, json, method, params, pathParams, role, operationId, mockResponse, bankId)) => + val reqWithCc = req.withAttribute(Http4sRequestAttributes.callContextKey, cc0) + EndpointHelpers.executeFutureWithStatus(reqWithCc) { + APIMethodsDynamicEndpoint.ImplementationsDynamicEndpoint.proxyHandle( + url, json, method, params, pathParams, role, operationId, mockResponse, bankId, cc0) + }.map(Some(_)) + } + } + } + /** * The exact wrapped form Lift held in statelessDispatch for dynamic-endpoint: * `routes.map(endpoint => oauthServe(apiPrefix{endpoint}, None))`. `oauthServe` registers, @@ -150,7 +185,8 @@ object Http4sDynamicEndpoint extends MdcLoggable { Kleisli[HttpF, Request[IO], Response[IO]] { (req: Request[IO]) => req.uri.path.segments.map(_.encoded).toList match { case standard :: version :: _ if standard == apiStandard && version == apiVersionString => - dispatch(req) + // Native Piece B (proxy) first; a non-match falls through to the Lift adapter for Piece C. + proxy(req).orElse(dispatch(req)) case _ => OptionT.none[IO, Response[IO]] } diff --git a/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/DynamicEndpointHelper.scala b/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/DynamicEndpointHelper.scala index 0866e8e09e..70a569a7ea 100644 --- a/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/DynamicEndpointHelper.scala +++ b/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/DynamicEndpointHelper.scala @@ -173,68 +173,81 @@ object DynamicEndpointHelper extends RestHelper { * @return (adapterUrl, requestBodyJson, httpMethod, requestParams, pathParams, role, operationId, mockResponseCode->mockResponseBody) */ def unapply(r: Req): Option[(String, JValue, PekkoHttpMethod, Map[String, List[String]], Map[String, String], ApiRole, String, Option[(Int, JValue)], Option[String])] = { - + val requestUri = r.request.uri //eg: `/obp/dynamic-endpoint/fashion-brand-list/BRAND_ID` val partPath = r.path.partPath //eg: List("fashion-brand-list","BRAND_ID"), the dynamic is from OBP URL, not in the partPath now. - + if (!testResponse_?(r) || !requestUri.startsWith(s"/${ApiStandards.obp.toString}/${ApiShortVersions.`dynamic-endpoint`.toString}"+urlPrefix))//if check the Content-Type contains json or not, and check the if it is the `dynamic_endpoints_url_prefix` None //if do not match `URL and Content-Type`, then can not find this endpoint. return None. - else { - val pekkoHttpMethod = HttpMethods.getForKeyCaseInsensitive(r.requestType.method).get - val httpMethod = HttpMethod.valueOf(r.requestType.method) - val urlQueryParameters = r.params - // url that match original swagger endpoint. - val url = partPath.mkString("/", "/", "") // eg: --> /feature-test - val foundDynamicEndpoint: Option[(String, String, Int, ResourceDoc, Option[String])] = dynamicEndpointInfos - .map(_.findDynamicEndpoint(httpMethod, url)) - .collectFirst { - case Some(x) => x - } + else + resolveProxyTarget(r.requestType.method, partPath, r.params, body(r).getOrElse(JNothing)) + } - foundDynamicEndpoint - .flatMap { it => - val (serverUrl, endpointUrl, code, doc, bankId) = it - - val pathParams: Map[String, String] = if(endpointUrl == url) { - Map.empty[String, String] - } else { - val tuples: Array[(String, String)] = StringUtils.split(endpointUrl, "/").zip(partPath) - tuples.collect { - case (ExpressionRegx(name), value) => name->value - }.toMap - } + /** + * Framework-neutral core of [[unapply]]: given the HTTP method name, the path segments + * AFTER the `/obp/dynamic-endpoint` prefix (Lift's `r.path.partPath`), the query params and + * the already-parsed request body, resolve the matching dynamic-endpoint to the proxy 9-tuple. + * Shared by the Lift `unapply` (above) and the native http4s dispatcher + * (code.api.dynamic.endpoint.Http4sDynamicEndpoint) so both build the identical tuple from the + * same DB lookup (`dynamicEndpointInfos` / `findDynamicEndpoint`) — only the request decoding differs. + */ + def resolveProxyTarget( + httpMethodStr: String, + partPath: List[String], + urlQueryParameters: Map[String, List[String]], + requestBodyJValue: JValue + ): Option[(String, JValue, PekkoHttpMethod, Map[String, List[String]], Map[String, String], ApiRole, String, Option[(Int, JValue)], Option[String])] = { + val pekkoHttpMethod = HttpMethods.getForKeyCaseInsensitive(httpMethodStr).get + val httpMethod = HttpMethod.valueOf(httpMethodStr) + // url that match original swagger endpoint. + val url = partPath.mkString("/", "/", "") // eg: --> /feature-test + val foundDynamicEndpoint: Option[(String, String, Int, ResourceDoc, Option[String])] = dynamicEndpointInfos + .map(_.findDynamicEndpoint(httpMethod, url)) + .collectFirst { + case Some(x) => x + } - val mockResponse: Option[(Int, JValue)] = (serverUrl, doc.successResponseBody) match { - case (IsMockUrl(), v: PrimaryDataBody[_]) => - //If the openAPI json do not have response body, we return true as default - val response = if (v.toJValue == JNothing) { - JBool(true) - } else{ - v.toJValue - } - Some(code -> response) - - case (IsMockUrl(), v: JValue) => - //If the openAPI json do not have response body, we return true as default - val response = if (v == JNothing) { - JBool(true) - } else{ - v - } - Some(code -> response) - - case (IsMockUrl(), v) => - Some(code -> json.Extraction.decompose(v)) - - case _ => None - } + foundDynamicEndpoint + .flatMap { it => + val (serverUrl, endpointUrl, code, doc, bankId) = it + + val pathParams: Map[String, String] = if(endpointUrl == url) { + Map.empty[String, String] + } else { + val tuples: Array[(String, String)] = StringUtils.split(endpointUrl, "/").zip(partPath) + tuples.collect { + case (ExpressionRegx(name), value) => name->value + }.toMap + } - val Some(role::_) = doc.roles - val requestBodyJValue = body(r).getOrElse(JNothing) - Full(s"""$serverUrl$url""", requestBodyJValue, pekkoHttpMethod, urlQueryParameters, pathParams, role, doc.operationId, mockResponse, bankId) + val mockResponse: Option[(Int, JValue)] = (serverUrl, doc.successResponseBody) match { + case (IsMockUrl(), v: PrimaryDataBody[_]) => + //If the openAPI json do not have response body, we return true as default + val response = if (v.toJValue == JNothing) { + JBool(true) + } else{ + v.toJValue + } + Some(code -> response) + + case (IsMockUrl(), v: JValue) => + //If the openAPI json do not have response body, we return true as default + val response = if (v == JNothing) { + JBool(true) + } else{ + v + } + Some(code -> response) + + case (IsMockUrl(), v) => + Some(code -> json.Extraction.decompose(v)) + + case _ => None } - } + val Some(role::_) = doc.roles + Full(s"""$serverUrl$url""", requestBodyJValue, pekkoHttpMethod, urlQueryParameters, pathParams, role, doc.operationId, mockResponse, bankId) + } } } diff --git a/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala b/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala index 0cae969002..67a46e26ee 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala @@ -444,6 +444,27 @@ object Http4sRequestAttributes { } } + /** + * Execute Future-based business logic that returns a (result, statusCode) pair, rendering the + * result JSON with the caller-supplied HTTP status. Converts errors via ErrorResponseConverter. + * + * Used by the native dynamic-endpoint proxy (code.api.dynamic.endpoint.Http4sDynamicEndpoint), + * where the status code is dynamic — it comes from the backend connector / obp_mock response + * (the `code` field), not a fixed 200/201. The caller has already built and attached the + * CallContext (auth/role checks run inside `f`); on a thrown auth/role failure the `.attempt` + * branch renders the correct 401/403 via ErrorResponseConverter, exactly like the other helpers. + */ + def executeFutureWithStatus[A](req: Request[IO])(f: => Future[(A, Int)])(implicit formats: Formats): IO[Response[IO]] = { + implicit val cc: CallContext = req.callContext + RequestScopeConnection.fromFuture(f).attempt.flatMap { + case Right((result, code)) => + val jsonString = prettyRender(Extraction.decompose(result)) + val status = Status.fromInt(code).getOrElse(Status.Ok) + IO.pure(Response[IO](status).withEntity(jsonString)).flatTap(recordMetric(result, _)) + case Left(err) => ErrorResponseConverter.toHttp4sResponse(err, cc).flatTap(recordMetric(err.getMessage, _)) + } + } + /** * Execute DELETE business logic (no auth required). * Returns 204 No Content on success, converts errors via ErrorResponseConverter. From a24396d1b207808e33340e6957cf316488992e5d Mon Sep 17 00:00:00 2001 From: Hongwei Date: Thu, 28 May 2026 02:07:43 +0200 Subject: [PATCH 03/14] Migrate dynamic-endpoint runtime-compiled dispatch (Piece C) to native http4s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 2 (final) of removing the Lift adapter from the dynamic-endpoint dispatch. The runtime-compiled / practise endpoints are now served natively; Http4sDynamicEndpoint no longer uses buildLiftReq / liftResponseToHttp4s / S.init / statelessSession at all. The dynamic-code authoring contract is redefined from Lift to native http4s: process(callContext, request: net.liftweb.http.Req, pathParams): Box[JsonResponse] becomes process(callContext, request: org.http4s.Request[IO], pathParams): IO[Response[IO]] Bodies read the request payload from callContext.httpBody, return errors via the new errorResponse(msg, code) helper (replacing Full(errorJsonResponse(...))), and may still yield an OBPReturnType which an injected implicit converts to IO[Response[IO]] (status from CallContext.httpCode). BREAKING: existing DB-stored methodBody rows written against the Lift contract no longer compile; DynamicResourceDocsEndpointGroup now isolates a failing row (log + skip) instead of crashing the group/boot, with a message to re-author against the native contract. Changes: - APIUtil: add type OBPEndpointIO = PartialFunction[Request[IO], CallContext => IO[Response[IO]]] (distinct from the Lift OBPEndpoint, which is shared by every static endpoint and unchanged); add ResourceDoc.dynamicHttp4sFunction: Option[OBPEndpointIO] = None to carry the compiled native handler; add ResourceDoc.matchesPartPath (public form of the wrappedWithAuthCheck URL match) and ResourceDoc.authCheckIO (native mirror of wrappedWithAuthCheck's auth/obp-id/bank/roles/account/view/counterparty chain, reusing the same predicates + *Fun). - DynamicCompileEndpoint: process returns IO[Response[IO]]; endpoint is OBPEndpointIO; add the OBPReturnType[T] => IO[Response[IO]] implicit and errorResponse helper; run via runInSandboxIO. - DynamicUtil.Sandbox: add runInSandboxIO — builds the body's IO under the privileged context (synchronous construction restricted, matching the Lift path) and evaluates it outside, and recovers a NonLocalReturnControl so a `return errorResponse(...)` in template code yields its response instead of a 500. - DynamicEndpoints: native code-generation template (OBPEndpointIO, http4s imports, pathParams from request.uri segments); EndpointGroup drops the Lift endpoints/wrapEndpoint; findEndpoint now takes Request[IO] and returns the matched ResourceDoc; the Lift dynamicEndpoint is removed. - DynamicResourceDocsEndpointGroup / PractiseEndpointGroup: carry the compiled handler in dynamicHttp4sFunction with partialFunction = dynamicEndpointStub; per-row try/skip for legacy rows. - PractiseEndpoint / ExampleValue.dynamicResourceDocMethodBodyExample: rewritten to the native contract (the body operators copy from). - Http4sDynamicEndpoint: native pieceC dispatch (findEndpoint -> authCheckIO -> compiled handler in sandbox -> IO[Response]); the entire Lift adapter is deleted. Entry is proxy.orElse(pieceC). - OBPAPIDynamicEndpoint.routes: drop the removed Lift Piece C entry. - DynamicResourceDocTest: add native-execution E2E scenarios (practise endpoint anonymous; create-and-call a runtime-compiled doc — happy path, and no-body 400 exercising the NonLocalReturn recovery). These prove the doc RUNS, not just compiles. - test props (build_pull_request.yml + test.default.props.template): grant the standard dynamic_code_sandbox_permissions (reflection / getenv) so dynamic bodies can execute under the sandbox in tests, matching default.props / production.default.props. Verified on JDK 11: 156 / 156 pass across DynamicResourceDocTest (incl. the 2 new E2E), DynamicEndpointsTest, DynamicEndpointHelperTest, DynamicMessageDocTest, DynamicIntegrationTest, DynamicUtilTest, and ForceError / JsonSchema / AuthenticationType interceptor regression. --- .github/workflows/build_pull_request.yml | 5 + .../props/test.default.props.template | 15 ++ .../endpoint/Http4sDynamicEndpoint.scala | 143 +++++++----------- .../endpoint/OBPAPIDynamicEndpoint.scala | 19 +-- .../helper/DynamicCompileEndpoint.scala | 60 ++++++-- .../endpoint/helper/DynamicEndpoints.scala | 137 ++++++----------- .../DynamicResourceDocsEndpointGroup.scala | 23 ++- .../helper/practise/PractiseEndpoint.scala | 23 +-- .../practise/PractiseEndpointGroup.scala | 9 +- .../main/scala/code/api/util/APIUtil.scala | 85 ++++++++++- .../scala/code/api/util/DynamicUtil.scala | 25 +++ .../scala/code/api/util/ExampleValue.scala | 36 +++-- .../api/v4_0_0/DynamicResourceDocTest.scala | 47 ++++++ 13 files changed, 385 insertions(+), 242 deletions(-) diff --git a/.github/workflows/build_pull_request.yml b/.github/workflows/build_pull_request.yml index 411bf39c40..2c4af6d108 100644 --- a/.github/workflows/build_pull_request.yml +++ b/.github/workflows/build_pull_request.yml @@ -243,6 +243,11 @@ jobs: # there's no mail server in CI. That surfaces as 500 in any test that # hits an endpoint triggering the notification (v5 consent flows, etc.). echo mail.test.mode=true >> obp-api/src/main/resources/props/test.default.props + # Permissions granted to runtime-compiled dynamic-endpoint code inside the security sandbox + # (mirrors default.props / production.default.props). Required so dynamic resource-doc bodies + # can do JSON extraction (reflection) and read OBP props (getenv); without it the sandbox + # denies these and DynamicResourceDocTest's native-execution scenarios fail. + echo 'dynamic_code_sandbox_permissions=[new java.net.NetPermission("specifyStreamHandler"), new java.lang.reflect.ReflectPermission("suppressAccessChecks"), new java.lang.RuntimePermission("getenv.*"), new java.util.PropertyPermission("cglib.useCache", "read"), new java.util.PropertyPermission("net.sf.cglib.test.stressHashCodes", "read"), new java.util.PropertyPermission("cglib.debugLocation", "read"), new java.lang.RuntimePermission("accessDeclaredMembers"), new java.lang.RuntimePermission("getClassLoader")]' >> obp-api/src/main/resources/props/test.default.props - name: Run tests — shard ${{ matrix.shard }} (${{ matrix.name }}) run: | diff --git a/obp-api/src/main/resources/props/test.default.props.template b/obp-api/src/main/resources/props/test.default.props.template index 78cf242755..240f3312f9 100644 --- a/obp-api/src/main/resources/props/test.default.props.template +++ b/obp-api/src/main/resources/props/test.default.props.template @@ -146,3 +146,18 @@ allow_public_views =true # requests + N background queries = 2*N connections needed. Default of 10 is exhausted by # the 10-thread concurrency tests. Set to 20 to provide headroom. hikari.maximumPoolSize=20 + +# Permissions granted to runtime-compiled dynamic-endpoint code inside the security sandbox. +# Mirrors default.props / production.default.props. Required so dynamic resource-doc bodies can do +# JSON extraction (reflection) and read OBP props (getenv); without it the sandbox denies these and +# dynamic-endpoint EXECUTION cannot run (only metadata CRUD / compilation). See DynamicResourceDocTest. +dynamic_code_sandbox_permissions=[\ + new java.net.NetPermission("specifyStreamHandler"),\ + new java.lang.reflect.ReflectPermission("suppressAccessChecks"),\ + new java.lang.RuntimePermission("getenv.*"),\ + new java.util.PropertyPermission("cglib.useCache", "read"),\ + new java.util.PropertyPermission("net.sf.cglib.test.stressHashCodes", "read"),\ + new java.util.PropertyPermission("cglib.debugLocation", "read"),\ + new java.lang.RuntimePermission("accessDeclaredMembers"),\ + new java.lang.RuntimePermission("getClassLoader")\ +] diff --git a/obp-api/src/main/scala/code/api/dynamic/endpoint/Http4sDynamicEndpoint.scala b/obp-api/src/main/scala/code/api/dynamic/endpoint/Http4sDynamicEndpoint.scala index b3188e5ecb..340050c96a 100644 --- a/obp-api/src/main/scala/code/api/dynamic/endpoint/Http4sDynamicEndpoint.scala +++ b/obp-api/src/main/scala/code/api/dynamic/endpoint/Http4sDynamicEndpoint.scala @@ -27,18 +27,13 @@ package code.api.dynamic.endpoint import cats.data.{Kleisli, OptionT} import cats.effect.IO -import code.api.dynamic.endpoint.helper.DynamicEndpointHelper -import code.api.util.APIUtil +import code.api.dynamic.endpoint.helper.{DynamicEndpointHelper, DynamicEndpoints} import code.api.util.CustomJsonFormats -import code.api.util.ErrorMessages.{InvalidUri, UnknownError} -import code.api.util.http4s.Http4sLiftWebBridge import code.api.util.http4s.Http4sRequestAttributes.EndpointHelpers -import code.api.util.http4s.{Http4sCallContextBuilder, Http4sRequestAttributes} -import code.api.{APIFailure, JsonResponseException} +import code.api.util.http4s.{ErrorResponseConverter, Http4sCallContextBuilder, Http4sRequestAttributes} import code.util.Helper.MdcLoggable import com.openbankproject.commons.util.{ApiShortVersions, ApiStandards} -import net.liftweb.common.{Empty, Failure, Full, ParamFailure} -import net.liftweb.http.{LiftResponse, LiftRules, Req, S} +import net.liftweb.common.{Box, Empty, Full} import net.liftweb.json.Formats import net.liftweb.json.JsonAST.{JNothing, JValue} import org.http4s.{HttpRoutes, Request, Response} @@ -47,31 +42,27 @@ import org.typelevel.ci.CIString /** * Native http4s entry point for the OBP dynamic-endpoint dispatch (under /obp/dynamic-endpoint/). * - * Replaces the Lift `LiftRules.statelessDispatch` registration of [[OBPAPIDynamicEndpoint]] - * (see APIUtil.enableVersionIfAllowed, now commented for `dynamic-endpoint`). It covers BOTH - * runtime pieces that OBPAPIDynamicEndpoint.routes carries: + * Fully native — no Lift `Req`, `S.init`, `buildLiftReq` or `liftResponseToHttp4s`. Covers BOTH + * runtime pieces that the former Lift `OBPAPIDynamicEndpoint` dispatch carried: * - * - Piece B (proxy): `ImplementationsDynamicEndpoint.dynamicEndpoint`, matched by - * `DynamicEndpointHelper.DynamicReq` and proxied to a backend connector / obp_mock. - * - Piece C (runtime-compiled): `DynamicEndpoints.dynamicEndpoint`, serving the - * practise / dynamic-resource-doc endpoints compiled from user Scala via - * `DynamicUtil.compileScalaCode[OBPEndpoint]`. + * - Piece B (proxy): requests matched by `DynamicEndpointHelper.DynamicReq.resolveProxyTarget` + * (the framework-neutral core of the Lift DynamicReq extractor) and run through the shared + * `APIMethodsDynamicEndpoint.proxyHandle` (auth / entitlement / before+after interceptors / + * mock-or-connector proxy). The dynamic status code from the connector / obp_mock result is + * rendered via `EndpointHelpers.executeFutureWithStatus`. Proxy writes run on auto-commit + * (no withBusinessDBTransaction), matching the prior bridge/adapter behaviour. * - * Stage 1 — Piece B is served NATIVELY by [[proxy]]: the request is matched by - * `DynamicEndpointHelper.DynamicReq.resolveProxyTarget` (the framework-neutral core of the Lift - * `DynamicReq` extractor) and run through the shared `APIMethodsDynamicEndpoint.proxyHandle` - * (auth / entitlement / before+after interceptors / mock-or-connector proxy). No Lift `Req`, - * `S.init`, `buildLiftReq` or `liftResponseToHttp4s` on this path. The dynamic status code carried - * by the connector / obp_mock result is rendered via `EndpointHelpers.executeFutureWithStatus`. - * Proxy writes run on auto-commit (no `withBusinessDBTransaction`), matching the prior bridge/adapter - * behaviour. + * - Piece C (runtime-compiled): requests matched by `DynamicEndpoints.findEndpoint` to a dynamic + * ResourceDoc whose compiled native handler is carried in `ResourceDoc.dynamicHttp4sFunction` + * (an `OBPEndpointIO` produced by the native code-generation template — see + * `code.api.dynamic.endpoint.helper.DynamicEndpoints.CompiledObjects` / `DynamicCompileEndpoint`). + * The doc's auth/validation chain (`ResourceDoc.authCheckIO`, the native mirror of + * `wrappedWithAuthCheck`) runs first, then the handler runs inside the dynamic-code security + * sandbox (`Sandbox.runInSandboxIO`, applied inside the compiled handler). * - * Piece C is STILL served by the in-process Lift adapter ([[dispatch]]) because its compiled - * artifact is hard-wired to Lift (`PartialFunction[Req, CallContext => Box[JsonResponse]]`, - * generated code imports `net.liftweb.http.{JsonResponse, Req}`) and can only be RUN, not natively - * rewritten. The native [[proxy]] is tried first; a non-match falls through to [[dispatch]], whose - * `collectFirst` over `OBPAPIDynamicEndpoint.routes` then serves Piece C. (Stage 2 will redefine the - * Piece C contract to native http4s and remove the adapter entirely.) + * Piece B is tried first; a non-match falls through to Piece C; a non-match there returns + * `OptionT.none`, so the request falls through the Http4sApp chain (the Lift bridge produces the + * final 404, as before). */ object Http4sDynamicEndpoint extends MdcLoggable { @@ -87,7 +78,7 @@ object Http4sDynamicEndpoint extends MdcLoggable { // Mirror of the Lift DynamicReq gate `testResponse_?`: only treat the request as a dynamic-endpoint // proxy candidate when it is JSON (Content-Type or Accept carries json). A non-JSON request returns - // OptionT.none so it falls through to the Piece C adapter / Http4sApp chain, exactly as before. + // OptionT.none so it falls through to Piece C / the Http4sApp chain, exactly as before. private def isJsonRequest(req: Request[IO]): Boolean = { def header(name: String): String = req.headers.get(CIString(name)).map(_.head.value).getOrElse("") header("Content-Type").toLowerCase.contains("json") || header("Accept").toLowerCase.contains("json") @@ -95,10 +86,10 @@ object Http4sDynamicEndpoint extends MdcLoggable { /** * Native Piece B (proxy) handler. Matches via `DynamicEndpointHelper.DynamicReq.resolveProxyTarget` - * (same DB lookup the Lift `DynamicReq.unapply` used) and runs the shared, framework-neutral - * `APIMethodsDynamicEndpoint.proxyHandle`. The CallContext is built by `Http4sCallContextBuilder` - * and attached so `EndpointHelpers.executeFutureWithStatus` can reuse the error conversion + metric; - * auth / entitlement run inside `proxyHandle`. No match -> `OptionT.none` (fall through to [[dispatch]]). + * and runs the shared, framework-neutral `APIMethodsDynamicEndpoint.proxyHandle`. The CallContext + * is built by `Http4sCallContextBuilder` and attached so `EndpointHelpers.executeFutureWithStatus` + * can reuse the error conversion + metric; auth / entitlement run inside `proxyHandle`. No match -> + * `OptionT.none` (fall through to [[pieceC]]). */ private def proxy(req: Request[IO]): OptionT[IO, Response[IO]] = if (!isJsonRequest(req)) OptionT.none[IO, Response[IO]] @@ -119,74 +110,42 @@ object Http4sDynamicEndpoint extends MdcLoggable { } /** - * The exact wrapped form Lift held in statelessDispatch for dynamic-endpoint: - * `routes.map(endpoint => oauthServe(apiPrefix{endpoint}, None))`. `oauthServe` registers, - * `buildOAuthHandler` returns the identical wrapped PF (failIfBadAuthorizationHeader { failIfBadJSON } - * + endpoint metric) without registering, so we can apply it in-process. Built once; the - * per-request DB lookups happen inside each route's `isDefinedAt`/`apply` - * (`DynamicReq.unapply` / `DynamicEndpoints.findEndpoint`), exactly as before. + * Native Piece C (runtime-compiled) handler. Locates the matching dynamic ResourceDoc via + * `DynamicEndpoints.findEndpoint`, builds + enriches the CallContext, runs the doc's native + * auth/validation chain (`ResourceDoc.authCheckIO`), then runs the compiled native handler + * (`ResourceDoc.dynamicHttp4sFunction`, which wraps itself in the security sandbox). Auth / role / + * lookup failures are converted to a response via `ErrorResponseConverter`. No match -> + * `OptionT.none` (fall through the Http4sApp chain). */ - private lazy val wrappedRoutes: List[PartialFunction[Req, () => net.liftweb.common.Box[LiftResponse]]] = - OBPAPIDynamicEndpoint.routes.map(route => - OBPAPIDynamicEndpoint.buildOAuthHandler(OBPAPIDynamicEndpoint.apiPrefix(route), None)) - - /** Reduce a handler's `Box[LiftResponse]` to a `LiftResponse`, mirroring runLiftDispatch. */ - private def boxToLiftResponse(box: net.liftweb.common.Box[LiftResponse], liftReq: Req): LiftResponse = - box match { - case Full(resp) => resp - case ParamFailure(_, _, _, apiFailure: APIFailure) => - APIUtil.errorJsonResponse(apiFailure.msg, apiFailure.responseCode) - case Failure(msg, _, _) => - APIUtil.errorJsonResponse(msg) - case Empty => - val contentType = liftReq.request.headers("Content-Type").headOption.getOrElse("") - APIUtil.errorJsonResponse( - s"${InvalidUri}Current Url is (${liftReq.request.uri}), Current Content-Type Header is ($contentType)", 404) - } - - private def dispatch(req: Request[IO]): OptionT[IO, Response[IO]] = OptionT { - val io: IO[Option[LiftResponse]] = for { - bodyBytes <- req.body.compile.to(Array) - liftReq = Http4sLiftWebBridge.buildLiftReq(req, bodyBytes) - liftRespOpt <- IO { - val session = LiftRules.statelessSession.vend.apply(liftReq) - S.init(Full(liftReq), session) { - try { - // collectFirst's guard runs each route's isDefinedAt (per-request DB lookup); - // pf(liftReq) eagerly runs failIfBadAuthorizationHeader/failIfBadJSON, so a - // JsonResponseException (auth / interceptor) can surface here — hence the try wraps both. - wrappedRoutes.collectFirst { case pf if pf.isDefinedAt(liftReq) => pf(liftReq) } match { - case None => Option.empty[LiftResponse] - case Some(run) => Some(boxToLiftResponse(run(), liftReq)) + private def pieceC(req: Request[IO]): OptionT[IO, Response[IO]] = + DynamicEndpoints.findEndpoint(req) match { + case None => OptionT.none[IO, Response[IO]] + case Some(doc) => + OptionT.liftF { + Http4sCallContextBuilder.fromRequest(req, apiVersionString).flatMap { cc0 => + val cc = cc0.copy(resourceDocument = Some(doc), operationId = Some(doc.operationId)) + val partPath = req.uri.path.segments.drop(2).map(_.encoded).toList + val bodyJValue: Box[JValue] = cc.httpBody.filter(_.nonEmpty).map(net.liftweb.json.parse) match { + case Some(jv) => Full(jv) + case None => Empty } - } catch { - case JsonResponseException(jsonResponse) => Some(jsonResponse) - case e if e.getClass.getName == "net.liftweb.http.rest.ContinuationException" => - Some(Http4sLiftWebBridge.resolveContinuation(e)) + val io: IO[Response[IO]] = for { + authedCcOpt <- IO.fromFuture(IO(doc.authCheckIO(partPath, bodyJValue, cc))) + authedCc = authedCcOpt.getOrElse(cc) + resp <- doc.dynamicHttp4sFunction.get.apply(req)(authedCc) + } yield resp + io.handleErrorWith(err => ErrorResponseConverter.toHttp4sResponse(err, cc)) } } - } - } yield liftRespOpt - - io.flatMap { - case None => IO.pure(Option.empty[Response[IO]]) - case Some(lr) => Http4sLiftWebBridge.liftResponseToHttp4s(lr).map(Some(_)) - }.handleErrorWith { e => - // A matched dynamic-endpoint handler threw an unexpected (non-JsonResponse) exception. - // The Lift bridge converted such cases to a 500; do the same here so it does not escape - // as an unhandled IO failure. (No fall-through: a handler had claimed the request.) - logger.error(s"[Http4sDynamicEndpoint] uncaught exception dispatching ${req.method} ${req.uri.renderString}: ${e.getMessage}", e) - Http4sLiftWebBridge.liftResponseToHttp4s(APIUtil.errorJsonResponse(s"$UnknownError ${e.getMessage}", 500)).map(Some(_)) } - } /** Entry point wired into Http4sApp.baseServices (before the Lift bridge). */ lazy val wrappedRoutesDynamicEndpoint: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { (req: Request[IO]) => req.uri.path.segments.map(_.encoded).toList match { case standard :: version :: _ if standard == apiStandard && version == apiVersionString => - // Native Piece B (proxy) first; a non-match falls through to the Lift adapter for Piece C. - proxy(req).orElse(dispatch(req)) + // Native Piece B (proxy) first; a non-match falls through to native Piece C (runtime-compiled). + proxy(req).orElse(pieceC(req)) case _ => OptionT.none[IO, Response[IO]] } diff --git a/obp-api/src/main/scala/code/api/dynamic/endpoint/OBPAPIDynamicEndpoint.scala b/obp-api/src/main/scala/code/api/dynamic/endpoint/OBPAPIDynamicEndpoint.scala index 3c27f71af4..0a3c10ec32 100644 --- a/obp-api/src/main/scala/code/api/dynamic/endpoint/OBPAPIDynamicEndpoint.scala +++ b/obp-api/src/main/scala/code/api/dynamic/endpoint/OBPAPIDynamicEndpoint.scala @@ -52,19 +52,12 @@ object OBPAPIDynamicEndpoint extends OBPRestHelper with MdcLoggable with Version val routes : List[OBPEndpoint] = List(APIUtil.dynamicEndpointStub, //This is for the dynamic endpoints which are created by dynamic swagger files - ImplementationsDynamicEndpoint.dynamicEndpoint, - /** - * Here is the place where we register the dynamicEndpoint, all the dynamic resource docs endpoints are here. - * Actually, we only register one endpoint for all the dynamic resource docs endpoints. - * For Liftweb, it just need to handle one endpoint, - * all the router functionalities are in OBP code. - * details: please also check code/api/vDynamic/dynamic/DynamicEndpoints.findEndpoint method - * NOTE: this must be the last one endpoint to register into Liftweb - * Because firstly, Liftweb should look for the static endpoints --> then the dynamic ones. - * This is for the dynamic endpoints which are createdy by dynamic resourceDocs - */ - DynamicEndpoints.dynamicEndpoint - ) + ImplementationsDynamicEndpoint.dynamicEndpoint + // Piece C (runtime-compiled dynamic-resource-doc / practise) endpoints are now served NATIVELY + // by code.api.dynamic.endpoint.Http4sDynamicEndpoint via DynamicEndpoints.findEndpoint. The + // former Lift `DynamicEndpoints.dynamicEndpoint` (OBPEndpoint) has been removed; the compiled + // artifacts are now OBPEndpointIO carried on each dynamic ResourceDoc.dynamicHttp4sFunction. + ) // dynamic-endpoint dispatch migrated to native http4s (code.api.dynamic.endpoint.Http4sDynamicEndpoint). // The Http4sDynamicEndpoint adapter rebuilds the wrapped form from `routes` directly diff --git a/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/DynamicCompileEndpoint.scala b/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/DynamicCompileEndpoint.scala index 4022feacd9..8e8c56c0d2 100644 --- a/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/DynamicCompileEndpoint.scala +++ b/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/DynamicCompileEndpoint.scala @@ -1,15 +1,22 @@ package code.api.dynamic.endpoint.helper import scala.language.implicitConversions -import code.api.util.APIUtil.{OBPEndpoint, OBPReturnType, futureToBoxedResponse, scalaFutureToLaFuture} +import cats.effect.IO +import code.api.util.APIUtil.{OBPEndpointIO, OBPReturnType} import code.api.util.DynamicUtil.{Sandbox, Validation} import code.api.util.{CallContext, CustomJsonFormats, DynamicUtil} -import net.liftweb.common.Box -import net.liftweb.http.{JsonResponse, Req} +import org.http4s.{Request, Response} /** - * this is super trait of dynamic compile endpoint, the dynamic compiled code should extends this trait and supply - * logic of process method + * Super-trait of a dynamic compiled endpoint. The dynamically-compiled code (Piece C) extends this + * and supplies the `process` method body. + * + * Native http4s contract (replaces the former Lift one + * `process(callContext, request: net.liftweb.http.Req, pathParams): Box[JsonResponse]`): the body + * receives the http4s `Request[IO]` and returns an `IO[Response[IO]]`. The implicit + * [[DynamicCompileEndpoint.obpReturnTypeToIOResponse]] lets a body whose last expression is an + * `OBPReturnType[T]` (the familiar `Future.successful((json, HttpCode.\`200\`(cc)))` style) be used + * directly — the response status is taken from `CallContext.httpCode` (set by `HttpCode.xxx`). */ trait DynamicCompileEndpoint { implicit val formats = CustomJsonFormats.formats @@ -17,20 +24,19 @@ trait DynamicCompileEndpoint { // * is any bankId val boundBankId: String - protected def process(callContext: CallContext, request: Req, pathParams: Map[String, String]): Box[JsonResponse] + protected def process(callContext: CallContext, request: Request[IO], pathParams: Map[String, String]): IO[Response[IO]] - val endpoint: OBPEndpoint = new OBPEndpoint { - override def isDefinedAt(x: Req): Boolean = true + val endpoint: OBPEndpointIO = new OBPEndpointIO { + override def isDefinedAt(x: Request[IO]): Boolean = true - override def apply(request: Req): CallContext => Box[JsonResponse] = { cc => - val Some(pathParams) = cc.resourceDocument.map(_.getPathParams(request.path.partPath)) + override def apply(request: Request[IO]): CallContext => IO[Response[IO]] = { cc => + val Some(pathParams) = cc.resourceDocument.map(_.getPathParams(request.uri.path.segments.toList.map(_.encoded))) validateDependencies() - Sandbox.sandbox(boundBankId).runInSandbox { + Sandbox.sandbox(boundBankId).runInSandboxIO { process(cc, request, pathParams) } - } } @@ -41,7 +47,31 @@ trait DynamicCompileEndpoint { } object DynamicCompileEndpoint { - implicit def scalaFutureToBoxedJsonResponse[T](scf: OBPReturnType[T])(implicit m: Manifest[T]): Box[JsonResponse] = { - futureToBoxedResponse(scalaFutureToLaFuture(scf)) + import net.liftweb.json.{Extraction, prettyRender} + import net.liftweb.json.JsonDSL._ + import org.http4s.Status + + /** + * Native error response helper for dynamic-code bodies, replacing the former + * `Full(errorJsonResponse(msg))` (a Lift `Box[JsonResponse]`). Renders the standard OBP error + * shape `{ "code", "message" }` with the given HTTP status (default 400). + */ + def errorResponse(message: String, code: Int = 400): IO[Response[IO]] = { + val json = ("code" -> code) ~ ("message" -> message) + IO.pure(Response[IO](Status.fromInt(code).getOrElse(Status.BadRequest)).withEntity(prettyRender(json))) } -} \ No newline at end of file + + /** + * Convert an `OBPReturnType[T]` (= `Future[(T, Option[CallContext])]`) to a native + * `IO[Response[IO]]`, the http4s replacement for the former + * `scalaFutureToBoxedJsonResponse` (which produced a Lift `Box[JsonResponse]`). The HTTP status + * comes from `CallContext.httpCode` (set by `NewStyle.HttpCode.xxx`), defaulting to 200; the + * value is rendered as JSON via Lift-json, matching the previous response shape. + */ + implicit def obpReturnTypeToIOResponse[T](scf: OBPReturnType[T])(implicit m: Manifest[T]): IO[Response[IO]] = + IO.fromFuture(IO(scf)).map { case (value, ccOpt) => + val code = ccOpt.flatMap(_.httpCode).getOrElse(200) + val jsonString = prettyRender(Extraction.decompose(value)(CustomJsonFormats.formats)) + Response[IO](Status.fromInt(code).getOrElse(Status.Ok)).withEntity(jsonString) + } +} diff --git a/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/DynamicEndpoints.scala b/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/DynamicEndpoints.scala index 39b94ea98c..9d343d84ff 100644 --- a/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/DynamicEndpoints.scala +++ b/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/DynamicEndpoints.scala @@ -1,24 +1,24 @@ package code.api.dynamic.endpoint.helper +import cats.effect.IO import code.api.dynamic.endpoint.helper.practise.{DynamicEndpointCodeGenerator, PractiseEndpointGroup} import code.api.dynamic.endpoint.helper.practise.PractiseEndpointGroup import code.api.util.DynamicUtil.{Sandbox, Validation} -import code.api.util.APIUtil.{BooleanBody, DoubleBody, EmptyBody, LongBody, OBPEndpoint, PrimaryDataBody, ResourceDoc, StringBody, getDisabledEndpointOperationIds} +import code.api.util.APIUtil.{BooleanBody, DoubleBody, EmptyBody, LongBody, OBPEndpointIO, PrimaryDataBody, ResourceDoc, StringBody, getDisabledEndpointOperationIds} import code.api.util.{CallContext, DynamicUtil} import net.liftweb.common.{Box, Failure, Full} -import net.liftweb.http.{JsonResponse, Req} import net.liftweb.json.{JNothing, JValue} import net.liftweb.json.JsonAST.{JBool, JDouble, JInt, JString} import org.apache.commons.lang3.StringUtils +import org.http4s.{Request, Response} import java.net.URLDecoder import scala.collection.immutable.List -import scala.util.control.Breaks.{break, breakable} object DynamicEndpoints { //TODO, better put all other dynamic endpoints into this list. eg: dynamicEntityEndpoints, dynamicSwaggerDocsEndpoints .... val disabledEndpointOperationIds = getDisabledEndpointOperationIds - + private val endpointGroups: List[EndpointGroup] = if(disabledEndpointOperationIds.contains("OBPv4.0.0-test-dynamic-resource-doc")) { DynamicResourceDocsEndpointGroup :: Nil @@ -27,40 +27,22 @@ object DynamicEndpoints { } /** - * this will find dynamic endpoint by request. - * the dynamic endpoints can be in obp database or memory or generated by obp code. - * This will be the OBP Router for all the dynamic endpoints. - * + * Native http4s router for all runtime-compiled dynamic endpoints (Piece C). + * Finds the matching dynamic ResourceDoc by HTTP verb + URL template; the doc carries the + * compiled native handler in `dynamicHttp4sFunction`. The dynamic endpoints can be in the OBP + * database (DynamicResourceDocsEndpointGroup) or compiled in code (PractiseEndpointGroup). + * + * This is the OBP Router for all the dynamic endpoints. It is iterated by + * code.api.dynamic.endpoint.Http4sDynamicEndpoint, which then runs the doc's auth chain + * (ResourceDoc.authCheckIO) and the handler. Replaces the former Lift `dynamicEndpoint` + * (PartialFunction[Req, CallContext => Box[JsonResponse]]) that ran through the Lift dispatch. */ - private def findEndpoint(req: Req): Option[OBPEndpoint] = { - var foundEndpoint: Option[OBPEndpoint] = None - breakable{ - endpointGroups.foreach { endpointGroup => { - val maybeEndpoint: Option[OBPEndpoint] = endpointGroup.endpoints.find(_.isDefinedAt(req)) - if(maybeEndpoint.isDefined) { - foundEndpoint = maybeEndpoint - break - } - }} - } - foundEndpoint - } - - /** - * This endpoint will be registered into Liftweb. - * It is only one endpoint for Liftweb <---> but it mean many for obp dynamic endpoints - * Because inside the method body, we override the `isDefinedAt` method, - * We can loop all the dynamic endpoints from obp database (better check EndpointGroup.endpoints we generate the endpoints - * by resourceDocs, then we can create the endpoints object in memory). - * - */ - val dynamicEndpoint: OBPEndpoint = new OBPEndpoint { - override def isDefinedAt(req: Req): Boolean = findEndpoint(req).isDefined - - override def apply(req: Req): CallContext => Box[JsonResponse] = { - val Some(endpoint) = findEndpoint(req) - endpoint(req) - } + def findEndpoint(req: Request[IO]): Option[ResourceDoc] = { + val partPath = req.uri.path.segments.drop(2).map(_.encoded).toList // segments after /obp/dynamic-endpoint + val verb = req.method.name + endpointGroups.iterator + .flatMap(_.docs.iterator) + .find(doc => doc.requestVerb == verb && doc.dynamicHttp4sFunction.isDefined && doc.matchesPartPath(partPath)) } def dynamicResourceDocs: List[ResourceDoc] = endpointGroups.flatMap(_.docs) @@ -77,42 +59,19 @@ trait EndpointGroup { } else { resourceDocs map { doc => val newUrl = s"/$urlPrefix/${doc.requestUrl}".replace("//", "/") - val newDoc = doc.copy(requestUrl = newUrl) + val newDoc = doc.copy(requestUrl = newUrl) // copy preserves dynamicHttp4sFunction newDoc.connectorMethods = doc.connectorMethods // copy method will not keep var value, So here reset it manually newDoc } } - - /** - * this method will generate the endpoints from the resourceDocs. - */ - def endpoints: List[OBPEndpoint] = docs.map(wrapEndpoint) - - //fill callContext with resourceDoc and operationId - private def wrapEndpoint(resourceDoc: ResourceDoc): OBPEndpoint = { - - val endpointFunction = resourceDoc.wrappedWithAuthCheck(resourceDoc.partialFunction) - - new OBPEndpoint { - override def isDefinedAt(req: Req): Boolean = req.requestType.method == resourceDoc.requestVerb && endpointFunction.isDefinedAt(req) - - override def apply(req: Req): CallContext => Box[JsonResponse] = { - (callContext: CallContext) => { - // fill callContext with resourceDoc and operationId, this will map the resourceDoc to endpoint. - val newCallContext = callContext.copy(resourceDocument = Some(resourceDoc), operationId = Some(resourceDoc.operationId)) - endpointFunction(req)(newCallContext) - } - } - } - } } /** - * This class will generate the ResourceDoc class fields(requestBody: Product, successResponse: Product and partialFunction: OBPEndpoint) - * by parameters: JValues and Strings. + * This class will generate the ResourceDoc class fields(requestBody: Product, successResponse: Product and the native + * http4s handler) by parameters: JValues and Strings. * successResponseBody: Option[JValue] --> toCaseObject(from JValue --> Scala code --> DynamicUtil.compileScalaCode --> generate the object. * methodBody: String --> prepare the template api level scala code --> DynamicUtil.compileScalaCode --> generate the api level code. - * + * * @param exampleRequestBody exampleRequestBody from the post json body, it is JValue here. * @param successResponseBody successResponseBody from the post json body,it is JValue here. * @param methodBody it is url-encoded string for the api level code. @@ -127,20 +86,20 @@ case class CompiledObjects(exampleRequestBody: Option[JValue], successResponseBo } val successResponse: Product = toCaseObject(successResponseBody) - private val partialFunction: OBPEndpoint = { + private val partialFunction: OBPEndpointIO = { //If the requestBody is PrimaryDataBody, return None. otherwise, return the exampleRequestBody:Option[JValue] // In side OBP resourceDoc, requestBody and successResponse must be Product type, - // both can not be the primitive type: `boolean, string, kong, int, long, double` and List. + // both can not be the primitive type: `boolean, string, kong, int, long, double` and List. // PrimaryDataBody is used for OBP mapping these types. - // Note: List and object will generate the `Case class`, `case class` must not be PrimaryDataBody. only these two + // Note: List and object will generate the `Case class`, `case class` must not be PrimaryDataBody. only these two // possibilities: case class or PrimaryDataBody val requestExample: Option[JValue] = if (requestBody.isInstanceOf[PrimaryDataBody[_]]) { - None + None } else exampleRequestBody val responseExample: Option[JValue] = if (successResponse.isInstanceOf[PrimaryDataBody[_]]) { - None + None } else successResponseBody // buildCaseClasses --> will generate the following case classes string, which are used for the scala template code. @@ -148,33 +107,34 @@ case class CompiledObjects(exampleRequestBody: Option[JValue], successResponseBo // case class ResponseRootJsonClass(person_id: String, name: String, age: Long) val (requestBodyCaseClasses, responseBodyCaseClasses) = DynamicEndpointCodeGenerator.buildCaseClasses(requestExample, responseExample) + // Native http4s template (replaces the former Lift `OBPEndpoint` template). The compiled + // artifact is an `OBPEndpointIO` (PartialFunction[Request[IO], CallContext => IO[Response[IO]]]). + // `DynamicCompileEndpoint._` injects the `OBPReturnType[T] => IO[Response[IO]]` implicit (so the + // familiar `Future.successful((json, HttpCode.`200`(cc)))` body style still works) and the + // `errorResponse(msg, code)` helper (replacing `Full(errorJsonResponse(...))`). val code = s""" + |import cats.effect.IO + |import org.http4s.{Request, Response} |import code.api.util.CallContext |import code.api.util.ErrorMessages.{InvalidJsonFormat, InvalidRequestPayload} |import code.api.util.NewStyle.HttpCode - |import code.api.util.APIUtil.{OBPReturnType, futureToBoxedResponse, scalaFutureToLaFuture, errorJsonResponse} - | - |import net.liftweb.common.{Box, EmptyBox, Full} - |import net.liftweb.http.{JsonResponse, Req} + |import code.api.util.APIUtil.OBPReturnType |import net.liftweb.json.MappingException + |import code.api.dynamic.endpoint.helper.DynamicCompileEndpoint._ | |import scala.concurrent.Future |import com.openbankproject.commons.ExecutionContext.Implicits.global | - |implicit def scalaFutureToBoxedJsonResponse[T](scf: OBPReturnType[T])(implicit m: Manifest[T]): Box[JsonResponse] = { - | futureToBoxedResponse(scalaFutureToLaFuture(scf)) - |} - | |implicit val formats = code.api.util.CustomJsonFormats.formats | |$requestBodyCaseClasses | |$responseBodyCaseClasses | - |val endpoint: code.api.util.APIUtil.OBPEndpoint = { + |val endpoint: code.api.util.APIUtil.OBPEndpointIO = { | case request => { callContext => - | val Some(pathParams) = callContext.resourceDocument.map(_.getPathParams(request.path.partPath)) + | val Some(pathParams) = callContext.resourceDocument.map(_.getPathParams(request.uri.path.segments.toList.map(_.encoded))) | $decodedMethodBody | } |} @@ -182,7 +142,7 @@ case class CompiledObjects(exampleRequestBody: Option[JValue], successResponseBo |endpoint | |""".stripMargin - val endpointMethod = DynamicUtil.compileScalaCode[OBPEndpoint](code) + val endpointMethod = DynamicUtil.compileScalaCode[OBPEndpointIO](code) endpointMethod match { case Full(func) => func @@ -194,31 +154,31 @@ case class CompiledObjects(exampleRequestBody: Option[JValue], successResponseBo /** * this will check all the dynamic scala code dependencies at compile time. - * + * *Search for the usage, you can see how to use it in OBP code. */ def validateDependency() = Validation.validateDependency(this.partialFunction) /** - * This is used to check the security permission at the run time. + * This is used to check the security permission at the run time. * all the obp partialFunctions will be wrapped into the sandbox which under the permission control. - * + * */ - def sandboxEndpoint(bankId: Option[String]) : OBPEndpoint = { + def sandboxEndpoint(bankId: Option[String]) : OBPEndpointIO = { val sandbox = bankId match { case Some(v) if StringUtils.isNotBlank(v) => Sandbox.sandbox(v) case _ => Sandbox.sandbox("*") } - new OBPEndpoint { - override def isDefinedAt(req: Req): Boolean = partialFunction.isDefinedAt(req) + new OBPEndpointIO { + override def isDefinedAt(req: Request[IO]): Boolean = partialFunction.isDefinedAt(req) // run dynamic code in sandbox - override def apply(req: Req): CallContext => Box[JsonResponse] = {cc => + override def apply(req: Request[IO]): CallContext => IO[Response[IO]] = { cc => val fn = partialFunction.apply(req) - sandbox.runInSandbox(fn(cc)) + sandbox.runInSandboxIO(fn(cc)) } } } @@ -237,4 +197,3 @@ case class CompiledObjects(exampleRequestBody: Option[JValue], successResponseBo } } } - diff --git a/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/DynamicResourceDocsEndpointGroup.scala b/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/DynamicResourceDocsEndpointGroup.scala index 94807d5549..f46ea9df05 100644 --- a/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/DynamicResourceDocsEndpointGroup.scala +++ b/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/DynamicResourceDocsEndpointGroup.scala @@ -8,12 +8,26 @@ import org.apache.commons.lang3.StringUtils import scala.collection.immutable.List -object DynamicResourceDocsEndpointGroup extends EndpointGroup { +object DynamicResourceDocsEndpointGroup extends EndpointGroup with code.util.Helper.MdcLoggable { override lazy val urlPrefix: String = APIUtil.getPropsValue("url.prefix.dynamic.resourceDoc", "dynamic-resource-doc") override protected def resourceDocs: List[APIUtil.ResourceDoc] = - DynamicResourceDocProvider.provider.vend.getAllAndConvert(None, toResourceDoc) //TODO need to check if this can be `NONE` + // Per-row isolation: a stored methodBody written against the deprecated Lift contract + // (request.json / Box[JsonResponse] / Full(errorJsonResponse(...))) will fail to compile under + // the native http4s template. Skip (and log) such a row so one bad endpoint does not crash the + // whole group / server boot. Re-author the body against the new native contract (see PractiseEndpoint). + DynamicResourceDocProvider.provider.vend.getAll(None).flatMap { dynamicDoc => + try { + Some(toResourceDoc(dynamicDoc)) + } catch { + case e: Throwable => + logger.error(s"[DynamicResourceDocsEndpointGroup] skipping dynamic resource doc '${dynamicDoc.requestVerb} ${dynamicDoc.requestUrl}' " + + s"(id=${dynamicDoc.dynamicResourceDocId.getOrElse("")}): its methodBody could not be compiled under the native http4s contract. " + + s"It is likely stored under the deprecated Lift contract — re-author the body against the new native contract. Cause: ${e.getMessage}", e) + None + } + } private val apiVersion : ScannedApiVersion = ApiVersion.v4_0_0 @@ -37,7 +51,10 @@ object DynamicResourceDocsEndpointGroup extends EndpointGroup { private val toResourceDoc: JsonDynamicResourceDoc => ResourceDoc = { dynamicDoc => val compiledObjects = CompiledObjects(dynamicDoc.exampleRequestBody, dynamicDoc.successResponseBody, dynamicDoc.methodBody) ResourceDoc( - partialFunction = compiledObjects.sandboxEndpoint(dynamicDoc.bankId), + // partialFunction is a no-op stub — the runtime dispatch uses the native handler in + // dynamicHttp4sFunction (the compiled artifact is OBPEndpointIO, not the Lift OBPEndpoint). + partialFunction = APIUtil.dynamicEndpointStub, + dynamicHttp4sFunction = Some(compiledObjects.sandboxEndpoint(dynamicDoc.bankId)), implementedInApiVersion = apiVersion, partialFunctionName = dynamicDoc.partialFunctionName + "_" + (dynamicDoc.requestVerb + dynamicDoc.requestUrl).hashCode, requestVerb = dynamicDoc.requestVerb, diff --git a/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/practise/PractiseEndpoint.scala b/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/practise/PractiseEndpoint.scala index c059abe77f..62b62a233e 100644 --- a/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/practise/PractiseEndpoint.scala +++ b/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/practise/PractiseEndpoint.scala @@ -20,10 +20,10 @@ object PractiseEndpoint extends DynamicCompileEndpoint { import code.api.util.CallContext import code.api.util.ErrorMessages.{InvalidJsonFormat, InvalidRequestPayload} import code.api.util.NewStyle.HttpCode - import code.api.util.APIUtil.{OBPReturnType, futureToBoxedResponse, scalaFutureToLaFuture, errorJsonResponse} + import code.api.util.APIUtil.OBPReturnType - import net.liftweb.common.{Box, EmptyBox, Full} - import net.liftweb.http.{JsonResponse, Req} + import cats.effect.IO + import org.http4s.{Request, Response} import net.liftweb.json.MappingException import scala.concurrent.Future @@ -48,13 +48,14 @@ object PractiseEndpoint extends DynamicCompileEndpoint { // copy the whole method body as "dynamicResourceDoc" method body override protected def - process(callContext: CallContext, request: Req, pathParams: Map[String, String]) : Box[JsonResponse] = { + process(callContext: CallContext, request: Request[IO], pathParams: Map[String, String]) : IO[Response[IO]] = { // please add import sentences here, those used by this method import code.api.util.NewStyle import code.api.v4_0_0.JSONFactory400 val Some(resourceDoc) = callContext.resourceDocument - val hasRequestBody = request.body.isDefined + // the request body is available as a String on the CallContext (read by Http4sCallContextBuilder) + val hasRequestBody = callContext.httpBody.exists(_.nonEmpty) // get Path Parameters, example: // if the requestUrl of resourceDoc is /hello/banks/BANK_ID/world @@ -62,16 +63,16 @@ object PractiseEndpoint extends DynamicCompileEndpoint { //pathParams.get("BANK_ID") will get Option("bank_x") value val myUserId = pathParams("MY_USER_ID") - val requestEntity = request.json match { - case Full(zson) => + val requestEntity = callContext.httpBody.filter(_.nonEmpty) match { + case Some(rawBody) => try { - zson.extract[RequestRootJsonClass] + net.liftweb.json.parse(rawBody).extract[RequestRootJsonClass] } catch { case e: MappingException => - return Full(errorJsonResponse(s"$InvalidJsonFormat ${e.msg}")) + return errorResponse(s"$InvalidJsonFormat ${e.msg}") } - case _: EmptyBox => - return Full(errorJsonResponse(s"$InvalidRequestPayload Current request has no payload")) + case None => + return errorResponse(s"$InvalidRequestPayload Current request has no payload") } // please add business logic here val responseBody:ResponseRootJsonClass = ResponseRootJsonClass(s"${myUserId}_from_path", requestEntity.name, requestEntity.age, requestEntity.hobby) diff --git a/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/practise/PractiseEndpointGroup.scala b/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/practise/PractiseEndpointGroup.scala index df15dc5837..3fd21c7310 100644 --- a/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/practise/PractiseEndpointGroup.scala +++ b/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/practise/PractiseEndpointGroup.scala @@ -18,7 +18,9 @@ object PractiseEndpointGroup extends EndpointGroup{ override protected lazy val urlPrefix: String = "test-dynamic-resource-doc" override protected def resourceDocs: List[APIUtil.ResourceDoc] = ResourceDoc( - PractiseEndpoint.endpoint, + // partialFunction is a no-op stub — the runtime dispatch uses the native handler in + // dynamicHttp4sFunction below (the compiled artifact is OBPEndpointIO, not the Lift OBPEndpoint). + APIUtil.dynamicEndpointStub, ApiVersion.v4_0_0, "test-dynamic-resource-doc", PractiseEndpoint.requestMethod, @@ -27,7 +29,7 @@ object PractiseEndpointGroup extends EndpointGroup{ s"""A test endpoint. | |Just for debug method body of dynamic resource doc. - |better watch the following introduction video first + |better watch the following introduction video first |* [Dynamic resourceDoc version1](https://vimeo.com/623381607) | |The endpoint return the response from PractiseEndpoint code. @@ -40,5 +42,6 @@ object PractiseEndpointGroup extends EndpointGroup{ List( UnknownError ), - List(apiTagDynamicResourceDoc)) :: Nil + List(apiTagDynamicResourceDoc), + dynamicHttp4sFunction = Some(PractiseEndpoint.endpoint)) :: Nil } 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 df593bbed8..f7c704beac 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -1611,7 +1611,11 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ var specifiedUrl: Option[String] = None, // A derived value: Contains the called version (added at run time). See the resource doc for resource doc! createdByBankId: Option[String] = None, //we need to filter the resource Doc by BankId authMode: EndpointAuthMode = UserOnly, // Per-endpoint auth mode: UserOnly, ApplicationOnly, UserOrApplication, UserAndApplication - http4sPartialFunction: Http4sEndpoint = None // http4s endpoint handler + http4sPartialFunction: Http4sEndpoint = None, // http4s endpoint handler + // Native http4s handler for runtime-compiled dynamic endpoints (Piece C). Defaulted to None so + // no existing construction site changes. Set by DynamicResourceDocsEndpointGroup / practise group + // (with partialFunction = dynamicEndpointStub); run by code.api.dynamic.endpoint.Http4sDynamicEndpoint. + dynamicHttp4sFunction: Option[OBPEndpointIO] = None ) { // this code block will be merged to constructor. { @@ -1764,6 +1768,81 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ case pair @(k, _) if isPathVariable(k) => pair }.toMap + /** + * Whether the given request path segments (after the version prefix) match this doc's + * requestUrl template — the public, framework-neutral form of the `isUrlMatchesResourceDocUrl` + * closure inside [[wrappedWithAuthCheck]]. Used by the native runtime-compiled dynamic-endpoint + * dispatcher (code.api.dynamic.endpoint.Http4sDynamicEndpoint) to locate the matching doc. + */ + def matchesPartPath(partPath: List[String]): Boolean = { + val urlInDoc = requestUrlPartPath.toList + if (partPath == urlInDoc) true + else { + val pathVariableNames = findPathVariableNames(this.requestUrl) + (partPath.size == urlInDoc.size) && + urlInDoc.zip(partPath).forall { case (k, v) => k == v || pathVariableNames.contains(k) } + } + } + + /** + * Native (http4s) analogue of [[wrappedWithAuthCheck]]'s auth/validation chain, for the + * runtime-compiled dynamic-endpoint dispatch. Runs the SAME ordered checks — authentication, + * obp-id format, bank, roles, account, view, counterparty — with the same predicates and *Fun + * helpers, returning the enriched CallContext (user set) for a native handler instead of + * wrapping a Lift OBPEndpoint. No S.init / SS / Box[JsonResponse]; the compiled native body + * looks up bank/account/view itself, so the entities validated here serve only 404/403 gating, + * exactly as the Lift checks did. Auth/role/lookup failures fail the Future (the dispatcher + * converts them to a response via ErrorResponseConverter). + */ + def authCheckIO(partPath: List[String], requestJsonBody: Box[JValue], cc: CallContext): Future[Option[CallContext]] = { + import com.openbankproject.commons.ExecutionContext.Implicits.global + val pathParams = getPathParams(partPath) + val allObpKeyValuePairs = + if (cc.verb == "POST" && requestJsonBody.isDefined) getAllObpIdKeyValuePairs(requestJsonBody.getOrElse(JString(""))) else Nil + val bankId = pathParams.get("BANK_ID").map(BankId(_)) + val accountId = pathParams.get("ACCOUNT_ID").map(AccountId(_)) + val viewId = pathParams.get("VIEW_ID").map(ViewId(_)) + val counterpartyId = pathParams.get("COUNTERPARTY_ID").map(CounterpartyId(_)) + + def checkAuth(cc: CallContext): Future[(Box[User], Option[CallContext])] = authMode match { + case UserOnly | UserAndApplication => if (AuthCheckIsRequired) authenticatedAccessFun(cc) else anonymousAccessFun(cc) + case ApplicationOnly | UserOrApplication => applicationAccessFun(cc) + } + def checkObpIds(pairs: List[(String, String)], callContext: Option[CallContext]): Future[Option[CallContext]] = Future { + val invalid = pairs.filter(p => !checkObpId(p._2).equals(SILENCE_IS_GOLDEN)) + if (invalid.nonEmpty) throw new RuntimeException(s"$InvalidJsonFormat Here are all invalid values: $invalid") else callContext + } + def checkBank(bankId: Option[BankId], callContext: Option[CallContext]): Future[(Bank, Option[CallContext])] = + if (isNeedCheckBank && bankId.isDefined) checkBankFun(bankId.get)(callContext) else Future.successful(null.asInstanceOf[Bank] -> callContext) + def checkRoles(bankId: Option[BankId], user: Box[User], callContext: Option[CallContext]): Future[Box[Unit]] = + if (isNeedCheckRoles) { + val bankIdStr = bankId.map(_.value).getOrElse("") + val userIdStr = user.map(_.userId).openOr("") + val consumerId = APIUtil.getConsumerPrimaryKey(callContext) + val errorMessage = if (rolesForCheck.filter(_.requiresBankId).isEmpty) UserHasMissingRoles + rolesForCheck.mkString(" or ") + else UserHasMissingRoles + rolesForCheck.mkString(" or ") + s" for BankId($bankIdStr)." + Helper.booleanToFuture(errorMessage, cc = callContext) { APIUtil.handleAccessControlWithAuthMode(bankIdStr, userIdStr, consumerId, rolesForCheck, authMode) } + } else Future.successful(Full(Unit)) + def checkAccount(bankId: Option[BankId], accountId: Option[AccountId], callContext: Option[CallContext]): Future[(BankAccount, Option[CallContext])] = + if (isNeedCheckAccount && bankId.isDefined && accountId.isDefined) checkAccountFun(bankId.get)(accountId.get, callContext) else Future.successful(null.asInstanceOf[BankAccount] -> callContext) + def checkView(viewId: Option[ViewId], bankId: Option[BankId], accountId: Option[AccountId], boxUser: Box[User], callContext: Option[CallContext]): Future[View] = + if (isNeedCheckView && bankId.isDefined && accountId.isDefined && viewId.isDefined) checkViewFun(viewId.get)(BankIdAccountId(bankId.get, accountId.get), boxUser, callContext) else Future.successful(null.asInstanceOf[View]) + def checkCounterparty(counterpartyId: Option[CounterpartyId], callContext: Option[CallContext]): OBPReturnType[CounterpartyTrait] = + if (isNeedCheckCounterparty && counterpartyId.isDefined) checkCounterpartyFun(counterpartyId.get)(callContext) else Future.successful(null.asInstanceOf[CounterpartyTrait] -> callContext) + + for { + (boxUser, callContext) <- checkAuth(cc) + _ <- checkObpIds(allObpKeyValuePairs, callContext) + (bank, callContext) <- checkBank(bankId, callContext) + _ <- checkRoles(bankId, boxUser, callContext) + (account, callContext) <- checkAccount(bankId, accountId, callContext) + view <- checkView(viewId, bankId, accountId, boxUser, callContext) + counterparty <- checkCounterparty(counterpartyId, callContext) + } yield { + if (boxUser.isDefined) callContext.map(_.copy(user = boxUser)) else callContext + } + } + /** * According errorResponseBodies whether contains AuthenticatedUserIsRequired and UserHasMissingRoles do validation. * So can avoid duplicate code in endpoint body for expression do check. @@ -2901,6 +2980,10 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ type OBPEndpoint = PartialFunction[Req, CallContext => Box[JsonResponse]] type OBPReturnType[T] = Future[(T, Option[CallContext])] type Http4sEndpoint = Option[HttpRoutes[IO]] + // Native http4s endpoint type for runtime-compiled dynamic endpoints (Piece C). Distinct from + // OBPEndpoint (which is Lift-typed and shared by every static endpoint, so must not change): + // the dynamic-code template compiles to this, and Http4sDynamicEndpoint runs it directly. + type OBPEndpointIO = PartialFunction[org.http4s.Request[IO], CallContext => IO[org.http4s.Response[IO]]] def getAllowedEndpoints (endpoints : Iterable[OBPEndpoint], resourceDocs: ArrayBuffer[ResourceDoc]) : List[OBPEndpoint] = { diff --git a/obp-api/src/main/scala/code/api/util/DynamicUtil.scala b/obp-api/src/main/scala/code/api/util/DynamicUtil.scala index df232a0762..7f14c4914e 100644 --- a/obp-api/src/main/scala/code/api/util/DynamicUtil.scala +++ b/obp-api/src/main/scala/code/api/util/DynamicUtil.scala @@ -3,6 +3,7 @@ package code.api.util import code.api.Constant.SHOW_USED_CONNECTOR_METHODS import code.api.{APIFailureNewStyle, JsonResponseException} import code.api.util.ErrorMessages.DynamicResourceDocMethodDependency +import cats.effect.IO import code.util.Helper.MdcLoggable import com.openbankproject.commons.model.BankId import com.openbankproject.commons.util.Functions.Memo @@ -180,6 +181,30 @@ object DynamicUtil extends MdcLoggable{ trait Sandbox { @throws[Exception] def runInSandbox[R](action: => R): R + + /** + * Run a dynamic body's IO under the same security sandbox, for native (http4s) runtime-compiled + * dynamic endpoints (Piece C). The body's SYNCHRONOUS CONSTRUCTION (forcing the by-name `io`, + * i.e. applying the compiled handler / running the user statements up to the first Future) runs + * inside the privileged context with the restricted permissions; the resulting IO is then + * evaluated by the cats-effect runtime OUTSIDE the privileged context. This mirrors the Lift + * path exactly: there `runInSandbox { process(...) }` wrapped only the synchronous construction + * plus the blocking wait, while the user's Future body (DB / network / serialization) ran on the + * EC thread outside `doPrivileged`. Running the whole IO inside `doPrivileged` instead would + * (wrongly) subject framework I/O — DB sockets, etc. — to the dynamic-code permission set. + * + * Non-local `return`: when the dynamic body is the runtime-compiled template it is a closure, + * so `return errorResponse(...)` throws a `NonLocalReturnControl` carrying the IO it should + * return (the Lift runInSandbox caught the JsonResponse equivalent). We recover that IO here so + * an early `return` in user code yields its response rather than a 500. (In PractiseEndpoint the + * body is a real method, so `return` is an ordinary return and never reaches this catch.) + */ + def runInSandboxIO[A](io: => IO[A]): IO[A] = { + def forceBodyIO(): IO[A] = + try io + catch { case e: scala.runtime.NonLocalReturnControl[_] => e.value.asInstanceOf[IO[A]] } + IO.defer(runInSandbox(forceBodyIO())) + } } object Sandbox { diff --git a/obp-api/src/main/scala/code/api/util/ExampleValue.scala b/obp-api/src/main/scala/code/api/util/ExampleValue.scala index a2b974e732..f8549a2f9b 100644 --- a/obp-api/src/main/scala/code/api/util/ExampleValue.scala +++ b/obp-api/src/main/scala/code/api/util/ExampleValue.scala @@ -485,21 +485,27 @@ object ExampleValue { lazy val connectorMethodIdExample = ConnectorField("ace0352a-9a0f-4bfa-b30b-9003aa467f51", "A string that MUST uniquely identify the connector method on this OBP instance, can be used in all cache. ") glossaryItems += makeGlossaryItem("ConnectorMethod.connectorMethodId", connectorMethodIdExample) - lazy val dynamicResourceDocMethodBodyExample = ConnectorField("%20%20%20%20val%20Some(resourceDoc)%20%3D%20callContext." + - "resourceDocument%0A%20%20%20%20val%20hasRequestBody%20%3D%20request.body.isDefined%0A%0A%20%20%20%20%2F%2F%20get%20" + - "Path%20Parameters%2C%20example%3A%0A%20%20%20%20%2F%2F%20if%20the%20requestUrl%20of%20resourceDoc%20is%20%2Fhello%2" + - "Fbanks%2FBANK_ID%2Fworld%0A%20%20%20%20%2F%2F%20the%20request%20path%20is%20%2Fhello%2Fbanks%2Fbank_x%2Fworld%0A%20" + - "%20%20%20%2F%2FpathParams.get(%22BANK_ID%22)%20will%20get%20Option(%22bank_x%22)%20value%0A%0A%20%20%20%20val%20my" + - "UserId%20%3D%20pathParams(%22MY_USER_ID%22)%0A%0A%0A%20%20%20%20val%20requestEntity%20%3D%20request.json%20match%20" + - "%7B%0A%20%20%20%20%20%20case%20Full(zson)%20%3D%3E%0A%20%20%20%20%20%20%20%20try%20%7B%0A%20%20%20%20%20%20%20%20%" + - "20%20zson.extract%5BRequestRootJsonClass%5D%0A%20%20%20%20%20%20%20%20%7D%20catch%20%7B%0A%20%20%20%20%20%20%20%20%" + - "20%20case%20e%3A%20MappingException%20%3D%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20return%20Full(errorJsonResponse(" + - "s%22%24InvalidJsonFormat%20%24%7Be.msg%7D%22))%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20case%20_%3A%20Emp" + - "tyBox%20%3D%3E%0A%20%20%20%20%20%20%20%20return%20Full(errorJsonResponse(s%22%24InvalidRequestPayload%20Current%20" + - "request%20has%20no%20payload%22))%0A%20%20%20%20%7D%0A%0A%0A%20%20%20%20%2F%2F%20please%20add%20business%20logic%20" + - "here%0A%20%20%20%20val%20responseBody%3AResponseRootJsonClass%20%3D%20ResponseRootJsonClass(s%22%24%7BmyUserId%7D_" + - "from_path%22%2C%20requestEntity.name%2C%20requestEntity.age%2C%20requestEntity.hobby)%0A%20%20%20%20Future.successf" + - "ul%20%7B%0A%20%20%20%20%20%20(responseBody%2C%20HttpCode.%60200%60(callContext.callContext))%0A%20%20%20%20%7D", + // Native http4s dynamic-resource-doc method body (the body operators copy via the practise + // endpoint). Mirrors the native PractiseEndpoint.process: reads the request body from + // callContext.httpBody, returns errors via `errorResponse(...)` (the native replacement for + // `Full(errorJsonResponse(...))`), and yields an OBPReturnType which the injected implicit + // converts to IO[Response[IO]]. URL-encoded (encodeURIComponent style) — CompiledObjects decodes it. + lazy val dynamicResourceDocMethodBodyExample = ConnectorField("%20%20%20%20val%20Some(resourceDoc)%20%3D%20callContext.resourceDocument%0A%20%20%20%20val%20hasRequestBody%20" + + "%3D%20callContext.httpBody.exists(_.nonEmpty)%0A%0A%20%20%20%20%2F%2F%20get%20Path%20Parameters%2C%20example%3" + + "A%0A%20%20%20%20%2F%2F%20if%20the%20requestUrl%20of%20resourceDoc%20is%20%2Fhello%2Fbanks%2FBANK_ID%2Fworld%0A" + + "%20%20%20%20%2F%2F%20the%20request%20path%20is%20%2Fhello%2Fbanks%2Fbank_x%2Fworld%0A%20%20%20%20%2F%2FpathPar" + + "ams.get(%22BANK_ID%22)%20will%20get%20Option(%22bank_x%22)%20value%0A%0A%20%20%20%20val%20myUserId%20%3D%20pat" + + "hParams(%22MY_USER_ID%22)%0A%0A%0A%20%20%20%20val%20requestEntity%20%3D%20callContext.httpBody.filter(_.nonEmp" + + "ty)%20match%20%7B%0A%20%20%20%20%20%20case%20Some(rawBody)%20%3D%3E%0A%20%20%20%20%20%20%20%20try%20%7B%0A%20%" + + "20%20%20%20%20%20%20%20%20net.liftweb.json.parse(rawBody).extract%5BRequestRootJsonClass%5D%0A%20%20%20%20%20%" + + "20%20%20%7D%20catch%20%7B%0A%20%20%20%20%20%20%20%20%20%20case%20e%3A%20MappingException%20%3D%3E%0A%20%20%20%" + + "20%20%20%20%20%20%20%20%20return%20errorResponse(s%22%24InvalidJsonFormat%20%24%7Be.msg%7D%22)%0A%20%20%20%20%" + + "20%20%20%20%7D%0A%20%20%20%20%20%20case%20None%20%3D%3E%0A%20%20%20%20%20%20%20%20return%20errorResponse(s%22%" + + "24InvalidRequestPayload%20Current%20request%20has%20no%20payload%22)%0A%20%20%20%20%7D%0A%0A%0A%20%20%20%20%2F" + + "%2F%20please%20add%20business%20logic%20here%0A%20%20%20%20val%20responseBody%3AResponseRootJsonClass%20%3D%20" + + "ResponseRootJsonClass(s%22%24%7BmyUserId%7D_from_path%22%2C%20requestEntity.name%2C%20requestEntity.age%2C%20r" + + "equestEntity.hobby)%0A%20%20%20%20Future.successful%20%7B%0A%20%20%20%20%20%20(responseBody%2C%20HttpCode.%602" + + "00%60(callContext.callContext))%0A%20%20%20%20%7D", "the URL-encoded format String, the original code is the OBP connector method body.") glossaryItems += makeGlossaryItem("DynamicResourceDoc.methodBody", dynamicResourceDocMethodBodyExample) diff --git a/obp-api/src/test/scala/code/api/v4_0_0/DynamicResourceDocTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/DynamicResourceDocTest.scala index e16fbe89e9..bca5c3fcc7 100644 --- a/obp-api/src/test/scala/code/api/v4_0_0/DynamicResourceDocTest.scala +++ b/obp-api/src/test/scala/code/api/v4_0_0/DynamicResourceDocTest.scala @@ -244,5 +244,52 @@ class DynamicResourceDocTest extends V400ServerSetup { } } + // End-to-end exercise of the NATIVE runtime-compiled dynamic-endpoint dispatch (Piece C): + // Http4sDynamicEndpoint.pieceC -> DynamicEndpoints.findEndpoint -> ResourceDoc.authCheckIO -> + // the compiled OBPEndpointIO handler -> Sandbox.runInSandboxIO -> OBPReturnType => IO[Response] implicit. + // The metadata-CRUD scenarios above only prove the doc/template compiles; these prove it RUNS. + feature("Native execution of runtime-compiled dynamic endpoints (Piece C)") { + + scenario("Call the always-available practise endpoint (anonymous) end-to-end", VersionOfApi) { + When("We POST a valid body to /obp/dynamic-endpoint/test-dynamic-resource-doc/my_user/MY_USER_ID") + val request = (dynamicEndpoint_Request / "test-dynamic-resource-doc" / "my_user" / "123").POST + val response = makePostRequest(request, """{"name":"Jhon","age":12,"hobby":["coding"]}""") + Then("We should get a 200 (the practise endpoint requires no auth) served natively by PractiseEndpoint") + response.code should equal(200) + And("the body is the banks JSON returned by the practise endpoint (createBanksJson)") + json.compactRender(response.body) should include("banks") + } + + scenario("Create a runtime-compiled dynamic resource doc (no roles) and call it end-to-end", ApiEndpoint1, VersionOfApi) { + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, ApiRole.canCreateDynamicResourceDoc.toString) + + When("We create a dynamic resource doc with no roles (anonymous) and a unique URL") + val createReq = (v4_0_0_Request / "management" / "dynamic-resource-docs").POST <@ (user1) + val doc = SwaggerDefinitionsJSON.jsonDynamicResourceDoc.copy( + dynamicResourceDocId = None, + bankId = None, + roles = "", + partialFunctionName = "nativePieceCTest", + requestUrl = "/my_native_user/MY_USER_ID" + ) + val createResp = makePostRequest(createReq, write(doc)) + Then("We should get a 201") + createResp.code should equal(201) + + Then("calling the compiled endpoint with a valid body returns 200 and the computed response body") + // The doc has no roles but its errorResponseBodies require an authenticated user, so call as user1. + val callReq = (dynamicEndpoint_Request / "dynamic-resource-doc" / "my_native_user" / "user-xyz").POST <@ (user1) + val callResp = makePostRequest(callReq, """{"name":"Jhon","age":12,"hobby":["coding"]}""") + callResp.code should equal(200) + val rendered = json.compactRender(callResp.body) + rendered should include("user-xyz_from_path") // pathParam MY_USER_ID flowed into the response + rendered should include("Jhon") // request body parsed and echoed back + + Then("calling without a body returns 400 — the body's `return errorResponse(...)` is recovered from the sandbox (NonLocalReturn)") + val callNoBodyReq = (dynamicEndpoint_Request / "dynamic-resource-doc" / "my_native_user" / "user-xyz").POST <@ (user1) + val callNoBodyResp = makePostRequest(callNoBodyReq, "") + callNoBodyResp.code should equal(400) + } + } } From a856cc59c0da7b0033dadb79513b59c04eb7e08b Mon Sep 17 00:00:00 2001 From: Hongwei Date: Thu, 28 May 2026 07:01:56 +0200 Subject: [PATCH 04/14] =?UTF-8?q?Fix:=20GET=20dynamic-endpoint=20proxy=20c?= =?UTF-8?q?alls=20404=20=E2=80=94=20drop=20the=20over-strict=20JSON=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The native dynamic-endpoint proxy carried an isJsonRequest gate (introduced with the Piece B migration) that only matched requests whose Content-Type or Accept literally contained "json". The Lift DynamicReq extractor it replaced gated on testResponse_?, which treats a wildcard Accept (and an absent Accept) as JSON-acceptable — so it matched the OBP test client's GET proxy calls (Accept */*, Content-Type text/plain). The literal check rejected those GETs, so a created dynamic endpoint called via GET fell through to the Lift bridge and returned 404 (caught by RateLimitingTest's Dynamic Endpoint scenario in the full suite). Remove the gate entirely: the native dispatch has no XML alternative, and resolveProxyTarget already returns None for any path that is not a registered dynamic-endpoint (falling through to Piece C / the chain), so the gate is unnecessary. POST proxy calls (JSON body) and GET proxy calls now both match. Verified on JDK 11: RateLimitingTest, DynamicEndpointsTest, DynamicResourceDocTest all pass (44/44). --- .../endpoint/Http4sDynamicEndpoint.scala | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/obp-api/src/main/scala/code/api/dynamic/endpoint/Http4sDynamicEndpoint.scala b/obp-api/src/main/scala/code/api/dynamic/endpoint/Http4sDynamicEndpoint.scala index 340050c96a..6168fa98a0 100644 --- a/obp-api/src/main/scala/code/api/dynamic/endpoint/Http4sDynamicEndpoint.scala +++ b/obp-api/src/main/scala/code/api/dynamic/endpoint/Http4sDynamicEndpoint.scala @@ -37,7 +37,6 @@ import net.liftweb.common.{Box, Empty, Full} import net.liftweb.json.Formats import net.liftweb.json.JsonAST.{JNothing, JValue} import org.http4s.{HttpRoutes, Request, Response} -import org.typelevel.ci.CIString /** * Native http4s entry point for the OBP dynamic-endpoint dispatch (under /obp/dynamic-endpoint/). @@ -76,24 +75,22 @@ object Http4sDynamicEndpoint extends MdcLoggable { private def queryParams(req: Request[IO]): Map[String, List[String]] = req.uri.query.multiParams.map { case (k, vs) => k -> vs.toList } - // Mirror of the Lift DynamicReq gate `testResponse_?`: only treat the request as a dynamic-endpoint - // proxy candidate when it is JSON (Content-Type or Accept carries json). A non-JSON request returns - // OptionT.none so it falls through to Piece C / the Http4sApp chain, exactly as before. - private def isJsonRequest(req: Request[IO]): Boolean = { - def header(name: String): String = req.headers.get(CIString(name)).map(_.head.value).getOrElse("") - header("Content-Type").toLowerCase.contains("json") || header("Accept").toLowerCase.contains("json") - } - /** * Native Piece B (proxy) handler. Matches via `DynamicEndpointHelper.DynamicReq.resolveProxyTarget` * and runs the shared, framework-neutral `APIMethodsDynamicEndpoint.proxyHandle`. The CallContext * is built by `Http4sCallContextBuilder` and attached so `EndpointHelpers.executeFutureWithStatus` * can reuse the error conversion + metric; auth / entitlement run inside `proxyHandle`. No match -> * `OptionT.none` (fall through to [[pieceC]]). + * + * Note: no JSON content-type gate. The Lift `DynamicReq` extractor gated on `testResponse_?`, but + * that treated a wildcard Accept (and absent Accept) as JSON-acceptable, i.e. it matched the OBP + * test client's GET requests (wildcard Accept, text/plain Content-Type). Re-implementing the gate + * as a literal "contains json" check wrongly rejected those GET proxy calls (404). Since the native + * dispatch has no XML alternative and `resolveProxyTarget` already returns None for any path that + * is not a registered dynamic-endpoint, the gate is unnecessary, so we just try to resolve. */ private def proxy(req: Request[IO]): OptionT[IO, Response[IO]] = - if (!isJsonRequest(req)) OptionT.none[IO, Response[IO]] - else OptionT { + OptionT { val partPath = req.uri.path.segments.drop(2).map(_.encoded).toList // segments after obp/dynamic-endpoint Http4sCallContextBuilder.fromRequest(req, apiVersionString).flatMap { cc0 => val bodyJValue: JValue = cc0.httpBody.filter(_.nonEmpty).map(net.liftweb.json.parse).getOrElse(JNothing) From 774b22ccbac4d657636c2367306ca70cf4772f45 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Thu, 28 May 2026 10:11:23 +0200 Subject: [PATCH 05/14] test: cover Piece C role-gated dispatch (ResourceDoc.authCheckIO) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a DynamicResourceDocTest scenario that creates a runtime-compiled dynamic-resource-doc gated by a (system-level) dynamic role and asserts the native auth chain enforces it: 401 without authentication, 403 when authenticated but missing the role, 200 once the role is granted. This was the only branch of ResourceDoc.authCheckIO (the native mirror of wrappedWithAuthCheck introduced in the Piece C migration) not previously exercised — the existing native-execution scenarios only covered the no-role/anonymous path. Verified on JDK 11: DynamicResourceDocTest 6/6 pass. Note: the proxy entity-mapping branch (isDynamicEntityResponse, in proxyHandle) is intentionally not given a new HTTP E2E here — it is verbatim-relocated Lift code (no logic change in the migration), already has an isDynamicEntityResponse unit test (DynamicEndpointHelperTest) plus mock-branch HTTP coverage (DynamicEndpointsTest / RateLimitingTest), and the existing example fixtures (swagger host=obp_mock, mapping referencing unrelated entities) are not aligned for a clean end-to-end call; a bespoke fixture would be brittle for little gain. --- .../api/v4_0_0/DynamicResourceDocTest.scala | 40 ++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/obp-api/src/test/scala/code/api/v4_0_0/DynamicResourceDocTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/DynamicResourceDocTest.scala index bca5c3fcc7..c9f8c421cc 100644 --- a/obp-api/src/test/scala/code/api/v4_0_0/DynamicResourceDocTest.scala +++ b/obp-api/src/test/scala/code/api/v4_0_0/DynamicResourceDocTest.scala @@ -28,7 +28,7 @@ package code.api.v4_0_0 import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON import code.api.util.APIUtil.OAuth._ import code.api.util.ApiRole._ -import code.api.util.ErrorMessages.{DynamicResourceDocAlreadyExists, DynamicResourceDocNotFound, UserHasMissingRoles} +import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, DynamicResourceDocAlreadyExists, DynamicResourceDocNotFound, UserHasMissingRoles} import code.api.util.ApiRole import code.api.v4_0_0.APIMethods400.Implementations4_0_0 import code.dynamicResourceDoc.JsonDynamicResourceDoc @@ -290,6 +290,44 @@ class DynamicResourceDocTest extends V400ServerSetup { val callNoBodyResp = makePostRequest(callNoBodyReq, "") callNoBodyResp.code should equal(400) } + + // Exercises ResourceDoc.authCheckIO's role-gated path (the native mirror of wrappedWithAuthCheck): + // a runtime-compiled dynamic-resource-doc declaring a role must enforce 401 (no auth) / 403 (no role) + // / 200 (role granted). The existing scenario above only covers the no-role (anonymous-ish) path. + scenario("Create a role-gated runtime-compiled dynamic resource doc and verify 401 / 403 / 200", ApiEndpoint1, VersionOfApi) { + val dynamicRole = "CanCallNativePieceCRoleTest" // becomes a system-level dynamic role (requiresBankId = false) + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, ApiRole.canCreateDynamicResourceDoc.toString) + + When("We create a dynamic resource doc gated by that role (system-level: URL has no BANK_ID)") + val createReq = (v4_0_0_Request / "management" / "dynamic-resource-docs").POST <@ (user1) + val doc = SwaggerDefinitionsJSON.jsonDynamicResourceDoc.copy( + dynamicResourceDocId = None, + bankId = None, + roles = dynamicRole, + partialFunctionName = "nativePieceCRoleTest", + requestUrl = "/my_role_user/MY_USER_ID" + ) + makePostRequest(createReq, write(doc)).code should equal(201) + + val callUrl = dynamicEndpoint_Request / "dynamic-resource-doc" / "my_role_user" / "user-1" + val body = """{"name":"Jhon","age":12,"hobby":["coding"]}""" + + Then("calling without authentication returns 401") + val resp401 = makePostRequest(callUrl.POST, body) + resp401.code should equal(401) + resp401.body.extract[ErrorMessage].message should include(AuthenticatedUserIsRequired) + + Then("calling authenticated but without the role returns 403") + val resp403 = makePostRequest(callUrl.POST <@ (user1), body) + resp403.code should equal(403) + resp403.body.extract[ErrorMessage].message should include(UserHasMissingRoles) + + Then("granting the role makes the call succeed (200)") + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, dynamicRole) + val resp200 = makePostRequest(callUrl.POST <@ (user1), body) + resp200.code should equal(200) + json.compactRender(resp200.body) should include("_from_path") + } } } From 2d37891cfc905697088b88b1bee948e6d34b80ab Mon Sep 17 00:00:00 2001 From: Hongwei Date: Thu, 28 May 2026 10:46:07 +0200 Subject: [PATCH 06/14] =?UTF-8?q?test:=20DynamicMessageDoc=20safety=20net?= =?UTF-8?q?=20=E2=80=94=20401=20+=20runtime=20invoke=20chain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a regression safety net ahead of refactoring the DynamicMessageDoc runtime mechanism. Two new scenarios in DynamicMessageDocTest: - 401: the management endpoints (POST/GET/GET-all/PUT/DELETE on /management/dynamic-message-docs) reject unauthenticated requests with 401. (Previously only the metadata CRUD and role-403 paths were covered.) - Runtime invoke chain: store a DynamicMessageDoc (Scala methodBody) via DynamicMessageDocProvider.create, then call DynamicConnector.invoke and assert the stored body is compiled and run, returning the expected object. This covers the full DB-stored-methodBody -> invoke -> getFunction -> getByProcess -> createFunction (DynamicUtil.compileScalaCode) -> execute path; InternalConnectorTest only exercised createFunction+executeFunction in isolation, bypassing the DB and invoke/getFunction. Connector methods do not run inside the security sandbox, so no sandbox-permission setup is needed; the Scala methodBody is compiled at runtime, which requires JDK 11. Test-only; no main code changed. The DynamicMessageDoc management endpoints are already native http4s and the runtime path uses no Lift web layer, so this is a coverage safety net rather than a migration. Verified on JDK 11: DynamicMessageDocTest 4/4, InternalConnectorTest, DynamicUtilTest pass. --- .../api/v4_0_0/DynamicMessageDocTest.scala | 66 +++++++++++++++++-- 1 file changed, 62 insertions(+), 4 deletions(-) diff --git a/obp-api/src/test/scala/code/api/v4_0_0/DynamicMessageDocTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/DynamicMessageDocTest.scala index f398716df8..95195bc0fd 100644 --- a/obp-api/src/test/scala/code/api/v4_0_0/DynamicMessageDocTest.scala +++ b/obp-api/src/test/scala/code/api/v4_0_0/DynamicMessageDocTest.scala @@ -28,18 +28,24 @@ package code.api.v4_0_0 import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON import code.api.util.APIUtil.OAuth._ import code.api.util.ApiRole._ -import code.api.util.ErrorMessages.{UserHasMissingRoles, DynamicMessageDocNotFound} -import code.api.util.{ApiRole} +import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, UserHasMissingRoles, DynamicMessageDocNotFound} +import code.api.util.{ApiRole, CallContext} import code.api.v4_0_0.APIMethods400.Implementations4_0_0 -import code.dynamicMessageDoc.{JsonDynamicMessageDoc} +import code.bankconnectors.DynamicConnector +import code.dynamicMessageDoc.{DynamicMessageDocProvider, JsonDynamicMessageDoc} import code.entitlement.Entitlement import com.github.dwickern.macros.NameOf.nameOf -import com.openbankproject.commons.model.{ErrorMessage} +import com.openbankproject.commons.model.{Bank, BankId, ErrorMessage} import com.openbankproject.commons.util.ApiVersion +import net.liftweb.common.Box import net.liftweb.json.JArray import net.liftweb.json.Serialization.write import org.scalatest.Tag +import scala.concurrent.Await +import scala.concurrent.Future +import scala.concurrent.duration._ + class DynamicMessageDocTest extends V400ServerSetup { @@ -244,7 +250,59 @@ class DynamicMessageDocTest extends V400ServerSetup { responseDelete.code should equal(403) responseDelete.body.extract[ErrorMessage].message should equal(s"$UserHasMissingRoles${CanDeleteDynamicMessageDoc}") } + + scenario("We call the DynamicMessageDoc management endpoints without authentication", ApiEndpoint1, VersionOfApi) { + val body = write(SwaggerDefinitionsJSON.jsonDynamicMessageDoc.copy(dynamicMessageDocId = None)) + + Then("POST without a token returns 401") + val post = makePostRequest((v4_0_0_Request / "management" / "dynamic-message-docs").POST, body) + post.code should equal(401) + post.body.extract[ErrorMessage].message should include(AuthenticatedUserIsRequired) + + Then("GET (single) without a token returns 401") + makeGetRequest((v4_0_0_Request / "management" / "dynamic-message-docs" / "xx").GET).code should equal(401) + + Then("GET (list) without a token returns 401") + makeGetRequest((v4_0_0_Request / "management" / "dynamic-message-docs").GET).code should equal(401) + + Then("PUT without a token returns 401") + makePutRequest((v4_0_0_Request / "management" / "dynamic-message-docs" / "xx").PUT, body).code should equal(401) + + Then("DELETE without a token returns 401") + makeDeleteRequest((v4_0_0_Request / "management" / "dynamic-message-docs" / "xx").DELETE).code should equal(401) + } } + // Safety net for the runtime connector-method path a refactor would touch: + // a DynamicMessageDoc stored in the DB -> DynamicConnector.invoke -> getFunction -> + // DynamicMessageDocProvider.getByProcess -> createFunction (DynamicUtil.compileScalaCode) -> run. + // InternalConnectorTest only covers createFunction+executeFunction in isolation (bypassing the DB + // and invoke/getFunction); this exercises the whole chain end to end. + // Note: connector methods do NOT run inside the security sandbox, so no sandbox-permission setup is + // needed; but the Scala methodBody is compiled at runtime, which requires JDK 11. + feature("DynamicMessageDoc runtime: stored methodBody compiled and invoked via DynamicConnector") { + scenario("Store a Scala methodBody and invoke it through DynamicConnector.invoke", VersionOfApi) { + val process = "obp.getBankSafetyNet" // unique, avoids colliding with the CRUD scenario's obp.getBank + val doc = SwaggerDefinitionsJSON.jsonDynamicMessageDoc.copy( + dynamicMessageDocId = None, + bankId = None, + process = process + // methodBody = connectorMethodBodyScalaExample (Scala, returns BankCommons(BankId("Hello bank id"), ...)) + ) + + When("We store the DynamicMessageDoc via the provider") + DynamicMessageDocProvider.provider.vend.create(None, doc).isDefined should equal(true) + + Then("DynamicConnector.invoke compiles the stored methodBody and runs the connector method") + val fut = DynamicConnector + .invoke(None, process, Array(BankId("1")), Some(CallContext())) + .asInstanceOf[Future[Box[(AnyRef, Option[CallContext])]]] + val box = Await.result(fut, 5.minutes) + + box.isDefined should equal(true) + val bank = box.openOrThrowException("dynamic connector method returned Empty")._1.asInstanceOf[Bank] + bank.bankId.value should equal("Hello bank id") + } + } } From 4315524fe7b838f4f4e29e92043d98ac2f5d244b Mon Sep 17 00:00:00 2001 From: Hongwei Date: Thu, 28 May 2026 11:22:27 +0200 Subject: [PATCH 07/14] Remove residual Lift web-layer types from the dynamic-code path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cleanup after the dynamic-endpoint/entity native migration. No behaviour change; removes dead Lift web types that no longer participate in dispatch. Part A — shared (DynamicUtil sandbox): - Drop the two NonLocalReturnControl[JsonResponse] catch clauses in Sandbox.createSandbox.runInSandbox (now a plain AccessController.doPrivileged) and the `import net.liftweb.http.JsonResponse`. The only caller is runInSandboxIO, whose forceBodyIO already recovers a NonLocalReturnControl before it reaches runInSandbox; connector methods (DynamicMessageDoc) never run inside the sandbox. Part B — dynamic-endpoint dead Lift refs: - ResourceDocsAPIMethods: add `case dynamic-endpoint => resourceDocs` to activeResourceDocs (mirrors dynamic-entity), so the dynamic-endpoint resource docs are returned unfiltered instead of being filtered by Lift route class. This must precede removing the routes entry, otherwise the proxy docs would be filtered out. - APIMethodsDynamicEndpoint: remove the dead Lift `dynamicEndpoint: OBPEndpoint` (matched by DynamicReq.unapply) — dispatch is fully native via proxyHandle. Drop the now-unused DynamicReq and net.liftweb.http.{JsonResponse, Req} imports. - DynamicEndpointHelper: remove the dead `DynamicReq.unapply(r: Req)` extractor (its only consumer was the removed dynamicEndpoint); keep resolveProxyTarget. DynamicReq no longer extends JsonTest with JsonBody. Drop the net.liftweb.http.Req import. - OBPAPIDynamicEndpoint: routes reduced to List(dynamicEndpointStub); drop the net.liftweb.http.{LiftResponse, PlainTextResponse} import (commented-out CORS only). - Tests: DynamicendPointsTest / ForceErrorValidationTest referenced the removed dynamicEndpoint via nameOf for a Tag; kept the tag name as the literal "dynamicEndpoint" (same convention already used there for the migrated genericEndpoint). The RestHelper mixins on DynamicEndpointHelper / APIMethodsDynamicEndpoint are kept (DynamicEndpointHelper overrides RestHelper's `formats`); removing them is higher-risk and out of scope for this cleanup. Verified on JDK 11: 105/105 across DynamicEndpointsTest, DynamicResourceDocTest, DynamicEndpointHelperTest, DynamicMessageDocTest, DynamicUtilTest, InternalConnectorTest, ForceErrorValidationTest. Full run_all_tests.sh in progress. --- .../ResourceDocsAPIMethods.scala | 1 + .../endpoint/APIMethodsDynamicEndpoint.scala | 14 ++----- .../endpoint/OBPAPIDynamicEndpoint.scala | 17 ++++----- .../helper/DynamicEndpointHelper.scala | 37 ++++--------------- .../scala/code/api/util/DynamicUtil.scala | 15 +++----- .../api/v4_0_0/DynamicendPointsTest.scala | 4 +- .../api/v4_0_0/ForceErrorValidationTest.scala | 4 +- 7 files changed, 32 insertions(+), 60 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 1815eba815..2cbd17a1f5 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 @@ -189,6 +189,7 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth 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 ApiVersion.`dynamic-endpoint` => resourceDocs // dispatch now on Http4sDynamicEndpoint (proxy + native Piece C); routes carry only the stub, skip Lift-route filter case _ => resourceDocs.filter(rd => versionRoutesClasses.contains(rd.partialFunction.getClass)) } diff --git a/obp-api/src/main/scala/code/api/dynamic/endpoint/APIMethodsDynamicEndpoint.scala b/obp-api/src/main/scala/code/api/dynamic/endpoint/APIMethodsDynamicEndpoint.scala index 17e8799a6d..cf533c9ced 100644 --- a/obp-api/src/main/scala/code/api/dynamic/endpoint/APIMethodsDynamicEndpoint.scala +++ b/obp-api/src/main/scala/code/api/dynamic/endpoint/APIMethodsDynamicEndpoint.scala @@ -2,7 +2,6 @@ package code.api.dynamic.endpoint import code.DynamicData.{DynamicData, DynamicDataProvider} import code.api.dynamic.endpoint.helper.{DynamicEndpointHelper, MockResponseHolder} -import code.api.dynamic.endpoint.helper.DynamicEndpointHelper.DynamicReq import code.api.dynamic.endpoint.helper.MockResponseHolder import code.api.dynamic.entity.helper.{DynamicEntityHelper, DynamicEntityInfo, EntityName} import code.api.util.APIUtil._ @@ -19,7 +18,6 @@ import com.openbankproject.commons.model.enums._ import com.openbankproject.commons.util.{ApiVersion, JsonUtils} import net.liftweb.common._ import net.liftweb.http.rest.RestHelper -import net.liftweb.http.{JsonResponse, Req} import net.liftweb.json.JsonAST.JValue import net.liftweb.json.JsonDSL._ import net.liftweb.json._ @@ -225,14 +223,10 @@ trait APIMethodsDynamicEndpoint { } } - - lazy val dynamicEndpoint: OBPEndpoint = { - case DynamicReq(url, json, method, params, pathParams, role, operationId, mockResponse, bankId) => { cc => - proxyHandle(url, json, method, params, pathParams, role, operationId, mockResponse, bankId, cc).map { - case (value, code) => (value, Option(cc.copy(httpCode = Some(code)))) - } - } - } + // The Lift `dynamicEndpoint: OBPEndpoint` (matched by DynamicReq.unapply, returning Box[JsonResponse]) + // has been removed: dynamic-endpoint dispatch is fully native (Http4sDynamicEndpoint.proxy calls + // proxyHandle directly), and the resource-doc aggregation no longer filters by Lift route class + // (ResourceDocsAPIMethods now returns the dynamic-endpoint resourceDocs unfiltered, like dynamic-entity). } } diff --git a/obp-api/src/main/scala/code/api/dynamic/endpoint/OBPAPIDynamicEndpoint.scala b/obp-api/src/main/scala/code/api/dynamic/endpoint/OBPAPIDynamicEndpoint.scala index 0a3c10ec32..782c2104ff 100644 --- a/obp-api/src/main/scala/code/api/dynamic/endpoint/OBPAPIDynamicEndpoint.scala +++ b/obp-api/src/main/scala/code/api/dynamic/endpoint/OBPAPIDynamicEndpoint.scala @@ -35,7 +35,6 @@ import code.api.v5_0_0.OBPAPI5_0_0.{allResourceDocs, apiPrefix, registerRoutes, import code.util.Helper.MdcLoggable import com.openbankproject.commons.util.{ApiVersion,ApiVersionStatus} import net.liftweb.common.{Box, Full} -import net.liftweb.http.{LiftResponse, PlainTextResponse} import org.apache.http.HttpStatus /* @@ -50,14 +49,14 @@ object OBPAPIDynamicEndpoint extends OBPRestHelper with MdcLoggable with Version // if old version ResourceDoc objects have the same name endpoint with new version, omit old version ResourceDoc. def allResourceDocs = collectResourceDocs(ImplementationsDynamicEndpoint.resourceDocs) - val routes : List[OBPEndpoint] = List(APIUtil.dynamicEndpointStub, - //This is for the dynamic endpoints which are created by dynamic swagger files - ImplementationsDynamicEndpoint.dynamicEndpoint - // Piece C (runtime-compiled dynamic-resource-doc / practise) endpoints are now served NATIVELY - // by code.api.dynamic.endpoint.Http4sDynamicEndpoint via DynamicEndpoints.findEndpoint. The - // former Lift `DynamicEndpoints.dynamicEndpoint` (OBPEndpoint) has been removed; the compiled - // artifacts are now OBPEndpointIO carried on each dynamic ResourceDoc.dynamicHttp4sFunction. - ) + // dynamic-endpoint dispatch is fully native (code.api.dynamic.endpoint.Http4sDynamicEndpoint): + // - Piece B (proxy): Http4sDynamicEndpoint.proxy -> APIMethodsDynamicEndpoint.proxyHandle + // - Piece C (runtime-compiled): DynamicEndpoints.findEndpoint -> ResourceDoc.dynamicHttp4sFunction + // The former Lift `OBPEndpoint`s (ImplementationsDynamicEndpoint.dynamicEndpoint via DynamicReq, + // and DynamicEndpoints.dynamicEndpoint) have been removed. `routes` keeps only the no-op stub; it + // is no longer used for resource-doc filtering (ResourceDocsAPIMethods returns the dynamic-endpoint + // resourceDocs unfiltered, like dynamic-entity). + val routes : List[OBPEndpoint] = List(APIUtil.dynamicEndpointStub) // dynamic-endpoint dispatch migrated to native http4s (code.api.dynamic.endpoint.Http4sDynamicEndpoint). // The Http4sDynamicEndpoint adapter rebuilds the wrapped form from `routes` directly diff --git a/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/DynamicEndpointHelper.scala b/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/DynamicEndpointHelper.scala index 70a569a7ea..9f43203a94 100644 --- a/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/DynamicEndpointHelper.scala +++ b/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/DynamicEndpointHelper.scala @@ -17,7 +17,6 @@ import io.swagger.v3.oas.models.responses.{ApiResponse, ApiResponses} import io.swagger.v3.oas.models.{OpenAPI, Operation, PathItem} import io.swagger.v3.parser.OpenAPIV3Parser import net.liftweb.common.{Box, Full} -import net.liftweb.http.Req import net.liftweb.http.rest.RestHelper import net.liftweb.json import net.liftweb.json.JsonAST.{JArray, JField, JNothing, JObject, JValue} @@ -158,38 +157,18 @@ object DynamicEndpointHelper extends RestHelper { /** * extract request body, no matter GET, POST, PUT or DELETE method */ - object DynamicReq extends JsonTest with JsonBody { + object DynamicReq { private val ExpressionRegx = """\{(.+?)\}""".r - /** - * unapply Request to (request url, json, http method, request parameters, path parameters, role) - * request url is current request target url to remote server - * json is request body - * http method is request http method - * request parameters : http request query parameters, eg: /pet/findByStatus?status=available => (status, List(available)) - * path parameters: /banks/{bankId}/users/{userId} bankId and userId corresponding key to value - * role is current endpoint required entitlement - * @param r HttpRequest - * @return (adapterUrl, requestBodyJson, httpMethod, requestParams, pathParams, role, operationId, mockResponseCode->mockResponseBody) - */ - def unapply(r: Req): Option[(String, JValue, PekkoHttpMethod, Map[String, List[String]], Map[String, String], ApiRole, String, Option[(Int, JValue)], Option[String])] = { - - val requestUri = r.request.uri //eg: `/obp/dynamic-endpoint/fashion-brand-list/BRAND_ID` - val partPath = r.path.partPath //eg: List("fashion-brand-list","BRAND_ID"), the dynamic is from OBP URL, not in the partPath now. - - if (!testResponse_?(r) || !requestUri.startsWith(s"/${ApiStandards.obp.toString}/${ApiShortVersions.`dynamic-endpoint`.toString}"+urlPrefix))//if check the Content-Type contains json or not, and check the if it is the `dynamic_endpoints_url_prefix` - None //if do not match `URL and Content-Type`, then can not find this endpoint. return None. - else - resolveProxyTarget(r.requestType.method, partPath, r.params, body(r).getOrElse(JNothing)) - } /** - * Framework-neutral core of [[unapply]]: given the HTTP method name, the path segments - * AFTER the `/obp/dynamic-endpoint` prefix (Lift's `r.path.partPath`), the query params and - * the already-parsed request body, resolve the matching dynamic-endpoint to the proxy 9-tuple. - * Shared by the Lift `unapply` (above) and the native http4s dispatcher - * (code.api.dynamic.endpoint.Http4sDynamicEndpoint) so both build the identical tuple from the - * same DB lookup (`dynamicEndpointInfos` / `findDynamicEndpoint`) — only the request decoding differs. + * Resolve a dynamic-endpoint proxy target: given the HTTP method name, the path segments AFTER + * the `/obp/dynamic-endpoint` prefix, the query params and the already-parsed request body, + * return the proxy 9-tuple by looking it up in the DB (`dynamicEndpointInfos` / `findDynamicEndpoint`). + * Called by the native http4s dispatcher (code.api.dynamic.endpoint.Http4sDynamicEndpoint.proxy). + * + * (Formerly the framework-neutral core of a Lift `unapply(r: Req)` extractor; the Lift extractor + * and its `dynamicEndpoint: OBPEndpoint` consumer have been removed now that dispatch is native.) */ def resolveProxyTarget( httpMethodStr: String, diff --git a/obp-api/src/main/scala/code/api/util/DynamicUtil.scala b/obp-api/src/main/scala/code/api/util/DynamicUtil.scala index 7f14c4914e..c5c185f991 100644 --- a/obp-api/src/main/scala/code/api/util/DynamicUtil.scala +++ b/obp-api/src/main/scala/code/api/util/DynamicUtil.scala @@ -10,7 +10,6 @@ import com.openbankproject.commons.util.Functions.Memo import com.openbankproject.commons.util.{JsonUtils, ReflectUtils} import javassist.{ClassPool, LoaderClassPath} import net.liftweb.common.{Box, Empty, Failure, Full, ParamFailure} -import net.liftweb.http.JsonResponse import net.liftweb.json.{Extraction, JValue, prettyRender} import org.apache.commons.lang3.StringUtils import org.graalvm.polyglot.{Context, Engine, HostAccess, PolyglotAccess} @@ -234,17 +233,13 @@ object DynamicUtil extends MdcLoggable{ new Sandbox { @throws[Exception] - def runInSandbox[R](action: => R): R = try { - val privilegedAction: PrivilegedAction[R] = () => action - + def runInSandbox[R](action: => R): R = { + val privilegedAction: PrivilegedAction[R] = () => action AccessController.doPrivileged(privilegedAction, accessControlContext) - } catch { - case e: NonLocalReturnControl[Full[JsonResponse]] if e.value.isInstanceOf[Full[JsonResponse]] => - throw JsonResponseException(e.value.orNull) - - case e: NonLocalReturnControl[JsonResponse] if e.value.isInstanceOf[JsonResponse] => - throw JsonResponseException(e.value) } + // The former NonLocalReturnControl[JsonResponse] catch (for the Lift dynamic-code path's + // `return Full(errorJsonResponse(...))`) is gone: the only caller is runInSandboxIO, whose + // forceBodyIO already recovers a NonLocalReturnControl before it reaches here. } } diff --git a/obp-api/src/test/scala/code/api/v4_0_0/DynamicendPointsTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/DynamicendPointsTest.scala index f8143922bd..ab118fe6da 100644 --- a/obp-api/src/test/scala/code/api/v4_0_0/DynamicendPointsTest.scala +++ b/obp-api/src/test/scala/code/api/v4_0_0/DynamicendPointsTest.scala @@ -33,7 +33,9 @@ class DynamicEndpointsTest extends V400ServerSetup { object ApiEndpoint5 extends Tag(nameOf(Implementations4_0_0.getMyDynamicEndpoints)) object ApiEndpoint6 extends Tag(nameOf(Implementations4_0_0.deleteMyDynamicEndpoint)) object ApiEndpoint7 extends Tag(nameOf(Implementations4_0_0.updateDynamicEndpointHost)) - object ApiEndpoint8 extends Tag(nameOf(ImplementationsDynamicEndpoint.dynamicEndpoint)) + // Tag name kept as "dynamicEndpoint" (the former nameOf(ImplementationsDynamicEndpoint.dynamicEndpoint)); + // that Lift OBPEndpoint was removed when dynamic-endpoint dispatch went fully native. + object ApiEndpoint8 extends Tag("dynamicEndpoint") object ApiEndpoint9 extends Tag(nameOf(Implementations4_0_0.createBankLevelDynamicEndpoint)) object ApiEndpoint10 extends Tag(nameOf(Implementations4_0_0.getBankLevelDynamicEndpoints)) object ApiEndpoint11 extends Tag(nameOf(Implementations4_0_0.getBankLevelDynamicEndpoint)) 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 97cf87a96f..fab407dd70 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 @@ -36,7 +36,9 @@ class ForceErrorValidationTest extends V400ServerSetup with PropsReset { // 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)) + // dynamicEndpoint was removed when dynamic-endpoint dispatch went fully native; its nameOf value + // was "dynamicEndpoint" — kept as a string literal so the tag is unchanged. + object ApiEndpoint4 extends Tag("dynamicEndpoint") object ApiEndpointCreateFx extends Tag(nameOf(Implementations2_2_0.createFx)) From bf4ebbad9e63f5cebc9f49da464e83991f05ab71 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Thu, 28 May 2026 11:35:11 +0200 Subject: [PATCH 08/14] ci: grant dynamic_code_sandbox_permissions in build_container workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The develop/container CI (build_container.yml) generates test.default.props from scratch via echo lines and was missing dynamic_code_sandbox_permissions — only build_pull_request.yml had it. Without those permissions the dynamic-code security sandbox denies getenv (connector metric prop reads), reflection and NetPermission("specifyStreamHandler"), so DynamicResourceDocTest's three native-execution scenarios (practise endpoint, create+call a runtime-compiled doc, role-gated doc) fail with AccessControlException in shard 1 (v4 only). Add the same permission list used by build_pull_request.yml / default.props / production.default.props so dynamic resource-doc bodies can execute under the sandbox in this workflow too. CI-only change. --- .github/workflows/build_container.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/build_container.yml b/.github/workflows/build_container.yml index 91a43e2181..3b21c1d697 100644 --- a/.github/workflows/build_container.yml +++ b/.github/workflows/build_container.yml @@ -234,6 +234,11 @@ jobs: echo consents.allowed=true >> obp-api/src/main/resources/props/test.default.props echo hikari.maximumPoolSize=20 >> obp-api/src/main/resources/props/test.default.props echo write_metrics=false >> obp-api/src/main/resources/props/test.default.props + # Permissions granted to runtime-compiled dynamic-endpoint code inside the security sandbox + # (mirrors default.props / production.default.props). Required so dynamic resource-doc bodies + # can do JSON extraction (reflection) and read OBP props (getenv); without it the sandbox + # denies these and DynamicResourceDocTest's native-execution scenarios fail. + echo 'dynamic_code_sandbox_permissions=[new java.net.NetPermission("specifyStreamHandler"), new java.lang.reflect.ReflectPermission("suppressAccessChecks"), new java.lang.RuntimePermission("getenv.*"), new java.util.PropertyPermission("cglib.useCache", "read"), new java.util.PropertyPermission("net.sf.cglib.test.stressHashCodes", "read"), new java.util.PropertyPermission("cglib.debugLocation", "read"), new java.lang.RuntimePermission("accessDeclaredMembers"), new java.lang.RuntimePermission("getClassLoader")]' >> obp-api/src/main/resources/props/test.default.props - name: Run tests — shard ${{ matrix.shard }} (${{ matrix.name }}) run: | From bb044fe74cb3c3bd870b2d3d80b06151ee08b42a Mon Sep 17 00:00:00 2001 From: Hongwei Date: Thu, 28 May 2026 14:20:03 +0200 Subject: [PATCH 09/14] feat(bg-v1.3): migrate PIIS group to native http4s + fix BG error response format Add Http4sBGv13PIIS (1 endpoint: POST /funds-confirmations) and Http4sBGv13 aggregator wired into Http4sApp ahead of the Lift bridge. Fix ErrorResponseConverter to emit ErrorMessagesBG (tppMessages) format for all Berlin Group paths, mirroring APIUtil.failedJsonResponse's URL-prefix check. Without this, BG v1.3 tests that assert tppMessages structure receive the standard OBP {code,message} format and fail with "head of empty list". --- .../api/berlin/group/v1_3/Http4sBGv13.scala | 33 +++++ .../berlin/group/v1_3/Http4sBGv13PIIS.scala | 122 ++++++++++++++++++ .../util/http4s/ErrorResponseConverter.scala | 46 +++++-- .../code/api/util/http4s/Http4sApp.scala | 1 + 4 files changed, 189 insertions(+), 13 deletions(-) create mode 100644 obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13.scala create mode 100644 obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13PIIS.scala diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13.scala new file mode 100644 index 0000000000..0ee1ca289b --- /dev/null +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13.scala @@ -0,0 +1,33 @@ +package code.api.berlin.group.v1_3 + +import cats.data.{Kleisli, OptionT} +import cats.effect._ +import code.api.berlin.group.ConstantsBG +import code.api.util.APIUtil.ResourceDoc +import code.api.util.http4s.ResourceDocMiddleware +import code.util.Helper.MdcLoggable +import org.http4s._ + +import scala.collection.mutable.ArrayBuffer + +/** + * Native http4s aggregator for Berlin Group v1.3, replacing the Lift + * `OBP_BERLIN_GROUP_1_3` statelessDispatch registration. Mirrors `Http4sBGv2`. + * + * Groups: AIS / PIS / SigningBaskets / PIIS. Added incrementally. + */ +object Http4sBGv13 extends MdcLoggable { + + type HttpF[A] = OptionT[IO, A] + + val implementedInApiVersion = ConstantsBG.berlinGroupVersion1 + + val resourceDocs: ArrayBuffer[ResourceDoc] = + Http4sBGv13PIIS.resourceDocs + + val allRoutes: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req => + Http4sBGv13PIIS.routes(req) + } + + val wrappedRoutes: HttpRoutes[IO] = ResourceDocMiddleware.apply(resourceDocs)(allRoutes) +} diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13PIIS.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13PIIS.scala new file mode 100644 index 0000000000..3af72af80c --- /dev/null +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13PIIS.scala @@ -0,0 +1,122 @@ +package code.api.berlin.group.v1_3 + +import cats.data.{Kleisli, OptionT} +import cats.effect._ +import code.api.berlin.group.ConstantsBG +import code.api.berlin.group.v1_3.JSONFactory_BERLIN_GROUP_1_3._ +import code.api.util.APIUtil._ +import code.api.util.ApiTag._ +import code.api.util.ErrorMessages._ +import code.api.util.CustomJsonFormats +import code.api.util.{ApiTag, NewStyle} +import code.api.util.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps} +import code.fx.fx +import code.util.Helper +import code.util.Helper.MdcLoggable +import com.github.dwickern.macros.NameOf.nameOf +import com.openbankproject.commons.ExecutionContext.Implicits.global +import net.liftweb.json +import net.liftweb.json.Formats +import org.http4s._ +import org.http4s.dsl.io._ + +import scala.collection.mutable.ArrayBuffer +import scala.language.implicitConversions + +object Http4sBGv13PIIS extends MdcLoggable { + + type HttpF[A] = OptionT[IO, A] + + implicit val formats: Formats = CustomJsonFormats.formats + + // ResourceDoc example bodies are written as `json.parse(...)` (JValue); ResourceDoc requires + // scala.Product, so wrap via the same implicit the Lift builder used (JvalueCaseClass is a Product). + protected implicit def JvalueToSuper(what: net.liftweb.json.JValue): JvalueCaseClass = JvalueCaseClass(what) + + val implementedInApiVersion = ConstantsBG.berlinGroupVersion1 + val resourceDocs = ArrayBuffer[ResourceDoc]() + + val bgV13Prefix = Root / ConstantsBG.berlinGroupVersion1.urlPrefix / ConstantsBG.berlinGroupVersion1.apiShortVersion + + // ── POST /funds-confirmations ───────────────────────────────────── + // Lift source: code.api.builder.ConfirmationOfFundsServicePIISApi.checkAvailabilityOfFunds + // Auth: authenticatedAccess → authMode UserOnly (default); user resolved by ResourceDocMiddleware. + val checkAvailabilityOfFunds: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `bgV13Prefix` / "funds-confirmations" => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + for { + _ <- passesPsd2Aisp(callContext) + checkAvailabilityOfFundsJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $CheckAvailabilityOfFundsJson ", 400, callContext) { + json.parse(cc.httpBody.getOrElse("")).extract[CheckAvailabilityOfFundsJson] + } + + requestAccountAmount <- NewStyle.function.tryons(s"$InvalidNumber Current input is ${checkAvailabilityOfFundsJson.instructedAmount.amount} ", 400, callContext) { + BigDecimal(checkAvailabilityOfFundsJson.instructedAmount.amount) + } + + requestAccountCurrency = checkAvailabilityOfFundsJson.instructedAmount.currency + + _ <- Helper.booleanToFuture(s"${InvalidISOCurrencyCode} Current input is: '${requestAccountCurrency}'", cc = callContext) { + isValidCurrencyISOCode(requestAccountCurrency) + } + + requestAccountIban = checkAvailabilityOfFundsJson.account.iban + (bankAccount, callContext) <- NewStyle.function.getBankAccountByIban(requestAccountIban, callContext) + currentAccountCurrency = bankAccount.currency + currentAccountBalance = bankAccount.balance + + // From change from requestAccount Currency to currentBankAccount Currency + rate = fx.exchangeRate(requestAccountCurrency, currentAccountCurrency, Some(bankAccount.bankId.value), callContext) + + _ <- Helper.booleanToFuture(s"$InvalidCurrency The requested currency conversion (${requestAccountCurrency} to ${currentAccountCurrency}) is not supported.", cc = callContext) { + rate.isDefined + } + + requestChangedCurrencyAmount = fx.convert(requestAccountAmount, rate) + + fundsAvailable = (currentAccountBalance >= requestChangedCurrencyAmount) + + } yield { + net.liftweb.json.parse(s"""{ + "fundsAvailable" : $fundsAvailable + }""") + } + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(checkAvailabilityOfFunds), + "POST", + "/funds-confirmations", + "Confirmation of Funds Request", + s""" ${mockedDataText(false)} +Creates a confirmation of funds request at the ASPSP. Checks whether a specific amount is available at point +of time of the request on an account linked to a given tuple card issuer(TPP)/card number, or addressed by +IBAN and TPP respectively. If the related extended services are used a conditional Consent-ID is contained +in the header. This field is contained but commented out in this specification. """, + json.parse( + """{ + "instructedAmount" : { + "amount" : "123", + "currency" : "EUR" + }, + "account" : { + "iban" : "GR12 1234 5123 4511 3981 4475 477", + } + }"""), + json.parse( + """{ + "fundsAvailable" : true + }"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Confirmation of Funds Service (PIIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(checkAvailabilityOfFunds) + ) + + val routes: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req => + checkAvailabilityOfFunds(req) + } +} 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 101d142bec..1d838a7a08 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 @@ -3,12 +3,15 @@ package code.api.util.http4s import cats.effect._ import code.api.APIFailureNewStyle import code.api.JsonResponseException +import code.api.berlin.group.ConstantsBG +import code.api.berlin.group.v1_3.JSONFactory_BERLIN_GROUP_1_3.{ErrorMessageBG, ErrorMessagesBG} import code.api.util.APIUtil.JsonResponseExtractor +import code.api.util.BerlinGroupError import code.api.util.ErrorMessages._ import code.api.util.CallContext import net.liftweb.common.{Failure => LiftFailure} import net.liftweb.json.JsonParser.parse -import net.liftweb.json.compactRender +import net.liftweb.json.{Extraction, compactRender} import net.liftweb.json.JsonDSL._ import org.http4s._ import org.http4s.headers.`Content-Type` @@ -64,7 +67,7 @@ object ErrorResponseConverter { code: Int, message: String ) - + /** * Convert error response to JSON string using Lift JSON. */ @@ -72,6 +75,17 @@ object ErrorResponseConverter { val json = ("code" -> error.code) ~ ("message" -> error.message) compactRender(json) } + + private def isBerlinGroupRequest(callContext: CallContext): Boolean = + callContext.url.contains(ConstantsBG.berlinGroupVersion1.urlPrefix) + + /** Mirror the BG error body from APIUtil.failedJsonResponse for http4s paths. */ + private def toBgErrorBody(statusCode: Int, message: String, callContext: CallContext): String = { + val pathOpt = Some(callContext.url) + val codeText = BerlinGroupError.translateToBerlinGroupError(statusCode.toString, message) + val errBg = ErrorMessagesBG(tppMessages = List(ErrorMessageBG(category = "ERROR", code = codeText, path = pathOpt, text = message))) + compactRender(Extraction.decompose(errBg)) + } /** * Convert any error to http4s Response[IO]. @@ -100,11 +114,12 @@ 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 body = if (isBerlinGroupRequest(callContext)) toBgErrorBody(code, message, callContext) + else toJsonString(OBPErrorResponse(code, message)) val status = org.http4s.Status.fromInt(code).getOrElse(org.http4s.Status.BadRequest) IO.pure( Response[IO](status) - .withEntity(toJsonString(errorJson)) + .withEntity(body) .withContentType(jsonContentType) .putHeaders(org.http4s.Header.Raw(CIString("Correlation-Id"), callContext.correlationId)) ) @@ -133,11 +148,12 @@ object ErrorResponseConverter { */ def apiFailureToResponse(failure: APIFailureNewStyle, callContext: CallContext): IO[Response[IO]] = { val resolvedCode = resolveStatusCode(failure.failCode, failure.failMsg, callContext) - val errorJson = OBPErrorResponse(resolvedCode, failure.failMsg) + val body = if (isBerlinGroupRequest(callContext)) toBgErrorBody(resolvedCode, failure.failMsg, callContext) + else toJsonString(OBPErrorResponse(resolvedCode, failure.failMsg)) val status = org.http4s.Status.fromInt(resolvedCode).getOrElse(org.http4s.Status.BadRequest) IO.pure( Response[IO](status) - .withEntity(toJsonString(errorJson)) + .withEntity(body) .withContentType(jsonContentType) .putHeaders(org.http4s.Header.Raw(CIString("Correlation-Id"), callContext.correlationId)) ) @@ -148,10 +164,11 @@ object ErrorResponseConverter { * Returns 400 Bad Request with failure message. */ def boxFailureToResponse(failure: LiftFailure, callContext: CallContext): IO[Response[IO]] = { - val errorJson = OBPErrorResponse(400, failure.msg) + val body = if (isBerlinGroupRequest(callContext)) toBgErrorBody(400, failure.msg, callContext) + else toJsonString(OBPErrorResponse(400, failure.msg)) IO.pure( Response[IO](org.http4s.Status.BadRequest) - .withEntity(toJsonString(errorJson)) + .withEntity(body) .withContentType(jsonContentType) .putHeaders(org.http4s.Header.Raw(CIString("Correlation-Id"), callContext.correlationId)) ) @@ -163,24 +180,27 @@ object ErrorResponseConverter { */ def unknownErrorToResponse(e: Throwable, callContext: CallContext): IO[Response[IO]] = { logger.error(s"unknownErrorToResponse says: 500 returned (correlationId=${callContext.correlationId})", e) - val errorJson = OBPErrorResponse(500, s"$UnknownError: ${e.getMessage}") + val message = s"$UnknownError: ${e.getMessage}" + val body = if (isBerlinGroupRequest(callContext)) toBgErrorBody(500, message, callContext) + else toJsonString(OBPErrorResponse(500, message)) IO.pure( Response[IO](org.http4s.Status.InternalServerError) - .withEntity(toJsonString(errorJson)) + .withEntity(body) .withContentType(jsonContentType) .putHeaders(org.http4s.Header.Raw(CIString("Correlation-Id"), callContext.correlationId)) ) } - + /** * Create error response with specific status code and message. */ def createErrorResponse(statusCode: Int, message: String, callContext: CallContext): IO[Response[IO]] = { - val errorJson = OBPErrorResponse(statusCode, message) + val body = if (isBerlinGroupRequest(callContext)) toBgErrorBody(statusCode, message, callContext) + else toJsonString(OBPErrorResponse(statusCode, message)) val status = org.http4s.Status.fromInt(statusCode).getOrElse(org.http4s.Status.BadRequest) IO.pure( Response[IO](status) - .withEntity(toJsonString(errorJson)) + .withEntity(body) .withContentType(jsonContentType) .putHeaders(org.http4s.Header.Raw(CIString("Correlation-Id"), callContext.correlationId)) ) 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 a87d429772..a0c68d4ee3 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 @@ -126,6 +126,7 @@ object Http4sApp { .orElse(v500Routes.run(req)) .orElse(v700Routes.run(req)) .orElse(code.api.berlin.group.v2.Http4sBGv2.wrappedRoutes.run(req)) + .orElse(code.api.berlin.group.v1_3.Http4sBGv13.wrappedRoutes.run(req)) .orElse(v400Routes.run(req)) .orElse(v310Routes.run(req)) .orElse(v300Routes.run(req)) From 2c8f17b3749e8f17a48adf7a56bf0cadd963df0b Mon Sep 17 00:00:00 2001 From: Hongwei Date: Thu, 28 May 2026 14:53:32 +0200 Subject: [PATCH 10/14] feat(bg-v1.3): migrate SigningBaskets group (8 endpoints) to native http4s Also fixes ResourceDocMatcher.apiPrefixPattern to handle Berlin Group paths (/berlin-group/v1.3/...) in addition to OBP-standard paths (/obp/vX.X.X/...). Without this fix the middleware could not strip the BG prefix, segment counts mismatched, findResourceDoc returned None, and auth was bypassed. --- .../api/berlin/group/v1_3/Http4sBGv13.scala | 4 +- .../v1_3/Http4sBGv13SigningBaskets.scala | 536 ++++++++++++++++++ .../code/api/util/http4s/Http4sSupport.scala | 4 +- 3 files changed, 541 insertions(+), 3 deletions(-) create mode 100644 obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13.scala index 0ee1ca289b..4e1978693b 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13.scala @@ -23,10 +23,12 @@ object Http4sBGv13 extends MdcLoggable { val implementedInApiVersion = ConstantsBG.berlinGroupVersion1 val resourceDocs: ArrayBuffer[ResourceDoc] = - Http4sBGv13PIIS.resourceDocs + Http4sBGv13PIIS.resourceDocs ++ + Http4sBGv13SigningBaskets.resourceDocs val allRoutes: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req => Http4sBGv13PIIS.routes(req) + .orElse(Http4sBGv13SigningBaskets.routes(req)) } val wrappedRoutes: HttpRoutes[IO] = ResourceDocMiddleware.apply(resourceDocs)(allRoutes) diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala new file mode 100644 index 0000000000..3a42a2e592 --- /dev/null +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala @@ -0,0 +1,536 @@ +package code.api.berlin.group.v1_3 + +import cats.data.{Kleisli, OptionT} +import cats.effect._ +import code.api.berlin.group.ConstantsBG +import code.api.berlin.group.v1_3.JSONFactory_BERLIN_GROUP_1_3._ +import code.api.util.APIUtil.{EmptyBody, ResourceDoc, connectorEmptyResponse, getSuggestedDefaultScaMethod, mockedDataText, passesPsd2Pisp, unboxFullOrFail} +import code.api.util.ApiTag._ +import code.api.util.ErrorMessages._ +import code.api.util.CustomJsonFormats +import code.api.util.{ApiTag, NewStyle} +import code.api.util.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps} +import code.api.util.newstyle.SigningBasketNewStyle +import code.bankconnectors.Connector +import code.signingbaskets.SigningBasketX +import code.util.Helper.{MdcLoggable, booleanToFuture} +import com.github.dwickern.macros.NameOf.nameOf +import com.openbankproject.commons.ExecutionContext.Implicits.global +import com.openbankproject.commons.model.enums.TransactionRequestStatus.{COMPLETED, REJECTED} +import com.openbankproject.commons.model.enums.{ChallengeType, StrongCustomerAuthenticationStatus, SuppliedAnswerType} +import com.openbankproject.commons.model.{ChallengeTrait, TransactionRequestId} +import net.liftweb.common.Empty +import net.liftweb.json +import net.liftweb.json.Formats +import org.http4s._ +import org.http4s.dsl.io._ + +import scala.collection.mutable.ArrayBuffer +import scala.concurrent.Future +import scala.language.implicitConversions + +object Http4sBGv13SigningBaskets extends MdcLoggable { + + type HttpF[A] = OptionT[IO, A] + + implicit val formats: Formats = CustomJsonFormats.formats + + protected implicit def JvalueToSuper(what: net.liftweb.json.JValue): JvalueCaseClass = JvalueCaseClass(what) + + val implementedInApiVersion = ConstantsBG.berlinGroupVersion1 + val resourceDocs = ArrayBuffer[ResourceDoc]() + + val bgV13Prefix = Root / ConstantsBG.berlinGroupVersion1.urlPrefix / ConstantsBG.berlinGroupVersion1.apiShortVersion + + // ── POST /signing-baskets ────────────────────────────────────────────── + val createSigningBasket: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `bgV13Prefix` / "signing-baskets" => + EndpointHelpers.executeFutureCreated(req) { + val cc = req.callContext + val callContext = Some(cc) + for { + _ <- passesPsd2Pisp(callContext) + failMsg = s"$InvalidJsonFormat The Json body should be the $PostSigningBasketJsonV13 " + postJson <- NewStyle.function.tryons(failMsg, 400, callContext) { + json.parse(cc.httpBody.getOrElse("")).extract[PostSigningBasketJsonV13] + } + _ <- booleanToFuture(failMsg, cc = callContext) { + !(postJson.paymentIds.isEmpty && postJson.consentIds.isEmpty) + } + signingBasket <- Future { + SigningBasketX.signingBasketProvider.vend.createSigningBasket( + postJson.paymentIds, + postJson.consentIds, + ) + }.map(connectorEmptyResponse(_, callContext)) + } yield { + createSigningBasketResponseJson(signingBasket) + } + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(createSigningBasket), + "POST", + "/signing-baskets", + "Create a signing basket resource", + s"""${mockedDataText(false)} +Create a signing basket resource for authorising several transactions with one SCA method. +The resource identifications of these transactions are contained in the payload of this access method +""", + PostSigningBasketJsonV13(paymentIds = Some(List("123qwert456789", "12345qwert7899")), None), + json.parse("""{ + "basketId" : "1234-basket-567", + "challengeData" : { + "otpMaxLength" : 0, + "additionalInformation" : "additionalInformation", + "image" : "image", + "imageLink" : "http://example.com/aeiou", + "otpFormat" : "characters", + "data" : [ "data", "data" ] + }, + "scaMethods" : "", + "tppMessages" : [ { + "path" : "path", + "code" : { }, + "text" : { }, + "category" : { } + } ], + "_links" : { + "scaStatus" : "/v1.3/payments/sepa-credit-transfers/1234-wertiq-983", + "startAuthorisation" : "/v1.3/payments/sepa-credit-transfers/1234-wertiq-983", + "status" : "/v1.3/payments/sepa-credit-transfers/1234-wertiq-983" + }, + "chosenScaMethod" : "", + "transactionStatus" : "ACCP", + "psuMessage" : { } +}"""), + List(AuthenticatedUserIsRequired, UnknownError), + apiTagSigningBaskets :: Nil, + http4sPartialFunction = Some(createSigningBasket) + ) + + // ── DELETE /signing-baskets/BASKETID ────────────────────────────────── + val deleteSigningBasket: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `bgV13Prefix` / "signing-baskets" / basketid => + EndpointHelpers.executeDelete(req) { cc => + val callContext = Some(cc) + for { + _ <- passesPsd2Pisp(callContext) + _ <- Future { + SigningBasketX.signingBasketProvider.vend.deleteSigningBasket(basketid) + }.map(connectorEmptyResponse(_, callContext)) + } yield () + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(deleteSigningBasket), + "DELETE", + "/signing-baskets/BASKETID", + "Delete the signing basket", + s"""${mockedDataText(false)} +Delete the signing basket structure as long as no (partial) authorisation has yet been applied. +The undlerying transactions are not affected by this deletion. + +Remark: The signing basket as such is not deletable after a first (partial) authorisation has been applied. +Nevertheless, single transactions might be cancelled on an individual basis on the XS2A interface. +""", + EmptyBody, + EmptyBody, + List(AuthenticatedUserIsRequired, UnknownError), + apiTagSigningBaskets :: Nil, + http4sPartialFunction = Some(deleteSigningBasket) + ) + + // ── GET /signing-baskets/BASKETID ───────────────────────────────────── + val getSigningBasket: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `bgV13Prefix` / "signing-baskets" / basketid => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + for { + _ <- passesPsd2Pisp(callContext) + basket <- Future { + SigningBasketX.signingBasketProvider.vend.getSigningBasketByBasketId(basketid) + }.map(connectorEmptyResponse(_, callContext)) + } yield { + getSigningBasketResponseJson(basket) + } + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getSigningBasket), + "GET", + "/signing-baskets/BASKETID", + "Returns the content of an signing basket object.", + s"""${mockedDataText(false)} +Returns the content of an signing basket object.""", + EmptyBody, + json.parse("""{ + "transactionStatus" : "ACCP", + "payments" : "", + "consents" : "" +}"""), + List(AuthenticatedUserIsRequired, UnknownError), + apiTagSigningBaskets :: Nil, + http4sPartialFunction = Some(getSigningBasket) + ) + + // ── GET /signing-baskets/BASKETID/authorisations ────────────────────── + val getSigningBasketAuthorisation: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `bgV13Prefix` / "signing-baskets" / basketid / "authorisations" => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + for { + _ <- passesPsd2Pisp(callContext) + (challenges, _) <- NewStyle.function.getChallengesByBasketId(basketid, callContext) + } yield { + JSONFactory_BERLIN_GROUP_1_3.AuthorisationJsonV13(challenges.map(_.challengeId)) + } + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getSigningBasketAuthorisation), + "GET", + "/signing-baskets/BASKETID/authorisations", + "Get Signing Basket Authorisation Sub-Resources Request", + s"""${mockedDataText(false)} +Read a list of all authorisation subresources IDs which have been created. + +This function returns an array of hyperlinks to all generated authorisation sub-resources. +""", + EmptyBody, + json.parse("""{ + "authorisationIds" : "" +}"""), + List(AuthenticatedUserIsRequired, UnknownError), + apiTagSigningBaskets :: Nil, + http4sPartialFunction = Some(getSigningBasketAuthorisation) + ) + + // ── GET /signing-baskets/BASKETID/authorisations/AUTHORISATIONID ─────── + val getSigningBasketScaStatus: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `bgV13Prefix` / "signing-baskets" / basketId / "authorisations" / authorisationId => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + for { + _ <- passesPsd2Pisp(callContext) + _ <- Future(SigningBasketX.signingBasketProvider.vend.getSigningBasketByBasketId(basketId)) + .map(unboxFullOrFail(_, callContext, s"$ConsentNotFound ($basketId)", 403)) + (challenges, _) <- NewStyle.function.getChallengesByBasketId(basketId, callContext) + } yield { + val challengeStatus = challenges.filter(_.challengeId == authorisationId) + .flatMap(_.scaStatus).headOption.map(_.toString).getOrElse("None") + JSONFactory_BERLIN_GROUP_1_3.ScaStatusJsonV13(challengeStatus) + } + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getSigningBasketScaStatus), + "GET", + "/signing-baskets/BASKETID/authorisations/AUTHORISATIONID", + "Read the SCA status of the signing basket authorisation", + s"""${mockedDataText(false)} +This method returns the SCA status of a signing basket's authorisation sub-resource. +""", + EmptyBody, + json.parse("""{ + "scaStatus" : "psuAuthenticated" +}"""), + List(AuthenticatedUserIsRequired, UnknownError), + apiTagSigningBaskets :: Nil, + http4sPartialFunction = Some(getSigningBasketScaStatus) + ) + + // ── GET /signing-baskets/BASKETID/status ────────────────────────────── + val getSigningBasketStatus: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `bgV13Prefix` / "signing-baskets" / basketid / "status" => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + for { + _ <- passesPsd2Pisp(callContext) + basket <- Future { + SigningBasketX.signingBasketProvider.vend.getSigningBasketByBasketId(basketid) + }.map(connectorEmptyResponse(_, callContext)) + } yield { + getSigningBasketStatusResponseJson(basket) + } + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getSigningBasketStatus), + "GET", + "/signing-baskets/BASKETID/status", + "Read the status of the signing basket", + s"""${mockedDataText(false)} +Returns the status of a signing basket object. +""", + EmptyBody, + json.parse("""{ + "transactionStatus" : "RCVD" +}"""), + List(AuthenticatedUserIsRequired, UnknownError), + apiTagSigningBaskets :: Nil, + http4sPartialFunction = Some(getSigningBasketStatus) + ) + + // ── POST /signing-baskets/BASKETID/authorisations ───────────────────── + val startSigningBasketAuthorisation: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `bgV13Prefix` / "signing-baskets" / basketId / "authorisations" => + EndpointHelpers.executeFutureCreated(req) { + val cc = req.callContext + val callContext = Some(cc) + for { + _ <- passesPsd2Pisp(callContext) + (challenges, _) <- NewStyle.function.createChallengesC3( + List(cc.user.map(_.userId).openOr("")), + ChallengeType.BERLIN_GROUP_SIGNING_BASKETS_CHALLENGE, + None, + getSuggestedDefaultScaMethod(), + Some(StrongCustomerAuthenticationStatus.received), + None, + Some(basketId), + None, + callContext + ) + challenge <- NewStyle.function.tryons(InvalidConnectorResponseForCreateChallenge, 400, callContext) { + challenges.head + } + } yield { + createStartSigningBasketAuthorisationJson(basketId, challenge) + } + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(startSigningBasketAuthorisation), + "POST", + "/signing-baskets/BASKETID/authorisations", + "Start the authorisation process for a signing basket", + s"""${mockedDataText(false)} +Create an authorisation sub-resource and start the authorisation process of a signing basket. +The message might in addition transmit authentication and authorisation related data. + +This method is iterated n times for a n times SCA authorisation in a +corporate context, each creating an own authorisation sub-endpoint for +the corresponding PSU authorising the signing-baskets. + +The ASPSP might make the usage of this access method unnecessary in case +of only one SCA process needed, since the related authorisation resource +might be automatically created by the ASPSP after the submission of the +payment data with the first POST signing basket call. + +The start authorisation process is a process which is needed for creating a new authorisation +or cancellation sub-resource. + +This applies in the following scenarios: + + * The ASPSP has indicated with an 'startAuthorisation' hyperlink in the preceeding Payment + Initiation Response that an explicit start of the authorisation process is needed by the TPP. + The 'startAuthorisation' hyperlink can transport more information about data which needs to be + uploaded by using the extended forms. + * 'startAuthorisationWithPsuIdentfication', + * 'startAuthorisationWithPsuAuthentication' #TODO + * 'startAuthorisationWithAuthentciationMethodSelection' + * The related payment initiation cannot yet be executed since a multilevel SCA is mandated. + * The ASPSP has indicated with an 'startAuthorisation' hyperlink in the preceeding + Payment Cancellation Response that an explicit start of the authorisation process is needed by the TPP. + The 'startAuthorisation' hyperlink can transport more information about data which needs to be uploaded + by using the extended forms as indicated above. + * The related payment cancellation request cannot be applied yet since a multilevel SCA is mandate for + executing the cancellation. + * The signing basket needs to be authorised yet. +""", + EmptyBody, + json.parse("""{ + "challengeData" : { + "otpMaxLength" : 0, + "additionalInformation" : "additionalInformation", + "image" : "image", + "imageLink" : "http://example.com/aeiou", + "otpFormat" : "characters", + "data" : "data" + }, + "scaMethods" : "", + "scaStatus" : "psuAuthenticated", + "_links" : { + "scaStatus" : "/v1.3/payments/sepa-credit-transfers/1234-wertiq-983", + "startAuthorisationWithEncryptedPsuAuthentication" : "/v1.3/payments/sepa-credit-transfers/1234-wertiq-983", + "scaRedirect" : "/v1.3/payments/sepa-credit-transfers/1234-wertiq-983", + "selectAuthenticationMethod" : "/v1.3/payments/sepa-credit-transfers/1234-wertiq-983", + "startAuthorisationWithPsuAuthentication" : "/v1.3/payments/sepa-credit-transfers/1234-wertiq-983", + "authoriseTransaction" : "/v1.3/payments/sepa-credit-transfers/1234-wertiq-983", + "scaOAuth" : "/v1.3/payments/sepa-credit-transfers/1234-wertiq-983", + "updatePsuIdentification" : "/v1.3/payments/sepa-credit-transfers/1234-wertiq-983" + }, + "chosenScaMethod" : "", + "psuMessage" : { } +}"""), + List(AuthenticatedUserIsRequired, UnknownError), + apiTagSigningBaskets :: Nil, + http4sPartialFunction = Some(startSigningBasketAuthorisation) + ) + + // ── PUT /signing-baskets/BASKETID/authorisations/AUTHORISATIONID ─────── + val updateSigningBasketPsuData: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `bgV13Prefix` / "signing-baskets" / basketId / "authorisations" / authorisationId => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + for { + _ <- passesPsd2Pisp(callContext) + failMsg = s"$InvalidJsonFormat The Json body should be the $UpdatePaymentPsuDataJson " + updateBasketPsuDataJson <- NewStyle.function.tryons(failMsg, 400, callContext) { + json.parse(cc.httpBody.getOrElse("")).extract[UpdatePaymentPsuDataJson] + } + _ <- SigningBasketNewStyle.checkSigningBasketPayments(basketId, callContext) + (boxedChallenge, _) <- NewStyle.function.validateChallengeAnswerC5( + ChallengeType.BERLIN_GROUP_SIGNING_BASKETS_CHALLENGE, + None, + None, + Some(basketId), + authorisationId, + updateBasketPsuDataJson.scaAuthenticationData, + SuppliedAnswerType.PLAIN_TEXT_VALUE, + callContext + ) + (challenge, updatedCC) <- NewStyle.function.getChallenge(authorisationId, callContext) + _ <- challenge.scaStatus match { + case Some(status) if status.toString == StrongCustomerAuthenticationStatus.finalised.toString => + Future { + val basket = SigningBasketX.signingBasketProvider.vend.getSigningBasketByBasketId(basketId) + val existAll = + basket.flatMap(_.payments.map(_.forall(i => Connector.connector.vend.getTransactionRequestImpl(TransactionRequestId(i), updatedCC).isDefined))) + val alreadyCompleted: List[String] = + basket.flatMap(_.payments).getOrElse(Nil).filter { i => + Connector.connector.vend.getTransactionRequestImpl(TransactionRequestId(i), updatedCC) + .exists(_._1.status == COMPLETED.toString) + } + if (alreadyCompleted.nonEmpty) { + unboxFullOrFail(Empty, updatedCC, s"$InvalidConnectorResponse Some of paymentIds [${alreadyCompleted.mkString(",")}] are already completed") + } else if (existAll.getOrElse(false)) { + basket.map { i => + i.payments.map(_.map { i => + NewStyle.function.saveTransactionRequestStatusImpl(TransactionRequestId(i), COMPLETED.toString, updatedCC) + Connector.connector.vend.getTransactionRequestImpl(TransactionRequestId(i), updatedCC).map { t => + Connector.connector.vend.makePaymentV400(t._1, None, updatedCC) + } + }) + } + SigningBasketX.signingBasketProvider.vend.saveSigningBasketStatus(basketId, ConstantsBG.SigningBasketsStatus.ACTC.toString) + unboxFullOrFail(boxedChallenge, updatedCC, s"$InvalidConnectorResponse validateChallengeAnswerC5") + } else { + val paymentIds = basket.flatMap(_.payments).getOrElse(Nil).mkString(",") + unboxFullOrFail(Empty, updatedCC, s"$InvalidConnectorResponse Some of paymentIds [${paymentIds}] are invalid") + } + } + case Some(status) if status.toString == StrongCustomerAuthenticationStatus.failed.toString => + Future { + val basket = SigningBasketX.signingBasketProvider.vend.getSigningBasketByBasketId(basketId) + basket.map { i => + i.payments.map(_.map { i => + NewStyle.function.saveTransactionRequestStatusImpl(TransactionRequestId(i), REJECTED.toString, updatedCC) + }) + } + unboxFullOrFail(boxedChallenge, updatedCC, s"$InvalidConnectorResponse validateChallengeAnswerC5") + } + case _ => + Future(unboxFullOrFail(Empty, updatedCC, s"$InvalidConnectorResponse getChallenge")) + } + } yield { + JSONFactory_BERLIN_GROUP_1_3.createStartPaymentAuthorisationJson(challenge) + } + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(updateSigningBasketPsuData), + "PUT", + "/signing-baskets/BASKETID/authorisations/AUTHORISATIONID", + "Update PSU Data for signing basket", + s"""${mockedDataText(false)} +This method update PSU data on the signing basket resource if needed. +It may authorise a igning basket within the Embedded SCA Approach where needed. + +Independently from the SCA Approach it supports e.g. the selection of +the authentication method and a non-SCA PSU authentication. + +This methods updates PSU data on the cancellation authorisation resource if needed. + +There are several possible Update PSU Data requests in the context of a consent request if needed, +which depends on the SCA approach: + +* Redirect SCA Approach: + A specific Update PSU Data Request is applicable for + * the selection of authentication methods, before choosing the actual SCA approach. +* Decoupled SCA Approach: + A specific Update PSU Data Request is only applicable for + * adding the PSU Identification, if not provided yet in the Payment Initiation Request or the Account Information Consent Request, or if no OAuth2 access token is used, or + * the selection of authentication methods. +* Embedded SCA Approach: + The Update PSU Data Request might be used + * to add credentials as a first factor authentication data of the PSU and + * to select the authentication method and + * transaction authorisation. + +The SCA Approach might depend on the chosen SCA method. +For that reason, the following possible Update PSU Data request can apply to all SCA approaches: + +* Select an SCA method in case of several SCA methods are available for the customer. + +There are the following request types on this access path: + * Update PSU Identification + * Update PSU Authentication + * Select PSU Autorization Method + WARNING: This method need a reduced header, + therefore many optional elements are not present. + Maybe in a later version the access path will change. + * Transaction Authorisation + WARNING: This method need a reduced header, + therefore many optional elements are not present. + Maybe in a later version the access path will change. +""", + json.parse("""{"scaAuthenticationData":"123"}"""), + json.parse("""{ + "scaStatus":"finalised", + "authorisationId":"4f4a8b7f-9968-4183-92ab-ca512b396bfc", + "psuMessage":"Please check your SMS at a mobile device.", + "_links":{ + "scaStatus":"/v1.3/payments/sepa-credit-transfers/PAYMENT_ID/4f4a8b7f-9968-4183-92ab-ca512b396bfc" + } + }"""), + List(AuthenticatedUserIsRequired, UnknownError), + apiTagSigningBaskets :: Nil, + http4sPartialFunction = Some(updateSigningBasketPsuData) + ) + + val routes: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req => + createSigningBasket(req) + .orElse(deleteSigningBasket(req)) + .orElse(getSigningBasket(req)) + .orElse(getSigningBasketAuthorisation(req)) + .orElse(getSigningBasketScaStatus(req)) + .orElse(getSigningBasketStatus(req)) + .orElse(startSigningBasketAuthorisation(req)) + .orElse(updateSigningBasketPsuData(req)) + } +} diff --git a/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala b/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala index 67a46e26ee..01faf73c89 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala @@ -625,8 +625,8 @@ object Http4sCallContextBuilder { */ object ResourceDocMatcher extends code.util.Helper.MdcLoggable { - // API prefix pattern: /obp/vX.X.X - private val apiPrefixPattern = """^/obp/v\d+\.\d+\.\d+""".r + // API prefix pattern: //v — handles both OBP (/obp/vX.X.X) and BG (/berlin-group/v1.3) + private val apiPrefixPattern = """^/[^/]+/v[\d.]+""".r // Pre-built index type: (VERB, apiVersion, segmentCount) -> candidates type ResourceDocIndex = Map[(String, String, Int), List[ResourceDoc]] From cbc85ea9007a165e3e6bbf0c40b6e287f89d0c18 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Thu, 28 May 2026 18:14:19 +0200 Subject: [PATCH 11/14] feat(bg-v1.3): migrate AIS group (22 endpoints) to native http4s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port all 22 Account Information Service endpoints from the Lift APIMethods_AccountInformationServiceAISApi builder to native http4s handlers in Http4sBGv13AIS. Key implementation details: - All route handlers declared as lazy val to avoid 64KB limit - ResourceDocs split into private initXxxResourceDocs() methods - Body-dispatch pattern for startConsentAuthorisationAll (3 POST variants) and updateConsentsPsuDataAll (4 PUT variants) — single handler, internal if/else on JSON body shape - authMode = UserOrApplication for applicationAccess endpoints (createConsent, deleteConsent, startConsentAuthorisationAll, updateConsentsPsuDataAll); default UserOnly for others - getAccountList/getConsentAuthorisation use withBalance/delta query params read from req.uri - Consent SCA flow (startConsentAuthorisation → updateConsentsPsuData challenge answer) fully ported including ChallengeType and StrongCustomerAuthenticationStatus enums Also fix ResourceDocMiddleware.validateOnly to reject duplicate query parameters (e.g. ?withBalance=true&withBalance=false) with OBP-10014 before auth processing, returning {"code":400,"message":"..."} so ErrorMessage can be extracted by BG test assertions. All 30 AIS scenarios pass with the http4s path (Lift bridge receives zero BG v1.3 AIS requests). --- .../api/berlin/group/v1_3/Http4sBGv13.scala | 4 +- .../berlin/group/v1_3/Http4sBGv13AIS.scala | 1356 +++++++++++++++++ .../util/http4s/ResourceDocMiddleware.scala | 32 +- 3 files changed, 1389 insertions(+), 3 deletions(-) create mode 100644 obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13AIS.scala diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13.scala index 4e1978693b..6b143b829a 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13.scala @@ -23,11 +23,13 @@ object Http4sBGv13 extends MdcLoggable { val implementedInApiVersion = ConstantsBG.berlinGroupVersion1 val resourceDocs: ArrayBuffer[ResourceDoc] = + Http4sBGv13AIS.resourceDocs ++ Http4sBGv13PIIS.resourceDocs ++ Http4sBGv13SigningBaskets.resourceDocs val allRoutes: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req => - Http4sBGv13PIIS.routes(req) + Http4sBGv13AIS.routes(req) + .orElse(Http4sBGv13PIIS.routes(req)) .orElse(Http4sBGv13SigningBaskets.routes(req)) } diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13AIS.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13AIS.scala new file mode 100644 index 0000000000..f536dcad48 --- /dev/null +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13AIS.scala @@ -0,0 +1,1356 @@ +package code.api.berlin.group.v1_3 + +import cats.data.{Kleisli, OptionT} +import cats.effect._ +import code.api.APIFailureNewStyle +import code.api.Constant.{SYSTEM_READ_ACCOUNTS_BERLIN_GROUP_VIEW_ID, SYSTEM_READ_BALANCES_BERLIN_GROUP_VIEW_ID, SYSTEM_READ_TRANSACTIONS_BERLIN_GROUP_VIEW_ID} +import code.api.berlin.group.ConstantsBG +import code.api.berlin.group.v1_3.JSONFactory_BERLIN_GROUP_1_3._ +import code.api.berlin.group.v1_3.model._ +import code.api.berlin.group.v1_3.{BgSpecValidation, JSONFactory_BERLIN_GROUP_1_3, JvalueCaseClass} +import code.api.util.APIUtil.{EmptyBody, ResourceDoc, UserOrApplication, connectorEmptyResponse, createQueriesByHttpParams, fullBoxOrException, getHttpRequestUrlParam, getSuggestedDefaultScaMethod, mockedDataText, passesPsd2Aisp, unboxFull, unboxFullOrFail} +import code.api.util.CallContext +import code.api.util.ApiTag._ +import code.api.util.CustomJsonFormats +import code.api.util.ErrorMessages._ +import code.api.util.{ApiTag, NewStyle} +import code.api.util.newstyle.ViewNewStyle +import code.api.util.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps} +import code.consent.{ConsentStatus, Consents} +import code.context.{ConsentAuthContextProvider, UserAuthContextProvider} +import code.model +import code.model._ +import code.util.Helper +import code.util.Helper.{MdcLoggable, booleanToFuture} +import code.views.Views +import com.github.dwickern.macros.NameOf.nameOf +import com.openbankproject.commons.ExecutionContext.Implicits.global +import com.openbankproject.commons.model._ +import com.openbankproject.commons.model.enums.{ChallengeType, StrongCustomerAuthenticationStatus, SuppliedAnswerType} +import net.liftweb +import net.liftweb.common.{Empty, Full} +import net.liftweb.json +import net.liftweb.json.Formats +import org.http4s._ +import org.http4s.dsl.io._ + +import scala.collection.mutable.ArrayBuffer +import scala.concurrent.Future +import scala.language.implicitConversions + +object Http4sBGv13AIS extends MdcLoggable { + + type HttpF[A] = OptionT[IO, A] + + implicit val formats: Formats = CustomJsonFormats.formats + + protected implicit def JvalueToSuper(what: net.liftweb.json.JValue): JvalueCaseClass = JvalueCaseClass(what) + + val implementedInApiVersion = ConstantsBG.berlinGroupVersion1 + val resourceDocs = ArrayBuffer[ResourceDoc]() + + val bgV13Prefix = Root / ConstantsBG.berlinGroupVersion1.urlPrefix / ConstantsBG.berlinGroupVersion1.apiShortVersion + + private def checkAccountAccess(viewId: ViewId, u: User, account: BankAccount, callContext: Option[code.api.util.CallContext]) = { + Future { + Helper.booleanToBox(u.hasViewAccess(BankIdAccountId(account.bankId, account.accountId), viewId, callContext)) + } map { + unboxFullOrFail(_, callContext, s"$NoViewReadAccountsBerlinGroup ${viewId.value} userId : ${u.userId}. account : ${account.accountId}", 403) + } + } + + // ── POST /consents ────────────────────────────────────────────────────── + lazy val createConsent: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `bgV13Prefix` / "consents" => + EndpointHelpers.executeFutureCreated(req) { + val cc = req.callContext + val callContext = Some(cc) + val createdByUser: Option[User] = cc.user match { + case Full(user) => Some(user) + case _ => None + } + for { + _ <- passesPsd2Aisp(callContext) + failMsg = s"$InvalidJsonFormat The Json body should be the $PostConsentJson " + consentJson <- NewStyle.function.tryons(failMsg, 400, callContext) { + json.parse(cc.httpBody.getOrElse("")).extract[PostConsentJson] + } + _ <- if (consentJson.access.availableAccounts.isDefined) { + for { + _ <- booleanToFuture(failMsg = BerlinGroupConsentAccessAvailableAccounts, cc = callContext) { + consentJson.access.availableAccounts.contains("allAccounts") + } + _ <- booleanToFuture(failMsg = BerlinGroupConsentAccessRecurringIndicator, cc = callContext) { + !consentJson.recurringIndicator + } + _ <- booleanToFuture(failMsg = BerlinGroupConsentAccessFrequencyPerDay, cc = callContext) { + consentJson.frequencyPerDay == 1 + } + } yield Full(()) + } else { + booleanToFuture(failMsg = BerlinGroupConsentAccessIsEmpty, cc = callContext) { + consentJson.access.accounts.isDefined || + consentJson.access.balances.isDefined || + consentJson.access.transactions.isDefined + } + } + upperLimit = code.api.util.APIUtil.getPropsAsIntValue("berlin_group_frequency_per_day_upper_limit", 4) + _ <- booleanToFuture(failMsg = FrequencyPerDayError, cc = callContext) { + consentJson.frequencyPerDay > 0 && consentJson.frequencyPerDay <= upperLimit + } + _ <- booleanToFuture(failMsg = FrequencyPerDayMustBeOneError, cc = callContext) { + consentJson.recurringIndicator || + !consentJson.recurringIndicator && consentJson.frequencyPerDay == 1 + } + failMsg2 = BgSpecValidation.getErrorMessage(consentJson.validUntil) + validUntil = BgSpecValidation.getDate(consentJson.validUntil) + _ <- booleanToFuture(failMsg2, 400, callContext) { + failMsg2.isEmpty + } + _ <- NewStyle.function.getBankAccountsByIban(consentJson.access.accounts.getOrElse(Nil).map(_.iban.getOrElse("")), callContext) + createdConsent <- Future(Consents.consentProvider.vend.createBerlinGroupConsent( + createdByUser, + callContext.flatMap(_.consumer), + recurringIndicator = consentJson.recurringIndicator, + validUntil = validUntil, + frequencyPerDay = consentJson.frequencyPerDay, + combinedServiceIndicator = consentJson.combinedServiceIndicator.getOrElse(false), + apiStandard = Some(implementedInApiVersion.apiStandard), + apiVersion = Some(implementedInApiVersion.apiShortVersion) + )) map { + i => connectorEmptyResponse(i, callContext) + } + consentJWT <- _root_.code.api.util.Consent.createBerlinGroupConsentJWT( + createdByUser, + consentJson, + createdConsent.secret, + createdConsent.consentId, + callContext.flatMap(_.consumer).map(_.consumerId.get), + Some(validUntil), + callContext + ) map { + i => connectorEmptyResponse(i, callContext) + } + _ <- Future(Consents.consentProvider.vend.setJsonWebToken(createdConsent.consentId, consentJWT)) map { + i => connectorEmptyResponse(i, callContext) + } + } yield { + createPostConsentResponseJson(createdConsent) + } + } + } + + // ── DELETE /consents/CONSENTID ────────────────────────────────────────── + lazy val deleteConsent: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `bgV13Prefix` / "consents" / consentId => + EndpointHelpers.executeDelete(req) { cc => + val callContext = Some(cc) + for { + _ <- passesPsd2Aisp(callContext) + consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map { + unboxFullOrFail(_, callContext, ConsentNotFound, 403) + } + consumerIdFromConsent = consent.mConsumerId.get + consumerIdFromCurrentCall = callContext.map(_.consumer.map(_.consumerId.get).getOrElse("None")).getOrElse("None") + _ <- booleanToFuture(failMsg = ConsentNotFound, failCode = 403, cc = callContext) { + consumerIdFromConsent == consumerIdFromCurrentCall + } + _ <- Future(Consents.consentProvider.vend.revokeBerlinGroupConsent(consentId)) map { + i => connectorEmptyResponse(i, callContext) + } + } yield () + } + } + + // ── GET /accounts ─────────────────────────────────────────────────────── + lazy val getAccountList: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `bgV13Prefix` / "accounts" => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + val u = cc.user.openOrThrowException(AuthenticatedUserIsRequired) + for { + withBalanceParam <- NewStyle.function.tryons(s"$InvalidUrlParameters withBalance parameter can only take two values: TRUE or FALSE!", 400, callContext) { + val withBalance = getHttpRequestUrlParam(cc.url, "withBalance") + if (withBalance.isEmpty) Some(false) else Some(withBalance.toBoolean) + } + _ <- passesPsd2Aisp(callContext) + (availablePrivateAccounts, callContext) <- NewStyle.function.getAccountListOfBerlinGroup(u, callContext) + (canReadBalancesAccounts, callContext) <- NewStyle.function.getAccountCanReadBalancesOfBerlinGroup(u, callContext) + (canReadTransactionsAccounts, callContext) <- NewStyle.function.getAccountCanReadTransactionsOfBerlinGroup(u, callContext) + (accounts, callContext) <- NewStyle.function.getBankAccounts(availablePrivateAccounts, callContext) + bankAccountsFiltered = accounts.filter(bankAccount => + bankAccount.attributes.toList.flatten.find(attribute => + attribute.name.equals("CashAccountTypeCode") && + attribute.`type`.equals("STRING") && + attribute.value.equalsIgnoreCase("card") + ).isEmpty) + (balances, callContext) <- code.api.util.newstyle.BankAccountBalanceNewStyle.getBankAccountsBalances( + bankAccountsFiltered.map(_.accountId), + callContext + ) + } yield { + JSONFactory_BERLIN_GROUP_1_3.createAccountListJson( + bankAccountsFiltered, + canReadBalancesAccounts, + canReadTransactionsAccounts, + u, + withBalanceParam, + balances + ) + } + } + } + + // ── GET /accounts/ACCOUNT_ID/balances ─────────────────────────────────── + lazy val getBalances: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `bgV13Prefix` / "accounts" / accountId / "balances" => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + val u = cc.user.openOrThrowException(AuthenticatedUserIsRequired) + for { + _ <- passesPsd2Aisp(callContext) + (account: BankAccount, callContext) <- NewStyle.function.getBankAccountByAccountId(AccountId(accountId), callContext) + _ <- checkAccountAccess(ViewId(SYSTEM_READ_BALANCES_BERLIN_GROUP_VIEW_ID), u, account, callContext) + (accountBalances, callContext) <- code.api.util.newstyle.BankAccountBalanceNewStyle.getBankAccountBalances( + AccountId(accountId), + callContext + ) + } yield { + JSONFactory_BERLIN_GROUP_1_3.createAccountBalanceJSON(account, accountBalances) + } + } + } + + // ── GET /card-accounts ────────────────────────────────────────────────── + lazy val getCardAccounts: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `bgV13Prefix` / "card-accounts" => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + val u = cc.user.openOrThrowException(AuthenticatedUserIsRequired) + for { + _ <- passesPsd2Aisp(callContext) + availablePrivateAccounts <- Views.views.vend.getPrivateBankAccountsFuture(u) + (_, callContext) <- NewStyle.function.getPhysicalCardsForUser(u, callContext) + (accounts, callContext) <- NewStyle.function.getBankAccounts(availablePrivateAccounts, callContext) + (canReadBalancesAccounts, callContext) <- NewStyle.function.getAccountCanReadBalancesOfBerlinGroup(u, callContext) + (canReadTransactionsAccounts, callContext) <- NewStyle.function.getAccountCanReadTransactionsOfBerlinGroup(u, callContext) + bankAccountsFiltered = accounts.filter(bankAccount => + bankAccount.attributes.toList.flatten.find(attribute => + attribute.name.equals("CashAccountTypeCode") && + attribute.`type`.equals("STRING") && + attribute.value.equalsIgnoreCase("card") + ).isDefined) + } yield { + JSONFactory_BERLIN_GROUP_1_3.createCardAccountListJson( + bankAccountsFiltered, + canReadBalancesAccounts, + canReadTransactionsAccounts, + u + ) + } + } + } + + // ── GET /card-accounts/ACCOUNT_ID/balances ────────────────────────────── + lazy val getCardAccountBalances: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `bgV13Prefix` / "card-accounts" / accountId / "balances" => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + val u = cc.user.openOrThrowException(AuthenticatedUserIsRequired) + for { + _ <- passesPsd2Aisp(callContext) + (account: BankAccount, callContext) <- NewStyle.function.getBankAccountByAccountId(AccountId(accountId), callContext) + _ <- checkAccountAccess(ViewId(SYSTEM_READ_BALANCES_BERLIN_GROUP_VIEW_ID), u, account, callContext) + (accountBalances, callContext) <- code.api.util.newstyle.BankAccountBalanceNewStyle.getBankAccountBalances( + AccountId(accountId), + callContext + ) + } yield { + JSONFactory_BERLIN_GROUP_1_3.createCardAccountBalanceJSON(account, accountBalances) + } + } + } + + // ── GET /card-accounts/ACCOUNT_ID/transactions ────────────────────────── + lazy val getCardAccountTransactionList: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `bgV13Prefix` / "card-accounts" / accountId / "transactions" => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + val u = cc.user.openOrThrowException(AuthenticatedUserIsRequired) + for { + _ <- passesPsd2Aisp(callContext) + (bankAccount: BankAccount, callContext) <- NewStyle.function.getBankAccountByAccountId(AccountId(accountId), callContext) + (bank, callContext) <- NewStyle.function.getBank(bankAccount.bankId, callContext) + viewId = ViewId(SYSTEM_READ_TRANSACTIONS_BERLIN_GROUP_VIEW_ID) + bankIdAccountId = BankIdAccountId(bankAccount.bankId, bankAccount.accountId) + view <- ViewNewStyle.checkAccountAccessAndGetView(viewId, bankIdAccountId, Full(u), callContext) + params <- Future { createQueriesByHttpParams(callContext.get.requestHeaders)} map { + x => fullBoxOrException(x ~> APIFailureNewStyle(UnknownError, 400, callContext.map(_.toLight))) + } map { unboxFull(_) } + (transactions, callContext) <- code.model.toBankAccountExtended(bankAccount).getModeratedTransactionsFuture(bank, Full(u), view, callContext, params) map { + x => fullBoxOrException(x ~> APIFailureNewStyle(UnknownError, 400, callContext.map(_.toLight))) + } map { unboxFull(_) } + } yield { + JSONFactory_BERLIN_GROUP_1_3.createCardTransactionsJson(bankAccount, transactions) + } + } + } + + // ── GET /consents/CONSENTID/authorisations ────────────────────────────── + lazy val getConsentAuthorisation: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `bgV13Prefix` / "consents" / consentId / "authorisations" => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + for { + _ <- passesPsd2Aisp(callContext) + (challenges, callContext) <- NewStyle.function.getChallengesByConsentId(consentId, callContext) + } yield { + JSONFactory_BERLIN_GROUP_1_3.AuthorisationJsonV13(challenges.map(_.challengeId)) + } + } + } + + // ── GET /consents/CONSENTID ───────────────────────────────────────────── + lazy val getConsentInformation: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `bgV13Prefix` / "consents" / consentId => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + for { + _ <- passesPsd2Aisp(callContext) + consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map { + unboxFullOrFail(_, callContext, s"$ConsentNotFound ($consentId)") + } + consumerIdFromConsent = consent.mConsumerId.get + consumerIdFromCurrentCall = callContext.map(_.consumer.map(_.consumerId.get).getOrElse("None")).getOrElse("None") + _ <- booleanToFuture(failMsg = ConsentNotFound, failCode = 403, cc = callContext) { + consumerIdFromConsent == consumerIdFromCurrentCall + } + } yield { + createGetConsentResponseJson(consent) + } + } + } + + // ── GET /consents/CONSENTID/authorisations/AUTHORISATIONID ───────────── + lazy val getConsentScaStatus: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `bgV13Prefix` / "consents" / consentId / "authorisations" / authorisationId => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + for { + _ <- passesPsd2Aisp(callContext) + _ <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map { + unboxFullOrFail(_, callContext, s"$ConsentNotFound ($consentId)", 403) + } + (challenges, callContext) <- NewStyle.function.getChallengesByConsentId(consentId, callContext) + } yield { + val challengeStatus = challenges.filter(_.challengeId == authorisationId) + .flatMap(_.scaStatus).headOption.map(_.toString).getOrElse("None") + JSONFactory_BERLIN_GROUP_1_3.ScaStatusJsonV13(challengeStatus) + } + } + } + + // ── GET /consents/CONSENTID/status ────────────────────────────────────── + lazy val getConsentStatus: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `bgV13Prefix` / "consents" / consentId / "status" => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + for { + _ <- passesPsd2Aisp(callContext) + consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map { + unboxFullOrFail(_, callContext, ConsentNotFound, 403) + } + } yield { + JSONFactory_BERLIN_GROUP_1_3.ConsentStatusJsonV13(consent.status) + } + } + } + + // ── GET /accounts/ACCOUNT_ID/transactions/TRANSACTIONID ───────────────── + lazy val getTransactionDetails: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `bgV13Prefix` / "accounts" / accountId / "transactions" / transactionId => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + val user = cc.user.openOrThrowException(AuthenticatedUserIsRequired) + for { + (account: BankAccount, callContext) <- NewStyle.function.getBankAccountByAccountId(AccountId(accountId), callContext) + viewId = ViewId(SYSTEM_READ_TRANSACTIONS_BERLIN_GROUP_VIEW_ID) + bankIdAccountId = BankIdAccountId(account.bankId, account.accountId) + view <- ViewNewStyle.checkAccountAccessAndGetView(viewId, bankIdAccountId, Full(user), callContext) + (moderatedTransaction, callContext) <- account.moderatedTransactionFuture(TransactionId(transactionId), view, Some(user), callContext) map { + unboxFullOrFail(_, callContext, GetTransactionsException) + } + } yield { + JSONFactory_BERLIN_GROUP_1_3.createTransactionJson(account, moderatedTransaction) + } + } + } + + // ── GET /accounts/ACCOUNT_ID/transactions ─────────────────────────────── + lazy val getTransactionList: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `bgV13Prefix` / "accounts" / accountId / "transactions" => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + val u = cc.user.openOrThrowException(AuthenticatedUserIsRequired) + for { + _ <- passesPsd2Aisp(callContext) + (bankAccount: BankAccount, callContext) <- NewStyle.function.getBankAccountByAccountId(AccountId(accountId), callContext) + (bank, callContext) <- NewStyle.function.getBank(bankAccount.bankId, callContext) + viewId = ViewId(SYSTEM_READ_TRANSACTIONS_BERLIN_GROUP_VIEW_ID) + bankIdAccountId = BankIdAccountId(bankAccount.bankId, bankAccount.accountId) + view <- ViewNewStyle.checkAccountAccessAndGetView(viewId, bankIdAccountId, Full(u), callContext) + params <- Future { createQueriesByHttpParams(callContext.get.requestHeaders)} map { + x => fullBoxOrException(x ~> APIFailureNewStyle(UnknownError, 400, callContext.map(_.toLight))) + } map { unboxFull(_) } + bookingStatus = getHttpRequestUrlParam(cc.url, "bookingStatus") + _ <- booleanToFuture(s"$InvalidUrlParameters bookingStatus parameter must take two one of those values : booked, pending or both!", 400, callContext) { + bookingStatus match { + case "booked" | "pending" | "both" => true + case _ => false + } + } + (transactions, callContext) <- bankAccount.getModeratedTransactionsFuture(bank, Full(u), view, callContext, params) map { + x => fullBoxOrException(x ~> APIFailureNewStyle(UnknownError, 400, callContext.map(_.toLight))) + } map { unboxFull(_) } + } yield { + JSONFactory_BERLIN_GROUP_1_3.createTransactionsJson(bankAccount, transactions, bookingStatus) + } + } + } + + // ── GET /accounts/ACCOUNT_ID ──────────────────────────────────────────── + lazy val getAccountDetails: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `bgV13Prefix` / "accounts" / accountId => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + val u = cc.user.openOrThrowException(AuthenticatedUserIsRequired) + for { + withBalanceParam <- NewStyle.function.tryons(s"$InvalidUrlParameters withBalance parameter can only take two values: TRUE or FALSE!", 400, callContext) { + val withBalance = getHttpRequestUrlParam(cc.url, "withBalance") + if (withBalance.isEmpty) Some(false) else Some(withBalance.toBoolean) + } + _ <- passesPsd2Aisp(callContext) + (account: BankAccount, callContext) <- NewStyle.function.getBankAccountByAccountId(AccountId(accountId), callContext) + (canReadBalancesAccounts, callContext) <- NewStyle.function.getAccountCanReadBalancesOfBerlinGroup(u, callContext) + (canReadTransactionsAccounts, callContext) <- NewStyle.function.getAccountCanReadTransactionsOfBerlinGroup(u, callContext) + _ <- checkAccountAccess(ViewId(SYSTEM_READ_ACCOUNTS_BERLIN_GROUP_VIEW_ID), u, account, callContext) + (accountBalances, callContext) <- code.api.util.newstyle.BankAccountBalanceNewStyle.getBankAccountBalances( + AccountId(accountId), + callContext + ) + } yield { + JSONFactory_BERLIN_GROUP_1_3.createAccountDetailsJson( + account, + canReadBalancesAccounts, + canReadTransactionsAccounts, + withBalanceParam, + accountBalances, + u + ) + } + } + } + + // ── GET /card-accounts/ACCOUNT_ID ─────────────────────────────────────── + lazy val readCardAccount: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `bgV13Prefix` / "card-accounts" / accountId => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + val u = cc.user.openOrThrowException(AuthenticatedUserIsRequired) + for { + _ <- passesPsd2Aisp(callContext) + (account: BankAccount, callContext) <- NewStyle.function.getBankAccountByAccountId(AccountId(accountId), callContext) + (canReadBalancesAccounts, callContext) <- NewStyle.function.getAccountCanReadBalancesOfBerlinGroup(u, callContext) + (canReadTransactionsAccounts, callContext) <- NewStyle.function.getAccountCanReadTransactionsOfBerlinGroup(u, callContext) + _ <- checkAccountAccess(ViewId(SYSTEM_READ_ACCOUNTS_BERLIN_GROUP_VIEW_ID), u, account, callContext) + withBalanceParam <- NewStyle.function.tryons(s"$InvalidUrlParameters withBalance parameter can only take two values: TRUE or FALSE!", 400, callContext) { + val withBalance = getHttpRequestUrlParam(cc.url, "withBalance") + if (withBalance.isEmpty) Some(false) else Some(withBalance.toBoolean) + } + (accountBalances, callContext) <- code.api.util.newstyle.BankAccountBalanceNewStyle.getBankAccountBalances( + AccountId(accountId), + callContext + ) + } yield { + JSONFactory_BERLIN_GROUP_1_3.createCardAccountDetailsJson( + account, + canReadBalancesAccounts, + canReadTransactionsAccounts, + withBalanceParam, + accountBalances, + u + ) + } + } + } + + // ── POST /consents/CONSENTID/authorisations (3 body-guard variants) ───── + lazy val startConsentAuthorisationAll: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `bgV13Prefix` / "consents" / consentId / "authorisations" => + EndpointHelpers.executeFutureCreated(req) { + val cc = req.callContext + val callContext = Some(cc) + val u = cc.user.openOrThrowException(AuthenticatedUserIsRequired) + val parsedJson = scala.util.Try(json.parse(cc.httpBody.getOrElse(""))).getOrElse(json.JNothing) + if (checkTransactionAuthorisation(parsedJson)) { + for { + _ <- passesPsd2Aisp(callContext) + consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map { + unboxFullOrFail(_, callContext, ConsentNotFound, 403) + } + (challenges, callContext) <- NewStyle.function.createChallengesC2( + List(u.userId), + ChallengeType.BERLIN_GROUP_CONSENT_CHALLENGE, + None, + getSuggestedDefaultScaMethod(), + Some(StrongCustomerAuthenticationStatus.received), + Some(consentId), + None, + callContext + ) + challenge <- NewStyle.function.tryons(InvalidConnectorResponseForCreateChallenge, 400, callContext) { + challenges.head + } + } yield { + createStartConsentAuthorisationJson(consent, challenge) + } + } else { + // mocked for updatePsuAuthentication and selectPsuAuthenticationMethod variants + Future.successful(liftweb.json.parse( + """{ + "scaStatus": "received", + "psuMessage": "Please use your BankApp for transaction Authorisation.", + "authorisationId": "123auth456.", + "_links": + { + "scaStatus": {"href":"/v1.3/consents/qwer3456tzui7890/authorisations/123auth456"} + } + }""")) + } + } + } + + // ── PUT /consents/CONSENTID/authorisations/AUTHORISATIONID (4 variants) ─ + lazy val updateConsentsPsuDataAll: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `bgV13Prefix` / "consents" / consentId / "authorisations" / authorisationId => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + val u = cc.user.openOrThrowException(AuthenticatedUserIsRequired) + val parsedJson = scala.util.Try(json.parse(cc.httpBody.getOrElse(""))).getOrElse(json.JNothing) + if (checkTransactionAuthorisation(parsedJson)) { + for { + _ <- passesPsd2Aisp(callContext) + _ <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map { + unboxFullOrFail(_, callContext, ConsentNotFound, 403) + } + failMsg = s"$InvalidJsonFormat The Json body should be the $TransactionAuthorisation " + updateJson <- NewStyle.function.tryons(failMsg, 400, callContext) { + parsedJson.extract[TransactionAuthorisation] + } + (_, callContext) <- NewStyle.function.getChallenge(authorisationId, callContext) + (challenge, callContext) <- NewStyle.function.validateChallengeAnswerC4( + ChallengeType.BERLIN_GROUP_CONSENT_CHALLENGE, + None, + Some(consentId), + authorisationId, + updateJson.scaAuthenticationData, + SuppliedAnswerType.PLAIN_TEXT_VALUE, + callContext + ) + consent <- challenge.scaStatus match { + case Some(status) if status == StrongCustomerAuthenticationStatus.finalised => + Future(Consents.consentProvider.vend.updateConsentStatus(consentId, ConsentStatus.valid)) + case Some(status) if status == StrongCustomerAuthenticationStatus.failed => + Future(Consents.consentProvider.vend.updateConsentStatus(consentId, ConsentStatus.rejected)) + case _ => + Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) + } + _ <- NewStyle.function.tryons(ConsentUpdateStatusError, 400, callContext) { + consent.toList.size == 1 + } + _ <- Future { + val authContexts = UserAuthContextProvider.userAuthContextProvider.vend.getUserAuthContextsBox(u.userId) + .map(_.map(i => BasicUserAuthContext(i.key, i.value))) + ConsentAuthContextProvider.consentAuthContextProvider.vend.createOrUpdateConsentAuthContexts(consentId, authContexts.getOrElse(Nil)) + } map { + unboxFullOrFail(_, callContext, ConsentUserAuthContextCannotBeAdded) + } + _ <- Future(Consents.consentProvider.vend.updateConsentUser(consentId, u)) map { + unboxFullOrFail(_, callContext, ConsentUserCannotBeAdded) + } + } yield { + createPutConsentResponseJson(consent.toList.head) + } + } else if (checkUpdatePsuAuthentication(parsedJson)) { + Future.successful(liftweb.json.parse( + """{ + | "scaStatus": "psuAuthenticated", + | "_links": { + | "authoriseTransaction": {"href": "/psd2/v1/payments/1234-wertiq-983/authorisations/123auth456"} + | } + |}""".stripMargin)) + } else if (checkSelectPsuAuthenticationMethod(parsedJson)) { + Future.successful(liftweb.json.parse( + """{ + | "scaStatus": "scaMethodSelected", + | "chosenScaMethod": { + | "authenticationType": "SMS_OTP", + | "authenticationMethodId": "myAuthenticationID"}, + | "challengeData": { + | "otpMaxLength": 6, + | "otpFormat": "integer"}, + | "_links": { + | "authoriseTransaction": {"href": "/psd2/v1/payments/1234-wertiq-983/authorisations/123auth456"} + | } + |}""".stripMargin)) + } else { + // authorisationConfirmation variant + Future.successful(liftweb.json.parse( + """{ + | "scaStatus": "finalised", + | "_links":{ + | "status": {"href":"/v1/payments/sepa-credit-transfers/qwer3456tzui7890/status"} + | } + |}""".stripMargin)) + } + } + } + + private def initConsentResourceDocs(): Unit = { + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(createConsent), + "POST", + "/consents", + "Create consent", + s"""${mockedDataText(false)} +This method create a consent resource, defining access rights to dedicated accounts of +a given PSU-ID. These accounts are addressed explicitly in the method as +parameters as a core function. + +**Side Effects** +When this Consent Request is a request where the "recurringIndicator" equals "true", +and if it exists already a former consent for recurring access on account information +for the addressed PSU, then the former consent automatically expires as soon as the new +consent request is authorised by the PSU. + +Optional Extension: +As an option, an ASPSP might optionally accept a specific access right on the access on all psd2 related services for all available accounts. + +As another option an ASPSP might optionally also accept a command, where only access rights are inserted without mentioning the addressed account. +The relation to accounts is then handled afterwards between PSU and ASPSP. +This option is not supported for the Embedded SCA Approach. +As a last option, an ASPSP might in addition accept a command with access rights + * to see the list of available payment accounts or + * to see the list of available payment accounts with balances. + +frequencyPerDay: + This field indicates the requested maximum frequency for an access without PSU involvement per day. + For a one-off access, this attribute is set to "1". + The frequency needs to be greater equal to one. + If not otherwise agreed bilaterally between TPP and ASPSP, the frequency is less equal to 4. +recurringIndicator: + "true", if the consent is for recurring access to the account data. + "false", if the consent is for one access to the account data. +""", + PostConsentJson( + access = ConsentAccessJson( + accounts = Option(List(ConsentAccessAccountsJson( + iban = Some(code.api.util.ExampleValue.ibanExample.value), + bban = None, + pan = None, + maskedPan = None, + msisdn = None, + currency = None, + ))), + balances = None, + transactions = None, + availableAccounts = None, + allPsd2 = None + ), + recurringIndicator = true, + validUntil = "2020-12-31", + frequencyPerDay = 4, + combinedServiceIndicator = Some(false) + ), + PostConsentResponseJson( + consentId = "1234-wertiq-983", + consentStatus = "received", + _links = ConsentLinksV13(Some(Href("/v1.3/consents/1234-wertiq-983/authorisations"))) + ), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + authMode = UserOrApplication, + http4sPartialFunction = Some(createConsent) + ) + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(deleteConsent), + "DELETE", + "/consents/CONSENTID", + "Delete Consent", + s"""${mockedDataText(false)} + The TPP can delete an account information consent object if needed.""", + EmptyBody, + EmptyBody, + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + authMode = UserOrApplication, + http4sPartialFunction = Some(deleteConsent) + ) + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getConsentInformation), + "GET", + "/consents/CONSENTID", + "Get Consent Request", + s"""${mockedDataText(false)} +Returns the content of an account information consent object. +This is returning the data for the TPP especially in cases, +where the consent was directly managed between ASPSP and PSU e.g. in a re-direct SCA Approach. +""", + EmptyBody, + json.parse("""{ + "access": { + "accounts": [ + { + "bban": "BARC12345612345678", + "maskedPan": "123456xxxxxx1234", + "iban": "FR7612345987650123456789014", + "currency": "EUR", + "msisdn": "+49 170 1234567", + "pan": "5409050000000000" + } + ] + }, + "recurringIndicator": false, + "validUntil": "2020-12-31", + "frequencyPerDay": 4, + "combinedServiceIndicator": false, + "lastActionDate": "2019-06-30", + "consentStatus": "received" + }"""), + List(AuthenticatedUserIsRequired, ConsentNotFound, UnknownError), + ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + authMode = UserOrApplication, + http4sPartialFunction = Some(getConsentInformation) + ) + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getConsentStatus), + "GET", + "/consents/CONSENTID/status", + "Consent status request", + s"""${mockedDataText(false)} + Read the status of an account information consent resource.""", + EmptyBody, + json.parse("""{ + "consentStatus": "received" + }"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + authMode = UserOrApplication, + http4sPartialFunction = Some(getConsentStatus) + ) + + val generalStartConsentAuthorisationSummary = + s"""${mockedDataText(false)} +Create an authorisation sub-resource and start the authorisation process of a consent. +The message might in addition transmit authentication and authorisation related data. +his method is iterated n times for a n times SCA authorisation in a corporate context, +each creating an own authorisation sub-endpoint for the corresponding PSU authorising the consent. +The ASPSP might make the usage of this access method unnecessary, since the related authorisation +resource will be automatically created by the ASPSP after the submission of the consent data with the +first POST consents call. The start authorisation process is a process which is needed for creating +a new authorisation or cancellation sub-resource. + +This applies in the following scenarios: * The ASPSP has indicated with an 'startAuthorisation' hyperlink +in the preceding Payment Initiation Response that an explicit start of the authorisation process is needed by the TPP. +The 'startAuthorisation' hyperlink can transport more information about data which needs to be uploaded by using +the extended forms. +* 'startAuthorisationWithPsuIdentfication', +* 'startAuthorisationWithPsuAuthentication' +* 'startAuthorisationWithEncryptedPsuAuthentication' +* 'startAuthorisationWithAuthentciationMethodSelection' +* The related payment initiation cannot yet be executed since a multilevel SCA is mandated. +* The ASPSP has indicated with an 'startAuthorisation' hyperlink in the preceding Payment Cancellation +Response that an explicit start of the authorisation process is needed by the TPP. + +The 'startAuthorisation' hyperlink can transport more information about data which needs to be uploaded by +using the extended forms as indicated above. +* The related payment cancellation request cannot be applied yet since a multilevel SCA is mandate for executing the cancellation. +* The signing basket needs to be authorised yet. + +""" + + val startConsentAuthorisationResponse = json.parse("""{ + "scaStatus": "received", + "psuMessage": "Please use your BankApp for transaction Authorisation.", + "authorisationId": "123auth456.", + "_links": + { + "scaStatus": {"href":"/v1.3/consents/qwer3456tzui7890/authorisations/123auth456"} + } + }""") + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + "startConsentAuthorisationTransactionAuthorisation", + "POST", + "/consents/CONSENTID/authorisations", + "Start the authorisation process for a consent(transactionAuthorisation)", + generalStartConsentAuthorisationSummary, + json.parse("""{"scaAuthenticationData":""}"""), + startConsentAuthorisationResponse, + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(startConsentAuthorisationAll) + ) + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + "startConsentAuthorisationUpdatePsuAuthentication", + "POST", + "/consents/CONSENTID/authorisations", + "Start the authorisation process for a consent(updatePsuAuthentication)", + generalStartConsentAuthorisationSummary, + json.parse("""{"psuData": {"password": "start12"}}"""), + startConsentAuthorisationResponse, + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(startConsentAuthorisationAll) + ) + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + "startConsentAuthorisationSelectPsuAuthenticationMethod", + "POST", + "/consents/CONSENTID/authorisations", + "Start the authorisation process for a consent(selectPsuAuthenticationMethod)", + generalStartConsentAuthorisationSummary, + json.parse("""{"authenticationMethodId":""}"""), + startConsentAuthorisationResponse, + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(startConsentAuthorisationAll) + ) + + val generalUpdateConsentsPsuDataSummary = + s"""${mockedDataText(false)} +This method update PSU data on the consents resource if needed. It may authorise a consent within the Embedded +SCA Approach where needed. Independently from the SCA Approach it supports +e.g. the selection of the authentication method and a non-SCA PSU authentication. +This methods updates PSU data on the cancellation authorisation resource if needed. +There are several possible Update PSU Data requests in the context of a consent request if needed, +which depends on the SCA approach: * Redirect SCA Approach: A specific Update PSU Data Request is applicable +for +* the selection of authentication methods, before choosing the actual SCA approach. +* Decoupled SCA Approach: A specific Update PSU Data Request is only applicable for +* adding the PSU Identification, if not provided yet in the Payment Initiation Request or the Account Information Consent Request, +or if no OAuth2 access token is used, or +* the selection of authentication methods. +* Embedded SCA Approach: The Update PSU Data Request might be used +* to add credentials as a first factor authentication data of the PSU and +* to select the authentication method and +* transaction authorisation. +The SCA Approach might depend on the chosen SCA method. For that reason, +the following possible Update PSU Data request can apply to all SCA approaches: +* Select an SCA method in case of several SCA methods are available for the customer. There are the following request types on this access path: +* Update PSU Identification * Update PSU Authentication +* Select PSU Autorization Method WARNING: This method need a reduced header, therefore many optional elements are not present. +Maybe in a later version the access path will change. +* Transaction Authorisation WARNING: This method need a reduced header, therefore many optional elements are not present. +Maybe in a later version the access path will change. + + """ + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + "updateConsentsPsuDataTransactionAuthorisation", + "PUT", + "/consents/CONSENTID/authorisations/AUTHORISATIONID", + "Update PSU Data for consents (transactionAuthorisation)", + generalUpdateConsentsPsuDataSummary, + json.parse("""{"scaAuthenticationData":"123"}"""), + ScaStatusResponse( + scaStatus = "received", + _links = Some(LinksAll(scaStatus = Some(HrefType(Some(s"/v1.3/consents/1234-wertiq-983/authorisations"))))) + ), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(updateConsentsPsuDataAll) + ) + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + "updateConsentsPsuDataUpdatePsuAuthentication", + "PUT", + "/consents/CONSENTID/authorisations/AUTHORISATIONID", + "Update PSU Data for consents (updatePsuAuthentication)", + generalUpdateConsentsPsuDataSummary, + json.parse("""{"psuData": {"password": "start12"}}""".stripMargin), + json.parse("""{ + | "scaStatus": "psuAuthenticated", + | "_links": { + | "authoriseTransaction": {"href": "/psd2/v1/payments/1234-wertiq-983/authorisations/123auth456"} + | } + | }""".stripMargin), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(updateConsentsPsuDataAll) + ) + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + "updateConsentsPsuDataUpdateSelectPsuAuthenticationMethod", + "PUT", + "/consents/CONSENTID/authorisations/AUTHORISATIONID", + "Update PSU Data for consents (selectPsuAuthenticationMethod)", + generalUpdateConsentsPsuDataSummary, + json.parse("""{ + | "authenticationMethodId": "myAuthenticationID" + |}""".stripMargin), + json.parse("""{ + | "scaStatus": "scaMethodSelected", + | "chosenScaMethod": { + | "authenticationType": "SMS_OTP", + | "authenticationMethodId": "myAuthenticationID"}, + | "challengeData": { + | "otpMaxLength": 6, + | "otpFormat": "integer"}, + | "_links": { + | "authoriseTransaction": {"href": "/psd2/v1/payments/1234-wertiq-983/authorisations/123auth456"} + | } + | }""".stripMargin), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(updateConsentsPsuDataAll) + ) + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + "updateConsentsPsuDataUpdateAuthorisationConfirmation", + "PUT", + "/consents/CONSENTID/authorisations/AUTHORISATIONID", + "Update PSU Data for consents (authorisationConfirmation)", + generalUpdateConsentsPsuDataSummary, + json.parse("""{"confirmationCode":"confirmationCode"}"""), + json.parse("""{ + | "scaStatus": "finalised", + | "_links":{ + | "status": {"href":"/v1/payments/sepa-credit-transfers/qwer3456tzui7890/status"} + | } + | }""".stripMargin), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(updateConsentsPsuDataAll) + ) + } + + private def initAccountResourceDocs(): Unit = { + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getAccountList), + "GET", + "/accounts", + "Read Account List", + s"""${mockedDataText(false)} +Read the identifiers of the available payment account together with +booking balance information, depending on the consent granted. + +It is assumed that a consent of the PSU to this access is already given and stored on the ASPSP system. +The addressed list of accounts depends then on the PSU ID and the stored consent addressed by consentId, +respectively the OAuth2 access token. + +Returns all identifiers of the accounts, to which an account access has been granted to through +the /consents endpoint by the PSU. +In addition, relevant information about the accounts and hyperlinks to corresponding account +information resources are provided if a related consent has been already granted. + +Remark: Note that the /consents endpoint optionally offers to grant an access on all available +payment accounts of a PSU. +In this case, this endpoint will deliver the information about all available payment accounts +of the PSU at this ASPSP. +""", + EmptyBody, + json.parse("""{ + | "accounts": [ + | { + | "resourceId": "3dc3d5b3-7023-4848-9853-f5400a64e80f", + | "iban": "DE2310010010123456789", + | "currency": "EUR", + | "product": "Girokonto", + | "cashAccountType": "CACC", + | "name": "Main Account", + | "_links": { + | "balances": { + | "href": "/v1/accounts/3dc3d5b3-7023-4848-9853-f5400a64e80f/balances" + | } + | } + | } + | ] + |}""".stripMargin), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(getAccountList) + ) + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getBalances), + "GET", + "/accounts/ACCOUNT_ID/balances", + "Read Balance", + s"""${mockedDataText(false)} +Reads account data from a given account addressed by "account-id". + +**Remark:** This account-id can be a tokenised identification due to data protection reason since the path +information might be logged on intermediary servers within the ASPSP sphere. +This account-id then can be retrieved by the "GET Account List" call. + +The account-id is constant at least throughout the lifecycle of a given consent. +""", + EmptyBody, + json.parse("""{ + "account":{ + "iban":"DE91 1000 0000 0123 4567 89" + }, + "balances":[{ + "balanceAmount":{ + "currency":"EUR", + "amount":"50.89" + }, + "balanceType":"AC", + "lastChangeDateTime":"yyyy-MM-dd'T'HH:mm:ss.SSSZ", + "lastCommittedTransaction":"String", + "referenceDate":"2018-03-08" + }] +} +"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(getBalances) + ) + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getCardAccounts), + "GET", + "/card-accounts", + "Reads a list of card accounts", + s"""${mockedDataText(false)} +Reads a list of card accounts with additional information, e.g. balance information. +It is assumed that a consent of the PSU to this access is already given and stored on the ASPSP system. +The addressed list of card accounts depends then on the PSU ID and the stored consent addressed by consentId, +respectively the OAuth2 access token. +""", + EmptyBody, + json.parse("""{ + "cardAccounts": [ + { + "resourceId": "3d9a81b3-a47d-4130-8765-a9c0ff861b99", + "maskedPan": "525412******3241", + "currency": "EUR", + "name": "Main", + "product": "Basic Credit", + "status": "enabled", + "creditLimit": { + "currency": "EUR", + "amount": 15000 + }, + "_links": { + "balances": { + "href": "/v1/card-accounts/3d9a81b3-a47d-4130-8765-a9c0ff861b99/balances" + } + } + } + ] +}"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Account Information Service (AIS)") :: apiTagMockedData :: Nil, + http4sPartialFunction = Some(getCardAccounts) + ) + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getCardAccountBalances), + "GET", + "/card-accounts/ACCOUNT_ID/balances", + "Read card account balances", + s"""${mockedDataText(false)} +Reads balance data from a given card account addressed by +"account-id". + +Remark: This account-id can be a tokenised identification due +to data protection reason since the path information might be +logged on intermediary servers within the ASPSP sphere. +This account-id then can be retrieved by the +"GET Card Account List" call +""", + EmptyBody, + json.parse("""{ + "cardAccount":{ + "iban":"DE91 1000 0000 0123 4567 89" + }, + "balances":[{ + "balanceAmount":{ + "currency":"EUR", + "amount":"50.89" + }, + "balanceType":"AC", + "lastChangeDateTime":"yyyy-MM-dd'T'HH:mm:ss.SSSZ", + "lastCommittedTransaction":"String", + "referenceDate":"2018-03-08" + }] +}"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Account Information Service (AIS)") :: Nil, + http4sPartialFunction = Some(getCardAccountBalances) + ) + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getCardAccountTransactionList), + "GET", + "/card-accounts/ACCOUNT_ID/transactions", + "Read transaction list of a card account", + s"""${mockedDataText(false)} +Reads account data from a given card account addressed by "account-id". +""", + EmptyBody, + json.parse("""{ + "cardAccount": { + "maskedPan": "525412******3241" + }, + "transactions": { + "booked": [], + "_links": { + "cardAccount": { + "href": "/v1.3/card-accounts/3d9a81b3-a47d-4130-8765-a9c0ff861b99" + } + } + } + }"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(getCardAccountTransactionList) + ) + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getConsentAuthorisation), + "GET", + "/consents/CONSENTID/authorisations", + "Get Consent Authorisation Sub-Resources Request", + s"""${mockedDataText(false)} +Return a list of all authorisation subresources IDs which have been created. + +This function returns an array of hyperlinks to all generated authorisation sub-resources. +""", + EmptyBody, + json.parse("""{ + "authorisationIds" : "faa3657e-13f0-4feb-a6c3-34bf21a9ae8e" +}"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(getConsentAuthorisation) + ) + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getConsentScaStatus), + "GET", + "/consents/CONSENTID/authorisations/AUTHORISATIONID", + "Read the SCA status of the consent authorisation", + s"""${mockedDataText(false)} +This method returns the SCA status of a consent initiation's authorisation sub-resource. +""", + EmptyBody, + json.parse("""{ + "scaStatus" : "started" +}"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(getConsentScaStatus) + ) + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getTransactionDetails), + "GET", + "/accounts/ACCOUNT_ID/transactions/TRANSACTIONID", + "Read Transaction Details", + s"""${mockedDataText(false)} +Reads transaction details from a given transaction addressed by "transactionId" on a given account addressed +by "account-id". This call is only available on transactions as reported in a JSON format. + +**Remark:** Please note that the PATH might be already given in detail by the corresponding entry of the response +of the "Read Transaction List" call within the _links subfield. + + """, + EmptyBody, + json.parse("""{ + "description": "Example for transaction details", + "value": { + "transactionsDetails": { + "transactionId": "1234567", + "creditorName": "John Miles", + "transactionAmount": { + "currency": "EUR", + "amount": "-256.67" + }, + "bookingDate": "2017-10-25", + "valueDate": "2017-10-26" + } + } +}"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Account Information Service (AIS)") :: Nil, + http4sPartialFunction = Some(getTransactionDetails) + ) + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getTransactionList), + "GET", + "/accounts/ACCOUNT_ID/transactions", + "Read transaction list of an account", + s"""${mockedDataText(false)} +Read transaction reports or transaction lists of a given account addressed by "account-id", +depending on the steering parameter "bookingStatus" together with balances. +For a given account, additional parameters are e.g. the attributes "dateFrom" and "dateTo". +The ASPSP might add balance information, if transaction lists without balances are not supported. """, + EmptyBody, + json.parse("""{ + "account": { + "iban": "DE2310010010123456788" + }, + "transactions": { + "booked": [], + "_links": { + "account": { + "href": "/v1.3/accounts/3dc3d5b3-7023-4848-9853-f5400a64e80f" + } + } + } + }"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(getTransactionList) + ) + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getAccountDetails), + "GET", + "/accounts/ACCOUNT_ID", + "Read Account Details", + s"""${mockedDataText(false)} +Reads details about an account, with balances where required. +It is assumed that a consent of the PSU to this access is already given and stored on the ASPSP system. +The addressed details of this account depends then on the stored consent addressed by consentId, +respectively the OAuth2 access token. **NOTE:** The account-id can represent a multicurrency account. +In this case the currency code is set to "XXX". Give detailed information about the addressed account. +Give detailed information about the addressed account together with balance information + + """, + EmptyBody, + json.parse("""{ + "account": { + "resourceId": "3dc3d5b3-7023-4848-9853-f5400a64e80f", + "iban": "FR7612345987650123456789014", + "currency": "EUR", + "product": "Girokonto", + "cashAccountType": "CACC", + "name": "Main Account", + "_links": { + "balances": { + "href": "/v1/accounts/3dc3d5b3-7023-4848-9853-f5400a64e80f/balances" + } + } + } +}"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(getAccountDetails) + ) + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(readCardAccount), + "GET", + "/card-accounts/ACCOUNT_ID", + "Reads details about a card account", + s"""${mockedDataText(false)} +Reads details about a card account. +It is assumed that a consent of the PSU to this access is already given and stored on the ASPSP system. +The addressed details of this account depends then on the stored consent addressed by consentId, +respectively the OAuth2 access token. +""", + EmptyBody, + json.parse("""{ + | "cardAccount": { + | "resourceId": "3d9a81b3-a47d-4130-8765-a9c0ff861b99", + | "maskedPan": "525412******3241", + | "currency": "EUR", + | "name": "Main", + | "product": "Basic Credit", + | "status": "enabled" + | } + |}""".stripMargin), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Account Information Service (AIS)") :: Nil, + http4sPartialFunction = Some(readCardAccount) + ) + } + + initConsentResourceDocs() + initAccountResourceDocs() + + val routes: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req => + createConsent(req) + .orElse(deleteConsent(req)) + .orElse(getAccountList(req)) + .orElse(getBalances(req)) + .orElse(getCardAccounts(req)) + .orElse(getCardAccountBalances(req)) + .orElse(getCardAccountTransactionList(req)) + .orElse(getConsentAuthorisation(req)) + .orElse(getConsentInformation(req)) + .orElse(getConsentScaStatus(req)) + .orElse(getConsentStatus(req)) + .orElse(getTransactionDetails(req)) + .orElse(getTransactionList(req)) + .orElse(getAccountDetails(req)) + .orElse(readCardAccount(req)) + .orElse(startConsentAuthorisationAll(req)) + .orElse(updateConsentsPsuDataAll(req)) + } +} 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 721f4fcca4..bc3c71a362 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 @@ -225,7 +225,8 @@ object ResourceDocMiddleware extends MdcLoggable { val initialContext = ValidationContext(callContext = cc) // Validation order MUST match Lift's wrappedWithAuthCheck (APIUtil.scala:1934-1969): - // auth → bank → roles → account → view → counterparty + // beforeAuthenticateInterceptors (= validateQueryParams / validateAuthType) first, + // then auth → bank → roles → account → view → counterparty // → afterAuthenticateInterceptors (= Force-Error / AuthType / JsonSchema) // Per Lift's own comment: "A Bank MUST be checked before Roles. In opposite case // we get next paradox: We set non existing bank → We get error that we don't @@ -236,7 +237,8 @@ object ResourceDocMiddleware extends MdcLoggable { // doc's role names) when Force-Error: OBP-20006 is sent and the natural // role check would also fail. val result: Validation[ValidationContext] = for { - context <- authenticate(req, resourceDoc, initialContext) + context <- validateDuplicateQueryParams(cc, initialContext) + context <- authenticate(req, resourceDoc, context) context <- validateBank(pathParams, context) context <- authorizeRoles(resourceDoc, pathParams, context) context <- validateAccount(pathParams, context) @@ -458,6 +460,32 @@ object ResourceDocMiddleware extends MdcLoggable { } } + /** + * Port of `APIUtil.validateQueryParams` (a `beforeAuthenticateInterceptor` in Lift). + * Rejects requests with duplicate query-parameter names with 400 + * `DuplicateQueryParameters`. Returns a plain OBP `{"message":"..."}` body (not BG + * format) to match Lift's `createErrorJsonResponse` output — the test asserts on + * `ErrorMessage.message`, not `ErrorMessagesBG.tppMessages`. + */ + private def validateDuplicateQueryParams(cc: CallContext, ctx: ValidationContext): Validation[ValidationContext] = { + import DSL._ + val queryString = if (cc.url.contains("?")) cc.url.split("\\?", 2)(1) else "" + val paramNames = queryString.split("&").map(s => s.split("=", 2)(0)).filter(_.nonEmpty) + val hasDuplicates = paramNames.groupBy(identity).exists(_._2.length > 1) + if (hasDuplicates) { + import net.liftweb.json.JsonDSL._ + import net.liftweb.json.compactRender + // Match Lift's createErrorJsonResponse: {"code": 400, "message": "OBP-XXXXX: ..."} + // The test asserts extract[ErrorMessage].message where ErrorMessage(code: Int, message: String). + val body = compactRender(("code" -> 400) ~ ("message" -> code.api.util.ErrorMessages.DuplicateQueryParameters)) + val resp = Response[IO](org.http4s.Status.BadRequest) + .withEntity(body.getBytes("UTF-8")) + .withContentType(jsonContentType) + EitherT.leftT[IO, ValidationContext](resp) + } else + success(ctx) + } + /** Bank validation: checks BANK_ID and fetches bank */ private def validateBank(pathParams: Map[String, String], ctx: ValidationContext): Validation[ValidationContext] = { From 7f1fa331c37669d415d6563ac64a4185a51b52ee Mon Sep 17 00:00:00 2001 From: Hongwei Date: Thu, 28 May 2026 18:14:43 +0200 Subject: [PATCH 12/14] feat(bg-v1.3): migrate PIS group (24 endpoints) to native http4s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port all 24 Payment Initiation Service endpoints from the Lift APIMethods_PaymentInitiationServicePISApi builder to native http4s handlers in Http4sBGv13PIS. Key implementation details: - All route handlers declared as lazy val (64KB limit avoidance) - ResourceDocs split into four private init*ResourceDocs() methods - initiatePaymentImpl private helper shared by initiatePayments, initiatePeriodicPayments, and initiateBulkPayments - cancelPayment uses a custom IO handler (not executeFutureWithStatus) to produce a truly-empty 204 NoContent for direct cancellations, vs 202 Accepted with CancelPaymentResponseJson for SCA-required cases - Body-dispatch for multi-variant POST/PUT endpoints: startPaymentAuthorisationAll (3 POST variants), startPaymentInitiationCancellationAuthorisationAll (3 POST variants), updatePaymentPsuDataAll (4 PUT variants), updatePaymentCancellationPsuDataAll (4 PUT variants) — single handler per URL, internal dispatch via checkTransactionAuthorisation / checkUpdatePsuAuthentication / checkSelectPsuAuthenticationMethod / checkAuthorisationConfirmation - getPaymentInitiationStatus uses JsonDSL ~ operator for proper JSON (fixes missing-comma bug in Lift's string interpolation) - authMode = UserOrApplication for initiate-payment endpoints All 26 PIS scenarios pass. Full BG v1.3 suite (72 tests) all green. --- .../api/berlin/group/v1_3/Http4sBGv13.scala | 2 + .../berlin/group/v1_3/Http4sBGv13PIS.scala | 1257 +++++++++++++++++ 2 files changed, 1259 insertions(+) create mode 100644 obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13PIS.scala diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13.scala index 6b143b829a..a2d83e2737 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13.scala @@ -24,11 +24,13 @@ object Http4sBGv13 extends MdcLoggable { val resourceDocs: ArrayBuffer[ResourceDoc] = Http4sBGv13AIS.resourceDocs ++ + Http4sBGv13PIS.resourceDocs ++ Http4sBGv13PIIS.resourceDocs ++ Http4sBGv13SigningBaskets.resourceDocs val allRoutes: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req => Http4sBGv13AIS.routes(req) + .orElse(Http4sBGv13PIS.routes(req)) .orElse(Http4sBGv13PIIS.routes(req)) .orElse(Http4sBGv13SigningBaskets.routes(req)) } diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13PIS.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13PIS.scala new file mode 100644 index 0000000000..43993fa87d --- /dev/null +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13PIS.scala @@ -0,0 +1,1257 @@ +package code.api.berlin.group.v1_3 + +import cats.data.{Kleisli, OptionT} +import cats.effect._ +import code.api.berlin.group.ConstantsBG +import code.api.berlin.group.v1_3.JSONFactory_BERLIN_GROUP_1_3._ +import code.api.berlin.group.v1_3.model.TransactionStatus.mapTransactionStatus +import code.api.berlin.group.v1_3.model._ +import code.api.util.APIUtil.{EmptyBody, ResourceDoc, UserOrApplication, getScaMethodAtInstance, getServerUrl, isValidCurrencyISOCode, mockedDataText, passesPsd2Pisp} +import code.api.util.ApiTag._ +import code.api.util.ErrorMessages._ +import code.api.util.CustomJsonFormats +import code.api.util.{ApiTag, CallContext, NewStyle} +import code.api.util.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps} +import code.api.util.http4s.{ErrorResponseConverter, RequestScopeConnection} +import code.fx.fx +import code.util.Helper +import code.util.Helper.{MdcLoggable, booleanToFuture} +import com.github.dwickern.macros.NameOf.nameOf +import com.openbankproject.commons.ExecutionContext.Implicits.global +import com.openbankproject.commons.model._ +import com.openbankproject.commons.model.enums.TransactionRequestStatus._ +import com.openbankproject.commons.model.enums.TransactionRequestTypes._ +import com.openbankproject.commons.model.enums.{ChallengeType, PaymentServiceTypes, StrongCustomerAuthenticationStatus, SuppliedAnswerType, TransactionRequestStatus, TransactionRequestTypes} +import net.liftweb.common.Box.tryo +import net.liftweb.common.Full +import net.liftweb.json +import net.liftweb.json.Formats +import net.liftweb.json.JsonAST.prettyRender +import net.liftweb.json.{Extraction => LiftExtraction} +import org.http4s._ +import org.http4s.dsl.io._ + +import scala.collection.mutable.ArrayBuffer +import scala.concurrent.Future +import scala.language.implicitConversions + +/** + * Native http4s aggregator for Berlin Group v1.3 – Payment Initiation Service (PIS). + * Ports all 24 PIS endpoints from code.api.builder.PaymentInitiationServicePISApi. + * Route handlers declared as lazy val (avoids 64KB limit for large objects). + */ +object Http4sBGv13PIS extends MdcLoggable { + + type HttpF[A] = OptionT[IO, A] + + implicit val formats: Formats = CustomJsonFormats.formats + + protected implicit def JvalueToSuper(what: net.liftweb.json.JValue): JvalueCaseClass = JvalueCaseClass(what) + + val implementedInApiVersion = ConstantsBG.berlinGroupVersion1 + val resourceDocs = ArrayBuffer[ResourceDoc]() + + val bgV13Prefix: Path = + Root / ConstantsBG.berlinGroupVersion1.urlPrefix / ConstantsBG.berlinGroupVersion1.apiShortVersion + + // ── private helpers (ported from APIMethods_PaymentInitiationServicePISApi) ─ + + private def checkPaymentServerTypeError(paymentService: String) = { + s"${InvalidTransactionRequestType.replaceAll("TRANSACTION_REQUEST_TYPE", "PAYMENT_SERVICE in the URL.")}: '${paymentService}'.It should be `payments` or `periodic-payments` for now, will support `bulk-payments` soon" + } + + private def checkPaymentProductError(paymentProduct: String) = + s"${InvalidTransactionRequestType.replaceAll("TRANSACTION_REQUEST_TYPE", "PAYMENT_PRODUCT in the URL.")}: '${paymentProduct}'.It should be `sepa-credit-transfers`for now, will support (instant-sepa-credit-transfers, target-2-payments, cross-border-credit-transfers) soon." + + private def checkPaymentServiceType(paymentService: String) = tryo { + PaymentServiceTypes.withName(paymentService.replaceAll("-", "_")) + }.isDefined + + /** + * Shared business logic for all three initiate-payment variants (payments / periodic-payments / + * bulk-payments). Mirrors `initiatePaymentImplementation` from the Lift builder; auth is handled + * by middleware (authMode = UserOrApplication), so no inline applicationAccess call. + */ + private def initiatePaymentImpl( + paymentService: String, + paymentProduct: String, + callContext: Option[CallContext] + ): Future[net.liftweb.json.JValue] = { + val u = callContext.flatMap(_.user.toOption) + val rawBody = callContext.flatMap(_.httpBody).getOrElse("") + val bodyJson = scala.util.Try(json.parse(rawBody)).getOrElse(json.JNothing) + for { + _ <- passesPsd2Pisp(callContext) + paymentServiceType <- NewStyle.function.tryons(checkPaymentServerTypeError(paymentService), 404, callContext) { + PaymentServiceTypes.withName(paymentService.replaceAll("-", "_")) + } + transactionRequestType <- NewStyle.function.tryons(checkPaymentProductError(paymentProduct), 404, callContext) { + TransactionRequestTypes.withName(paymentProduct.replaceAll("-", "_").toUpperCase) + } + sepaCreditTransfersBerlinGroupV13 <- if (paymentServiceType.equals(PaymentServiceTypes.payments)) { + NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $SepaCreditTransfersBerlinGroupV13 ", 400, callContext) { + bodyJson.extract[SepaCreditTransfersBerlinGroupV13] + } + } else if (paymentServiceType.equals(PaymentServiceTypes.periodic_payments)) { + NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $PeriodicSepaCreditTransfersBerlinGroupV13 ", 400, callContext) { + bodyJson.extract[PeriodicSepaCreditTransfersBerlinGroupV13] + } + } else { + Future { throw new RuntimeException(checkPaymentServerTypeError(paymentServiceType.toString)) } + } + isValidAmountNumber <- NewStyle.function.tryons(s"$InvalidNumber Current input is ${sepaCreditTransfersBerlinGroupV13.instructedAmount.amount} ", 400, callContext) { + BigDecimal(sepaCreditTransfersBerlinGroupV13.instructedAmount.amount) + } + _ <- Helper.booleanToFuture(s"${NotPositiveAmount} Current input is: '${isValidAmountNumber}'", cc = callContext) { + isValidAmountNumber > BigDecimal("0") + } + _ <- Helper.booleanToFuture(s"${InvalidISOCurrencyCode} Current input is: '${sepaCreditTransfersBerlinGroupV13.instructedAmount.currency}'", cc = callContext) { + isValidCurrencyISOCode(sepaCreditTransfersBerlinGroupV13.instructedAmount.currency) + } + _ <- NewStyle.function.isEnabledTransactionRequests(callContext) + (createdTransactionRequest, _) <- transactionRequestType match { + case TransactionRequestTypes.SEPA_CREDIT_TRANSFERS => + NewStyle.function.createTransactionRequestBGV1( + initiator = u, + paymentServiceType, + transactionRequestType, + transactionRequestBody = sepaCreditTransfersBerlinGroupV13, + callContext + ) + } + } yield { + LiftExtraction.decompose(JSONFactory_BERLIN_GROUP_1_3.createTransactionRequestJson(createdTransactionRequest)) + } + } + + // ── DELETE /{paymentService}/{paymentProduct}/{paymentId} ────────────────────────────────────────────────────── + // Variable response: 202 (SCA required) with CancelPaymentResponseJson body, or 204 (direct cancel) with no body. + // Custom IO handler to produce truly-empty 204 (NoContent) — executeFutureWithStatus would always add a body. + lazy val cancelPayment: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `bgV13Prefix` / paymentService / paymentProduct / paymentId => + implicit val cc: CallContext = req.callContext + val callContext = Some(cc) + RequestScopeConnection.fromFuture { + val u = cc.user.openOrThrowException(AuthenticatedUserIsRequired) + for { + _ <- passesPsd2Pisp(callContext) + _ <- NewStyle.function.tryons(checkPaymentServerTypeError(paymentService), 404, callContext) { + PaymentServiceTypes.withName(paymentService.replaceAll("-", "_")) + } + transactionRequestTypes <- NewStyle.function.tryons(checkPaymentProductError(paymentProduct), 404, callContext) { + TransactionRequestTypes.withName(paymentProduct.replaceAll("-", "_").toUpperCase) + } + (transactionRequest, _) <- NewStyle.function.getTransactionRequestImpl(TransactionRequestId(paymentId), callContext) + transactionRequestBody <- NewStyle.function.tryons(s"${UnknownError} No data for Payment Body ", 400, callContext) { + transactionRequest.body.to_sepa_credit_transfers.get + } + fromAccountIban = transactionRequestBody.debtorAccount.iban + toAccountIban = transactionRequestBody.creditorAccount.iban + (_, _) <- NewStyle.function.getBankAccountByIban(fromAccountIban, callContext) + (ibanChecker, _) <- NewStyle.function.validateAndCheckIbanNumber(toAccountIban, callContext) + _ <- Helper.booleanToFuture(invalidIban, cc = callContext) { ibanChecker.isValid == true } + (_, _) <- NewStyle.function.getToBankAccountByIban(toAccountIban, callContext) + currentStatus = transactionRequest.status.toUpperCase() + mappedStatus = mapTransactionStatus(currentStatus) + (canBeCancelled, _, startSca) <- transactionRequestTypes match { + case TransactionRequestTypes.SEPA_CREDIT_TRANSFERS => + currentStatus match { + case TransactionStatus.RCVD.code | "INITIATED" => + NewStyle.function.saveTransactionRequestStatusImpl(transactionRequest.id, CANCELLED.toString, callContext) + Future.successful((true, callContext, Some(false))) + case TransactionStatus.ACCP.code | "COMPLETED" => + NewStyle.function.cancelPaymentV400(TransactionId(transactionRequest.transaction_ids), callContext) map { x => + x._1 match { + case CancelPayment(true, Some(startSca)) if startSca => + NewStyle.function.saveTransactionRequestStatusImpl(transactionRequest.id, CANCELLATION_PENDING.toString, callContext) + (true, x._2, Some(startSca)) + case CancelPayment(true, Some(startSca)) if !startSca => + NewStyle.function.saveTransactionRequestStatusImpl(transactionRequest.id, CANCELLED.toString, callContext) + (true, x._2, Some(startSca)) + case CancelPayment(false, _) => + (false, x._2, Some(false)) + } + } + case TransactionStatus.PDNG.code | "PENDING" => + NewStyle.function.cancelPaymentV400(TransactionId(transactionRequest.transaction_ids), callContext) map { x => + x._1 match { + case CancelPayment(true, Some(startSca)) if startSca => + NewStyle.function.saveTransactionRequestStatusImpl(transactionRequest.id, CANCELLATION_PENDING.toString, callContext) + (true, x._2, Some(startSca)) + case CancelPayment(true, Some(startSca)) if !startSca => + NewStyle.function.saveTransactionRequestStatusImpl(transactionRequest.id, CANCELLED.toString, callContext) + (true, x._2, Some(startSca)) + case CancelPayment(false, _) => + (false, x._2, Some(false)) + } + } + case TransactionStatus.CANC.code | "CANCELLED" => + Future.successful((true, callContext, Some(false))) + case _ => + Future.successful((false, callContext, Some(false))) + } + } + _ <- Helper.booleanToFuture( + failMsg = s"$TransactionRequestCannotBeCancelled Payment status: $mappedStatus. Only payments in RCVD, ACCP, PDNG, or CANC status can be cancelled.", + cc = callContext + ) { canBeCancelled == true } + (updatedTransactionRequest, _) <- NewStyle.function.getTransactionRequestImpl(TransactionRequestId(paymentId), callContext) + } yield { + startSca.getOrElse(false) match { + case true => Some(createCancellationTransactionRequestJson(updatedTransactionRequest)) + case false => None + } + } + }.attempt.flatMap { + case Right(Some(cancelJson)) => + Accepted(prettyRender(LiftExtraction.decompose(cancelJson))) + case Right(None) => + NoContent() + case Left(err) => + ErrorResponseConverter.toHttp4sResponse(err, cc) + } + } + + // ── GET /{paymentService}/{paymentProduct}/{paymentId}/cancellation-authorisations/{cancellationId} ─── + lazy val getPaymentCancellationScaStatus: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `bgV13Prefix` / paymentService / paymentProduct / paymentId / "cancellation-authorisations" / cancellationId => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + for { + _ <- passesPsd2Pisp(callContext) + _ <- NewStyle.function.tryons(checkPaymentServerTypeError(paymentService), 404, callContext) { + PaymentServiceTypes.withName(paymentService.replaceAll("-", "_")) + } + _ <- NewStyle.function.tryons(checkPaymentProductError(paymentProduct), 404, callContext) { + TransactionRequestTypes.withName(paymentProduct.replaceAll("-", "_").toUpperCase) + } + (_, _) <- NewStyle.function.getTransactionRequestImpl(TransactionRequestId(paymentId), callContext) + (challenge, _) <- NewStyle.function.getChallenge(cancellationId, callContext) + } yield { + JSONFactory_BERLIN_GROUP_1_3.ScaStatusJsonV13(challenge.scaStatus.map(_.toString).getOrElse("None")) + } + } + } + + // ── GET /{paymentService}/{paymentProduct}/{paymentId} (with checkPaymentServiceType guard in Lift) ── + lazy val getPaymentInformation: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `bgV13Prefix` / paymentService / paymentProduct / paymentId if checkPaymentServiceType(paymentService) => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + for { + _ <- passesPsd2Pisp(callContext) + _ <- NewStyle.function.tryons(checkPaymentServerTypeError(paymentService), 404, callContext) { + PaymentServiceTypes.withName(paymentService.replaceAll("-", "_")) + } + _ <- NewStyle.function.tryons(checkPaymentProductError(paymentProduct), 404, callContext) { + TransactionRequestTypes.withName(paymentProduct.replaceAll("-", "_").toUpperCase) + } + (transactionRequest, _) <- NewStyle.function.getTransactionRequestImpl(TransactionRequestId(paymentId), callContext) + transactionRequestBody <- NewStyle.function.tryons(s"${UnknownError} No data for Payment Body ", 400, callContext) { + transactionRequest.body.to_sepa_credit_transfers.get + } + } yield { + transactionRequestBody + } + } + } + + // ── GET /{paymentService}/{paymentProduct}/{paymentId}/authorisations ───────────────────────────── + lazy val getPaymentInitiationAuthorisation: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `bgV13Prefix` / paymentService / paymentProduct / paymentId / "authorisations" => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + for { + _ <- passesPsd2Pisp(callContext) + _ <- NewStyle.function.tryons(checkPaymentServerTypeError(paymentService), 404, callContext) { + PaymentServiceTypes.withName(paymentService.replaceAll("-", "_")) + } + _ <- NewStyle.function.tryons(checkPaymentProductError(paymentProduct), 404, callContext) { + TransactionRequestTypes.withName(paymentProduct.replaceAll("-", "_").toUpperCase) + } + (_, _) <- NewStyle.function.getTransactionRequestImpl(TransactionRequestId(paymentId), callContext) + (challenges, _) <- NewStyle.function.getChallengesByTransactionRequestId(paymentId, callContext) + } yield { + JSONFactory_BERLIN_GROUP_1_3.createStartPaymentAuthorisationsJson(challenges) + } + } + } + + // ── GET /{paymentService}/{paymentProduct}/{paymentId}/cancellation-authorisations ────────────────── + lazy val getPaymentInitiationCancellationAuthorisationInformation: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `bgV13Prefix` / paymentService / paymentProduct / paymentId / "cancellation-authorisations" => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + for { + _ <- passesPsd2Pisp(callContext) + _ <- NewStyle.function.tryons(checkPaymentServerTypeError(paymentService), 404, callContext) { + PaymentServiceTypes.withName(paymentService.replaceAll("-", "_")) + } + _ <- NewStyle.function.tryons(checkPaymentProductError(paymentProduct), 404, callContext) { + TransactionRequestTypes.withName(paymentProduct.replaceAll("-", "_").toUpperCase) + } + (challenges, _) <- NewStyle.function.getChallengesByTransactionRequestId(paymentId, callContext) + } yield { + JSONFactory_BERLIN_GROUP_1_3.CancellationJsonV13(challenges.map(_.challengeId)) + } + } + } + + // ── GET /{paymentService}/{paymentProduct}/{paymentId}/authorisations/{authorisationId} ──────────── + lazy val getPaymentInitiationScaStatus: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `bgV13Prefix` / paymentService / paymentProduct / paymentId / "authorisations" / authorisationId => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + for { + _ <- passesPsd2Pisp(callContext) + _ <- NewStyle.function.tryons(checkPaymentServerTypeError(paymentService), 404, callContext) { + PaymentServiceTypes.withName(paymentService.replaceAll("-", "_")) + } + _ <- NewStyle.function.tryons(checkPaymentProductError(paymentProduct), 404, callContext) { + TransactionRequestTypes.withName(paymentProduct.replaceAll("-", "_").toUpperCase) + } + (_, _) <- NewStyle.function.getTransactionRequestImpl(TransactionRequestId(paymentId), callContext) + (challenge, _) <- NewStyle.function.getChallenge(authorisationId, callContext) + } yield { + json.parse(s"""{"scaStatus" : "${challenge.scaStatus.getOrElse("None")}"}""") + } + } + } + + // ── GET /{paymentService}/{paymentProduct}/{paymentId}/status ───────────────────────────────────── + lazy val getPaymentInitiationStatus: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `bgV13Prefix` / paymentService / paymentProduct / paymentId / "status" => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + import net.liftweb.json.JsonDSL._ + for { + _ <- passesPsd2Pisp(callContext) + _ <- NewStyle.function.tryons(checkPaymentServerTypeError(paymentService), 404, callContext) { + PaymentServiceTypes.withName(paymentService.replaceAll("-", "_")) + } + _ <- NewStyle.function.tryons(checkPaymentProductError(paymentProduct), 404, callContext) { + TransactionRequestTypes.withName(paymentProduct.replaceAll("-", "_").toUpperCase) + } + (transactionRequest, _) <- NewStyle.function.getTransactionRequestImpl(TransactionRequestId(paymentId), callContext) + transactionRequestStatus = mapTransactionStatus(transactionRequest.status) + transactionRequestAmount <- NewStyle.function.tryons(s"${InvalidNumber} transaction request amount cannot convert to a Decimal", 400, callContext) { + BigDecimal(transactionRequest.body.to_sepa_credit_transfers.get.instructedAmount.amount) + } + transactionRequestCurrency <- NewStyle.function.tryons(s"${InvalidCurrency} can not get currency from this paymentId(${paymentId})", 400, callContext) { + transactionRequest.body.to_sepa_credit_transfers.get.instructedAmount.currency + } + transactionRequestFromAccount = transactionRequest.from + (fromAccount, _) <- NewStyle.function.checkBankAccountExists( + BankId(transactionRequestFromAccount.bank_id), + AccountId(transactionRequestFromAccount.account_id), + callContext + ) + fromAccountBalance = fromAccount.balance + fromAccountCurrency = fromAccount.currency + rate = fx.exchangeRate(transactionRequestCurrency, fromAccountCurrency, None, callContext) + _ <- Helper.booleanToFuture(s"$InvalidCurrency The requested currency conversion (${transactionRequestCurrency} to ${fromAccountCurrency}) is not supported.", cc = callContext) { + rate.isDefined + } + requestChangedCurrencyAmount = fx.convert(transactionRequestAmount, rate) + fundsAvailable = (fromAccountBalance >= requestChangedCurrencyAmount) + transactionRequestStatusCheckedFunds = if (fundsAvailable) transactionRequestStatus else TransactionStatus.RCVD.code + } yield { + ("transactionStatus" -> transactionRequestStatusCheckedFunds) ~ + ("fundsAvailable" -> fundsAvailable) + } + } + } + + // ── POST /payments/{paymentProduct} ────────────────────────────────────────────────────────────── + // Auth: applicationAccess in Lift → authMode = UserOrApplication in ResourceDoc + lazy val initiatePayments: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `bgV13Prefix` / "payments" / paymentProduct => + EndpointHelpers.executeFutureCreated(req) { + initiatePaymentImpl("payments", paymentProduct, Some(req.callContext)) + } + } + + // ── POST /periodic-payments/{paymentProduct} ────────────────────────────────────────────────────── + lazy val initiatePeriodicPayments: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `bgV13Prefix` / "periodic-payments" / paymentProduct => + EndpointHelpers.executeFutureCreated(req) { + initiatePaymentImpl("periodic-payments", paymentProduct, Some(req.callContext)) + } + } + + // ── POST /bulk-payments/{paymentProduct} ────────────────────────────────────────────────────────── + lazy val initiateBulkPayments: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `bgV13Prefix` / "bulk-payments" / paymentProduct => + EndpointHelpers.executeFutureCreated(req) { + initiatePaymentImpl("bulk-payments", paymentProduct, Some(req.callContext)) + } + } + + // ── POST /{paymentService}/{paymentProduct}/{paymentId}/authorisations (3 body-guard variants) ─── + // + // Dispatches on the request body: + // scaAuthenticationData → startPaymentAuthorisationTransactionAuthorisation (real SCA logic) + // psuData → startPaymentAuthorisationUpdatePsuAuthentication (mocked) + // authenticationMethodId → startPaymentAuthorisationSelectPsuAuthenticationMethod (mocked) + lazy val startPaymentAuthorisationAll: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `bgV13Prefix` / paymentService / paymentProduct / paymentId / "authorisations" => + EndpointHelpers.executeFutureCreated(req) { + val cc = req.callContext + val callContext = Some(cc) + val u = cc.user.openOrThrowException(AuthenticatedUserIsRequired) + val parsedJson = scala.util.Try(json.parse(cc.httpBody.getOrElse(""))).getOrElse(json.JNothing) + if (checkTransactionAuthorisation(parsedJson)) { + for { + _ <- passesPsd2Pisp(callContext) + _ <- NewStyle.function.tryons(checkPaymentServerTypeError(paymentService), 404, callContext) { + PaymentServiceTypes.withName(paymentService.replaceAll("-", "_")) + } + _ <- NewStyle.function.tryons(checkPaymentProductError(paymentProduct), 404, callContext) { + TransactionRequestTypes.withName(paymentProduct.replaceAll("-", "_").toUpperCase) + } + (_, _) <- NewStyle.function.getTransactionRequestImpl(TransactionRequestId(paymentId), callContext) + (challenges, _) <- NewStyle.function.createChallengesC2( + List(u.userId), + ChallengeType.BERLIN_GROUP_PAYMENT_CHALLENGE, + Some(paymentId), + getScaMethodAtInstance(SEPA_CREDIT_TRANSFERS.toString).toOption, + Some(StrongCustomerAuthenticationStatus.received), + None, + None, + callContext + ) + challenge <- NewStyle.function.tryons(InvalidConnectorResponseForCreateChallenge, 400, callContext) { + challenges.head + } + } yield { + JSONFactory_BERLIN_GROUP_1_3.createStartPaymentAuthorisationJson(challenge) + } + } else { + // Mocked response for updatePsuAuthentication and selectPsuAuthenticationMethod variants + Future.successful(json.parse( + """{ + "challengeData": { + "scaStatus": "received", + "authorisationId": "88695566-6642-46d5-9985-0d824624f507", + "psuMessage": "Please check your SMS at a mobile device.", + "_links": { + "scaStatus": "/v1.3/payments/sepa-credit-transfers/88695566-6642-46d5-9985-0d824624f507" + } + } + }""")) + } + } + } + + // ── POST /{paymentService}/{paymentProduct}/{paymentId}/cancellation-authorisations (3 variants) ─ + lazy val startPaymentInitiationCancellationAuthorisationAll: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `bgV13Prefix` / paymentService / paymentProduct / paymentId / "cancellation-authorisations" => + EndpointHelpers.executeFutureCreated(req) { + val cc = req.callContext + val callContext = Some(cc) + val u = cc.user.openOrThrowException(AuthenticatedUserIsRequired) + val parsedJson = scala.util.Try(json.parse(cc.httpBody.getOrElse(""))).getOrElse(json.JNothing) + if (checkTransactionAuthorisation(parsedJson)) { + for { + _ <- passesPsd2Pisp(callContext) + _ <- NewStyle.function.tryons(checkPaymentServerTypeError(paymentService), 404, callContext) { + PaymentServiceTypes.withName(paymentService.replaceAll("-", "_")) + } + _ <- NewStyle.function.tryons(checkPaymentProductError(paymentProduct), 404, callContext) { + TransactionRequestTypes.withName(paymentProduct.replaceAll("-", "_").toUpperCase) + } + (transactionRequest, _) <- NewStyle.function.getTransactionRequestImpl(TransactionRequestId(paymentId), callContext) + _ <- Helper.booleanToFuture(failMsg = CannotStartTheAuthorisationProcessForTheCancellation, cc = callContext) { + transactionRequest.status == TransactionRequestStatus.CANCELLATION_PENDING.toString + } + (challenges, _) <- NewStyle.function.createChallengesC2( + List(u.userId), + ChallengeType.BERLIN_GROUP_PAYMENT_CHALLENGE, + Some(paymentId), + getScaMethodAtInstance(SEPA_CREDIT_TRANSFERS.toString).toOption, + Some(StrongCustomerAuthenticationStatus.received), + None, + None, + callContext + ) + challenge <- NewStyle.function.tryons(InvalidConnectorResponseForCreateChallenge, 400, callContext) { + challenges.head + } + } yield { + JSONFactory_BERLIN_GROUP_1_3.createStartPaymentInitiationCancellationAuthorisation( + challenge, paymentService, paymentProduct, paymentId + ) + } + } else { + // Mocked for updatePsuAuthentication and selectPsuAuthenticationMethod variants + Future.successful(json.parse( + """{ + "scaStatus": "received", + "authorisationId": "123auth456", + "psuMessage": "Please use your BankApp for transaction Authorisation.", + "_links": { + "scaStatus": { + "href": "/v1.3/payments/qwer3456tzui7890/authorisations/123auth456" + } + } + }""")) + } + } + } + + // ── PUT /{paymentService}/{paymentProduct}/{paymentId}/cancellation-authorisations/{authorisationId} (4 variants) ─ + lazy val updatePaymentCancellationPsuDataAll: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `bgV13Prefix` / paymentService / paymentProduct / paymentId / "cancellation-authorisations" / authorisationId => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + val parsedJson = scala.util.Try(json.parse(cc.httpBody.getOrElse(""))).getOrElse(json.JNothing) + if (checkTransactionAuthorisation(parsedJson)) { + for { + _ <- passesPsd2Pisp(callContext) + failMsg = s"$InvalidJsonFormat The Json body should be the $UpdatePaymentPsuDataJson " + transactionAuthorisation <- NewStyle.function.tryons(failMsg, 400, callContext) { + parsedJson.extract[TransactionAuthorisation] + } + _ <- NewStyle.function.tryons(checkPaymentServerTypeError(paymentService), 404, callContext) { + PaymentServiceTypes.withName(paymentService.replaceAll("-", "_")) + } + _ <- NewStyle.function.tryons(checkPaymentProductError(paymentProduct), 404, callContext) { + TransactionRequestTypes.withName(paymentProduct.replaceAll("-", "_").toUpperCase) + } + transactionRequestId = TransactionRequestId(paymentId) + (existingTransactionRequest, _) <- NewStyle.function.getTransactionRequestImpl(transactionRequestId, callContext) + _ <- Helper.booleanToFuture(failMsg = CannotUpdatePSUDataCancellation, cc = callContext) { + existingTransactionRequest.status == TransactionRequestStatus.INITIATED.toString || + existingTransactionRequest.status == TransactionRequestStatus.CANCELLATION_PENDING.toString || + existingTransactionRequest.status == TransactionRequestStatus.COMPLETED.toString + } + (_, _) <- NewStyle.function.getTransactionRequestImpl(TransactionRequestId(paymentId), callContext) + (challenge, _) <- NewStyle.function.validateChallengeAnswerC4( + ChallengeType.BERLIN_GROUP_PAYMENT_CHALLENGE, + Some(paymentId), + None, + authorisationId, + transactionAuthorisation.scaAuthenticationData, + SuppliedAnswerType.PLAIN_TEXT_VALUE, + callContext + ) + (fromAccount, _) <- NewStyle.function.checkBankAccountExists( + BankId(existingTransactionRequest.from.bank_id), + AccountId(existingTransactionRequest.from.account_id), + callContext + ) + _ <- challenge.scaStatus match { + case Some(status) if status == StrongCustomerAuthenticationStatus.finalised => + NewStyle.function.saveTransactionRequestStatusImpl(existingTransactionRequest.id, CANCELLED.toString, callContext) + case Some(status) if status == StrongCustomerAuthenticationStatus.failed => + NewStyle.function.saveTransactionRequestStatusImpl(existingTransactionRequest.id, REJECTED.toString, callContext) + case _ => + Future(Full(true)) + } + } yield { + JSONFactory_BERLIN_GROUP_1_3.createStartPaymentCancellationAuthorisationJson( + challenge, paymentService, paymentProduct, paymentId + ) + } + } else if (checkUpdatePsuAuthentication(parsedJson)) { + Future.successful(json.parse( + """{ + "scaStatus": "psuAuthenticated", + "_links": { + "authoriseTransaction": {"href": "/psd2/v1.3/payments/1234-wertiq-983/authorisations/123auth456"} + } + }""")) + } else if (checkSelectPsuAuthenticationMethod(parsedJson)) { + Future.successful(json.parse( + """{ + "scaStatus": "scaMethodSelected", + "chosenScaMethod": { + "authenticationType": "SMS_OTP", + "authenticationMethodId": "myAuthenticationID"}, + "challengeData": { + "otpMaxLength": 6, + "otpFormat": "integer"}, + "_links": { + "authoriseTransaction": {"href": "/psd2/v1.3/payments/1234-wertiq-983/authorisations/123auth456"} + } + }""")) + } else { + // authorisationConfirmation variant + Future.successful(json.parse( + """{ + "scaStatus": "finalised", + "_links":{ + "status": {"href":"/v1.3/payments/sepa-credit-transfers/qwer3456tzui7890/status"} + } + }""")) + } + } + } + + // ── PUT /{paymentService}/{paymentProduct}/{paymentId}/authorisations/{authorisationId} (4 variants) ─ + lazy val updatePaymentPsuDataAll: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `bgV13Prefix` / paymentService / paymentProduct / paymentId / "authorisations" / authorisationId => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + val parsedJson = scala.util.Try(json.parse(cc.httpBody.getOrElse(""))).getOrElse(json.JNothing) + if (checkTransactionAuthorisation(parsedJson)) { + val u = cc.user.openOrThrowException(AuthenticatedUserIsRequired) + for { + _ <- passesPsd2Pisp(callContext) + failMsg = s"$InvalidJsonFormat The Json body should be the $TransactionAuthorisation " + transactionAuthorisationJson <- NewStyle.function.tryons(failMsg, 400, callContext) { + parsedJson.extract[TransactionAuthorisation] + } + _ <- NewStyle.function.tryons(checkPaymentServerTypeError(paymentService), 404, callContext) { + PaymentServiceTypes.withName(paymentService.replaceAll("-", "_")) + } + _ <- NewStyle.function.tryons(checkPaymentProductError(paymentProduct), 404, callContext) { + TransactionRequestTypes.withName(paymentProduct.replaceAll("-", "_").toUpperCase) + } + transactionRequestId = TransactionRequestId(paymentId) + (existingTransactionRequest, _) <- NewStyle.function.getTransactionRequestImpl(transactionRequestId, callContext) + _ <- Helper.booleanToFuture(failMsg = CannotUpdatePSUData, cc = callContext) { + existingTransactionRequest.status == TransactionStatus.RCVD.code + } + (_, _) <- NewStyle.function.getChallenge(authorisationId, callContext) + (challenge, _) <- NewStyle.function.validateChallengeAnswerC4( + ChallengeType.BERLIN_GROUP_PAYMENT_CHALLENGE, + Some(paymentId), + None, + authorisationId, + transactionAuthorisationJson.scaAuthenticationData, + SuppliedAnswerType.PLAIN_TEXT_VALUE, + callContext + ) + (fromAccount, _) <- NewStyle.function.checkBankAccountExists( + BankId(existingTransactionRequest.from.bank_id), + AccountId(existingTransactionRequest.from.account_id), + callContext + ) + _ <- challenge.scaStatus match { + case Some(status) if status == StrongCustomerAuthenticationStatus.finalised => + NewStyle.function.createTransactionAfterChallengeV210(fromAccount, existingTransactionRequest, callContext) map { _ => + NewStyle.function.saveTransactionRequestStatusImpl(existingTransactionRequest.id, COMPLETED.toString, callContext) + } + case Some(status) if status == StrongCustomerAuthenticationStatus.failed => + NewStyle.function.saveTransactionRequestStatusImpl(existingTransactionRequest.id, REJECTED.toString, callContext) + case _ => + Future(Full(true)) + } + } yield { + JSONFactory_BERLIN_GROUP_1_3.createUpdatePaymentPsuDataTransactionAuthorisationJson(challenge) + } + } else if (checkUpdatePsuAuthentication(parsedJson)) { + Future.successful(json.parse( + """{ + "scaStatus": "finalised", + "_links": { + "scaStatus": {"href":"/v1.3/payments/sepa-credit-transfers/88695566-6642-46d5-9985-0d824624f507"} + } + }""")) + } else if (checkSelectPsuAuthenticationMethod(parsedJson)) { + Future.successful(json.parse( + """{ + "scaStatus": "scaMethodSelected", + "chosenScaMethod": { + "authenticationType": "SMS_OTP", + "authenticationMethodId": "myAuthenticationID"}, + "challengeData": { + "otpMaxLength": 6, + "otpFormat": "integer"}, + "_links": { + "authoriseTransaction": {"href": "/psd2/v1.3/payments/1234-wertiq-983/authorisations/123auth456"} + } + }""")) + } else { + // authorisationConfirmation variant + Future.successful(json.parse( + """{ + "scaStatus": "finalised", + "_links":{ + "status": {"href":"/v1.3/payments/sepa-credit-transfers/qwer3456tzui7890/status"} + } + }""")) + } + } + } + + // ── ResourceDocs ─────────────────────────────────────────────────────────────────────────────────── + + private val generalPaymentSummaryText: String = + s""" This method is used to initiate a payment at the ASPSP. + + ## Variants of Payment Initiation Requests + + This method to initiate a payment initiation at the ASPSP can be sent with either a JSON body or an pain.001 body depending on the payment product in the path. + + There are the following **payment products**: + + - Payment products with payment information in *JSON* format: + - ***sepa-credit-transfers*** + - ***instant-sepa-credit-transfers*** + - ***target-2-payments*** + - ***cross-border-credit-transfers*** + - Payment products with payment information in *pain.001* XML format: + - ***pain.001-sepa-credit-transfers*** + - ***pain.001-instant-sepa-credit-transfers*** + - ***pain.001-target-2-payments*** + - ***pain.001-cross-border-credit-transfers*** + + - Furthermore the request body depends on the **payment-service** + - ***payments***: A single payment initiation request. + - ***bulk-payments***: A collection of several payment iniatiation requests. + In case of a *pain.001* message there are more than one payments contained in the *pain.001 message. + In case of a *JSON* there are several JSON payment blocks contained in a joining list. + - ***periodic-payments***: + Create a standing order initiation resource for recurrent i.e. periodic payments addressable under {paymentId} + with all data relevant for the corresponding payment product and the execution of the standing order contained in a JSON body. + + This is the first step in the API to initiate the related recurring/periodic payment. + + Additional Instructions: + + for PAYMENT_SERVICE use payments + + for PAYMENT_PRODUCT use sepa-credit-transfers + """ + + private val generalStartPaymentAuthorisationSummary: String = + s"""${mockedDataText(true)} +Create an authorisation sub-resource and start the authorisation process. +The message might in addition transmit authentication and authorisation related data. + +This method is iterated n times for a n times SCA authorisation in a +corporate context, each creating an own authorisation sub-endpoint for +the corresponding PSU authorising the transaction. + +The ASPSP might make the usage of this access method unnecessary in case +of only one SCA process needed, since the related authorisation resource +might be automatically created by the ASPSP after the submission of the +payment data with the first POST payments/{payment-product} call. + +The start authorisation process is a process which is needed for creating a new authorisation +or cancellation sub-resource. +""" + + private val startPaymentAuthorisationResponse = json.parse("""{ + "challengeData": { + "scaStatus": "received", + "authorisationId": "88695566-6642-46d5-9985-0d824624f507", + "psuMessage": "Please check your SMS at a mobile device.", + "_links": { + "scaStatus": "/v1.3/payments/sepa-credit-transfers/88695566-6642-46d5-9985-0d824624f507" + } + } + }""") + + private val generalStartPaymentInitiationCancellationAuthorisationSummary: String = + s"""${mockedDataText(true)} +Creates an authorisation sub-resource and start the authorisation process of the cancellation of the addressed payment. +The message might in addition transmit authentication and authorisation related data. +""" + + private val startPaymentInitiationCancellationAuthorisationResponse = json.parse("""{ + "scaStatus": "received", + "authorisationId": "123auth456", + "psuMessage": "Please use your BankApp for transaction Authorisation.", + "_links": { + "scaStatus": { + "href": "/v1.3/payments/qwer3456tzui7890/authorisations/123auth456" + } + } + }""") + + private val generalUpdatePaymentCancellationPsuDataSummary: String = + s"""${mockedDataText(true)} +This method updates PSU data on the cancellation authorisation resource if needed. +It may authorise a cancellation of the payment within the Embedded SCA Approach where needed. +""" + + private val generalUpdatePaymentPsuDataSummary: String = + s"""${mockedDataText(false)} +This methods updates PSU data on the authorisation resource if needed. +It may authorise a payment within the Embedded SCA Approach where needed. + + NOTE: For this endpoint, for sandbox mode, the `scaAuthenticationData` is fixed value: 123. To make the process work. + Normally the app use will get SMS/EMAIL to get the value for this process. +""" + + private def initCancelAndGetResourceDocs(): Unit = { + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(cancelPayment), + "DELETE", "/PAYMENT_SERVICE/PAYMENT_PRODUCT/PAYMENTID", + "Payment Cancellation Request", + s"""${mockedDataText(false)} +This method initiates the cancellation of a payment. Depending on the payment-service, the payment-product +and the ASPSP's implementation, this TPP call might be sufficient to cancel a payment. If an authorisation +of the payment cancellation is mandated by the ASPSP, a corresponding hyperlink will be contained in the +response message. Cancels the addressed payment with resource identification paymentId if applicable to the +payment-service, payment-product and received in product related timelines (e.g. before end of business day +for scheduled payments of the last business day before the scheduled execution day). The response to this +DELETE command will tell the TPP whether the * access method was rejected * access method was successful, +or * access method is generally applicable, but further authorisation processes are needed. +""", + EmptyBody, + CancelPaymentResponseJson( + "ACTC", + _links = CancelPaymentResponseLinks( + self = LinkHrefJson(s"/v1.3/payments/sepa-credit-transfers/1234-wertiq-983"), + status = LinkHrefJson(s"/v1.3/payments/sepa-credit-transfers/1234-wertiq-983/status"), + startAuthorisation = LinkHrefJson(s"/v1.3/payments/sepa-credit-transfers/cancellation-authorisations/1234-wertiq-983/status") + ) + ), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Payment Initiation Service (PIS)") :: Nil, + http4sPartialFunction = Some(cancelPayment) + ) + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getPaymentCancellationScaStatus), + "GET", "/PAYMENT_SERVICE/PAYMENT_PRODUCT/PAYMENTID/cancellation-authorisations/CANCELLATIONID", + "Read the SCA status of the payment cancellation's authorisation.", + s"""${mockedDataText(false)} +This method returns the SCA status of a payment initiation's authorisation sub-resource. +""", + EmptyBody, + json.parse("""{"scaStatus" : "psuAuthenticated"}"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Payment Initiation Service (PIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(getPaymentCancellationScaStatus) + ) + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getPaymentInformation), + "GET", "/PAYMENT_SERVICE/PAYMENT_PRODUCT/PAYMENTID", + "Get Payment Information", + s"""${mockedDataText(false)} +Returns the content of a payment object""", + EmptyBody, + json.parse("""{ + "debtorAccount":{ + "iban":"GR12 1234 5123 4511 3981 4475 477" + }, + "instructedAmount":{ + "currency":"EUR", + "amount":"1234" + }, + "creditorAccount":{ + "iban":"GR12 1234 5123 4514 4575 3645 077" + }, + "creditorName":"70charname" + }"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Payment Initiation Service (PIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(getPaymentInformation) + ) + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getPaymentInitiationAuthorisation), + "GET", "/PAYMENT_SERVICE/PAYMENT_PRODUCT/PAYMENTID/authorisations", + "Get Payment Initiation Authorisation Sub-Resources Request", + s"""${mockedDataText(false)} +Read a list of all authorisation subresources IDs which have been created. + +This function returns an array of hyperlinks to all generated authorisation sub-resources. +""", + EmptyBody, + json.parse("""[{ + "scaStatus": "received", + "authorisationId": "940948c7-1c86-4d88-977e-e739bf2c1492", + "psuMessage": "Please check your SMS at a mobile device.", + "_links": {"scaStatus": "/v1.3/payments/sepa-credit-transfers/940948c7-1c86-4d88-977e-e739bf2c1492"} + }]"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Payment Initiation Service (PIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(getPaymentInitiationAuthorisation) + ) + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getPaymentInitiationCancellationAuthorisationInformation), + "GET", "/PAYMENT_SERVICE/PAYMENT_PRODUCT/PAYMENTID/cancellation-authorisations", + "Get Cancellation Authorisation Sub-Resources Request", + s"""${mockedDataText(false)} +Retrieve a list of all created cancellation authorisation sub-resources. +""", + EmptyBody, + json.parse("""{"cancellationIds" : ["faa3657e-13f0-4feb-a6c3-34bf21a9ae8e"]}"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Payment Initiation Service (PIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(getPaymentInitiationCancellationAuthorisationInformation) + ) + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getPaymentInitiationScaStatus), + "GET", "/PAYMENT_SERVICE/PAYMENT_PRODUCT/PAYMENT_ID/authorisations/AUTHORISATION_ID", + "Read the SCA Status of the payment authorisation", + s"""${mockedDataText(false)} +This method returns the SCA status of a payment initiation's authorisation sub-resource. +""", + EmptyBody, + json.parse("""{"scaStatus" : "psuAuthenticated"}"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Payment Initiation Service (PIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(getPaymentInitiationScaStatus) + ) + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getPaymentInitiationStatus), + "GET", "/PAYMENT_SERVICE/PAYMENT_PRODUCT/PAYMENT_ID/status", + "Payment initiation status request", + s"""${mockedDataText(false)} +Check the transaction status of a payment initiation.""", + EmptyBody, + json.parse(s"""{"transactionStatus": "${TransactionStatus.ACCP.code}"}"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Payment Initiation Service (PIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(getPaymentInitiationStatus) + ) + } + + private def initInitiatePaymentResourceDocs(): Unit = { + val initiatePaymentRequestBody = json.parse(s"""{ + "debtorAccount": {"iban": "DE123456987480123"}, + "instructedAmount": {"currency": "EUR", "amount": "100"}, + "creditorAccount": {"iban": "UK12 1234 5123 4517 2948 6166 077"}, + "creditorName": "70charname" + }""") + val initiatePaymentResponseBody = json.parse(s"""{ + "transactionStatus": "${TransactionStatus.RCVD.code}", + "paymentId": "1234-wertiq-983", + "_links": { + "scaRedirect": {"href": "$getServerUrl/otp?flow=payment&paymentService=payments&paymentProduct=sepa_credit_transfers&paymentId=b0472c21-6cea-4ee0-b036-3e253adb3b0b"}, + "self": {"href": "/v1.3/payments/sepa-credit-transfers/1234-wertiq-983"}, + "status": {"href": "/v1.3/payments/1234-wertiq-983/status"}, + "scaStatus": {"href": "/v1.3/payments/1234-wertiq-983/authorisations/123auth456"} + } + }""") + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(initiatePayments), + "POST", "/payments/PAYMENT_PRODUCT", + "Payment initiation request(payments)", + s"""${mockedDataText(false)} +$generalPaymentSummaryText""", + initiatePaymentRequestBody, + initiatePaymentResponseBody, + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Payment Initiation Service (PIS)") :: apiTagBerlinGroupM :: Nil, + authMode = UserOrApplication, + http4sPartialFunction = Some(initiatePayments) + ) + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(initiatePeriodicPayments), + "POST", "/periodic-payments/PAYMENT_PRODUCT", + "Payment initiation request(periodic-payments)", + s"""${mockedDataText(false)} +$generalPaymentSummaryText""", + json.parse(s"""{ + "instructedAmount": {"currency": "EUR", "amount": "123"}, + "debtorAccount": {"iban": "DE40100100103307118608"}, + "creditorName": "Merchant123", + "creditorAccount": {"iban": "DE23100120020123456789"}, + "remittanceInformationUnstructured": "Ref Number Abonnement", + "startDate": "2018-03-01", + "executionRule": "preceding", + "frequency": "Monthly", + "dayOfExecution": "01" + }"""), + json.parse(s"""{ + "transactionStatus": "${TransactionStatus.RCVD.code}", + "paymentId": "1234-wertiq-983", + "_links": { + "scaRedirect": {"href": "$getServerUrl/otp?flow=payment&paymentService=payments&paymentProduct=sepa_credit_transfers&paymentId=b0472c21-6cea-4ee0-b036-3e253adb3b0b"}, + "self": {"href": "/v1.3/periodic-payments/instant-sepa-credit-transfer/1234-wertiq-983"}, + "status": {"href": "/v1.3/periodic-payments/1234-wertiq-983/status"}, + "scaStatus": {"href": "/v1.3/periodic-payments/1234-wertiq-983/authorisations/123auth456"} + } + }"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Payment Initiation Service (PIS)") :: apiTagBerlinGroupM :: Nil, + authMode = UserOrApplication, + http4sPartialFunction = Some(initiatePeriodicPayments) + ) + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(initiateBulkPayments), + "POST", "/bulk-payments/PAYMENT_PRODUCT", + "Payment initiation request(bulk-payments)", + s"""${mockedDataText(true)} +$generalPaymentSummaryText""", + json.parse(s"""{ + "batchBookingPreferred": "true", + "debtorAccount": {"iban": "DE40100100103307118608"}, + "paymentInformationId": "my-bulk-identification-1234", + "requestedExecutionDate": "2018-08-01", + "payments": [ + {"instructedAmount": {"currency": "EUR", "amount": "123.50"}, "creditorName": "Merchant123", + "creditorAccount": {"iban": "DE02100100109307118603"}, + "remittanceInformationUnstructured": "Ref Number Merchant 1"}, + {"instructedAmount": {"currency": "EUR", "amount": "34.10"}, "creditorName": "Merchant456", + "creditorAccount": {"iban": "FR7612345987650123456789014"}, + "remittanceInformationUnstructured": "Ref Number Merchant 2"} + ] + }"""), + json.parse(s"""{ + "transactionStatus": "${TransactionStatus.RCVD.code}", + "paymentId": "1234-wertiq-983", + "_links": { + "scaRedirect": {"href": "$getServerUrl/otp?flow=payment&paymentService=payments&paymentProduct=sepa_credit_transfers&paymentId=b0472c21-6cea-4ee0-b036-3e253adb3b0b"}, + "self": {"href": "/v1.3/bulk-payments/sepa-credit-transfers/1234-wertiq-983"}, + "status": {"href": "/v1.3/bulk-payments/1234-wertiq-983/status"}, + "scaStatus": {"href": "/v1.3/bulk-payments/1234-wertiq-983/authorisations/123auth456"} + } + }"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Payment Initiation Service (PIS)") :: apiTagBerlinGroupM :: Nil, + authMode = UserOrApplication, + http4sPartialFunction = Some(initiateBulkPayments) + ) + } + + private def initStartAuthorisationResourceDocs(): Unit = { + // POST /PAYMENT_SERVICE/PAYMENT_PRODUCT/PAYMENT_ID/authorisations — 3 body variants + resourceDocs += ResourceDoc( + null, implementedInApiVersion, + "startPaymentAuthorisationUpdatePsuAuthentication", + "POST", "/PAYMENT_SERVICE/PAYMENT_PRODUCT/PAYMENT_ID/authorisations", + "Start the authorisation process for a payment initiation (updatePsuAuthentication)", + generalStartPaymentAuthorisationSummary, + json.parse("""{"psuData": {"password": "start12"}}"""), + startPaymentAuthorisationResponse, + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Payment Initiation Service (PIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(startPaymentAuthorisationAll) + ) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, + "startPaymentAuthorisationSelectPsuAuthenticationMethod", + "POST", "/PAYMENT_SERVICE/PAYMENT_PRODUCT/PAYMENT_ID/authorisations", + "Start the authorisation process for a payment initiation (selectPsuAuthenticationMethod)", + generalStartPaymentAuthorisationSummary, + json.parse("""{"authenticationMethodId":""}"""), + startPaymentAuthorisationResponse, + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Payment Initiation Service (PIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(startPaymentAuthorisationAll) + ) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, + "startPaymentAuthorisationTransactionAuthorisation", + "POST", "/PAYMENT_SERVICE/PAYMENT_PRODUCT/PAYMENT_ID/authorisations", + "Start the authorisation process for a payment initiation (transactionAuthorisation)", + s"""${mockedDataText(false)} +Create an authorisation sub-resource and start the authorisation process. +The message might in addition transmit authentication and authorisation related data. +""", + json.parse("""{"scaAuthenticationData":"123"}"""), + startPaymentAuthorisationResponse, + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Payment Initiation Service (PIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(startPaymentAuthorisationAll) + ) + + // POST /PAYMENT_SERVICE/PAYMENT_PRODUCT/PAYMENT_ID/cancellation-authorisations — 3 body variants + resourceDocs += ResourceDoc( + null, implementedInApiVersion, + "startPaymentInitiationCancellationAuthorisationTransactionAuthorisation", + "POST", "/PAYMENT_SERVICE/PAYMENT_PRODUCT/PAYMENT_ID/cancellation-authorisations", + "Start the authorisation process for the cancellation of the addressed payment (transactionAuthorisation)", + s"""${mockedDataText(false)} +Creates an authorisation sub-resource and start the authorisation process of the cancellation of the addressed payment. +""", + json.parse("""{"scaAuthenticationData":""}"""), + json.parse("""{ + "scaStatus": "received", + "authorisationId": "123auth456", + "psuMessage": "Please use your BankApp for transaction Authorisation.", + "_links": {"scaStatus": {"href": "/v1.3/payments/qwer3456tzui7890/authorisations/123auth456"}} + }"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Payment Initiation Service (PIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(startPaymentInitiationCancellationAuthorisationAll) + ) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, + "startPaymentInitiationCancellationAuthorisationUpdatePsuAuthentication", + "POST", "/PAYMENT_SERVICE/PAYMENT_PRODUCT/PAYMENT_ID/cancellation-authorisations", + "Start the authorisation process for the cancellation of the addressed payment (updatePsuAuthentication)", + generalStartPaymentInitiationCancellationAuthorisationSummary, + json.parse("""{"psuData": {"password": "start12"}}"""), + startPaymentInitiationCancellationAuthorisationResponse, + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Payment Initiation Service (PIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(startPaymentInitiationCancellationAuthorisationAll) + ) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, + "startPaymentInitiationCancellationAuthorisationSelectPsuAuthenticationMethod", + "POST", "/PAYMENT_SERVICE/PAYMENT_PRODUCT/PAYMENT_ID/cancellation-authorisations", + "Start the authorisation process for the cancellation of the addressed payment (selectPsuAuthenticationMethod)", + generalStartPaymentInitiationCancellationAuthorisationSummary, + json.parse("""{"authenticationMethodId":""}"""), + startPaymentInitiationCancellationAuthorisationResponse, + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Payment Initiation Service (PIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(startPaymentInitiationCancellationAuthorisationAll) + ) + } + + private def initUpdatePsuDataResourceDocs(): Unit = { + // PUT /PAYMENT_SERVICE/PAYMENT_PRODUCT/PAYMENT_ID/cancellation-authorisations/AUTHORISATION_ID — 4 variants + resourceDocs += ResourceDoc( + null, implementedInApiVersion, + "updatePaymentCancellationPsuDataTransactionAuthorisation", + "PUT", "/PAYMENT_SERVICE/PAYMENT_PRODUCT/PAYMENT_ID/cancellation-authorisations/AUTHORISATION_ID", + "Update PSU Data for payment initiation cancellation (transactionAuthorisation)", + s"""${mockedDataText(false)} +This method updates PSU data on the cancellation authorisation resource if needed. +""", + json.parse("""{"scaAuthenticationData":"123"}"""), + json.parse("""{ + "scaStatus":"finalised", + "psuMessage":"Please check your SMS at a mobile device.", + "_links":{"scaStatus":"/v1.3/payments/sepa-credit-transfers/PAYMENT_ID/4f4a8b7f-9968-4183-92ab-ca512b396bfc"} + }"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Payment Initiation Service (PIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(updatePaymentCancellationPsuDataAll) + ) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, + "updatePaymentCancellationPsuDataUpdatePsuAuthentication", + "PUT", "/PAYMENT_SERVICE/PAYMENT_PRODUCT/PAYMENT_ID/cancellation-authorisations/AUTHORISATION_ID", + "Update PSU Data for payment initiation cancellation (updatePsuAuthentication)", + generalUpdatePaymentCancellationPsuDataSummary, + json.parse("""{"psuData":{"password":"start12"}}"""), + json.parse("""{ + "scaStatus": "psuAuthenticated", + "_links": {"authoriseTransaction": {"href": "/psd2/v1.3/payments/1234-wertiq-983/authorisations/123auth456"}} + }"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Payment Initiation Service (PIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(updatePaymentCancellationPsuDataAll) + ) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, + "updatePaymentCancellationPsuDataSelectPsuAuthenticationMethod", + "PUT", "/PAYMENT_SERVICE/PAYMENT_PRODUCT/PAYMENT_ID/cancellation-authorisations/AUTHORISATION_ID", + "Update PSU Data for payment initiation cancellation (selectPsuAuthenticationMethod)", + generalUpdatePaymentCancellationPsuDataSummary, + json.parse("""{"authenticationMethodId":""}"""), + json.parse("""{ + "scaStatus": "scaMethodSelected", + "chosenScaMethod": {"authenticationType": "SMS_OTP", "authenticationMethodId": "myAuthenticationID"}, + "challengeData": {"otpMaxLength": 6, "otpFormat": "integer"}, + "_links": {"authoriseTransaction": {"href": "/psd2/v1.3/payments/1234-wertiq-983/authorisations/123auth456"}} + }"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Payment Initiation Service (PIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(updatePaymentCancellationPsuDataAll) + ) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, + "updatePaymentCancellationPsuDataAuthorisationConfirmation", + "PUT", "/PAYMENT_SERVICE/PAYMENT_PRODUCT/PAYMENT_ID/cancellation-authorisations/AUTHORISATION_ID", + "Update PSU Data for payment initiation cancellation (authorisationConfirmation)", + generalUpdatePaymentCancellationPsuDataSummary, + json.parse("""{"confirmationCode":"confirmationCode"}"""), + json.parse("""{ + "scaStatus": "finalised", + "_links":{"status": {"href":"/v1.3/payments/sepa-credit-transfers/qwer3456tzui7890/status"}} + }"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Payment Initiation Service (PIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(updatePaymentCancellationPsuDataAll) + ) + + // PUT /PAYMENT_SERVICE/PAYMENT_PRODUCT/PAYMENT_ID/authorisations/AUTHORISATION_ID — 4 variants + resourceDocs += ResourceDoc( + null, implementedInApiVersion, + "updatePaymentPsuDataTransactionAuthorisation", + "PUT", "/PAYMENT_SERVICE/PAYMENT_PRODUCT/PAYMENT_ID/authorisations/AUTHORISATION_ID", + "Update PSU data for payment initiation (transactionAuthorisation)", + generalUpdatePaymentPsuDataSummary, + json.parse("""{"scaAuthenticationData":"123"}"""), + json.parse("""{ + "scaStatus": "finalised", + "psuMessage": "Please check your SMS at a mobile device.", + "_links": {"scaStatus": {"href":"/v1.3/payments/sepa-credit-transfers/88695566-6642-46d5-9985-0d824624f507"}} + }"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Payment Initiation Service (PIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(updatePaymentPsuDataAll) + ) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, + "updatePaymentPsuDataUpdatePsuAuthentication", + "PUT", "/PAYMENT_SERVICE/PAYMENT_PRODUCT/PAYMENT_ID/authorisations/AUTHORISATION_ID", + "Update PSU data for payment initiation (updatePsuAuthentication)", + generalUpdatePaymentPsuDataSummary, + json.parse("""{"psuData": {"password": "start12"}}"""), + json.parse("""{ + "scaStatus": "finalised", + "_links": {"scaStatus": {"href":"/v1.3/payments/sepa-credit-transfers/88695566-6642-46d5-9985-0d824624f507"}} + }"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Payment Initiation Service (PIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(updatePaymentPsuDataAll) + ) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, + "updatePaymentPsuDataSelectPsuAuthenticationMethod", + "PUT", "/PAYMENT_SERVICE/PAYMENT_PRODUCT/PAYMENT_ID/authorisations/AUTHORISATION_ID", + "Update PSU data for payment initiation (selectPsuAuthenticationMethod)", + generalUpdatePaymentPsuDataSummary, + json.parse("""{"authenticationMethodId":""}"""), + json.parse("""{ + "scaStatus": "scaMethodSelected", + "chosenScaMethod": {"authenticationType": "SMS_OTP", "authenticationMethodId": "myAuthenticationID"}, + "challengeData": {"otpMaxLength": 6, "otpFormat": "integer"}, + "_links": {"authoriseTransaction": {"href": "/psd2/v1.3/payments/1234-wertiq-983/authorisations/123auth456"}} + }"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Payment Initiation Service (PIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(updatePaymentPsuDataAll) + ) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, + "updatePaymentPsuDataAuthorisationConfirmation", + "PUT", "/PAYMENT_SERVICE/PAYMENT_PRODUCT/PAYMENT_ID/authorisations/AUTHORISATION_ID", + "Update PSU data for payment initiation (authorisationConfirmation)", + generalUpdatePaymentPsuDataSummary, + json.parse("""{"confirmationCode":"confirmationCode"}"""), + json.parse("""{ + "scaStatus": "finalised", + "_links":{"status": {"href":"/v1.3/payments/sepa-credit-transfers/qwer3456tzui7890/status"}} + }"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Payment Initiation Service (PIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(updatePaymentPsuDataAll) + ) + } + + // Initialise all ResourceDocs at object-construction time + initCancelAndGetResourceDocs() + initInitiatePaymentResourceDocs() + initStartAuthorisationResourceDocs() + initUpdatePsuDataResourceDocs() + + val routes: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req => + cancelPayment(req) + .orElse(getPaymentCancellationScaStatus(req)) + .orElse(getPaymentInformation(req)) + .orElse(getPaymentInitiationAuthorisation(req)) + .orElse(getPaymentInitiationCancellationAuthorisationInformation(req)) + .orElse(getPaymentInitiationScaStatus(req)) + .orElse(getPaymentInitiationStatus(req)) + .orElse(initiatePayments(req)) + .orElse(initiatePeriodicPayments(req)) + .orElse(initiateBulkPayments(req)) + .orElse(startPaymentAuthorisationAll(req)) + .orElse(startPaymentInitiationCancellationAuthorisationAll(req)) + .orElse(updatePaymentCancellationPsuDataAll(req)) + .orElse(updatePaymentPsuDataAll(req)) + } +} From bdb1386f1e8548df31e1f522481f8ea78f74862e Mon Sep 17 00:00:00 2001 From: Hongwei Date: Thu, 28 May 2026 18:17:24 +0200 Subject: [PATCH 13/14] feat(bg-v1.3): add http4s alias path-rewrite bridge (Http4sBGv13Alias) When the berlin_group_v1_3_alias_path prop is configured (e.g. "my-bank/v1.3"), Http4sBGv13Alias.wrappedRoutes intercepts requests arriving at the alias prefix, rewrites the URI path to the canonical /berlin-group/v1.3/... prefix, then delegates to Http4sBGv13.wrappedRoutes. This mirrors the Lift OBP_BERLIN_GROUP_1_3_Alias behaviour: same endpoints, same auth, different URL namespace. - Http4sBGv13Alias.resourceDocs: canonical docs re-stamped with alias implementedInApiVersion (for resource-docs endpoint discovery) - Http4sBGv13Alias.wrappedRoutes: HttpRoutes.empty when alias prop absent, path-rewriting bridge when active - Wire into Http4sApp.baseServices after Http4sBGv13.wrappedRoutes --- .../berlin/group/v1_3/Http4sBGv13Alias.scala | 85 +++++++++++++++++++ .../code/api/util/http4s/Http4sApp.scala | 1 + 2 files changed, 86 insertions(+) create mode 100644 obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13Alias.scala diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13Alias.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13Alias.scala new file mode 100644 index 0000000000..029e5a8eda --- /dev/null +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13Alias.scala @@ -0,0 +1,85 @@ +package code.api.berlin.group.v1_3 + +import cats.data.OptionT +import cats.effect._ +import code.api.berlin.group.ConstantsBG +import code.api.util.APIUtil.{ResourceDoc, berlinGroupV13AliasPath} +import code.util.Helper.MdcLoggable +import com.openbankproject.commons.util.ScannedApiVersion +import org.http4s._ + +import scala.collection.mutable.ArrayBuffer + +/** + * http4s alias bridge for Berlin Group v1.3. + * + * When the `berlin_group_v1_3_alias_path` prop is set (e.g. "my-bank/v1.3"), + * requests arriving at the alias prefix are path-rewritten to the canonical + * `/berlin-group/v1.3/...` prefix and delegated to `Http4sBGv13.wrappedRoutes`. + * This mirrors the behaviour of the Lift `OBP_BERLIN_GROUP_1_3_Alias` aggregator. + * + * When the prop is absent/empty, `wrappedRoutes` is `HttpRoutes.empty` (no-op). + * + * ResourceDocs are the same as the canonical BG v1.3 docs, re-stamped with the + * alias `implementedInApiVersion` so they appear under the alias version in the + * resource-docs endpoint. + */ +object Http4sBGv13Alias extends MdcLoggable { + + /** The alias ScannedApiVersion, matching OBP_BERLIN_GROUP_1_3_Alias.apiVersion. */ + val aliasVersion: ScannedApiVersion = + if (berlinGroupV13AliasPath.nonEmpty) + ScannedApiVersion(berlinGroupV13AliasPath.head, berlinGroupV13AliasPath.head, berlinGroupV13AliasPath.last) + else + ConstantsBG.berlinGroupVersion1 // inactive; value unused + + /** + * ResourceDocs for the alias: the canonical BG v1.3 docs with + * `implementedInApiVersion` overridden to the alias version. + * Empty when the alias is not configured. + */ + val resourceDocs: ArrayBuffer[ResourceDoc] = + if (berlinGroupV13AliasPath.nonEmpty) + Http4sBGv13.resourceDocs.map(doc => + doc.copy(implementedInApiVersion = + aliasVersion.copy(apiStandard = doc.implementedInApiVersion.apiStandard))) + else + ArrayBuffer.empty[ResourceDoc] + + // e.g. "/berlin-group/v1.3" + private val canonicalPrefixStr: String = + s"/${ConstantsBG.berlinGroupVersion1.urlPrefix}/${ConstantsBG.berlinGroupVersion1.apiShortVersion}" + + // e.g. "/my-bank-group/v1.3" (empty string when alias not configured) + private val aliasPrefixStr: String = + if (berlinGroupV13AliasPath.nonEmpty) "/" + berlinGroupV13AliasPath.mkString("/") + else "" + + /** + * Path-rewriting bridge routes. + * + * For each request whose path starts with the alias prefix: + * 1. Strip the alias prefix. + * 2. Prepend the canonical BG v1.3 prefix. + * 3. Delegate the rewritten request to `Http4sBGv13.wrappedRoutes`. + * + * Falls through (`OptionT.none`) for paths that do not start with the alias + * prefix, and is `HttpRoutes.empty` when the alias is not configured. + */ + val wrappedRoutes: HttpRoutes[IO] = + if (berlinGroupV13AliasPath.nonEmpty) { + HttpRoutes[IO] { req => + val pathStr = req.uri.path.renderString + if (pathStr.startsWith(aliasPrefixStr)) { + val remainder = pathStr.substring(aliasPrefixStr.length) // "" or "/..." + val rewrittenPath = Uri.Path.unsafeFromString(canonicalPrefixStr + remainder) + val rewrittenReq = req.withUri(req.uri.copy(path = rewrittenPath)) + Http4sBGv13.wrappedRoutes.run(rewrittenReq) + } else { + OptionT.none + } + } + } else { + HttpRoutes.empty[IO] + } +} 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 a0c68d4ee3..94a692abfb 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 @@ -127,6 +127,7 @@ object Http4sApp { .orElse(v700Routes.run(req)) .orElse(code.api.berlin.group.v2.Http4sBGv2.wrappedRoutes.run(req)) .orElse(code.api.berlin.group.v1_3.Http4sBGv13.wrappedRoutes.run(req)) + .orElse(code.api.berlin.group.v1_3.Http4sBGv13Alias.wrappedRoutes.run(req)) .orElse(v400Routes.run(req)) .orElse(v310Routes.run(req)) .orElse(v300Routes.run(req)) From 7483bf1fc0fbf96a60dfabb33e148201ee6187ea Mon Sep 17 00:00:00 2001 From: Hongwei Date: Thu, 28 May 2026 18:20:44 +0200 Subject: [PATCH 14/14] feat(bg-v1.3): retire Lift dispatch; wire catalog for BG v1.3 + alias All 55 Berlin Group v1.3 endpoints (AIS/PIS/SigningBaskets/PIIS) are now served natively by Http4sBGv13.wrappedRoutes. This commit completes the migration by: ResourceDocsAPIMethods: - Add explicit case ConstantsBG.berlinGroupVersion1 in all three match blocks (resourceDocs / versionRoutes / activeResourceDocs), mirroring the berlinGroupVersion2 pattern. BG v1.3 resource-docs are now served from Http4sBGv13.resourceDocs instead of the ScannedApis fallthrough. OBP_BERLIN_GROUP_1_3: - Set routes = Nil; remove registerRoutes call. allResourceDocs and endpoints retained for ScannedApis version-discovery. OBP_BERLIN_GROUP_1_3_Alias: - Set routes = Nil; remove conditional registerRoutes block. allResourceDocs retained; Http4sBGv13Alias.wrappedRoutes handles the alias path prefix when berlin_group_v1_3_alias_path is configured. All 72 BG v1.3 tests (AIS/PIS/PIIS/SigningBaskets) pass via the http4s path; the Lift bridge receives zero BG v1.3 requests. --- .../ResourceDocs1_4_0/ResourceDocsAPIMethods.scala | 3 +++ .../berlin/group/v1_3/OBP_BERLIN_GROUP_1_3.scala | 12 +++++------- .../group/v1_3/OBP_BERLIN_GROUP_1_3_Alias.scala | 14 ++++---------- 3 files changed, 12 insertions(+), 17 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 2cbd17a1f5..ebed51f92b 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 @@ -123,6 +123,7 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth val resourceDocs = requestedApiVersion match { case ApiVersion.v7_0_0 => code.api.v7_0_0.Http4s700.allResourceDocs // Use aggregated docs for v7.0.0 + case ConstantsBG.`berlinGroupVersion1` => code.api.berlin.group.v1_3.Http4sBGv13.resourceDocs case ConstantsBG.`berlinGroupVersion2` => code.api.berlin.group.v2.Http4sBGv2.resourceDocs case ApiVersion.v6_0_0 => OBPAPI6_0_0.allResourceDocs case ApiVersion.v5_1_0 => OBPAPI5_1_0.allResourceDocs @@ -146,6 +147,7 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth val versionRoutes = requestedApiVersion match { case ApiVersion.v7_0_0 => Nil + case ConstantsBG.`berlinGroupVersion1` => Nil case ConstantsBG.`berlinGroupVersion2` => Nil case ApiVersion.v6_0_0 => OBPAPI6_0_0.routes case ApiVersion.v5_1_0 => OBPAPI5_1_0.routes @@ -175,6 +177,7 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth // Only return the resource docs that have available routes val activeResourceDocs = requestedApiVersion match { case ApiVersion.v7_0_0 => resourceDocs + case ConstantsBG.`berlinGroupVersion1` => resourceDocs // fully on http4s — no Lift route filter case ConstantsBG.`berlinGroupVersion2` => resourceDocs case ApiVersion.v1_2_1 => resourceDocs case ApiVersion.v6_0_0 => resourceDocs // fully on http4s — no Lift route filter diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/OBP_BERLIN_GROUP_1_3.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/OBP_BERLIN_GROUP_1_3.scala index 0fea84db81..6c9f41677a 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/OBP_BERLIN_GROUP_1_3.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/OBP_BERLIN_GROUP_1_3.scala @@ -70,11 +70,9 @@ object OBP_BERLIN_GROUP_1_3 extends OBPRestHelper with MdcLoggable with ScannedA APIMethods_SigningBasketsApi.resourceDocs ++ APIMethods_CommonServicesApi.resourceDocs - // Filter the possible endpoints by the disabled / enabled Props settings and add them together - override val routes : List[OBPEndpoint] = getAllowedEndpoints(endpoints, allResourceDocs) - - // Make them available for use! - registerRoutes(routes, allResourceDocs, apiPrefix) - - logger.info(s"version $version has been run! There are ${routes.length} routes.") + // All BG v1.3 routes are now served natively by Http4sBGv13.wrappedRoutes. + // Lift dispatch is retired: routes is Nil so registerRoutes is not called. + // allResourceDocs and endpoints are retained so ScannedApis version-discovery + // (and apiVersion equality checks) still resolve correctly. + override val routes: List[OBPEndpoint] = Nil } diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/OBP_BERLIN_GROUP_1_3_Alias.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/OBP_BERLIN_GROUP_1_3_Alias.scala index 35eaab593a..43a56d7a83 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/OBP_BERLIN_GROUP_1_3_Alias.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/OBP_BERLIN_GROUP_1_3_Alias.scala @@ -51,14 +51,8 @@ object OBP_BERLIN_GROUP_1_3_Alias extends OBPRestHelper with MdcLoggable with Sc )) } else ArrayBuffer.empty[ResourceDoc] - // Filter the possible endpoints by the disabled / enabled Props settings and add them together - override val routes: List[OBPEndpoint] = if(berlinGroupV13AliasPath.nonEmpty){ - getAllowedEndpoints(OBP_BERLIN_GROUP_1_3.endpoints, allResourceDocs) - } else List.empty[OBPEndpoint] - - // Make them available for use! - if(berlinGroupV13AliasPath.nonEmpty){ - registerRoutes(routes, allResourceDocs, apiPrefix) - logger.info(s"version $apiVersion has been run! There are ${routes.length} routes.") - } + // All BG v1.3 alias routes are now served natively by Http4sBGv13Alias.wrappedRoutes. + // Lift dispatch is retired: routes is Nil so registerRoutes is not called. + // allResourceDocs is retained for ScannedApis version-discovery. + override val routes: List[OBPEndpoint] = Nil }