From 53cd143b0fdc6f562b1ceb41f3150ce442929d96 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 27 May 2026 23:59:09 +0200 Subject: [PATCH 1/8] 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 2/8] 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 3/8] 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 4/8] =?UTF-8?q?Fix:=20GET=20dynamic-endpoint=20proxy=20cal?= =?UTF-8?q?ls=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 5/8] 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 6/8] =?UTF-8?q?test:=20DynamicMessageDoc=20safety=20net=20?= =?UTF-8?q?=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 7/8] 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 8/8] 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: |