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: | 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/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/ResourceDocs1_4_0/ResourceDocsAPIMethods.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocsAPIMethods.scala index 30cce89f82..1889cbc618 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 ApiVersion.ukOpenBankingV20 => resourceDocs // fully on http4s — no Lift route filter case ApiVersion.ukOpenBankingV31 => resourceDocs // fully on http4s — no 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 f6877bb604..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._ @@ -59,21 +57,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 +213,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,8 +222,11 @@ trait APIMethodsDynamicEndpoint { } } - } } + // 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/Http4sDynamicEndpoint.scala b/obp-api/src/main/scala/code/api/dynamic/endpoint/Http4sDynamicEndpoint.scala new file mode 100644 index 0000000000..6168fa98a0 --- /dev/null +++ b/obp-api/src/main/scala/code/api/dynamic/endpoint/Http4sDynamicEndpoint.scala @@ -0,0 +1,150 @@ +/** +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.dynamic.endpoint.helper.{DynamicEndpointHelper, DynamicEndpoints} +import code.api.util.CustomJsonFormats +import code.api.util.http4s.Http4sRequestAttributes.EndpointHelpers +import code.api.util.http4s.{ErrorResponseConverter, Http4sCallContextBuilder, Http4sRequestAttributes} +import code.util.Helper.MdcLoggable +import com.openbankproject.commons.util.{ApiShortVersions, ApiStandards} +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} + +/** + * Native http4s entry point for the OBP dynamic-endpoint dispatch (under /obp/dynamic-endpoint/). + * + * Fully native — no Lift `Req`, `S.init`, `buildLiftReq` or `liftResponseToHttp4s`. Covers BOTH + * runtime pieces that the former Lift `OBPAPIDynamicEndpoint` dispatch carried: + * + * - 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. + * + * - 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 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 { + + 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 } + + /** + * 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]] = + 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(_)) + } + } + } + + /** + * 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 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 + } + 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)) + } + } + } + + /** 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 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 4be891c3f6..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,40 +49,41 @@ 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, - /** - * 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 - ) + // 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 + // (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)) - 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/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/DynamicEndpointHelper.scala b/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/DynamicEndpointHelper.scala index 0866e8e09e..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,83 +157,76 @@ 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) + * 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 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 - } + 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 + } - 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 - } + foundDynamicEndpoint + .flatMap { it => + val (serverUrl, endpointUrl, code, doc, bankId) = it - 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 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/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 cf84c192e2..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. @@ -2874,7 +2953,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). @@ -2898,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..c5c185f991 100644 --- a/obp-api/src/main/scala/code/api/util/DynamicUtil.scala +++ b/obp-api/src/main/scala/code/api/util/DynamicUtil.scala @@ -3,13 +3,13 @@ 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 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} @@ -180,6 +180,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 { @@ -209,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/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/main/scala/code/api/util/http4s/Http4sApp.scala b/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala index e6d6b81a9d..91e6241a43 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) // UK Open Banking (non-/obp prefixes /open-banking/v2.0 and /open-banking/v3.1) — native // http4s, replaces the classpath-scanned Lift ScannedApis. All endpoints (v2.0: 5, v3.1: ~67) // are migrated to http4s; the Lift ScannedApis aggregators register `routes = Nil`, so Lift @@ -140,6 +144,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)) 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. 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") + } + } } 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..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 @@ -244,5 +244,90 @@ 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) + } + + // 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") + } + } } 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))