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 1815eba815..ebed51f92b 100644 --- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocsAPIMethods.scala +++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocsAPIMethods.scala @@ -123,6 +123,7 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth val resourceDocs = requestedApiVersion match { case ApiVersion.v7_0_0 => code.api.v7_0_0.Http4s700.allResourceDocs // Use aggregated docs for v7.0.0 + case ConstantsBG.`berlinGroupVersion1` => code.api.berlin.group.v1_3.Http4sBGv13.resourceDocs case ConstantsBG.`berlinGroupVersion2` => code.api.berlin.group.v2.Http4sBGv2.resourceDocs case ApiVersion.v6_0_0 => OBPAPI6_0_0.allResourceDocs case ApiVersion.v5_1_0 => OBPAPI5_1_0.allResourceDocs @@ -146,6 +147,7 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth val versionRoutes = requestedApiVersion match { case ApiVersion.v7_0_0 => Nil + case ConstantsBG.`berlinGroupVersion1` => Nil case ConstantsBG.`berlinGroupVersion2` => Nil case ApiVersion.v6_0_0 => OBPAPI6_0_0.routes case ApiVersion.v5_1_0 => OBPAPI5_1_0.routes @@ -175,6 +177,7 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth // Only return the resource docs that have available routes val activeResourceDocs = requestedApiVersion match { case ApiVersion.v7_0_0 => resourceDocs + case ConstantsBG.`berlinGroupVersion1` => resourceDocs // fully on http4s — no Lift route filter case ConstantsBG.`berlinGroupVersion2` => resourceDocs case ApiVersion.v1_2_1 => resourceDocs case ApiVersion.v6_0_0 => resourceDocs // fully on http4s — no Lift route filter @@ -189,6 +192,7 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth case ApiVersion.v1_4_0 => resourceDocs // fully on http4s — no Lift route filter case ApiVersion.v1_3_0 => resourceDocs // fully on http4s — no Lift route filter case ApiVersion.`dynamic-entity` => resourceDocs // runtime CRUD now on Http4sDynamicEntity; routes are Nil, skip Lift-route filter + case ApiVersion.`dynamic-endpoint` => resourceDocs // dispatch now on Http4sDynamicEndpoint (proxy + native Piece C); routes carry only the stub, skip Lift-route filter case _ => resourceDocs.filter(rd => versionRoutesClasses.contains(rd.partialFunction.getClass)) } diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13.scala new file mode 100644 index 0000000000..a2d83e2737 --- /dev/null +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13.scala @@ -0,0 +1,39 @@ +package code.api.berlin.group.v1_3 + +import cats.data.{Kleisli, OptionT} +import cats.effect._ +import code.api.berlin.group.ConstantsBG +import code.api.util.APIUtil.ResourceDoc +import code.api.util.http4s.ResourceDocMiddleware +import code.util.Helper.MdcLoggable +import org.http4s._ + +import scala.collection.mutable.ArrayBuffer + +/** + * Native http4s aggregator for Berlin Group v1.3, replacing the Lift + * `OBP_BERLIN_GROUP_1_3` statelessDispatch registration. Mirrors `Http4sBGv2`. + * + * Groups: AIS / PIS / SigningBaskets / PIIS. Added incrementally. + */ +object Http4sBGv13 extends MdcLoggable { + + type HttpF[A] = OptionT[IO, A] + + val implementedInApiVersion = ConstantsBG.berlinGroupVersion1 + + val resourceDocs: ArrayBuffer[ResourceDoc] = + Http4sBGv13AIS.resourceDocs ++ + Http4sBGv13PIS.resourceDocs ++ + Http4sBGv13PIIS.resourceDocs ++ + Http4sBGv13SigningBaskets.resourceDocs + + val allRoutes: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req => + Http4sBGv13AIS.routes(req) + .orElse(Http4sBGv13PIS.routes(req)) + .orElse(Http4sBGv13PIIS.routes(req)) + .orElse(Http4sBGv13SigningBaskets.routes(req)) + } + + val wrappedRoutes: HttpRoutes[IO] = ResourceDocMiddleware.apply(resourceDocs)(allRoutes) +} diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13AIS.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13AIS.scala new file mode 100644 index 0000000000..f536dcad48 --- /dev/null +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13AIS.scala @@ -0,0 +1,1356 @@ +package code.api.berlin.group.v1_3 + +import cats.data.{Kleisli, OptionT} +import cats.effect._ +import code.api.APIFailureNewStyle +import code.api.Constant.{SYSTEM_READ_ACCOUNTS_BERLIN_GROUP_VIEW_ID, SYSTEM_READ_BALANCES_BERLIN_GROUP_VIEW_ID, SYSTEM_READ_TRANSACTIONS_BERLIN_GROUP_VIEW_ID} +import code.api.berlin.group.ConstantsBG +import code.api.berlin.group.v1_3.JSONFactory_BERLIN_GROUP_1_3._ +import code.api.berlin.group.v1_3.model._ +import code.api.berlin.group.v1_3.{BgSpecValidation, JSONFactory_BERLIN_GROUP_1_3, JvalueCaseClass} +import code.api.util.APIUtil.{EmptyBody, ResourceDoc, UserOrApplication, connectorEmptyResponse, createQueriesByHttpParams, fullBoxOrException, getHttpRequestUrlParam, getSuggestedDefaultScaMethod, mockedDataText, passesPsd2Aisp, unboxFull, unboxFullOrFail} +import code.api.util.CallContext +import code.api.util.ApiTag._ +import code.api.util.CustomJsonFormats +import code.api.util.ErrorMessages._ +import code.api.util.{ApiTag, NewStyle} +import code.api.util.newstyle.ViewNewStyle +import code.api.util.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps} +import code.consent.{ConsentStatus, Consents} +import code.context.{ConsentAuthContextProvider, UserAuthContextProvider} +import code.model +import code.model._ +import code.util.Helper +import code.util.Helper.{MdcLoggable, booleanToFuture} +import code.views.Views +import com.github.dwickern.macros.NameOf.nameOf +import com.openbankproject.commons.ExecutionContext.Implicits.global +import com.openbankproject.commons.model._ +import com.openbankproject.commons.model.enums.{ChallengeType, StrongCustomerAuthenticationStatus, SuppliedAnswerType} +import net.liftweb +import net.liftweb.common.{Empty, Full} +import net.liftweb.json +import net.liftweb.json.Formats +import org.http4s._ +import org.http4s.dsl.io._ + +import scala.collection.mutable.ArrayBuffer +import scala.concurrent.Future +import scala.language.implicitConversions + +object Http4sBGv13AIS extends MdcLoggable { + + type HttpF[A] = OptionT[IO, A] + + implicit val formats: Formats = CustomJsonFormats.formats + + protected implicit def JvalueToSuper(what: net.liftweb.json.JValue): JvalueCaseClass = JvalueCaseClass(what) + + val implementedInApiVersion = ConstantsBG.berlinGroupVersion1 + val resourceDocs = ArrayBuffer[ResourceDoc]() + + val bgV13Prefix = Root / ConstantsBG.berlinGroupVersion1.urlPrefix / ConstantsBG.berlinGroupVersion1.apiShortVersion + + private def checkAccountAccess(viewId: ViewId, u: User, account: BankAccount, callContext: Option[code.api.util.CallContext]) = { + Future { + Helper.booleanToBox(u.hasViewAccess(BankIdAccountId(account.bankId, account.accountId), viewId, callContext)) + } map { + unboxFullOrFail(_, callContext, s"$NoViewReadAccountsBerlinGroup ${viewId.value} userId : ${u.userId}. account : ${account.accountId}", 403) + } + } + + // ── POST /consents ────────────────────────────────────────────────────── + lazy val createConsent: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `bgV13Prefix` / "consents" => + EndpointHelpers.executeFutureCreated(req) { + val cc = req.callContext + val callContext = Some(cc) + val createdByUser: Option[User] = cc.user match { + case Full(user) => Some(user) + case _ => None + } + for { + _ <- passesPsd2Aisp(callContext) + failMsg = s"$InvalidJsonFormat The Json body should be the $PostConsentJson " + consentJson <- NewStyle.function.tryons(failMsg, 400, callContext) { + json.parse(cc.httpBody.getOrElse("")).extract[PostConsentJson] + } + _ <- if (consentJson.access.availableAccounts.isDefined) { + for { + _ <- booleanToFuture(failMsg = BerlinGroupConsentAccessAvailableAccounts, cc = callContext) { + consentJson.access.availableAccounts.contains("allAccounts") + } + _ <- booleanToFuture(failMsg = BerlinGroupConsentAccessRecurringIndicator, cc = callContext) { + !consentJson.recurringIndicator + } + _ <- booleanToFuture(failMsg = BerlinGroupConsentAccessFrequencyPerDay, cc = callContext) { + consentJson.frequencyPerDay == 1 + } + } yield Full(()) + } else { + booleanToFuture(failMsg = BerlinGroupConsentAccessIsEmpty, cc = callContext) { + consentJson.access.accounts.isDefined || + consentJson.access.balances.isDefined || + consentJson.access.transactions.isDefined + } + } + upperLimit = code.api.util.APIUtil.getPropsAsIntValue("berlin_group_frequency_per_day_upper_limit", 4) + _ <- booleanToFuture(failMsg = FrequencyPerDayError, cc = callContext) { + consentJson.frequencyPerDay > 0 && consentJson.frequencyPerDay <= upperLimit + } + _ <- booleanToFuture(failMsg = FrequencyPerDayMustBeOneError, cc = callContext) { + consentJson.recurringIndicator || + !consentJson.recurringIndicator && consentJson.frequencyPerDay == 1 + } + failMsg2 = BgSpecValidation.getErrorMessage(consentJson.validUntil) + validUntil = BgSpecValidation.getDate(consentJson.validUntil) + _ <- booleanToFuture(failMsg2, 400, callContext) { + failMsg2.isEmpty + } + _ <- NewStyle.function.getBankAccountsByIban(consentJson.access.accounts.getOrElse(Nil).map(_.iban.getOrElse("")), callContext) + createdConsent <- Future(Consents.consentProvider.vend.createBerlinGroupConsent( + createdByUser, + callContext.flatMap(_.consumer), + recurringIndicator = consentJson.recurringIndicator, + validUntil = validUntil, + frequencyPerDay = consentJson.frequencyPerDay, + combinedServiceIndicator = consentJson.combinedServiceIndicator.getOrElse(false), + apiStandard = Some(implementedInApiVersion.apiStandard), + apiVersion = Some(implementedInApiVersion.apiShortVersion) + )) map { + i => connectorEmptyResponse(i, callContext) + } + consentJWT <- _root_.code.api.util.Consent.createBerlinGroupConsentJWT( + createdByUser, + consentJson, + createdConsent.secret, + createdConsent.consentId, + callContext.flatMap(_.consumer).map(_.consumerId.get), + Some(validUntil), + callContext + ) map { + i => connectorEmptyResponse(i, callContext) + } + _ <- Future(Consents.consentProvider.vend.setJsonWebToken(createdConsent.consentId, consentJWT)) map { + i => connectorEmptyResponse(i, callContext) + } + } yield { + createPostConsentResponseJson(createdConsent) + } + } + } + + // ── DELETE /consents/CONSENTID ────────────────────────────────────────── + lazy val deleteConsent: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `bgV13Prefix` / "consents" / consentId => + EndpointHelpers.executeDelete(req) { cc => + val callContext = Some(cc) + for { + _ <- passesPsd2Aisp(callContext) + consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map { + unboxFullOrFail(_, callContext, ConsentNotFound, 403) + } + consumerIdFromConsent = consent.mConsumerId.get + consumerIdFromCurrentCall = callContext.map(_.consumer.map(_.consumerId.get).getOrElse("None")).getOrElse("None") + _ <- booleanToFuture(failMsg = ConsentNotFound, failCode = 403, cc = callContext) { + consumerIdFromConsent == consumerIdFromCurrentCall + } + _ <- Future(Consents.consentProvider.vend.revokeBerlinGroupConsent(consentId)) map { + i => connectorEmptyResponse(i, callContext) + } + } yield () + } + } + + // ── GET /accounts ─────────────────────────────────────────────────────── + lazy val getAccountList: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `bgV13Prefix` / "accounts" => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + val u = cc.user.openOrThrowException(AuthenticatedUserIsRequired) + for { + withBalanceParam <- NewStyle.function.tryons(s"$InvalidUrlParameters withBalance parameter can only take two values: TRUE or FALSE!", 400, callContext) { + val withBalance = getHttpRequestUrlParam(cc.url, "withBalance") + if (withBalance.isEmpty) Some(false) else Some(withBalance.toBoolean) + } + _ <- passesPsd2Aisp(callContext) + (availablePrivateAccounts, callContext) <- NewStyle.function.getAccountListOfBerlinGroup(u, callContext) + (canReadBalancesAccounts, callContext) <- NewStyle.function.getAccountCanReadBalancesOfBerlinGroup(u, callContext) + (canReadTransactionsAccounts, callContext) <- NewStyle.function.getAccountCanReadTransactionsOfBerlinGroup(u, callContext) + (accounts, callContext) <- NewStyle.function.getBankAccounts(availablePrivateAccounts, callContext) + bankAccountsFiltered = accounts.filter(bankAccount => + bankAccount.attributes.toList.flatten.find(attribute => + attribute.name.equals("CashAccountTypeCode") && + attribute.`type`.equals("STRING") && + attribute.value.equalsIgnoreCase("card") + ).isEmpty) + (balances, callContext) <- code.api.util.newstyle.BankAccountBalanceNewStyle.getBankAccountsBalances( + bankAccountsFiltered.map(_.accountId), + callContext + ) + } yield { + JSONFactory_BERLIN_GROUP_1_3.createAccountListJson( + bankAccountsFiltered, + canReadBalancesAccounts, + canReadTransactionsAccounts, + u, + withBalanceParam, + balances + ) + } + } + } + + // ── GET /accounts/ACCOUNT_ID/balances ─────────────────────────────────── + lazy val getBalances: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `bgV13Prefix` / "accounts" / accountId / "balances" => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + val u = cc.user.openOrThrowException(AuthenticatedUserIsRequired) + for { + _ <- passesPsd2Aisp(callContext) + (account: BankAccount, callContext) <- NewStyle.function.getBankAccountByAccountId(AccountId(accountId), callContext) + _ <- checkAccountAccess(ViewId(SYSTEM_READ_BALANCES_BERLIN_GROUP_VIEW_ID), u, account, callContext) + (accountBalances, callContext) <- code.api.util.newstyle.BankAccountBalanceNewStyle.getBankAccountBalances( + AccountId(accountId), + callContext + ) + } yield { + JSONFactory_BERLIN_GROUP_1_3.createAccountBalanceJSON(account, accountBalances) + } + } + } + + // ── GET /card-accounts ────────────────────────────────────────────────── + lazy val getCardAccounts: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `bgV13Prefix` / "card-accounts" => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + val u = cc.user.openOrThrowException(AuthenticatedUserIsRequired) + for { + _ <- passesPsd2Aisp(callContext) + availablePrivateAccounts <- Views.views.vend.getPrivateBankAccountsFuture(u) + (_, callContext) <- NewStyle.function.getPhysicalCardsForUser(u, callContext) + (accounts, callContext) <- NewStyle.function.getBankAccounts(availablePrivateAccounts, callContext) + (canReadBalancesAccounts, callContext) <- NewStyle.function.getAccountCanReadBalancesOfBerlinGroup(u, callContext) + (canReadTransactionsAccounts, callContext) <- NewStyle.function.getAccountCanReadTransactionsOfBerlinGroup(u, callContext) + bankAccountsFiltered = accounts.filter(bankAccount => + bankAccount.attributes.toList.flatten.find(attribute => + attribute.name.equals("CashAccountTypeCode") && + attribute.`type`.equals("STRING") && + attribute.value.equalsIgnoreCase("card") + ).isDefined) + } yield { + JSONFactory_BERLIN_GROUP_1_3.createCardAccountListJson( + bankAccountsFiltered, + canReadBalancesAccounts, + canReadTransactionsAccounts, + u + ) + } + } + } + + // ── GET /card-accounts/ACCOUNT_ID/balances ────────────────────────────── + lazy val getCardAccountBalances: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `bgV13Prefix` / "card-accounts" / accountId / "balances" => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + val u = cc.user.openOrThrowException(AuthenticatedUserIsRequired) + for { + _ <- passesPsd2Aisp(callContext) + (account: BankAccount, callContext) <- NewStyle.function.getBankAccountByAccountId(AccountId(accountId), callContext) + _ <- checkAccountAccess(ViewId(SYSTEM_READ_BALANCES_BERLIN_GROUP_VIEW_ID), u, account, callContext) + (accountBalances, callContext) <- code.api.util.newstyle.BankAccountBalanceNewStyle.getBankAccountBalances( + AccountId(accountId), + callContext + ) + } yield { + JSONFactory_BERLIN_GROUP_1_3.createCardAccountBalanceJSON(account, accountBalances) + } + } + } + + // ── GET /card-accounts/ACCOUNT_ID/transactions ────────────────────────── + lazy val getCardAccountTransactionList: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `bgV13Prefix` / "card-accounts" / accountId / "transactions" => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + val u = cc.user.openOrThrowException(AuthenticatedUserIsRequired) + for { + _ <- passesPsd2Aisp(callContext) + (bankAccount: BankAccount, callContext) <- NewStyle.function.getBankAccountByAccountId(AccountId(accountId), callContext) + (bank, callContext) <- NewStyle.function.getBank(bankAccount.bankId, callContext) + viewId = ViewId(SYSTEM_READ_TRANSACTIONS_BERLIN_GROUP_VIEW_ID) + bankIdAccountId = BankIdAccountId(bankAccount.bankId, bankAccount.accountId) + view <- ViewNewStyle.checkAccountAccessAndGetView(viewId, bankIdAccountId, Full(u), callContext) + params <- Future { createQueriesByHttpParams(callContext.get.requestHeaders)} map { + x => fullBoxOrException(x ~> APIFailureNewStyle(UnknownError, 400, callContext.map(_.toLight))) + } map { unboxFull(_) } + (transactions, callContext) <- code.model.toBankAccountExtended(bankAccount).getModeratedTransactionsFuture(bank, Full(u), view, callContext, params) map { + x => fullBoxOrException(x ~> APIFailureNewStyle(UnknownError, 400, callContext.map(_.toLight))) + } map { unboxFull(_) } + } yield { + JSONFactory_BERLIN_GROUP_1_3.createCardTransactionsJson(bankAccount, transactions) + } + } + } + + // ── GET /consents/CONSENTID/authorisations ────────────────────────────── + lazy val getConsentAuthorisation: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `bgV13Prefix` / "consents" / consentId / "authorisations" => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + for { + _ <- passesPsd2Aisp(callContext) + (challenges, callContext) <- NewStyle.function.getChallengesByConsentId(consentId, callContext) + } yield { + JSONFactory_BERLIN_GROUP_1_3.AuthorisationJsonV13(challenges.map(_.challengeId)) + } + } + } + + // ── GET /consents/CONSENTID ───────────────────────────────────────────── + lazy val getConsentInformation: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `bgV13Prefix` / "consents" / consentId => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + for { + _ <- passesPsd2Aisp(callContext) + consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map { + unboxFullOrFail(_, callContext, s"$ConsentNotFound ($consentId)") + } + consumerIdFromConsent = consent.mConsumerId.get + consumerIdFromCurrentCall = callContext.map(_.consumer.map(_.consumerId.get).getOrElse("None")).getOrElse("None") + _ <- booleanToFuture(failMsg = ConsentNotFound, failCode = 403, cc = callContext) { + consumerIdFromConsent == consumerIdFromCurrentCall + } + } yield { + createGetConsentResponseJson(consent) + } + } + } + + // ── GET /consents/CONSENTID/authorisations/AUTHORISATIONID ───────────── + lazy val getConsentScaStatus: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `bgV13Prefix` / "consents" / consentId / "authorisations" / authorisationId => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + for { + _ <- passesPsd2Aisp(callContext) + _ <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map { + unboxFullOrFail(_, callContext, s"$ConsentNotFound ($consentId)", 403) + } + (challenges, callContext) <- NewStyle.function.getChallengesByConsentId(consentId, callContext) + } yield { + val challengeStatus = challenges.filter(_.challengeId == authorisationId) + .flatMap(_.scaStatus).headOption.map(_.toString).getOrElse("None") + JSONFactory_BERLIN_GROUP_1_3.ScaStatusJsonV13(challengeStatus) + } + } + } + + // ── GET /consents/CONSENTID/status ────────────────────────────────────── + lazy val getConsentStatus: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `bgV13Prefix` / "consents" / consentId / "status" => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + for { + _ <- passesPsd2Aisp(callContext) + consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map { + unboxFullOrFail(_, callContext, ConsentNotFound, 403) + } + } yield { + JSONFactory_BERLIN_GROUP_1_3.ConsentStatusJsonV13(consent.status) + } + } + } + + // ── GET /accounts/ACCOUNT_ID/transactions/TRANSACTIONID ───────────────── + lazy val getTransactionDetails: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `bgV13Prefix` / "accounts" / accountId / "transactions" / transactionId => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + val user = cc.user.openOrThrowException(AuthenticatedUserIsRequired) + for { + (account: BankAccount, callContext) <- NewStyle.function.getBankAccountByAccountId(AccountId(accountId), callContext) + viewId = ViewId(SYSTEM_READ_TRANSACTIONS_BERLIN_GROUP_VIEW_ID) + bankIdAccountId = BankIdAccountId(account.bankId, account.accountId) + view <- ViewNewStyle.checkAccountAccessAndGetView(viewId, bankIdAccountId, Full(user), callContext) + (moderatedTransaction, callContext) <- account.moderatedTransactionFuture(TransactionId(transactionId), view, Some(user), callContext) map { + unboxFullOrFail(_, callContext, GetTransactionsException) + } + } yield { + JSONFactory_BERLIN_GROUP_1_3.createTransactionJson(account, moderatedTransaction) + } + } + } + + // ── GET /accounts/ACCOUNT_ID/transactions ─────────────────────────────── + lazy val getTransactionList: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `bgV13Prefix` / "accounts" / accountId / "transactions" => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + val u = cc.user.openOrThrowException(AuthenticatedUserIsRequired) + for { + _ <- passesPsd2Aisp(callContext) + (bankAccount: BankAccount, callContext) <- NewStyle.function.getBankAccountByAccountId(AccountId(accountId), callContext) + (bank, callContext) <- NewStyle.function.getBank(bankAccount.bankId, callContext) + viewId = ViewId(SYSTEM_READ_TRANSACTIONS_BERLIN_GROUP_VIEW_ID) + bankIdAccountId = BankIdAccountId(bankAccount.bankId, bankAccount.accountId) + view <- ViewNewStyle.checkAccountAccessAndGetView(viewId, bankIdAccountId, Full(u), callContext) + params <- Future { createQueriesByHttpParams(callContext.get.requestHeaders)} map { + x => fullBoxOrException(x ~> APIFailureNewStyle(UnknownError, 400, callContext.map(_.toLight))) + } map { unboxFull(_) } + bookingStatus = getHttpRequestUrlParam(cc.url, "bookingStatus") + _ <- booleanToFuture(s"$InvalidUrlParameters bookingStatus parameter must take two one of those values : booked, pending or both!", 400, callContext) { + bookingStatus match { + case "booked" | "pending" | "both" => true + case _ => false + } + } + (transactions, callContext) <- bankAccount.getModeratedTransactionsFuture(bank, Full(u), view, callContext, params) map { + x => fullBoxOrException(x ~> APIFailureNewStyle(UnknownError, 400, callContext.map(_.toLight))) + } map { unboxFull(_) } + } yield { + JSONFactory_BERLIN_GROUP_1_3.createTransactionsJson(bankAccount, transactions, bookingStatus) + } + } + } + + // ── GET /accounts/ACCOUNT_ID ──────────────────────────────────────────── + lazy val getAccountDetails: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `bgV13Prefix` / "accounts" / accountId => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + val u = cc.user.openOrThrowException(AuthenticatedUserIsRequired) + for { + withBalanceParam <- NewStyle.function.tryons(s"$InvalidUrlParameters withBalance parameter can only take two values: TRUE or FALSE!", 400, callContext) { + val withBalance = getHttpRequestUrlParam(cc.url, "withBalance") + if (withBalance.isEmpty) Some(false) else Some(withBalance.toBoolean) + } + _ <- passesPsd2Aisp(callContext) + (account: BankAccount, callContext) <- NewStyle.function.getBankAccountByAccountId(AccountId(accountId), callContext) + (canReadBalancesAccounts, callContext) <- NewStyle.function.getAccountCanReadBalancesOfBerlinGroup(u, callContext) + (canReadTransactionsAccounts, callContext) <- NewStyle.function.getAccountCanReadTransactionsOfBerlinGroup(u, callContext) + _ <- checkAccountAccess(ViewId(SYSTEM_READ_ACCOUNTS_BERLIN_GROUP_VIEW_ID), u, account, callContext) + (accountBalances, callContext) <- code.api.util.newstyle.BankAccountBalanceNewStyle.getBankAccountBalances( + AccountId(accountId), + callContext + ) + } yield { + JSONFactory_BERLIN_GROUP_1_3.createAccountDetailsJson( + account, + canReadBalancesAccounts, + canReadTransactionsAccounts, + withBalanceParam, + accountBalances, + u + ) + } + } + } + + // ── GET /card-accounts/ACCOUNT_ID ─────────────────────────────────────── + lazy val readCardAccount: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `bgV13Prefix` / "card-accounts" / accountId => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + val u = cc.user.openOrThrowException(AuthenticatedUserIsRequired) + for { + _ <- passesPsd2Aisp(callContext) + (account: BankAccount, callContext) <- NewStyle.function.getBankAccountByAccountId(AccountId(accountId), callContext) + (canReadBalancesAccounts, callContext) <- NewStyle.function.getAccountCanReadBalancesOfBerlinGroup(u, callContext) + (canReadTransactionsAccounts, callContext) <- NewStyle.function.getAccountCanReadTransactionsOfBerlinGroup(u, callContext) + _ <- checkAccountAccess(ViewId(SYSTEM_READ_ACCOUNTS_BERLIN_GROUP_VIEW_ID), u, account, callContext) + withBalanceParam <- NewStyle.function.tryons(s"$InvalidUrlParameters withBalance parameter can only take two values: TRUE or FALSE!", 400, callContext) { + val withBalance = getHttpRequestUrlParam(cc.url, "withBalance") + if (withBalance.isEmpty) Some(false) else Some(withBalance.toBoolean) + } + (accountBalances, callContext) <- code.api.util.newstyle.BankAccountBalanceNewStyle.getBankAccountBalances( + AccountId(accountId), + callContext + ) + } yield { + JSONFactory_BERLIN_GROUP_1_3.createCardAccountDetailsJson( + account, + canReadBalancesAccounts, + canReadTransactionsAccounts, + withBalanceParam, + accountBalances, + u + ) + } + } + } + + // ── POST /consents/CONSENTID/authorisations (3 body-guard variants) ───── + lazy val startConsentAuthorisationAll: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `bgV13Prefix` / "consents" / consentId / "authorisations" => + EndpointHelpers.executeFutureCreated(req) { + val cc = req.callContext + val callContext = Some(cc) + val u = cc.user.openOrThrowException(AuthenticatedUserIsRequired) + val parsedJson = scala.util.Try(json.parse(cc.httpBody.getOrElse(""))).getOrElse(json.JNothing) + if (checkTransactionAuthorisation(parsedJson)) { + for { + _ <- passesPsd2Aisp(callContext) + consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map { + unboxFullOrFail(_, callContext, ConsentNotFound, 403) + } + (challenges, callContext) <- NewStyle.function.createChallengesC2( + List(u.userId), + ChallengeType.BERLIN_GROUP_CONSENT_CHALLENGE, + None, + getSuggestedDefaultScaMethod(), + Some(StrongCustomerAuthenticationStatus.received), + Some(consentId), + None, + callContext + ) + challenge <- NewStyle.function.tryons(InvalidConnectorResponseForCreateChallenge, 400, callContext) { + challenges.head + } + } yield { + createStartConsentAuthorisationJson(consent, challenge) + } + } else { + // mocked for updatePsuAuthentication and selectPsuAuthenticationMethod variants + Future.successful(liftweb.json.parse( + """{ + "scaStatus": "received", + "psuMessage": "Please use your BankApp for transaction Authorisation.", + "authorisationId": "123auth456.", + "_links": + { + "scaStatus": {"href":"/v1.3/consents/qwer3456tzui7890/authorisations/123auth456"} + } + }""")) + } + } + } + + // ── PUT /consents/CONSENTID/authorisations/AUTHORISATIONID (4 variants) ─ + lazy val updateConsentsPsuDataAll: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `bgV13Prefix` / "consents" / consentId / "authorisations" / authorisationId => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + val u = cc.user.openOrThrowException(AuthenticatedUserIsRequired) + val parsedJson = scala.util.Try(json.parse(cc.httpBody.getOrElse(""))).getOrElse(json.JNothing) + if (checkTransactionAuthorisation(parsedJson)) { + for { + _ <- passesPsd2Aisp(callContext) + _ <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map { + unboxFullOrFail(_, callContext, ConsentNotFound, 403) + } + failMsg = s"$InvalidJsonFormat The Json body should be the $TransactionAuthorisation " + updateJson <- NewStyle.function.tryons(failMsg, 400, callContext) { + parsedJson.extract[TransactionAuthorisation] + } + (_, callContext) <- NewStyle.function.getChallenge(authorisationId, callContext) + (challenge, callContext) <- NewStyle.function.validateChallengeAnswerC4( + ChallengeType.BERLIN_GROUP_CONSENT_CHALLENGE, + None, + Some(consentId), + authorisationId, + updateJson.scaAuthenticationData, + SuppliedAnswerType.PLAIN_TEXT_VALUE, + callContext + ) + consent <- challenge.scaStatus match { + case Some(status) if status == StrongCustomerAuthenticationStatus.finalised => + Future(Consents.consentProvider.vend.updateConsentStatus(consentId, ConsentStatus.valid)) + case Some(status) if status == StrongCustomerAuthenticationStatus.failed => + Future(Consents.consentProvider.vend.updateConsentStatus(consentId, ConsentStatus.rejected)) + case _ => + Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) + } + _ <- NewStyle.function.tryons(ConsentUpdateStatusError, 400, callContext) { + consent.toList.size == 1 + } + _ <- Future { + val authContexts = UserAuthContextProvider.userAuthContextProvider.vend.getUserAuthContextsBox(u.userId) + .map(_.map(i => BasicUserAuthContext(i.key, i.value))) + ConsentAuthContextProvider.consentAuthContextProvider.vend.createOrUpdateConsentAuthContexts(consentId, authContexts.getOrElse(Nil)) + } map { + unboxFullOrFail(_, callContext, ConsentUserAuthContextCannotBeAdded) + } + _ <- Future(Consents.consentProvider.vend.updateConsentUser(consentId, u)) map { + unboxFullOrFail(_, callContext, ConsentUserCannotBeAdded) + } + } yield { + createPutConsentResponseJson(consent.toList.head) + } + } else if (checkUpdatePsuAuthentication(parsedJson)) { + Future.successful(liftweb.json.parse( + """{ + | "scaStatus": "psuAuthenticated", + | "_links": { + | "authoriseTransaction": {"href": "/psd2/v1/payments/1234-wertiq-983/authorisations/123auth456"} + | } + |}""".stripMargin)) + } else if (checkSelectPsuAuthenticationMethod(parsedJson)) { + Future.successful(liftweb.json.parse( + """{ + | "scaStatus": "scaMethodSelected", + | "chosenScaMethod": { + | "authenticationType": "SMS_OTP", + | "authenticationMethodId": "myAuthenticationID"}, + | "challengeData": { + | "otpMaxLength": 6, + | "otpFormat": "integer"}, + | "_links": { + | "authoriseTransaction": {"href": "/psd2/v1/payments/1234-wertiq-983/authorisations/123auth456"} + | } + |}""".stripMargin)) + } else { + // authorisationConfirmation variant + Future.successful(liftweb.json.parse( + """{ + | "scaStatus": "finalised", + | "_links":{ + | "status": {"href":"/v1/payments/sepa-credit-transfers/qwer3456tzui7890/status"} + | } + |}""".stripMargin)) + } + } + } + + private def initConsentResourceDocs(): Unit = { + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(createConsent), + "POST", + "/consents", + "Create consent", + s"""${mockedDataText(false)} +This method create a consent resource, defining access rights to dedicated accounts of +a given PSU-ID. These accounts are addressed explicitly in the method as +parameters as a core function. + +**Side Effects** +When this Consent Request is a request where the "recurringIndicator" equals "true", +and if it exists already a former consent for recurring access on account information +for the addressed PSU, then the former consent automatically expires as soon as the new +consent request is authorised by the PSU. + +Optional Extension: +As an option, an ASPSP might optionally accept a specific access right on the access on all psd2 related services for all available accounts. + +As another option an ASPSP might optionally also accept a command, where only access rights are inserted without mentioning the addressed account. +The relation to accounts is then handled afterwards between PSU and ASPSP. +This option is not supported for the Embedded SCA Approach. +As a last option, an ASPSP might in addition accept a command with access rights + * to see the list of available payment accounts or + * to see the list of available payment accounts with balances. + +frequencyPerDay: + This field indicates the requested maximum frequency for an access without PSU involvement per day. + For a one-off access, this attribute is set to "1". + The frequency needs to be greater equal to one. + If not otherwise agreed bilaterally between TPP and ASPSP, the frequency is less equal to 4. +recurringIndicator: + "true", if the consent is for recurring access to the account data. + "false", if the consent is for one access to the account data. +""", + PostConsentJson( + access = ConsentAccessJson( + accounts = Option(List(ConsentAccessAccountsJson( + iban = Some(code.api.util.ExampleValue.ibanExample.value), + bban = None, + pan = None, + maskedPan = None, + msisdn = None, + currency = None, + ))), + balances = None, + transactions = None, + availableAccounts = None, + allPsd2 = None + ), + recurringIndicator = true, + validUntil = "2020-12-31", + frequencyPerDay = 4, + combinedServiceIndicator = Some(false) + ), + PostConsentResponseJson( + consentId = "1234-wertiq-983", + consentStatus = "received", + _links = ConsentLinksV13(Some(Href("/v1.3/consents/1234-wertiq-983/authorisations"))) + ), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + authMode = UserOrApplication, + http4sPartialFunction = Some(createConsent) + ) + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(deleteConsent), + "DELETE", + "/consents/CONSENTID", + "Delete Consent", + s"""${mockedDataText(false)} + The TPP can delete an account information consent object if needed.""", + EmptyBody, + EmptyBody, + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + authMode = UserOrApplication, + http4sPartialFunction = Some(deleteConsent) + ) + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getConsentInformation), + "GET", + "/consents/CONSENTID", + "Get Consent Request", + s"""${mockedDataText(false)} +Returns the content of an account information consent object. +This is returning the data for the TPP especially in cases, +where the consent was directly managed between ASPSP and PSU e.g. in a re-direct SCA Approach. +""", + EmptyBody, + json.parse("""{ + "access": { + "accounts": [ + { + "bban": "BARC12345612345678", + "maskedPan": "123456xxxxxx1234", + "iban": "FR7612345987650123456789014", + "currency": "EUR", + "msisdn": "+49 170 1234567", + "pan": "5409050000000000" + } + ] + }, + "recurringIndicator": false, + "validUntil": "2020-12-31", + "frequencyPerDay": 4, + "combinedServiceIndicator": false, + "lastActionDate": "2019-06-30", + "consentStatus": "received" + }"""), + List(AuthenticatedUserIsRequired, ConsentNotFound, UnknownError), + ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + authMode = UserOrApplication, + http4sPartialFunction = Some(getConsentInformation) + ) + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getConsentStatus), + "GET", + "/consents/CONSENTID/status", + "Consent status request", + s"""${mockedDataText(false)} + Read the status of an account information consent resource.""", + EmptyBody, + json.parse("""{ + "consentStatus": "received" + }"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + authMode = UserOrApplication, + http4sPartialFunction = Some(getConsentStatus) + ) + + val generalStartConsentAuthorisationSummary = + s"""${mockedDataText(false)} +Create an authorisation sub-resource and start the authorisation process of a consent. +The message might in addition transmit authentication and authorisation related data. +his method is iterated n times for a n times SCA authorisation in a corporate context, +each creating an own authorisation sub-endpoint for the corresponding PSU authorising the consent. +The ASPSP might make the usage of this access method unnecessary, since the related authorisation +resource will be automatically created by the ASPSP after the submission of the consent data with the +first POST consents call. The start authorisation process is a process which is needed for creating +a new authorisation or cancellation sub-resource. + +This applies in the following scenarios: * The ASPSP has indicated with an 'startAuthorisation' hyperlink +in the preceding Payment Initiation Response that an explicit start of the authorisation process is needed by the TPP. +The 'startAuthorisation' hyperlink can transport more information about data which needs to be uploaded by using +the extended forms. +* 'startAuthorisationWithPsuIdentfication', +* 'startAuthorisationWithPsuAuthentication' +* 'startAuthorisationWithEncryptedPsuAuthentication' +* 'startAuthorisationWithAuthentciationMethodSelection' +* The related payment initiation cannot yet be executed since a multilevel SCA is mandated. +* The ASPSP has indicated with an 'startAuthorisation' hyperlink in the preceding Payment Cancellation +Response that an explicit start of the authorisation process is needed by the TPP. + +The 'startAuthorisation' hyperlink can transport more information about data which needs to be uploaded by +using the extended forms as indicated above. +* The related payment cancellation request cannot be applied yet since a multilevel SCA is mandate for executing the cancellation. +* The signing basket needs to be authorised yet. + +""" + + val startConsentAuthorisationResponse = json.parse("""{ + "scaStatus": "received", + "psuMessage": "Please use your BankApp for transaction Authorisation.", + "authorisationId": "123auth456.", + "_links": + { + "scaStatus": {"href":"/v1.3/consents/qwer3456tzui7890/authorisations/123auth456"} + } + }""") + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + "startConsentAuthorisationTransactionAuthorisation", + "POST", + "/consents/CONSENTID/authorisations", + "Start the authorisation process for a consent(transactionAuthorisation)", + generalStartConsentAuthorisationSummary, + json.parse("""{"scaAuthenticationData":""}"""), + startConsentAuthorisationResponse, + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(startConsentAuthorisationAll) + ) + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + "startConsentAuthorisationUpdatePsuAuthentication", + "POST", + "/consents/CONSENTID/authorisations", + "Start the authorisation process for a consent(updatePsuAuthentication)", + generalStartConsentAuthorisationSummary, + json.parse("""{"psuData": {"password": "start12"}}"""), + startConsentAuthorisationResponse, + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(startConsentAuthorisationAll) + ) + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + "startConsentAuthorisationSelectPsuAuthenticationMethod", + "POST", + "/consents/CONSENTID/authorisations", + "Start the authorisation process for a consent(selectPsuAuthenticationMethod)", + generalStartConsentAuthorisationSummary, + json.parse("""{"authenticationMethodId":""}"""), + startConsentAuthorisationResponse, + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(startConsentAuthorisationAll) + ) + + val generalUpdateConsentsPsuDataSummary = + s"""${mockedDataText(false)} +This method update PSU data on the consents resource if needed. It may authorise a consent within the Embedded +SCA Approach where needed. Independently from the SCA Approach it supports +e.g. the selection of the authentication method and a non-SCA PSU authentication. +This methods updates PSU data on the cancellation authorisation resource if needed. +There are several possible Update PSU Data requests in the context of a consent request if needed, +which depends on the SCA approach: * Redirect SCA Approach: A specific Update PSU Data Request is applicable +for +* the selection of authentication methods, before choosing the actual SCA approach. +* Decoupled SCA Approach: A specific Update PSU Data Request is only applicable for +* adding the PSU Identification, if not provided yet in the Payment Initiation Request or the Account Information Consent Request, +or if no OAuth2 access token is used, or +* the selection of authentication methods. +* Embedded SCA Approach: The Update PSU Data Request might be used +* to add credentials as a first factor authentication data of the PSU and +* to select the authentication method and +* transaction authorisation. +The SCA Approach might depend on the chosen SCA method. For that reason, +the following possible Update PSU Data request can apply to all SCA approaches: +* Select an SCA method in case of several SCA methods are available for the customer. There are the following request types on this access path: +* Update PSU Identification * Update PSU Authentication +* Select PSU Autorization Method WARNING: This method need a reduced header, therefore many optional elements are not present. +Maybe in a later version the access path will change. +* Transaction Authorisation WARNING: This method need a reduced header, therefore many optional elements are not present. +Maybe in a later version the access path will change. + + """ + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + "updateConsentsPsuDataTransactionAuthorisation", + "PUT", + "/consents/CONSENTID/authorisations/AUTHORISATIONID", + "Update PSU Data for consents (transactionAuthorisation)", + generalUpdateConsentsPsuDataSummary, + json.parse("""{"scaAuthenticationData":"123"}"""), + ScaStatusResponse( + scaStatus = "received", + _links = Some(LinksAll(scaStatus = Some(HrefType(Some(s"/v1.3/consents/1234-wertiq-983/authorisations"))))) + ), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(updateConsentsPsuDataAll) + ) + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + "updateConsentsPsuDataUpdatePsuAuthentication", + "PUT", + "/consents/CONSENTID/authorisations/AUTHORISATIONID", + "Update PSU Data for consents (updatePsuAuthentication)", + generalUpdateConsentsPsuDataSummary, + json.parse("""{"psuData": {"password": "start12"}}""".stripMargin), + json.parse("""{ + | "scaStatus": "psuAuthenticated", + | "_links": { + | "authoriseTransaction": {"href": "/psd2/v1/payments/1234-wertiq-983/authorisations/123auth456"} + | } + | }""".stripMargin), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(updateConsentsPsuDataAll) + ) + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + "updateConsentsPsuDataUpdateSelectPsuAuthenticationMethod", + "PUT", + "/consents/CONSENTID/authorisations/AUTHORISATIONID", + "Update PSU Data for consents (selectPsuAuthenticationMethod)", + generalUpdateConsentsPsuDataSummary, + json.parse("""{ + | "authenticationMethodId": "myAuthenticationID" + |}""".stripMargin), + json.parse("""{ + | "scaStatus": "scaMethodSelected", + | "chosenScaMethod": { + | "authenticationType": "SMS_OTP", + | "authenticationMethodId": "myAuthenticationID"}, + | "challengeData": { + | "otpMaxLength": 6, + | "otpFormat": "integer"}, + | "_links": { + | "authoriseTransaction": {"href": "/psd2/v1/payments/1234-wertiq-983/authorisations/123auth456"} + | } + | }""".stripMargin), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(updateConsentsPsuDataAll) + ) + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + "updateConsentsPsuDataUpdateAuthorisationConfirmation", + "PUT", + "/consents/CONSENTID/authorisations/AUTHORISATIONID", + "Update PSU Data for consents (authorisationConfirmation)", + generalUpdateConsentsPsuDataSummary, + json.parse("""{"confirmationCode":"confirmationCode"}"""), + json.parse("""{ + | "scaStatus": "finalised", + | "_links":{ + | "status": {"href":"/v1/payments/sepa-credit-transfers/qwer3456tzui7890/status"} + | } + | }""".stripMargin), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(updateConsentsPsuDataAll) + ) + } + + private def initAccountResourceDocs(): Unit = { + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getAccountList), + "GET", + "/accounts", + "Read Account List", + s"""${mockedDataText(false)} +Read the identifiers of the available payment account together with +booking balance information, depending on the consent granted. + +It is assumed that a consent of the PSU to this access is already given and stored on the ASPSP system. +The addressed list of accounts depends then on the PSU ID and the stored consent addressed by consentId, +respectively the OAuth2 access token. + +Returns all identifiers of the accounts, to which an account access has been granted to through +the /consents endpoint by the PSU. +In addition, relevant information about the accounts and hyperlinks to corresponding account +information resources are provided if a related consent has been already granted. + +Remark: Note that the /consents endpoint optionally offers to grant an access on all available +payment accounts of a PSU. +In this case, this endpoint will deliver the information about all available payment accounts +of the PSU at this ASPSP. +""", + EmptyBody, + json.parse("""{ + | "accounts": [ + | { + | "resourceId": "3dc3d5b3-7023-4848-9853-f5400a64e80f", + | "iban": "DE2310010010123456789", + | "currency": "EUR", + | "product": "Girokonto", + | "cashAccountType": "CACC", + | "name": "Main Account", + | "_links": { + | "balances": { + | "href": "/v1/accounts/3dc3d5b3-7023-4848-9853-f5400a64e80f/balances" + | } + | } + | } + | ] + |}""".stripMargin), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(getAccountList) + ) + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getBalances), + "GET", + "/accounts/ACCOUNT_ID/balances", + "Read Balance", + s"""${mockedDataText(false)} +Reads account data from a given account addressed by "account-id". + +**Remark:** This account-id can be a tokenised identification due to data protection reason since the path +information might be logged on intermediary servers within the ASPSP sphere. +This account-id then can be retrieved by the "GET Account List" call. + +The account-id is constant at least throughout the lifecycle of a given consent. +""", + EmptyBody, + json.parse("""{ + "account":{ + "iban":"DE91 1000 0000 0123 4567 89" + }, + "balances":[{ + "balanceAmount":{ + "currency":"EUR", + "amount":"50.89" + }, + "balanceType":"AC", + "lastChangeDateTime":"yyyy-MM-dd'T'HH:mm:ss.SSSZ", + "lastCommittedTransaction":"String", + "referenceDate":"2018-03-08" + }] +} +"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(getBalances) + ) + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getCardAccounts), + "GET", + "/card-accounts", + "Reads a list of card accounts", + s"""${mockedDataText(false)} +Reads a list of card accounts with additional information, e.g. balance information. +It is assumed that a consent of the PSU to this access is already given and stored on the ASPSP system. +The addressed list of card accounts depends then on the PSU ID and the stored consent addressed by consentId, +respectively the OAuth2 access token. +""", + EmptyBody, + json.parse("""{ + "cardAccounts": [ + { + "resourceId": "3d9a81b3-a47d-4130-8765-a9c0ff861b99", + "maskedPan": "525412******3241", + "currency": "EUR", + "name": "Main", + "product": "Basic Credit", + "status": "enabled", + "creditLimit": { + "currency": "EUR", + "amount": 15000 + }, + "_links": { + "balances": { + "href": "/v1/card-accounts/3d9a81b3-a47d-4130-8765-a9c0ff861b99/balances" + } + } + } + ] +}"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Account Information Service (AIS)") :: apiTagMockedData :: Nil, + http4sPartialFunction = Some(getCardAccounts) + ) + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getCardAccountBalances), + "GET", + "/card-accounts/ACCOUNT_ID/balances", + "Read card account balances", + s"""${mockedDataText(false)} +Reads balance data from a given card account addressed by +"account-id". + +Remark: This account-id can be a tokenised identification due +to data protection reason since the path information might be +logged on intermediary servers within the ASPSP sphere. +This account-id then can be retrieved by the +"GET Card Account List" call +""", + EmptyBody, + json.parse("""{ + "cardAccount":{ + "iban":"DE91 1000 0000 0123 4567 89" + }, + "balances":[{ + "balanceAmount":{ + "currency":"EUR", + "amount":"50.89" + }, + "balanceType":"AC", + "lastChangeDateTime":"yyyy-MM-dd'T'HH:mm:ss.SSSZ", + "lastCommittedTransaction":"String", + "referenceDate":"2018-03-08" + }] +}"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Account Information Service (AIS)") :: Nil, + http4sPartialFunction = Some(getCardAccountBalances) + ) + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getCardAccountTransactionList), + "GET", + "/card-accounts/ACCOUNT_ID/transactions", + "Read transaction list of a card account", + s"""${mockedDataText(false)} +Reads account data from a given card account addressed by "account-id". +""", + EmptyBody, + json.parse("""{ + "cardAccount": { + "maskedPan": "525412******3241" + }, + "transactions": { + "booked": [], + "_links": { + "cardAccount": { + "href": "/v1.3/card-accounts/3d9a81b3-a47d-4130-8765-a9c0ff861b99" + } + } + } + }"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(getCardAccountTransactionList) + ) + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getConsentAuthorisation), + "GET", + "/consents/CONSENTID/authorisations", + "Get Consent Authorisation Sub-Resources Request", + s"""${mockedDataText(false)} +Return a list of all authorisation subresources IDs which have been created. + +This function returns an array of hyperlinks to all generated authorisation sub-resources. +""", + EmptyBody, + json.parse("""{ + "authorisationIds" : "faa3657e-13f0-4feb-a6c3-34bf21a9ae8e" +}"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(getConsentAuthorisation) + ) + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getConsentScaStatus), + "GET", + "/consents/CONSENTID/authorisations/AUTHORISATIONID", + "Read the SCA status of the consent authorisation", + s"""${mockedDataText(false)} +This method returns the SCA status of a consent initiation's authorisation sub-resource. +""", + EmptyBody, + json.parse("""{ + "scaStatus" : "started" +}"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(getConsentScaStatus) + ) + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getTransactionDetails), + "GET", + "/accounts/ACCOUNT_ID/transactions/TRANSACTIONID", + "Read Transaction Details", + s"""${mockedDataText(false)} +Reads transaction details from a given transaction addressed by "transactionId" on a given account addressed +by "account-id". This call is only available on transactions as reported in a JSON format. + +**Remark:** Please note that the PATH might be already given in detail by the corresponding entry of the response +of the "Read Transaction List" call within the _links subfield. + + """, + EmptyBody, + json.parse("""{ + "description": "Example for transaction details", + "value": { + "transactionsDetails": { + "transactionId": "1234567", + "creditorName": "John Miles", + "transactionAmount": { + "currency": "EUR", + "amount": "-256.67" + }, + "bookingDate": "2017-10-25", + "valueDate": "2017-10-26" + } + } +}"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Account Information Service (AIS)") :: Nil, + http4sPartialFunction = Some(getTransactionDetails) + ) + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getTransactionList), + "GET", + "/accounts/ACCOUNT_ID/transactions", + "Read transaction list of an account", + s"""${mockedDataText(false)} +Read transaction reports or transaction lists of a given account addressed by "account-id", +depending on the steering parameter "bookingStatus" together with balances. +For a given account, additional parameters are e.g. the attributes "dateFrom" and "dateTo". +The ASPSP might add balance information, if transaction lists without balances are not supported. """, + EmptyBody, + json.parse("""{ + "account": { + "iban": "DE2310010010123456788" + }, + "transactions": { + "booked": [], + "_links": { + "account": { + "href": "/v1.3/accounts/3dc3d5b3-7023-4848-9853-f5400a64e80f" + } + } + } + }"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(getTransactionList) + ) + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getAccountDetails), + "GET", + "/accounts/ACCOUNT_ID", + "Read Account Details", + s"""${mockedDataText(false)} +Reads details about an account, with balances where required. +It is assumed that a consent of the PSU to this access is already given and stored on the ASPSP system. +The addressed details of this account depends then on the stored consent addressed by consentId, +respectively the OAuth2 access token. **NOTE:** The account-id can represent a multicurrency account. +In this case the currency code is set to "XXX". Give detailed information about the addressed account. +Give detailed information about the addressed account together with balance information + + """, + EmptyBody, + json.parse("""{ + "account": { + "resourceId": "3dc3d5b3-7023-4848-9853-f5400a64e80f", + "iban": "FR7612345987650123456789014", + "currency": "EUR", + "product": "Girokonto", + "cashAccountType": "CACC", + "name": "Main Account", + "_links": { + "balances": { + "href": "/v1/accounts/3dc3d5b3-7023-4848-9853-f5400a64e80f/balances" + } + } + } +}"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(getAccountDetails) + ) + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(readCardAccount), + "GET", + "/card-accounts/ACCOUNT_ID", + "Reads details about a card account", + s"""${mockedDataText(false)} +Reads details about a card account. +It is assumed that a consent of the PSU to this access is already given and stored on the ASPSP system. +The addressed details of this account depends then on the stored consent addressed by consentId, +respectively the OAuth2 access token. +""", + EmptyBody, + json.parse("""{ + | "cardAccount": { + | "resourceId": "3d9a81b3-a47d-4130-8765-a9c0ff861b99", + | "maskedPan": "525412******3241", + | "currency": "EUR", + | "name": "Main", + | "product": "Basic Credit", + | "status": "enabled" + | } + |}""".stripMargin), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Account Information Service (AIS)") :: Nil, + http4sPartialFunction = Some(readCardAccount) + ) + } + + initConsentResourceDocs() + initAccountResourceDocs() + + val routes: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req => + createConsent(req) + .orElse(deleteConsent(req)) + .orElse(getAccountList(req)) + .orElse(getBalances(req)) + .orElse(getCardAccounts(req)) + .orElse(getCardAccountBalances(req)) + .orElse(getCardAccountTransactionList(req)) + .orElse(getConsentAuthorisation(req)) + .orElse(getConsentInformation(req)) + .orElse(getConsentScaStatus(req)) + .orElse(getConsentStatus(req)) + .orElse(getTransactionDetails(req)) + .orElse(getTransactionList(req)) + .orElse(getAccountDetails(req)) + .orElse(readCardAccount(req)) + .orElse(startConsentAuthorisationAll(req)) + .orElse(updateConsentsPsuDataAll(req)) + } +} diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13Alias.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13Alias.scala new file mode 100644 index 0000000000..029e5a8eda --- /dev/null +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13Alias.scala @@ -0,0 +1,85 @@ +package code.api.berlin.group.v1_3 + +import cats.data.OptionT +import cats.effect._ +import code.api.berlin.group.ConstantsBG +import code.api.util.APIUtil.{ResourceDoc, berlinGroupV13AliasPath} +import code.util.Helper.MdcLoggable +import com.openbankproject.commons.util.ScannedApiVersion +import org.http4s._ + +import scala.collection.mutable.ArrayBuffer + +/** + * http4s alias bridge for Berlin Group v1.3. + * + * When the `berlin_group_v1_3_alias_path` prop is set (e.g. "my-bank/v1.3"), + * requests arriving at the alias prefix are path-rewritten to the canonical + * `/berlin-group/v1.3/...` prefix and delegated to `Http4sBGv13.wrappedRoutes`. + * This mirrors the behaviour of the Lift `OBP_BERLIN_GROUP_1_3_Alias` aggregator. + * + * When the prop is absent/empty, `wrappedRoutes` is `HttpRoutes.empty` (no-op). + * + * ResourceDocs are the same as the canonical BG v1.3 docs, re-stamped with the + * alias `implementedInApiVersion` so they appear under the alias version in the + * resource-docs endpoint. + */ +object Http4sBGv13Alias extends MdcLoggable { + + /** The alias ScannedApiVersion, matching OBP_BERLIN_GROUP_1_3_Alias.apiVersion. */ + val aliasVersion: ScannedApiVersion = + if (berlinGroupV13AliasPath.nonEmpty) + ScannedApiVersion(berlinGroupV13AliasPath.head, berlinGroupV13AliasPath.head, berlinGroupV13AliasPath.last) + else + ConstantsBG.berlinGroupVersion1 // inactive; value unused + + /** + * ResourceDocs for the alias: the canonical BG v1.3 docs with + * `implementedInApiVersion` overridden to the alias version. + * Empty when the alias is not configured. + */ + val resourceDocs: ArrayBuffer[ResourceDoc] = + if (berlinGroupV13AliasPath.nonEmpty) + Http4sBGv13.resourceDocs.map(doc => + doc.copy(implementedInApiVersion = + aliasVersion.copy(apiStandard = doc.implementedInApiVersion.apiStandard))) + else + ArrayBuffer.empty[ResourceDoc] + + // e.g. "/berlin-group/v1.3" + private val canonicalPrefixStr: String = + s"/${ConstantsBG.berlinGroupVersion1.urlPrefix}/${ConstantsBG.berlinGroupVersion1.apiShortVersion}" + + // e.g. "/my-bank-group/v1.3" (empty string when alias not configured) + private val aliasPrefixStr: String = + if (berlinGroupV13AliasPath.nonEmpty) "/" + berlinGroupV13AliasPath.mkString("/") + else "" + + /** + * Path-rewriting bridge routes. + * + * For each request whose path starts with the alias prefix: + * 1. Strip the alias prefix. + * 2. Prepend the canonical BG v1.3 prefix. + * 3. Delegate the rewritten request to `Http4sBGv13.wrappedRoutes`. + * + * Falls through (`OptionT.none`) for paths that do not start with the alias + * prefix, and is `HttpRoutes.empty` when the alias is not configured. + */ + val wrappedRoutes: HttpRoutes[IO] = + if (berlinGroupV13AliasPath.nonEmpty) { + HttpRoutes[IO] { req => + val pathStr = req.uri.path.renderString + if (pathStr.startsWith(aliasPrefixStr)) { + val remainder = pathStr.substring(aliasPrefixStr.length) // "" or "/..." + val rewrittenPath = Uri.Path.unsafeFromString(canonicalPrefixStr + remainder) + val rewrittenReq = req.withUri(req.uri.copy(path = rewrittenPath)) + Http4sBGv13.wrappedRoutes.run(rewrittenReq) + } else { + OptionT.none + } + } + } else { + HttpRoutes.empty[IO] + } +} diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13PIIS.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13PIIS.scala new file mode 100644 index 0000000000..3af72af80c --- /dev/null +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13PIIS.scala @@ -0,0 +1,122 @@ +package code.api.berlin.group.v1_3 + +import cats.data.{Kleisli, OptionT} +import cats.effect._ +import code.api.berlin.group.ConstantsBG +import code.api.berlin.group.v1_3.JSONFactory_BERLIN_GROUP_1_3._ +import code.api.util.APIUtil._ +import code.api.util.ApiTag._ +import code.api.util.ErrorMessages._ +import code.api.util.CustomJsonFormats +import code.api.util.{ApiTag, NewStyle} +import code.api.util.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps} +import code.fx.fx +import code.util.Helper +import code.util.Helper.MdcLoggable +import com.github.dwickern.macros.NameOf.nameOf +import com.openbankproject.commons.ExecutionContext.Implicits.global +import net.liftweb.json +import net.liftweb.json.Formats +import org.http4s._ +import org.http4s.dsl.io._ + +import scala.collection.mutable.ArrayBuffer +import scala.language.implicitConversions + +object Http4sBGv13PIIS extends MdcLoggable { + + type HttpF[A] = OptionT[IO, A] + + implicit val formats: Formats = CustomJsonFormats.formats + + // ResourceDoc example bodies are written as `json.parse(...)` (JValue); ResourceDoc requires + // scala.Product, so wrap via the same implicit the Lift builder used (JvalueCaseClass is a Product). + protected implicit def JvalueToSuper(what: net.liftweb.json.JValue): JvalueCaseClass = JvalueCaseClass(what) + + val implementedInApiVersion = ConstantsBG.berlinGroupVersion1 + val resourceDocs = ArrayBuffer[ResourceDoc]() + + val bgV13Prefix = Root / ConstantsBG.berlinGroupVersion1.urlPrefix / ConstantsBG.berlinGroupVersion1.apiShortVersion + + // ── POST /funds-confirmations ───────────────────────────────────── + // Lift source: code.api.builder.ConfirmationOfFundsServicePIISApi.checkAvailabilityOfFunds + // Auth: authenticatedAccess → authMode UserOnly (default); user resolved by ResourceDocMiddleware. + val checkAvailabilityOfFunds: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `bgV13Prefix` / "funds-confirmations" => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + for { + _ <- passesPsd2Aisp(callContext) + checkAvailabilityOfFundsJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $CheckAvailabilityOfFundsJson ", 400, callContext) { + json.parse(cc.httpBody.getOrElse("")).extract[CheckAvailabilityOfFundsJson] + } + + requestAccountAmount <- NewStyle.function.tryons(s"$InvalidNumber Current input is ${checkAvailabilityOfFundsJson.instructedAmount.amount} ", 400, callContext) { + BigDecimal(checkAvailabilityOfFundsJson.instructedAmount.amount) + } + + requestAccountCurrency = checkAvailabilityOfFundsJson.instructedAmount.currency + + _ <- Helper.booleanToFuture(s"${InvalidISOCurrencyCode} Current input is: '${requestAccountCurrency}'", cc = callContext) { + isValidCurrencyISOCode(requestAccountCurrency) + } + + requestAccountIban = checkAvailabilityOfFundsJson.account.iban + (bankAccount, callContext) <- NewStyle.function.getBankAccountByIban(requestAccountIban, callContext) + currentAccountCurrency = bankAccount.currency + currentAccountBalance = bankAccount.balance + + // From change from requestAccount Currency to currentBankAccount Currency + rate = fx.exchangeRate(requestAccountCurrency, currentAccountCurrency, Some(bankAccount.bankId.value), callContext) + + _ <- Helper.booleanToFuture(s"$InvalidCurrency The requested currency conversion (${requestAccountCurrency} to ${currentAccountCurrency}) is not supported.", cc = callContext) { + rate.isDefined + } + + requestChangedCurrencyAmount = fx.convert(requestAccountAmount, rate) + + fundsAvailable = (currentAccountBalance >= requestChangedCurrencyAmount) + + } yield { + net.liftweb.json.parse(s"""{ + "fundsAvailable" : $fundsAvailable + }""") + } + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(checkAvailabilityOfFunds), + "POST", + "/funds-confirmations", + "Confirmation of Funds Request", + s""" ${mockedDataText(false)} +Creates a confirmation of funds request at the ASPSP. Checks whether a specific amount is available at point +of time of the request on an account linked to a given tuple card issuer(TPP)/card number, or addressed by +IBAN and TPP respectively. If the related extended services are used a conditional Consent-ID is contained +in the header. This field is contained but commented out in this specification. """, + json.parse( + """{ + "instructedAmount" : { + "amount" : "123", + "currency" : "EUR" + }, + "account" : { + "iban" : "GR12 1234 5123 4511 3981 4475 477", + } + }"""), + json.parse( + """{ + "fundsAvailable" : true + }"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Confirmation of Funds Service (PIIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(checkAvailabilityOfFunds) + ) + + val routes: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req => + checkAvailabilityOfFunds(req) + } +} diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13PIS.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13PIS.scala new file mode 100644 index 0000000000..43993fa87d --- /dev/null +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13PIS.scala @@ -0,0 +1,1257 @@ +package code.api.berlin.group.v1_3 + +import cats.data.{Kleisli, OptionT} +import cats.effect._ +import code.api.berlin.group.ConstantsBG +import code.api.berlin.group.v1_3.JSONFactory_BERLIN_GROUP_1_3._ +import code.api.berlin.group.v1_3.model.TransactionStatus.mapTransactionStatus +import code.api.berlin.group.v1_3.model._ +import code.api.util.APIUtil.{EmptyBody, ResourceDoc, UserOrApplication, getScaMethodAtInstance, getServerUrl, isValidCurrencyISOCode, mockedDataText, passesPsd2Pisp} +import code.api.util.ApiTag._ +import code.api.util.ErrorMessages._ +import code.api.util.CustomJsonFormats +import code.api.util.{ApiTag, CallContext, NewStyle} +import code.api.util.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps} +import code.api.util.http4s.{ErrorResponseConverter, RequestScopeConnection} +import code.fx.fx +import code.util.Helper +import code.util.Helper.{MdcLoggable, booleanToFuture} +import com.github.dwickern.macros.NameOf.nameOf +import com.openbankproject.commons.ExecutionContext.Implicits.global +import com.openbankproject.commons.model._ +import com.openbankproject.commons.model.enums.TransactionRequestStatus._ +import com.openbankproject.commons.model.enums.TransactionRequestTypes._ +import com.openbankproject.commons.model.enums.{ChallengeType, PaymentServiceTypes, StrongCustomerAuthenticationStatus, SuppliedAnswerType, TransactionRequestStatus, TransactionRequestTypes} +import net.liftweb.common.Box.tryo +import net.liftweb.common.Full +import net.liftweb.json +import net.liftweb.json.Formats +import net.liftweb.json.JsonAST.prettyRender +import net.liftweb.json.{Extraction => LiftExtraction} +import org.http4s._ +import org.http4s.dsl.io._ + +import scala.collection.mutable.ArrayBuffer +import scala.concurrent.Future +import scala.language.implicitConversions + +/** + * Native http4s aggregator for Berlin Group v1.3 – Payment Initiation Service (PIS). + * Ports all 24 PIS endpoints from code.api.builder.PaymentInitiationServicePISApi. + * Route handlers declared as lazy val (avoids 64KB limit for large objects). + */ +object Http4sBGv13PIS extends MdcLoggable { + + type HttpF[A] = OptionT[IO, A] + + implicit val formats: Formats = CustomJsonFormats.formats + + protected implicit def JvalueToSuper(what: net.liftweb.json.JValue): JvalueCaseClass = JvalueCaseClass(what) + + val implementedInApiVersion = ConstantsBG.berlinGroupVersion1 + val resourceDocs = ArrayBuffer[ResourceDoc]() + + val bgV13Prefix: Path = + Root / ConstantsBG.berlinGroupVersion1.urlPrefix / ConstantsBG.berlinGroupVersion1.apiShortVersion + + // ── private helpers (ported from APIMethods_PaymentInitiationServicePISApi) ─ + + private def checkPaymentServerTypeError(paymentService: String) = { + s"${InvalidTransactionRequestType.replaceAll("TRANSACTION_REQUEST_TYPE", "PAYMENT_SERVICE in the URL.")}: '${paymentService}'.It should be `payments` or `periodic-payments` for now, will support `bulk-payments` soon" + } + + private def checkPaymentProductError(paymentProduct: String) = + s"${InvalidTransactionRequestType.replaceAll("TRANSACTION_REQUEST_TYPE", "PAYMENT_PRODUCT in the URL.")}: '${paymentProduct}'.It should be `sepa-credit-transfers`for now, will support (instant-sepa-credit-transfers, target-2-payments, cross-border-credit-transfers) soon." + + private def checkPaymentServiceType(paymentService: String) = tryo { + PaymentServiceTypes.withName(paymentService.replaceAll("-", "_")) + }.isDefined + + /** + * Shared business logic for all three initiate-payment variants (payments / periodic-payments / + * bulk-payments). Mirrors `initiatePaymentImplementation` from the Lift builder; auth is handled + * by middleware (authMode = UserOrApplication), so no inline applicationAccess call. + */ + private def initiatePaymentImpl( + paymentService: String, + paymentProduct: String, + callContext: Option[CallContext] + ): Future[net.liftweb.json.JValue] = { + val u = callContext.flatMap(_.user.toOption) + val rawBody = callContext.flatMap(_.httpBody).getOrElse("") + val bodyJson = scala.util.Try(json.parse(rawBody)).getOrElse(json.JNothing) + for { + _ <- passesPsd2Pisp(callContext) + paymentServiceType <- NewStyle.function.tryons(checkPaymentServerTypeError(paymentService), 404, callContext) { + PaymentServiceTypes.withName(paymentService.replaceAll("-", "_")) + } + transactionRequestType <- NewStyle.function.tryons(checkPaymentProductError(paymentProduct), 404, callContext) { + TransactionRequestTypes.withName(paymentProduct.replaceAll("-", "_").toUpperCase) + } + sepaCreditTransfersBerlinGroupV13 <- if (paymentServiceType.equals(PaymentServiceTypes.payments)) { + NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $SepaCreditTransfersBerlinGroupV13 ", 400, callContext) { + bodyJson.extract[SepaCreditTransfersBerlinGroupV13] + } + } else if (paymentServiceType.equals(PaymentServiceTypes.periodic_payments)) { + NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $PeriodicSepaCreditTransfersBerlinGroupV13 ", 400, callContext) { + bodyJson.extract[PeriodicSepaCreditTransfersBerlinGroupV13] + } + } else { + Future { throw new RuntimeException(checkPaymentServerTypeError(paymentServiceType.toString)) } + } + isValidAmountNumber <- NewStyle.function.tryons(s"$InvalidNumber Current input is ${sepaCreditTransfersBerlinGroupV13.instructedAmount.amount} ", 400, callContext) { + BigDecimal(sepaCreditTransfersBerlinGroupV13.instructedAmount.amount) + } + _ <- Helper.booleanToFuture(s"${NotPositiveAmount} Current input is: '${isValidAmountNumber}'", cc = callContext) { + isValidAmountNumber > BigDecimal("0") + } + _ <- Helper.booleanToFuture(s"${InvalidISOCurrencyCode} Current input is: '${sepaCreditTransfersBerlinGroupV13.instructedAmount.currency}'", cc = callContext) { + isValidCurrencyISOCode(sepaCreditTransfersBerlinGroupV13.instructedAmount.currency) + } + _ <- NewStyle.function.isEnabledTransactionRequests(callContext) + (createdTransactionRequest, _) <- transactionRequestType match { + case TransactionRequestTypes.SEPA_CREDIT_TRANSFERS => + NewStyle.function.createTransactionRequestBGV1( + initiator = u, + paymentServiceType, + transactionRequestType, + transactionRequestBody = sepaCreditTransfersBerlinGroupV13, + callContext + ) + } + } yield { + LiftExtraction.decompose(JSONFactory_BERLIN_GROUP_1_3.createTransactionRequestJson(createdTransactionRequest)) + } + } + + // ── DELETE /{paymentService}/{paymentProduct}/{paymentId} ────────────────────────────────────────────────────── + // Variable response: 202 (SCA required) with CancelPaymentResponseJson body, or 204 (direct cancel) with no body. + // Custom IO handler to produce truly-empty 204 (NoContent) — executeFutureWithStatus would always add a body. + lazy val cancelPayment: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `bgV13Prefix` / paymentService / paymentProduct / paymentId => + implicit val cc: CallContext = req.callContext + val callContext = Some(cc) + RequestScopeConnection.fromFuture { + val u = cc.user.openOrThrowException(AuthenticatedUserIsRequired) + for { + _ <- passesPsd2Pisp(callContext) + _ <- NewStyle.function.tryons(checkPaymentServerTypeError(paymentService), 404, callContext) { + PaymentServiceTypes.withName(paymentService.replaceAll("-", "_")) + } + transactionRequestTypes <- NewStyle.function.tryons(checkPaymentProductError(paymentProduct), 404, callContext) { + TransactionRequestTypes.withName(paymentProduct.replaceAll("-", "_").toUpperCase) + } + (transactionRequest, _) <- NewStyle.function.getTransactionRequestImpl(TransactionRequestId(paymentId), callContext) + transactionRequestBody <- NewStyle.function.tryons(s"${UnknownError} No data for Payment Body ", 400, callContext) { + transactionRequest.body.to_sepa_credit_transfers.get + } + fromAccountIban = transactionRequestBody.debtorAccount.iban + toAccountIban = transactionRequestBody.creditorAccount.iban + (_, _) <- NewStyle.function.getBankAccountByIban(fromAccountIban, callContext) + (ibanChecker, _) <- NewStyle.function.validateAndCheckIbanNumber(toAccountIban, callContext) + _ <- Helper.booleanToFuture(invalidIban, cc = callContext) { ibanChecker.isValid == true } + (_, _) <- NewStyle.function.getToBankAccountByIban(toAccountIban, callContext) + currentStatus = transactionRequest.status.toUpperCase() + mappedStatus = mapTransactionStatus(currentStatus) + (canBeCancelled, _, startSca) <- transactionRequestTypes match { + case TransactionRequestTypes.SEPA_CREDIT_TRANSFERS => + currentStatus match { + case TransactionStatus.RCVD.code | "INITIATED" => + NewStyle.function.saveTransactionRequestStatusImpl(transactionRequest.id, CANCELLED.toString, callContext) + Future.successful((true, callContext, Some(false))) + case TransactionStatus.ACCP.code | "COMPLETED" => + NewStyle.function.cancelPaymentV400(TransactionId(transactionRequest.transaction_ids), callContext) map { x => + x._1 match { + case CancelPayment(true, Some(startSca)) if startSca => + NewStyle.function.saveTransactionRequestStatusImpl(transactionRequest.id, CANCELLATION_PENDING.toString, callContext) + (true, x._2, Some(startSca)) + case CancelPayment(true, Some(startSca)) if !startSca => + NewStyle.function.saveTransactionRequestStatusImpl(transactionRequest.id, CANCELLED.toString, callContext) + (true, x._2, Some(startSca)) + case CancelPayment(false, _) => + (false, x._2, Some(false)) + } + } + case TransactionStatus.PDNG.code | "PENDING" => + NewStyle.function.cancelPaymentV400(TransactionId(transactionRequest.transaction_ids), callContext) map { x => + x._1 match { + case CancelPayment(true, Some(startSca)) if startSca => + NewStyle.function.saveTransactionRequestStatusImpl(transactionRequest.id, CANCELLATION_PENDING.toString, callContext) + (true, x._2, Some(startSca)) + case CancelPayment(true, Some(startSca)) if !startSca => + NewStyle.function.saveTransactionRequestStatusImpl(transactionRequest.id, CANCELLED.toString, callContext) + (true, x._2, Some(startSca)) + case CancelPayment(false, _) => + (false, x._2, Some(false)) + } + } + case TransactionStatus.CANC.code | "CANCELLED" => + Future.successful((true, callContext, Some(false))) + case _ => + Future.successful((false, callContext, Some(false))) + } + } + _ <- Helper.booleanToFuture( + failMsg = s"$TransactionRequestCannotBeCancelled Payment status: $mappedStatus. Only payments in RCVD, ACCP, PDNG, or CANC status can be cancelled.", + cc = callContext + ) { canBeCancelled == true } + (updatedTransactionRequest, _) <- NewStyle.function.getTransactionRequestImpl(TransactionRequestId(paymentId), callContext) + } yield { + startSca.getOrElse(false) match { + case true => Some(createCancellationTransactionRequestJson(updatedTransactionRequest)) + case false => None + } + } + }.attempt.flatMap { + case Right(Some(cancelJson)) => + Accepted(prettyRender(LiftExtraction.decompose(cancelJson))) + case Right(None) => + NoContent() + case Left(err) => + ErrorResponseConverter.toHttp4sResponse(err, cc) + } + } + + // ── GET /{paymentService}/{paymentProduct}/{paymentId}/cancellation-authorisations/{cancellationId} ─── + lazy val getPaymentCancellationScaStatus: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `bgV13Prefix` / paymentService / paymentProduct / paymentId / "cancellation-authorisations" / cancellationId => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + for { + _ <- passesPsd2Pisp(callContext) + _ <- NewStyle.function.tryons(checkPaymentServerTypeError(paymentService), 404, callContext) { + PaymentServiceTypes.withName(paymentService.replaceAll("-", "_")) + } + _ <- NewStyle.function.tryons(checkPaymentProductError(paymentProduct), 404, callContext) { + TransactionRequestTypes.withName(paymentProduct.replaceAll("-", "_").toUpperCase) + } + (_, _) <- NewStyle.function.getTransactionRequestImpl(TransactionRequestId(paymentId), callContext) + (challenge, _) <- NewStyle.function.getChallenge(cancellationId, callContext) + } yield { + JSONFactory_BERLIN_GROUP_1_3.ScaStatusJsonV13(challenge.scaStatus.map(_.toString).getOrElse("None")) + } + } + } + + // ── GET /{paymentService}/{paymentProduct}/{paymentId} (with checkPaymentServiceType guard in Lift) ── + lazy val getPaymentInformation: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `bgV13Prefix` / paymentService / paymentProduct / paymentId if checkPaymentServiceType(paymentService) => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + for { + _ <- passesPsd2Pisp(callContext) + _ <- NewStyle.function.tryons(checkPaymentServerTypeError(paymentService), 404, callContext) { + PaymentServiceTypes.withName(paymentService.replaceAll("-", "_")) + } + _ <- NewStyle.function.tryons(checkPaymentProductError(paymentProduct), 404, callContext) { + TransactionRequestTypes.withName(paymentProduct.replaceAll("-", "_").toUpperCase) + } + (transactionRequest, _) <- NewStyle.function.getTransactionRequestImpl(TransactionRequestId(paymentId), callContext) + transactionRequestBody <- NewStyle.function.tryons(s"${UnknownError} No data for Payment Body ", 400, callContext) { + transactionRequest.body.to_sepa_credit_transfers.get + } + } yield { + transactionRequestBody + } + } + } + + // ── GET /{paymentService}/{paymentProduct}/{paymentId}/authorisations ───────────────────────────── + lazy val getPaymentInitiationAuthorisation: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `bgV13Prefix` / paymentService / paymentProduct / paymentId / "authorisations" => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + for { + _ <- passesPsd2Pisp(callContext) + _ <- NewStyle.function.tryons(checkPaymentServerTypeError(paymentService), 404, callContext) { + PaymentServiceTypes.withName(paymentService.replaceAll("-", "_")) + } + _ <- NewStyle.function.tryons(checkPaymentProductError(paymentProduct), 404, callContext) { + TransactionRequestTypes.withName(paymentProduct.replaceAll("-", "_").toUpperCase) + } + (_, _) <- NewStyle.function.getTransactionRequestImpl(TransactionRequestId(paymentId), callContext) + (challenges, _) <- NewStyle.function.getChallengesByTransactionRequestId(paymentId, callContext) + } yield { + JSONFactory_BERLIN_GROUP_1_3.createStartPaymentAuthorisationsJson(challenges) + } + } + } + + // ── GET /{paymentService}/{paymentProduct}/{paymentId}/cancellation-authorisations ────────────────── + lazy val getPaymentInitiationCancellationAuthorisationInformation: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `bgV13Prefix` / paymentService / paymentProduct / paymentId / "cancellation-authorisations" => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + for { + _ <- passesPsd2Pisp(callContext) + _ <- NewStyle.function.tryons(checkPaymentServerTypeError(paymentService), 404, callContext) { + PaymentServiceTypes.withName(paymentService.replaceAll("-", "_")) + } + _ <- NewStyle.function.tryons(checkPaymentProductError(paymentProduct), 404, callContext) { + TransactionRequestTypes.withName(paymentProduct.replaceAll("-", "_").toUpperCase) + } + (challenges, _) <- NewStyle.function.getChallengesByTransactionRequestId(paymentId, callContext) + } yield { + JSONFactory_BERLIN_GROUP_1_3.CancellationJsonV13(challenges.map(_.challengeId)) + } + } + } + + // ── GET /{paymentService}/{paymentProduct}/{paymentId}/authorisations/{authorisationId} ──────────── + lazy val getPaymentInitiationScaStatus: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `bgV13Prefix` / paymentService / paymentProduct / paymentId / "authorisations" / authorisationId => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + for { + _ <- passesPsd2Pisp(callContext) + _ <- NewStyle.function.tryons(checkPaymentServerTypeError(paymentService), 404, callContext) { + PaymentServiceTypes.withName(paymentService.replaceAll("-", "_")) + } + _ <- NewStyle.function.tryons(checkPaymentProductError(paymentProduct), 404, callContext) { + TransactionRequestTypes.withName(paymentProduct.replaceAll("-", "_").toUpperCase) + } + (_, _) <- NewStyle.function.getTransactionRequestImpl(TransactionRequestId(paymentId), callContext) + (challenge, _) <- NewStyle.function.getChallenge(authorisationId, callContext) + } yield { + json.parse(s"""{"scaStatus" : "${challenge.scaStatus.getOrElse("None")}"}""") + } + } + } + + // ── GET /{paymentService}/{paymentProduct}/{paymentId}/status ───────────────────────────────────── + lazy val getPaymentInitiationStatus: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `bgV13Prefix` / paymentService / paymentProduct / paymentId / "status" => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + import net.liftweb.json.JsonDSL._ + for { + _ <- passesPsd2Pisp(callContext) + _ <- NewStyle.function.tryons(checkPaymentServerTypeError(paymentService), 404, callContext) { + PaymentServiceTypes.withName(paymentService.replaceAll("-", "_")) + } + _ <- NewStyle.function.tryons(checkPaymentProductError(paymentProduct), 404, callContext) { + TransactionRequestTypes.withName(paymentProduct.replaceAll("-", "_").toUpperCase) + } + (transactionRequest, _) <- NewStyle.function.getTransactionRequestImpl(TransactionRequestId(paymentId), callContext) + transactionRequestStatus = mapTransactionStatus(transactionRequest.status) + transactionRequestAmount <- NewStyle.function.tryons(s"${InvalidNumber} transaction request amount cannot convert to a Decimal", 400, callContext) { + BigDecimal(transactionRequest.body.to_sepa_credit_transfers.get.instructedAmount.amount) + } + transactionRequestCurrency <- NewStyle.function.tryons(s"${InvalidCurrency} can not get currency from this paymentId(${paymentId})", 400, callContext) { + transactionRequest.body.to_sepa_credit_transfers.get.instructedAmount.currency + } + transactionRequestFromAccount = transactionRequest.from + (fromAccount, _) <- NewStyle.function.checkBankAccountExists( + BankId(transactionRequestFromAccount.bank_id), + AccountId(transactionRequestFromAccount.account_id), + callContext + ) + fromAccountBalance = fromAccount.balance + fromAccountCurrency = fromAccount.currency + rate = fx.exchangeRate(transactionRequestCurrency, fromAccountCurrency, None, callContext) + _ <- Helper.booleanToFuture(s"$InvalidCurrency The requested currency conversion (${transactionRequestCurrency} to ${fromAccountCurrency}) is not supported.", cc = callContext) { + rate.isDefined + } + requestChangedCurrencyAmount = fx.convert(transactionRequestAmount, rate) + fundsAvailable = (fromAccountBalance >= requestChangedCurrencyAmount) + transactionRequestStatusCheckedFunds = if (fundsAvailable) transactionRequestStatus else TransactionStatus.RCVD.code + } yield { + ("transactionStatus" -> transactionRequestStatusCheckedFunds) ~ + ("fundsAvailable" -> fundsAvailable) + } + } + } + + // ── POST /payments/{paymentProduct} ────────────────────────────────────────────────────────────── + // Auth: applicationAccess in Lift → authMode = UserOrApplication in ResourceDoc + lazy val initiatePayments: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `bgV13Prefix` / "payments" / paymentProduct => + EndpointHelpers.executeFutureCreated(req) { + initiatePaymentImpl("payments", paymentProduct, Some(req.callContext)) + } + } + + // ── POST /periodic-payments/{paymentProduct} ────────────────────────────────────────────────────── + lazy val initiatePeriodicPayments: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `bgV13Prefix` / "periodic-payments" / paymentProduct => + EndpointHelpers.executeFutureCreated(req) { + initiatePaymentImpl("periodic-payments", paymentProduct, Some(req.callContext)) + } + } + + // ── POST /bulk-payments/{paymentProduct} ────────────────────────────────────────────────────────── + lazy val initiateBulkPayments: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `bgV13Prefix` / "bulk-payments" / paymentProduct => + EndpointHelpers.executeFutureCreated(req) { + initiatePaymentImpl("bulk-payments", paymentProduct, Some(req.callContext)) + } + } + + // ── POST /{paymentService}/{paymentProduct}/{paymentId}/authorisations (3 body-guard variants) ─── + // + // Dispatches on the request body: + // scaAuthenticationData → startPaymentAuthorisationTransactionAuthorisation (real SCA logic) + // psuData → startPaymentAuthorisationUpdatePsuAuthentication (mocked) + // authenticationMethodId → startPaymentAuthorisationSelectPsuAuthenticationMethod (mocked) + lazy val startPaymentAuthorisationAll: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `bgV13Prefix` / paymentService / paymentProduct / paymentId / "authorisations" => + EndpointHelpers.executeFutureCreated(req) { + val cc = req.callContext + val callContext = Some(cc) + val u = cc.user.openOrThrowException(AuthenticatedUserIsRequired) + val parsedJson = scala.util.Try(json.parse(cc.httpBody.getOrElse(""))).getOrElse(json.JNothing) + if (checkTransactionAuthorisation(parsedJson)) { + for { + _ <- passesPsd2Pisp(callContext) + _ <- NewStyle.function.tryons(checkPaymentServerTypeError(paymentService), 404, callContext) { + PaymentServiceTypes.withName(paymentService.replaceAll("-", "_")) + } + _ <- NewStyle.function.tryons(checkPaymentProductError(paymentProduct), 404, callContext) { + TransactionRequestTypes.withName(paymentProduct.replaceAll("-", "_").toUpperCase) + } + (_, _) <- NewStyle.function.getTransactionRequestImpl(TransactionRequestId(paymentId), callContext) + (challenges, _) <- NewStyle.function.createChallengesC2( + List(u.userId), + ChallengeType.BERLIN_GROUP_PAYMENT_CHALLENGE, + Some(paymentId), + getScaMethodAtInstance(SEPA_CREDIT_TRANSFERS.toString).toOption, + Some(StrongCustomerAuthenticationStatus.received), + None, + None, + callContext + ) + challenge <- NewStyle.function.tryons(InvalidConnectorResponseForCreateChallenge, 400, callContext) { + challenges.head + } + } yield { + JSONFactory_BERLIN_GROUP_1_3.createStartPaymentAuthorisationJson(challenge) + } + } else { + // Mocked response for updatePsuAuthentication and selectPsuAuthenticationMethod variants + Future.successful(json.parse( + """{ + "challengeData": { + "scaStatus": "received", + "authorisationId": "88695566-6642-46d5-9985-0d824624f507", + "psuMessage": "Please check your SMS at a mobile device.", + "_links": { + "scaStatus": "/v1.3/payments/sepa-credit-transfers/88695566-6642-46d5-9985-0d824624f507" + } + } + }""")) + } + } + } + + // ── POST /{paymentService}/{paymentProduct}/{paymentId}/cancellation-authorisations (3 variants) ─ + lazy val startPaymentInitiationCancellationAuthorisationAll: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `bgV13Prefix` / paymentService / paymentProduct / paymentId / "cancellation-authorisations" => + EndpointHelpers.executeFutureCreated(req) { + val cc = req.callContext + val callContext = Some(cc) + val u = cc.user.openOrThrowException(AuthenticatedUserIsRequired) + val parsedJson = scala.util.Try(json.parse(cc.httpBody.getOrElse(""))).getOrElse(json.JNothing) + if (checkTransactionAuthorisation(parsedJson)) { + for { + _ <- passesPsd2Pisp(callContext) + _ <- NewStyle.function.tryons(checkPaymentServerTypeError(paymentService), 404, callContext) { + PaymentServiceTypes.withName(paymentService.replaceAll("-", "_")) + } + _ <- NewStyle.function.tryons(checkPaymentProductError(paymentProduct), 404, callContext) { + TransactionRequestTypes.withName(paymentProduct.replaceAll("-", "_").toUpperCase) + } + (transactionRequest, _) <- NewStyle.function.getTransactionRequestImpl(TransactionRequestId(paymentId), callContext) + _ <- Helper.booleanToFuture(failMsg = CannotStartTheAuthorisationProcessForTheCancellation, cc = callContext) { + transactionRequest.status == TransactionRequestStatus.CANCELLATION_PENDING.toString + } + (challenges, _) <- NewStyle.function.createChallengesC2( + List(u.userId), + ChallengeType.BERLIN_GROUP_PAYMENT_CHALLENGE, + Some(paymentId), + getScaMethodAtInstance(SEPA_CREDIT_TRANSFERS.toString).toOption, + Some(StrongCustomerAuthenticationStatus.received), + None, + None, + callContext + ) + challenge <- NewStyle.function.tryons(InvalidConnectorResponseForCreateChallenge, 400, callContext) { + challenges.head + } + } yield { + JSONFactory_BERLIN_GROUP_1_3.createStartPaymentInitiationCancellationAuthorisation( + challenge, paymentService, paymentProduct, paymentId + ) + } + } else { + // Mocked for updatePsuAuthentication and selectPsuAuthenticationMethod variants + Future.successful(json.parse( + """{ + "scaStatus": "received", + "authorisationId": "123auth456", + "psuMessage": "Please use your BankApp for transaction Authorisation.", + "_links": { + "scaStatus": { + "href": "/v1.3/payments/qwer3456tzui7890/authorisations/123auth456" + } + } + }""")) + } + } + } + + // ── PUT /{paymentService}/{paymentProduct}/{paymentId}/cancellation-authorisations/{authorisationId} (4 variants) ─ + lazy val updatePaymentCancellationPsuDataAll: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `bgV13Prefix` / paymentService / paymentProduct / paymentId / "cancellation-authorisations" / authorisationId => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + val parsedJson = scala.util.Try(json.parse(cc.httpBody.getOrElse(""))).getOrElse(json.JNothing) + if (checkTransactionAuthorisation(parsedJson)) { + for { + _ <- passesPsd2Pisp(callContext) + failMsg = s"$InvalidJsonFormat The Json body should be the $UpdatePaymentPsuDataJson " + transactionAuthorisation <- NewStyle.function.tryons(failMsg, 400, callContext) { + parsedJson.extract[TransactionAuthorisation] + } + _ <- NewStyle.function.tryons(checkPaymentServerTypeError(paymentService), 404, callContext) { + PaymentServiceTypes.withName(paymentService.replaceAll("-", "_")) + } + _ <- NewStyle.function.tryons(checkPaymentProductError(paymentProduct), 404, callContext) { + TransactionRequestTypes.withName(paymentProduct.replaceAll("-", "_").toUpperCase) + } + transactionRequestId = TransactionRequestId(paymentId) + (existingTransactionRequest, _) <- NewStyle.function.getTransactionRequestImpl(transactionRequestId, callContext) + _ <- Helper.booleanToFuture(failMsg = CannotUpdatePSUDataCancellation, cc = callContext) { + existingTransactionRequest.status == TransactionRequestStatus.INITIATED.toString || + existingTransactionRequest.status == TransactionRequestStatus.CANCELLATION_PENDING.toString || + existingTransactionRequest.status == TransactionRequestStatus.COMPLETED.toString + } + (_, _) <- NewStyle.function.getTransactionRequestImpl(TransactionRequestId(paymentId), callContext) + (challenge, _) <- NewStyle.function.validateChallengeAnswerC4( + ChallengeType.BERLIN_GROUP_PAYMENT_CHALLENGE, + Some(paymentId), + None, + authorisationId, + transactionAuthorisation.scaAuthenticationData, + SuppliedAnswerType.PLAIN_TEXT_VALUE, + callContext + ) + (fromAccount, _) <- NewStyle.function.checkBankAccountExists( + BankId(existingTransactionRequest.from.bank_id), + AccountId(existingTransactionRequest.from.account_id), + callContext + ) + _ <- challenge.scaStatus match { + case Some(status) if status == StrongCustomerAuthenticationStatus.finalised => + NewStyle.function.saveTransactionRequestStatusImpl(existingTransactionRequest.id, CANCELLED.toString, callContext) + case Some(status) if status == StrongCustomerAuthenticationStatus.failed => + NewStyle.function.saveTransactionRequestStatusImpl(existingTransactionRequest.id, REJECTED.toString, callContext) + case _ => + Future(Full(true)) + } + } yield { + JSONFactory_BERLIN_GROUP_1_3.createStartPaymentCancellationAuthorisationJson( + challenge, paymentService, paymentProduct, paymentId + ) + } + } else if (checkUpdatePsuAuthentication(parsedJson)) { + Future.successful(json.parse( + """{ + "scaStatus": "psuAuthenticated", + "_links": { + "authoriseTransaction": {"href": "/psd2/v1.3/payments/1234-wertiq-983/authorisations/123auth456"} + } + }""")) + } else if (checkSelectPsuAuthenticationMethod(parsedJson)) { + Future.successful(json.parse( + """{ + "scaStatus": "scaMethodSelected", + "chosenScaMethod": { + "authenticationType": "SMS_OTP", + "authenticationMethodId": "myAuthenticationID"}, + "challengeData": { + "otpMaxLength": 6, + "otpFormat": "integer"}, + "_links": { + "authoriseTransaction": {"href": "/psd2/v1.3/payments/1234-wertiq-983/authorisations/123auth456"} + } + }""")) + } else { + // authorisationConfirmation variant + Future.successful(json.parse( + """{ + "scaStatus": "finalised", + "_links":{ + "status": {"href":"/v1.3/payments/sepa-credit-transfers/qwer3456tzui7890/status"} + } + }""")) + } + } + } + + // ── PUT /{paymentService}/{paymentProduct}/{paymentId}/authorisations/{authorisationId} (4 variants) ─ + lazy val updatePaymentPsuDataAll: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `bgV13Prefix` / paymentService / paymentProduct / paymentId / "authorisations" / authorisationId => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + val parsedJson = scala.util.Try(json.parse(cc.httpBody.getOrElse(""))).getOrElse(json.JNothing) + if (checkTransactionAuthorisation(parsedJson)) { + val u = cc.user.openOrThrowException(AuthenticatedUserIsRequired) + for { + _ <- passesPsd2Pisp(callContext) + failMsg = s"$InvalidJsonFormat The Json body should be the $TransactionAuthorisation " + transactionAuthorisationJson <- NewStyle.function.tryons(failMsg, 400, callContext) { + parsedJson.extract[TransactionAuthorisation] + } + _ <- NewStyle.function.tryons(checkPaymentServerTypeError(paymentService), 404, callContext) { + PaymentServiceTypes.withName(paymentService.replaceAll("-", "_")) + } + _ <- NewStyle.function.tryons(checkPaymentProductError(paymentProduct), 404, callContext) { + TransactionRequestTypes.withName(paymentProduct.replaceAll("-", "_").toUpperCase) + } + transactionRequestId = TransactionRequestId(paymentId) + (existingTransactionRequest, _) <- NewStyle.function.getTransactionRequestImpl(transactionRequestId, callContext) + _ <- Helper.booleanToFuture(failMsg = CannotUpdatePSUData, cc = callContext) { + existingTransactionRequest.status == TransactionStatus.RCVD.code + } + (_, _) <- NewStyle.function.getChallenge(authorisationId, callContext) + (challenge, _) <- NewStyle.function.validateChallengeAnswerC4( + ChallengeType.BERLIN_GROUP_PAYMENT_CHALLENGE, + Some(paymentId), + None, + authorisationId, + transactionAuthorisationJson.scaAuthenticationData, + SuppliedAnswerType.PLAIN_TEXT_VALUE, + callContext + ) + (fromAccount, _) <- NewStyle.function.checkBankAccountExists( + BankId(existingTransactionRequest.from.bank_id), + AccountId(existingTransactionRequest.from.account_id), + callContext + ) + _ <- challenge.scaStatus match { + case Some(status) if status == StrongCustomerAuthenticationStatus.finalised => + NewStyle.function.createTransactionAfterChallengeV210(fromAccount, existingTransactionRequest, callContext) map { _ => + NewStyle.function.saveTransactionRequestStatusImpl(existingTransactionRequest.id, COMPLETED.toString, callContext) + } + case Some(status) if status == StrongCustomerAuthenticationStatus.failed => + NewStyle.function.saveTransactionRequestStatusImpl(existingTransactionRequest.id, REJECTED.toString, callContext) + case _ => + Future(Full(true)) + } + } yield { + JSONFactory_BERLIN_GROUP_1_3.createUpdatePaymentPsuDataTransactionAuthorisationJson(challenge) + } + } else if (checkUpdatePsuAuthentication(parsedJson)) { + Future.successful(json.parse( + """{ + "scaStatus": "finalised", + "_links": { + "scaStatus": {"href":"/v1.3/payments/sepa-credit-transfers/88695566-6642-46d5-9985-0d824624f507"} + } + }""")) + } else if (checkSelectPsuAuthenticationMethod(parsedJson)) { + Future.successful(json.parse( + """{ + "scaStatus": "scaMethodSelected", + "chosenScaMethod": { + "authenticationType": "SMS_OTP", + "authenticationMethodId": "myAuthenticationID"}, + "challengeData": { + "otpMaxLength": 6, + "otpFormat": "integer"}, + "_links": { + "authoriseTransaction": {"href": "/psd2/v1.3/payments/1234-wertiq-983/authorisations/123auth456"} + } + }""")) + } else { + // authorisationConfirmation variant + Future.successful(json.parse( + """{ + "scaStatus": "finalised", + "_links":{ + "status": {"href":"/v1.3/payments/sepa-credit-transfers/qwer3456tzui7890/status"} + } + }""")) + } + } + } + + // ── ResourceDocs ─────────────────────────────────────────────────────────────────────────────────── + + private val generalPaymentSummaryText: String = + s""" This method is used to initiate a payment at the ASPSP. + + ## Variants of Payment Initiation Requests + + This method to initiate a payment initiation at the ASPSP can be sent with either a JSON body or an pain.001 body depending on the payment product in the path. + + There are the following **payment products**: + + - Payment products with payment information in *JSON* format: + - ***sepa-credit-transfers*** + - ***instant-sepa-credit-transfers*** + - ***target-2-payments*** + - ***cross-border-credit-transfers*** + - Payment products with payment information in *pain.001* XML format: + - ***pain.001-sepa-credit-transfers*** + - ***pain.001-instant-sepa-credit-transfers*** + - ***pain.001-target-2-payments*** + - ***pain.001-cross-border-credit-transfers*** + + - Furthermore the request body depends on the **payment-service** + - ***payments***: A single payment initiation request. + - ***bulk-payments***: A collection of several payment iniatiation requests. + In case of a *pain.001* message there are more than one payments contained in the *pain.001 message. + In case of a *JSON* there are several JSON payment blocks contained in a joining list. + - ***periodic-payments***: + Create a standing order initiation resource for recurrent i.e. periodic payments addressable under {paymentId} + with all data relevant for the corresponding payment product and the execution of the standing order contained in a JSON body. + + This is the first step in the API to initiate the related recurring/periodic payment. + + Additional Instructions: + + for PAYMENT_SERVICE use payments + + for PAYMENT_PRODUCT use sepa-credit-transfers + """ + + private val generalStartPaymentAuthorisationSummary: String = + s"""${mockedDataText(true)} +Create an authorisation sub-resource and start the authorisation process. +The message might in addition transmit authentication and authorisation related data. + +This method is iterated n times for a n times SCA authorisation in a +corporate context, each creating an own authorisation sub-endpoint for +the corresponding PSU authorising the transaction. + +The ASPSP might make the usage of this access method unnecessary in case +of only one SCA process needed, since the related authorisation resource +might be automatically created by the ASPSP after the submission of the +payment data with the first POST payments/{payment-product} call. + +The start authorisation process is a process which is needed for creating a new authorisation +or cancellation sub-resource. +""" + + private val startPaymentAuthorisationResponse = json.parse("""{ + "challengeData": { + "scaStatus": "received", + "authorisationId": "88695566-6642-46d5-9985-0d824624f507", + "psuMessage": "Please check your SMS at a mobile device.", + "_links": { + "scaStatus": "/v1.3/payments/sepa-credit-transfers/88695566-6642-46d5-9985-0d824624f507" + } + } + }""") + + private val generalStartPaymentInitiationCancellationAuthorisationSummary: String = + s"""${mockedDataText(true)} +Creates an authorisation sub-resource and start the authorisation process of the cancellation of the addressed payment. +The message might in addition transmit authentication and authorisation related data. +""" + + private val startPaymentInitiationCancellationAuthorisationResponse = json.parse("""{ + "scaStatus": "received", + "authorisationId": "123auth456", + "psuMessage": "Please use your BankApp for transaction Authorisation.", + "_links": { + "scaStatus": { + "href": "/v1.3/payments/qwer3456tzui7890/authorisations/123auth456" + } + } + }""") + + private val generalUpdatePaymentCancellationPsuDataSummary: String = + s"""${mockedDataText(true)} +This method updates PSU data on the cancellation authorisation resource if needed. +It may authorise a cancellation of the payment within the Embedded SCA Approach where needed. +""" + + private val generalUpdatePaymentPsuDataSummary: String = + s"""${mockedDataText(false)} +This methods updates PSU data on the authorisation resource if needed. +It may authorise a payment within the Embedded SCA Approach where needed. + + NOTE: For this endpoint, for sandbox mode, the `scaAuthenticationData` is fixed value: 123. To make the process work. + Normally the app use will get SMS/EMAIL to get the value for this process. +""" + + private def initCancelAndGetResourceDocs(): Unit = { + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(cancelPayment), + "DELETE", "/PAYMENT_SERVICE/PAYMENT_PRODUCT/PAYMENTID", + "Payment Cancellation Request", + s"""${mockedDataText(false)} +This method initiates the cancellation of a payment. Depending on the payment-service, the payment-product +and the ASPSP's implementation, this TPP call might be sufficient to cancel a payment. If an authorisation +of the payment cancellation is mandated by the ASPSP, a corresponding hyperlink will be contained in the +response message. Cancels the addressed payment with resource identification paymentId if applicable to the +payment-service, payment-product and received in product related timelines (e.g. before end of business day +for scheduled payments of the last business day before the scheduled execution day). The response to this +DELETE command will tell the TPP whether the * access method was rejected * access method was successful, +or * access method is generally applicable, but further authorisation processes are needed. +""", + EmptyBody, + CancelPaymentResponseJson( + "ACTC", + _links = CancelPaymentResponseLinks( + self = LinkHrefJson(s"/v1.3/payments/sepa-credit-transfers/1234-wertiq-983"), + status = LinkHrefJson(s"/v1.3/payments/sepa-credit-transfers/1234-wertiq-983/status"), + startAuthorisation = LinkHrefJson(s"/v1.3/payments/sepa-credit-transfers/cancellation-authorisations/1234-wertiq-983/status") + ) + ), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Payment Initiation Service (PIS)") :: Nil, + http4sPartialFunction = Some(cancelPayment) + ) + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getPaymentCancellationScaStatus), + "GET", "/PAYMENT_SERVICE/PAYMENT_PRODUCT/PAYMENTID/cancellation-authorisations/CANCELLATIONID", + "Read the SCA status of the payment cancellation's authorisation.", + s"""${mockedDataText(false)} +This method returns the SCA status of a payment initiation's authorisation sub-resource. +""", + EmptyBody, + json.parse("""{"scaStatus" : "psuAuthenticated"}"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Payment Initiation Service (PIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(getPaymentCancellationScaStatus) + ) + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getPaymentInformation), + "GET", "/PAYMENT_SERVICE/PAYMENT_PRODUCT/PAYMENTID", + "Get Payment Information", + s"""${mockedDataText(false)} +Returns the content of a payment object""", + EmptyBody, + json.parse("""{ + "debtorAccount":{ + "iban":"GR12 1234 5123 4511 3981 4475 477" + }, + "instructedAmount":{ + "currency":"EUR", + "amount":"1234" + }, + "creditorAccount":{ + "iban":"GR12 1234 5123 4514 4575 3645 077" + }, + "creditorName":"70charname" + }"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Payment Initiation Service (PIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(getPaymentInformation) + ) + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getPaymentInitiationAuthorisation), + "GET", "/PAYMENT_SERVICE/PAYMENT_PRODUCT/PAYMENTID/authorisations", + "Get Payment Initiation Authorisation Sub-Resources Request", + s"""${mockedDataText(false)} +Read a list of all authorisation subresources IDs which have been created. + +This function returns an array of hyperlinks to all generated authorisation sub-resources. +""", + EmptyBody, + json.parse("""[{ + "scaStatus": "received", + "authorisationId": "940948c7-1c86-4d88-977e-e739bf2c1492", + "psuMessage": "Please check your SMS at a mobile device.", + "_links": {"scaStatus": "/v1.3/payments/sepa-credit-transfers/940948c7-1c86-4d88-977e-e739bf2c1492"} + }]"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Payment Initiation Service (PIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(getPaymentInitiationAuthorisation) + ) + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getPaymentInitiationCancellationAuthorisationInformation), + "GET", "/PAYMENT_SERVICE/PAYMENT_PRODUCT/PAYMENTID/cancellation-authorisations", + "Get Cancellation Authorisation Sub-Resources Request", + s"""${mockedDataText(false)} +Retrieve a list of all created cancellation authorisation sub-resources. +""", + EmptyBody, + json.parse("""{"cancellationIds" : ["faa3657e-13f0-4feb-a6c3-34bf21a9ae8e"]}"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Payment Initiation Service (PIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(getPaymentInitiationCancellationAuthorisationInformation) + ) + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getPaymentInitiationScaStatus), + "GET", "/PAYMENT_SERVICE/PAYMENT_PRODUCT/PAYMENT_ID/authorisations/AUTHORISATION_ID", + "Read the SCA Status of the payment authorisation", + s"""${mockedDataText(false)} +This method returns the SCA status of a payment initiation's authorisation sub-resource. +""", + EmptyBody, + json.parse("""{"scaStatus" : "psuAuthenticated"}"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Payment Initiation Service (PIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(getPaymentInitiationScaStatus) + ) + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getPaymentInitiationStatus), + "GET", "/PAYMENT_SERVICE/PAYMENT_PRODUCT/PAYMENT_ID/status", + "Payment initiation status request", + s"""${mockedDataText(false)} +Check the transaction status of a payment initiation.""", + EmptyBody, + json.parse(s"""{"transactionStatus": "${TransactionStatus.ACCP.code}"}"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Payment Initiation Service (PIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(getPaymentInitiationStatus) + ) + } + + private def initInitiatePaymentResourceDocs(): Unit = { + val initiatePaymentRequestBody = json.parse(s"""{ + "debtorAccount": {"iban": "DE123456987480123"}, + "instructedAmount": {"currency": "EUR", "amount": "100"}, + "creditorAccount": {"iban": "UK12 1234 5123 4517 2948 6166 077"}, + "creditorName": "70charname" + }""") + val initiatePaymentResponseBody = json.parse(s"""{ + "transactionStatus": "${TransactionStatus.RCVD.code}", + "paymentId": "1234-wertiq-983", + "_links": { + "scaRedirect": {"href": "$getServerUrl/otp?flow=payment&paymentService=payments&paymentProduct=sepa_credit_transfers&paymentId=b0472c21-6cea-4ee0-b036-3e253adb3b0b"}, + "self": {"href": "/v1.3/payments/sepa-credit-transfers/1234-wertiq-983"}, + "status": {"href": "/v1.3/payments/1234-wertiq-983/status"}, + "scaStatus": {"href": "/v1.3/payments/1234-wertiq-983/authorisations/123auth456"} + } + }""") + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(initiatePayments), + "POST", "/payments/PAYMENT_PRODUCT", + "Payment initiation request(payments)", + s"""${mockedDataText(false)} +$generalPaymentSummaryText""", + initiatePaymentRequestBody, + initiatePaymentResponseBody, + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Payment Initiation Service (PIS)") :: apiTagBerlinGroupM :: Nil, + authMode = UserOrApplication, + http4sPartialFunction = Some(initiatePayments) + ) + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(initiatePeriodicPayments), + "POST", "/periodic-payments/PAYMENT_PRODUCT", + "Payment initiation request(periodic-payments)", + s"""${mockedDataText(false)} +$generalPaymentSummaryText""", + json.parse(s"""{ + "instructedAmount": {"currency": "EUR", "amount": "123"}, + "debtorAccount": {"iban": "DE40100100103307118608"}, + "creditorName": "Merchant123", + "creditorAccount": {"iban": "DE23100120020123456789"}, + "remittanceInformationUnstructured": "Ref Number Abonnement", + "startDate": "2018-03-01", + "executionRule": "preceding", + "frequency": "Monthly", + "dayOfExecution": "01" + }"""), + json.parse(s"""{ + "transactionStatus": "${TransactionStatus.RCVD.code}", + "paymentId": "1234-wertiq-983", + "_links": { + "scaRedirect": {"href": "$getServerUrl/otp?flow=payment&paymentService=payments&paymentProduct=sepa_credit_transfers&paymentId=b0472c21-6cea-4ee0-b036-3e253adb3b0b"}, + "self": {"href": "/v1.3/periodic-payments/instant-sepa-credit-transfer/1234-wertiq-983"}, + "status": {"href": "/v1.3/periodic-payments/1234-wertiq-983/status"}, + "scaStatus": {"href": "/v1.3/periodic-payments/1234-wertiq-983/authorisations/123auth456"} + } + }"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Payment Initiation Service (PIS)") :: apiTagBerlinGroupM :: Nil, + authMode = UserOrApplication, + http4sPartialFunction = Some(initiatePeriodicPayments) + ) + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(initiateBulkPayments), + "POST", "/bulk-payments/PAYMENT_PRODUCT", + "Payment initiation request(bulk-payments)", + s"""${mockedDataText(true)} +$generalPaymentSummaryText""", + json.parse(s"""{ + "batchBookingPreferred": "true", + "debtorAccount": {"iban": "DE40100100103307118608"}, + "paymentInformationId": "my-bulk-identification-1234", + "requestedExecutionDate": "2018-08-01", + "payments": [ + {"instructedAmount": {"currency": "EUR", "amount": "123.50"}, "creditorName": "Merchant123", + "creditorAccount": {"iban": "DE02100100109307118603"}, + "remittanceInformationUnstructured": "Ref Number Merchant 1"}, + {"instructedAmount": {"currency": "EUR", "amount": "34.10"}, "creditorName": "Merchant456", + "creditorAccount": {"iban": "FR7612345987650123456789014"}, + "remittanceInformationUnstructured": "Ref Number Merchant 2"} + ] + }"""), + json.parse(s"""{ + "transactionStatus": "${TransactionStatus.RCVD.code}", + "paymentId": "1234-wertiq-983", + "_links": { + "scaRedirect": {"href": "$getServerUrl/otp?flow=payment&paymentService=payments&paymentProduct=sepa_credit_transfers&paymentId=b0472c21-6cea-4ee0-b036-3e253adb3b0b"}, + "self": {"href": "/v1.3/bulk-payments/sepa-credit-transfers/1234-wertiq-983"}, + "status": {"href": "/v1.3/bulk-payments/1234-wertiq-983/status"}, + "scaStatus": {"href": "/v1.3/bulk-payments/1234-wertiq-983/authorisations/123auth456"} + } + }"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Payment Initiation Service (PIS)") :: apiTagBerlinGroupM :: Nil, + authMode = UserOrApplication, + http4sPartialFunction = Some(initiateBulkPayments) + ) + } + + private def initStartAuthorisationResourceDocs(): Unit = { + // POST /PAYMENT_SERVICE/PAYMENT_PRODUCT/PAYMENT_ID/authorisations — 3 body variants + resourceDocs += ResourceDoc( + null, implementedInApiVersion, + "startPaymentAuthorisationUpdatePsuAuthentication", + "POST", "/PAYMENT_SERVICE/PAYMENT_PRODUCT/PAYMENT_ID/authorisations", + "Start the authorisation process for a payment initiation (updatePsuAuthentication)", + generalStartPaymentAuthorisationSummary, + json.parse("""{"psuData": {"password": "start12"}}"""), + startPaymentAuthorisationResponse, + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Payment Initiation Service (PIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(startPaymentAuthorisationAll) + ) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, + "startPaymentAuthorisationSelectPsuAuthenticationMethod", + "POST", "/PAYMENT_SERVICE/PAYMENT_PRODUCT/PAYMENT_ID/authorisations", + "Start the authorisation process for a payment initiation (selectPsuAuthenticationMethod)", + generalStartPaymentAuthorisationSummary, + json.parse("""{"authenticationMethodId":""}"""), + startPaymentAuthorisationResponse, + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Payment Initiation Service (PIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(startPaymentAuthorisationAll) + ) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, + "startPaymentAuthorisationTransactionAuthorisation", + "POST", "/PAYMENT_SERVICE/PAYMENT_PRODUCT/PAYMENT_ID/authorisations", + "Start the authorisation process for a payment initiation (transactionAuthorisation)", + s"""${mockedDataText(false)} +Create an authorisation sub-resource and start the authorisation process. +The message might in addition transmit authentication and authorisation related data. +""", + json.parse("""{"scaAuthenticationData":"123"}"""), + startPaymentAuthorisationResponse, + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Payment Initiation Service (PIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(startPaymentAuthorisationAll) + ) + + // POST /PAYMENT_SERVICE/PAYMENT_PRODUCT/PAYMENT_ID/cancellation-authorisations — 3 body variants + resourceDocs += ResourceDoc( + null, implementedInApiVersion, + "startPaymentInitiationCancellationAuthorisationTransactionAuthorisation", + "POST", "/PAYMENT_SERVICE/PAYMENT_PRODUCT/PAYMENT_ID/cancellation-authorisations", + "Start the authorisation process for the cancellation of the addressed payment (transactionAuthorisation)", + s"""${mockedDataText(false)} +Creates an authorisation sub-resource and start the authorisation process of the cancellation of the addressed payment. +""", + json.parse("""{"scaAuthenticationData":""}"""), + json.parse("""{ + "scaStatus": "received", + "authorisationId": "123auth456", + "psuMessage": "Please use your BankApp for transaction Authorisation.", + "_links": {"scaStatus": {"href": "/v1.3/payments/qwer3456tzui7890/authorisations/123auth456"}} + }"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Payment Initiation Service (PIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(startPaymentInitiationCancellationAuthorisationAll) + ) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, + "startPaymentInitiationCancellationAuthorisationUpdatePsuAuthentication", + "POST", "/PAYMENT_SERVICE/PAYMENT_PRODUCT/PAYMENT_ID/cancellation-authorisations", + "Start the authorisation process for the cancellation of the addressed payment (updatePsuAuthentication)", + generalStartPaymentInitiationCancellationAuthorisationSummary, + json.parse("""{"psuData": {"password": "start12"}}"""), + startPaymentInitiationCancellationAuthorisationResponse, + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Payment Initiation Service (PIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(startPaymentInitiationCancellationAuthorisationAll) + ) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, + "startPaymentInitiationCancellationAuthorisationSelectPsuAuthenticationMethod", + "POST", "/PAYMENT_SERVICE/PAYMENT_PRODUCT/PAYMENT_ID/cancellation-authorisations", + "Start the authorisation process for the cancellation of the addressed payment (selectPsuAuthenticationMethod)", + generalStartPaymentInitiationCancellationAuthorisationSummary, + json.parse("""{"authenticationMethodId":""}"""), + startPaymentInitiationCancellationAuthorisationResponse, + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Payment Initiation Service (PIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(startPaymentInitiationCancellationAuthorisationAll) + ) + } + + private def initUpdatePsuDataResourceDocs(): Unit = { + // PUT /PAYMENT_SERVICE/PAYMENT_PRODUCT/PAYMENT_ID/cancellation-authorisations/AUTHORISATION_ID — 4 variants + resourceDocs += ResourceDoc( + null, implementedInApiVersion, + "updatePaymentCancellationPsuDataTransactionAuthorisation", + "PUT", "/PAYMENT_SERVICE/PAYMENT_PRODUCT/PAYMENT_ID/cancellation-authorisations/AUTHORISATION_ID", + "Update PSU Data for payment initiation cancellation (transactionAuthorisation)", + s"""${mockedDataText(false)} +This method updates PSU data on the cancellation authorisation resource if needed. +""", + json.parse("""{"scaAuthenticationData":"123"}"""), + json.parse("""{ + "scaStatus":"finalised", + "psuMessage":"Please check your SMS at a mobile device.", + "_links":{"scaStatus":"/v1.3/payments/sepa-credit-transfers/PAYMENT_ID/4f4a8b7f-9968-4183-92ab-ca512b396bfc"} + }"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Payment Initiation Service (PIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(updatePaymentCancellationPsuDataAll) + ) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, + "updatePaymentCancellationPsuDataUpdatePsuAuthentication", + "PUT", "/PAYMENT_SERVICE/PAYMENT_PRODUCT/PAYMENT_ID/cancellation-authorisations/AUTHORISATION_ID", + "Update PSU Data for payment initiation cancellation (updatePsuAuthentication)", + generalUpdatePaymentCancellationPsuDataSummary, + json.parse("""{"psuData":{"password":"start12"}}"""), + json.parse("""{ + "scaStatus": "psuAuthenticated", + "_links": {"authoriseTransaction": {"href": "/psd2/v1.3/payments/1234-wertiq-983/authorisations/123auth456"}} + }"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Payment Initiation Service (PIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(updatePaymentCancellationPsuDataAll) + ) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, + "updatePaymentCancellationPsuDataSelectPsuAuthenticationMethod", + "PUT", "/PAYMENT_SERVICE/PAYMENT_PRODUCT/PAYMENT_ID/cancellation-authorisations/AUTHORISATION_ID", + "Update PSU Data for payment initiation cancellation (selectPsuAuthenticationMethod)", + generalUpdatePaymentCancellationPsuDataSummary, + json.parse("""{"authenticationMethodId":""}"""), + json.parse("""{ + "scaStatus": "scaMethodSelected", + "chosenScaMethod": {"authenticationType": "SMS_OTP", "authenticationMethodId": "myAuthenticationID"}, + "challengeData": {"otpMaxLength": 6, "otpFormat": "integer"}, + "_links": {"authoriseTransaction": {"href": "/psd2/v1.3/payments/1234-wertiq-983/authorisations/123auth456"}} + }"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Payment Initiation Service (PIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(updatePaymentCancellationPsuDataAll) + ) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, + "updatePaymentCancellationPsuDataAuthorisationConfirmation", + "PUT", "/PAYMENT_SERVICE/PAYMENT_PRODUCT/PAYMENT_ID/cancellation-authorisations/AUTHORISATION_ID", + "Update PSU Data for payment initiation cancellation (authorisationConfirmation)", + generalUpdatePaymentCancellationPsuDataSummary, + json.parse("""{"confirmationCode":"confirmationCode"}"""), + json.parse("""{ + "scaStatus": "finalised", + "_links":{"status": {"href":"/v1.3/payments/sepa-credit-transfers/qwer3456tzui7890/status"}} + }"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Payment Initiation Service (PIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(updatePaymentCancellationPsuDataAll) + ) + + // PUT /PAYMENT_SERVICE/PAYMENT_PRODUCT/PAYMENT_ID/authorisations/AUTHORISATION_ID — 4 variants + resourceDocs += ResourceDoc( + null, implementedInApiVersion, + "updatePaymentPsuDataTransactionAuthorisation", + "PUT", "/PAYMENT_SERVICE/PAYMENT_PRODUCT/PAYMENT_ID/authorisations/AUTHORISATION_ID", + "Update PSU data for payment initiation (transactionAuthorisation)", + generalUpdatePaymentPsuDataSummary, + json.parse("""{"scaAuthenticationData":"123"}"""), + json.parse("""{ + "scaStatus": "finalised", + "psuMessage": "Please check your SMS at a mobile device.", + "_links": {"scaStatus": {"href":"/v1.3/payments/sepa-credit-transfers/88695566-6642-46d5-9985-0d824624f507"}} + }"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Payment Initiation Service (PIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(updatePaymentPsuDataAll) + ) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, + "updatePaymentPsuDataUpdatePsuAuthentication", + "PUT", "/PAYMENT_SERVICE/PAYMENT_PRODUCT/PAYMENT_ID/authorisations/AUTHORISATION_ID", + "Update PSU data for payment initiation (updatePsuAuthentication)", + generalUpdatePaymentPsuDataSummary, + json.parse("""{"psuData": {"password": "start12"}}"""), + json.parse("""{ + "scaStatus": "finalised", + "_links": {"scaStatus": {"href":"/v1.3/payments/sepa-credit-transfers/88695566-6642-46d5-9985-0d824624f507"}} + }"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Payment Initiation Service (PIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(updatePaymentPsuDataAll) + ) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, + "updatePaymentPsuDataSelectPsuAuthenticationMethod", + "PUT", "/PAYMENT_SERVICE/PAYMENT_PRODUCT/PAYMENT_ID/authorisations/AUTHORISATION_ID", + "Update PSU data for payment initiation (selectPsuAuthenticationMethod)", + generalUpdatePaymentPsuDataSummary, + json.parse("""{"authenticationMethodId":""}"""), + json.parse("""{ + "scaStatus": "scaMethodSelected", + "chosenScaMethod": {"authenticationType": "SMS_OTP", "authenticationMethodId": "myAuthenticationID"}, + "challengeData": {"otpMaxLength": 6, "otpFormat": "integer"}, + "_links": {"authoriseTransaction": {"href": "/psd2/v1.3/payments/1234-wertiq-983/authorisations/123auth456"}} + }"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Payment Initiation Service (PIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(updatePaymentPsuDataAll) + ) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, + "updatePaymentPsuDataAuthorisationConfirmation", + "PUT", "/PAYMENT_SERVICE/PAYMENT_PRODUCT/PAYMENT_ID/authorisations/AUTHORISATION_ID", + "Update PSU data for payment initiation (authorisationConfirmation)", + generalUpdatePaymentPsuDataSummary, + json.parse("""{"confirmationCode":"confirmationCode"}"""), + json.parse("""{ + "scaStatus": "finalised", + "_links":{"status": {"href":"/v1.3/payments/sepa-credit-transfers/qwer3456tzui7890/status"}} + }"""), + List(AuthenticatedUserIsRequired, UnknownError), + ApiTag("Payment Initiation Service (PIS)") :: apiTagBerlinGroupM :: Nil, + http4sPartialFunction = Some(updatePaymentPsuDataAll) + ) + } + + // Initialise all ResourceDocs at object-construction time + initCancelAndGetResourceDocs() + initInitiatePaymentResourceDocs() + initStartAuthorisationResourceDocs() + initUpdatePsuDataResourceDocs() + + val routes: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req => + cancelPayment(req) + .orElse(getPaymentCancellationScaStatus(req)) + .orElse(getPaymentInformation(req)) + .orElse(getPaymentInitiationAuthorisation(req)) + .orElse(getPaymentInitiationCancellationAuthorisationInformation(req)) + .orElse(getPaymentInitiationScaStatus(req)) + .orElse(getPaymentInitiationStatus(req)) + .orElse(initiatePayments(req)) + .orElse(initiatePeriodicPayments(req)) + .orElse(initiateBulkPayments(req)) + .orElse(startPaymentAuthorisationAll(req)) + .orElse(startPaymentInitiationCancellationAuthorisationAll(req)) + .orElse(updatePaymentCancellationPsuDataAll(req)) + .orElse(updatePaymentPsuDataAll(req)) + } +} diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala new file mode 100644 index 0000000000..3a42a2e592 --- /dev/null +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala @@ -0,0 +1,536 @@ +package code.api.berlin.group.v1_3 + +import cats.data.{Kleisli, OptionT} +import cats.effect._ +import code.api.berlin.group.ConstantsBG +import code.api.berlin.group.v1_3.JSONFactory_BERLIN_GROUP_1_3._ +import code.api.util.APIUtil.{EmptyBody, ResourceDoc, connectorEmptyResponse, getSuggestedDefaultScaMethod, mockedDataText, passesPsd2Pisp, unboxFullOrFail} +import code.api.util.ApiTag._ +import code.api.util.ErrorMessages._ +import code.api.util.CustomJsonFormats +import code.api.util.{ApiTag, NewStyle} +import code.api.util.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps} +import code.api.util.newstyle.SigningBasketNewStyle +import code.bankconnectors.Connector +import code.signingbaskets.SigningBasketX +import code.util.Helper.{MdcLoggable, booleanToFuture} +import com.github.dwickern.macros.NameOf.nameOf +import com.openbankproject.commons.ExecutionContext.Implicits.global +import com.openbankproject.commons.model.enums.TransactionRequestStatus.{COMPLETED, REJECTED} +import com.openbankproject.commons.model.enums.{ChallengeType, StrongCustomerAuthenticationStatus, SuppliedAnswerType} +import com.openbankproject.commons.model.{ChallengeTrait, TransactionRequestId} +import net.liftweb.common.Empty +import net.liftweb.json +import net.liftweb.json.Formats +import org.http4s._ +import org.http4s.dsl.io._ + +import scala.collection.mutable.ArrayBuffer +import scala.concurrent.Future +import scala.language.implicitConversions + +object Http4sBGv13SigningBaskets extends MdcLoggable { + + type HttpF[A] = OptionT[IO, A] + + implicit val formats: Formats = CustomJsonFormats.formats + + protected implicit def JvalueToSuper(what: net.liftweb.json.JValue): JvalueCaseClass = JvalueCaseClass(what) + + val implementedInApiVersion = ConstantsBG.berlinGroupVersion1 + val resourceDocs = ArrayBuffer[ResourceDoc]() + + val bgV13Prefix = Root / ConstantsBG.berlinGroupVersion1.urlPrefix / ConstantsBG.berlinGroupVersion1.apiShortVersion + + // ── POST /signing-baskets ────────────────────────────────────────────── + val createSigningBasket: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `bgV13Prefix` / "signing-baskets" => + EndpointHelpers.executeFutureCreated(req) { + val cc = req.callContext + val callContext = Some(cc) + for { + _ <- passesPsd2Pisp(callContext) + failMsg = s"$InvalidJsonFormat The Json body should be the $PostSigningBasketJsonV13 " + postJson <- NewStyle.function.tryons(failMsg, 400, callContext) { + json.parse(cc.httpBody.getOrElse("")).extract[PostSigningBasketJsonV13] + } + _ <- booleanToFuture(failMsg, cc = callContext) { + !(postJson.paymentIds.isEmpty && postJson.consentIds.isEmpty) + } + signingBasket <- Future { + SigningBasketX.signingBasketProvider.vend.createSigningBasket( + postJson.paymentIds, + postJson.consentIds, + ) + }.map(connectorEmptyResponse(_, callContext)) + } yield { + createSigningBasketResponseJson(signingBasket) + } + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(createSigningBasket), + "POST", + "/signing-baskets", + "Create a signing basket resource", + s"""${mockedDataText(false)} +Create a signing basket resource for authorising several transactions with one SCA method. +The resource identifications of these transactions are contained in the payload of this access method +""", + PostSigningBasketJsonV13(paymentIds = Some(List("123qwert456789", "12345qwert7899")), None), + json.parse("""{ + "basketId" : "1234-basket-567", + "challengeData" : { + "otpMaxLength" : 0, + "additionalInformation" : "additionalInformation", + "image" : "image", + "imageLink" : "http://example.com/aeiou", + "otpFormat" : "characters", + "data" : [ "data", "data" ] + }, + "scaMethods" : "", + "tppMessages" : [ { + "path" : "path", + "code" : { }, + "text" : { }, + "category" : { } + } ], + "_links" : { + "scaStatus" : "/v1.3/payments/sepa-credit-transfers/1234-wertiq-983", + "startAuthorisation" : "/v1.3/payments/sepa-credit-transfers/1234-wertiq-983", + "status" : "/v1.3/payments/sepa-credit-transfers/1234-wertiq-983" + }, + "chosenScaMethod" : "", + "transactionStatus" : "ACCP", + "psuMessage" : { } +}"""), + List(AuthenticatedUserIsRequired, UnknownError), + apiTagSigningBaskets :: Nil, + http4sPartialFunction = Some(createSigningBasket) + ) + + // ── DELETE /signing-baskets/BASKETID ────────────────────────────────── + val deleteSigningBasket: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `bgV13Prefix` / "signing-baskets" / basketid => + EndpointHelpers.executeDelete(req) { cc => + val callContext = Some(cc) + for { + _ <- passesPsd2Pisp(callContext) + _ <- Future { + SigningBasketX.signingBasketProvider.vend.deleteSigningBasket(basketid) + }.map(connectorEmptyResponse(_, callContext)) + } yield () + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(deleteSigningBasket), + "DELETE", + "/signing-baskets/BASKETID", + "Delete the signing basket", + s"""${mockedDataText(false)} +Delete the signing basket structure as long as no (partial) authorisation has yet been applied. +The undlerying transactions are not affected by this deletion. + +Remark: The signing basket as such is not deletable after a first (partial) authorisation has been applied. +Nevertheless, single transactions might be cancelled on an individual basis on the XS2A interface. +""", + EmptyBody, + EmptyBody, + List(AuthenticatedUserIsRequired, UnknownError), + apiTagSigningBaskets :: Nil, + http4sPartialFunction = Some(deleteSigningBasket) + ) + + // ── GET /signing-baskets/BASKETID ───────────────────────────────────── + val getSigningBasket: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `bgV13Prefix` / "signing-baskets" / basketid => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + for { + _ <- passesPsd2Pisp(callContext) + basket <- Future { + SigningBasketX.signingBasketProvider.vend.getSigningBasketByBasketId(basketid) + }.map(connectorEmptyResponse(_, callContext)) + } yield { + getSigningBasketResponseJson(basket) + } + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getSigningBasket), + "GET", + "/signing-baskets/BASKETID", + "Returns the content of an signing basket object.", + s"""${mockedDataText(false)} +Returns the content of an signing basket object.""", + EmptyBody, + json.parse("""{ + "transactionStatus" : "ACCP", + "payments" : "", + "consents" : "" +}"""), + List(AuthenticatedUserIsRequired, UnknownError), + apiTagSigningBaskets :: Nil, + http4sPartialFunction = Some(getSigningBasket) + ) + + // ── GET /signing-baskets/BASKETID/authorisations ────────────────────── + val getSigningBasketAuthorisation: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `bgV13Prefix` / "signing-baskets" / basketid / "authorisations" => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + for { + _ <- passesPsd2Pisp(callContext) + (challenges, _) <- NewStyle.function.getChallengesByBasketId(basketid, callContext) + } yield { + JSONFactory_BERLIN_GROUP_1_3.AuthorisationJsonV13(challenges.map(_.challengeId)) + } + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getSigningBasketAuthorisation), + "GET", + "/signing-baskets/BASKETID/authorisations", + "Get Signing Basket Authorisation Sub-Resources Request", + s"""${mockedDataText(false)} +Read a list of all authorisation subresources IDs which have been created. + +This function returns an array of hyperlinks to all generated authorisation sub-resources. +""", + EmptyBody, + json.parse("""{ + "authorisationIds" : "" +}"""), + List(AuthenticatedUserIsRequired, UnknownError), + apiTagSigningBaskets :: Nil, + http4sPartialFunction = Some(getSigningBasketAuthorisation) + ) + + // ── GET /signing-baskets/BASKETID/authorisations/AUTHORISATIONID ─────── + val getSigningBasketScaStatus: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `bgV13Prefix` / "signing-baskets" / basketId / "authorisations" / authorisationId => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + for { + _ <- passesPsd2Pisp(callContext) + _ <- Future(SigningBasketX.signingBasketProvider.vend.getSigningBasketByBasketId(basketId)) + .map(unboxFullOrFail(_, callContext, s"$ConsentNotFound ($basketId)", 403)) + (challenges, _) <- NewStyle.function.getChallengesByBasketId(basketId, callContext) + } yield { + val challengeStatus = challenges.filter(_.challengeId == authorisationId) + .flatMap(_.scaStatus).headOption.map(_.toString).getOrElse("None") + JSONFactory_BERLIN_GROUP_1_3.ScaStatusJsonV13(challengeStatus) + } + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getSigningBasketScaStatus), + "GET", + "/signing-baskets/BASKETID/authorisations/AUTHORISATIONID", + "Read the SCA status of the signing basket authorisation", + s"""${mockedDataText(false)} +This method returns the SCA status of a signing basket's authorisation sub-resource. +""", + EmptyBody, + json.parse("""{ + "scaStatus" : "psuAuthenticated" +}"""), + List(AuthenticatedUserIsRequired, UnknownError), + apiTagSigningBaskets :: Nil, + http4sPartialFunction = Some(getSigningBasketScaStatus) + ) + + // ── GET /signing-baskets/BASKETID/status ────────────────────────────── + val getSigningBasketStatus: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `bgV13Prefix` / "signing-baskets" / basketid / "status" => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + for { + _ <- passesPsd2Pisp(callContext) + basket <- Future { + SigningBasketX.signingBasketProvider.vend.getSigningBasketByBasketId(basketid) + }.map(connectorEmptyResponse(_, callContext)) + } yield { + getSigningBasketStatusResponseJson(basket) + } + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getSigningBasketStatus), + "GET", + "/signing-baskets/BASKETID/status", + "Read the status of the signing basket", + s"""${mockedDataText(false)} +Returns the status of a signing basket object. +""", + EmptyBody, + json.parse("""{ + "transactionStatus" : "RCVD" +}"""), + List(AuthenticatedUserIsRequired, UnknownError), + apiTagSigningBaskets :: Nil, + http4sPartialFunction = Some(getSigningBasketStatus) + ) + + // ── POST /signing-baskets/BASKETID/authorisations ───────────────────── + val startSigningBasketAuthorisation: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `bgV13Prefix` / "signing-baskets" / basketId / "authorisations" => + EndpointHelpers.executeFutureCreated(req) { + val cc = req.callContext + val callContext = Some(cc) + for { + _ <- passesPsd2Pisp(callContext) + (challenges, _) <- NewStyle.function.createChallengesC3( + List(cc.user.map(_.userId).openOr("")), + ChallengeType.BERLIN_GROUP_SIGNING_BASKETS_CHALLENGE, + None, + getSuggestedDefaultScaMethod(), + Some(StrongCustomerAuthenticationStatus.received), + None, + Some(basketId), + None, + callContext + ) + challenge <- NewStyle.function.tryons(InvalidConnectorResponseForCreateChallenge, 400, callContext) { + challenges.head + } + } yield { + createStartSigningBasketAuthorisationJson(basketId, challenge) + } + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(startSigningBasketAuthorisation), + "POST", + "/signing-baskets/BASKETID/authorisations", + "Start the authorisation process for a signing basket", + s"""${mockedDataText(false)} +Create an authorisation sub-resource and start the authorisation process of a signing basket. +The message might in addition transmit authentication and authorisation related data. + +This method is iterated n times for a n times SCA authorisation in a +corporate context, each creating an own authorisation sub-endpoint for +the corresponding PSU authorising the signing-baskets. + +The ASPSP might make the usage of this access method unnecessary in case +of only one SCA process needed, since the related authorisation resource +might be automatically created by the ASPSP after the submission of the +payment data with the first POST signing basket call. + +The start authorisation process is a process which is needed for creating a new authorisation +or cancellation sub-resource. + +This applies in the following scenarios: + + * The ASPSP has indicated with an 'startAuthorisation' hyperlink in the preceeding Payment + Initiation Response that an explicit start of the authorisation process is needed by the TPP. + The 'startAuthorisation' hyperlink can transport more information about data which needs to be + uploaded by using the extended forms. + * 'startAuthorisationWithPsuIdentfication', + * 'startAuthorisationWithPsuAuthentication' #TODO + * 'startAuthorisationWithAuthentciationMethodSelection' + * The related payment initiation cannot yet be executed since a multilevel SCA is mandated. + * The ASPSP has indicated with an 'startAuthorisation' hyperlink in the preceeding + Payment Cancellation Response that an explicit start of the authorisation process is needed by the TPP. + The 'startAuthorisation' hyperlink can transport more information about data which needs to be uploaded + by using the extended forms as indicated above. + * The related payment cancellation request cannot be applied yet since a multilevel SCA is mandate for + executing the cancellation. + * The signing basket needs to be authorised yet. +""", + EmptyBody, + json.parse("""{ + "challengeData" : { + "otpMaxLength" : 0, + "additionalInformation" : "additionalInformation", + "image" : "image", + "imageLink" : "http://example.com/aeiou", + "otpFormat" : "characters", + "data" : "data" + }, + "scaMethods" : "", + "scaStatus" : "psuAuthenticated", + "_links" : { + "scaStatus" : "/v1.3/payments/sepa-credit-transfers/1234-wertiq-983", + "startAuthorisationWithEncryptedPsuAuthentication" : "/v1.3/payments/sepa-credit-transfers/1234-wertiq-983", + "scaRedirect" : "/v1.3/payments/sepa-credit-transfers/1234-wertiq-983", + "selectAuthenticationMethod" : "/v1.3/payments/sepa-credit-transfers/1234-wertiq-983", + "startAuthorisationWithPsuAuthentication" : "/v1.3/payments/sepa-credit-transfers/1234-wertiq-983", + "authoriseTransaction" : "/v1.3/payments/sepa-credit-transfers/1234-wertiq-983", + "scaOAuth" : "/v1.3/payments/sepa-credit-transfers/1234-wertiq-983", + "updatePsuIdentification" : "/v1.3/payments/sepa-credit-transfers/1234-wertiq-983" + }, + "chosenScaMethod" : "", + "psuMessage" : { } +}"""), + List(AuthenticatedUserIsRequired, UnknownError), + apiTagSigningBaskets :: Nil, + http4sPartialFunction = Some(startSigningBasketAuthorisation) + ) + + // ── PUT /signing-baskets/BASKETID/authorisations/AUTHORISATIONID ─────── + val updateSigningBasketPsuData: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `bgV13Prefix` / "signing-baskets" / basketId / "authorisations" / authorisationId => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + for { + _ <- passesPsd2Pisp(callContext) + failMsg = s"$InvalidJsonFormat The Json body should be the $UpdatePaymentPsuDataJson " + updateBasketPsuDataJson <- NewStyle.function.tryons(failMsg, 400, callContext) { + json.parse(cc.httpBody.getOrElse("")).extract[UpdatePaymentPsuDataJson] + } + _ <- SigningBasketNewStyle.checkSigningBasketPayments(basketId, callContext) + (boxedChallenge, _) <- NewStyle.function.validateChallengeAnswerC5( + ChallengeType.BERLIN_GROUP_SIGNING_BASKETS_CHALLENGE, + None, + None, + Some(basketId), + authorisationId, + updateBasketPsuDataJson.scaAuthenticationData, + SuppliedAnswerType.PLAIN_TEXT_VALUE, + callContext + ) + (challenge, updatedCC) <- NewStyle.function.getChallenge(authorisationId, callContext) + _ <- challenge.scaStatus match { + case Some(status) if status.toString == StrongCustomerAuthenticationStatus.finalised.toString => + Future { + val basket = SigningBasketX.signingBasketProvider.vend.getSigningBasketByBasketId(basketId) + val existAll = + basket.flatMap(_.payments.map(_.forall(i => Connector.connector.vend.getTransactionRequestImpl(TransactionRequestId(i), updatedCC).isDefined))) + val alreadyCompleted: List[String] = + basket.flatMap(_.payments).getOrElse(Nil).filter { i => + Connector.connector.vend.getTransactionRequestImpl(TransactionRequestId(i), updatedCC) + .exists(_._1.status == COMPLETED.toString) + } + if (alreadyCompleted.nonEmpty) { + unboxFullOrFail(Empty, updatedCC, s"$InvalidConnectorResponse Some of paymentIds [${alreadyCompleted.mkString(",")}] are already completed") + } else if (existAll.getOrElse(false)) { + basket.map { i => + i.payments.map(_.map { i => + NewStyle.function.saveTransactionRequestStatusImpl(TransactionRequestId(i), COMPLETED.toString, updatedCC) + Connector.connector.vend.getTransactionRequestImpl(TransactionRequestId(i), updatedCC).map { t => + Connector.connector.vend.makePaymentV400(t._1, None, updatedCC) + } + }) + } + SigningBasketX.signingBasketProvider.vend.saveSigningBasketStatus(basketId, ConstantsBG.SigningBasketsStatus.ACTC.toString) + unboxFullOrFail(boxedChallenge, updatedCC, s"$InvalidConnectorResponse validateChallengeAnswerC5") + } else { + val paymentIds = basket.flatMap(_.payments).getOrElse(Nil).mkString(",") + unboxFullOrFail(Empty, updatedCC, s"$InvalidConnectorResponse Some of paymentIds [${paymentIds}] are invalid") + } + } + case Some(status) if status.toString == StrongCustomerAuthenticationStatus.failed.toString => + Future { + val basket = SigningBasketX.signingBasketProvider.vend.getSigningBasketByBasketId(basketId) + basket.map { i => + i.payments.map(_.map { i => + NewStyle.function.saveTransactionRequestStatusImpl(TransactionRequestId(i), REJECTED.toString, updatedCC) + }) + } + unboxFullOrFail(boxedChallenge, updatedCC, s"$InvalidConnectorResponse validateChallengeAnswerC5") + } + case _ => + Future(unboxFullOrFail(Empty, updatedCC, s"$InvalidConnectorResponse getChallenge")) + } + } yield { + JSONFactory_BERLIN_GROUP_1_3.createStartPaymentAuthorisationJson(challenge) + } + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(updateSigningBasketPsuData), + "PUT", + "/signing-baskets/BASKETID/authorisations/AUTHORISATIONID", + "Update PSU Data for signing basket", + s"""${mockedDataText(false)} +This method update PSU data on the signing basket resource if needed. +It may authorise a igning basket within the Embedded SCA Approach where needed. + +Independently from the SCA Approach it supports e.g. the selection of +the authentication method and a non-SCA PSU authentication. + +This methods updates PSU data on the cancellation authorisation resource if needed. + +There are several possible Update PSU Data requests in the context of a consent request if needed, +which depends on the SCA approach: + +* Redirect SCA Approach: + A specific Update PSU Data Request is applicable for + * the selection of authentication methods, before choosing the actual SCA approach. +* Decoupled SCA Approach: + A specific Update PSU Data Request is only applicable for + * adding the PSU Identification, if not provided yet in the Payment Initiation Request or the Account Information Consent Request, or if no OAuth2 access token is used, or + * the selection of authentication methods. +* Embedded SCA Approach: + The Update PSU Data Request might be used + * to add credentials as a first factor authentication data of the PSU and + * to select the authentication method and + * transaction authorisation. + +The SCA Approach might depend on the chosen SCA method. +For that reason, the following possible Update PSU Data request can apply to all SCA approaches: + +* Select an SCA method in case of several SCA methods are available for the customer. + +There are the following request types on this access path: + * Update PSU Identification + * Update PSU Authentication + * Select PSU Autorization Method + WARNING: This method need a reduced header, + therefore many optional elements are not present. + Maybe in a later version the access path will change. + * Transaction Authorisation + WARNING: This method need a reduced header, + therefore many optional elements are not present. + Maybe in a later version the access path will change. +""", + json.parse("""{"scaAuthenticationData":"123"}"""), + json.parse("""{ + "scaStatus":"finalised", + "authorisationId":"4f4a8b7f-9968-4183-92ab-ca512b396bfc", + "psuMessage":"Please check your SMS at a mobile device.", + "_links":{ + "scaStatus":"/v1.3/payments/sepa-credit-transfers/PAYMENT_ID/4f4a8b7f-9968-4183-92ab-ca512b396bfc" + } + }"""), + List(AuthenticatedUserIsRequired, UnknownError), + apiTagSigningBaskets :: Nil, + http4sPartialFunction = Some(updateSigningBasketPsuData) + ) + + val routes: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req => + createSigningBasket(req) + .orElse(deleteSigningBasket(req)) + .orElse(getSigningBasket(req)) + .orElse(getSigningBasketAuthorisation(req)) + .orElse(getSigningBasketScaStatus(req)) + .orElse(getSigningBasketStatus(req)) + .orElse(startSigningBasketAuthorisation(req)) + .orElse(updateSigningBasketPsuData(req)) + } +} diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/OBP_BERLIN_GROUP_1_3.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/OBP_BERLIN_GROUP_1_3.scala index 0fea84db81..6c9f41677a 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/OBP_BERLIN_GROUP_1_3.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/OBP_BERLIN_GROUP_1_3.scala @@ -70,11 +70,9 @@ object OBP_BERLIN_GROUP_1_3 extends OBPRestHelper with MdcLoggable with ScannedA APIMethods_SigningBasketsApi.resourceDocs ++ APIMethods_CommonServicesApi.resourceDocs - // Filter the possible endpoints by the disabled / enabled Props settings and add them together - override val routes : List[OBPEndpoint] = getAllowedEndpoints(endpoints, allResourceDocs) - - // Make them available for use! - registerRoutes(routes, allResourceDocs, apiPrefix) - - logger.info(s"version $version has been run! There are ${routes.length} routes.") + // All BG v1.3 routes are now served natively by Http4sBGv13.wrappedRoutes. + // Lift dispatch is retired: routes is Nil so registerRoutes is not called. + // allResourceDocs and endpoints are retained so ScannedApis version-discovery + // (and apiVersion equality checks) still resolve correctly. + override val routes: List[OBPEndpoint] = Nil } diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/OBP_BERLIN_GROUP_1_3_Alias.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/OBP_BERLIN_GROUP_1_3_Alias.scala index 35eaab593a..43a56d7a83 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/OBP_BERLIN_GROUP_1_3_Alias.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/OBP_BERLIN_GROUP_1_3_Alias.scala @@ -51,14 +51,8 @@ object OBP_BERLIN_GROUP_1_3_Alias extends OBPRestHelper with MdcLoggable with Sc )) } else ArrayBuffer.empty[ResourceDoc] - // Filter the possible endpoints by the disabled / enabled Props settings and add them together - override val routes: List[OBPEndpoint] = if(berlinGroupV13AliasPath.nonEmpty){ - getAllowedEndpoints(OBP_BERLIN_GROUP_1_3.endpoints, allResourceDocs) - } else List.empty[OBPEndpoint] - - // Make them available for use! - if(berlinGroupV13AliasPath.nonEmpty){ - registerRoutes(routes, allResourceDocs, apiPrefix) - logger.info(s"version $apiVersion has been run! There are ${routes.length} routes.") - } + // All BG v1.3 alias routes are now served natively by Http4sBGv13Alias.wrappedRoutes. + // Lift dispatch is retired: routes is Nil so registerRoutes is not called. + // allResourceDocs is retained for ScannedApis version-discovery. + override val routes: List[OBPEndpoint] = Nil } 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/ErrorResponseConverter.scala b/obp-api/src/main/scala/code/api/util/http4s/ErrorResponseConverter.scala index 101d142bec..1d838a7a08 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/ErrorResponseConverter.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/ErrorResponseConverter.scala @@ -3,12 +3,15 @@ package code.api.util.http4s import cats.effect._ import code.api.APIFailureNewStyle import code.api.JsonResponseException +import code.api.berlin.group.ConstantsBG +import code.api.berlin.group.v1_3.JSONFactory_BERLIN_GROUP_1_3.{ErrorMessageBG, ErrorMessagesBG} import code.api.util.APIUtil.JsonResponseExtractor +import code.api.util.BerlinGroupError import code.api.util.ErrorMessages._ import code.api.util.CallContext import net.liftweb.common.{Failure => LiftFailure} import net.liftweb.json.JsonParser.parse -import net.liftweb.json.compactRender +import net.liftweb.json.{Extraction, compactRender} import net.liftweb.json.JsonDSL._ import org.http4s._ import org.http4s.headers.`Content-Type` @@ -64,7 +67,7 @@ object ErrorResponseConverter { code: Int, message: String ) - + /** * Convert error response to JSON string using Lift JSON. */ @@ -72,6 +75,17 @@ object ErrorResponseConverter { val json = ("code" -> error.code) ~ ("message" -> error.message) compactRender(json) } + + private def isBerlinGroupRequest(callContext: CallContext): Boolean = + callContext.url.contains(ConstantsBG.berlinGroupVersion1.urlPrefix) + + /** Mirror the BG error body from APIUtil.failedJsonResponse for http4s paths. */ + private def toBgErrorBody(statusCode: Int, message: String, callContext: CallContext): String = { + val pathOpt = Some(callContext.url) + val codeText = BerlinGroupError.translateToBerlinGroupError(statusCode.toString, message) + val errBg = ErrorMessagesBG(tppMessages = List(ErrorMessageBG(category = "ERROR", code = codeText, path = pathOpt, text = message))) + compactRender(Extraction.decompose(errBg)) + } /** * Convert any error to http4s Response[IO]. @@ -100,11 +114,12 @@ object ErrorResponseConverter { /** Build a JSON error response using the supplied status code verbatim (used for * JsonResponseException, whose embedded JsonResponse already carries the final code). */ private def jsonErrorResponse(code: Int, message: String, callContext: CallContext): IO[Response[IO]] = { - val errorJson = OBPErrorResponse(code, message) + val body = if (isBerlinGroupRequest(callContext)) toBgErrorBody(code, message, callContext) + else toJsonString(OBPErrorResponse(code, message)) val status = org.http4s.Status.fromInt(code).getOrElse(org.http4s.Status.BadRequest) IO.pure( Response[IO](status) - .withEntity(toJsonString(errorJson)) + .withEntity(body) .withContentType(jsonContentType) .putHeaders(org.http4s.Header.Raw(CIString("Correlation-Id"), callContext.correlationId)) ) @@ -133,11 +148,12 @@ object ErrorResponseConverter { */ def apiFailureToResponse(failure: APIFailureNewStyle, callContext: CallContext): IO[Response[IO]] = { val resolvedCode = resolveStatusCode(failure.failCode, failure.failMsg, callContext) - val errorJson = OBPErrorResponse(resolvedCode, failure.failMsg) + val body = if (isBerlinGroupRequest(callContext)) toBgErrorBody(resolvedCode, failure.failMsg, callContext) + else toJsonString(OBPErrorResponse(resolvedCode, failure.failMsg)) val status = org.http4s.Status.fromInt(resolvedCode).getOrElse(org.http4s.Status.BadRequest) IO.pure( Response[IO](status) - .withEntity(toJsonString(errorJson)) + .withEntity(body) .withContentType(jsonContentType) .putHeaders(org.http4s.Header.Raw(CIString("Correlation-Id"), callContext.correlationId)) ) @@ -148,10 +164,11 @@ object ErrorResponseConverter { * Returns 400 Bad Request with failure message. */ def boxFailureToResponse(failure: LiftFailure, callContext: CallContext): IO[Response[IO]] = { - val errorJson = OBPErrorResponse(400, failure.msg) + val body = if (isBerlinGroupRequest(callContext)) toBgErrorBody(400, failure.msg, callContext) + else toJsonString(OBPErrorResponse(400, failure.msg)) IO.pure( Response[IO](org.http4s.Status.BadRequest) - .withEntity(toJsonString(errorJson)) + .withEntity(body) .withContentType(jsonContentType) .putHeaders(org.http4s.Header.Raw(CIString("Correlation-Id"), callContext.correlationId)) ) @@ -163,24 +180,27 @@ object ErrorResponseConverter { */ def unknownErrorToResponse(e: Throwable, callContext: CallContext): IO[Response[IO]] = { logger.error(s"unknownErrorToResponse says: 500 returned (correlationId=${callContext.correlationId})", e) - val errorJson = OBPErrorResponse(500, s"$UnknownError: ${e.getMessage}") + val message = s"$UnknownError: ${e.getMessage}" + val body = if (isBerlinGroupRequest(callContext)) toBgErrorBody(500, message, callContext) + else toJsonString(OBPErrorResponse(500, message)) IO.pure( Response[IO](org.http4s.Status.InternalServerError) - .withEntity(toJsonString(errorJson)) + .withEntity(body) .withContentType(jsonContentType) .putHeaders(org.http4s.Header.Raw(CIString("Correlation-Id"), callContext.correlationId)) ) } - + /** * Create error response with specific status code and message. */ def createErrorResponse(statusCode: Int, message: String, callContext: CallContext): IO[Response[IO]] = { - val errorJson = OBPErrorResponse(statusCode, message) + val body = if (isBerlinGroupRequest(callContext)) toBgErrorBody(statusCode, message, callContext) + else toJsonString(OBPErrorResponse(statusCode, message)) val status = org.http4s.Status.fromInt(statusCode).getOrElse(org.http4s.Status.BadRequest) IO.pure( Response[IO](status) - .withEntity(toJsonString(errorJson)) + .withEntity(body) .withContentType(jsonContentType) .putHeaders(org.http4s.Header.Raw(CIString("Correlation-Id"), callContext.correlationId)) ) diff --git a/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala b/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala index 93622dd0bc..94a692abfb 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala @@ -75,9 +75,13 @@ object Http4sApp { private val v600Routes: HttpRoutes[IO] = gate(ApiVersion.v6_0_0, code.api.v6_0_0.Http4s600.wrappedRoutesV600Services) private val v700Routes: HttpRoutes[IO] = gate(ApiVersion.v7_0_0, code.api.v7_0_0.Http4s700.wrappedRoutesV700Services) // DynamicEntity runtime CRUD (/obp/dynamic-entity/*) — native http4s, replaces the Lift - // OBPAPIDynamicEntity dispatch. dynamic-endpoint (proxy + compiled resource docs) is a - // separate task and still falls through to the Lift bridge. + // OBPAPIDynamicEntity dispatch. private val dynamicEntityRoutes: HttpRoutes[IO] = gate(ApiVersion.`dynamic-entity`, code.api.dynamic.entity.Http4sDynamicEntity.wrappedRoutesDynamicEntity) + // DynamicEndpoint dispatch (/obp/dynamic-endpoint/*) — proxy (DynamicReq) + runtime-compiled + // resource docs / practise. Runs the Lift OBPAPIDynamicEndpoint.routes in-process via an + // adapter, replacing their LiftRules.statelessDispatch registration. Must sit AHEAD of the + // Lift bridge (the bridge no longer carries dynamic-endpoint). + private val dynamicEndpointRoutes: HttpRoutes[IO] = gate(ApiVersion.`dynamic-endpoint`, code.api.dynamic.endpoint.Http4sDynamicEndpoint.wrappedRoutesDynamicEndpoint) /** * Build the base HTTP4S routes with priority-based routing. @@ -122,6 +126,8 @@ object Http4sApp { .orElse(v500Routes.run(req)) .orElse(v700Routes.run(req)) .orElse(code.api.berlin.group.v2.Http4sBGv2.wrappedRoutes.run(req)) + .orElse(code.api.berlin.group.v1_3.Http4sBGv13.wrappedRoutes.run(req)) + .orElse(code.api.berlin.group.v1_3.Http4sBGv13Alias.wrappedRoutes.run(req)) .orElse(v400Routes.run(req)) .orElse(v310Routes.run(req)) .orElse(v300Routes.run(req)) @@ -132,6 +138,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..01faf73c89 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala @@ -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. @@ -604,8 +625,8 @@ object Http4sCallContextBuilder { */ object ResourceDocMatcher extends code.util.Helper.MdcLoggable { - // API prefix pattern: /obp/vX.X.X - private val apiPrefixPattern = """^/obp/v\d+\.\d+\.\d+""".r + // API prefix pattern: //v — handles both OBP (/obp/vX.X.X) and BG (/berlin-group/v1.3) + private val apiPrefixPattern = """^/[^/]+/v[\d.]+""".r // Pre-built index type: (VERB, apiVersion, segmentCount) -> candidates type ResourceDocIndex = Map[(String, String, Int), List[ResourceDoc]] diff --git a/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala b/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala index 721f4fcca4..bc3c71a362 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala @@ -225,7 +225,8 @@ object ResourceDocMiddleware extends MdcLoggable { val initialContext = ValidationContext(callContext = cc) // Validation order MUST match Lift's wrappedWithAuthCheck (APIUtil.scala:1934-1969): - // auth → bank → roles → account → view → counterparty + // beforeAuthenticateInterceptors (= validateQueryParams / validateAuthType) first, + // then auth → bank → roles → account → view → counterparty // → afterAuthenticateInterceptors (= Force-Error / AuthType / JsonSchema) // Per Lift's own comment: "A Bank MUST be checked before Roles. In opposite case // we get next paradox: We set non existing bank → We get error that we don't @@ -236,7 +237,8 @@ object ResourceDocMiddleware extends MdcLoggable { // doc's role names) when Force-Error: OBP-20006 is sent and the natural // role check would also fail. val result: Validation[ValidationContext] = for { - context <- authenticate(req, resourceDoc, initialContext) + context <- validateDuplicateQueryParams(cc, initialContext) + context <- authenticate(req, resourceDoc, context) context <- validateBank(pathParams, context) context <- authorizeRoles(resourceDoc, pathParams, context) context <- validateAccount(pathParams, context) @@ -458,6 +460,32 @@ object ResourceDocMiddleware extends MdcLoggable { } } + /** + * Port of `APIUtil.validateQueryParams` (a `beforeAuthenticateInterceptor` in Lift). + * Rejects requests with duplicate query-parameter names with 400 + * `DuplicateQueryParameters`. Returns a plain OBP `{"message":"..."}` body (not BG + * format) to match Lift's `createErrorJsonResponse` output — the test asserts on + * `ErrorMessage.message`, not `ErrorMessagesBG.tppMessages`. + */ + private def validateDuplicateQueryParams(cc: CallContext, ctx: ValidationContext): Validation[ValidationContext] = { + import DSL._ + val queryString = if (cc.url.contains("?")) cc.url.split("\\?", 2)(1) else "" + val paramNames = queryString.split("&").map(s => s.split("=", 2)(0)).filter(_.nonEmpty) + val hasDuplicates = paramNames.groupBy(identity).exists(_._2.length > 1) + if (hasDuplicates) { + import net.liftweb.json.JsonDSL._ + import net.liftweb.json.compactRender + // Match Lift's createErrorJsonResponse: {"code": 400, "message": "OBP-XXXXX: ..."} + // The test asserts extract[ErrorMessage].message where ErrorMessage(code: Int, message: String). + val body = compactRender(("code" -> 400) ~ ("message" -> code.api.util.ErrorMessages.DuplicateQueryParameters)) + val resp = Response[IO](org.http4s.Status.BadRequest) + .withEntity(body.getBytes("UTF-8")) + .withContentType(jsonContentType) + EitherT.leftT[IO, ValidationContext](resp) + } else + success(ctx) + } + /** Bank validation: checks BANK_ID and fetches bank */ private def validateBank(pathParams: Map[String, String], ctx: ValidationContext): Validation[ValidationContext] = { 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))