From 784261f23e33a4eaafe83ec7bb20403d395d08b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Mon, 11 May 2026 12:30:36 +0200 Subject: [PATCH 01/30] docs: add firehose-pattern and auth status code gotchas to CLAUDE.md --- CLAUDE.md | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index b8978a66fb..57df8ce47b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -149,7 +149,28 @@ case req @ POST -> `prefixPath` / "banks" / _ / "accounts" / accountIdStr / "vie ``` `checkBankAccountExists` returns `OBPReturnType[Box[BankAccount]]` = `Future[(Box[BankAccount], Option[CC])]`. Extract the `Box` with `.map(_._1)`. `unboxFullOrFail` with default `emptyBoxErrorCode=400` throws a JSON-encoded 400 exception that `ErrorResponseConverter` parses correctly. -**Auth failure status code — Old Style vs New Style**: `ResourceDocMiddleware.authenticate` returns 400 for auth failures (locked user, invalid DAuth JWT) on Old Style endpoints (v1.2.1, v1.3.0, v1.4.0, v2.0.0) and 401 on New Style endpoints (v2.1.0+). The version check is in the `case Left(e)` branch: `oldStyleShortVersions.contains(resourceDoc.implementedInApiVersion.apiShortVersion)`. `anonymousAccess` always converts Failure boxes to `Exception(json_of_APIFailureNewStyle)` with `failCode=401` via `fullBoxOrException`. The Old Style 400 is a deliberate override for backward compatibility. +**Auth failure status code — Old Style vs New Style**: `ResourceDocMiddleware.authenticate` returns **400** for auth failures (locked user, invalid DAuth JWT, etc.) on Old Style endpoints (v1.2.1, v1.3.0, v1.4.0, v2.0.0) and **401** on New Style endpoints (v2.1.0+). Internally, `anonymousAccess` always converts Failure boxes to a thrown `Exception(json_of_APIFailureNewStyle)` with `failCode=401` via `fullBoxOrException`. The `case Left(e)` branch in `authenticate` parses the JSON, then overrides to 400 for Old Style versions via `oldStyleShortVersions.contains(resourceDoc.implementedInApiVersion.apiShortVersion)`. If a new version file returns the wrong code, check: (1) `implementedInApiVersion` is set correctly, and (2) the version is/isn't in `oldStyleShortVersions`. + +**Prop check before role check (firehose-pattern)**: Some endpoints must enforce a feature-flag prop check (→ 400) *before* a role check (→ 403), and both *before* the bank/account lookup (→ 404). Middleware processes roles then bank, so putting roles in the ResourceDoc causes 403 before the prop runs; using `withUserAndBank` causes 404 for fake bank IDs before either check. The fix: +1. Use `withUser` (auth only — no bank/account resolution from middleware). +2. Use non-standard ALL_CAPS vars in the ResourceDoc URL template (`FIREHOSE_BANK_ID`, `FIREHOSE_VIEW_ID`) so middleware skips bank/view validation. +3. In the handler body: prop check first (booleanToFuture → 400), then role check with `booleanToFuture(failCode=403)` (→ 403), then manual `NewStyle.function.getBank(...)` (→ 404 for unknown bank). +4. Keep roles **out** of the ResourceDoc (`None` instead of `Some(List(...))`). +```scala +EndpointHelpers.withUser(req) { (user, cc) => + val roles = ApiRole.canUseAccountFirehose :: canUseAccountFirehoseAtAnyBank :: Nil + val roleMsg = UserHasMissingRoles + roles.mkString(" or ") + for { + _ <- code.util.Helper.booleanToFuture(AccountFirehoseNotAllowedOnThisInstance, cc = Some(cc)) { allowAccountFirehose } + _ <- code.util.Helper.booleanToFuture(roleMsg, failCode = 403, cc = Some(cc)) { + APIUtil.hasAtLeastOneEntitlement(bankIdStr, user.userId, roles) } + (bank, _) <- NewStyle.function.getBank(BankId(bankIdStr), Some(cc)) + ... + } yield ... +} +// ResourceDoc: +resourceDocs += ResourceDoc(null, ..., "/banks/FIREHOSE_BANK_ID/firehose/...", ..., None, ...) +``` **`ResourceDoc` description and `needsAuthentication`**: The `ResourceDoc` constructor removes `AuthenticatedUserIsRequired` from `errorResponseBodies` when `description.contains(authenticationIsOptional) && rolesIsEmpty`. `needsAuthentication = errorResponseBodies.contains($AuthenticatedUserIsRequired) || roles.nonEmpty`. If the description embeds `${userAuthenticationMessage(false)}` (which includes `authenticationIsOptional`) and roles are empty, the error is silently removed → `needsAuthentication=false` → anonymous access → unauthenticated requests reach the handler. Fix: remove `${userAuthenticationMessage(false)}` from the description when `AuthenticatedUserIsRequired` must remain in the error list. From a40b5ac42429e8da0de0fdc0836bd65b99f1d98c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Tue, 12 May 2026 17:28:45 +0200 Subject: [PATCH 02/30] =?UTF-8?q?feat:=20migrate=20v3.1.0=20to=20http4s=20?= =?UTF-8?q?=E2=80=94=20Http4s310.scala?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 100 of 102 functional endpoints (42 GET, 10 DELETE, 19 POST, 25 PUT, 1 GET-shaped revoke, 3 SCA aliases) on native http4s + path-rewriting bridge to Http4s300; all 181 v3.1.0 tests pass. Two endpoints intentionally remain on the Lift bridge and are tracked in LIFT_HTTP4S_MIGRATION.md "Per-version Lift leftovers": - getMessageDocsSwagger — absorbed by future Http4sResourceDocs workstream - getObpConnectorLoopback — deprecated stub that throws NotImplemented Side-fixes uncovered while making v3.1.0 tests pass: - ResourceDocMiddleware joins missing roles with " or " (was ", ") to match NewStyle.function.hasAtLeastOneEntitlement convention. - Http4s200.getPrivateAccountsAtOneBank now returns raw List[BasicAccountJSON] (JArray), matching Lift; the sibling /accounts/private endpoint still wraps in BasicAccountsJSON. Docs: - CLAUDE.md gains 11 new gotchas (empty path segments, RuntimeException → 500, role check before body parse, DELETE returns 200 vs 204, etc.) and the comma-separated -DwildcardSuites recipe for running tests locally. - LIFT_HTTP4S_MIGRATION.md adds a "Per-version Lift leftovers" tracking table and folds getMessageDocsSwagger into the resource-docs workstream steps. --- CLAUDE.md | 60 + LIFT_HTTP4S_MIGRATION.md | 33 +- .../code/api/util/http4s/Http4sApp.scala | 2 + .../util/http4s/ResourceDocMiddleware.scala | 2 +- .../scala/code/api/v2_0_0/Http4s200.scala | 13 +- .../scala/code/api/v3_1_0/Http4s310.scala | 3648 +++++++++++++++++ 6 files changed, 3747 insertions(+), 11 deletions(-) create mode 100644 obp-api/src/main/scala/code/api/v3_1_0/Http4s310.scala diff --git a/CLAUDE.md b/CLAUDE.md index 57df8ce47b..0e0f4a6bbd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -178,6 +178,66 @@ resourceDocs += ResourceDoc(null, ..., "/banks/FIREHOSE_BANK_ID/firehose/...", . **CI**: Tests run with `mvn test -DwildcardSuites="..."`. `hikari.maximumPoolSize=20` required in test props for concurrent tests (`withRequestTransaction` holds 1 connection per request; rate-limit queries need a 2nd → pool of 10 exhausts at 5 concurrent requests). +**Running tests for a single API version locally**: `-DwildcardSuites="code.api.v3_1_0"` (just the package prefix, no `.*`) discovers zero tests — the prefix form only works in the CI workflow's piped invocation. From the shell, pass an explicit **comma-separated list of fully qualified suite class names**. Generate it once per version: +```sh +ls obp-api/src/test/scala/code/api/v3_1_0/*.scala | xargs -n1 basename | sed 's/\.scala$//' \ + | grep -v 'ServerSetup' | sed 's/^/code.api.v3_1_0./' | tr '\n' ',' | sed 's/,$//' +``` +Pipe that into `-DwildcardSuites=`. The `ServerSetup` filter skips the abstract base trait — it has no own tests and listing it makes scalatest abort discovery. Add `-DfailIfNoTests=false` so an empty match doesn't fail the build. + +**Surefire reports beat truncated maven output**: When a `mvn test` invocation has hundreds of failures, the run summary at the tail says e.g. `*** 23 TESTS FAILED ***` but the individual failure messages are scrolled off. Don't re-run; mine `obp-api/target/surefire-reports/TEST-*.xml` instead. Suites with failures have `failures=` or `errors=` >0; per-testcase failures are `` elements. Quick extract: +```sh +python3 -c " +import xml.etree.ElementTree as ET +t = ET.parse('TEST-code.api.v3_1_0.AccountTest.xml').getroot() +for tc in t.findall('testcase'): + fail = tc.find('failure') + if fail is not None: + print(tc.get('name')[:120], '--', (fail.get('message') or '')[:200]) +" +``` +The `` element's *text* contains the full stack trace + the lift-json `MappingException` body dump — read that when the message alone (`"500 did not equal 400"`) isn't enough to find the failing assertion. + +**Empty path segments fall into http4s patterns that should reject them**: A Lift test like `getSystemView("")` builds URL `/system-views/`. http4s's `Path` keeps the trailing empty segment, so `case GET -> prefixPath / "system-views" / viewIdStr` matches with `viewIdStr = ""`. Meanwhile `ResourceDocMatcher.matchesUrlTemplate` filters empty segments via `.split("/").filter(_.nonEmpty)`, so the matcher sees 1 segment vs the template's 2 — no doc match → middleware skips auth/role validation and falls through to your handler with `viewIdStr = ""`. The handler then throws inside the business logic → 500 (test expected 401/403 from middleware). Fix: add a pattern guard so empty viewId doesn't match and the request falls through to the Lift bridge: `case req @ GET -> prefixPath / "system-views" / viewIdStr if viewIdStr.nonEmpty =>`. Apply to GET/PUT/DELETE variants. + +**Throwing a `RuntimeException` in Lift returns 500, not 400**: When porting Lift code like: +```scala +(fromAccount, _) <- if (...) for { ... } else if (...) for { ... } + else throw new RuntimeException(s"$InvalidJsonFormat ...") +``` +the `throw` synthesises a 500 response in the http4s path (test expects 400). Lift sometimes converted these to 400 via its exception handler; the http4s migration does not. Replace the throw with an upfront `code.util.Helper.booleanToFuture(failMsg, cc = Some(cc)) { validShape }` *before* the if/else — `booleanToFuture` defaults to `failCode = 400`. This also flattens nested else-branch logic. + +**Middleware role check runs before body parsing**: When a ResourceDoc declares `Some(List(canX))`, the middleware enforces the role in the **auth/role validation** phase, which precedes the handler. Tests that send malformed JSON expecting 400 (InvalidJsonFormat) instead get 403 (UserHasMissingRoles) because the user lacks the role. Fix: when a test asserts body-validation 400s should fire *before* role 403s, take the role out of the ResourceDoc (`None` for roles) and check it inline inside the for-comp with `code.util.Helper.booleanToFuture(failMsg, failCode = 403, cc = Some(cc)) { APIUtil.hasEntitlement(...) }`. This is a generalisation of the firehose-pattern documented above — it applies to any POST/PUT where the test ordering is "bad body → 400" before "missing role → 403." + +**ResourceDoc role and handler role disagreement is a Lift-only quirk**: Some Lift endpoints declare role X in the `ResourceDoc(...)` metadata but actually check role Y inline via `NewStyle.function.hasEntitlement(Y, ...)`. Example: `updateCustomerBranch` Lift had `Some(canUpdateCustomerIdentity :: Nil)` in the doc but called `hasEntitlement(canUpdateCustomerBranch, ...)` in the handler. Lift never enforced doc roles, so the inline check was the real gate and tests passed by granting role Y. The http4s middleware **does** enforce doc roles, so the discrepancy produces 403 with the wrong message. Always cross-check the test's `.addEntitlement(... CanXyz ...)` calls and put the matching role in the http4s ResourceDoc, OR set roles to `None` and rely on the inline check exclusively. + +**Most v3.1.0 DELETEs return 200, not 204**: The CLAUDE.md helper matrix says "DELETE → 204" but in practice many v3.1.0 endpoints return `(Full(deletedThing), HttpCode.\`200\`(cc))` — 200 with a body. Mirror Lift: use `withUser` / `withUserAndBank` (which return 200) for these, **not** `withUserDelete` / `withUserAndBankDelete` (which return 204). Reserve the `*Delete` helpers for endpoints that genuinely return 204 (verified examples in v3.1.0: `deleteProductAttribute`, `deleteCardForBank`). The HTTP method comes from the route pattern (`case req @ DELETE -> ...`), not the helper name. + +**Bug-compatibility with Lift error strings**: Some Lift endpoints have copy-paste bugs in their error messages that tests assert on verbatim. Example: `getFirehoseCustomers` (customer firehose) uses the constant `AccountFirehoseNotAllowedOnThisInstance` (account firehose's error message). The test asserts on this exact string. Preserve the bug in the http4s migration — adding a `// Lift used X here despite this being Y — preserve the message verbatim (the test asserts it).` comment is the right move. Fixing the bug means also patching the test, which expands the PR scope. + +**`extract[List[X]]` requires a JArray at the top level**: lift-json's extraction is strict about the root shape. If a Lift endpoint returns `Extraction.decompose(myList: List[X])` (root JArray) and the http4s migration changes it to `myList.wrappedIn(Container)` (root JObject), tests doing `response.body.extract[List[X]]` fail with `MappingException: Expected collection but got JObject`. Cross-reference Lift's JSON factory exactly — pay attention to whether it wraps in a case class (`{accounts: [...]}`) or decomposes a raw list (`[...]`). Two examples that look identical but aren't: +- `/banks/BANK_ID/accounts` → Lift returns raw `List[BasicAccountJSON]` (JArray) +- `/banks/BANK_ID/accounts/private` → Lift returns `BasicAccountsJSON(accounts)` (JObject) + +**Missing-role error message: `" or "` not `", "`**: The middleware joins multiple missing roles with `" or "` to match `NewStyle.function.hasAtLeastOneEntitlement`'s convention, which every test asserts as `UserHasMissingRoles + roles.mkString(" or ")`. If you add a new role-check path bypassing the middleware (e.g. inline `booleanToFuture`), use the same `" or "` joiner. + +**Custom JSON body parse error format**: Some tests assert the parse-failure message starts with a specific string like `"OBP-10001: Incorrect json format. The Json body should be the CreateMeetingJson "`. The standard `withUserAndBankAndBodyCreated[B, A]` helper produces a different format (`"$InvalidJsonFormat ${classSimpleName}"` — `"CreateMeetingJsonV310"`, no leading "The Json body should be the..."). When a test asserts the Lift wording verbatim, bypass the body helper and parse manually: +```scala +EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + for { + parsed <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the ${classOf[ExpectedType].getSimpleName} ", + 400, Some(cc)) { net.liftweb.json.parse(rawBody).extract[ExpectedType] } + ... + } yield ... +} +``` +Note: `executeFutureCreated` returns 201; pair it with `cc.user.openOrThrowException(...)` / `cc.bank.getOrElse(...)` since middleware has already validated auth/bank. + +**Use `NEW_ACCOUNT_ID` for PUT-creates-account URLs**: When a `PUT /banks/BANK_ID/accounts/ACCOUNT_ID` *creates* the account (it doesn't exist yet), the middleware's `validateAccount` keys off the literal `ACCOUNT_ID` template var and tries to look it up → 404 before the handler runs. Change the ResourceDoc URL template to `/banks/BANK_ID/accounts/NEW_ACCOUNT_ID` (or any non-standard ALL_CAPS variant) — middleware treats it as a wildcard and skips the lookup, but the path still matches the route pattern. The handler can check "already exists" inline with `Connector.connector.vend.checkBankAccountExists(...)` and return 409/400 as needed. + ## CI Performance Profile Measured from a 3-shard run (2691 tests total, all passing). Numbers are stable across shards. diff --git a/LIFT_HTTP4S_MIGRATION.md b/LIFT_HTTP4S_MIGRATION.md index 6e5e6a3377..5cfa9a803d 100644 --- a/LIFT_HTTP4S_MIGRATION.md +++ b/LIFT_HTTP4S_MIGRATION.md @@ -29,7 +29,7 @@ New API versions are implemented as native http4s routes and do not pass through ### Priority routing -Routes are tried in order: `corsHandler` (OPTIONS) → `AppsPage` → `StatusPage` → `Http4s500` → `Http4s700` → `Http4sBGv2` → `Http4s300` → `Http4s220` → `Http4s210` → `Http4s200` → `Http4s140` → `Http4s130` → `Http4s121` → `Http4sLiftWebBridge` (Lift fallback). Unhandled `/obp/v7.0.0/*` paths fall through silently to Lift — they do not 404. +Routes are tried in order: `corsHandler` (OPTIONS) → `AppsPage` → `StatusPage` → `Http4s500` → `Http4s700` → `Http4sBGv2` → `Http4s310` → `Http4s300` → `Http4s220` → `Http4s210` → `Http4s200` → `Http4s140` → `Http4s130` → `Http4s121` → `Http4sLiftWebBridge` (Lift fallback). Unhandled `/obp/v7.0.0/*` paths fall through silently to Lift — they do not 404. ``` HTTP Request @@ -119,7 +119,7 @@ Bottom-up — each version depends on the one below it being done. | 5 | `APIMethods210` | 28 | **Done** — `Http4s210.scala`: 25 own endpoints + path-rewriting bridge to `Http4s200`; all 79 v2.1.0 tests pass | | 6 | `APIMethods220` | 19 | **Done** — `Http4s220.scala`: 18 own endpoints + path-rewriting bridge to `Http4s210`; all 27 v2.2.0 tests pass | | 7 | `APIMethods300` | 47 | **Done** — `Http4s300.scala`: 47 own endpoints + path-rewriting bridge to `Http4s220`; all 86 v3.0.0 tests pass | -| 8 | `APIMethods310` | 102 | | +| 8 | `APIMethods310` | 102 | **Done** — `Http4s310.scala` has all 100 functional endpoints (42 GET, 10 DELETE, 19 POST, 25 PUT, 1 GET-shaped revoke, 3 SCA aliases) + path-rewriting bridge to `Http4s300`; 181 v3.1.0 tests pass. Two endpoints tracked separately in "Per-version Lift leftovers" (`getMessageDocsSwagger`, `getObpConnectorLoopback`) — they retire via the Resource-docs workstream / bridge-removal PR, not as v3.1.0 follow-up. | | 9 | `APIMethods400` | ~258 total | Largest file; may need splitting into sub-traits | | 10 | `APIMethods500` | 37 | | | 11 | `APIMethods510` | 111 | | @@ -136,8 +136,9 @@ Resource-docs endpoints are **version-polymorphic**: `GET /obp/v6.0.0/resource-d Add one service to `Http4sApp` (above the Lift bridge, before any per-version service) that handles: ``` -GET /obp/*/resource-docs/API_VERSION/obp → version-dispatch via getResourceDocsList +GET /obp/*/resource-docs/API_VERSION/obp → version-dispatch via getResourceDocsList GET /obp/*/resource-docs/API_VERSION/openapi.yaml +GET /obp/*/message-docs/CONNECTOR/swagger2.0 → absorbs APIMethods310.getMessageDocsSwagger ``` The wildcard prefix means all resource-doc requests are intercepted regardless of which version prefix the client uses. This workstream is **independent of the per-version migration order** — it can land at any time and immediately removes all resource-docs traffic from the Lift bridge. @@ -159,7 +160,8 @@ Currently served via a raw Lift `serve { case Req(..., "openapi.yaml", ...) }` b 1. Fix aggregation bug in `getResourceDocsObpV700` → make `V7ResourceDocsAggregationTest` pass. 2. Extract shared handler logic into `Http4sResourceDocs` service; wire into `Http4sApp`. 3. Add `openapi.yaml` route to the same service. -4. Remove resource-docs from the per-version Lift objects (`ResourceDocs140`–`ResourceDocs600`) once the centralized service covers them. +4. Port `getMessageDocsSwagger` from `APIMethods310` into the same service (currently still served by the Lift bridge — see "Per-version Lift leftovers" below). +5. Remove resource-docs from the per-version Lift objects (`ResourceDocs140`–`ResourceDocs600`) once the centralized service covers them. --- @@ -178,6 +180,19 @@ These are the last hard dependency on Lift Web in the request path. The Lift bri --- +## Per-version Lift leftovers + +An `APIMethods{version}` file is marked **done** in the progress table when every *functional* endpoint is on http4s and the version's test suite is green. A small number of endpoints are deliberately *not* migrated inline because they belong to a different workstream or have no behaviour worth porting. They continue to be served by the Lift bridge until the workstream that owns them lands; they do **not** create new follow-up work on the per-version file. + +| Endpoint | Origin | Why on Lift | Retired by | +|---|---|---|---| +| `getMessageDocsSwagger` (`GET /message-docs/CONNECTOR/swagger2.0`) | `APIMethods310` | Same shape as `getResourceDocsObpV700` / `openapi.yaml` — runtime Swagger generation with shared caching | The **Http4sResourceDocs** workstream (step 4) | +| `getObpConnectorLoopback` (`GET /connector/loopback`) | `APIMethods310` | Deprecated stub that unconditionally throws `IllegalStateException(NotImplemented)`; no functional behaviour | Either a 3-line native http4s route that throws the same exception or outright deletion, decided when the Lift bridge is removed | + +Track new leftovers here when later version files are migrated — the bridge-removal milestone in "Done Criteria" only requires the per-version files to be **done** in this table's sense (functional endpoints migrated, tests green). Leftovers folded into the Resource-docs or Auth-stack workstreams retire via those workstreams. + +--- + ## Server Chain After Full Migration ``` @@ -206,10 +221,10 @@ corsHandler | Milestone | Condition | |---|---| -| Version file done | All endpoints are `HttpRoutes[IO]`; `OBPRestHelper` removed from the file; existing tests pass | -| Lift bridge removable | All 12 APIMethods files done + auth stack done | -| Lift Web removed | `lift-webkit` removed from `pom.xml`; `Boot.scala` reduced to DB init + scheduler startup | -| `lift-mapper` | Separate long-term effort — not in scope here | +| Version file done | All *functional* endpoints are `HttpRoutes[IO]`; the version's test suite is green. Endpoints folded into the Resource-docs / Auth-stack workstreams or marked as non-functional stubs are listed in "Per-version Lift leftovers" rather than blocking the file's done status. | +| Lift bridge removable | All 12 APIMethods files done (per the row above) + auth stack done + Resource-docs workstream done. Any remaining stubs from "Per-version Lift leftovers" are ported or deleted in the bridge-removal PR. | +| Lift Web removed | `lift-webkit` removed from `pom.xml`; `Boot.scala` reduced to DB init + scheduler startup. | +| `lift-mapper` | Separate long-term effort — not in scope here. | --- @@ -245,7 +260,7 @@ Binds to `hostname` / `dev.port` from your props file (defaults: `127.0.0.1:8080 | `APIMethods210` | done — `Http4s210.scala` (25 own endpoints; path-rewriting bridge to Http4s200) | | `APIMethods220` | done — `Http4s220.scala` (18 own endpoints; path-rewriting bridge to Http4s210) | | `APIMethods300` | done — `Http4s300.scala` (47 own endpoints; path-rewriting bridge to Http4s220; all 86 v3.0.0 tests pass) | -| `APIMethods310` | todo | +| `APIMethods310` | done — `Http4s310.scala` (100 own endpoints; path-rewriting bridge to Http4s300; 2 endpoints intentionally left on Lift: `getMessageDocsSwagger`, `getObpConnectorLoopback`) | | `APIMethods400` | todo | | `APIMethods500` | todo | | `APIMethods510` | todo | 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 7b0d3c0eec..21cdb41146 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 @@ -67,6 +67,7 @@ object Http4sApp { private val v210Routes: HttpRoutes[IO] = gate(ApiVersion.v2_1_0, code.api.v2_1_0.Http4s210.wrappedRoutesV210Services) private val v220Routes: HttpRoutes[IO] = gate(ApiVersion.v2_2_0, code.api.v2_2_0.Http4s220.wrappedRoutesV220Services) private val v300Routes: HttpRoutes[IO] = gate(ApiVersion.v3_0_0, code.api.v3_0_0.Http4s300.wrappedRoutesV300Services) + private val v310Routes: HttpRoutes[IO] = gate(ApiVersion.v3_1_0, code.api.v3_1_0.Http4s310.wrappedRoutesV310Services) private val v500Routes: HttpRoutes[IO] = gate(ApiVersion.v5_0_0, code.api.v5_0_0.Http4s500.wrappedRoutesV500Services) private val v700Routes: HttpRoutes[IO] = gate(ApiVersion.v7_0_0, code.api.v7_0_0.Http4s700.wrappedRoutesV700Services) @@ -80,6 +81,7 @@ object Http4sApp { .orElse(v500Routes.run(req)) .orElse(v700Routes.run(req)) .orElse(code.api.berlin.group.v2.Http4sBGv2.wrappedRoutes.run(req)) + .orElse(v310Routes.run(req)) .orElse(v300Routes.run(req)) .orElse(v220Routes.run(req)) .orElse(v210Routes.run(req)) diff --git a/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala b/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala index 6ae7cfe362..f4ceeffffe 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 @@ -353,7 +353,7 @@ object ResourceDocMiddleware extends MdcLoggable { } if (ok) success(ctx) else EitherT[IO, Response[IO], ValidationContext]( - ErrorResponseConverter.createErrorResponse(403, UserHasMissingRoles + roles.mkString(", "), ctx.callContext) + ErrorResponseConverter.createErrorResponse(403, UserHasMissingRoles + roles.mkString(" or "), ctx.callContext) .map[Either[Response[IO], ValidationContext]](Left(_)) ) case _ => diff --git a/obp-api/src/main/scala/code/api/v2_0_0/Http4s200.scala b/obp-api/src/main/scala/code/api/v2_0_0/Http4s200.scala index 4ccfaee2de..c6910826ae 100644 --- a/obp-api/src/main/scala/code/api/v2_0_0/Http4s200.scala +++ b/obp-api/src/main/scala/code/api/v2_0_0/Http4s200.scala @@ -177,7 +177,18 @@ object Http4s200 { Views.views.vend.privateViewsUserCanAccessAtBank(user, bank.bankId) } (availablePrivateAccounts, _) <- BankExtended(bank).privateAccountsFuture(privateAccountAccess, Some(cc)) - } yield privateBankAccountsListToJson(availablePrivateAccounts, privateViewsUserCanAccessAtOneBank) + } yield { + // Lift returns a raw List[BasicAccountJSON] here (JArray), not BasicAccountsJSON + // (JObject). v3_1_0/AccountTest extracts `List[BasicAccountJSON]` directly, so we + // must NOT wrap; the sibling /banks/BANK_ID/accounts/private endpoint does wrap. + availablePrivateAccounts.map { account => + val viewsAvailable = privateViewsUserCanAccessAtOneBank + .filter(v => v.bankId == account.bankId && v.accountId == account.accountId && v.isPrivate) + .map(createBasicViewJSON) + .distinct + createBasicAccountJSON(account, viewsAvailable) + } + } } } diff --git a/obp-api/src/main/scala/code/api/v3_1_0/Http4s310.scala b/obp-api/src/main/scala/code/api/v3_1_0/Http4s310.scala new file mode 100644 index 0000000000..89a0d2787b --- /dev/null +++ b/obp-api/src/main/scala/code/api/v3_1_0/Http4s310.scala @@ -0,0 +1,3648 @@ +package code.api.v3_1_0 + +import cats.data.{Kleisli, OptionT} +import cats.effect._ +import code.api.Constant._ +import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON +import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON._ +import code.api.util.ExampleValue._ +import code.api.util.APIUtil.{EmptyBody, ResourceDoc, _} +import code.api.util.ApiRole +import code.api.util.ApiRole._ +import code.api.util.ApiTag._ +import code.api.util.ErrorMessages._ +import code.api.util.CertificateUtil +import code.api.util.{ApiTrigger, Consent, SecureRandomUtil} +import code.api.util.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps} +import code.api.util.http4s.ResourceDocMiddleware +import code.api.util.newstyle.{BalanceNewStyle, ViewNewStyle} +import code.api.util.{APIUtil, CallContext, CustomJsonFormats, NewStyle, OBPBankId, RateLimitingUtil} +import code.api.v1_2_1.{JSONFactory, RateLimiting} +import code.api.v2_1_0.{JSONFactory210, PutEnabledJSON} +import code.api.v3_0_0.{CreateViewJsonV300, JSONFactory300} +import code.api.v3_1_0.JSONFactory310._ +import code.bankconnectors.Connector +import code.consent.{ConsentStatus, Consents, DoobieConsentQueries, MappedConsent} +import code.methodrouting.{MethodRouting, MethodRoutingCommons, MethodRoutingParam} +import code.model.dataAccess.AuthUser +import code.consumer.Consumers +import code.entitlement.Entitlement +import code.loginattempts.LoginAttempt +import code.metrics.APIMetrics +import code.model._ +import code.ratelimiting.RateLimitingDI +import code.userlocks.UserLocksProvider +import code.users.Users +import code.views.Views +import code.webhook.AccountWebhook +import code.webuiprops.{MappedWebUiPropsProvider, WebUiPropsCommons} +import code.api.Constant +import code.model.dataAccess.BankAccountCreation +import com.openbankproject.commons.dto.GetProductsParam +import com.openbankproject.commons.model.enums.{AccountAttributeType, CardAttributeType, ProductAttributeType, StrongCustomerAuthentication} +import com.github.dwickern.macros.NameOf.nameOf +import com.openbankproject.commons.ExecutionContext.Implicits.global +import com.openbankproject.commons.model._ +import com.openbankproject.commons.util.{ApiVersion, ApiVersionStatus, ScannedApiVersion} +import net.liftweb.common.{Empty, Full} +import net.liftweb.http.provider.HTTPParam +import net.liftweb.json.Formats +import net.liftweb.mapper.By +import net.liftweb.util.{Helpers, Props} +import org.apache.commons.lang3.StringUtils + +import java.text.SimpleDateFormat +import java.util.regex.Pattern +import org.http4s._ +import org.http4s.dsl.io._ + +import scala.collection.mutable.ArrayBuffer +import scala.concurrent.Future + +object Http4s310 { + val implementedInApiVersion: ScannedApiVersion = ApiVersion.v3_1_0 + val versionStatus: String = ApiVersionStatus.STABLE.toString + val resourceDocs: ArrayBuffer[ResourceDoc] = ArrayBuffer[ResourceDoc]() + + implicit val formats: Formats = CustomJsonFormats.formats + + type HttpF[A] = OptionT[IO, A] + + object Implementations3_1_0 { + val prefixPath: Path = Root / ApiPathZero.toString / implementedInApiVersion.toString + + // ─── root ───────────────────────────────────────────────────────────────── + + val root: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` => + EndpointHelpers.executeAndRespond(req) { _ => + Future.successful(JSONFactory.getApiInfoJSON(ApiVersion.v3_1_0, versionStatus)) + } + case req @ GET -> `prefixPath` / "root" => + EndpointHelpers.executeAndRespond(req) { _ => + Future.successful(JSONFactory.getApiInfoJSON(ApiVersion.v3_1_0, versionStatus)) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(root), "GET", "/root", + "Get API Info (root)", + """Returns information about: + | + |* API version + |* Hosted by information + |* Git Commit""", + EmptyBody, apiInfoJSON, + List(UnknownError, MandatoryPropertyIsNotSet), apiTagApi :: Nil, None, + http4sPartialFunction = Some(root)) + + // ─── getCheckbookOrders ─────────────────────────────────────────────────── + + val getCheckbookOrders: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "checkbook" / "orders" => + EndpointHelpers.withView(req) { (_, account, _, cc) => + for { + (checkbookOrders, _) <- Connector.connector.vend.getCheckbookOrders( + account.bankId.value, account.accountId.value, Some(cc)) map { + unboxFullOrFail(_, Some(cc), InvalidConnectorResponseForGetCheckbookOrdersFuture) + } + } yield JSONFactory310.createCheckbookOrdersJson(checkbookOrders) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCheckbookOrders), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/checkbook/orders", + "Get Checkbook orders", + s"""${mockedDataText(false)}Get all checkbook orders""", + EmptyBody, checkbookOrdersJson, + List(AuthenticatedUserIsRequired, BankNotFound, BankAccountNotFound, + InvalidConnectorResponseForGetCheckbookOrdersFuture, UnknownError), + apiTagAccount :: Nil, None, + http4sPartialFunction = Some(getCheckbookOrders)) + + // ─── getStatusOfCreditCardOrder ─────────────────────────────────────────── + + val getStatusOfCreditCardOrder: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "credit_cards" / "orders" => + EndpointHelpers.withView(req) { (_, account, _, cc) => + for { + (cards, _) <- Connector.connector.vend.getStatusOfCreditCardOrder( + account.bankId.value, account.accountId.value, Some(cc)) map { + unboxFullOrFail(_, Some(cc), InvalidConnectorResponseForGetStatusOfCreditCardOrderFuture) + } + } yield JSONFactory310.createStatisOfCreditCardJson(cards) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getStatusOfCreditCardOrder), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/credit_cards/orders", + "Get status of Credit Card order ", + s"""${mockedDataText(false)}Get status of Credit Card orders + |Get all orders + |""", + EmptyBody, creditCardOrderStatusResponseJson, + List(AuthenticatedUserIsRequired, BankNotFound, BankAccountNotFound, + InvalidConnectorResponseForGetStatusOfCreditCardOrderFuture, UnknownError), + apiTagCard :: Nil, None, + http4sPartialFunction = Some(getStatusOfCreditCardOrder)) + + // ─── getTopAPIs ─────────────────────────────────────────────────────────── + + val getTopAPIs: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "metrics" / "top-apis" => + EndpointHelpers.withUser(req) { (_, cc) => + val httpParams = req.headers.headers.toList.map(h => HTTPParam(h.name.toString, h.value)) ::: + req.uri.query.multiParams.toList.flatMap { case (k, vs) => vs.map(v => HTTPParam(k, v)) } + for { + (params, _) <- createQueriesByHttpParamsFuture(httpParams, Some(cc)) + topApis <- APIMetrics.apiMetrics.vend.getTopApisFuture(params) map { + unboxFullOrFail(_, Some(cc), GetTopApisError) + } + } yield JSONFactory310.createTopApisJson(topApis) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getTopAPIs), "GET", + "/management/metrics/top-apis", + "Get Top APIs", + s"""Get metrics about the most popular APIs. e.g.: total count, response time (in ms), etc. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, topApisJson, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidFilterParameterFormat, + GetTopApisError, UnknownError), + apiTagMetric :: Nil, Some(List(canReadMetrics)), + http4sPartialFunction = Some(getTopAPIs)) + + // ─── getMetricsTopConsumers ─────────────────────────────────────────────── + + val getMetricsTopConsumers: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "metrics" / "top-consumers" => + EndpointHelpers.withUser(req) { (_, cc) => + val httpParams = req.headers.headers.toList.map(h => HTTPParam(h.name.toString, h.value)) ::: + req.uri.query.multiParams.toList.flatMap { case (k, vs) => vs.map(v => HTTPParam(k, v)) } + for { + (params, _) <- createQueriesByHttpParamsFuture(httpParams, Some(cc)) + topConsumers <- APIMetrics.apiMetrics.vend.getTopConsumersFuture(params) map { + unboxFullOrFail(_, Some(cc), GetMetricsTopConsumersError) + } + } yield JSONFactory310.createTopConsumersJson(topConsumers) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getMetricsTopConsumers), "GET", + "/management/metrics/top-consumers", + "Get Top Consumers", + s"""Get metrics about the top consumers of the API usage e.g. total count, consumer_id and app_name. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, topConsumersJson, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidFilterParameterFormat, + GetMetricsTopConsumersError, UnknownError), + apiTagMetric :: Nil, Some(List(canReadMetrics)), + http4sPartialFunction = Some(getMetricsTopConsumers)) + + // ─── getFirehoseCustomers ──────────────────────────────────────────────── + // Firehose pattern: prop check (→400) before role check (→403) before bank lookup (→404). + // Uses non-standard ALL_CAPS template var FIREHOSE_BANK_ID so middleware skips bank validation. + + val getFirehoseCustomers: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankIdStr / "firehose" / "customers" => + EndpointHelpers.withUser(req) { (user, cc) => + val roles = ApiRole.canUseCustomerFirehose :: canUseCustomerFirehoseAtAnyBank :: Nil + val roleMsg = UserHasMissingRoles + roles.mkString(" or ") + for { + // Lift used AccountFirehoseNotAllowedOnThisInstance here despite this being the + // customer firehose endpoint — preserve the message verbatim (the test asserts it). + _ <- code.util.Helper.booleanToFuture(AccountFirehoseNotAllowedOnThisInstance, cc = Some(cc)) { + allowCustomerFirehose + } + _ <- code.util.Helper.booleanToFuture(roleMsg, failCode = 403, cc = Some(cc)) { + APIUtil.hasAtLeastOneEntitlement(bankIdStr, user.userId, roles) + } + (_, _) <- NewStyle.function.getBank(BankId(bankIdStr), Some(cc)) + allowedParams = List("sort_direction", "limit", "offset", "from_date", "to_date") + httpParams = req.headers.headers.toList.map(h => HTTPParam(h.name.toString, h.value)) ::: + req.uri.query.multiParams.toList.flatMap { case (k, vs) => vs.map(v => HTTPParam(k, v)) } + (obpQueryParams, _) <- NewStyle.function.createObpParams(httpParams, allowedParams, Some(cc)) + customers <- NewStyle.function.getCustomers(BankId(bankIdStr), Some(cc), obpQueryParams) + reqParams: Map[String, List[String]] = req.uri.query.multiParams + .filterNot { case (k, _) => allowedParams.contains(k) } + .map { case (k, vs) => k -> vs.toList } + customersFiltered <- if (reqParams.isEmpty) Future.successful(customers) + else for { + (customerIds, _) <- NewStyle.function.getCustomerIdsByAttributeNameValues(BankId(bankIdStr), reqParams, Some(cc)) + } yield customers.filter(customer => customerIds.contains(CustomerId(customer.customerId))) + } yield JSONFactory300.createCustomersJson(customersFiltered) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getFirehoseCustomers), "GET", + "/banks/FIREHOSE_BANK_ID/firehose/customers", + "Get Firehose Customers", + s""" + |Get Customers that has a firehose View. + | + |Allows bulk access to customers. + |User must have the CanUseFirehoseAtAnyBank Role + | + |${urlParametersDocument(true, true)} + | + |${userAuthenticationMessage(true)} + |""".stripMargin, + EmptyBody, customerJSONs, + List(AuthenticatedUserIsRequired, CustomerFirehoseNotAllowedOnThisInstance, + UserHasMissingRoles, UnknownError), + List(apiTagCustomer, apiTagFirehoseData), None, + http4sPartialFunction = Some(getFirehoseCustomers)) + + // ─── getBadLoginStatus ──────────────────────────────────────────────────── + + val getBadLoginStatus: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "users" / username / "lock-status" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + _ <- NewStyle.function.hasEntitlement("", user.userId, ApiRole.canReadUserLockedStatus, Some(cc)) + _ <- Users.users.vend.getUserByProviderAndUsernameFuture(Constant.localIdentityProvider, username) map { + x => unboxFullOrFail(x, Some(cc), UserNotFoundByProviderAndUsername, 404) + } + badLoginStatus <- Future { + LoginAttempt.getOrCreateBadLoginStatus(localIdentityProvider, username) + } map { unboxFullOrFail(_, Some(cc), s"$UserNotFoundByProviderAndUsername($username)", 404) } + } yield createBadLoginStatusJson(badLoginStatus) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getBadLoginStatus), "GET", + "/users/USERNAME/lock-status", + "Get User Lock Status", + s""" + |Get User Login Status. + |${userAuthenticationMessage(true)} + |""".stripMargin, + EmptyBody, badLoginStatusJson, + List(AuthenticatedUserIsRequired, UserNotFoundByProviderAndUsername, + UserHasMissingRoles, UnknownError), + List(apiTagUser), Some(List(canReadUserLockedStatus)), + http4sPartialFunction = Some(getBadLoginStatus)) + + // ─── getCallsLimit ──────────────────────────────────────────────────────── + + val getCallsLimit: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "consumers" / consumerIdStr / "consumer" / "call-limits" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + _ <- NewStyle.function.hasEntitlement("", user.userId, canReadCallLimits, Some(cc)) + consumer <- NewStyle.function.getConsumerByConsumerId(consumerIdStr, Some(cc)) + rateLimit <- Future(RateLimitingUtil.consumerRateLimitState(consumer.consumerId.get).toList) + } yield createCallLimitJson(consumer, rateLimit) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCallsLimit), "GET", + "/management/consumers/CONSUMER_ID/consumer/call-limits", + "Get Rate Limits for a Consumer", + s""" + |Get Rate Limits per Consumer. + |${userAuthenticationMessage(true)} + |""".stripMargin, + EmptyBody, callLimitJson, + List(AuthenticatedUserIsRequired, InvalidJsonFormat, InvalidConsumerId, + ConsumerNotFoundByConsumerId, UserHasMissingRoles, UpdateConsumerError, UnknownError), + List(apiTagConsumer), Some(List(canReadCallLimits)), + http4sPartialFunction = Some(getCallsLimit)) + + // ─── getConsumer ────────────────────────────────────────────────────────── + + val getConsumer: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "consumers" / consumerIdStr => + EndpointHelpers.withUser(req) { (user, cc) => + for { + _ <- NewStyle.function.hasEntitlement("", user.userId, ApiRole.canGetConsumers, Some(cc)) + consumer <- NewStyle.function.getConsumerByConsumerId(consumerIdStr, Some(cc)) + consumerUser <- Users.users.vend.getUserByUserIdFuture(consumer.createdByUserId.get) + } yield createConsumerJSON(consumer, consumerUser) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getConsumer), "GET", + "/management/consumers/CONSUMER_ID", + "Get Consumer", + s"""Get the Consumer specified by CONSUMER_ID. + | + |""", + EmptyBody, consumerJSON, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, + ConsumerNotFoundByConsumerId, UnknownError), + List(apiTagConsumer), Some(List(canGetConsumers)), + http4sPartialFunction = Some(getConsumer)) + + // ─── getConsumersForCurrentUser ────────────────────────────────────────── + + val getConsumersForCurrentUser: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "users" / "current" / "consumers" => + EndpointHelpers.withUser(req) { (user, _) => + for { + consumers <- Consumers.consumers.vend.getConsumersByUserIdFuture(user.userId) + } yield createConsumersJson(consumers, Full(user)) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getConsumersForCurrentUser), "GET", + "/management/users/current/consumers", + "Get Consumers (logged in User)", + s"""Get the Consumers for logged in User. + | + |""", + EmptyBody, consumersJson310, + List(AuthenticatedUserIsRequired, UnknownError), + List(apiTagConsumer), None, + http4sPartialFunction = Some(getConsumersForCurrentUser)) + + // ─── getConsumers ──────────────────────────────────────────────────────── + + val getConsumers: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "consumers" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + _ <- NewStyle.function.hasEntitlement("", user.userId, ApiRole.canGetConsumers, Some(cc)) + httpParams = req.headers.headers.toList.map(h => HTTPParam(h.name.toString, h.value)) ::: + req.uri.query.multiParams.toList.flatMap { case (k, vs) => vs.map(v => HTTPParam(k, v)) } + (obpQueryParams, _) <- createQueriesByHttpParamsFuture(httpParams, Some(cc)) + consumers <- Consumers.consumers.vend.getConsumersFuture(obpQueryParams, Some(cc)) + users <- Users.users.vend.getUsersByUserIdsFuture(consumers.map(_.createdByUserId.get)) + } yield createConsumersJson(consumers, users) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getConsumers), "GET", + "/management/consumers", + "Get Consumers", + s"""Get the all Consumers. + | + |${userAuthenticationMessage(true)} + | + |${urlParametersDocument(true, true)} + | + |""", + EmptyBody, consumersJson310, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagConsumer), Some(List(canGetConsumers)), + http4sPartialFunction = Some(getConsumers)) + + // ─── getAccountWebhooks ────────────────────────────────────────────────── + + val getAccountWebhooks: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "banks" / _ / "account-web-hooks" => + EndpointHelpers.withUserAndBank(req) { (user, bank, cc) => + for { + _ <- NewStyle.function.hasEntitlement(bank.bankId.value, user.userId, ApiRole.canGetWebhooks, Some(cc)) + httpParams = req.headers.headers.toList.map(h => HTTPParam(h.name.toString, h.value)) ::: + req.uri.query.multiParams.toList.flatMap { case (k, vs) => vs.map(v => HTTPParam(k, v)) } + allowedParams = List("limit", "offset", "account_id", "user_id") + (obpParams, _) <- NewStyle.function.createObpParams(httpParams, allowedParams, Some(cc)) + additionalParam = OBPBankId(bank.bankId.value) + webhooks <- NewStyle.function.getAccountWebhooks(additionalParam :: obpParams, Some(cc)) + } yield createAccountWebhooksJson(webhooks) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAccountWebhooks), "GET", + "/management/banks/BANK_ID/account-web-hooks", + "Get Account Webhooks", + s"""Get Account Webhooks. + | + |Possible custom URL parameters for pagination: + | + |${urlParametersDocument(false, false)} + |* account_id=STRING (if null ignore) + |* user_id=STRING (if null ignore) + | + |""", + EmptyBody, accountWebhooksJson, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagWebhook :: apiTagBank :: Nil, Some(List(canGetWebhooks)), + http4sPartialFunction = Some(getAccountWebhooks)) + + // ─── config ─────────────────────────────────────────────────────────────── + + val config: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "config" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + _ <- NewStyle.function.hasEntitlement("", user.userId, ApiRole.canGetConfig, Some(cc)) + } yield JSONFactory310.getConfigInfoJSON() + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(config), "GET", + "/config", + "Get API Configuration", + """Returns information about: + | + |* The default bank_id + |* Akka configuration + |* Elastic Search configuration + |* Cached functions """, + EmptyBody, configurationJSON, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagApi :: Nil, Some(List(canGetConfig)), + http4sPartialFunction = Some(config)) + + // ─── getAdapterInfo ─────────────────────────────────────────────────────── + + val getAdapterInfo: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "adapter" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + _ <- NewStyle.function.hasEntitlement("", user.userId, ApiRole.canGetAdapterInfo, Some(cc)) + (ai, _) <- NewStyle.function.getAdapterInfo(Some(cc)) + } yield JSONFactory300.createAdapterInfoJson(ai) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAdapterInfo), "GET", + "/adapter", + "Get Adapter Info", + s"""Get basic information about the Adapter. + | + |${userAuthenticationMessage(true)} + |""".stripMargin, + EmptyBody, adapterInfoJsonV300, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagApi), Some(List(canGetAdapterInfo)), + http4sPartialFunction = Some(getAdapterInfo)) + + // ─── getRateLimitingInfo ────────────────────────────────────────────────── + // Anonymous endpoint — no auth required. + + val getRateLimitingInfo: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "rate-limiting" => + EndpointHelpers.executeAndRespond(req) { cc => + for { + rateLimiting <- NewStyle.function.tryons("", 400, Some(cc)) { + val isActive = if (RateLimitingUtil.useConsumerLimits) true else false + RateLimiting(RateLimitingUtil.useConsumerLimits, "REDIS", true, isActive) + } + } yield createRateLimitingInfo(rateLimiting) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getRateLimitingInfo), "GET", + "/rate-limiting", + "Get Rate Limiting Info", + s"""Get information about the Rate Limiting setup on this OBP Instance such as: + | + |Is rate limiting enabled and active? + |What backend is used to keep track of the API calls (e.g. REDIS). + | + |Note: Rate limiting can be set at the Consumer level and also for anonymous calls. + | + |See the consumer rate limits / call limits endpoints. + |""".stripMargin, + EmptyBody, rateLimitingInfoV310, + List(UnknownError), + List(apiTagApi, apiTagRateLimits), None, + http4sPartialFunction = Some(getRateLimitingInfo)) + + // ─── getCustomerByCustomerId ────────────────────────────────────────────── + + val getCustomerByCustomerId: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "customers" / customerIdStr => + EndpointHelpers.withUserAndBank(req) { (user, bank, cc) => + for { + _ <- NewStyle.function.hasEntitlement(bank.bankId.value, user.userId, canGetCustomersAtOneBank, Some(cc)) + (customer, _) <- NewStyle.function.getCustomerByCustomerId(customerIdStr, Some(cc)) + (customerAttributes, _) <- NewStyle.function.getCustomerAttributes( + bank.bankId, CustomerId(customerIdStr), Some(cc)) + } yield JSONFactory310.createCustomerWithAttributesJson(customer, customerAttributes) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCustomerByCustomerId), "GET", + "/banks/BANK_ID/customers/CUSTOMER_ID", + "Get Customer by CUSTOMER_ID", + s"""Gets the Customer specified by CUSTOMER_ID. + | + |${userAuthenticationMessage(true)} + |""", + EmptyBody, customerWithAttributesJsonV310, + List(AuthenticatedUserIsRequired, UserCustomerLinksNotFoundForUser, UnknownError), + List(apiTagCustomer, apiTagKyc), Some(List(canGetCustomersAtOneBank)), + http4sPartialFunction = Some(getCustomerByCustomerId)) + + // ─── getUserAuthContexts ───────────────────────────────────────────────── + + val getUserAuthContexts: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "users" / userIdStr / "auth-context" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + _ <- NewStyle.function.hasEntitlement("", user.userId, canGetUserAuthContext, Some(cc)) + (_, _) <- NewStyle.function.findByUserId(userIdStr, Some(cc)) + (userAuthContexts, _) <- NewStyle.function.getUserAuthContexts(userIdStr, Some(cc)) + } yield JSONFactory310.createUserAuthContextsJson(userAuthContexts) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getUserAuthContexts), "GET", + "/users/USER_ID/auth-context", + "Get User Auth Contexts", + s"""Get User Auth Contexts for a User. + | + |${userAuthenticationMessage(true)} + |""", + EmptyBody, userAuthContextsJson, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagUser), Some(canGetUserAuthContext :: Nil), + http4sPartialFunction = Some(getUserAuthContexts)) + + // ─── getTaxResidence ───────────────────────────────────────────────────── + + val getTaxResidence: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "customers" / customerIdStr / "tax-residences" => + EndpointHelpers.withUserAndBank(req) { (user, bank, cc) => + for { + _ <- NewStyle.function.hasEntitlement(bank.bankId.value, user.userId, canGetTaxResidence, Some(cc)) + (_, _) <- NewStyle.function.getCustomerByCustomerId(customerIdStr, Some(cc)) + (taxResidences, _) <- NewStyle.function.getTaxResidences(customerIdStr, Some(cc)) + } yield JSONFactory310.createTaxResidences(taxResidences) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getTaxResidence), "GET", + "/banks/BANK_ID/customers/CUSTOMER_ID/tax-residences", + "Get Tax Residences of Customer", + s"""Get the Tax Residences of the Customer specified by CUSTOMER_ID. + | + |${userAuthenticationMessage(true)} + |""", + EmptyBody, taxResidencesJsonV310, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagCustomer, apiTagKyc), Some(List(canGetTaxResidence)), + http4sPartialFunction = Some(getTaxResidence)) + + // ─── getAllEntitlements ────────────────────────────────────────────────── + + val getAllEntitlements: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "entitlements" => + EndpointHelpers.withUser(req) { (user, cc) => + val roleName = req.uri.query.params.getOrElse("role", "") + for { + _ <- NewStyle.function.hasEntitlement("", user.userId, canGetEntitlementsForAnyUserAtAnyBank, Some(cc)) + entitlements <- Entitlement.entitlement.vend.getEntitlementsByRoleFuture(roleName) map { + connectorEmptyResponse(_, Some(cc)) + } + } yield JSONFactory310.createEntitlementJsonsV310(entitlements) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAllEntitlements), "GET", + "/entitlements", + "Get all Entitlements", + s""" + |Login is required. + | + |Possible filter on the role field: + | + |eg: /entitlements?role=${canGetCustomersAtOneBank.toString} + |""".stripMargin, + EmptyBody, entitlementJSonsV310, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagRole, apiTagEntitlement), None, + http4sPartialFunction = Some(getAllEntitlements)) + + // ─── getCustomerAddresses ──────────────────────────────────────────────── + + val getCustomerAddresses: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "customers" / customerIdStr / "addresses" => + EndpointHelpers.withUserAndBank(req) { (user, bank, cc) => + for { + _ <- NewStyle.function.hasEntitlement(bank.bankId.value, user.userId, canGetCustomerAddress, Some(cc)) + (_, _) <- NewStyle.function.getCustomerByCustomerId(customerIdStr, Some(cc)) + (addresses, _) <- NewStyle.function.getCustomerAddress(customerIdStr, Some(cc)) + } yield JSONFactory310.createAddresses(addresses) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCustomerAddresses), "GET", + "/banks/BANK_ID/customers/CUSTOMER_ID/addresses", + "Get Customer Addresses", + s"""Get the Addresses of the Customer specified by CUSTOMER_ID. + | + |${userAuthenticationMessage(true)} + |""", + EmptyBody, customerAddressesJsonV310, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagCustomer, apiTagKyc), Some(List(canGetCustomerAddress)), + http4sPartialFunction = Some(getCustomerAddresses)) + + // ─── getProductAttribute ───────────────────────────────────────────────── + + val getProductAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankIdStr / "products" / _ / "attributes" / productAttributeIdStr => + EndpointHelpers.withUser(req) { (user, cc) => + for { + _ <- NewStyle.function.hasEntitlement(bankIdStr, user.userId, canGetProductAttribute, Some(cc)) + (_, _) <- NewStyle.function.getBank(BankId(bankIdStr), Some(cc)) + (productAttribute, _) <- NewStyle.function.getProductAttributeById(productAttributeIdStr, Some(cc)) + } yield createProductAttributeJson(productAttribute) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getProductAttribute), "GET", + "/banks/BANK_ID/products/PRODUCT_CODE/attributes/PRODUCT_ATTRIBUTE_ID", + "Get Product Attribute", + s"""Get one Product Attribute by its id. + | + |${userAuthenticationMessage(true)} + |""", + EmptyBody, productAttributeResponseJson, + List(UserHasMissingRoles, UnknownError), + List(apiTagProduct, apiTagProductAttribute, apiTagAttribute), + Some(List(canGetProductAttribute)), + http4sPartialFunction = Some(getProductAttribute)) + + // ─── getAccountApplications ────────────────────────────────────────────── + + val getAccountApplications: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "account-applications" => + EndpointHelpers.withUserAndBank(req) { (user, _, cc) => + for { + _ <- NewStyle.function.hasEntitlement("", user.userId, canGetAccountApplications, Some(cc)) + (accountApplications, _) <- NewStyle.function.getAllAccountApplication(Some(cc)) + (users, _) <- NewStyle.function.findUsers(accountApplications.map(_.userId), Some(cc)) + (customers, _) <- NewStyle.function.findCustomers(accountApplications.map(_.customerId), Some(cc)) + } yield JSONFactory310.createAccountApplications(accountApplications, users, customers) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAccountApplications), "GET", + "/banks/BANK_ID/account-applications", + "Get Account Applications", + s"""Get the Account Applications. + | + |${userAuthenticationMessage(true)} + |""", + EmptyBody, accountApplicationsJsonV310, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagAccountApplication, apiTagAccount), None, + http4sPartialFunction = Some(getAccountApplications)) + + // ─── getAccountApplication ─────────────────────────────────────────────── + + val getAccountApplication: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "account-applications" / accountApplicationIdStr => + EndpointHelpers.withUserAndBank(req) { (_, _, cc) => + for { + (accountApplication, _) <- NewStyle.function.getAccountApplicationById(accountApplicationIdStr, Some(cc)) + userId = Option(accountApplication.userId) + customerId = Option(accountApplication.customerId) + user <- unboxOptionOBPReturnType(userId.map(NewStyle.function.findByUserId(_, Some(cc)))) + customer <- unboxOptionOBPReturnType(customerId.map(NewStyle.function.getCustomerByCustomerId(_, Some(cc)))) + } yield createAccountApplicationJson(accountApplication, user, customer) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAccountApplication), "GET", + "/banks/BANK_ID/account-applications/ACCOUNT_APPLICATION_ID", + "Get Account Application by Id", + s"""Get the Account Application. + | + |${userAuthenticationMessage(true)} + |""", + EmptyBody, accountApplicationResponseJson, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagAccountApplication, apiTagAccount), None, + http4sPartialFunction = Some(getAccountApplication)) + + // ─── getMeetings ───────────────────────────────────────────────────────── + + val getMeetings: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "meetings" => + EndpointHelpers.withUserAndBank(req) { (user, bank, cc) => + for { + (meetings, _) <- NewStyle.function.getMeetings(bank.bankId, user, Some(cc)) + } yield createMeetingsJson(meetings) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getMeetings), "GET", + "/banks/BANK_ID/meetings", + "Get Meetings", + """Meetings contain meta data about, and are used to facilitate, video conferences / chats etc. + | + |The actual conference/chats are handled by external services. + | + |Login is required. + | + |This call is **experimental** and will require further authorisation in the future. + """.stripMargin, + EmptyBody, meetingsJsonV310, + List(AuthenticatedUserIsRequired, BankNotFound, UnknownError), + List(apiTagMeeting, apiTagCustomer, apiTagExperimental), None, + http4sPartialFunction = Some(getMeetings)) + + // ─── getMeeting ────────────────────────────────────────────────────────── + + val getMeeting: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "meetings" / meetingIdStr => + EndpointHelpers.withUserAndBank(req) { (user, bank, cc) => + for { + (meeting, _) <- NewStyle.function.getMeeting(bank.bankId, user, meetingIdStr, Some(cc)) + } yield JSONFactory310.createMeetingJson(meeting) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getMeeting), "GET", + "/banks/BANK_ID/meetings/MEETING_ID", + "Get Meeting", + """Get Meeting specified by BANK_ID / MEETING_ID + |Meetings contain meta data about, and are used to facilitate, video conferences / chats etc. + | + |The actual conference/chats are handled by external services. + | + |Login is required. + | + |This call is **experimental** and will require further authorisation in the future. + """.stripMargin, + EmptyBody, meetingJsonV310, + List(AuthenticatedUserIsRequired, BankNotFound, MeetingNotFound, UnknownError), + List(apiTagMeeting, apiTagCustomer, apiTagExperimental), None, + http4sPartialFunction = Some(getMeeting)) + + // ─── getServerJWK ──────────────────────────────────────────────────────── + + val getServerJWK: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "certs" => + EndpointHelpers.executeAndRespond(req) { _ => + Future.successful(net.liftweb.json.parse(CertificateUtil.convertRSAPublicKeyToAnRSAJWK())) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getServerJWK), "GET", + "/certs", + "Get JSON Web Key (JWK)", + """Get the server's public JSON Web Key (JWK) set and certificate chain. + | It is required by client applications to validate ID tokens, self-contained access tokens and other issued objects. + |""".stripMargin, + EmptyBody, severJWK, + List(UnknownError), + List(apiTagApi, apiTagPSD2AIS, apiTagPsd2), None, + http4sPartialFunction = Some(getServerJWK)) + + // ─── getOAuth2ServerJWKsURIs ───────────────────────────────────────────── + + val getOAuth2ServerJWKsURIs: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "jwks-uris" => + EndpointHelpers.executeAndRespond(req) { _ => + Future.successful(JSONFactory310.getOAuth2ServerJwksUrisJson()) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getOAuth2ServerJWKsURIs), "GET", + "/jwks-uris", + "Get JSON Web Key (JWK) URIs", + """Get the OAuth2 server's public JSON Web Key (JWK) URIs. + | It is required by client applications to validate ID tokens, self-contained access tokens and other issued objects. + |""".stripMargin, + EmptyBody, oAuth2ServerJwksUrisJson, + List(UnknownError), + List(apiTagApi, apiTagOAuth, apiTagOIDC), None, + http4sPartialFunction = Some(getOAuth2ServerJWKsURIs)) + + // ─── getMethodRoutings ─────────────────────────────────────────────────── + + val getMethodRoutings: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "method_routings" => + EndpointHelpers.withUser(req) { (user, cc) => + val methodNameParam = req.uri.query.params.get("method_name").map(Full(_)).getOrElse(net.liftweb.common.Empty) + val activeParam = req.uri.query.params.get("active") + for { + _ <- NewStyle.function.hasEntitlement("", user.userId, ApiRole.canGetMethodRoutings, Some(cc)) + methodRoutings <- NewStyle.function.getMethodRoutingsByMethodName(methodNameParam) + } yield { + val definedMethodRoutings = methodRoutings.sortWith(_.methodName < _.methodName) + val listCommons: List[code.methodrouting.MethodRoutingCommons] = activeParam match { + case Some("true") => (definedMethodRoutings ++ getDefaultMethodRoutings).sortWith(_.methodName < _.methodName) + case _ => definedMethodRoutings + } + ListResult("method_routings", listCommons.map(_.toJson)) + } + } + } + + private def getDefaultMethodRoutings: List[code.methodrouting.MethodRoutingCommons] = { + val methodRegex = """method \S+(? methodRegex.matcher(it.toString).matches()) + .filter(_.asMethod.isPublic) + .map(_.asMethod) + .map(it => code.methodrouting.MethodRoutingCommons( + methodName = it.name.toString, + connectorName = "mapped", + isBankIdExactMatch = false, + bankIdPattern = Some("*"), + parameters = List.empty[code.methodrouting.MethodRoutingParam], + methodRoutingId = Some(""), + )) + .toList + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getMethodRoutings), "GET", + "/management/method_routings", + "Get MethodRoutings", + s"""Get the all MethodRoutings. + | + |Query url parameters: + | + |* method_name: filter with method_name + |* active: if active = true, it will show all the webui_ props. + |""", + EmptyBody, + ListResult( + "method_routings", + List(code.methodrouting.MethodRoutingCommons("getBanks", "rest_vMar2019", false, Some("some_bank_.*"), + List(code.methodrouting.MethodRoutingParam("url", "http://mydomain.com/xxx")), Some("method-routing-id"))) + ), + List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagMethodRouting, apiTagApi), Some(List(canGetMethodRoutings)), + http4sPartialFunction = Some(getMethodRoutings)) + + // ─── getSystemView ─────────────────────────────────────────────────────── + // VIEW_ID path is /system-views/VIEW_ID — no BANK_ID/ACCOUNT_ID, so the middleware + // validateView would try to look up an account that doesn't exist. Use SYS_VIEW_ID + // (non-standard ALL_CAPS) to make middleware skip view validation; we look up via + // ViewNewStyle.systemView inline. + + val getSystemView: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "system-views" / viewIdStr if viewIdStr.nonEmpty => + EndpointHelpers.withUser(req) { (user, cc) => + for { + _ <- NewStyle.function.hasEntitlement("", user.userId, canGetSystemView, Some(cc)) + view <- ViewNewStyle.systemView(ViewId(viewIdStr), Some(cc)) + } yield JSONFactory310.createViewJSON(view) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getSystemView), "GET", + "/system-views/SYS_VIEW_ID", + "Get System View", + s"""Get System View. + | + |${userAuthenticationMessage(true)} and the user needs to have access to the $canGetSystemView entitlement. + |""", + EmptyBody, viewJsonV300, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, ViewNotFound, UnknownError), + List(apiTagSystemView), Some(List(canGetSystemView)), + http4sPartialFunction = Some(getSystemView)) + + // ─── getCardsForBank ───────────────────────────────────────────────────── + + val getCardsForBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "banks" / _ / "cards" => + EndpointHelpers.withUserAndBank(req) { (user, bank, cc) => + val httpParams = req.headers.headers.toList.map(h => HTTPParam(h.name.toString, h.value)) ::: + req.uri.query.multiParams.toList.flatMap { case (k, vs) => vs.map(v => HTTPParam(k, v)) } + for { + (obpQueryParams, _) <- createQueriesByHttpParamsFuture(httpParams, Some(cc)) + _ <- NewStyle.function.hasEntitlement(bank.bankId.value, user.userId, ApiRole.canGetCardsForBank, Some(cc)) + (cards, _) <- NewStyle.function.getPhysicalCardsForBank(bank, user, obpQueryParams, Some(cc)) + } yield createPhysicalCardsJson(cards, user) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCardsForBank), "GET", + "/management/banks/BANK_ID/cards", + "Get Cards for the specified bank", + s"""Cards for the specified bank. + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, physicalCardsJsonV310, + List(AuthenticatedUserIsRequired, BankNotFound, UnknownError), + List(apiTagCard), None, + http4sPartialFunction = Some(getCardsForBank)) + + // ─── getCardForBank ────────────────────────────────────────────────────── + + val getCardForBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "banks" / _ / "cards" / cardIdStr => + EndpointHelpers.withUserAndBank(req) { (user, bank, cc) => + for { + _ <- NewStyle.function.hasEntitlement(bank.bankId.value, user.userId, ApiRole.canGetCardsForBank, Some(cc)) + (card, _) <- NewStyle.function.getPhysicalCardForBank(bank.bankId, cardIdStr, Some(cc)) + (cardAttributes, _) <- NewStyle.function.getCardAttributesFromProvider(cardIdStr, Some(cc)) + } yield { + val views: List[View] = Views.views.vend.assignedViewsForAccount( + BankIdAccountId(card.account.bankId, card.account.accountId)) + val commonsData: List[CardAttributeCommons] = cardAttributes + createPhysicalCardWithAttributesJson(card, commonsData, user, views) + } + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCardForBank), "GET", + "/management/banks/BANK_ID/cards/CARD_ID", + "Get Card By Id", + s"""Get the details of a card by its id. + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, physicalCardWithAttributesJsonV310, + List(AuthenticatedUserIsRequired, BankNotFound, UnknownError), + List(apiTagCard), Some(List(canGetCardsForBank)), + http4sPartialFunction = Some(getCardForBank)) + + // ─── getBankAccountsBalances ───────────────────────────────────────────── + + val getBankAccountsBalances: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "balances" => + EndpointHelpers.withUserAndBank(req) { (user, bank, cc) => + for { + availablePrivateAccounts <- Views.views.vend.getPrivateBankAccountsFuture(user, bank.bankId) + (accountsBalances, _) <- BalanceNewStyle.getBankAccountsBalances(availablePrivateAccounts, Some(cc)) + } yield createBalancesJson(accountsBalances) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getBankAccountsBalances), "GET", + "/banks/BANK_ID/balances", + "Get Accounts Balances", + """Get the Balances for the Accounts of the current User at one bank.""", + EmptyBody, accountBalancesV310Json, + List(UnknownError), + apiTagAccount :: apiTagPSD2AIS :: apiTagPsd2 :: Nil, None, + http4sPartialFunction = Some(getBankAccountsBalances)) + + // ─── checkFundsAvailable ───────────────────────────────────────────────── + + val checkFundsAvailable: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "funds-available" => + EndpointHelpers.withView(req) { (user, account, view, cc) => + val amountKey = "amount" + val currencyKey = "currency" + val queryParams = req.uri.query.params + for { + _ <- code.util.Helper.booleanToFuture( + s"$ViewDoesNotPermitAccess + You need the `${CAN_QUERY_AVAILABLE_FUNDS}` permission on any your views", + cc = Some(cc)) { + view.allowed_actions.exists(_ == CAN_QUERY_AVAILABLE_FUNDS) + } + _ <- code.util.Helper.booleanToFuture(MissingQueryParams + amountKey, cc = Some(cc)) { + queryParams.contains(amountKey) + } + _ <- code.util.Helper.booleanToFuture(MissingQueryParams + currencyKey, cc = Some(cc)) { + queryParams.contains(currencyKey) + } + available <- NewStyle.function.tryons(s"$InvalidAmount", 400, Some(cc)) { + new java.math.BigDecimal(queryParams(amountKey)) + } + ccy = queryParams(currencyKey) + _ <- NewStyle.function.isValidCurrencyISOCode(ccy, Some(cc)) + _ <- NewStyle.function.moderatedBankAccountCore(account, view, Full(user), Some(cc)) + } yield { + val fundsAvailable = (view.allowed_actions.exists(_ == CAN_QUERY_AVAILABLE_FUNDS), account.balance, account.currency) match { + case (false, _, _) => "" + case (true, _, c) if c != ccy => "no" + case (true, b, _) if b.compare(available) >= 0 => "yes" + case _ => "no" + } + val availableFundsRequestId = cc.correlationId + createCheckFundsAvailableJson(fundsAvailable, availableFundsRequestId) + } + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(checkFundsAvailable), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/funds-available", + "Check Available Funds", + """Check Available Funds + |Mandatory URL parameters: + | + |* amount=NUMBER + |* currency=STRING + |""".stripMargin, + EmptyBody, checkFundsAvailableJson, + List(AuthenticatedUserIsRequired, BankNotFound, BankAccountNotFound, + InvalidAmount, InvalidISOCurrencyCode, UnknownError), + apiTagAccount :: apiTagPSD2PIIS :: apiTagPsd2 :: Nil, None, + http4sPartialFunction = Some(checkFundsAvailable)) + + // ─── getTransactionByIdForBankAccount ──────────────────────────────────── + + val getTransactionByIdForBankAccount: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "transactions" / transactionIdStr / "transaction" => + EndpointHelpers.withView(req) { (user, account, view, cc) => + for { + _ <- code.api.util.APIUtil.passesPsd2Pisp(Some(cc)) + (moderatedTransaction, _) <- account.moderatedTransactionFuture( + TransactionId(transactionIdStr), view, Full(user), Some(cc)) map { + unboxFullOrFail(_, Some(cc), GetTransactionsException) + } + (transactionAttributes, _) <- NewStyle.function.getTransactionAttributes( + account.bankId, TransactionId(transactionIdStr), Some(cc)) + } yield JSONFactory300.createTransactionJSON(moderatedTransaction, transactionAttributes) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getTransactionByIdForBankAccount), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transactions/TRANSACTION_ID/transaction", + "Get Transaction by Id", + s"""Returns one transaction specified by TRANSACTION_ID of the account ACCOUNT_ID and moderated by the view (VIEW_ID). + | + |${userAuthenticationMessage(true)} + |""", + EmptyBody, transactionJsonV300, + List(AuthenticatedUserIsRequired, BankAccountNotFound, ViewNotFound, + UserNoPermissionAccessView, UnknownError), + List(apiTagTransaction), None, + http4sPartialFunction = Some(getTransactionByIdForBankAccount)) + + // ─── getTransactionRequests ────────────────────────────────────────────── + + val getTransactionRequests: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / viewIdStr / "transaction-requests" => + EndpointHelpers.withBankAccount(req) { (user, account, cc) => + for { + _ <- NewStyle.function.isEnabledTransactionRequests(Some(cc)) + view <- ViewNewStyle.checkAccountAccessAndGetView( + ViewId(viewIdStr), BankIdAccountId(account.bankId, account.accountId), Full(user), Some(cc)) + _ <- code.util.Helper.booleanToFuture( + s"${ViewDoesNotPermitAccess} You need the `${CAN_SEE_TRANSACTION_REQUESTS}` permission on the View(${viewIdStr})", + cc = Some(cc)) { + view.allowed_actions.exists(_ == CAN_SEE_TRANSACTION_REQUESTS) + } + (transactionRequests, _) <- Future(Connector.connector.vend.getTransactionRequests210(user, account, Some(cc))) map { + unboxFullOrFail(_, Some(cc), GetTransactionRequestsException) + } + } yield JSONFactory210.createTransactionRequestJSONs(transactionRequests) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getTransactionRequests), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transaction-requests", + "Get Transaction Requests.", + """Returns transaction requests for account specified by ACCOUNT_ID at bank specified by BANK_ID. + | + |The VIEW_ID specified must be 'owner' and the user must have access to this view. + |""".stripMargin, + EmptyBody, transactionRequestWithChargeJSONs210, + List(AuthenticatedUserIsRequired, BankNotFound, BankAccountNotFound, + UserNoPermissionAccessView, ViewDoesNotPermitAccess, + GetTransactionRequestsException, UnknownError), + List(apiTagTransactionRequest, apiTagPSD2PIS), None, + http4sPartialFunction = Some(getTransactionRequests)) + + // ─── getProduct ────────────────────────────────────────────────────────── + // Conditional auth: middleware uses `userAuthenticationMessage(!getProductsIsPublic)` + // in description to drive needsAuthentication. When public, anonymous is OK. + + val getProduct: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankIdStr / "products" / productCodeStr => + EndpointHelpers.executeAndRespond(req) { cc => + for { + (_, _) <- NewStyle.function.getBank(BankId(bankIdStr), Some(cc)) + (product, _) <- NewStyle.function.getProduct(BankId(bankIdStr), ProductCode(productCodeStr), Some(cc)) + (productAttributes, _) <- NewStyle.function.getProductAttributesByBankAndCode( + BankId(bankIdStr), ProductCode(productCodeStr), Some(cc)) + } yield JSONFactory310.createProductJson(product, productAttributes) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getProduct), "GET", + "/banks/BANK_ID/products/PRODUCT_CODE", + "Get Bank Product", + s"""Returns information about a financial Product offered by the bank specified by BANK_ID and PRODUCT_CODE. + | + |${userAuthenticationMessage(!getProductsIsPublic)}""".stripMargin, + EmptyBody, productJsonV310, + List(AuthenticatedUserIsRequired, ProductNotFoundByProductCode, UnknownError), + List(apiTagProduct), None, + http4sPartialFunction = Some(getProduct)) + + // ─── getProductTree ────────────────────────────────────────────────────── + + val getProductTree: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankIdStr / "product-tree" / productCodeStr => + EndpointHelpers.executeAndRespond(req) { cc => + for { + (_, _) <- NewStyle.function.getBank(BankId(bankIdStr), Some(cc)) + (_, _) <- NewStyle.function.getProduct(BankId(bankIdStr), ProductCode(productCodeStr), Some(cc)) + (products, _) <- NewStyle.function.getProductTree(BankId(bankIdStr), ProductCode(productCodeStr), Some(cc)) + } yield JSONFactory310.createProductTreeJson(products, productCodeStr) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getProductTree), "GET", + "/banks/BANK_ID/product-tree/PRODUCT_CODE", + "Get Product Tree", + s"""Returns information about a particular financial product specified by BANK_ID and PRODUCT_CODE + |and it's parent product(s) recursively as specified by parent_product_code. + | + |${userAuthenticationMessage(!getProductsIsPublic)}""".stripMargin, + EmptyBody, childProductTreeJsonV310, + List(AuthenticatedUserIsRequired, ProductNotFoundByProductCode, UnknownError), + List(apiTagProduct), None, + http4sPartialFunction = Some(getProductTree)) + + // ─── getProducts ───────────────────────────────────────────────────────── + + val getProducts: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankIdStr / "products" => + EndpointHelpers.executeAndRespond(req) { cc => + val params = req.uri.query.multiParams.toList.map { case (k, vs) => GetProductsParam(k, vs.toList) } + for { + (_, _) <- NewStyle.function.getBank(BankId(bankIdStr), Some(cc)) + (products, _) <- NewStyle.function.getProducts(BankId(bankIdStr), params, Some(cc)) + } yield JSONFactory310.createProductsJson(products) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, "getProducts", "GET", + "/banks/BANK_ID/products", + "Get Products", + s"""Returns information about the financial products offered by a bank specified by BANK_ID. + | + |Can filter with attributes name and values. + |URL params example: /banks/some-bank-id/products?&limit=50&offset=1 + | + |${userAuthenticationMessage(!getProductsIsPublic)}""".stripMargin, + EmptyBody, productsJsonV310, + List(AuthenticatedUserIsRequired, BankNotFound, ProductNotFoundByProductCode, UnknownError), + List(apiTagProduct), None, + http4sPartialFunction = Some(getProducts)) + + // ─── getProductCollection ──────────────────────────────────────────────── + + val getProductCollection: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "product-collections" / collectionCodeStr => + EndpointHelpers.withUserAndBank(req) { (_, bank, cc) => + for { + (payload, _) <- NewStyle.function.getProductCollectionItemsTree( + collectionCodeStr, bank.bankId.value, Some(cc)) + } yield createProductCollectionsTreeJson(payload) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getProductCollection), "GET", + "/banks/BANK_ID/product-collections/COLLECTION_CODE", + "Get Product Collection", + """Returns information about the financial Product Collection specified by BANK_ID and COLLECTION_CODE.""", + EmptyBody, productCollectionJsonTreeV310, + List(AuthenticatedUserIsRequired, BankNotFound, UnknownError), + List(apiTagProductCollection, apiTagProduct), None, + http4sPartialFunction = Some(getProductCollection)) + + // ─── getConsents ───────────────────────────────────────────────────────── + + val getConsents: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "my" / "consents" => + EndpointHelpers.withUserAndBank(req) { (user, bank, _) => + val params = req.uri.query.params + val limit = params.get("limit").flatMap(s => scala.util.Try(s.toInt).toOption).getOrElse(50) + val offset = params.get("offset").flatMap(s => scala.util.Try(s.toInt).toOption).getOrElse(0) + for { + rows <- Future { + DoobieConsentQueries.getConsentsByUserAndBank( + userId = user.userId, + bankId = bank.bankId.value, + status = None, + limit = limit, + offset = offset, + sortField = "created_date", + sortDirection = "desc" + ) + } + } yield { + val consents = rows.map(r => ConsentJsonV310(r.consentId, r.jwt.getOrElse(""), r.status)) + ConsentsJsonV310(consents) + } + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getConsents), "GET", + "/banks/BANK_ID/my/consents", + "Get Consents", + s"""Get Consents for the current User at the specified bank. + | + |${userAuthenticationMessage(true)} + |""", + EmptyBody, consentsJsonV310, + List(AuthenticatedUserIsRequired, BankNotFound, UnknownError), + List(apiTagConsent, apiTagPSD2AIS, apiTagPsd2), None, + http4sPartialFunction = Some(getConsents)) + + // ─── getPrivateAccountByIdFull ─────────────────────────────────────────── + + val getPrivateAccountByIdFull: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "account" => + EndpointHelpers.withView(req) { (user, account, view, cc) => + for { + moderatedAccount <- NewStyle.function.moderatedBankAccountCore(account, view, Full(user), Some(cc)) + (accountAttributes, _) <- NewStyle.function.getAccountAttributesByAccount( + account.bankId, account.accountId, Some(cc)) + } yield { + val availableViews = Views.views.vend.privateViewsUserCanAccessForAccount( + user, BankIdAccountId(account.bankId, account.accountId)) + val viewsAvailable = availableViews.map(JSONFactory.createViewJSON).sortBy(_.short_name) + JSONFactory310.createBankAccountJSON(moderatedAccount, viewsAvailable, accountAttributes) + } + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getPrivateAccountByIdFull), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/account", + "Get Account by Id (Full)", + """Information returned about an account specified by ACCOUNT_ID as moderated by the view (VIEW_ID): + | + |* Number + |* Owners + |* Type + |* Balance + |* IBAN + |* Available views (sorted by short_name) + |""".stripMargin, + EmptyBody, moderatedAccountJSON310, + List(BankNotFound, AccountNotFound, ViewNotFound, UserNoPermissionAccessView, UnknownError), + apiTagAccount :: Nil, None, + http4sPartialFunction = Some(getPrivateAccountByIdFull)) + + // ─── getWebUiProps ─────────────────────────────────────────────────────── + + val getWebUiProps: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "webui_props" => + EndpointHelpers.withUser(req) { (user, cc) => + val activeRaw = req.uri.query.params.getOrElse("active", "false") + for { + isActive <- NewStyle.function.tryons( + s"$InvalidFilterParameterFormat `active` must be a boolean, but current `active` value is: $activeRaw", + 400, Some(cc)) { activeRaw.toBoolean } + _ <- NewStyle.function.hasEntitlement("", user.userId, ApiRole.canGetWebUiProps, Some(cc)) + explicitWebUiProps <- Future(MappedWebUiPropsProvider.getAll()) + implicitWebUiPropsRemovedDuplicated = if (isActive) { + val implicitWebUiProps = getWebUIPropsPairs.map(p => WebUiPropsCommons(p._1, p._2, webUiPropsId = Some("default"))) + if (explicitWebUiProps.nonEmpty) { + val duplicatedProps: List[WebUiPropsCommons] = + explicitWebUiProps.flatMap(e => implicitWebUiProps.filter(_.name == e.name)) + implicitWebUiProps diff duplicatedProps + } else implicitWebUiProps.distinct + } else List.empty[WebUiPropsCommons] + } yield { + val listCommons: List[WebUiPropsCommons] = explicitWebUiProps ++ implicitWebUiPropsRemovedDuplicated + ListResult("webui_props", listCommons) + } + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getWebUiProps), "GET", + "/management/webui_props", + "Get WebUiProps", + s"""Get WebUiProps. + | + |${userAuthenticationMessage(true)} + |""", + EmptyBody, + ListResult( + "webui_props", + List(WebUiPropsCommons("webui_api_explorer_url", "https://apiexplorer.openbankproject.com", Some("web-ui-props-id"))) + ), + List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagWebUiProps), Some(List(canGetWebUiProps)), + http4sPartialFunction = Some(getWebUiProps)) + + // ─── deleteUserAuthContexts ────────────────────────────────────────────── + + val deleteUserAuthContexts: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "users" / userIdStr / "auth-context" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + _ <- NewStyle.function.hasEntitlement("", user.userId, canDeleteUserAuthContext, Some(cc)) + (_, _) <- NewStyle.function.findByUserId(userIdStr, Some(cc)) + (deleted, _) <- NewStyle.function.deleteUserAuthContexts(userIdStr, Some(cc)) + } yield Full(deleted) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteUserAuthContexts), "DELETE", + "/users/USER_ID/auth-context", + "Delete User's Auth Contexts", + s"""Delete the Auth Contexts of a User specified by USER_ID. + | + |${userAuthenticationMessage(true)} + |""", + EmptyBody, EmptyBody, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagUser), Some(List(canDeleteUserAuthContext)), + http4sPartialFunction = Some(deleteUserAuthContexts)) + + // ─── deleteUserAuthContextById ────────────────────────────────────────── + + val deleteUserAuthContextById: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "users" / userIdStr / "auth-context" / userAuthContextIdStr => + EndpointHelpers.withUser(req) { (user, cc) => + for { + _ <- NewStyle.function.hasEntitlement("", user.userId, canDeleteUserAuthContext, Some(cc)) + (subjectUser, _) <- NewStyle.function.findByUserId(userIdStr, Some(cc)) + (deleted, _) <- NewStyle.function.deleteUserAuthContextById(subjectUser, userAuthContextIdStr, Some(cc)) + } yield Full(deleted) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteUserAuthContextById), "DELETE", + "/users/USER_ID/auth-context/USER_AUTH_CONTEXT_ID", + "Delete User Auth Context", + s"""Delete a User AuthContext of the User specified by USER_AUTH_CONTEXT_ID. + | + |${userAuthenticationMessage(true)} + |""", + EmptyBody, EmptyBody, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagUser), Some(List(canDeleteUserAuthContext)), + http4sPartialFunction = Some(deleteUserAuthContextById)) + + // ─── deleteTaxResidence ────────────────────────────────────────────────── + + val deleteTaxResidence: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / _ / "customers" / customerIdStr / "tax_residencies" / taxResidenceIdStr => + EndpointHelpers.withUserAndBank(req) { (user, bank, cc) => + for { + _ <- NewStyle.function.hasEntitlement(bank.bankId.value, user.userId, canDeleteTaxResidence, Some(cc)) + (_, _) <- NewStyle.function.getCustomerByCustomerId(customerIdStr, Some(cc)) + (deleted, _) <- NewStyle.function.deleteTaxResidence(taxResidenceIdStr, Some(cc)) + } yield Full(deleted) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteTaxResidence), "DELETE", + "/banks/BANK_ID/customers/CUSTOMER_ID/tax_residencies/TAX_RESIDENCE_ID", + "Delete Tax Residence", + s"""Delete a Tax Residence of the Customer specified by TAX_RESIDENCE_ID. + | + |${userAuthenticationMessage(true)} + |""", + EmptyBody, EmptyBody, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagCustomer, apiTagKyc), Some(List(canDeleteTaxResidence)), + http4sPartialFunction = Some(deleteTaxResidence)) + + // ─── deleteCustomerAddress ─────────────────────────────────────────────── + + val deleteCustomerAddress: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / _ / "customers" / customerIdStr / "addresses" / customerAddressIdStr => + EndpointHelpers.withUserAndBank(req) { (user, bank, cc) => + for { + _ <- NewStyle.function.hasEntitlement(bank.bankId.value, user.userId, canDeleteCustomerAddress, Some(cc)) + (_, _) <- NewStyle.function.getCustomerByCustomerId(customerIdStr, Some(cc)) + (deleted, _) <- NewStyle.function.deleteCustomerAddress(customerAddressIdStr, Some(cc)) + } yield Full(deleted) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteCustomerAddress), "DELETE", + "/banks/BANK_ID/customers/CUSTOMER_ID/addresses/CUSTOMER_ADDRESS_ID", + "Delete Customer Address", + s"""Delete an Address of the Customer specified by CUSTOMER_ADDRESS_ID. + | + |${userAuthenticationMessage(true)} + |""", + EmptyBody, EmptyBody, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagCustomer, apiTagKyc), Some(List(canDeleteCustomerAddress)), + http4sPartialFunction = Some(deleteCustomerAddress)) + + // ─── deleteProductAttribute ────────────────────────────────────────────── + // Note: this DELETE returns 204 (matches original v3.1.0 behavior). + + val deleteProductAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / bankIdStr / "products" / _ / "attributes" / productAttributeIdStr => + EndpointHelpers.withUserDelete(req) { (user, cc) => + for { + _ <- NewStyle.function.hasEntitlement(bankIdStr, user.userId, canDeleteProductAttribute, Some(cc)) + (_, _) <- NewStyle.function.getBank(BankId(bankIdStr), Some(cc)) + (deleted, _) <- NewStyle.function.deleteProductAttribute(productAttributeIdStr, Some(cc)) + } yield deleted + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteProductAttribute), "DELETE", + "/banks/BANK_ID/products/PRODUCT_CODE/attributes/PRODUCT_ATTRIBUTE_ID", + "Delete Product Attribute", + s"""Delete a Product Attribute by its id. + | + |${userAuthenticationMessage(true)} + |""", + EmptyBody, EmptyBody, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, BankNotFound, UnknownError), + List(apiTagProduct, apiTagProductAttribute, apiTagAttribute), + Some(List(canDeleteProductAttribute)), + http4sPartialFunction = Some(deleteProductAttribute)) + + // ─── deleteBranch ──────────────────────────────────────────────────────── + + val deleteBranch: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / _ / "branches" / branchIdStr => + EndpointHelpers.withUserAndBank(req) { (user, bank, cc) => + val allowedEntitlements = canDeleteBranch :: canDeleteBranchAtAnyBank :: Nil + val allowedEntitlementsTxt = allowedEntitlements.mkString(" or ") + for { + _ <- NewStyle.function.hasAtLeastOneEntitlement(failMsg = UserHasMissingRoles + allowedEntitlementsTxt)( + bank.bankId.value, user.userId, allowedEntitlements, Some(cc)) + (branch, _) <- NewStyle.function.getBranch(bank.bankId, BranchId(branchIdStr), Some(cc)) + (deleted, _) <- NewStyle.function.deleteBranch(branch, Some(cc)) + } yield Full(deleted) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteBranch), "DELETE", + "/banks/BANK_ID/branches/BRANCH_ID", + "Delete Branch", + s"""Delete Branch from given Bank. + | + |${userAuthenticationMessage(true)} + |""", + EmptyBody, EmptyBody, + List(AuthenticatedUserIsRequired, BankNotFound, InsufficientAuthorisationToDeleteBranch, UnknownError), + List(apiTagBranch), Some(List(canDeleteBranch, canDeleteBranchAtAnyBank)), + http4sPartialFunction = Some(deleteBranch)) + + // ─── deleteSystemView ──────────────────────────────────────────────────── + + val deleteSystemView: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "system-views" / viewIdStr if viewIdStr.nonEmpty => + EndpointHelpers.withUser(req) { (user, cc) => + for { + _ <- NewStyle.function.hasEntitlement("", user.userId, canDeleteSystemView, Some(cc)) + _ <- ViewNewStyle.systemView(ViewId(viewIdStr), Some(cc)) + deleted <- ViewNewStyle.deleteSystemView(ViewId(viewIdStr), Some(cc)) + } yield Full(deleted) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, "deleteSystemView", "DELETE", + "/system-views/SYS_VIEW_ID", + "Delete System View", + "Deletes the system view specified by VIEW_ID", + EmptyBody, EmptyBody, + List(AuthenticatedUserIsRequired, BankAccountNotFound, UnknownError, "user does not have owner access"), + List(apiTagSystemView), Some(List(canDeleteSystemView)), + http4sPartialFunction = Some(deleteSystemView)) + + // ─── deleteMethodRouting ───────────────────────────────────────────────── + + val deleteMethodRouting: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "management" / "method_routings" / methodRoutingIdStr => + EndpointHelpers.withUser(req) { (user, cc) => + for { + (_, _) <- NewStyle.function.getMethodRoutingById(methodRoutingIdStr, Some(cc)) + _ <- NewStyle.function.hasEntitlement("", user.userId, canDeleteMethodRouting, Some(cc)) + deleted <- NewStyle.function.deleteMethodRouting(methodRoutingIdStr) + } yield deleted + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteMethodRouting), "DELETE", + "/management/method_routings/METHOD_ROUTING_ID", + "Delete MethodRouting", + s"""Delete a MethodRouting specified by METHOD_ROUTING_ID. + | + |${userAuthenticationMessage(true)} + |""", + EmptyBody, EmptyBody, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagMethodRouting, apiTagApi), Some(List(canDeleteMethodRouting)), + http4sPartialFunction = Some(deleteMethodRouting)) + + // ─── deleteCardForBank ─────────────────────────────────────────────────── + // Note: original v3.1.0 returns 204 — use withUserAndBankDelete. + + val deleteCardForBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "management" / "banks" / _ / "cards" / cardIdStr => + EndpointHelpers.withUserAndBankDelete(req) { (user, bank, cc) => + for { + _ <- NewStyle.function.hasEntitlement(bank.bankId.value, user.userId, ApiRole.canDeleteCardsForBank, Some(cc)) + (deleted, _) <- NewStyle.function.deletePhysicalCardForBank(bank.bankId, cardIdStr, Some(cc)) + } yield deleted + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteCardForBank), "DELETE", + "/management/banks/BANK_ID/cards/CARD_ID", + "Delete Card", + s"""Delete a Card at bank specified by CARD_ID. + | + |${userAuthenticationMessage(true)} + |""", + EmptyBody, EmptyBody, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, AllowedValuesAre, UnknownError), + List(apiTagCard), Some(List(canCreateCardsForBank)), + http4sPartialFunction = Some(deleteCardForBank)) + + // ─── deleteWebUiProps ──────────────────────────────────────────────────── + + val deleteWebUiProps: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "management" / "webui_props" / webUiPropsIdStr => + EndpointHelpers.withUser(req) { (user, cc) => + for { + _ <- NewStyle.function.hasEntitlement("", user.userId, canDeleteWebUiProps, Some(cc)) + deleted <- Future(MappedWebUiPropsProvider.delete(webUiPropsIdStr)) map { + unboxFullOrFail(_, Some(cc)) + } + } yield deleted + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteWebUiProps), "DELETE", + "/management/webui_props/WEB_UI_PROPS_ID", + "Delete WebUiProps", + s"""Delete a WebUiProps specified by WEB_UI_PROPS_ID. + | + |${userAuthenticationMessage(true)} + |""", + EmptyBody, EmptyBody, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagWebUiProps), Some(List(canDeleteWebUiProps)), + http4sPartialFunction = Some(deleteWebUiProps)) + + // ─── revokeConsent ─────────────────────────────────────────────────────── + // Routed as GET in Lift — keep matching shape. + + val revokeConsent: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "my" / "consents" / consentIdStr / "revoke" => + EndpointHelpers.withUserAndBank(req) { (user, _, cc) => + for { + consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentIdStr)) map { + unboxFullOrFail(_, Some(cc), ConsentNotFound) + } + _ <- code.util.Helper.booleanToFuture(failMsg = ConsentNotFound, cc = Some(cc)) { + consent.mUserId == user.userId + } + revoked <- Future(Consents.consentProvider.vend.revoke(consentIdStr)) map { + i => connectorEmptyResponse(i, Some(cc)) + } + } yield ConsentJsonV310(revoked.consentId, revoked.jsonWebToken, revoked.status) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, "revokeConsent", "GET", + "/banks/BANK_ID/my/consents/CONSENT_ID/revoke", + "Revoke Consent", + s"""Revoke Consent for current user specified by CONSENT_ID + | + |${userAuthenticationMessage(true)} + |""".stripMargin, + EmptyBody, revokedConsentJsonV310, + List(AuthenticatedUserIsRequired, BankNotFound, UnknownError), + List(apiTagConsent, apiTagPSD2AIS, apiTagPsd2), None, + http4sPartialFunction = Some(revokeConsent)) + + // ─── createTaxResidence (POST) ─────────────────────────────────────────── + + val createTaxResidence: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "customers" / customerIdStr / "tax-residence" => + EndpointHelpers.withUserAndBankAndBodyCreated[PostTaxResidenceJsonV310, Any](req) { (user, bank, postedData, cc) => + for { + _ <- NewStyle.function.hasEntitlement(bank.bankId.value, user.userId, canCreateTaxResidence, Some(cc)) + (_, _) <- NewStyle.function.getCustomerByCustomerId(customerIdStr, Some(cc)) + (taxResidence, _) <- NewStyle.function.createTaxResidence( + customerIdStr, postedData.domain, postedData.tax_number, Some(cc)) + } yield JSONFactory310.createTaxResidence(taxResidence) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createTaxResidence), "POST", + "/banks/BANK_ID/customers/CUSTOMER_ID/tax-residence", + "Create Tax Residence", + s"""Create a Tax Residence for a Customer specified by CUSTOMER_ID. + | + |${userAuthenticationMessage(true)} + |""", + postTaxResidenceJsonV310, taxResidenceV310, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagCustomer, apiTagKyc), Some(List(canCreateTaxResidence)), + http4sPartialFunction = Some(createTaxResidence)) + + // ─── createCustomerAddress (POST) ──────────────────────────────────────── + + val createCustomerAddress: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "customers" / customerIdStr / "address" => + EndpointHelpers.withUserAndBankAndBodyCreated[PostCustomerAddressJsonV310, Any](req) { (user, bank, postedData, cc) => + for { + _ <- NewStyle.function.hasEntitlement(bank.bankId.value, user.userId, canCreateCustomerAddress, Some(cc)) + (_, _) <- NewStyle.function.getCustomerByCustomerId(customerIdStr, Some(cc)) + (address, _) <- NewStyle.function.createCustomerAddress( + customerIdStr, + postedData.line_1, postedData.line_2, postedData.line_3, + postedData.city, postedData.county, postedData.state, + postedData.postcode, postedData.country_code, + postedData.state, + postedData.tags.mkString(","), + Some(cc)) + } yield JSONFactory310.createAddress(address) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createCustomerAddress), "POST", + "/banks/BANK_ID/customers/CUSTOMER_ID/address", + "Create Address", + s"""Create an Address for a Customer specified by CUSTOMER_ID. + | + |${userAuthenticationMessage(true)} + |""", + postCustomerAddressJsonV310, customerAddressJsonV310, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagCustomer), Some(List(canCreateCustomerAddress)), + http4sPartialFunction = Some(createCustomerAddress)) + + // ─── createUserAuthContext (POST) ──────────────────────────────────────── + + val createUserAuthContext: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "users" / userIdStr / "auth-context" => + EndpointHelpers.withUserAndBodyCreated[PostUserAuthContextJson, Any](req) { (user, postedData, cc) => + for { + _ <- NewStyle.function.hasEntitlement("", user.userId, canCreateUserAuthContext, Some(cc)) + (subjectUser, _) <- NewStyle.function.findByUserId(userIdStr, Some(cc)) + (userAuthContext, _) <- NewStyle.function.createUserAuthContext( + subjectUser, postedData.key.trim, postedData.value.trim, Some(cc)) + } yield JSONFactory310.createUserAuthContextJson(userAuthContext) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createUserAuthContext), "POST", + "/users/USER_ID/auth-context", + "Create User Auth Context", + s"""Create User Auth Context. + | + |${userAuthenticationMessage(true)} + |""", + postUserAuthContextJson, userAuthContextJson, + List(AuthenticatedUserIsRequired, InvalidJsonFormat, CreateUserAuthContextError, UnknownError), + List(apiTagUser), Some(List(canCreateUserAuthContext)), + http4sPartialFunction = Some(createUserAuthContext)) + + // ─── createProductAttribute (POST) ─────────────────────────────────────── + + val createProductAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "products" / productCodeStr / "attribute" => + EndpointHelpers.withUserAndBodyCreated[ProductAttributeJson, Any](req) { (user, postedData, cc) => + for { + _ <- NewStyle.function.hasEntitlement(bankIdStr, user.userId, canCreateProductAttribute, Some(cc)) + (_, _) <- NewStyle.function.getBank(BankId(bankIdStr), Some(cc)) + productAttributeType <- NewStyle.function.tryons( + s"$InvalidJsonFormat The `Type` field can only accept the following field: " + + s"${ProductAttributeType.DOUBLE}(12.1234), ${ProductAttributeType.STRING}(TAX_NUMBER), ${ProductAttributeType.INTEGER}(123) and ${ProductAttributeType.DATE_WITH_DAY}(2012-04-23)", + 400, Some(cc)) { ProductAttributeType.withName(postedData.`type`) } + (productAttribute, _) <- NewStyle.function.createOrUpdateProductAttribute( + BankId(bankIdStr), ProductCode(productCodeStr), None, + postedData.name, productAttributeType, postedData.value, None, Some(cc)) + } yield createProductAttributeJson(productAttribute) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createProductAttribute), "POST", + "/banks/BANK_ID/products/PRODUCT_CODE/attribute", + "Create Product Attribute", + s"""Create a Product Attribute on a Product. + | + |${userAuthenticationMessage(true)} + |""", + productAttributeJson, productAttributeResponseJson, + List(InvalidJsonFormat, UnknownError), + List(apiTagProduct, apiTagProductAttribute, apiTagAttribute), + Some(List(canCreateProductAttribute)), + http4sPartialFunction = Some(createProductAttribute)) + + // ─── createAccountWebhook (POST) ───────────────────────────────────────── + + private val accountWebHookInfo = + s"""Webhooks are used to call external URLs when certain events happen. + | + |Account Webhooks focus on events around accounts. + | + |For instance, a webhook could be used to notify an external service if a balance changes on an account. + | + |This functionality is work in progress! Please note that only implemented trigger is: ${ApiTrigger.onBalanceChange}""" + + val createAccountWebhook: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "account-web-hooks" => + EndpointHelpers.withUserAndBankAndBodyCreated[AccountWebhookPostJson, Any](req) { (user, bank, postJson, cc) => + for { + _ <- NewStyle.function.hasEntitlement(bank.bankId.value, user.userId, ApiRole.canCreateWebhook, Some(cc)) + _ <- NewStyle.function.tryons( + IncorrectTriggerName + postJson.trigger_name + ". Possible values are " + ApiTrigger.availableTriggers.sorted.mkString(", "), + 400, Some(cc)) { ApiTrigger.valueOf(postJson.trigger_name) } + isActive <- NewStyle.function.tryons( + s"$InvalidBoolean Possible values of the json field is_active are true or false.", + 400, Some(cc)) { postJson.is_active.toBoolean } + wh <- AccountWebhook.accountWebhook.vend.createAccountWebhookFuture( + bankId = bank.bankId.value, + accountId = postJson.account_id, + userId = user.userId, + triggerName = postJson.trigger_name, + url = postJson.url, + httpMethod = postJson.http_method, + httpProtocol = postJson.http_protocol, + isActive = isActive + ) map { unboxFullOrFail(_, Some(cc), CreateWebhookError) } + } yield createAccountWebhookJson(wh) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createAccountWebhook), "POST", + "/banks/BANK_ID/account-web-hooks", + "Create an Account Webhook", + s"""Create an Account Webhook + | + |$accountWebHookInfo + |""", + accountWebhookPostJson, accountWebhookJson, + List(UnknownError), + apiTagWebhook :: apiTagBank :: Nil, Some(List(canCreateWebhook)), + http4sPartialFunction = Some(createAccountWebhook)) + + // ─── unlockUser (PUT) ──────────────────────────────────────────────────── + + val unlockUser: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "users" / username / "lock-status" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + subjectUser <- Users.users.vend.getUserByProviderAndUsernameFuture(Constant.localIdentityProvider, username) map { + x => unboxFullOrFail(x, Some(cc), UserNotFoundByProviderAndUsername, 404) + } + _ <- NewStyle.function.hasEntitlement("", user.userId, ApiRole.canUnlockUser, Some(cc)) + _ <- Future(LoginAttempt.resetBadLoginAttempts(localIdentityProvider, username)) + _ <- Future(UserLocksProvider.unlockUser(localIdentityProvider, username)) + badLoginStatus <- Future(LoginAttempt.getOrCreateBadLoginStatus(localIdentityProvider, username)) map { + unboxFullOrFail(_, Some(cc), s"$UserNotFoundByProviderAndUsername($username)", 404) + } + } yield createBadLoginStatusJson(badLoginStatus) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(unlockUser), "PUT", + "/users/USERNAME/lock-status", + "Unlock the user", + s"""Unlock a User. + | + |(Perhaps the user was locked due to multiple failed login attempts) + | + |${userAuthenticationMessage(true)} + |""".stripMargin, + EmptyBody, badLoginStatusJson, + List(AuthenticatedUserIsRequired, UserNotFoundByProviderAndUsername, + UserHasMissingRoles, UnknownError), + List(apiTagUser), Some(List(canUnlockUser)), + http4sPartialFunction = Some(unlockUser)) + + // ─── callsLimit (PUT) ──────────────────────────────────────────────────── + + val callsLimit: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "management" / "consumers" / consumerIdStr / "consumer" / "call-limits" => + EndpointHelpers.withUserAndBody[CallLimitPostJson, Any](req) { (user, postJson, cc) => + for { + _ <- NewStyle.function.hasEntitlement("", user.userId, canUpdateRateLimits, Some(cc)) + _ <- NewStyle.function.getConsumerByConsumerId(consumerIdStr, Some(cc)) + rateLimiting <- RateLimitingDI.rateLimiting.vend.createOrUpdateConsumerCallLimits( + consumerIdStr, + postJson.from_date, + postJson.to_date, + None, None, None, + Some(postJson.per_second_call_limit), + Some(postJson.per_minute_call_limit), + Some(postJson.per_hour_call_limit), + Some(postJson.per_day_call_limit), + Some(postJson.per_week_call_limit), + Some(postJson.per_month_call_limit)) map { + unboxFullOrFail(_, Some(cc), UpdateConsumerError) + } + } yield createCallsLimitJson(rateLimiting) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(callsLimit), "PUT", + "/management/consumers/CONSUMER_ID/consumer/call-limits", + "Set Rate Limits (call limits) per Consumer", + s"""Set the API rate limiting (call limits) per Consumer. + | + |${userAuthenticationMessage(true)} + |""".stripMargin, + callLimitPostJson, callLimitPostJson, + List(AuthenticatedUserIsRequired, InvalidJsonFormat, InvalidConsumerId, + ConsumerNotFoundByConsumerId, UserHasMissingRoles, UpdateConsumerError, UnknownError), + List(apiTagConsumer), Some(List(canUpdateRateLimits)), + http4sPartialFunction = Some(callsLimit)) + + // ─── enableDisableAccountWebhook (PUT) ─────────────────────────────────── + + val enableDisableAccountWebhook: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "account-web-hooks" => + EndpointHelpers.withUserAndBankAndBody[AccountWebhookPutJson, Any](req) { (user, bank, putJson, cc) => + for { + _ <- NewStyle.function.hasEntitlement(bank.bankId.value, user.userId, ApiRole.canUpdateWebhook, Some(cc)) + isActive <- NewStyle.function.tryons( + s"$InvalidBoolean Possible values of the json field is_active are true or false.", + 400, Some(cc)) { putJson.is_active.toBoolean } + _ <- AccountWebhook.accountWebhook.vend.getAccountWebhookByIdFuture(putJson.account_webhook_id) map { + unboxFullOrFail(_, Some(cc), WebhookNotFound) + } + wh <- AccountWebhook.accountWebhook.vend.updateAccountWebhookFuture( + accountWebhookId = putJson.account_webhook_id, + isActive = isActive + ) map { unboxFullOrFail(_, Some(cc), UpdateWebhookError) } + } yield createAccountWebhookJson(wh) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(enableDisableAccountWebhook), "PUT", + "/banks/BANK_ID/account-web-hooks", + "Enable/Disable an Account Webhook", + s"""Enable/Disable an Account Webhook + | + |$accountWebHookInfo + |""", + accountWebhookPutJson, accountWebhookJson, + List(UnknownError), + apiTagWebhook :: apiTagBank :: Nil, Some(List(canUpdateWebhook)), + http4sPartialFunction = Some(enableDisableAccountWebhook)) + + // ─── enableDisableConsumers (PUT) ──────────────────────────────────────── + + val enableDisableConsumers: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "management" / "consumers" / consumerIdStr => + EndpointHelpers.withUserAndBody[PutEnabledJSON, Any](req) { (user, putData, cc) => + for { + _ <- putData.enabled match { + case true => NewStyle.function.hasEntitlement("", user.userId, ApiRole.canEnableConsumers, Some(cc)) + case false => NewStyle.function.hasEntitlement("", user.userId, ApiRole.canDisableConsumers, Some(cc)) + } + consumer <- NewStyle.function.getConsumerByConsumerId(consumerIdStr, Some(cc)) + updatedConsumer <- Future { + Consumers.consumers.vend.updateConsumer( + consumer.id.get, None, None, Some(putData.enabled), + None, None, None, None, None, None, None, None) ?~! "Cannot update Consumer" + } + } yield PutEnabledJSON(updatedConsumer.map(_.isActive.get).getOrElse(false)) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, "enableDisableConsumers", "PUT", + "/management/consumers/CONSUMER_ID", + "Enable or Disable Consumers", + s"""Enable/Disable a Consumer specified by CONSUMER_ID.""", + putEnabledJSON, putEnabledJSON, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagConsumer), Some(List(canEnableConsumers, canDisableConsumers)), + http4sPartialFunction = Some(enableDisableConsumers)) + + // ─── updateSystemView (PUT) ────────────────────────────────────────────── + + val updateSystemView: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "system-views" / viewIdStr if viewIdStr.nonEmpty => + EndpointHelpers.withUserAndBody[UpdateViewJSON, Any](req) { (user, updateJson, cc) => + for { + _ <- NewStyle.function.hasEntitlement("", user.userId, canUpdateSystemView, Some(cc)) + _ <- code.util.Helper.booleanToFuture(SystemViewCannotBePublicError, failCode = 400, cc = Some(cc)) { + updateJson.is_public == false + } + _ <- ViewNewStyle.systemView(ViewId(viewIdStr), Some(cc)) + updatedView <- ViewNewStyle.updateSystemView(ViewId(viewIdStr), updateJson, Some(cc)) + } yield JSONFactory310.createViewJSON(updatedView) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateSystemView), "PUT", + "/system-views/SYS_VIEW_ID", + "Update System View", + s"""Update an existing view on a bank account. + | + |${userAuthenticationMessage(true)} and the user needs to have access to the owner view. + | + |The json sent is the same as during view creation, with one difference: the 'name' field + |of a view is not editable (it is only set when a view is created).""", + updateSystemViewJson310, viewJsonV300, + List(InvalidJsonFormat, AuthenticatedUserIsRequired, BankAccountNotFound, UnknownError), + List(apiTagSystemView), Some(List(canUpdateSystemView)), + http4sPartialFunction = Some(updateSystemView)) + + // ─── updateProductAttribute (PUT) ──────────────────────────────────────── + + val updateProductAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / bankIdStr / "products" / productCodeStr / "attributes" / productAttributeIdStr => + EndpointHelpers.withUserAndBody[ProductAttributeJson, Any](req) { (user, postedData, cc) => + for { + _ <- NewStyle.function.hasEntitlement(bankIdStr, user.userId, canUpdateProductAttribute, Some(cc)) + (_, _) <- NewStyle.function.getBank(BankId(bankIdStr), Some(cc)) + productAttributeType <- NewStyle.function.tryons( + s"$InvalidJsonFormat The `Type` field can only accept the following field: " + + s"${ProductAttributeType.DOUBLE}(12.1234), ${ProductAttributeType.STRING}(TAX_NUMBER), ${ProductAttributeType.INTEGER}(123) and ${ProductAttributeType.DATE_WITH_DAY}(2012-04-23)", + 400, Some(cc)) { ProductAttributeType.withName(postedData.`type`) } + (_, _) <- NewStyle.function.getProductAttributeById(productAttributeIdStr, Some(cc)) + (productAttribute, _) <- NewStyle.function.createOrUpdateProductAttribute( + BankId(bankIdStr), ProductCode(productCodeStr), Some(productAttributeIdStr), + postedData.name, productAttributeType, postedData.value, None, Some(cc)) + } yield createProductAttributeJson(productAttribute) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateProductAttribute), "PUT", + "/banks/BANK_ID/products/PRODUCT_CODE/attributes/PRODUCT_ATTRIBUTE_ID", + "Update Product Attribute", + s"""Update one Product Attribute by its id. + | + |${userAuthenticationMessage(true)} + |""", + productAttributeJson, productAttributeResponseJson, + List(UserHasMissingRoles, UnknownError), + List(apiTagProduct, apiTagProductAttribute, apiTagAttribute), + Some(List(canUpdateProductAttribute)), + http4sPartialFunction = Some(updateProductAttribute)) + + // ─── updateCustomerEmail (PUT) ─────────────────────────────────────────── + + val updateCustomerEmail: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "customers" / customerIdStr / "email" => + EndpointHelpers.withUserAndBankAndBody[PutUpdateCustomerEmailJsonV310, Any](req) { (user, bank, putData, cc) => + for { + _ <- NewStyle.function.hasEntitlement(bank.bankId.value, user.userId, canUpdateCustomerEmail, Some(cc)) + (_, _) <- NewStyle.function.getCustomerByCustomerId(customerIdStr, Some(cc)) + (customer, _) <- NewStyle.function.updateCustomerScaData( + customerIdStr, None, Some(putData.email), None, Some(cc)) + } yield JSONFactory310.createCustomerJson(customer) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateCustomerEmail), "PUT", + "/banks/BANK_ID/customers/CUSTOMER_ID/email", + "Update the email of a Customer", + s"""Update an email of the Customer specified by CUSTOMER_ID. + | + |${userAuthenticationMessage(true)} + |""", + putUpdateCustomerEmailJsonV310, customerJsonV310, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagCustomer), Some(canUpdateCustomerEmail :: Nil), + http4sPartialFunction = Some(updateCustomerEmail)) + + // ─── updateCustomerNumber (PUT) ────────────────────────────────────────── + + val updateCustomerNumber: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "customers" / customerIdStr / "number" => + EndpointHelpers.withUserAndBankAndBody[PutUpdateCustomerNumberJsonV310, Any](req) { (user, bank, putData, cc) => + for { + _ <- NewStyle.function.hasEntitlement(bank.bankId.value, user.userId, canUpdateCustomerNumber, Some(cc)) + (_, _) <- NewStyle.function.getCustomerByCustomerId(customerIdStr, Some(cc)) + (customerNumberIsAvalible, _) <- NewStyle.function.checkCustomerNumberAvailable(bank.bankId, putData.customer_number, Some(cc)) + _ <- code.util.Helper.booleanToFuture( + failMsg = s"$CustomerNumberAlreadyExists Current customer_number(${putData.customer_number}) and Current bank_id(${bank.bankId.value})", + cc = Some(cc)) { customerNumberIsAvalible } + (customer, _) <- NewStyle.function.updateCustomerScaData( + customerIdStr, None, None, Some(putData.customer_number), Some(cc)) + } yield JSONFactory310.createCustomerJson(customer) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateCustomerNumber), "PUT", + "/banks/BANK_ID/customers/CUSTOMER_ID/number", + "Update the number of a Customer", + s"""Update the number of the Customer specified by CUSTOMER_ID. + | + |${userAuthenticationMessage(true)} + |""", + putUpdateCustomerNumberJsonV310, customerJsonV310, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagCustomer), Some(canUpdateCustomerNumber :: Nil), + http4sPartialFunction = Some(updateCustomerNumber)) + + // ─── updateCustomerMobileNumber (PUT) ──────────────────────────────────── + + val updateCustomerMobileNumber: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "customers" / customerIdStr / "mobile-number" => + EndpointHelpers.withUserAndBankAndBody[PutUpdateCustomerMobilePhoneNumberJsonV310, Any](req) { (user, bank, putData, cc) => + for { + _ <- NewStyle.function.hasEntitlement(bank.bankId.value, user.userId, canUpdateCustomerMobilePhoneNumber, Some(cc)) + (_, _) <- NewStyle.function.getCustomerByCustomerId(customerIdStr, Some(cc)) + (customer, _) <- NewStyle.function.updateCustomerScaData( + customerIdStr, Some(putData.mobile_phone_number), None, None, Some(cc)) + } yield JSONFactory310.createCustomerJson(customer) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateCustomerMobileNumber), "PUT", + "/banks/BANK_ID/customers/CUSTOMER_ID/mobile-number", + "Update the mobile number of a Customer", + s"""Update the mobile number of the Customer specified by CUSTOMER_ID. + | + |${userAuthenticationMessage(true)} + |""", + putUpdateCustomerMobileNumberJsonV310, customerJsonV310, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagCustomer), Some(canUpdateCustomerMobilePhoneNumber :: Nil), + http4sPartialFunction = Some(updateCustomerMobileNumber)) + + // ─── updateCustomerIdentity (PUT) ──────────────────────────────────────── + + val updateCustomerIdentity: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "customers" / customerIdStr / "identity" => + EndpointHelpers.withUserAndBankAndBody[PutUpdateCustomerIdentityJsonV310, Any](req) { (user, bank, putData, cc) => + for { + _ <- NewStyle.function.hasEntitlement(bank.bankId.value, user.userId, canUpdateCustomerIdentity, Some(cc)) + (_, _) <- NewStyle.function.getCustomerByCustomerId(customerIdStr, Some(cc)) + (customer, _) <- NewStyle.function.updateCustomerGeneralData( + customerIdStr, + Some(putData.legal_name), None, Some(putData.date_of_birth), + None, None, None, None, + Some(putData.title), None, Some(putData.name_suffix), + None, None, Some(cc)) + } yield JSONFactory310.createCustomerJson(customer) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateCustomerIdentity), "PUT", + "/banks/BANK_ID/customers/CUSTOMER_ID/identity", + "Update the identity data of a Customer", + s"""Update the identity data of the Customer specified by CUSTOMER_ID. + | + |${userAuthenticationMessage(true)} + |""", + putUpdateCustomerIdentityJsonV310, customerJsonV310, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagCustomer), Some(canUpdateCustomerIdentity :: Nil), + http4sPartialFunction = Some(updateCustomerIdentity)) + + // ─── updateCustomerCreditLimit (PUT) ───────────────────────────────────── + + val updateCustomerCreditLimit: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "customers" / customerIdStr / "credit-limit" => + EndpointHelpers.withUserAndBankAndBody[PutUpdateCustomerCreditLimitJsonV310, Any](req) { (user, bank, putData, cc) => + for { + _ <- NewStyle.function.hasEntitlement(bank.bankId.value, user.userId, canUpdateCustomerCreditLimit, Some(cc)) + (_, _) <- NewStyle.function.getCustomerByCustomerId(customerIdStr, Some(cc)) + (customer, _) <- NewStyle.function.updateCustomerCreditData( + customerIdStr, None, None, Some(putData.credit_limit), Some(cc)) + } yield JSONFactory310.createCustomerJson(customer) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateCustomerCreditLimit), "PUT", + "/banks/BANK_ID/customers/CUSTOMER_ID/credit-limit", + "Update the credit limit of a Customer", + s"""Update the credit limit of the Customer specified by CUSTOMER_ID. + | + |${userAuthenticationMessage(true)} + |""", + putUpdateCustomerCreditLimitJsonV310, customerJsonV310, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagCustomer), Some(canUpdateCustomerCreditLimit :: Nil), + http4sPartialFunction = Some(updateCustomerCreditLimit)) + + // ─── updateCustomerCreditRatingAndSource (PUT) ─────────────────────────── + + val updateCustomerCreditRatingAndSource: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "customers" / customerIdStr / "credit-rating-and-source" => + EndpointHelpers.withUserAndBankAndBody[PutUpdateCustomerCreditRatingAndSourceJsonV310, Any](req) { (user, bank, putData, cc) => + for { + _ <- NewStyle.function.hasAtLeastOneEntitlement(bank.bankId.value, user.userId, + List(canUpdateCustomerCreditRatingAndSource, canUpdateCustomerCreditRatingAndSourceAtAnyBank), Some(cc)) + (_, _) <- NewStyle.function.getCustomerByCustomerId(customerIdStr, Some(cc)) + (customer, _) <- NewStyle.function.updateCustomerCreditData( + customerIdStr, Some(putData.credit_rating), Some(putData.credit_source), None, Some(cc)) + } yield JSONFactory310.createCustomerJson(customer) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateCustomerCreditRatingAndSource), "PUT", + "/banks/BANK_ID/customers/CUSTOMER_ID/credit-rating-and-source", + "Update the credit rating and source of a Customer", + s"""Update the credit rating and source of the Customer specified by CUSTOMER_ID. + | + |${userAuthenticationMessage(true)} + |""", + putUpdateCustomerCreditRatingAndSourceJsonV310, customerJsonV310, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagCustomer), + Some(canUpdateCustomerCreditRatingAndSource :: canUpdateCustomerCreditRatingAndSourceAtAnyBank :: Nil), + http4sPartialFunction = Some(updateCustomerCreditRatingAndSource)) + + // ─── updateCustomerBranch (PUT) ────────────────────────────────────────── + + val updateCustomerBranch: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "customers" / customerIdStr / "branch" => + EndpointHelpers.withUserAndBankAndBody[PutUpdateCustomerBranchJsonV310, Any](req) { (user, bank, putData, cc) => + for { + _ <- NewStyle.function.hasEntitlement(bank.bankId.value, user.userId, canUpdateCustomerBranch, Some(cc)) + (_, _) <- NewStyle.function.getCustomerByCustomerId(customerIdStr, Some(cc)) + (customer, _) <- NewStyle.function.updateCustomerGeneralData( + customerIdStr, + None, None, None, None, None, None, None, + None, Some(putData.branch_id), None, None, None, Some(cc)) + } yield JSONFactory310.createCustomerJson(customer) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateCustomerBranch), "PUT", + "/banks/BANK_ID/customers/CUSTOMER_ID/branch", + "Update the Branch of a Customer", + s"""Update the Branch of the Customer specified by CUSTOMER_ID. + | + |${userAuthenticationMessage(true)} + |""", + putCustomerBranchJsonV310, customerJsonV310, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagCustomer), Some(canUpdateCustomerBranch :: Nil), + http4sPartialFunction = Some(updateCustomerBranch)) + + // ─── updateCustomerData (PUT) ──────────────────────────────────────────── + + val updateCustomerData: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "customers" / customerIdStr / "data" => + EndpointHelpers.withUserAndBankAndBody[PutUpdateCustomerDataJsonV310, Any](req) { (user, bank, putData, cc) => + for { + _ <- NewStyle.function.hasEntitlement(bank.bankId.value, user.userId, canUpdateCustomerData, Some(cc)) + (_, _) <- NewStyle.function.getCustomerByCustomerId(customerIdStr, Some(cc)) + (customer, _) <- NewStyle.function.updateCustomerGeneralData( + customerIdStr, + None, + Some(CustomerFaceImage(putData.face_image.date, putData.face_image.url)), + None, + Some(putData.relationship_status), + Some(putData.dependants), + Some(putData.highest_education_attained), + Some(putData.employment_status), + None, None, None, None, None, Some(cc)) + } yield JSONFactory310.createCustomerJson(customer) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateCustomerData), "PUT", + "/banks/BANK_ID/customers/CUSTOMER_ID/data", + "Update the other data of a Customer", + s"""Update the other data of the Customer specified by CUSTOMER_ID. + | + |${userAuthenticationMessage(true)} + |""", + putUpdateCustomerDataJsonV310, customerJsonV310, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagCustomer), Some(canUpdateCustomerData :: Nil), + http4sPartialFunction = Some(updateCustomerData)) + + // ─── updateAccountApplicationStatus (PUT) ──────────────────────────────── + // Side effect: when status == "ACCEPTED", a new bank account is created for the + // logged-in user. Preserved verbatim from the Lift implementation. + + val updateAccountApplicationStatus: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "account-applications" / accountApplicationIdStr => + EndpointHelpers.withUserAndBankAndBody[AccountApplicationUpdateStatusJson, Any](req) { (user, bank, putJson, cc) => + for { + _ <- NewStyle.function.hasEntitlement("", user.userId, ApiRole.canUpdateAccountApplications, Some(cc)) + _ <- NewStyle.function.tryons(s"$InvalidJsonFormat status should not be blank.", 400, Some(cc)) { + org.apache.commons.lang3.Validate.notBlank(putJson.status) + } + (_, _) <- NewStyle.function.getAccountApplicationById(accountApplicationIdStr, Some(cc)) + (accountApplication, _) <- NewStyle.function.updateAccountApplicationStatus(accountApplicationIdStr, putJson.status, Some(cc)) + userIdOpt = Option(accountApplication.userId) + customerIdOpt = Option(accountApplication.customerId) + appUser <- unboxOptionOBPReturnType(userIdOpt.map(NewStyle.function.findByUserId(_, Some(cc)))) + customer <- unboxOptionOBPReturnType(customerIdOpt.map(NewStyle.function.getCustomerByCustomerId(_, Some(cc)))) + _ <- putJson.status match { + case "ACCEPTED" => + for { + accountId <- Future(AccountId(java.util.UUID.randomUUID().toString)) + (_, _) <- NewStyle.function.createBankAccount( + bank.bankId, accountId, + accountApplication.productCode.value, + "", "EUR", BigDecimal("0"), + user.name, "", + List.empty, Some(cc)) + success <- code.model.dataAccess.BankAccountCreation.setAccountHolderAndRefreshUserAccountAccess( + bank.bankId, accountId, user, Some(cc)) + } yield success + case _ => Future("") + } + } yield createAccountApplicationJson(accountApplication, appUser, customer) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateAccountApplicationStatus), "PUT", + "/banks/BANK_ID/account-applications/ACCOUNT_APPLICATION_ID", + "Update Account Application Status", + s"""Update an Account Application status. + | + |${userAuthenticationMessage(true)} + |""", + accountApplicationUpdateStatusJson, accountApplicationResponseJson, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagAccountApplication, apiTagAccount), None, + http4sPartialFunction = Some(updateAccountApplicationStatus)) + + // ─── createCustomer (POST) ─────────────────────────────────────────────── + + val createCustomer: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "customers" => + EndpointHelpers.withUserAndBankAndBodyCreated[PostCustomerJsonV310, Any](req) { (user, bank, postedData, cc) => + for { + _ <- NewStyle.function.hasAtLeastOneEntitlement(bank.bankId.value, user.userId, + canCreateCustomer :: canCreateCustomerAtAnyBank :: Nil, Some(cc)) + _ <- code.util.Helper.booleanToFuture( + failMsg = InvalidJsonContent + s" The field dependants(${postedData.dependants}) not equal the length(${postedData.dob_of_dependants.length}) of dob_of_dependants array", + cc = Some(cc)) { + postedData.dependants == postedData.dob_of_dependants.length + } + (customer, _) <- NewStyle.function.createCustomer( + bank.bankId, + postedData.legal_name, + postedData.mobile_phone_number, + postedData.email, + CustomerFaceImage(postedData.face_image.date, postedData.face_image.url), + postedData.date_of_birth, + postedData.relationship_status, + postedData.dependants, + postedData.dob_of_dependants, + postedData.highest_education_attained, + postedData.employment_status, + postedData.kyc_status, + postedData.last_ok_date, + Option(CreditRating(postedData.credit_rating.rating, postedData.credit_rating.source)), + Option(CreditLimit(postedData.credit_limit.currency, postedData.credit_limit.amount)), + postedData.title, + postedData.branch_id, + postedData.name_suffix, + Some(cc)) + } yield JSONFactory310.createCustomerJson(customer) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createCustomer), "POST", + "/banks/BANK_ID/customers", + "Create Customer", + s"""The Customer resource stores the customer number (which is set by the backend), legal name, email, phone number, their date of birth, relationship status, education attained, a url for a profile image, KYC status etc. + |Dates need to be in the format 2013-01-21T23:08:00Z + | + |Note: If you need to set a specific customer number, use the Update Customer Number endpoint after this call. + | + |${userAuthenticationMessage(true)} + |""", + postCustomerJsonV310, customerJsonV310, + List(AuthenticatedUserIsRequired, BankNotFound, InvalidJsonFormat, + CustomerNumberAlreadyExists, UserNotFoundById, CustomerAlreadyExistsForUser, + CreateCustomerError, UnknownError), + List(apiTagCustomer, apiTagPerson), + Some(List(canCreateCustomer, canCreateCustomerAtAnyBank)), + http4sPartialFunction = Some(createCustomer)) + + // ─── getCustomerByCustomerNumber (POST → 200) ──────────────────────────── + + val getCustomerByCustomerNumber: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "customers" / "customer-number" => + EndpointHelpers.withUserAndBankAndBody[PostCustomerNumberJsonV310, Any](req) { (user, bank, postedData, cc) => + for { + _ <- NewStyle.function.hasEntitlement(bank.bankId.value, user.userId, canGetCustomersAtOneBank, Some(cc)) + (customer, _) <- NewStyle.function.getCustomerByCustomerNumber(postedData.customer_number, bank.bankId, Some(cc)) + (customerAttributes, _) <- NewStyle.function.getCustomerAttributes( + bank.bankId, CustomerId(customer.customerId), Some(cc)) + } yield JSONFactory310.createCustomerWithAttributesJson(customer, customerAttributes) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCustomerByCustomerNumber), "POST", + "/banks/BANK_ID/customers/customer-number", + "Get Customer by CUSTOMER_NUMBER", + s"""Gets the Customer specified by CUSTOMER_NUMBER. + | + |${userAuthenticationMessage(true)} + |""", + postCustomerNumberJsonV310, customerWithAttributesJsonV310, + List(AuthenticatedUserIsRequired, UserCustomerLinksNotFoundForUser, UnknownError), + List(apiTagCustomer, apiTagKyc), Some(List(canGetCustomersAtOneBank)), + http4sPartialFunction = Some(getCustomerByCustomerNumber)) + + // ─── createAccountApplication (POST) ───────────────────────────────────── + + val createAccountApplication: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "account-applications" => + EndpointHelpers.withUserAndBankAndBodyCreated[AccountApplicationJson, Any](req) { (_, bank, postedData, cc) => + for { + _ <- NewStyle.function.tryons(s"$InvalidJsonFormat product_code should not be empty.", 400, Some(cc)) { + org.apache.commons.lang3.Validate.notBlank(postedData.product_code) + } + _ <- NewStyle.function.tryons(s"$InvalidJsonFormat User_id and customer_id should not both are empty.", 400, Some(cc)) { + org.apache.commons.lang3.Validate.isTrue(postedData.user_id.isDefined || postedData.customer_id.isDefined) + } + appUser <- unboxOptionOBPReturnType(postedData.user_id.map(NewStyle.function.findByUserId(_, Some(cc)))) + customer <- unboxOptionOBPReturnType(postedData.customer_id.map(NewStyle.function.getCustomerByCustomerId(_, Some(cc)))) + (accountApplication, _) <- NewStyle.function.createAccountApplication( + productCode = ProductCode(postedData.product_code), + userId = postedData.user_id, + customerId = postedData.customer_id, + callContext = Some(cc)) + } yield createAccountApplicationJson(accountApplication, appUser, customer) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createAccountApplication), "POST", + "/banks/BANK_ID/account-applications", + "Create Account Application", + s"""Create Account Application. + | + |${userAuthenticationMessage(true)} + |""", + accountApplicationJson, accountApplicationResponseJson, + List(InvalidJsonFormat, UnknownError), + List(apiTagAccountApplication, apiTagAccount), None, + http4sPartialFunction = Some(createAccountApplication)) + + // ─── createAccountAttribute (POST) ─────────────────────────────────────── + + val createAccountAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr / "products" / productCodeStr / "attribute" => + EndpointHelpers.withUserAndBodyCreated[AccountAttributeJson, Any](req) { (user, postedData, cc) => + for { + accountAttributeType <- NewStyle.function.tryons( + s"$InvalidJsonFormat The `Type` field can only accept the following field: " + + s"${AccountAttributeType.DOUBLE}(2012-04-23), ${AccountAttributeType.STRING}(TAX_NUMBER), ${AccountAttributeType.INTEGER}(123) and ${AccountAttributeType.DATE_WITH_DAY}(2012-04-23)", + 400, Some(cc)) { AccountAttributeType.withName(postedData.`type`) } + (_, _) <- NewStyle.function.getBank(BankId(bankIdStr), Some(cc)) + (_, _) <- NewStyle.function.getBankAccount(BankId(bankIdStr), AccountId(accountIdStr), Some(cc)) + _ <- NewStyle.function.hasEntitlement(bankIdStr, user.userId, ApiRole.canCreateAccountAttributeAtOneBank, Some(cc)) + (_, _) <- NewStyle.function.getProduct(BankId(bankIdStr), ProductCode(productCodeStr), Some(cc)) + (accountAttribute, _) <- NewStyle.function.createOrUpdateAccountAttribute( + BankId(bankIdStr), AccountId(accountIdStr), ProductCode(productCodeStr), + None, postedData.name, accountAttributeType, postedData.value, + postedData.product_instance_code, Some(cc)) + } yield createAccountAttributeJson(accountAttribute) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createAccountAttribute), "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/products/PRODUCT_CODE/attribute", + "Create Account Attribute", + s"""Create an Account Attribute. + | + |${userAuthenticationMessage(true)} + |""", + accountAttributeJson, accountAttributeResponseJson, + List(AuthenticatedUserIsRequired, InvalidJsonFormat, UnknownError), + List(apiTagAccount, apiTagAccountAttribute, apiTagAttribute), + Some(List(canCreateAccountAttributeAtOneBank)), + http4sPartialFunction = Some(createAccountAttribute)) + + // ─── updateAccountAttribute (PUT) ──────────────────────────────────────── + + val updateAccountAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr / "products" / productCodeStr / "attributes" / accountAttributeIdStr => + EndpointHelpers.withUserAndBodyCreated[AccountAttributeJson, Any](req) { (user, postedData, cc) => + for { + (_, _) <- NewStyle.function.getBank(BankId(bankIdStr), Some(cc)) + _ <- NewStyle.function.hasEntitlement(bankIdStr, user.userId, canUpdateAccountAttribute, Some(cc)) + accountAttributeType <- NewStyle.function.tryons( + s"$InvalidJsonFormat The `Type` field can only accept the following field: " + + s"${AccountAttributeType.DOUBLE}(2012-04-23), ${AccountAttributeType.STRING}(TAX_NUMBER), ${AccountAttributeType.INTEGER}(123) and ${AccountAttributeType.DATE_WITH_DAY}(2012-04-23)", + 400, Some(cc)) { AccountAttributeType.withName(postedData.`type`) } + (_, _) <- NewStyle.function.getBankAccount(BankId(bankIdStr), AccountId(accountIdStr), Some(cc)) + (_, _) <- NewStyle.function.getProduct(BankId(bankIdStr), ProductCode(productCodeStr), Some(cc)) + (_, _) <- NewStyle.function.getAccountAttributeById(accountAttributeIdStr, Some(cc)) + (accountAttribute, _) <- NewStyle.function.createOrUpdateAccountAttribute( + BankId(bankIdStr), AccountId(accountIdStr), ProductCode(productCodeStr), + Some(accountAttributeIdStr), postedData.name, accountAttributeType, postedData.value, + postedData.product_instance_code, Some(cc)) + } yield createAccountAttributeJson(accountAttribute) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateAccountAttribute), "PUT", + "/banks/BANK_ID/accounts/ACCOUNT_ID/products/PRODUCT_CODE/attributes/ACCOUNT_ATTRIBUTE_ID", + "Update Account Attribute", + s"""Update an Account Attribute by its id. + | + |${userAuthenticationMessage(true)} + |""", + accountAttributeJson, accountAttributeResponseJson, + List(AuthenticatedUserIsRequired, InvalidJsonFormat, UnknownError), + List(apiTagAccount, apiTagAccountAttribute, apiTagAttribute), + Some(List(canUpdateAccountAttribute)), + http4sPartialFunction = Some(updateAccountAttribute)) + + // ─── createMeeting (POST) ──────────────────────────────────────────────── + + val createMeeting: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "meetings" => + // Manual body parsing to preserve Lift's error message format: + // "$InvalidJsonFormat The Json body should be the $CreateMeetingJson " + // (uses the v2.0.0 class name in the message; the test asserts this exact prefix.) + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + val user = cc.user.openOrThrowException(AuthenticatedUserIsRequired) + val bank = cc.bank.getOrElse(throw new RuntimeException(BankNotFound)) + for { + createMeetingJson <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the ${classOf[code.api.v2_0_0.CreateMeetingJson].getSimpleName} ", + 400, Some(cc)) { net.liftweb.json.parse(rawBody).extract[CreateMeetingJsonV310] } + creator = ContactDetails( + createMeetingJson.creator.name, + createMeetingJson.creator.mobile_phone, + createMeetingJson.creator.email_address) + invitees = createMeetingJson.invitees.map(invitee => + Invitee( + ContactDetails(invitee.contact_details.name, invitee.contact_details.mobile_phone, invitee.contact_details.email_address), + invitee.status)) + (meeting, _) <- NewStyle.function.createMeeting( + bank.bankId, user, user, + createMeetingJson.provider_id, createMeetingJson.purpose_id, + createMeetingJson.date, "", "", "", + creator, invitees, Some(cc)) + } yield JSONFactory310.createMeetingJson(meeting) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, "createMeeting", "POST", + "/banks/BANK_ID/meetings", + "Create Meeting (video conference/call)", + """Create Meeting: Initiate a video conference/call with the bank. + | + |Login is required. + |""".stripMargin, + createMeetingJsonV310, meetingJsonV310, + List(AuthenticatedUserIsRequired, BankNotFound, InvalidJsonFormat, UnknownError), + List(apiTagMeeting, apiTagCustomer, apiTagExperimental), None, + http4sPartialFunction = Some(createMeeting)) + + // ─── createSystemView (POST) ───────────────────────────────────────────── + + val createSystemView: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "system-views" => + EndpointHelpers.withUserAndBodyCreated[CreateViewJsonV300, Any](req) { (user, createViewJson, cc) => + for { + _ <- NewStyle.function.hasEntitlement("", user.userId, canCreateSystemView, Some(cc)) + _ <- code.util.Helper.booleanToFuture( + failMsg = InvalidSystemViewFormat + s"Current view_name (${createViewJson.name})", + cc = Some(cc)) { isValidSystemViewName(createViewJson.name) } + _ <- code.util.Helper.booleanToFuture(SystemViewCannotBePublicError, failCode = 400, cc = Some(cc)) { + createViewJson.is_public == false + } + view <- ViewNewStyle.createSystemView(createViewJson.toCreateViewJson, Some(cc)) + } yield JSONFactory310.createViewJSON(view) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createSystemView), "POST", + "/system-views", + "Create System View", + s"""Create a system view. + | + |${userAuthenticationMessage(true)} and the user needs to have access to the $canCreateSystemView entitlement. + |""", + SwaggerDefinitionsJSON.createSystemViewJsonV300, viewJsonV300, + List(AuthenticatedUserIsRequired, InvalidJsonFormat, UnknownError), + List(apiTagSystemView), Some(List(canCreateSystemView)), + http4sPartialFunction = Some(createSystemView)) + + // ─── createProductCollection (PUT — "Create or Update") ────────────────── + + val createProductCollection: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "product-collections" / collectionCodeStr => + EndpointHelpers.withUserAndBankAndBodyCreated[PutProductCollectionsV310, Any](req) { (user, bank, product, cc) => + for { + _ <- NewStyle.function.hasEntitlement(bank.bankId.value, user.userId, canMaintainProductCollection, Some(cc)) + (products, _) <- NewStyle.function.getProducts(bank.bankId, Nil, Some(cc)) + _ <- code.util.Helper.booleanToFuture( + ProductNotFoundByProductCode + " {" + (product.parent_product_code :: product.children_product_codes).mkString(", ") + "}", + cc = Some(cc)) { + val existingCodes = products.map(_.code.value) + val codes = product.parent_product_code :: product.children_product_codes + codes.forall(i => existingCodes.contains(i)) + } + (productCollection, _) <- NewStyle.function.getOrCreateProductCollection( + collectionCodeStr, List(product.parent_product_code), Some(cc)) + (productCollectionItems, _) <- NewStyle.function.getOrCreateProductCollectionItems( + collectionCodeStr, product.children_product_codes, Some(cc)) + } yield createProductCollectionsJson(productCollection, productCollectionItems) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createProductCollection), "PUT", + "/banks/BANK_ID/product-collections/COLLECTION_CODE", + "Create Product Collection", + s"""Create or Update a Product Collection at the Bank. + | + |${userAuthenticationMessage(true)} + |""", + putProductCollectionsV310, productCollectionsJsonV310, + List(AuthenticatedUserIsRequired, BankNotFound, UserHasMissingRoles, UnknownError), + List(apiTagProductCollection, apiTagProduct), Some(List(canMaintainProductCollection)), + http4sPartialFunction = Some(createProductCollection)) + + // ─── addCardForBank (POST) ─────────────────────────────────────────────── + + val addCardForBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "banks" / _ / "cards" => + EndpointHelpers.withUserAndBankAndBodyCreated[CreatePhysicalCardJsonV310, Any](req) { (user, bank, postJson, cc) => + for { + _ <- postJson.allows match { + case List() => Future.successful(true) + case _ => code.util.Helper.booleanToFuture( + AllowedValuesAre + CardAction.availableValues.mkString(", "), cc = Some(cc)) { + postJson.allows.forall(a => CardAction.availableValues.contains(a)) + } + } + cardReplacementReason <- NewStyle.function.tryons( + AllowedValuesAre + CardReplacementReason.availableValues.mkString(", "), 400, Some(cc)) { + postJson.replacement match { + case Some(value) => CardReplacementReason.valueOf(value.reason_requested) + case None => CardReplacementReason.valueOf(CardReplacementReason.FIRST.toString) + } + } + _ <- code.util.Helper.booleanToFuture( + s"${maximumLimitExceeded.replace("10000", "10")} Current issue_number is ${postJson.issue_number}", + cc = Some(cc)) { postJson.issue_number.length <= 10 } + _ <- code.util.Helper.booleanToFuture( + s"$UserHasMissingRoles${ApiRole.canCreateCardsForBank}", + failCode = 403, cc = Some(cc)) { + APIUtil.hasEntitlement(bank.bankId.value, user.userId, ApiRole.canCreateCardsForBank) + } + (_, _) <- NewStyle.function.getBankAccount(bank.bankId, AccountId(postJson.account_id), Some(cc)) + (_, _) <- NewStyle.function.getCustomerByCustomerId(postJson.customer_id, Some(cc)) + replacement = postJson.replacement.map(r => + CardReplacementInfo(requestedDate = r.requested_date, cardReplacementReason)) + collected = postJson.collected.map(c => CardCollectionInfo(c)) + posted = postJson.posted.map(p => CardPostedInfo(p)) + (card, _) <- NewStyle.function.createPhysicalCard( + bankCardNumber = postJson.card_number, + nameOnCard = postJson.name_on_card, + cardType = postJson.card_type, + issueNumber = postJson.issue_number, + serialNumber = postJson.serial_number, + validFrom = postJson.valid_from_date, + expires = postJson.expires_date, + enabled = postJson.enabled, + cancelled = false, + onHotList = false, + technology = postJson.technology, + networks = postJson.networks, + allows = postJson.allows, + accountId = postJson.account_id, + bankId = bank.bankId.value, + replacement = replacement, + pinResets = postJson.pin_reset.map(e => PinResetInfo(e.requested_date, PinResetReason.valueOf(e.reason_requested.toUpperCase))), + collected = collected, + posted = posted, + customerId = postJson.customer_id, + cvv = "", + brand = "", + callContext = Some(cc)) + } yield createPhysicalCardJson(card, user) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(addCardForBank), "POST", + "/management/banks/BANK_ID/cards", + "Create Card", + s"""Create Card at bank specified by BANK_ID. + | + |${userAuthenticationMessage(true)} + |""", + createPhysicalCardJsonV310, physicalCardJsonV310, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, AllowedValuesAre, UnknownError), + List(apiTagCard), None, + http4sPartialFunction = Some(addCardForBank)) + + // ─── updatedCardForBank (PUT) ──────────────────────────────────────────── + + val updatedCardForBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "management" / "banks" / _ / "cards" / cardIdStr => + EndpointHelpers.withUserAndBankAndBody[UpdatePhysicalCardJsonV310, Any](req) { (user, bank, postJson, cc) => + for { + _ <- NewStyle.function.hasEntitlement(bank.bankId.value, user.userId, ApiRole.canUpdateCardsForBank, Some(cc)) + _ <- postJson.allows match { + case List() => Future.successful(1) + case _ => code.util.Helper.booleanToFuture( + AllowedValuesAre + CardAction.availableValues.mkString(", "), cc = Some(cc)) { + postJson.allows.forall(a => CardAction.availableValues.contains(a)) + } + } + _ <- NewStyle.function.tryons( + AllowedValuesAre + CardReplacementReason.availableValues.mkString(", "), 400, Some(cc)) { + CardReplacementReason.valueOf(postJson.replacement.reason_requested) + } + _ <- code.util.Helper.booleanToFuture( + s"${maximumLimitExceeded.replace("10000", "10")} Current issue_number is ${postJson.issue_number}", + cc = Some(cc)) { postJson.issue_number.length <= 10 } + (_, _) <- NewStyle.function.getBankAccount(bank.bankId, AccountId(postJson.account_id), Some(cc)) + (existingCard, _) <- NewStyle.function.getPhysicalCardForBank(bank.bankId, cardIdStr, Some(cc)) + (_, _) <- NewStyle.function.getCustomerByCustomerId(postJson.customer_id, Some(cc)) + (card, _) <- NewStyle.function.updatePhysicalCard( + cardId = cardIdStr, + bankCardNumber = existingCard.bankCardNumber, + cardType = postJson.card_type, + nameOnCard = postJson.name_on_card, + issueNumber = postJson.issue_number, + serialNumber = postJson.serial_number, + validFrom = postJson.valid_from_date, + expires = postJson.expires_date, + enabled = postJson.enabled, + cancelled = false, + onHotList = false, + technology = postJson.technology, + networks = postJson.networks, + allows = postJson.allows, + accountId = postJson.account_id, + bankId = bank.bankId.value, + replacement = Some(CardReplacementInfo( + requestedDate = postJson.replacement.requested_date, + CardReplacementReason.valueOf(postJson.replacement.reason_requested))), + pinResets = postJson.pin_reset.map(e => PinResetInfo(e.requested_date, PinResetReason.valueOf(e.reason_requested.toUpperCase))), + collected = Option(CardCollectionInfo(postJson.collected)), + posted = Option(CardPostedInfo(postJson.posted)), + customerId = postJson.customer_id, + callContext = Some(cc)) + } yield createPhysicalCardJson(card, user) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updatedCardForBank), "PUT", + "/management/banks/BANK_ID/cards/CARD_ID", + "Update Card", + s"""Update Card at bank specified by CARD_ID. + | + |${userAuthenticationMessage(true)} + |""", + updatePhysicalCardJsonV310, physicalCardJsonV310, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, AllowedValuesAre, UnknownError), + List(apiTagCard), Some(List(canCreateCardsForBank)), + http4sPartialFunction = Some(updatedCardForBank)) + + // ─── createCardAttribute (POST) ────────────────────────────────────────── + + val createCardAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "banks" / bankIdStr / "cards" / cardIdStr / "attribute" => + EndpointHelpers.executeFutureWithBodyCreated[CardAttributeJson, Any](req) { (postedData, cc) => + for { + (_, _) <- NewStyle.function.getBank(BankId(bankIdStr), Some(cc)) + (_, _) <- NewStyle.function.getPhysicalCardForBank(BankId(bankIdStr), cardIdStr, Some(cc)) + cardAttrType <- NewStyle.function.tryons( + s"$InvalidJsonFormat The `Type` field can only accept the following field: " + + s"${CardAttributeType.DOUBLE}(12.1234), ${CardAttributeType.STRING}(TAX_NUMBER), ${CardAttributeType.INTEGER}(123) and ${CardAttributeType.DATE_WITH_DAY}(2012-04-23)", + 400, Some(cc)) { CardAttributeType.withName(postedData.`type`) } + (cardAttribute, _) <- NewStyle.function.createOrUpdateCardAttribute( + Some(BankId(bankIdStr)), Some(cardIdStr), None, + postedData.name, cardAttrType, postedData.value, Some(cc)) + } yield (cardAttribute: CardAttributeCommons) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createCardAttribute), "POST", + "/management/banks/BANK_ID/cards/CARD_ID/attribute", + "Create Card Attribute", + s"""Create a Card Attribute. + | + |${userAuthenticationMessage(true)} + |""", + CardAttributeJson( + cardAttributeNameExample.value, + CardAttributeType.DOUBLE.toString, + cardAttributeValueExample.value), + CardAttributeCommons( + Some(BankId(bankIdExample.value)), + Some(cardIdExample.value), + Some(cardAttributeIdExample.value), + cardAttributeNameExample.value, + CardAttributeType.DOUBLE, + cardAttributeValueExample.value), + List(AuthenticatedUserIsRequired, InvalidJsonFormat, UnknownError), + List(apiTagCard, apiTagCardAttribute, apiTagAttribute), None, + http4sPartialFunction = Some(createCardAttribute)) + + // ─── updateCardAttribute (PUT) ─────────────────────────────────────────── + + val updateCardAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "management" / "banks" / bankIdStr / "cards" / cardIdStr / "attributes" / cardAttributeIdStr => + EndpointHelpers.executeFutureWithBody[CardAttributeJson, Any](req) { (postedData, cc) => + for { + (_, _) <- NewStyle.function.getBank(BankId(bankIdStr), Some(cc)) + (_, _) <- NewStyle.function.getPhysicalCardForBank(BankId(bankIdStr), cardIdStr, Some(cc)) + (_, _) <- NewStyle.function.getCardAttributeById(cardAttributeIdStr, Some(cc)) + cardAttrType <- NewStyle.function.tryons( + s"$InvalidJsonFormat The `Type` field can only accept the following field: " + + s"${CardAttributeType.DOUBLE}(12.1234), ${CardAttributeType.STRING}(TAX_NUMBER), ${CardAttributeType.INTEGER}(123) and ${CardAttributeType.DATE_WITH_DAY}(2012-04-23)", + 400, Some(cc)) { CardAttributeType.withName(postedData.`type`) } + (cardAttribute, _) <- NewStyle.function.createOrUpdateCardAttribute( + Some(BankId(bankIdStr)), Some(cardIdStr), Some(cardAttributeIdStr), + postedData.name, cardAttrType, postedData.value, Some(cc)) + } yield (cardAttribute: CardAttributeCommons) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateCardAttribute), "PUT", + "/management/banks/BANK_ID/cards/CARD_ID/attributes/CARD_ATTRIBUTE_ID", + "Update Card Attribute", + s"""Update a Card Attribute. + | + |${userAuthenticationMessage(true)} + |""", + CardAttributeJson( + cardAttributeNameExample.value, + CardAttributeType.DOUBLE.toString, + cardAttributeValueExample.value), + CardAttributeCommons( + Some(BankId(bankIdExample.value)), + Some(cardIdExample.value), + Some(cardAttributeIdExample.value), + cardAttributeNameExample.value, + CardAttributeType.DOUBLE, + cardAttributeValueExample.value), + List(AuthenticatedUserIsRequired, InvalidJsonFormat, UnknownError), + List(apiTagCard, apiTagCardAttribute, apiTagAttribute), None, + http4sPartialFunction = Some(updateCardAttribute)) + + // ─── createWebUiProps (POST) ───────────────────────────────────────────── + + val createWebUiProps: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "webui_props" => + EndpointHelpers.withUserAndBodyCreated[WebUiPropsCommons, Any](req) { (user, postedData, cc) => + for { + _ <- NewStyle.function.hasEntitlement("", user.userId, canCreateWebUiProps, Some(cc)) + _ <- NewStyle.function.tryons( + s"""$InvalidWebUiProps name must be start with webui_, but current post name is: ${postedData.name} """, + 400, Some(cc)) { require(postedData.name.startsWith("webui_")) } + webUiProps <- Future(MappedWebUiPropsProvider.createOrUpdate(postedData)) map { + unboxFullOrFail(_, Some(cc)) + } + } yield (webUiProps: WebUiPropsCommons) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createWebUiProps), "POST", + "/management/webui_props", + "Create WebUiProps", + s"""Create a WebUiProps. + | + |${userAuthenticationMessage(true)} + |""", + WebUiPropsCommons("webui_api_explorer_url", "https://apiexplorer.openbankproject.com"), + WebUiPropsCommons("webui_api_explorer_url", "https://apiexplorer.openbankproject.com", Some("web-ui-props-id")), + List(AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagWebUiProps), Some(List(canCreateWebUiProps)), + http4sPartialFunction = Some(createWebUiProps)) + + // ─── createUserAuthContextUpdateRequest (POST) ─────────────────────────── + + val createUserAuthContextUpdateRequest: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "users" / "current" / "auth-context-updates" / scaMethod => + EndpointHelpers.withUserAndBankAndBodyCreated[PostUserAuthContextJson, Any](req) { (user, bank, postedData, cc) => + for { + _ <- code.util.Helper.booleanToFuture( + failMsg = ConsumerHasMissingRoles + ApiRole.canCreateUserAuthContextUpdate, + cc = Some(cc)) { + APIUtil.checkScope(bank.bankId.value, APIUtil.getConsumerPrimaryKey(Some(cc)), ApiRole.canCreateUserAuthContextUpdate) + } + _ <- code.util.Helper.booleanToFuture(UserAuthContextUpdateRequestAllowedScaMethods, cc = Some(cc)) { + List(StrongCustomerAuthentication.SMS.toString, StrongCustomerAuthentication.EMAIL.toString).contains(scaMethod) + } + (userAuthContextUpdate, _) <- NewStyle.function.validateUserAuthContextUpdateRequest( + bank.bankId.value, user.userId, postedData.key, postedData.value, scaMethod, Some(cc)) + } yield JSONFactory310.createUserAuthContextUpdateJson(userAuthContextUpdate) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createUserAuthContextUpdateRequest), "POST", + "/banks/BANK_ID/users/current/auth-context-updates/SCA_METHOD", + "Create User Auth Context Update Request", + s"""Create User Auth Context Update Request. + |${userAuthenticationMessage(true)} + |""", + postUserAuthContextJson, userAuthContextUpdateJson, + List(AuthenticatedUserIsRequired, InvalidJsonFormat, CreateUserAuthContextError, UnknownError), + List(apiTagUser), None, + http4sPartialFunction = Some(createUserAuthContextUpdateRequest)) + + // ─── answerUserAuthContextUpdateChallenge (POST → 200) ─────────────────── + + val answerUserAuthContextUpdateChallenge: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "users" / "current" / "auth-context-updates" / authContextUpdateIdStr / "challenge" => + EndpointHelpers.executeFutureWithBody[PostUserAuthContextUpdateJsonV310, Any](req) { (postBody, cc) => + for { + (userAuthContextUpdate, _) <- NewStyle.function.checkAnswer(authContextUpdateIdStr, postBody.answer, Some(cc)) + (subjectUser, _) <- NewStyle.function.getUserByUserId(userAuthContextUpdate.userId, Some(cc)) + _ <- userAuthContextUpdate.status match { + case status if status == com.openbankproject.commons.model.UserAuthContextUpdateStatus.ACCEPTED.toString => + NewStyle.function.createUserAuthContext( + subjectUser, userAuthContextUpdate.key, userAuthContextUpdate.value, Some(cc) + ).map(x => (Some(x._1), x._2)) + case _ => Future((None, Some(cc))) + } + _ <- userAuthContextUpdate.key match { + case "CUSTOMER_NUMBER" => + NewStyle.function.getOCreateUserCustomerLink( + BankId(bankIdStr), userAuthContextUpdate.value, subjectUser.userId, Some(cc)) + case _ => Future((None, Some(cc))) + } + } yield createUserAuthContextUpdateJson(userAuthContextUpdate) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(answerUserAuthContextUpdateChallenge), "POST", + "/banks/BANK_ID/users/current/auth-context-updates/AUTH_CONTEXT_UPDATE_ID/challenge", + "Answer Auth Context Update Challenge", + s"""Answer Auth Context Update Challenge.""", + PostUserAuthContextUpdateJsonV310(answer = "12345678"), + userAuthContextUpdateJson, + List(AuthenticatedUserIsRequired, BankNotFound, InvalidJsonFormat, InvalidConnectorResponse, UnknownError), + apiTagUser :: Nil, None, + http4sPartialFunction = Some(answerUserAuthContextUpdateChallenge)) + + // ─── refreshUser (POST) ────────────────────────────────────────────────── + + val refreshUser: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "users" / userIdStr / "refresh" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + _ <- NewStyle.function.hasEntitlement("", user.userId, canRefreshUser, Some(cc)) + startTime <- Future(Helpers.now) + (subjectUser, _) <- NewStyle.function.findByUserId(userIdStr, Some(cc)) + _ <- AuthUser.refreshUser(subjectUser, Some(cc)) + endTime <- Future(Helpers.now) + durationTime = endTime.getTime - startTime.getTime + } yield createRefreshUserJson(durationTime) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(refreshUser), "POST", + "/users/USER_ID/refresh", + "Refresh User", + s"""The endpoint is used for updating the accounts, views, account holders for the user. + | + |${userAuthenticationMessage(true)} + |""", + EmptyBody, refresUserJson, + List(UserHasMissingRoles, UnknownError), + List(apiTagUser), Some(List(canRefreshUser)), + http4sPartialFunction = Some(refreshUser)) + + // ─── createProduct (PUT — "Create or Update") ──────────────────────────── + + val createProduct: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / bankIdStr / "products" / productCodeStr => + EndpointHelpers.withUserAndBankAndBodyCreated[PostPutProductJsonV310, Any](req) { (user, bank, product, cc) => + for { + _ <- NewStyle.function.hasAtLeastOneEntitlement(failMsg = createProductEntitlementsRequiredText)( + bank.bankId.value, user.userId, createProductEntitlements, Some(cc)) + (parentProduct, _) <- product.parent_product_code.trim.nonEmpty match { + case false => Future((Empty, Some(cc))) + case true => + NewStyle.function.getProduct(bank.bankId, ProductCode(product.parent_product_code), Some(cc)) + .map(p => (Full(p._1), Some(cc))) + } + (success, _) <- NewStyle.function.createOrUpdateProduct( + bankId = bank.bankId.value, + code = productCodeStr, + parentProductCode = parentProduct.map(_.code.value).toOption, + name = product.name, + category = product.category, + family = product.family, + superFamily = product.super_family, + moreInfoUrl = product.more_info_url, + termsAndConditionsUrl = null, + details = product.details, + description = product.description, + metaLicenceId = product.meta.license.id, + metaLicenceName = product.meta.license.name, + Some(cc)) + } yield JSONFactory310.createProductJson(success) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, "createProduct", "PUT", + "/banks/BANK_ID/products/PRODUCT_CODE", + "Create Product", + s"""Create or Update Product for the Bank. + | + |${userAuthenticationMessage(true)} + |""", + postPutProductJsonV310, productJsonV310, + List(AuthenticatedUserIsRequired, BankNotFound, UserHasMissingRoles, UnknownError), + List(apiTagProduct), Some(List(canCreateProduct, canCreateProductAtAnyBank)), + http4sPartialFunction = Some(createProduct)) + + // ─── createMethodRouting (POST) ────────────────────────────────────────── + + val createMethodRouting: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "method_routings" => + EndpointHelpers.withUserAndBodyCreated[MethodRoutingCommons, Any](req) { (user, raw, cc) => + for { + _ <- NewStyle.function.hasEntitlement("", user.userId, canCreateMethodRouting, Some(cc)) + postedData <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the ${classOf[MethodRoutingCommons]}", 400, Some(cc)) { + raw.bankIdPattern match { + case Some(v) if StringUtils.isBlank(v) || v.trim == "*" => + raw.copy(bankIdPattern = Some(MethodRouting.bankIdPatternMatchAny)) + case _ => raw + } + } + _ <- NewStyle.function.tryons(InvalidOutBoundMapping, 400, Some(cc)) { postedData.getOutBoundMapping } + _ <- NewStyle.function.tryons(InvalidInBoundMapping, 400, Some(cc)) { postedData.getInBoundMapping } + _ <- code.util.Helper.booleanToFuture( + s"$InvalidConnectorName please check connectorName: ${postedData.connectorName} or the connector(${postedData.connectorName}) is not supported for this sandbox. ", + failCode = 400, cc = Some(cc)) { + NewStyle.function.getConnectorByName(postedData.connectorName).isDefined + } + _ <- code.util.Helper.booleanToFuture( + s"$InvalidConnectorMethodName please check methodName: ${postedData.methodName}", + failCode = 400, cc = Some(cc)) { + if (postedData.connectorName == "internal") + NewStyle.function.getConnectorMethod("mapped", postedData.methodName).isDefined + else + NewStyle.function.getConnectorMethod(postedData.connectorName, postedData.methodName).isDefined + } + _ <- NewStyle.function.tryons( + s"$InvalidBankIdRegex The bankIdPattern is invalid regex, bankIdPatten: ${postedData.bankIdPattern.orNull} ", + 400, Some(cc)) { + if (!postedData.isBankIdExactMatch && postedData.bankIdPattern.isDefined) + Pattern.compile(postedData.bankIdPattern.get) + } + _ <- NewStyle.function.checkMethodRoutingAlreadyExists(postedData, Some(cc)) + created <- NewStyle.function.createOrUpdateMethodRouting(postedData) map { + unboxFullOrFail(_, Some(cc)) + } + } yield (created: MethodRoutingCommons).toJson + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createMethodRouting), "POST", + "/management/method_routings", + "Create MethodRouting", + s"""Create a MethodRouting. + | + |${userAuthenticationMessage(true)} + |""", + MethodRoutingCommons("getBank", "rest_vMar2019", false, Some("some_bankId_.*"), + List(MethodRoutingParam("url", "http://mydomain.com/xxx"))), + MethodRoutingCommons("getBank", "rest_vMar2019", false, Some("some_bankId_.*"), + List(MethodRoutingParam("url", "http://mydomain.com/xxx")), Some("this-method-routing-Id")), + List(AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, + InvalidConnectorName, InvalidConnectorMethodName, UnknownError), + List(apiTagMethodRouting, apiTagApi), Some(List(canCreateMethodRouting)), + http4sPartialFunction = Some(createMethodRouting)) + + // ─── updateMethodRouting (PUT) ─────────────────────────────────────────── + + val updateMethodRouting: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "management" / "method_routings" / methodRoutingIdStr => + EndpointHelpers.withUserAndBody[MethodRoutingCommons, Any](req) { (user, raw, cc) => + for { + _ <- NewStyle.function.hasEntitlement("", user.userId, canUpdateMethodRouting, Some(cc)) + putData <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the ${classOf[MethodRoutingCommons]}", 400, Some(cc)) { + val entity = raw.copy(methodRoutingId = Some(methodRoutingIdStr)) + entity.bankIdPattern match { + case Some(v) if StringUtils.isBlank(v) || v.trim == "*" => + entity.copy(bankIdPattern = Some(MethodRouting.bankIdPatternMatchAny)) + case _ => entity + } + } + _ <- NewStyle.function.tryons(InvalidOutBoundMapping, 400, Some(cc)) { putData.getOutBoundMapping } + _ <- NewStyle.function.tryons(InvalidInBoundMapping, 400, Some(cc)) { putData.getInBoundMapping } + _ <- code.util.Helper.booleanToFuture( + s"$InvalidConnectorName please check connectorName: ${putData.connectorName}", + failCode = 400, cc = Some(cc)) { + NewStyle.function.getConnectorByName(putData.connectorName).isDefined + } + _ <- code.util.Helper.booleanToFuture( + s"$InvalidConnectorMethodName please check methodName: ${putData.methodName}", + failCode = 400, cc = Some(cc)) { + if (putData.connectorName == "internal") + NewStyle.function.getConnectorMethod("mapped", putData.methodName).isDefined + else + NewStyle.function.getConnectorMethod(putData.connectorName, putData.methodName).isDefined + } + (_, _) <- NewStyle.function.getMethodRoutingById(methodRoutingIdStr, Some(cc)) + _ <- NewStyle.function.tryons( + s"$InvalidBankIdRegex The bankIdPattern is invalid regex, bankIdPatten: ${putData.bankIdPattern.orNull} ", + 400, Some(cc)) { + if (!putData.isBankIdExactMatch && putData.bankIdPattern.isDefined) + Pattern.compile(putData.bankIdPattern.get) + } + updated <- NewStyle.function.createOrUpdateMethodRouting(putData) map { + unboxFullOrFail(_, Some(cc)) + } + } yield (updated: MethodRoutingCommons).toJson + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateMethodRouting), "PUT", + "/management/method_routings/METHOD_ROUTING_ID", + "Update MethodRouting", + s"""Update a MethodRouting. + | + |${userAuthenticationMessage(true)} + |""", + MethodRoutingCommons("getBank", "rest_vMar2019", true, Some("some_bankId"), + List(MethodRoutingParam("url", "http://mydomain.com/xxx"))), + MethodRoutingCommons("getBank", "rest_vMar2019", true, Some("some_bankId"), + List(MethodRoutingParam("url", "http://mydomain.com/xxx")), Some("this-method-routing-Id")), + List(AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, + InvalidConnectorName, InvalidConnectorMethodName, UnknownError), + List(apiTagMethodRouting, apiTagApi), Some(List(canUpdateMethodRouting)), + http4sPartialFunction = Some(updateMethodRouting)) + + // ─── updateAccount (PUT) ───────────────────────────────────────────────── + + val updateAccount: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "management" / "banks" / _ / "accounts" / accountIdStr => + EndpointHelpers.withUserAndBankAndBody[UpdateAccountRequestJsonV310, Any](req) { (user, bank, body, cc) => + for { + _ <- NewStyle.function.hasEntitlement(bank.bankId.value, user.userId, ApiRole.canUpdateAccount, Some(cc)) + (_, _) <- NewStyle.function.getBankAccount(bank.bankId, AccountId(accountIdStr), Some(cc)) + _ <- code.util.Helper.booleanToFuture( + s"$UpdateBankAccountException Duplication detected in account routings, please specify only one value per routing scheme", + cc = Some(cc)) { + body.account_routings.map(_.scheme).distinct.size == body.account_routings.size + } + alreadyExistAccountRoutings <- Future.sequence(body.account_routings.map(accountRouting => + NewStyle.function.getAccountRouting(Some(bank.bankId), accountRouting.scheme, accountRouting.address, Some(cc)) + .map { + case bankAccount if !(bankAccount._1.bankId == bank.bankId && bankAccount._1.accountId == AccountId(accountIdStr)) => Some(accountRouting) + case _ => None + } fallbackTo Future.successful(None) + )) + alreadyExistingAccountRouting = alreadyExistAccountRoutings.collect { + case Some(accountRouting) => s"bankId: ${bank.bankId}, scheme: ${accountRouting.scheme}, address: ${accountRouting.address}" + } + _ <- code.util.Helper.booleanToFuture( + s"$AccountRoutingAlreadyExist (${alreadyExistingAccountRouting.mkString("; ")})", + cc = Some(cc)) { alreadyExistingAccountRouting.isEmpty } + (bankAccount, _) <- NewStyle.function.updateBankAccount( + bank.bankId, AccountId(accountIdStr), + body.`type`, body.label, body.branch_id, + body.account_routings.map(r => AccountRouting(r.scheme, r.address)), + Some(cc)) + } yield JSONFactory310.createUpdateResponseAccountJson(bankAccount) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateAccount), "PUT", + "/management/banks/BANK_ID/accounts/ACCOUNT_ID", + "Update Account", + s"""Update the account. + | + |${userAuthenticationMessage(true)} + |""", + updateAccountRequestJsonV310, updateAccountResponseJsonV310, + List(InvalidJsonFormat, AuthenticatedUserIsRequired, UnknownError, BankAccountNotFound), + List(apiTagAccount), Some(List(canUpdateAccount)), + http4sPartialFunction = Some(updateAccount)) + + // ─── createAccount (PUT) ───────────────────────────────────────────────── + // Self-or-other role check: when the logged-in user is creating an account + // for themselves the role is waived; otherwise canCreateAccount is required + // (403 on missing). booleanToFuture is used to enforce 403, matching CLAUDE.md + // guidance for conditional roles. + + val createAccount: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr => + EndpointHelpers.withUserAndBankAndBodyCreated[CreateAccountRequestJsonV310, Any](req) { (user, bank, body, cc) => + for { + (accountBox, _) <- Connector.connector.vend.checkBankAccountExists(bank.bankId, AccountId(accountIdStr), Some(cc)) + _ <- code.util.Helper.booleanToFuture(AccountIdAlreadyExists, cc = Some(cc)) { accountBox.isEmpty } + loggedInUserId = user.userId + userIdAccountOwner = if (body.user_id.nonEmpty) body.user_id else loggedInUserId + _ <- code.util.Helper.booleanToFuture(InvalidAccountIdFormat, cc = Some(cc)) { isValidID(accountIdStr) } + _ <- code.util.Helper.booleanToFuture(InvalidBankIdFormat, cc = Some(cc)) { isValidID(bankIdStr) } + (accountOwner, _) <- NewStyle.function.findByUserId(userIdAccountOwner, Some(cc)) + _ <- if (userIdAccountOwner == loggedInUserId) Future.successful(Full(())) + else code.util.Helper.booleanToFuture( + s"$UserHasMissingRoles $canCreateAccount or create account for self", + failCode = 403, cc = Some(cc)) { + APIUtil.hasEntitlement(bank.bankId.value, loggedInUserId, canCreateAccount) + } + initialBalanceAsNumber <- NewStyle.function.tryons(InvalidAccountInitialBalance, 400, Some(cc)) { + BigDecimal(body.balance.amount) + } + _ <- code.util.Helper.booleanToFuture(InitialBalanceMustBeZero, cc = Some(cc)) { 0 == initialBalanceAsNumber } + _ <- code.util.Helper.booleanToFuture(InvalidISOCurrencyCode, cc = Some(cc)) { + isValidCurrencyISOCode(body.balance.currency) + } + _ <- code.util.Helper.booleanToFuture( + s"$InvalidAccountRoutings Duplication detected in account routings, please specify only one value per routing scheme", + failCode = 400, cc = Some(cc)) { + body.account_routings.map(_.scheme).distinct.size == body.account_routings.size + } + alreadyExistAccountRoutings <- Future.sequence(body.account_routings.map(ar => + NewStyle.function.getAccountRouting(Some(bank.bankId), ar.scheme, ar.address, Some(cc)) + .map(_ => Some(ar)).fallbackTo(Future.successful(None)) + )) + alreadyExistingAccountRouting = alreadyExistAccountRoutings.collect { + case Some(ar) => s"bankId: ${bank.bankId}, scheme: ${ar.scheme}, address: ${ar.address}" + } + _ <- code.util.Helper.booleanToFuture( + s"$AccountRoutingAlreadyExist (${alreadyExistingAccountRouting.mkString("; ")})", + cc = Some(cc)) { alreadyExistingAccountRouting.isEmpty } + (bankAccount, _) <- NewStyle.function.createBankAccount( + bank.bankId, AccountId(accountIdStr), + body.product_code, body.label, body.balance.currency, initialBalanceAsNumber, + accountOwner.name, body.branch_id, + body.account_routings.map(r => AccountRouting(r.scheme, r.address)), + Some(cc)) + (productAttributes, _) <- NewStyle.function.getProductAttributesByBankAndCode( + bank.bankId, ProductCode(body.product_code), Some(cc)) + (accountAttributes, _) <- NewStyle.function.createAccountAttributes( + bank.bankId, AccountId(accountIdStr), ProductCode(body.product_code), + productAttributes, None, Some(cc)) + _ <- BankAccountCreation.setAccountHolderAndRefreshUserAccountAccess( + bank.bankId, AccountId(accountIdStr), accountOwner, Some(cc)) + } yield JSONFactory310.createAccountJSON(userIdAccountOwner, bankAccount, accountAttributes) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, "createAccount", "PUT", + "/banks/BANK_ID/accounts/NEW_ACCOUNT_ID", + "Create Account", + """Create Account at bank specified by BANK_ID with Id specified by ACCOUNT_ID. + | + |The User can create an Account for themself - or - the User that has the USER_ID specified in the POST body. + | + |Note: The Amount MUST be zero.""".stripMargin, + createAccountRequestJsonV310, createAccountResponseJsonV310, + List(InvalidJsonFormat, BankNotFound, AuthenticatedUserIsRequired, + InvalidUserId, InvalidAccountIdFormat, InvalidBankIdFormat, + UserNotFoundById, UserHasMissingRoles, InvalidAccountBalanceAmount, + InvalidAccountInitialBalance, InitialBalanceMustBeZero, + InvalidAccountBalanceCurrency, AccountIdAlreadyExists, UnknownError), + List(apiTagAccount, apiTagOnboarding), None, + http4sPartialFunction = Some(createAccount)) + + // ─── createConsent (POST) ──────────────────────────────────────────────── + + val createConsent: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "my" / "consents" / scaMethod => + EndpointHelpers.withUserAndBankAndBodyCreated[PostConsentBodyCommonJson, Any](req) { (user, bank, consentJson, cc) => + val raw = cc.httpBody.getOrElse("") + for { + _ <- code.util.Helper.booleanToFuture(ConsentAllowedScaMethods, cc = Some(cc)) { + List(StrongCustomerAuthentication.SMS.toString, + StrongCustomerAuthentication.EMAIL.toString, + StrongCustomerAuthentication.IMPLICIT.toString).contains(scaMethod) + } + maxTimeToLive = APIUtil.getPropsAsIntValue(nameOfProperty = "consents.max_time_to_live", defaultValue = 3600) + _ <- code.util.Helper.booleanToFuture(s"$ConsentMaxTTL ($maxTimeToLive)", cc = Some(cc)) { + consentJson.time_to_live match { + case Some(ttl) => ttl <= maxTimeToLive + case _ => true + } + } + myEntitlements <- Entitlement.entitlement.vend.getEntitlementsByUserIdFuture(user.userId) + _ <- code.util.Helper.booleanToFuture(RolesAllowedInConsent, cc = Some(cc)) { + consentJson.entitlements.forall(re => + myEntitlements.getOrElse(Nil).exists(e => e.roleName == re.role_name && e.bankId == re.bank_id)) + } + (_, assignedViews) <- Future(Views.views.vend.privateViewsUserCanAccess(user)) + _ <- code.util.Helper.booleanToFuture(ViewsAllowedInConsent, cc = Some(cc)) { + consentJson.views.forall(rv => assignedViews.exists(e => + e.view_id == rv.view_id && e.bank_id == rv.bank_id && e.account_id == rv.account_id)) + } + consumerTuple <- consentJson.consumer_id match { + case Some(id) => NewStyle.function.checkConsumerByConsumerId(id, Some(cc)) map { + c => (Some(c.consumerId.get), c.description, Some(c)) + } + case None => Future((None, "Any application", None)) + } + (consumerId, applicationText, consumer) = consumerTuple + challengeAnswer = Props.mode match { + case Props.RunModes.Test => Consent.challengeAnswerAtTestEnvironment + case _ => SecureRandomUtil.numeric() + } + createdConsent <- Future(Consents.consentProvider.vend.createObpConsent(user, challengeAnswer, None, consumer)) map { + i => connectorEmptyResponse(i, Some(cc)) + } + consentJWT = Consent.createConsentJWT( + user, consentJson, createdConsent.secret, createdConsent.consentId, + consumerId, consentJson.valid_from, consentJson.time_to_live.getOrElse(3600), None) + _ <- Future(Consents.consentProvider.vend.setJsonWebToken(createdConsent.consentId, consentJWT)) map { + i => connectorEmptyResponse(i, Some(cc)) + } + validUntil = code.util.Helper.calculateValidTo(consentJson.valid_from, consentJson.time_to_live.getOrElse(3600)) + _ <- Future(Consents.consentProvider.vend.setValidUntil(createdConsent.consentId, validUntil)) map { + i => connectorEmptyResponse(i, Some(cc)) + } + grantorConsumerId = cc.consumer.toOption.map(_.consumerId.get).getOrElse("Unknown") + granteeConsumerId = consentJson.consumer_id.getOrElse("Unknown") + shouldSkipConsentSca = APIUtil.skipConsentScaForConsumerIdPairs.contains( + APIUtil.ConsumerIdPair(grantorConsumerId, granteeConsumerId)) + _ <- if (shouldSkipConsentSca) { + Future { + MappedConsent.find(By(MappedConsent.mConsentId, createdConsent.consentId)) + .map(_.mStatus(ConsentStatus.ACCEPTED.toString).saveMe()).head + } + } else { + val challengeText = s"Your consent challenge : $challengeAnswer, Application: $applicationText" + scaMethod match { + case v if v == StrongCustomerAuthentication.EMAIL.toString => + for { + postConsentEmailJson <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the $PostConsentEmailJsonV310", 400, Some(cc)) { + net.liftweb.json.parse(raw).extract[PostConsentEmailJsonV310] + } + _ <- NewStyle.function.sendCustomerNotification( + StrongCustomerAuthentication.EMAIL, + postConsentEmailJson.email, + Some("OBP Consent Challenge"), challengeText, Some(cc)) + } yield createdConsent + case v if v == StrongCustomerAuthentication.SMS.toString => + for { + postConsentPhoneJson <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the $PostConsentPhoneJsonV310", 400, Some(cc)) { + net.liftweb.json.parse(raw).extract[PostConsentPhoneJsonV310] + } + _ <- NewStyle.function.sendCustomerNotification( + StrongCustomerAuthentication.SMS, + postConsentPhoneJson.phone_number, + None, challengeText, Some(cc)) + } yield createdConsent + case v if v == StrongCustomerAuthentication.IMPLICIT.toString => + for { + (consentImplicitSCA, _) <- NewStyle.function.getConsentImplicitSCA(user, Some(cc)) + _ <- consentImplicitSCA.scaMethod match { + case m if m == StrongCustomerAuthentication.EMAIL => + NewStyle.function.sendCustomerNotification( + StrongCustomerAuthentication.EMAIL, + consentImplicitSCA.recipient, + Some("OBP Consent Challenge"), challengeText, Some(cc)) + case m if m == StrongCustomerAuthentication.SMS => + NewStyle.function.sendCustomerNotification( + StrongCustomerAuthentication.SMS, + consentImplicitSCA.recipient, + None, challengeText, Some(cc)) + case _ => Future("Success") + } + } yield createdConsent + case _ => Future(createdConsent) + } + } + } yield ConsentJsonV310(createdConsent.consentId, consentJWT, createdConsent.status) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createConsent), "POST", + "/banks/BANK_ID/my/consents/SCA_METHOD", + "Create Consent", + s"""This endpoint starts the process of creating a Consent. + | + |${userAuthenticationMessage(true)} + |""", + postConsentRequestJsonV310, + ConsentJsonV310( + consent_id = "9d429899-24f5-42c8-8565-943ffa6a7945", + jwt = "eyJ...", + status = "INITIATED"), + List(AuthenticatedUserIsRequired, BankNotFound, InvalidJsonFormat, + ConsentMaxTTL, RolesAllowedInConsent, ViewsAllowedInConsent, UnknownError), + apiTagConsent :: apiTagPSD2AIS :: apiTagPsd2 :: Nil, None, + http4sPartialFunction = Some(createConsent)) + + // Re-declarations for the resource doc registration of the 3 SCA-method variants + // (Lift had three aliases for the same handler — for the http4s router only the + // single `createConsent` route is needed since SCA_METHOD is a path variable). + val createConsentEmail: HttpRoutes[IO] = createConsent + val createConsentSms: HttpRoutes[IO] = createConsent + val createConsentImplicit: HttpRoutes[IO] = createConsent + + // ─── answerConsentChallenge (POST → 201) ───────────────────────────────── + + val answerConsentChallenge: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "consents" / consentIdStr / "challenge" => + EndpointHelpers.withUserAndBankAndBodyCreated[PostConsentChallengeJsonV310, Any](req) { (_, _, body, cc) => + for { + consent <- Future(Consents.consentProvider.vend.checkAnswer(consentIdStr, body.answer)) map { + i => connectorEmptyResponse(i, Some(cc)) + } + } yield ConsentJsonV310(consent.consentId, consent.jsonWebToken, consent.status) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(answerConsentChallenge), "POST", + "/banks/BANK_ID/consents/CONSENT_ID/challenge", + "Answer Consent Challenge", + s"""This endpoint is used to confirm a Consent previously created. + | + |The User must supply a code that was sent out of band (OOB) for example via an SMS. + | + |${userAuthenticationMessage(true)} + |""", + PostConsentChallengeJsonV310(answer = "12345678"), + ConsentJsonV310("9d429899-24f5-42c8-8565-943ffa6a7945", "eyJ...", "INITIATED"), + List(AuthenticatedUserIsRequired, BankNotFound, InvalidJsonFormat, + InvalidConnectorResponse, UnknownError), + apiTagConsent :: apiTagPSD2AIS :: apiTagPsd2 :: Nil, None, + http4sPartialFunction = Some(answerConsentChallenge)) + + // ─── saveHistoricalTransaction (POST) ──────────────────────────────────── + + val saveHistoricalTransaction: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "historical" / "transactions" => + EndpointHelpers.withUserAndBodyCreated[PostHistoricalTransactionJson, Any](req) { (user, body, cc) => + for { + _ <- NewStyle.function.hasEntitlement("", user.userId, ApiRole.canCreateHistoricalTransaction, Some(cc)) + fromAccountPost = body.from + _ <- code.util.Helper.booleanToFuture( + s"$InvalidJsonFormat from object should only contain bank_id and account_id or counterparty_id in the post json body.", + cc = Some(cc)) { + (fromAccountPost.bank_id.isDefined && fromAccountPost.account_id.isDefined && fromAccountPost.counterparty_id.isEmpty) || + (fromAccountPost.bank_id.isEmpty && fromAccountPost.account_id.isEmpty && fromAccountPost.counterparty_id.isDefined) + } + (fromAccount, _) <- + if (fromAccountPost.counterparty_id.isEmpty) + for { + (_, _) <- NewStyle.function.getBank(BankId(fromAccountPost.bank_id.get), Some(cc)) + (acc, _) <- NewStyle.function.checkBankAccountExists( + BankId(fromAccountPost.bank_id.get), AccountId(fromAccountPost.account_id.get), Some(cc)) + } yield (acc, Some(cc)) + else + for { + (fromCp, _) <- NewStyle.function.getCounterpartyByCounterpartyId( + CounterpartyId(fromAccountPost.counterparty_id.get), Some(cc)) + (acc, _) <- NewStyle.function.getBankAccountFromCounterparty(fromCp, false, Some(cc)) + } yield (acc, Some(cc)) + toAccountPost = body.to + _ <- code.util.Helper.booleanToFuture( + s"$InvalidJsonFormat to object should only contain bank_id and account_id or counterparty_id in the post json body.", + cc = Some(cc)) { + (toAccountPost.bank_id.isDefined && toAccountPost.account_id.isDefined && toAccountPost.counterparty_id.isEmpty) || + (toAccountPost.bank_id.isEmpty && toAccountPost.account_id.isEmpty && toAccountPost.counterparty_id.isDefined) + } + (toAccount, _) <- + if (toAccountPost.counterparty_id.isEmpty) + for { + (_, _) <- NewStyle.function.getBank(BankId(toAccountPost.bank_id.get), Some(cc)) + (acc, _) <- NewStyle.function.checkBankAccountExists( + BankId(toAccountPost.bank_id.get), AccountId(toAccountPost.account_id.get), Some(cc)) + } yield (acc, Some(cc)) + else + for { + (toCp, _) <- NewStyle.function.getCounterpartyByCounterpartyId( + CounterpartyId(toAccountPost.counterparty_id.get), Some(cc)) + (acc, _) <- NewStyle.function.getBankAccountFromCounterparty(toCp, true, Some(cc)) + } yield (acc, Some(cc)) + amountNumber <- NewStyle.function.tryons( + s"$InvalidNumber Current input is ${body.value.amount} ", 400, Some(cc)) { + BigDecimal(body.value.amount) + } + _ <- code.util.Helper.booleanToFuture( + s"$NotPositiveAmount Current input is: '$amountNumber'", cc = Some(cc)) { + amountNumber > BigDecimal("0") + } + posted <- NewStyle.function.tryons( + s"$InvalidDateFormat Current `posted` field is ${body.posted}. Please use this format ${DateWithSecondsFormat.toPattern}! ", + 400, Some(cc)) { new SimpleDateFormat(DateWithSeconds).parse(body.posted) } + completed <- NewStyle.function.tryons( + s"$InvalidDateFormat Current `completed` field is ${body.completed}. Please use this format ${DateWithSecondsFormat.toPattern}! ", + 400, Some(cc)) { new SimpleDateFormat(DateWithSeconds).parse(body.completed) } + _ <- code.util.Helper.booleanToFuture( + s"$InvalidISOCurrencyCode Current input is: '${body.value.currency}'", cc = Some(cc)) { + isValidCurrencyISOCode(body.value.currency) + } + amountOfMoneyJson = AmountOfMoneyJsonV121(body.value.currency, body.value.amount) + (transactionId, _) <- NewStyle.function.makeHistoricalPayment( + fromAccount, toAccount, posted, completed, + amountNumber, body.value.currency, body.description, body.`type`, + body.charge_policy, Some(cc)) + } yield JSONFactory310.createPostHistoricalTransactionResponseJson( + transactionId, fromAccountPost, toAccountPost, + value = amountOfMoneyJson, description = body.description, + posted, completed, + transactionRequestType = body.`type`, + chargePolicy = body.charge_policy) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(saveHistoricalTransaction), "POST", + "/management/historical/transactions", + "Save Historical Transactions", + s"""Import the historical transactions. + | + |The fields bank_id, account_id, counterparty_id in the json body are all optional ones. + | + |This call is experimental.""".stripMargin, + postHistoricalTransactionJson, postHistoricalTransactionResponseJson, + List(InvalidJsonFormat, BankNotFound, AccountNotFound, + CounterpartyNotFoundByCounterpartyId, InvalidNumber, NotPositiveAmount, + InvalidTransactionRequestCurrency, UnknownError), + List(apiTagTransactionRequest), Some(List(canCreateHistoricalTransaction)), + http4sPartialFunction = Some(saveHistoricalTransaction)) + + // ─── allRoutes ──────────────────────────────────────────────────────────── + + private val allOwnRoutes: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req => + root.run(req) + .orElse(getCheckbookOrders.run(req)) + .orElse(getStatusOfCreditCardOrder.run(req)) + .orElse(getTopAPIs.run(req)) + .orElse(getMetricsTopConsumers.run(req)) + .orElse(getFirehoseCustomers.run(req)) + .orElse(getBadLoginStatus.run(req)) + .orElse(getCallsLimit.run(req)) + .orElse(getConsumer.run(req)) + .orElse(getConsumersForCurrentUser.run(req)) + .orElse(getConsumers.run(req)) + .orElse(getAccountWebhooks.run(req)) + .orElse(config.run(req)) + .orElse(getAdapterInfo.run(req)) + .orElse(getRateLimitingInfo.run(req)) + .orElse(getCustomerByCustomerId.run(req)) + .orElse(getUserAuthContexts.run(req)) + .orElse(getTaxResidence.run(req)) + .orElse(getAllEntitlements.run(req)) + .orElse(getCustomerAddresses.run(req)) + .orElse(getProductAttribute.run(req)) + .orElse(getAccountApplications.run(req)) + .orElse(getAccountApplication.run(req)) + .orElse(getMeetings.run(req)) + .orElse(getMeeting.run(req)) + .orElse(getServerJWK.run(req)) + .orElse(getOAuth2ServerJWKsURIs.run(req)) + .orElse(getMethodRoutings.run(req)) + .orElse(getSystemView.run(req)) + .orElse(getCardsForBank.run(req)) + .orElse(getCardForBank.run(req)) + .orElse(getBankAccountsBalances.run(req)) + .orElse(checkFundsAvailable.run(req)) + .orElse(getTransactionByIdForBankAccount.run(req)) + .orElse(getTransactionRequests.run(req)) + .orElse(getProduct.run(req)) + .orElse(getProductTree.run(req)) + .orElse(getProducts.run(req)) + .orElse(getProductCollection.run(req)) + .orElse(getConsents.run(req)) + .orElse(getPrivateAccountByIdFull.run(req)) + .orElse(getWebUiProps.run(req)) + .orElse(deleteUserAuthContexts.run(req)) + .orElse(deleteUserAuthContextById.run(req)) + .orElse(deleteTaxResidence.run(req)) + .orElse(deleteCustomerAddress.run(req)) + .orElse(deleteProductAttribute.run(req)) + .orElse(deleteBranch.run(req)) + .orElse(deleteSystemView.run(req)) + .orElse(deleteMethodRouting.run(req)) + .orElse(deleteCardForBank.run(req)) + .orElse(deleteWebUiProps.run(req)) + .orElse(revokeConsent.run(req)) + .orElse(createTaxResidence.run(req)) + .orElse(createCustomerAddress.run(req)) + .orElse(createUserAuthContext.run(req)) + .orElse(createProductAttribute.run(req)) + .orElse(createAccountWebhook.run(req)) + .orElse(unlockUser.run(req)) + .orElse(callsLimit.run(req)) + .orElse(enableDisableAccountWebhook.run(req)) + .orElse(enableDisableConsumers.run(req)) + .orElse(updateSystemView.run(req)) + .orElse(updateProductAttribute.run(req)) + .orElse(updateCustomerEmail.run(req)) + .orElse(updateCustomerNumber.run(req)) + .orElse(updateCustomerMobileNumber.run(req)) + .orElse(updateCustomerIdentity.run(req)) + .orElse(updateCustomerCreditLimit.run(req)) + .orElse(updateCustomerCreditRatingAndSource.run(req)) + .orElse(updateCustomerBranch.run(req)) + .orElse(updateCustomerData.run(req)) + .orElse(updateAccountApplicationStatus.run(req)) + .orElse(createCustomer.run(req)) + .orElse(getCustomerByCustomerNumber.run(req)) + .orElse(createAccountApplication.run(req)) + .orElse(createAccountAttribute.run(req)) + .orElse(updateAccountAttribute.run(req)) + .orElse(createMeeting.run(req)) + .orElse(createSystemView.run(req)) + .orElse(createProductCollection.run(req)) + .orElse(addCardForBank.run(req)) + .orElse(updatedCardForBank.run(req)) + .orElse(createCardAttribute.run(req)) + .orElse(updateCardAttribute.run(req)) + .orElse(createWebUiProps.run(req)) + .orElse(createUserAuthContextUpdateRequest.run(req)) + .orElse(answerUserAuthContextUpdateChallenge.run(req)) + .orElse(refreshUser.run(req)) + .orElse(createProduct.run(req)) + .orElse(createMethodRouting.run(req)) + .orElse(updateMethodRouting.run(req)) + .orElse(updateAccount.run(req)) + .orElse(createAccount.run(req)) + .orElse(createConsent.run(req)) + .orElse(answerConsentChallenge.run(req)) + .orElse(saveHistoricalTransaction.run(req)) + } + + val allRoutesWithMiddleware: HttpRoutes[IO] = ResourceDocMiddleware.apply(resourceDocs)(allOwnRoutes) + + // ─── path-rewriting bridge: /obp/v3.1.0/… → /obp/v3.0.0/… ────────────── + + val v310ToV300Bridge: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req => + val rawPath = req.uri.path.renderString + if (rawPath.startsWith("/obp/v3.1.0/")) { + val rewritten = rawPath.replaceFirst("/obp/v3\\.1\\.0/", "/obp/v3.0.0/") + val newUri = req.uri.withPath(Uri.Path.unsafeFromString(rewritten)) + val rewrittenReq = req.withUri(newUri) + code.api.v3_0_0.Http4s300.wrappedRoutesV300Services.run(rewrittenReq) + } else { + OptionT.none[IO, Response[IO]] + } + } + } + + val wrappedRoutesV310Services: HttpRoutes[IO] = + Kleisli[HttpF, Request[IO], Response[IO]] { req => + Implementations3_1_0.allRoutesWithMiddleware.run(req) + .orElse(Implementations3_1_0.v310ToV300Bridge.run(req)) + } +} From 55f010eaa772244bd4342cf09db7da94e29fb16c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Tue, 12 May 2026 18:10:05 +0200 Subject: [PATCH 03/30] fix(v3.1.0): refreshUser returns 201 (was 200); harden local-test recipe CI shard4 (RefreshUserTest) caught a regression my local run missed: Lift's refreshUser returns 201, my migration used withUser which returns 200. Switched to executeFutureCreated. Both RefreshUserTest scenarios now pass. The local test discovery slipped because the suite class `RefreshUserTest` lives in a file named `RefreshObpDateTest.scala`, and the basename-based -DwildcardSuites generator I documented in CLAUDE.md produced `code.api.v3_1_0.RefreshObpDateTest` (no such class). Replace the basename generator with one that greps each file for its declared `class X extends ServerSetup` name. Now produces the correct FQNs even when class and filename diverge. Documented the failure mode explicitly so the next migration doesn't repeat the trap. --- CLAUDE.md | 9 +++++---- obp-api/src/main/scala/code/api/v3_1_0/Http4s310.scala | 6 ++++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0e0f4a6bbd..f96a9e1fd3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -178,12 +178,13 @@ resourceDocs += ResourceDoc(null, ..., "/banks/FIREHOSE_BANK_ID/firehose/...", . **CI**: Tests run with `mvn test -DwildcardSuites="..."`. `hikari.maximumPoolSize=20` required in test props for concurrent tests (`withRequestTransaction` holds 1 connection per request; rate-limit queries need a 2nd → pool of 10 exhausts at 5 concurrent requests). -**Running tests for a single API version locally**: `-DwildcardSuites="code.api.v3_1_0"` (just the package prefix, no `.*`) discovers zero tests — the prefix form only works in the CI workflow's piped invocation. From the shell, pass an explicit **comma-separated list of fully qualified suite class names**. Generate it once per version: +**Running tests for a single API version locally**: `-DwildcardSuites="code.api.v3_1_0"` (just the package prefix, no `.*`) discovers zero tests — the prefix form only works in the CI workflow's piped invocation. From the shell, pass an explicit **comma-separated list of fully qualified suite class names**. Generate it by grepping each file for its declared class — a filename-based generator misses cases where the class name doesn't match the file (e.g. `RefreshObpDateTest.scala` declares `class RefreshUserTest`): ```sh -ls obp-api/src/test/scala/code/api/v3_1_0/*.scala | xargs -n1 basename | sed 's/\.scala$//' \ - | grep -v 'ServerSetup' | sed 's/^/code.api.v3_1_0./' | tr '\n' ',' | sed 's/,$//' +grep -l '^class.*extends.*ServerSetup' obp-api/src/test/scala/code/api/v3_1_0/*.scala \ + | xargs -I{} grep -hoP '^class \K[A-Z][A-Za-z0-9_]+' {} \ + | sed 's/^/code.api.v3_1_0./' | tr '\n' ',' | sed 's/,$//' ``` -Pipe that into `-DwildcardSuites=`. The `ServerSetup` filter skips the abstract base trait — it has no own tests and listing it makes scalatest abort discovery. Add `-DfailIfNoTests=false` so an empty match doesn't fail the build. +Pipe that into `-DwildcardSuites=`. Add `-DfailIfNoTests=false` so an empty match doesn't fail the build. The `extends.*ServerSetup` filter only keeps real suites (skips the abstract base trait itself and any utility helpers in the directory). Don't generate suite names from `basename` — that silently drops suites with class-vs-file name mismatches, which is exactly how a CI failure can slip past a green local run. **Surefire reports beat truncated maven output**: When a `mvn test` invocation has hundreds of failures, the run summary at the tail says e.g. `*** 23 TESTS FAILED ***` but the individual failure messages are scrolled off. Don't re-run; mine `obp-api/target/surefire-reports/TEST-*.xml` instead. Suites with failures have `failures=` or `errors=` >0; per-testcase failures are `` elements. Quick extract: ```sh diff --git a/obp-api/src/main/scala/code/api/v3_1_0/Http4s310.scala b/obp-api/src/main/scala/code/api/v3_1_0/Http4s310.scala index 89a0d2787b..e8a4e90132 100644 --- a/obp-api/src/main/scala/code/api/v3_1_0/Http4s310.scala +++ b/obp-api/src/main/scala/code/api/v3_1_0/Http4s310.scala @@ -2938,9 +2938,11 @@ object Http4s310 { val refreshUser: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ POST -> `prefixPath` / "users" / userIdStr / "refresh" => - EndpointHelpers.withUser(req) { (user, cc) => + // Lift returns 201 (CREATED) for this POST — middleware has already validated auth + // and the canRefreshUser role, so we use executeFutureCreated to preserve the status. + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext for { - _ <- NewStyle.function.hasEntitlement("", user.userId, canRefreshUser, Some(cc)) startTime <- Future(Helpers.now) (subjectUser, _) <- NewStyle.function.findByUserId(userIdStr, Some(cc)) _ <- AuthUser.refreshUser(subjectUser, Some(cc)) From fd00ec8f75808e7cf7eb165104966d3bbe116b5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Tue, 12 May 2026 21:43:39 +0200 Subject: [PATCH 04/30] =?UTF-8?q?feat:=20start=20v4.0.0=20to=20http4s=20mi?= =?UTF-8?q?gration=20=E2=80=94=20Http4s400.scala?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scaffold Http4s400.scala with staticResourceDocs/resourceDocs split mirroring APIMethods400 (leaves room for dynamic-doc entries), v400→v310 path-rewriting bridge, wired into Http4sApp. Endpoints migrated so far (47): - Dynamic-entity family (11/11): get/create/update/delete System+BankLevel + get/update/delete My. New helper tryOrApiFail converts IllegalArgumentException from DynamicEntityCommons.apply validation into a JSON-encoded APIFailureNewStyle so the response is 400 with the verbatim message (NewStyle.function.tryons would produce a Lift Failure chain that doesn't match what tests assert with startWith). - Dynamic-endpoint family (12/12): create/getList/get/delete System +BankLevel/My + updateHost variants. Shared helpers ported from APIMethods400 (createDynamicEndpointImpl, updateDynamicEndpointHostImpl, getDynamicEndpoint(s)Impl). - Mainstream batch 1 (7): getMapperDatabaseInfo, getLogoutLink, getBanks, getBank, ibanChecker, callsLimit, createBank. - Override audit batch 1 (5 more): root, getAtms, getAtm, getProducts, getProduct, createAtm, createProduct, createProductAttribute, updateProductAttribute. Tests: DynamicEntityTest (21), DynamicEndpointsTest (30), DynamicEndpointHelperTest (20), DynamicResourceDocTest (3), DynamicMessageDocTest (2), DynamicIntegrationTest (1), BankTests (3), BankAttributeTests (11), MapperDatabaseInfoTest (3), RateLimitingTest (9), AtmsTest (9), ProductTest (4) — all pass. Discovered & documented: the "bridge-cascade hijack" gotcha. When a new version overrides an older version's same URL+verb (e.g. v4's POST /banks adds entitlement granting that v2.2.0's POST /banks doesn't), the v4 override must be in Http4s400 own-routes before the bridge cascade runs — otherwise the bridge rewrites the path (v4.0.0 → v3.1.0 → v3.0.0 → v2.2.0) and the request lands on the older version's handler. createBank was the first such case. The collectResourceDocs URL+verb dedup at Lift's level normally keeps the highest-version handler for each route, which is why the test passes without any v4 work (full fallthrough to Lift). Once an HttpXYZ for an in-flight migration is wired in, that "Lift dedup" protection disappears for routes the bridge intercepts. CLAUDE.md updated: - Added the bridge-cascade hijack gotcha with symptom + how to find the override set in advance (intersect v4 ResourceDoc URLs+verbs with older Http4s files; the Lift excludeEndpoints list is *not* the right one — it names removed endpoints, not overrides). 22 v4-over-older overrides remain (user management, customer endpoints, account/balance/counterparty, consumer/scope, consents, complex POSTs). Will continue in follow-up commits. --- CLAUDE.md | 16 + LIFT_HTTP4S_MIGRATION.md | 4 +- .../code/api/util/http4s/Http4sApp.scala | 2 + .../scala/code/api/v4_0_0/Http4s400.scala | 1436 +++++++++++++++++ 4 files changed, 1456 insertions(+), 2 deletions(-) create mode 100644 obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala diff --git a/CLAUDE.md b/CLAUDE.md index f96a9e1fd3..83eed3d25e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -239,6 +239,22 @@ Note: `executeFutureCreated` returns 201; pair it with `cc.user.openOrThrowExcep **Use `NEW_ACCOUNT_ID` for PUT-creates-account URLs**: When a `PUT /banks/BANK_ID/accounts/ACCOUNT_ID` *creates* the account (it doesn't exist yet), the middleware's `validateAccount` keys off the literal `ACCOUNT_ID` template var and tries to look it up → 404 before the handler runs. Change the ResourceDoc URL template to `/banks/BANK_ID/accounts/NEW_ACCOUNT_ID` (or any non-standard ALL_CAPS variant) — middleware treats it as a wildcard and skips the lookup, but the path still matches the route pattern. The handler can check "already exists" inline with `Connector.connector.vend.checkBankAccountExists(...)` and return 409/400 as needed. +**Bridge-cascade hijack**: when a new version (e.g. v4.0.0) *overrides* an endpoint from an earlier version with the same URL + verb (e.g. v4's `POST /banks` adds entitlement-granting that v2.2.0's `POST /banks` doesn't have), the v4 override **must** be migrated to `Http4s400`'s own-routes **before** wiring `Http4s400` into the chain. Otherwise the path-rewriting bridge cascade silently sends the request to the older handler: + +``` +POST /obp/v4.0.0/banks + → Http4s400 own-routes (no POST /banks match — falls through) + → v400ToV310Bridge (rewrites to /obp/v3.1.0/banks, calls Http4s310) + → ... cascades down ... + → Http4s220 (HAS POST /banks → executes v2.2.0 createBank ✗) +``` + +Without the v4 work the chain falls all the way through to the Lift bridge, which honours the `collectResourceDocs` URL+verb dedup that keeps the highest-version handler for each route — so Lift's v4 createBank runs and the test passes. Once you add an `Http4sXYZ` for an in-flight migration, that "Lift dedup" no longer protects you. Cure: before flipping a new version's `wrappedRoutesVxxxServices` into `Http4sApp.baseServices`, audit the version's overrides (Lift's `excludeEndpoints` is *not* the right list — it only names *removed* endpoints, not overrides) and migrate them too. + +How to find overrides for a version: grep `lazy val (\w+)` in the target `APIMethods*.scala`, then check whether the same URL + verb also appears in any older `APIMethods*.scala`. The intersection is the override set. Migrate that set as part of the same PR that introduces the bridge; otherwise reviewers will see test failures whose proximate cause (a downstream version's handler running) doesn't match the file the migration touches. + +Symptoms in tests: a v4-specific assertion fails (e.g. an entitlement should-be-granted check returns false). The HTTP response is usually a successful 200/201, just from the wrong handler — so it can look like a flaky failure on the surface. + ## CI Performance Profile Measured from a 3-shard run (2691 tests total, all passing). Numbers are stable across shards. diff --git a/LIFT_HTTP4S_MIGRATION.md b/LIFT_HTTP4S_MIGRATION.md index 5cfa9a803d..063621f443 100644 --- a/LIFT_HTTP4S_MIGRATION.md +++ b/LIFT_HTTP4S_MIGRATION.md @@ -29,7 +29,7 @@ New API versions are implemented as native http4s routes and do not pass through ### Priority routing -Routes are tried in order: `corsHandler` (OPTIONS) → `AppsPage` → `StatusPage` → `Http4s500` → `Http4s700` → `Http4sBGv2` → `Http4s310` → `Http4s300` → `Http4s220` → `Http4s210` → `Http4s200` → `Http4s140` → `Http4s130` → `Http4s121` → `Http4sLiftWebBridge` (Lift fallback). Unhandled `/obp/v7.0.0/*` paths fall through silently to Lift — they do not 404. +Routes are tried in order: `corsHandler` (OPTIONS) → `AppsPage` → `StatusPage` → `Http4s500` → `Http4s700` → `Http4sBGv2` → `Http4s400` → `Http4s310` → `Http4s300` → `Http4s220` → `Http4s210` → `Http4s200` → `Http4s140` → `Http4s130` → `Http4s121` → `Http4sLiftWebBridge` (Lift fallback). Unhandled `/obp/v7.0.0/*` paths fall through silently to Lift — they do not 404. ``` HTTP Request @@ -120,7 +120,7 @@ Bottom-up — each version depends on the one below it being done. | 6 | `APIMethods220` | 19 | **Done** — `Http4s220.scala`: 18 own endpoints + path-rewriting bridge to `Http4s210`; all 27 v2.2.0 tests pass | | 7 | `APIMethods300` | 47 | **Done** — `Http4s300.scala`: 47 own endpoints + path-rewriting bridge to `Http4s220`; all 86 v3.0.0 tests pass | | 8 | `APIMethods310` | 102 | **Done** — `Http4s310.scala` has all 100 functional endpoints (42 GET, 10 DELETE, 19 POST, 25 PUT, 1 GET-shaped revoke, 3 SCA aliases) + path-rewriting bridge to `Http4s300`; 181 v3.1.0 tests pass. Two endpoints tracked separately in "Per-version Lift leftovers" (`getMessageDocsSwagger`, `getObpConnectorLoopback`) — they retire via the Resource-docs workstream / bridge-removal PR, not as v3.1.0 follow-up. | -| 9 | `APIMethods400` | ~258 total | Largest file; may need splitting into sub-traits | +| 9 | `APIMethods400` | ~258 total | **In progress (47/258 endpoints)** — `Http4s400.scala` scaffolded with `staticResourceDocs`/`resourceDocs` split + bridge to `Http4s310`. **Dynamic-entity family complete** (11/11), **dynamic-endpoint family complete** (12/12), **mainstream batch 1** (`getMapperDatabaseInfo`, `getLogoutLink`, `getBanks`, `getBank`, `ibanChecker`, `callsLimit`, `createBank`, `root`). **Override audit started** (13/35 v4-over-older overrides migrated: `getBanks`, `getBank`, `createBank`, `root`, `getAtms`, `getAtm`, `createAtm`, `getProducts`, `getProduct`, `createProduct`, `createProductAttribute`, `updateProductAttribute`, `callsLimit`). Tests passing: BankTests, BankAttributeTests, MapperDatabaseInfoTest, RateLimitingTest, AtmsTest, ProductTest, DynamicEntityTest, DynamicEndpointsTest et al. **Bridge-cascade hijack gotcha** (see CLAUDE.md): v4 endpoints that *override* a same-URL endpoint from an earlier version must be migrated to `Http4s400` own-routes *before* relying on the bridge — otherwise the bridge cascade rewrites the path down to the older version's handler (which has different behaviour). 22 overrides remain to migrate. | | 10 | `APIMethods500` | 37 | | | 11 | `APIMethods510` | 111 | | | 12 | `APIMethods600` | ~244 total | Final Lift endpoint file | 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 21cdb41146..68a7723c06 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 @@ -68,6 +68,7 @@ object Http4sApp { private val v220Routes: HttpRoutes[IO] = gate(ApiVersion.v2_2_0, code.api.v2_2_0.Http4s220.wrappedRoutesV220Services) private val v300Routes: HttpRoutes[IO] = gate(ApiVersion.v3_0_0, code.api.v3_0_0.Http4s300.wrappedRoutesV300Services) private val v310Routes: HttpRoutes[IO] = gate(ApiVersion.v3_1_0, code.api.v3_1_0.Http4s310.wrappedRoutesV310Services) + private val v400Routes: HttpRoutes[IO] = gate(ApiVersion.v4_0_0, code.api.v4_0_0.Http4s400.wrappedRoutesV400Services) private val v500Routes: HttpRoutes[IO] = gate(ApiVersion.v5_0_0, code.api.v5_0_0.Http4s500.wrappedRoutesV500Services) private val v700Routes: HttpRoutes[IO] = gate(ApiVersion.v7_0_0, code.api.v7_0_0.Http4s700.wrappedRoutesV700Services) @@ -81,6 +82,7 @@ object Http4sApp { .orElse(v500Routes.run(req)) .orElse(v700Routes.run(req)) .orElse(code.api.berlin.group.v2.Http4sBGv2.wrappedRoutes.run(req)) + .orElse(v400Routes.run(req)) .orElse(v310Routes.run(req)) .orElse(v300Routes.run(req)) .orElse(v220Routes.run(req)) diff --git a/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala b/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala new file mode 100644 index 0000000000..23a99ab158 --- /dev/null +++ b/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala @@ -0,0 +1,1436 @@ +package code.api.v4_0_0 + +import cats.data.{Kleisli, OptionT} +import cats.effect._ +import code.api.Constant._ +import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON._ +import code.api.util.APIUtil.{EmptyBody, ResourceDoc, _} +import code.api.util.ApiRole._ +import code.api.util.ApiTag._ +import code.api.util.ErrorMessages._ +import code.api.util.ExampleValue._ +import code.api.util.Glossary +import code.api.dynamic.endpoint.helper.DynamicEndpointHelper +import code.api.dynamic.entity.helper.DynamicEntityInfo +import code.api.v1_4_0.JSONFactory1_4_0 +import code.DynamicEndpoint.DynamicEndpointSwagger +import code.api.util.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps} +import code.api.util.http4s.ResourceDocMiddleware +import code.api.util.{APIUtil, CallContext, CustomJsonFormats, NewStyle} +import code.api.v4_0_0.JSONFactory400._ +import code.DynamicData.DynamicData +import code.api.util.migration.Migration +import code.dynamicEntity.DynamicEntityCommons +import code.entitlement.Entitlement +import code.model.dataAccess.AuthUser +import code.ratelimiting.RateLimitingDI +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.DynamicEntityOperation.GET_ALL +import com.openbankproject.commons.model.enums.ProductAttributeType +import com.openbankproject.commons.util.{ApiVersion, ApiVersionStatus, ScannedApiVersion} +import net.liftweb.common.{Box, Failure, Full} +import net.liftweb.json.Formats +import net.liftweb.json.JsonAST.{JArray, JObject, JValue} +import net.liftweb.json.JsonDSL._ +import net.liftweb.json.{compactRender, parse} +import org.apache.commons.lang3.StringUtils +import org.http4s._ +import org.http4s.dsl.io._ + +import scala.collection.mutable.ArrayBuffer +import scala.concurrent.Future + +object Http4s400 { + val implementedInApiVersion: ScannedApiVersion = ApiVersion.v4_0_0 + val versionStatus: String = ApiVersionStatus.STABLE.toString + // v4.0.0 splits doc registration into a static buffer plus a few entries that are dynamic + // at construction time (createDynamicEntityDoc et al). The public `resourceDocs` accessor + // (used by the middleware) is the union. For now only `staticResourceDocs` is populated; + // dynamic doc entries are added by the management endpoints when they're migrated. + val staticResourceDocs: ArrayBuffer[ResourceDoc] = ArrayBuffer[ResourceDoc]() + val resourceDocs: ArrayBuffer[ResourceDoc] = staticResourceDocs + + implicit val formats: Formats = CustomJsonFormats.formats + + type HttpF[A] = OptionT[IO, A] + + object Implementations4_0_0 { + val prefixPath: Path = Root / ApiPathZero.toString / implementedInApiVersion.toString + + // ─── getMapperDatabaseInfo ──────────────────────────────────────────────── + + val getMapperDatabaseInfo: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "database" / "info" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + _ <- NewStyle.function.hasEntitlement("", user.userId, canGetDatabaseInfo, Some(cc)) + } yield Migration.DbFunction.mapperDatabaseInfo + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getMapperDatabaseInfo), "GET", + "/database/info", + "Get Mapper Database Info", + s"""Get basic information about the Mapper Database. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, adapterInfoJsonV300, + List(AuthenticatedUserIsRequired, UnknownError), + List(apiTagApi), Some(List(canGetDatabaseInfo)), + http4sPartialFunction = Some(getMapperDatabaseInfo)) + + // ─── getLogoutLink ──────────────────────────────────────────────────────── + + val getLogoutLink: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "users" / "current" / "logout-link" => + EndpointHelpers.withUser(req) { (_, _) => + Future { + val link = code.api.Constant.HostName + AuthUser.logoutPath.foldLeft("")(_ + "/" + _) + LogoutLinkJson(link) + } + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getLogoutLink), "GET", + "/users/current/logout-link", + "Get Logout Link", + s"""Get the Logout Link + | + |${userAuthenticationMessage(true)}""", + EmptyBody, logoutLinkV400, + List(AuthenticatedUserIsRequired, UnknownError), + List(apiTagUser), None, + http4sPartialFunction = Some(getLogoutLink)) + + // ─── getBanks ───────────────────────────────────────────────────────────── + // v4.0.0 overrides v3.x getBanks — v4 uses createBanksJson which adds attributes. + + val getBanks: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" => + EndpointHelpers.executeAndRespond(req) { cc => + for { + (banks, _) <- NewStyle.function.getBanks(Some(cc)) + } yield JSONFactory400.createBanksJson(banks) + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getBanks), "GET", + "/banks", + "Get Banks", + """Get banks on this API instance + |Returns a list of banks supported on this server.""".stripMargin, + EmptyBody, banksJSON400, + List(UnknownError), + apiTagBank :: apiTagPSD2AIS :: apiTagPsd2 :: Nil, None, + http4sPartialFunction = Some(getBanks)) + + // ─── getBank ────────────────────────────────────────────────────────────── + // v4.0.0 overrides v3.x getBank — v4 includes bank attributes. + + val getBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ => + EndpointHelpers.withBank(req) { (bank, cc) => + for { + (attributes, _) <- NewStyle.function.getBankAttributesByBank(bank.bankId, Some(cc)) + } yield JSONFactory400.createBankJSON400(bank, attributes) + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getBank), "GET", + "/banks/BANK_ID", + "Get Bank", + """Get the bank specified by BANK_ID.""".stripMargin, + EmptyBody, bankJson400, + List(UnknownError, BankNotFound), + apiTagBank :: apiTagPSD2AIS :: apiTagPsd2 :: Nil, None, + http4sPartialFunction = Some(getBank)) + + // ─── ibanChecker (POST → 200) ───────────────────────────────────────────── + + val ibanChecker: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "account" / "check" / "scheme" / "iban" => + EndpointHelpers.executeFutureWithBody[IbanAddress, Any](req) { (ibanJson, cc) => + for { + (ibanCheckerResult, _) <- NewStyle.function.validateAndCheckIbanNumber(ibanJson.address, Some(cc)) + } yield JSONFactory400.createIbanCheckerJson(ibanCheckerResult) + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(ibanChecker), "POST", + "/account/check/scheme/iban", + "Validate and check IBAN", + """Validate and check IBAN for errors""", + ibanCheckerPostJsonV400, ibanCheckerJsonV400, + List(UnknownError), + apiTagAccount :: Nil, None, + http4sPartialFunction = Some(ibanChecker)) + + // ─── callsLimit (PUT → 200) ─────────────────────────────────────────────── + // v4.0.0 overrides v3.1.0 — v4 takes additional api_version / api_name / bank_id fields + // in the request body for finer-grained rate limiting. + + val callsLimit: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "management" / "consumers" / consumerIdStr / "consumer" / "call-limits" => + EndpointHelpers.withUserAndBody[CallLimitPostJsonV400, Any](req) { (user, postJson, cc) => + for { + _ <- NewStyle.function.handleEntitlementsAndScopes("", user.userId, List(canUpdateRateLimits), Some(cc)) + _ <- NewStyle.function.getConsumerByConsumerId(consumerIdStr, Some(cc)) + rateLimiting <- RateLimitingDI.rateLimiting.vend.createOrUpdateConsumerCallLimits( + consumerIdStr, + postJson.from_date, postJson.to_date, + postJson.api_version, postJson.api_name, postJson.bank_id, + Some(postJson.per_second_call_limit), + Some(postJson.per_minute_call_limit), + Some(postJson.per_hour_call_limit), + Some(postJson.per_day_call_limit), + Some(postJson.per_week_call_limit), + Some(postJson.per_month_call_limit)) map { + unboxFullOrFail(_, Some(cc), UpdateConsumerError) + } + } yield createCallsLimitJson(rateLimiting) + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(callsLimit), "PUT", + "/management/consumers/CONSUMER_ID/consumer/call-limits", + "Set Rate Limits / Call Limits per Consumer", + s"""Set the API rate limits / call limits for a Consumer. + | + |${userAuthenticationMessage(true)}""", + callLimitPostJsonV400, callLimitPostJsonV400, + List(AuthenticatedUserIsRequired, InvalidJsonFormat, InvalidConsumerId, + ConsumerNotFoundByConsumerId, UserHasMissingRoles, UpdateConsumerError, UnknownError), + List(apiTagConsumer, apiTagRateLimits), + Some(List(canUpdateRateLimits)), + http4sPartialFunction = Some(callsLimit)) + + // ─── createBank (POST → 201) ────────────────────────────────────────────── + // v4 overrides v2.2.0's createBank — v4 grants CanCreateEntitlementAtOneBank + + // CanReadDynamicResourceDocsAtOneBank to the creator after the bank is created. + // Must live in Http4s400's own routes so the bridge cascade can't hijack POST /banks + // down to Http4s220 (which has its own v2.2.0 createBank — different behavior). + + val createBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + for { + bank <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the $BankJson400 ", 400, Some(cc)) { + net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[BankJson400] + } + _ <- code.util.Helper.booleanToFuture( + failMsg = InvalidConsumerCredentials, cc = Some(cc)) { + cc.consumer.isDefined + } + shortStringCheck = APIUtil.checkShortString(bank.id) + _ <- code.util.Helper.booleanToFuture( + failMsg = s"$shortStringCheck.", cc = Some(cc)) { + shortStringCheck == code.util.Helper.SILENCE_IS_GOLDEN + } + _ <- code.util.Helper.booleanToFuture( + failMsg = s"$InvalidJsonFormat Min length of BANK_ID should be greater than 3 characters.", + cc = Some(cc)) { bank.id.length > 3 } + _ <- code.util.Helper.booleanToFuture( + failMsg = s"$InvalidJsonFormat BANK_ID can not contain space characters", + cc = Some(cc)) { !bank.id.contains(" ") } + _ <- code.util.Helper.booleanToFuture( + failMsg = s"$InvalidJsonFormat BANK_ID can not contain `::::` characters", + cc = Some(cc)) { !APIUtil.`checkIfContains::::`(bank.id) } + (success, _) <- NewStyle.function.createOrUpdateBank( + bank.id, bank.full_name, bank.short_name, bank.logo, bank.website, + bank.bank_routings.find(_.scheme == "BIC").map(_.address).getOrElse(""), + "", + bank.bank_routings.filterNot(_.scheme == "BIC").headOption.map(_.scheme).getOrElse(""), + bank.bank_routings.filterNot(_.scheme == "BIC").headOption.map(_.address).getOrElse(""), + Some(cc)) + entitlements <- NewStyle.function.getEntitlementsByUserId(cc.userId, Some(cc)) + entitlementsByBank = entitlements.filter(_.bankId == bank.id) + _ <- entitlementsByBank.exists(_.roleName == CanCreateEntitlementAtOneBank.toString()) match { + case true => Future.successful(()) + case false => Future(Entitlement.entitlement.vend.addEntitlement( + bank.id, cc.userId, CanCreateEntitlementAtOneBank.toString())) + } + _ <- entitlementsByBank.exists(_.roleName == CanReadDynamicResourceDocsAtOneBank.toString()) match { + case true => Future.successful(()) + case false => Future(Entitlement.entitlement.vend.addEntitlement( + bank.id, cc.userId, CanReadDynamicResourceDocsAtOneBank.toString())) + } + } yield JSONFactory400.createBankJSON400(success) + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, "createBank", "POST", + "/banks", + "Create Bank", + s"""Create a new bank (Authenticated access). + | + |The user creating this will be automatically assigned the Role CanCreateEntitlementAtOneBank.""", + postBankJson400, bankJson400, + List(InvalidJsonFormat, AuthenticatedUserIsRequired, + InsufficientAuthorisationToCreateBank, UnknownError), + List(apiTagBank), + Some(List(canCreateBank)), + http4sPartialFunction = Some(createBank)) + + // ─── root (GET) — v4 override of v3.1.0's /root ────────────────────────── + + val root: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` => + EndpointHelpers.executeAndRespond(req) { _ => + Future.successful(JSONFactory400.getApiInfoJSON( + ApiVersion.v4_0_0, versionStatus)) + } + case req @ GET -> `prefixPath` / "root" => + EndpointHelpers.executeAndRespond(req) { _ => + Future.successful(JSONFactory400.getApiInfoJSON( + ApiVersion.v4_0_0, versionStatus)) + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(root), "GET", "/root", + "Get API Info (root)", + """Returns information about: + | + |* API version + |* Hosted by information + |* Git Commit""", + EmptyBody, apiInfoJson400, + List(UnknownError, MandatoryPropertyIsNotSet), apiTagApi :: Nil, None, + http4sPartialFunction = Some(root)) + + // ─── getAtms (GET) — v4 override; conditional auth via getAtmsIsPublic ─── + + val getAtms: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "atms" => + EndpointHelpers.withBank(req) { (bank, cc) => + val limit = req.uri.query.params.get("limit").map(Full(_)).getOrElse(net.liftweb.common.Empty) + val offset = req.uri.query.params.get("offset").map(Full(_)).getOrElse(net.liftweb.common.Empty) + for { + _ <- code.util.Helper.booleanToFuture( + failMsg = s"$InvalidNumber limit:${limit.getOrElse("")}", cc = Some(cc)) { + limit match { + case Full(i) => i.forall(_.isDigit) + case _ => true + } + } + _ <- code.util.Helper.booleanToFuture(failMsg = maximumLimitExceeded, cc = Some(cc)) { + limit match { + case Full(i) if i.toInt > 10000 => false + case _ => true + } + } + (atms, _) <- NewStyle.function.getAtmsByBankId(bank.bankId, offset, limit, Some(cc)) + } yield JSONFactory400.createAtmsJsonV400(atms) + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAtms), "GET", + "/banks/BANK_ID/atms", + "Get Bank ATMS", + s"""Returns information about ATMs for a single bank specified by BANK_ID. + | + |${userAuthenticationMessage(!getAtmsIsPublic)}""".stripMargin, + EmptyBody, atmsJsonV400, + List(AuthenticatedUserIsRequired, BankNotFound, InvalidJsonFormat, UnknownError), + List(apiTagATM), None, + http4sPartialFunction = Some(getAtms)) + + // ─── getAtm (GET) — v4 override; conditional auth ──────────────────────── + + val getAtm: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "atms" / atmIdStr => + EndpointHelpers.withBank(req) { (bank, cc) => + for { + (atm, _) <- NewStyle.function.getAtm(bank.bankId, AtmId(atmIdStr), Some(cc)) + } yield JSONFactory400.createAtmJsonV400(atm) + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAtm), "GET", + "/banks/BANK_ID/atms/ATM_ID", + "Get Bank ATM", + s"""Returns information about ATM for a single bank specified by BANK_ID and ATM_ID. + | + |${userAuthenticationMessage(!getAtmsIsPublic)}""".stripMargin, + EmptyBody, atmJsonV400, + List(AuthenticatedUserIsRequired, InvalidJsonFormat, UnknownError), + List(apiTagATM), None, + http4sPartialFunction = Some(getAtm)) + + // ─── getProducts (GET) — v4 override; conditional auth ─────────────────── + + val getProducts: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankIdStr / "products" => + EndpointHelpers.executeAndRespond(req) { cc => + val params = req.uri.query.multiParams.toList.flatMap { + case (k, vs) => vs.map(v => com.openbankproject.commons.dto.GetProductsParam(k, List(v))) + } + for { + (_, _) <- NewStyle.function.getBank(BankId(bankIdStr), Some(cc)) + (products, _) <- NewStyle.function.getProducts(BankId(bankIdStr), params, Some(cc)) + } yield JSONFactory400.createProductsJson(products) + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getProducts), "GET", + "/banks/BANK_ID/products", + "Get Products", + s"""Returns information about the financial products offered by a bank specified by BANK_ID. + | + |${userAuthenticationMessage(!getProductsIsPublic)}""".stripMargin, + EmptyBody, productsJsonV400, + List(AuthenticatedUserIsRequired, BankNotFound, ProductNotFoundByProductCode, UnknownError), + List(apiTagProduct), None, + http4sPartialFunction = Some(getProducts)) + + // ─── getProduct (GET) — v4 override; loads attributes + fees ───────────── + + val getProduct: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankIdStr / "products" / productCodeStr => + EndpointHelpers.executeAndRespond(req) { cc => + for { + (product, _) <- NewStyle.function.getProduct(BankId(bankIdStr), ProductCode(productCodeStr), Some(cc)) + (productAttributes, _) <- NewStyle.function.getProductAttributesByBankAndCode( + BankId(bankIdStr), ProductCode(productCodeStr), Some(cc)) + (productFees, _) <- NewStyle.function.getProductFeesFromProvider( + BankId(bankIdStr), ProductCode(productCodeStr), Some(cc)) + } yield JSONFactory400.createProductJson(product, productAttributes, productFees) + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getProduct), "GET", + "/banks/BANK_ID/products/PRODUCT_CODE", + "Get Bank Product", + s"""Returns information about a financial Product offered by the bank specified by BANK_ID and PRODUCT_CODE. + | + |${userAuthenticationMessage(!getProductsIsPublic)}""".stripMargin, + EmptyBody, productJsonV400, + List(AuthenticatedUserIsRequired, BankNotFound, ProductNotFoundByProductCode, UnknownError), + List(apiTagProduct), None, + http4sPartialFunction = Some(getProduct)) + + // ─── createAtm (POST → 201) — v4 override ───────────────────────────────── + + val createAtm: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "atms" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val bank = cc.bank.getOrElse(throw new RuntimeException(BankNotFound)) + val rawBody = cc.httpBody.getOrElse("") + for { + atmJsonV400 <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the ${classOf[AtmJsonV400]}", + 400, Some(cc)) { + val atm = net.liftweb.json.parse(rawBody).extract[AtmJsonV400] + atm.id.get + atm + } + _ <- code.util.Helper.booleanToFuture( + s"$InvalidJsonValue BANK_ID has to be the same in the URL and Body", + failCode = 400, cc = Some(cc)) { + atmJsonV400.bank_id == bank.bankId.value + } + atm <- NewStyle.function.tryons( + CouldNotTransformJsonToInternalModel + " Atm", 400, Some(cc)) { + JSONFactory400.transformToAtmFromV400(atmJsonV400) + } + (created, _) <- NewStyle.function.createOrUpdateAtm(atm, Some(cc)) + } yield JSONFactory400.createAtmJsonV400(created) + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createAtm), "POST", + "/banks/BANK_ID/atms", + "Create ATM", + s"""Create ATM.""", + atmJsonV400, atmJsonV400, + List(AuthenticatedUserIsRequired, InvalidJsonFormat, UnknownError), + List(apiTagATM), + Some(List(canCreateAtm, canCreateAtmAtAnyBank)), + http4sPartialFunction = Some(createAtm)) + + // ─── createProduct (PUT → 201) — v4 override ────────────────────────────── + + val createProduct: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / bankIdStr / "products" / productCodeStr => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val user = cc.user.openOrThrowException(AuthenticatedUserIsRequired) + val rawBody = cc.httpBody.getOrElse("") + for { + _ <- NewStyle.function.hasAtLeastOneEntitlement(failMsg = createProductEntitlementsRequiredText)( + bankIdStr, user.userId, createProductEntitlements, Some(cc)) + product <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the $PutProductJsonV400 ", + 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[PutProductJsonV400] + } + (parentProduct, _) <- product.parent_product_code.trim.nonEmpty match { + case false => Future((net.liftweb.common.Empty, Some(cc))) + case true => + NewStyle.function.getProduct( + BankId(bankIdStr), ProductCode(product.parent_product_code), Some(cc)) + .map { case (p, ccc) => (Full(p), ccc) } + } + (success, _) <- NewStyle.function.createOrUpdateProduct( + bankId = bankIdStr, + code = productCodeStr, + parentProductCode = parentProduct.map(_.code.value).toOption, + name = product.name, + category = null, family = null, superFamily = null, + moreInfoUrl = product.more_info_url, + termsAndConditionsUrl = product.terms_and_conditions_url, + details = null, + description = product.description, + metaLicenceId = product.meta.license.id, + metaLicenceName = product.meta.license.name, + Some(cc)) + } yield JSONFactory400.createProductJson(success) + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createProduct), "PUT", + "/banks/BANK_ID/products/PRODUCT_CODE", + "Create Product", + s"""Create or Update Product for the Bank. + | + |${userAuthenticationMessage(true)}""", + putProductJsonV400, productJsonV400.copy(attributes = None, fees = None), + List(AuthenticatedUserIsRequired, BankNotFound, UserHasMissingRoles, UnknownError), + List(apiTagProduct), + Some(List(canCreateProduct, canCreateProductAtAnyBank)), + http4sPartialFunction = Some(createProduct)) + + // ─── createProductAttribute (POST → 201) — v4 override ──────────────────── + + val createProductAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "products" / productCodeStr / "attribute" => + EndpointHelpers.withUserAndBodyCreated[ProductAttributeJsonV400, Any](req) { (user, postedData, cc) => + for { + _ <- NewStyle.function.hasEntitlement(bankIdStr, user.userId, canCreateProductAttribute, Some(cc)) + (_, _) <- NewStyle.function.getBank(BankId(bankIdStr), Some(cc)) + productAttributeType <- NewStyle.function.tryons( + s"$InvalidJsonFormat The `Type` field can only accept the following field: " + + s"${ProductAttributeType.DOUBLE}(12.1234), ${ProductAttributeType.STRING}(TAX_NUMBER), ${ProductAttributeType.INTEGER}(123) and ${ProductAttributeType.DATE_WITH_DAY}(2012-04-23)", + 400, Some(cc)) { ProductAttributeType.withName(postedData.`type`) } + (_, _) <- NewStyle.function.getProduct(BankId(bankIdStr), ProductCode(productCodeStr), Some(cc)) + (productAttribute, _) <- NewStyle.function.createOrUpdateProductAttribute( + BankId(bankIdStr), ProductCode(productCodeStr), None, + postedData.name, productAttributeType, postedData.value, postedData.is_active, Some(cc)) + } yield createProductAttributeJson(productAttribute) + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createProductAttribute), "POST", + "/banks/BANK_ID/products/PRODUCT_CODE/attribute", + "Create Product Attribute", + s"""Create a Product Attribute. + | + |${userAuthenticationMessage(true)}""", + productAttributeJsonV400, productAttributeResponseJsonV400, + List(InvalidJsonFormat, UnknownError), + List(apiTagProduct, apiTagProductAttribute, apiTagAttribute), + Some(List(canCreateProductAttribute)), + http4sPartialFunction = Some(createProductAttribute)) + + // ─── updateProductAttribute (PUT → 200) — v4 override ───────────────────── + + val updateProductAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / bankIdStr / "products" / productCodeStr / "attributes" / productAttributeIdStr => + EndpointHelpers.withUserAndBody[ProductAttributeJsonV400, Any](req) { (user, postedData, cc) => + for { + _ <- NewStyle.function.hasEntitlement(bankIdStr, user.userId, canUpdateProductAttribute, Some(cc)) + (_, _) <- NewStyle.function.getBank(BankId(bankIdStr), Some(cc)) + productAttributeType <- NewStyle.function.tryons( + s"$InvalidJsonFormat The `Type` field can only accept the following field: " + + s"${ProductAttributeType.DOUBLE}(12.1234), ${ProductAttributeType.STRING}(TAX_NUMBER), ${ProductAttributeType.INTEGER}(123) and ${ProductAttributeType.DATE_WITH_DAY}(2012-04-23)", + 400, Some(cc)) { ProductAttributeType.withName(postedData.`type`) } + (_, _) <- NewStyle.function.getProductAttributeById(productAttributeIdStr, Some(cc)) + (productAttribute, _) <- NewStyle.function.createOrUpdateProductAttribute( + BankId(bankIdStr), ProductCode(productCodeStr), Some(productAttributeIdStr), + postedData.name, productAttributeType, postedData.value, postedData.is_active, Some(cc)) + } yield createProductAttributeJson(productAttribute) + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateProductAttribute), "PUT", + "/banks/BANK_ID/products/PRODUCT_CODE/attributes/PRODUCT_ATTRIBUTE_ID", + "Update Product Attribute", + s"""Update one Product Attribute by its id. + | + |${userAuthenticationMessage(true)}""", + productAttributeJsonV400, productAttributeResponseJsonV400, + List(UserHasMissingRoles, UnknownError), + List(apiTagProduct, apiTagProductAttribute, apiTagAttribute), + Some(List(canUpdateProductAttribute)), + http4sPartialFunction = Some(updateProductAttribute)) + + // ─── getSystemDynamicEntities ───────────────────────────────────────────── + + val getSystemDynamicEntities: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "system-dynamic-entities" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + _ <- NewStyle.function.hasEntitlement("", user.userId, canGetSystemLevelDynamicEntities, Some(cc)) + dynamicEntities <- Future(NewStyle.function.getDynamicEntities(None, false)) + } yield { + val listCommons: List[DynamicEntityCommons] = dynamicEntities + ListResult("dynamic_entities", listCommons.map(_.jValue)) + } + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getSystemDynamicEntities), "GET", + "/management/system-dynamic-entities", + "Get System Dynamic Entities", + s"""Get all System Dynamic Entities. + | + |For more information see ${Glossary.getGlossaryItemLink("Dynamic-Entities")}""", + EmptyBody, + ListResult("dynamic_entities", List(dynamicEntityResponseBodyExample)), + List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagManageDynamicEntity, apiTagApi), + Some(List(canGetSystemLevelDynamicEntities)), + http4sPartialFunction = Some(getSystemDynamicEntities)) + + // ─── getBankLevelDynamicEntities ────────────────────────────────────────── + + val getBankLevelDynamicEntities: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "banks" / _ / "dynamic-entities" => + EndpointHelpers.withUserAndBank(req) { (user, bank, cc) => + for { + _ <- NewStyle.function.hasAtLeastOneEntitlement(bank.bankId.value, user.userId, + List(canGetBankLevelDynamicEntities, canGetAnyBankLevelDynamicEntities), Some(cc)) + dynamicEntities <- Future(NewStyle.function.getDynamicEntities(Some(bank.bankId.value), false)) + } yield { + val listCommons: List[DynamicEntityCommons] = dynamicEntities + ListResult("dynamic_entities", listCommons.map(_.jValue)) + } + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getBankLevelDynamicEntities), "GET", + "/management/banks/BANK_ID/dynamic-entities", + "Get Bank Level Dynamic Entities", + s"""Get all the bank level Dynamic Entities for one bank. + | + |For more information see ${Glossary.getGlossaryItemLink("Dynamic-Entities")}""", + EmptyBody, + ListResult("dynamic_entities", List(dynamicEntityResponseBodyExample)), + List(BankNotFound, AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagManageDynamicEntity, apiTagApi), + Some(List(canGetBankLevelDynamicEntities, canGetAnyBankLevelDynamicEntities)), + http4sPartialFunction = Some(getBankLevelDynamicEntities)) + + // ─── getMyDynamicEntities ───────────────────────────────────────────────── + + val getMyDynamicEntities: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "my" / "dynamic-entities" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + dynamicEntities <- Future(NewStyle.function.getDynamicEntitiesByUserId(user.userId)) + } yield { + val listCommons: List[DynamicEntityCommons] = dynamicEntities + ListResult("dynamic_entities", listCommons.map(_.jValue)) + } + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getMyDynamicEntities), "GET", + "/my/dynamic-entities", + "Get My Dynamic Entities", + s"""Get all the Dynamic Entities created by the current user. + | + |For more information see ${Glossary.getGlossaryItemLink("Dynamic-Entities")}""", + EmptyBody, + ListResult("dynamic_entities", List(dynamicEntityResponseBodyExample)), + List(AuthenticatedUserIsRequired, UnknownError), + List(apiTagManageDynamicEntity, apiTagApi), None, + http4sPartialFunction = Some(getMyDynamicEntities)) + + // ─── dynamic-entity shared helpers (ported from APIMethods400) ────────── + + /** + * Convert IllegalArgumentException from validation (e.g. DynamicEntityCommons.apply + * shape checks) into a JSON-encoded APIFailureNewStyle exception. ErrorResponseConverter + * picks this up and emits an HTTP response with the exact failMsg verbatim. + * + * Why not `NewStyle.function.tryons`: tryons builds a Lift Failure chain and produces + * messages like ". Details: " or " <- . Details: ", which doesn't match + * the original error string the v4.0.0 tests assert on. + */ + private def tryOrApiFail[T](cc: CallContext, failCode: Int = 400)(f: => T): Future[T] = Future { + try f catch { + case e: IllegalArgumentException => + val apiFailure = code.api.APIFailureNewStyle(e.getMessage, failCode, Some(cc.toLight)) + throw new Exception(net.liftweb.json.JsonAST.compactRender( + net.liftweb.json.Extraction.decompose(apiFailure))) + } + } + + private def unboxResult[T: Manifest](box: Box[T], entityName: String): T = { + if (box.isInstanceOf[Failure]) { + val failure = box.asInstanceOf[Failure] + val msg = failure.msg.replace( + DynamicData.DynamicDataId.dbColumnName, + StringUtils.uncapitalize(entityName) + "Id") + val changedMsgFailure = failure.copy(msg = s"${code.api.util.ErrorMessages.InternalServerError} $msg") + APIUtil.fullBoxOrException[T](changedMsgFailure) + } + box.openOrThrowException("impossible error") + } + + private def createDynamicEntityImpl(cc: CallContext, dynamicEntity: DynamicEntityCommons): Future[JValue] = + for { + Full(result) <- NewStyle.function.createOrUpdateDynamicEntity(dynamicEntity, Some(cc)) + crudRoles = List( + DynamicEntityInfo.canCreateRole(result.entityName, dynamicEntity.bankId), + DynamicEntityInfo.canUpdateRole(result.entityName, dynamicEntity.bankId), + DynamicEntityInfo.canGetRole(result.entityName, dynamicEntity.bankId), + DynamicEntityInfo.canDeleteRole(result.entityName, dynamicEntity.bankId) + ) + } yield { + crudRoles.foreach(role => + Entitlement.entitlement.vend.addEntitlement( + dynamicEntity.bankId.getOrElse(""), cc.userId, role.toString())) + val commonsData: DynamicEntityCommons = result + commonsData.jValue + } + + private def updateDynamicEntityImpl(bankId: Option[String], dynamicEntityId: String, json: JValue, cc: CallContext): Future[JValue] = + for { + (entity, _) <- NewStyle.function.getDynamicEntityById(bankId, dynamicEntityId, Some(cc)) + (box, _) <- NewStyle.function.invokeDynamicConnector( + GET_ALL, entity.entityName, None, None, entity.bankId, None, None, false, Some(cc)) + resultList: JArray = unboxResult(box.asInstanceOf[Box[JArray]], entity.entityName) + _ <- code.util.Helper.booleanToFuture(DynamicEntityOperationNotAllowed, cc = Some(cc)) { + resultList.arr.isEmpty + } + dynamicEntity <- tryOrApiFail(cc) { + DynamicEntityCommons(json.asInstanceOf[JObject], Some(dynamicEntityId), cc.userId, bankId) + } + Full(result) <- NewStyle.function.createOrUpdateDynamicEntity(dynamicEntity, Some(cc)) + } yield { + val commonsData: DynamicEntityCommons = result + commonsData.jValue + } + + private def deleteDynamicEntityImpl(bankId: Option[String], dynamicEntityId: String, cc: CallContext): Future[Box[Boolean]] = + for { + (entity, _) <- NewStyle.function.getDynamicEntityById(bankId, dynamicEntityId, Some(cc)) + (box, _) <- NewStyle.function.invokeDynamicConnector( + GET_ALL, entity.entityName, None, None, entity.bankId, None, None, false, Some(cc)) + resultList: JArray = unboxResult(box.asInstanceOf[Box[JArray]], entity.entityName) + _ <- code.util.Helper.booleanToFuture(DynamicEntityOperationNotAllowed, cc = Some(cc)) { + resultList.arr.isEmpty + } + deleted: Box[Boolean] <- NewStyle.function.deleteDynamicEntity(bankId, dynamicEntityId) + } yield deleted + + // ─── createSystemDynamicEntity ──────────────────────────────────────────── + + val createSystemDynamicEntity: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "system-dynamic-entities" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + for { + jsonObj <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + net.liftweb.json.parse(rawBody).asInstanceOf[JObject] + } + dynamicEntity <- tryOrApiFail(cc) { + DynamicEntityCommons(jsonObj, None, cc.userId, None) + } + result <- createDynamicEntityImpl(cc, dynamicEntity) + } yield result + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createSystemDynamicEntity), "POST", + "/management/system-dynamic-entities", + "Create System Level Dynamic Entity", + s"""Create a system level Dynamic Entity. + | + |For more information see ${Glossary.getGlossaryItemLink("Dynamic-Entities")} + | + |${userAuthenticationMessage(true)}""", + dynamicEntityRequestBodyExample.copy(bankId = None), + dynamicEntityResponseBodyExample, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagManageDynamicEntity, apiTagApi), + Some(List(canCreateSystemLevelDynamicEntity)), + http4sPartialFunction = Some(createSystemDynamicEntity)) + + // ─── createBankLevelDynamicEntity ───────────────────────────────────────── + + val createBankLevelDynamicEntity: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "banks" / _ / "dynamic-entities" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val bank = cc.bank.getOrElse(throw new RuntimeException(BankNotFound)) + val rawBody = cc.httpBody.getOrElse("") + for { + jsonObj <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + net.liftweb.json.parse(rawBody).asInstanceOf[JObject] + } + dynamicEntity <- tryOrApiFail(cc) { + DynamicEntityCommons(jsonObj, None, cc.userId, Some(bank.bankId.value)) + } + result <- createDynamicEntityImpl(cc, dynamicEntity) + } yield result + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createBankLevelDynamicEntity), "POST", + "/management/banks/BANK_ID/dynamic-entities", + "Create Bank Level Dynamic Entity", + s"""Create a Bank Level DynamicEntity. + | + |For more information see ${Glossary.getGlossaryItemLink("Dynamic-Entities")} + | + |${userAuthenticationMessage(true)}""", + dynamicEntityRequestBodyExample.copy(bankId = None), + dynamicEntityResponseBodyExample, + List(BankNotFound, AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagManageDynamicEntity, apiTagApi), + Some(List(canCreateBankLevelDynamicEntity, canCreateAnyBankLevelDynamicEntity)), + http4sPartialFunction = Some(createBankLevelDynamicEntity)) + + // ─── updateSystemDynamicEntity ──────────────────────────────────────────── + + val updateSystemDynamicEntity: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "management" / "system-dynamic-entities" / dynamicEntityId => + EndpointHelpers.executeAndRespond(req) { cc => + val rawBody = cc.httpBody.getOrElse("") + for { + json <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + net.liftweb.json.parse(rawBody) + } + result <- updateDynamicEntityImpl(None, dynamicEntityId, json, cc) + } yield result + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateSystemDynamicEntity), "PUT", + "/management/system-dynamic-entities/DYNAMIC_ENTITY_ID", + "Update System Level Dynamic Entity", + s"""Update a system level DynamicEntity. + | + |${userAuthenticationMessage(true)}""", + dynamicEntityRequestBodyExample.copy(bankId = None), + dynamicEntityResponseBodyExample, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagManageDynamicEntity, apiTagApi), + Some(List(canUpdateSystemDynamicEntity)), + http4sPartialFunction = Some(updateSystemDynamicEntity)) + + // ─── updateBankLevelDynamicEntity ───────────────────────────────────────── + + val updateBankLevelDynamicEntity: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "management" / "banks" / bankIdStr / "dynamic-entities" / dynamicEntityId => + EndpointHelpers.withUserAndBank(req) { (_, bank, cc) => + val rawBody = cc.httpBody.getOrElse("") + for { + json <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + net.liftweb.json.parse(rawBody) + } + result <- updateDynamicEntityImpl(Some(bank.bankId.value), dynamicEntityId, json, cc) + } yield result + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateBankLevelDynamicEntity), "PUT", + "/management/banks/BANK_ID/dynamic-entities/DYNAMIC_ENTITY_ID", + "Update Bank Level Dynamic Entity", + s"""Update a Bank Level DynamicEntity. + | + |${userAuthenticationMessage(true)}""", + dynamicEntityRequestBodyExample.copy(bankId = None), + dynamicEntityResponseBodyExample, + List(BankNotFound, AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagManageDynamicEntity, apiTagApi), + Some(List(canUpdateBankLevelDynamicEntity)), + http4sPartialFunction = Some(updateBankLevelDynamicEntity)) + + // ─── deleteSystemDynamicEntity (200) ───────────────────────────────────── + + val deleteSystemDynamicEntity: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "management" / "system-dynamic-entities" / dynamicEntityId => + EndpointHelpers.withUser(req) { (_, cc) => + deleteDynamicEntityImpl(None, dynamicEntityId, cc).map(Full(_)) + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteSystemDynamicEntity), "DELETE", + "/management/system-dynamic-entities/DYNAMIC_ENTITY_ID", + "Delete System Level Dynamic Entity", + s"""Delete a system-level DynamicEntity specified by DYNAMIC_ENTITY_ID. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, EmptyBody, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagManageDynamicEntity, apiTagApi), + Some(List(canDeleteSystemLevelDynamicEntity)), + http4sPartialFunction = Some(deleteSystemDynamicEntity)) + + // ─── deleteBankLevelDynamicEntity (200) ────────────────────────────────── + + val deleteBankLevelDynamicEntity: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "management" / "banks" / _ / "dynamic-entities" / dynamicEntityId => + EndpointHelpers.withUserAndBank(req) { (_, bank, cc) => + deleteDynamicEntityImpl(Some(bank.bankId.value), dynamicEntityId, cc).map(Full(_)) + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteBankLevelDynamicEntity), "DELETE", + "/management/banks/BANK_ID/dynamic-entities/DYNAMIC_ENTITY_ID", + "Delete Bank Level Dynamic Entity", + s"""Delete a bank-level DynamicEntity specified by DYNAMIC_ENTITY_ID. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, EmptyBody, + List(BankNotFound, AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagManageDynamicEntity, apiTagApi), + Some(List(canDeleteBankLevelDynamicEntity)), + http4sPartialFunction = Some(deleteBankLevelDynamicEntity)) + + // ─── updateMyDynamicEntity ──────────────────────────────────────────────── + + val updateMyDynamicEntity: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "my" / "dynamic-entities" / dynamicEntityId => + EndpointHelpers.withUser(req) { (user, cc) => + val rawBody = cc.httpBody.getOrElse("") + for { + dynamicEntities <- Future(NewStyle.function.getDynamicEntitiesByUserId(user.userId)) + entityOption = dynamicEntities.find(_.dynamicEntityId.contains(dynamicEntityId)) + myEntity <- NewStyle.function.tryons(InvalidMyDynamicEntityUser, 400, Some(cc)) { + entityOption.get + } + (box, _) <- NewStyle.function.invokeDynamicConnector( + GET_ALL, myEntity.entityName, None, myEntity.dynamicEntityId, + myEntity.bankId, None, Some(myEntity.userId), false, Some(cc)) + resultList: JArray = unboxResult(box.asInstanceOf[Box[JArray]], myEntity.entityName) + _ <- code.util.Helper.booleanToFuture(DynamicEntityOperationNotAllowed, cc = Some(cc)) { + resultList.arr.isEmpty + } + jsonObj <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + net.liftweb.json.parse(rawBody).asInstanceOf[JObject] + } + dynamicEntity <- tryOrApiFail(cc) { + DynamicEntityCommons(jsonObj, Some(dynamicEntityId), user.userId, myEntity.bankId) + } + Full(result) <- NewStyle.function.createOrUpdateDynamicEntity(dynamicEntity, Some(cc)) + } yield { + val commonsData: DynamicEntityCommons = result + commonsData.jValue + } + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateMyDynamicEntity), "PUT", + "/my/dynamic-entities/DYNAMIC_ENTITY_ID", + "Update My Dynamic Entity", + s"""Update my DynamicEntity specified by DYNAMIC_ENTITY_ID. + | + |${userAuthenticationMessage(true)}""", + dynamicEntityRequestBodyExample.copy(bankId = None), + dynamicEntityResponseBodyExample, + List(AuthenticatedUserIsRequired, InvalidMyDynamicEntityUser, InvalidJsonFormat, UnknownError), + List(apiTagManageDynamicEntity, apiTagApi), None, + http4sPartialFunction = Some(updateMyDynamicEntity)) + + // ─── deleteMyDynamicEntity (200) ───────────────────────────────────────── + + val deleteMyDynamicEntity: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "my" / "dynamic-entities" / dynamicEntityId => + EndpointHelpers.withUser(req) { (user, cc) => + for { + dynamicEntities <- Future(NewStyle.function.getDynamicEntitiesByUserId(user.userId)) + entityOption = dynamicEntities.find(_.dynamicEntityId.contains(dynamicEntityId)) + myEntity <- NewStyle.function.tryons(InvalidMyDynamicEntityUser, 400, Some(cc)) { + entityOption.get + } + (box, _) <- NewStyle.function.invokeDynamicConnector( + GET_ALL, myEntity.entityName, None, myEntity.dynamicEntityId, + myEntity.bankId, None, Some(myEntity.userId), false, Some(cc)) + resultList: JArray = unboxResult(box.asInstanceOf[Box[JArray]], myEntity.entityName) + _ <- code.util.Helper.booleanToFuture(DynamicEntityOperationNotAllowed, cc = Some(cc)) { + resultList.arr.isEmpty + } + deleted: Box[Boolean] <- NewStyle.function.deleteDynamicEntity(myEntity.bankId, dynamicEntityId) + } yield deleted + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteMyDynamicEntity), "DELETE", + "/my/dynamic-entities/DYNAMIC_ENTITY_ID", + "Delete My Dynamic Entity", + s"""Delete my DynamicEntity specified by DYNAMIC_ENTITY_ID. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, EmptyBody, + List(AuthenticatedUserIsRequired, InvalidMyDynamicEntityUser, UnknownError), + List(apiTagManageDynamicEntity, apiTagApi), None, + http4sPartialFunction = Some(deleteMyDynamicEntity)) + + // ─── dynamic-endpoint shared helpers (ported from APIMethods400) ──────── + + private def createDynamicEndpointImpl(bankId: Option[String], json: JValue, cc: CallContext): Future[JObject] = + for { + tup <- NewStyle.function.tryons( + InvalidJsonFormat + "The request json is not valid OpenAPIV3.0.x or Swagger 2.0.x Please check it in Swagger Editor or similar tools ", + 400, Some(cc)) { + val jsonTweakedPath = DynamicEndpointHelper.addedBankToPath(json, bankId) + val swaggerContent = compactRender(jsonTweakedPath) + (DynamicEndpointSwagger(swaggerContent), DynamicEndpointHelper.parseSwaggerContent(swaggerContent)) + } + (postedJson, openAPI) = tup + duplicatedUrl = DynamicEndpointHelper.findExistingDynamicEndpoints(openAPI).map(kv => s"${kv._1}:${kv._2}") + errorMsg = s"""$DynamicEndpointExists Duplicated ${if (duplicatedUrl.size > 1) "endpoints" else "endpoint"}: ${duplicatedUrl.mkString("; ")}""" + _ <- code.util.Helper.booleanToFuture(errorMsg, cc = Some(cc)) { duplicatedUrl.isEmpty } + dynamicEndpointInfo <- NewStyle.function.tryons( + InvalidJsonFormat + "Can not convert to OBP Internal Resource Docs", 400, Some(cc)) { + DynamicEndpointHelper.buildDynamicEndpointInfo(openAPI, "current_request_json_body", bankId) + } + roles <- NewStyle.function.tryons( + InvalidJsonFormat + "Can not generate OBP roles", 400, Some(cc)) { + DynamicEndpointHelper.getRoles(dynamicEndpointInfo) + } + _ <- NewStyle.function.tryons( + InvalidJsonFormat + "Can not generate OBP external Resource Docs", 400, Some(cc)) { + JSONFactory1_4_0.createResourceDocsJson(dynamicEndpointInfo.resourceDocs.toList, false, None) + } + (dynamicEndpoint, _) <- NewStyle.function.createDynamicEndpoint( + bankId, cc.userId, postedJson.swaggerString, Some(cc)) + _ <- NewStyle.function.tryons( + InvalidJsonFormat + s"Can not grant these roles ${roles.toString} ", 400, Some(cc)) { + roles.map(role => Entitlement.entitlement.vend.addEntitlement( + bankId.getOrElse(""), cc.userId, role.toString())) + } + } yield { + val swaggerJson = parse(dynamicEndpoint.swaggerString) + ("bank_id", dynamicEndpoint.bankId) ~ ("user_id", cc.userId) ~ + ("dynamic_endpoint_id", dynamicEndpoint.dynamicEndpointId) ~ ("swagger_string", swaggerJson) + } + + private def updateDynamicEndpointHostImpl(bankId: Option[String], dynamicEndpointId: String, json: JValue, cc: CallContext): Future[code.api.v4_0_0.DynamicEndpointHostJson400] = + for { + (_, _) <- NewStyle.function.getDynamicEndpoint(bankId, dynamicEndpointId, Some(cc)) + postedData <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the $DynamicEndpointHostJson400", + 400, Some(cc)) { + json.extract[code.api.v4_0_0.DynamicEndpointHostJson400] + } + (_, _) <- NewStyle.function.updateDynamicEndpointHost(bankId, dynamicEndpointId, postedData.host, Some(cc)) + } yield postedData + + private def getDynamicEndpointsImpl(bankId: Option[String], cc: CallContext): Future[JValue] = + for { + (dynamicEndpoints, _) <- NewStyle.function.getDynamicEndpoints(bankId, Some(cc)) + } yield { + val resultList = dynamicEndpoints.map[JObject, List[JObject]] { dynamicEndpoint => + val swaggerJson = parse(dynamicEndpoint.swaggerString) + ("user_id", cc.userId) ~ ("dynamic_endpoint_id", dynamicEndpoint.dynamicEndpointId) ~ + ("swagger_string", swaggerJson) + } + net.liftweb.json.Extraction.decompose(ListResult("dynamic_endpoints", resultList)) + } + + private def getDynamicEndpointImpl(bankId: Option[String], dynamicEndpointId: String, cc: CallContext): Future[JObject] = + for { + (dynamicEndpoint, _) <- NewStyle.function.getDynamicEndpoint(bankId, dynamicEndpointId, Some(cc)) + } yield { + val swaggerJson = parse(dynamicEndpoint.swaggerString) + ("user_id", cc.userId) ~ ("dynamic_endpoint_id", dynamicEndpoint.dynamicEndpointId) ~ + ("swagger_string", swaggerJson) + } + + // ─── createDynamicEndpoint (POST → 201) ────────────────────────────────── + + val createDynamicEndpoint: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "dynamic-endpoints" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + for { + json <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + parse(rawBody) + } + result <- createDynamicEndpointImpl(None, json, cc) + } yield result + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createDynamicEndpoint), "POST", + "/management/dynamic-endpoints", + "Create Dynamic Endpoint", + s"""Create dynamic endpoints with one json format swagger content. + | + |${userAuthenticationMessage(true)}""", + dynamicEndpointRequestBodyExample, dynamicEndpointResponseBodyExample, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, DynamicEndpointExists, + InvalidJsonFormat, UnknownError), + List(apiTagManageDynamicEndpoint, apiTagApi), + Some(List(canCreateDynamicEndpoint)), + http4sPartialFunction = Some(createDynamicEndpoint)) + + // ─── createBankLevelDynamicEndpoint (POST → 201) ───────────────────────── + + val createBankLevelDynamicEndpoint: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "banks" / _ / "dynamic-endpoints" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val bank = cc.bank.getOrElse(throw new RuntimeException(BankNotFound)) + val rawBody = cc.httpBody.getOrElse("") + for { + json <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + parse(rawBody) + } + result <- createDynamicEndpointImpl(Some(bank.bankId.value), json, cc) + } yield result + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createBankLevelDynamicEndpoint), "POST", + "/management/banks/BANK_ID/dynamic-endpoints", + "Create Bank Level Dynamic Endpoint", + s"""Create dynamic endpoints with one json format swagger content. + | + |${userAuthenticationMessage(true)}""", + dynamicEndpointRequestBodyExample, dynamicEndpointResponseBodyExample, + List(BankNotFound, AuthenticatedUserIsRequired, UserHasMissingRoles, DynamicEndpointExists, + InvalidJsonFormat, UnknownError), + List(apiTagManageDynamicEndpoint, apiTagApi), + Some(List(canCreateBankLevelDynamicEndpoint, canCreateDynamicEndpoint)), + http4sPartialFunction = Some(createBankLevelDynamicEndpoint)) + + // ─── updateDynamicEndpointHost (PUT → 201) ─────────────────────────────── + + val updateDynamicEndpointHost: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "management" / "dynamic-endpoints" / dynamicEndpointId / "host" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + for { + json <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { parse(rawBody) } + result <- updateDynamicEndpointHostImpl(None, dynamicEndpointId, json, cc) + } yield result + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateDynamicEndpointHost), "PUT", + "/management/dynamic-endpoints/DYNAMIC_ENDPOINT_ID/host", + " Update Dynamic Endpoint Host", + s"""Update dynamic endpoint Host. + |The value can be obp_mock, dynamic_entity, or some service url.""", + dynamicEndpointHostJson400, dynamicEndpointHostJson400, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, + DynamicEntityNotFoundByDynamicEntityId, InvalidJsonFormat, UnknownError), + List(apiTagManageDynamicEndpoint, apiTagApi), + Some(List(canUpdateDynamicEndpoint)), + http4sPartialFunction = Some(updateDynamicEndpointHost)) + + // ─── updateBankLevelDynamicEndpointHost (PUT → 201) ────────────────────── + + val updateBankLevelDynamicEndpointHost: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "management" / "banks" / _ / "dynamic-endpoints" / dynamicEndpointId / "host" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val bank = cc.bank.getOrElse(throw new RuntimeException(BankNotFound)) + val rawBody = cc.httpBody.getOrElse("") + for { + json <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { parse(rawBody) } + result <- updateDynamicEndpointHostImpl(Some(bank.bankId.value), dynamicEndpointId, json, cc) + } yield result + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateBankLevelDynamicEndpointHost), "PUT", + "/management/banks/BANK_ID/dynamic-endpoints/DYNAMIC_ENDPOINT_ID/host", + " Update Bank Level Dynamic Endpoint Host", + s"""Update Bank Level dynamic endpoint Host.""", + dynamicEndpointHostJson400, dynamicEndpointHostJson400, + List(BankNotFound, AuthenticatedUserIsRequired, UserHasMissingRoles, + DynamicEntityNotFoundByDynamicEntityId, InvalidJsonFormat, UnknownError), + List(apiTagManageDynamicEndpoint, apiTagApi), + Some(List(canUpdateBankLevelDynamicEndpoint, canUpdateDynamicEndpoint)), + http4sPartialFunction = Some(updateBankLevelDynamicEndpointHost)) + + // ─── getDynamicEndpoint (GET → 200) ────────────────────────────────────── + + val getDynamicEndpoint: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "dynamic-endpoints" / dynamicEndpointId => + EndpointHelpers.executeAndRespond(req) { cc => + getDynamicEndpointImpl(None, dynamicEndpointId, cc) + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getDynamicEndpoint), "GET", + "/management/dynamic-endpoints/DYNAMIC_ENDPOINT_ID", + "Get Dynamic Endpoint", + s"""Get a Dynamic Endpoint by DYNAMIC_ENDPOINT_ID.""", + EmptyBody, dynamicEndpointResponseBodyExample, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, + DynamicEndpointNotFoundByDynamicEndpointId, InvalidJsonFormat, UnknownError), + List(apiTagManageDynamicEndpoint, apiTagApi), + Some(List(canGetDynamicEndpoint)), + http4sPartialFunction = Some(getDynamicEndpoint)) + + // ─── getDynamicEndpoints (GET → 200) ───────────────────────────────────── + + val getDynamicEndpoints: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "dynamic-endpoints" => + EndpointHelpers.executeAndRespond(req) { cc => + getDynamicEndpointsImpl(None, cc) + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getDynamicEndpoints), "GET", + "/management/dynamic-endpoints", + " Get Dynamic Endpoints", + s"""Get Dynamic Endpoints.""", + EmptyBody, ListResult("dynamic_endpoints", List(dynamicEndpointResponseBodyExample)), + List(AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagManageDynamicEndpoint, apiTagApi), + Some(List(canGetDynamicEndpoints)), + http4sPartialFunction = Some(getDynamicEndpoints)) + + // ─── getBankLevelDynamicEndpoint (GET → 200) ───────────────────────────── + + val getBankLevelDynamicEndpoint: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "banks" / _ / "dynamic-endpoints" / dynamicEndpointId => + EndpointHelpers.withUserAndBank(req) { (_, bank, cc) => + getDynamicEndpointImpl(Some(bank.bankId.value), dynamicEndpointId, cc) + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getBankLevelDynamicEndpoint), "GET", + "/management/banks/BANK_ID/dynamic-endpoints/DYNAMIC_ENDPOINT_ID", + " Get Bank Level Dynamic Endpoint", + s"""Get a Bank Level Dynamic Endpoint.""", + EmptyBody, dynamicEndpointResponseBodyExample, + List(BankNotFound, AuthenticatedUserIsRequired, UserHasMissingRoles, + DynamicEndpointNotFoundByDynamicEndpointId, InvalidJsonFormat, UnknownError), + List(apiTagManageDynamicEndpoint, apiTagApi), + Some(List(canGetBankLevelDynamicEndpoint, canGetDynamicEndpoint)), + http4sPartialFunction = Some(getBankLevelDynamicEndpoint)) + + // ─── getBankLevelDynamicEndpoints (GET → 200) ──────────────────────────── + + val getBankLevelDynamicEndpoints: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "banks" / _ / "dynamic-endpoints" => + EndpointHelpers.withUserAndBank(req) { (_, bank, cc) => + getDynamicEndpointsImpl(Some(bank.bankId.value), cc) + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getBankLevelDynamicEndpoints), "GET", + "/management/banks/BANK_ID/dynamic-endpoints", + "Get Bank Level Dynamic Endpoints", + s"""Get Bank Level Dynamic Endpoints.""", + EmptyBody, ListResult("dynamic_endpoints", List(dynamicEndpointResponseBodyExample)), + List(BankNotFound, AuthenticatedUserIsRequired, UserHasMissingRoles, + InvalidJsonFormat, UnknownError), + List(apiTagManageDynamicEndpoint, apiTagApi), + Some(List(canGetBankLevelDynamicEndpoints, canGetDynamicEndpoints)), + http4sPartialFunction = Some(getBankLevelDynamicEndpoints)) + + // ─── deleteDynamicEndpoint (DELETE → 204) ──────────────────────────────── + + val deleteDynamicEndpoint: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "management" / "dynamic-endpoints" / dynamicEndpointId => + EndpointHelpers.withUserDelete(req) { (_, cc) => + NewStyle.function.deleteDynamicEndpoint(None, dynamicEndpointId, Some(cc)) + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteDynamicEndpoint), "DELETE", + "/management/dynamic-endpoints/DYNAMIC_ENDPOINT_ID", + " Delete Dynamic Endpoint", + s"""Delete a DynamicEndpoint specified by DYNAMIC_ENDPOINT_ID.""", + EmptyBody, EmptyBody, + List(AuthenticatedUserIsRequired, DynamicEndpointNotFoundByDynamicEndpointId, UnknownError), + List(apiTagManageDynamicEndpoint, apiTagApi), + Some(List(canDeleteDynamicEndpoint)), + http4sPartialFunction = Some(deleteDynamicEndpoint)) + + // ─── deleteBankLevelDynamicEndpoint (DELETE → 204) ─────────────────────── + + val deleteBankLevelDynamicEndpoint: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "management" / "banks" / _ / "dynamic-endpoints" / dynamicEndpointId => + EndpointHelpers.withUserAndBankDelete(req) { (_, bank, cc) => + NewStyle.function.deleteDynamicEndpoint(Some(bank.bankId.value), dynamicEndpointId, Some(cc)) + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteBankLevelDynamicEndpoint), "DELETE", + "/management/banks/BANK_ID/dynamic-endpoints/DYNAMIC_ENDPOINT_ID", + " Delete Bank Level Dynamic Endpoint", + s"""Delete a Bank Level DynamicEndpoint specified by DYNAMIC_ENDPOINT_ID.""", + EmptyBody, EmptyBody, + List(BankNotFound, AuthenticatedUserIsRequired, + DynamicEndpointNotFoundByDynamicEndpointId, UnknownError), + List(apiTagManageDynamicEndpoint, apiTagApi), + Some(List(canDeleteBankLevelDynamicEndpoint, canDeleteDynamicEndpoint)), + http4sPartialFunction = Some(deleteBankLevelDynamicEndpoint)) + + // ─── getMyDynamicEndpoints (GET → 200) ─────────────────────────────────── + + val getMyDynamicEndpoints: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "my" / "dynamic-endpoints" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + (dynamicEndpoints, _) <- NewStyle.function.getDynamicEndpointsByUserId(user.userId, Some(cc)) + } yield { + val resultList = dynamicEndpoints.map[JObject, List[JObject]] { dynamicEndpoint => + val swaggerJson = parse(dynamicEndpoint.swaggerString) + ("user_id", user.userId) ~ ("dynamic_endpoint_id", dynamicEndpoint.dynamicEndpointId) ~ + ("swagger_string", swaggerJson) + } + ListResult("dynamic_endpoints", resultList) + } + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getMyDynamicEndpoints), "GET", + "/my/dynamic-endpoints", + "Get My Dynamic Endpoints", + s"""Get My Dynamic Endpoints.""", + EmptyBody, ListResult("dynamic_endpoints", List(dynamicEndpointResponseBodyExample)), + List(AuthenticatedUserIsRequired, InvalidJsonFormat, UnknownError), + List(apiTagManageDynamicEndpoint, apiTagApi), None, + http4sPartialFunction = Some(getMyDynamicEndpoints)) + + // ─── deleteMyDynamicEndpoint (DELETE → 204) ────────────────────────────── + + val deleteMyDynamicEndpoint: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "my" / "dynamic-endpoints" / dynamicEndpointId => + EndpointHelpers.withUserDelete(req) { (user, cc) => + for { + (dynamicEndpoint, _) <- NewStyle.function.getDynamicEndpoint(None, dynamicEndpointId, Some(cc)) + _ <- code.util.Helper.booleanToFuture(InvalidMyDynamicEndpointUser, cc = Some(cc)) { + dynamicEndpoint.userId.equals(user.userId) + } + deleted <- NewStyle.function.deleteDynamicEndpoint(None, dynamicEndpointId, Some(cc)) + } yield deleted + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteMyDynamicEndpoint), "DELETE", + "/my/dynamic-endpoints/DYNAMIC_ENDPOINT_ID", + "Delete My Dynamic Endpoint", + s"""Delete a DynamicEndpoint specified by DYNAMIC_ENDPOINT_ID.""", + EmptyBody, EmptyBody, + List(AuthenticatedUserIsRequired, DynamicEndpointNotFoundByDynamicEndpointId, UnknownError), + List(apiTagManageDynamicEndpoint, apiTagApi), None, + http4sPartialFunction = Some(deleteMyDynamicEndpoint)) + + // ─── allRoutes ──────────────────────────────────────────────────────────── + + private val allOwnRoutes: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req => + root.run(req) + .orElse(getMapperDatabaseInfo.run(req)) + .orElse(getLogoutLink.run(req)) + .orElse(getBanks.run(req)) + .orElse(getBank.run(req)) + .orElse(ibanChecker.run(req)) + .orElse(callsLimit.run(req)) + .orElse(createBank.run(req)) + .orElse(getAtms.run(req)) + .orElse(getAtm.run(req)) + .orElse(getProducts.run(req)) + .orElse(getProduct.run(req)) + .orElse(createAtm.run(req)) + .orElse(createProduct.run(req)) + .orElse(createProductAttribute.run(req)) + .orElse(updateProductAttribute.run(req)) + .orElse(getSystemDynamicEntities.run(req)) + .orElse(getBankLevelDynamicEntities.run(req)) + .orElse(getMyDynamicEntities.run(req)) + .orElse(createSystemDynamicEntity.run(req)) + .orElse(createBankLevelDynamicEntity.run(req)) + .orElse(updateSystemDynamicEntity.run(req)) + .orElse(updateBankLevelDynamicEntity.run(req)) + .orElse(deleteSystemDynamicEntity.run(req)) + .orElse(deleteBankLevelDynamicEntity.run(req)) + .orElse(updateMyDynamicEntity.run(req)) + .orElse(deleteMyDynamicEntity.run(req)) + .orElse(createDynamicEndpoint.run(req)) + .orElse(createBankLevelDynamicEndpoint.run(req)) + .orElse(updateDynamicEndpointHost.run(req)) + .orElse(updateBankLevelDynamicEndpointHost.run(req)) + .orElse(getDynamicEndpoint.run(req)) + .orElse(getDynamicEndpoints.run(req)) + .orElse(getBankLevelDynamicEndpoint.run(req)) + .orElse(getBankLevelDynamicEndpoints.run(req)) + .orElse(deleteDynamicEndpoint.run(req)) + .orElse(deleteBankLevelDynamicEndpoint.run(req)) + .orElse(getMyDynamicEndpoints.run(req)) + .orElse(deleteMyDynamicEndpoint.run(req)) + } + + val allRoutesWithMiddleware: HttpRoutes[IO] = ResourceDocMiddleware.apply(resourceDocs)(allOwnRoutes) + + // ─── path-rewriting bridge: /obp/v4.0.0/… → /obp/v3.1.0/… ────────────── + + val v400ToV310Bridge: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req => + val rawPath = req.uri.path.renderString + if (rawPath.startsWith("/obp/v4.0.0/")) { + val rewritten = rawPath.replaceFirst("/obp/v4\\.0\\.0/", "/obp/v3.1.0/") + val newUri = req.uri.withPath(Uri.Path.unsafeFromString(rewritten)) + val rewrittenReq = req.withUri(newUri) + code.api.v3_1_0.Http4s310.wrappedRoutesV310Services.run(rewrittenReq) + } else { + OptionT.none[IO, Response[IO]] + } + } + } + + val wrappedRoutesV400Services: HttpRoutes[IO] = + Kleisli[HttpF, Request[IO], Response[IO]] { req => + Implementations4_0_0.allRoutesWithMiddleware.run(req) + .orElse(Implementations4_0_0.v400ToV310Bridge.run(req)) + } +} From 7d435c3cf2c234875a3f239b7adff9242fb74361 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Wed, 13 May 2026 07:46:19 +0200 Subject: [PATCH 05/30] =?UTF-8?q?feat(v4.0.0):=20migrate=2012=20more=20bri?= =?UTF-8?q?dge-hijack=20overrides=20=E2=80=94=20user/customer/account=20fa?= =?UTF-8?q?mily?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds v4-native handlers for endpoints that were silently routed to older versions via the v400→v310→...→v200 bridge cascade. Without these, the v4-specific behaviour (different JSON shapes, different status codes, different role checks) was being lost. Migrated in this commit: - root (GET /root) - getAtms (GET /banks/BANK_ID/atms), getAtm (GET .../ATM_ID), createAtm (POST) - getProducts (GET /banks/BANK_ID/products), getProduct (GET .../PRODUCT_CODE) - createProduct (PUT), createProductAttribute (POST), updateProductAttribute (PUT) - getEntitlements (GET /users/USER_ID/entitlements) - getUserByUserId, getUserByUsername, getUsersByEmail, getUsers - getCustomersByAttributes (GET /banks/BANK_ID/customers) - createCustomer (POST /banks/BANK_ID/customers) - getBankAccountsBalancesForCurrentUser (GET /banks/BANK_ID/balances) - getCoreAccountById (GET /my/banks/.../account) - getPrivateAccountByIdFull (GET .../VIEW_ID/account) - getPrivateAccountsAtOneBank (GET /banks/BANK_ID/accounts) - createUserCustomerLinks (POST /banks/BANK_ID/user_customer_links) Progress: 25 of 35 v4-over-older overrides migrated; 10 remain (counterparties GET/POST, /banks/.../my/consents, getProductAttribute, scopes GET/POST, /management/consumers POST, createAccountV400, and answerTransactionRequestChallenge). Tests passing: AtmsTest (9), ProductTest (4), UserTest (14), EntitlementTests (9), AccountTest (13), AccountBalanceTest (2), CustomerTest (12), CustomerAttributesTest (17), UserCustomerLinkTest (9), plus all earlier suites (BankTests, BankAttributeTests, MapperDatabaseInfoTest, RateLimitingTest, DynamicEntityTest, DynamicEndpointsTest, etc). Notes on the migration: - Replaced Lift's SS thread-global DSL (which is populated by Lift's dispatch wrapper and unavailable in http4s) with direct cc.user / cc.bank / cc.bankAccount / cc.view access — middleware already populates these fields before the handler runs. - For getPrivateAccountsAtOneBank, gave explicit types to the tuple destructure (List[View], List[code.views.system.AccountAccess]) because the path-dependent return type of Views.views.vend was inferring Any otherwise. - For createUserCustomerLinks, removed the assert + inline tryons pattern in favour of the standard withUserAndBankAndBodyCreated helper; the inline isValidID(bankId.value) check stays. --- .../scala/code/api/v4_0_0/Http4s400.scala | 425 ++++++++++++++++++ 1 file changed, 425 insertions(+) diff --git a/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala b/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala index 23a99ab158..5f234c2748 100644 --- a/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala +++ b/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala @@ -10,8 +10,12 @@ import code.api.util.ApiTag._ import code.api.util.ErrorMessages._ import code.api.util.ExampleValue._ import code.api.util.Glossary +import code.api.Constant import code.api.dynamic.endpoint.helper.DynamicEndpointHelper import code.api.dynamic.entity.helper.DynamicEntityInfo +import code.api.util.newstyle.ViewNewStyle +import code.users.Users +import code.views.Views import code.api.v1_4_0.JSONFactory1_4_0 import code.DynamicEndpoint.DynamicEndpointSwagger import code.api.util.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps} @@ -584,6 +588,415 @@ object Http4s400 { Some(List(canUpdateProductAttribute)), http4sPartialFunction = Some(updateProductAttribute)) + // ─── getEntitlements (GET /users/USER_ID/entitlements) — v4 override ──── + + val getEntitlements: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "users" / userIdStr / "entitlements" => + EndpointHelpers.withUser(req) { (_, cc) => + for { + entitlements <- NewStyle.function.getEntitlementsByUserId(userIdStr, Some(cc)) + } yield { + if (APIUtil.isSuperAdmin(userIdStr)) { + code.api.v2_0_0.JSONFactory200.withVirtualEntitlements( + entitlements, code.api.v2_0_0.JSONFactory200.superAdminVirtualRoles) + } else if (APIUtil.isOidcOperator(userIdStr)) { + code.api.v2_0_0.JSONFactory200.withVirtualEntitlements( + entitlements, code.api.v2_0_0.JSONFactory200.oidcOperatorVirtualRoles) + } else { + code.api.v2_0_0.JSONFactory200.createEntitlementJSONs(entitlements) + } + } + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, "getEntitlements", "GET", + "/users/USER_ID/entitlements", + "Get Entitlements for User", + "", + EmptyBody, entitlementsJsonV400, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagRole, apiTagEntitlement, apiTagUser), + Some(List(canGetEntitlementsForAnyUserAtAnyBank)), + http4sPartialFunction = Some(getEntitlements)) + + // ─── getUserByUserId (GET /users/user_id/USER_ID) — v4 override ───────── + + val getUserByUserId: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "users" / "user_id" / userIdStr => + EndpointHelpers.withUser(req) { (_, cc) => + for { + user <- Users.users.vend.getUserByUserIdFuture(userIdStr) map { x => + unboxFullOrFail(x, Some(cc), s"$UserNotFoundByUserId Current UserId($userIdStr)") + } + entitlements <- NewStyle.function.getEntitlementsByUserId(user.userId, Some(cc)) + acceptMarketingInfo <- NewStyle.function.getAgreementByUserId(user.userId, "accept_marketing_info", Some(cc)) + termsAndConditions <- NewStyle.function.getAgreementByUserId(user.userId, "terms_and_conditions", Some(cc)) + privacyConditions <- NewStyle.function.getAgreementByUserId(user.userId, "privacy_conditions", Some(cc)) + isLocked = code.loginattempts.LoginAttempt.userIsLocked(user.provider, user.name) + } yield { + val agreements = acceptMarketingInfo.toList ::: termsAndConditions.toList ::: privacyConditions.toList + JSONFactory400.createUserInfoJSON(user, entitlements, Some(agreements), isLocked) + } + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, "getUserByUserId", "GET", + "/users/user_id/USER_ID", + "Get User by USER_ID", + s"""Get user by USER_ID + | + |${userAuthenticationMessage(true)} + | + |CanGetAnyUser entitlement is required,""", + EmptyBody, userJsonV400, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, UserNotFoundByUserId, UnknownError), + List(apiTagUser), + Some(List(canGetAnyUser)), + http4sPartialFunction = Some(getUserByUserId)) + + // ─── getUserByUsername (GET /users/username/USERNAME) — v4 override ───── + + val getUserByUsername: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "users" / "username" / username => + EndpointHelpers.withUser(req) { (_, cc) => + for { + user <- Users.users.vend.getUserByProviderAndUsernameFuture( + Constant.localIdentityProvider, username) map { x => + unboxFullOrFail(x, Some(cc), UserNotFoundByProviderAndUsername, 404) + } + entitlements <- NewStyle.function.getEntitlementsByUserId(user.userId, Some(cc)) + isLocked = code.loginattempts.LoginAttempt.userIsLocked(user.provider, user.name) + } yield JSONFactory400.createUserInfoJSON(user, entitlements, None, isLocked) + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, "getUserByUsername", "GET", + "/users/username/USERNAME", + "Get User by USERNAME", + s"""Get user by USERNAME + | + |${userAuthenticationMessage(true)} + | + |CanGetAnyUser entitlement is required,""", + EmptyBody, userJsonV400, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, + UserNotFoundByProviderAndUsername, UnknownError), + List(apiTagUser), + Some(List(canGetAnyUser)), + http4sPartialFunction = Some(getUserByUsername)) + + // ─── getUsersByEmail (GET /users/email/EMAIL/terminator) — v4 override ── + + val getUsersByEmail: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "users" / "email" / email / "terminator" => + EndpointHelpers.withUser(req) { (_, _) => + for { + users <- Users.users.vend.getUsersByEmail(email) + } yield JSONFactory400.createUsersJson(users) + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, "getUsersByEmail", "GET", + "/users/email/EMAIL/terminator", + "Get Users by Email Address", + s"""Get users by email address + | + |${userAuthenticationMessage(true)} + |CanGetAnyUser entitlement is required,""", + EmptyBody, usersJsonV400, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, UserNotFoundByEmail, UnknownError), + List(apiTagUser), + Some(List(canGetAnyUser)), + http4sPartialFunction = Some(getUsersByEmail)) + + // ─── getUsers (GET /users) — v4 override ───────────────────────────────── + + val getUsers: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "users" => + EndpointHelpers.withUser(req) { (_, cc) => + val httpParams = req.headers.headers.toList.map(h => + net.liftweb.http.provider.HTTPParam(h.name.toString, h.value)) ::: + req.uri.query.multiParams.toList.flatMap { case (k, vs) => + vs.map(v => net.liftweb.http.provider.HTTPParam(k, v)) + } + for { + (obpQueryParams, _) <- createQueriesByHttpParamsFuture(httpParams, Some(cc)) + users <- Users.users.vend.getUsers(obpQueryParams) + } yield JSONFactory400.createUsersJson(users) + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, "getUsers", "GET", + "/users", + "Get all Users", + s"""Get all users + | + |${userAuthenticationMessage(true)} + | + |CanGetAnyUser entitlement is required,""", + EmptyBody, usersJsonV400, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagUser), + Some(List(canGetAnyUser)), + http4sPartialFunction = Some(getUsers)) + + // ─── getCustomersByAttributes (GET /banks/BANK_ID/customers) — v4 override + + val getCustomersByAttributes: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "customers" => + EndpointHelpers.withUserAndBank(req) { (_, bank, cc) => + val params = req.uri.query.multiParams.map { case (k, vs) => k -> vs.toList } + for { + (customerIds, _) <- NewStyle.function.getCustomerIdsByAttributeNameValues( + bank.bankId, params, Some(cc)) + list <- Future.sequence(customerIds.map { customerId => + val customerFuture = NewStyle.function.getCustomerByCustomerId(customerId.value, Some(cc)) + customerFuture.flatMap { case (customer, ccc) => + NewStyle.function.getCustomerAttributes(bank.bankId, customerId, ccc) + .map { case (attributes, _) => + code.api.v3_1_0.JSONFactory310.createCustomerWithAttributesJson(customer, attributes) + } + } + }) + } yield ListResult("customers", list) + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, "getCustomersByAttributes", "GET", + "/banks/BANK_ID/customers", + "Get Customers by ATTRIBUTES", + "Gets the Customers specified by attributes", + EmptyBody, + ListResult("customers", List(customerWithAttributesJsonV310)), + List(AuthenticatedUserIsRequired, BankNotFound, UserCustomerLinksNotFoundForUser, UnknownError), + List(apiTagCustomer), + Some(List(canGetCustomersAtOneBank)), + http4sPartialFunction = Some(getCustomersByAttributes)) + + // ─── createCustomer (POST /banks/BANK_ID/customers → 201) — v4 override ── + + val createCustomer: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "customers" => + EndpointHelpers.withUserAndBankAndBodyCreated[code.api.v3_1_0.PostCustomerJsonV310, Any](req) { (_, bank, postedData, cc) => + for { + _ <- code.util.Helper.booleanToFuture( + failMsg = InvalidJsonContent + s" The field dependants(${postedData.dependants}) not equal the length(${postedData.dob_of_dependants.length}) of dob_of_dependants array", + failCode = 400, cc = Some(cc)) { + postedData.dependants == postedData.dob_of_dependants.length + } + (customer, _) <- NewStyle.function.createCustomer( + bank.bankId, + postedData.legal_name, postedData.mobile_phone_number, postedData.email, + CustomerFaceImage(postedData.face_image.date, postedData.face_image.url), + postedData.date_of_birth, postedData.relationship_status, + postedData.dependants, postedData.dob_of_dependants, + postedData.highest_education_attained, postedData.employment_status, + postedData.kyc_status, postedData.last_ok_date, + Option(CreditRating(postedData.credit_rating.rating, postedData.credit_rating.source)), + Option(CreditLimit(postedData.credit_limit.currency, postedData.credit_limit.amount)), + postedData.title, postedData.branch_id, postedData.name_suffix, + Some(cc)) + } yield code.api.v3_1_0.JSONFactory310.createCustomerJson(customer) + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createCustomer), "POST", + "/banks/BANK_ID/customers", + "Create Customer", + s"""The Customer resource stores the customer number (set by backend), legal name, email, phone number, date of birth, etc. + | + |${userAuthenticationMessage(true)}""", + code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON.postCustomerJsonV310, + code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON.customerJsonV310, + List(AuthenticatedUserIsRequired, BankNotFound, InvalidJsonFormat, + CustomerNumberAlreadyExists, UserNotFoundById, CustomerAlreadyExistsForUser, + CreateCustomerError, UnknownError), + List(apiTagCustomer, apiTagPerson), + Some(List(canCreateCustomer, canCreateCustomerAtAnyBank)), + http4sPartialFunction = Some(createCustomer)) + + // ─── getBankAccountsBalancesForCurrentUser (GET /banks/BANK_ID/balances) — v4 + + val getBankAccountsBalancesForCurrentUser: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "balances" => + EndpointHelpers.withUserAndBank(req) { (user, bank, cc) => + for { + (allowedAccounts, _) <- code.api.util.newstyle.BalanceNewStyle.getAccountAccessAtBank(user, bank.bankId, Some(cc)) + (accountsBalances, _) <- code.api.util.newstyle.BalanceNewStyle.getBankAccountsBalances(allowedAccounts, Some(cc)) + } yield createBalancesJson(accountsBalances) + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getBankAccountsBalancesForCurrentUser), "GET", + "/banks/BANK_ID/balances", + "Get Accounts Balances", + "Get the Balances for the Accounts of the current User at one bank.", + EmptyBody, accountBalancesV400Json, + List(AuthenticatedUserIsRequired, BankNotFound, UnknownError), + apiTagAccount :: apiTagPSD2AIS :: apiTagPsd2 :: Nil, None, + http4sPartialFunction = Some(getBankAccountsBalancesForCurrentUser)) + + // ─── getCoreAccountById (GET /my/banks/BANK_ID/accounts/ACCOUNT_ID/account) + + val getCoreAccountById: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "my" / "banks" / bankIdStr / "accounts" / accountIdStr / "account" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + (_, _) <- NewStyle.function.getBank(BankId(bankIdStr), Some(cc)) + (account, _) <- NewStyle.function.checkBankAccountExists(BankId(bankIdStr), AccountId(accountIdStr), Some(cc)) + view <- ViewNewStyle.checkOwnerViewAccessAndReturnOwnerView(user, + BankIdAccountId(account.bankId, account.accountId), Some(cc)) + moderatedAccount <- NewStyle.function.moderatedBankAccountCore(account, view, Full(user), Some(cc)) + } yield { + val availableViews: List[View] = + Views.views.vend.privateViewsUserCanAccessForAccount(user, + BankIdAccountId(account.bankId, account.accountId)) + createNewCoreBankAccountJson(moderatedAccount, availableViews) + } + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCoreAccountById), "GET", + "/my/banks/BANK_ID/accounts/ACCOUNT_ID/account", + "Get Account by Id (Core)", + s"""Information returned about the account specified by ACCOUNT_ID. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, moderatedCoreAccountJsonV400, + List(AuthenticatedUserIsRequired, BankAccountNotFound, UnknownError), + apiTagAccount :: apiTagPSD2AIS :: apiTagPsd2 :: Nil, None, + http4sPartialFunction = Some(getCoreAccountById)) + + // ─── getPrivateAccountByIdFull (GET /banks/BANK_ID/.../VIEW_ID/account) ── + + val getPrivateAccountByIdFull: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankIdStr / "accounts" / _ / _ / "account" => + EndpointHelpers.withView(req) { (user, account, view, cc) => + for { + moderatedAccount <- NewStyle.function.moderatedBankAccountCore(account, view, Full(user), Some(cc)) + (accountAttributes, _) <- NewStyle.function.getAccountAttributesByAccount( + account.bankId, account.accountId, Some(cc)) + } yield { + val availableViews = Views.views.vend.privateViewsUserCanAccessForAccount( + user, BankIdAccountId(account.bankId, account.accountId)) + val viewsAvailable = availableViews.map(code.api.v1_2_1.JSONFactory.createViewJSON).sortBy(_.short_name) + val tags = code.metadata.tags.Tags.tags.vend.getTagsOnAccount( + account.bankId, account.accountId)(view.viewId) + createBankAccountJSON(moderatedAccount, viewsAvailable, accountAttributes, tags) + } + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getPrivateAccountByIdFull), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/account", + "Get Account by Id (Full)", + """Information returned about an account specified by ACCOUNT_ID moderated by the view (VIEW_ID).""", + EmptyBody, moderatedAccountJSON400, + List(AuthenticatedUserIsRequired, BankNotFound, BankAccountNotFound, + UserNoPermissionAccessView, UnknownError), + apiTagAccount :: Nil, None, + http4sPartialFunction = Some(getPrivateAccountByIdFull)) + + // ─── getPrivateAccountsAtOneBank (GET /banks/BANK_ID/accounts) — v4 override + + val getPrivateAccountsAtOneBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" => + EndpointHelpers.withUserAndBank(req) { (user, bank, cc) => + val params: Map[String, String] = req.uri.query.params + .filterNot(_._1 == code.api.Constant.PARAM_TIMESTAMP) + .filterNot(_._1 == code.api.Constant.PARAM_LOCALE) + val viewsAndAccess: (List[View], List[code.views.system.AccountAccess]) = + Views.views.vend.privateViewsUserCanAccessAtBank(user, bank.bankId) + val privateViewsUserCanAccessAtOneBank: List[View] = viewsAndAccess._1 + val privateAccountAccess: List[code.views.system.AccountAccess] = viewsAndAccess._2 + for { + privateAccountAccess2 <- + if (params.isEmpty || privateAccountAccess.isEmpty) + Future.successful(privateAccountAccess) + else + code.accountattribute.AccountAttributeX.accountAttributeProvider.vend + .getAccountIdsByParams(bank.bankId, params.map { case (k, v) => k -> List(v) }) + .map { boxedAccountIds => + val accountIds = boxedAccountIds.getOrElse(Nil) + privateAccountAccess.filter(aa => accountIds.contains(aa.account_id.get)) + } + (availablePrivateAccounts, _) <- code.model.BankExtended(bank).privateAccountsFuture( + privateAccountAccess2, Some(cc)) + } yield code.api.v2_0_0.OBPAPI2_0_0.Implementations2_0_0.processAccounts( + privateViewsUserCanAccessAtOneBank, availablePrivateAccounts) + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getPrivateAccountsAtOneBank), "GET", + "/banks/BANK_ID/accounts", + "Get Accounts at Bank", + s"""Returns the list of accounts at BANK_ID that the user has access to.""", + EmptyBody, basicAccountsJSON, + List(AuthenticatedUserIsRequired, BankNotFound, UnknownError), + List(apiTagAccount, apiTagPrivateData, apiTagPublicData), None, + http4sPartialFunction = Some(getPrivateAccountsAtOneBank)) + + // ─── createUserCustomerLinks (POST → 201) — v4 override ───────────────── + + val createUserCustomerLinks: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "user_customer_links" => + EndpointHelpers.withUserAndBankAndBodyCreated[code.api.v2_0_0.CreateUserCustomerLinkJson, Any](req) { (_, bank, postedData, cc) => + for { + _ <- NewStyle.function.tryons(InvalidBankIdFormat, 400, Some(cc)) { + assert(isValidID(bank.bankId.value)) + } + _ <- Users.users.vend.getUserByUserIdFuture(postedData.user_id) map { x => + unboxFullOrFail(x, Some(cc), UserNotFoundByUserId, 404) + } + _ <- code.util.Helper.booleanToFuture( + "Field customer_id is not defined in the posted json!", + failCode = 400, cc = Some(cc)) { + postedData.customer_id.nonEmpty + } + (customer, _) <- NewStyle.function.getCustomerByCustomerId(postedData.customer_id, Some(cc)) + _ <- code.util.Helper.booleanToFuture( + s"Bank of the customer specified by the CUSTOMER_ID(${customer.bankId}) has to matches BANK_ID(${bank.bankId.value}) in URL", + failCode = 400, cc = Some(cc)) { + customer.bankId == bank.bankId.value + } + _ <- code.util.Helper.booleanToFuture(CustomerAlreadyExistsForUser, failCode = 400, cc = Some(cc)) { + code.usercustomerlinks.UserCustomerLink.userCustomerLink.vend + .getUserCustomerLink(postedData.user_id, postedData.customer_id).isEmpty + } + userCustomerLink <- Future { + code.usercustomerlinks.UserCustomerLink.userCustomerLink.vend.createUserCustomerLink( + postedData.user_id, postedData.customer_id, new java.util.Date(), true) + } map { x => unboxFullOrFail(x, Some(cc), CreateUserCustomerLinksError, 400) } + } yield code.api.v2_0_0.JSONFactory200.createUserCustomerLinkJSON(userCustomerLink) + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, "createUserCustomerLinks", "POST", + "/banks/BANK_ID/user_customer_links", + "Create User Customer Link", + s"""Link a User to a Customer + | + |${userAuthenticationMessage(true)}""", + createUserCustomerLinkJson, userCustomerLinkJson, + List(AuthenticatedUserIsRequired, InvalidBankIdFormat, BankNotFound, InvalidJsonFormat, + CustomerNotFoundByCustomerId, UserHasMissingRoles, CustomerAlreadyExistsForUser, + CreateUserCustomerLinksError, UnknownError), + List(apiTagCustomer, apiTagUser), + Some(List(canCreateUserCustomerLinkAtAnyBank, canCreateUserCustomerLink)), + http4sPartialFunction = Some(createUserCustomerLinks)) + // ─── getSystemDynamicEntities ───────────────────────────────────────────── val getSystemDynamicEntities: HttpRoutes[IO] = HttpRoutes.of[IO] { @@ -1386,6 +1799,18 @@ object Http4s400 { .orElse(createProduct.run(req)) .orElse(createProductAttribute.run(req)) .orElse(updateProductAttribute.run(req)) + .orElse(getEntitlements.run(req)) + .orElse(getUserByUserId.run(req)) + .orElse(getUserByUsername.run(req)) + .orElse(getUsersByEmail.run(req)) + .orElse(getUsers.run(req)) + .orElse(getCustomersByAttributes.run(req)) + .orElse(createCustomer.run(req)) + .orElse(getBankAccountsBalancesForCurrentUser.run(req)) + .orElse(getCoreAccountById.run(req)) + .orElse(getPrivateAccountByIdFull.run(req)) + .orElse(getPrivateAccountsAtOneBank.run(req)) + .orElse(createUserCustomerLinks.run(req)) .orElse(getSystemDynamicEntities.run(req)) .orElse(getBankLevelDynamicEntities.run(req)) .orElse(getMyDynamicEntities.run(req)) From 41419b5d9a1e66ca49edf14d6a3cc46e74d99d87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Wed, 13 May 2026 08:30:07 +0200 Subject: [PATCH 06/30] feat(v4.0.0): migrate 4 more bridge-hijack overrides + fix scope-aware role check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrated to Http4s400 own-routes: - getProductAttribute (GET /banks/BANK_ID/products/PRODUCT_CODE/attributes/PRODUCT_ATTRIBUTE_ID) - getScopes (GET /consumers/CONSUMER_ID/scopes) - addScope (POST /consumers/CONSUMER_ID/scopes — 201) - getConsents (GET /banks/BANK_ID/my/consents — returns ConsentsJsonV400 with api_standard/api_version fields, distinct from v3.1.0's ConsentsJsonV310) ResourceDocMiddleware fix: ScopesTest scenarios "require_scopes_for_all_roles=true but without scope" and "require_scopes_for_listed_roles=CanGetAnyUser but without scope" were failing with 200 when 403 was expected. Root cause: authorizeRoles was using APIUtil.hasEntitlement(checkBankId, userId, role) which only checks user entitlements and ignores the require_scopes_for_all_roles / require_scopes_for_listed_roles properties. Switched to APIUtil.handleAccessControlRegardingEntitlementsAndScopes(bankId, userId, consumerId, roles), the same scope-aware check the Lift dispatcher uses. The pre-existing bug only surfaced once getUserByUserId moved from Lift to http4s. Tests passing: ScopesTest (10/10), ConsentTests (5/5), ProductTest (4), AccountTest (13), BankTests (33), UserTest (14), CustomerTest (12), EntitlementTests (9), UserCustomerLinkTest (9), AccountBalanceTest (2), AtmsTest (9), CustomerAttributesTest (17), RateLimitingTest, plus Http4s700RoutesTest (111), Http4s500SystemViewsTest (13), and v3.1.0 ConsentTest/AccountAccessTest — 124 v7/v5/v3.1 tests in total. Override audit: 29/35 done, 6 remain (createCounterpartyForAnyAccount, getExplicitCounterpartiesForAccount, getExplicitCounterpartyById, getFirehoseAccountsAtOneBank, updateAccountLabel, createConsumer, createTransactionRequestCard, answerTransactionRequestChallenge). --- .../util/http4s/ResourceDocMiddleware.scala | 6 +- .../scala/code/api/v4_0_0/Http4s400.scala | 153 ++++++++++++++++++ 2 files changed, 155 insertions(+), 4 deletions(-) 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 f4ceeffffe..9f913782a3 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 @@ -347,10 +347,8 @@ object ResourceDocMiddleware extends MdcLoggable { ctx.user match { case Full(user) => val bankId = pathParams.getOrElse("BANK_ID", "") - val ok = roles.exists { role => - val checkBankId = if (role.requiresBankId) bankId else "" - APIUtil.hasEntitlement(checkBankId, user.userId, role) - } + val consumerId = APIUtil.getConsumerPrimaryKey(Some(ctx.callContext)) + val ok = APIUtil.handleAccessControlRegardingEntitlementsAndScopes(bankId, user.userId, consumerId, roles) if (ok) success(ctx) else EitherT[IO, Response[IO], ValidationContext]( ErrorResponseConverter.createErrorResponse(403, UserHasMissingRoles + roles.mkString(" or "), ctx.callContext) diff --git a/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala b/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala index 5f234c2748..ee2a204cea 100644 --- a/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala +++ b/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala @@ -26,6 +26,7 @@ import code.DynamicData.DynamicData import code.api.util.migration.Migration import code.dynamicEntity.DynamicEntityCommons import code.entitlement.Entitlement +import code.model.BankX import code.model.dataAccess.AuthUser import code.ratelimiting.RateLimitingDI import com.github.dwickern.macros.NameOf.nameOf @@ -1780,6 +1781,154 @@ object Http4s400 { List(apiTagManageDynamicEndpoint, apiTagApi), None, http4sPartialFunction = Some(deleteMyDynamicEndpoint)) + // ─── getProductAttribute (v4 override of Http4s310 — Lift declared role mismatch fixed) ─ + + val getProductAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankIdStr / "products" / _ / "attributes" / productAttributeIdStr => + EndpointHelpers.withUser(req) { (user, cc) => + for { + _ <- NewStyle.function.hasEntitlement(bankIdStr, user.userId, canGetProductAttribute, Some(cc)) + (_, _) <- NewStyle.function.getBank(BankId(bankIdStr), Some(cc)) + (productAttribute, _) <- NewStyle.function.getProductAttributeById(productAttributeIdStr, Some(cc)) + } yield createProductAttributeJson(productAttribute) + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getProductAttribute), "GET", + "/banks/BANK_ID/products/PRODUCT_CODE/attributes/PRODUCT_ATTRIBUTE_ID", + "Get Product Attribute", + s"""Get one Product Attribute by its id. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, productAttributeResponseJsonV400, + List(UserHasMissingRoles, UnknownError), + List(apiTagProduct, apiTagProductAttribute, apiTagAttribute), + Some(List(canGetProductAttribute)), + http4sPartialFunction = Some(getProductAttribute)) + + // ─── getScopes (GET /consumers/CONSUMER_ID/scopes) — v4 override of Http4s300 ─ + + val getScopes: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "consumers" / uuidOfConsumer / "scopes" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + callingConsumer <- Future { cc.consumer } map { x => + unboxFullOrFail(x, Some(cc), InvalidConsumerCredentials) + } + _ <- Future { + NewStyle.function.hasEntitlementAndScope( + "", user.userId, callingConsumer.id.get.toString, + canGetEntitlementsForAnyUserAtAnyBank, Some(cc)) + } flatMap { unboxFullAndWrapIntoFuture(_) } + targetConsumer <- NewStyle.function.getConsumerByConsumerId(uuidOfConsumer, Some(cc)) + scopes <- Future { + code.scope.Scope.scope.vend.getScopesByConsumerId(targetConsumer.id.get.toString) + } map { unboxFull(_) } + } yield code.api.v3_0_0.JSONFactory300.createScopeJSONs(scopes) + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getScopes), "GET", + "/consumers/CONSUMER_ID/scopes", + "Get Scopes for Consumer", + s"""Get all the scopes for an consumer specified by CONSUMER_ID + | + |${userAuthenticationMessage(true)}""", + EmptyBody, scopeJsons, + List(AuthenticatedUserIsRequired, EntitlementNotFound, ConsumerNotFoundByConsumerId, UnknownError), + List(apiTagScope, apiTagConsumer), None, + http4sPartialFunction = Some(getScopes)) + + // ─── addScope (POST /consumers/CONSUMER_ID/scopes → 201) — v4 override ──── + + val addScope: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "consumers" / consumerId / "scopes" => + EndpointHelpers.withUserAndBodyCreated[code.api.v3_0_0.CreateScopeJson, Any](req) { (user, postedData, cc) => + for { + consumer <- NewStyle.function.getConsumerByConsumerId(consumerId, Some(cc)) + role <- Future { net.liftweb.util.Helpers.tryo { code.api.util.ApiRole.valueOf(postedData.role_name) } } map { x => + unboxFullOrFail(x, Some(cc), + IncorrectRoleName + postedData.role_name + ". Possible roles are " + code.api.util.ApiRole.availableRoles.sorted.mkString(", ")) + } + _ <- code.util.Helper.booleanToFuture( + failMsg = if (role.requiresBankId) EntitlementIsBankRole else EntitlementIsSystemRole, + cc = Some(cc)) { + role.requiresBankId == postedData.bank_id.nonEmpty + } + allowedEntitlements = canCreateScopeAtOneBank :: canCreateScopeAtAnyBank :: Nil + _ <- NewStyle.function.hasAtLeastOneEntitlement( + failMsg = s"$UserHasMissingRoles ${allowedEntitlements.mkString(", ")}!" + )(postedData.bank_id, user.userId, allowedEntitlements, Some(cc)) + _ <- code.util.Helper.booleanToFuture(failMsg = BankNotFound, cc = Some(cc)) { + postedData.bank_id.isEmpty || BankX(BankId(postedData.bank_id), Some(cc)).map(_._1).isDefined + } + _ <- code.util.Helper.booleanToFuture(failMsg = EntitlementAlreadyExists, cc = Some(cc)) { + !APIUtil.hasScope(postedData.bank_id, consumerId, role) + } + addedEntitlement <- Future { + code.scope.Scope.scope.vend.addScope( + postedData.bank_id, consumer.id.get.toString, postedData.role_name) + } map { unboxFull(_) } + } yield code.api.v3_0_0.JSONFactory300.createScopeJson(addedEntitlement) + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(addScope), "POST", + "/consumers/CONSUMER_ID/scopes", + "Create Scope for a Consumer", + """Create Scope. Grant Role to Consumer. + | + |Scopes are used to grant System or Bank level roles to the Consumer (App).""", + createScopeJson, scopeJson, + List(AuthenticatedUserIsRequired, ConsumerNotFoundById, InvalidJsonFormat, + IncorrectRoleName, EntitlementIsBankRole, EntitlementIsSystemRole, EntitlementAlreadyExists, UnknownError), + List(apiTagScope, apiTagConsumer), + Some(List(canCreateScopeAtAnyBank, canCreateScopeAtOneBank)), + http4sPartialFunction = Some(addScope)) + + // ─── getConsents (GET /banks/BANK_ID/my/consents) — v4 override of Http4s310 ─ + + val getConsents: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "my" / "consents" => + EndpointHelpers.withUserAndBank(req) { (user, bank, _) => + val params = req.uri.query.params + val limit = params.get("limit").flatMap(s => scala.util.Try(s.toInt).toOption).getOrElse(50) + val offset = params.get("offset").flatMap(s => scala.util.Try(s.toInt).toOption).getOrElse(0) + for { + rows <- Future { + code.consent.DoobieConsentQueries.getConsentsByUserAndBank( + userId = user.userId, bankId = bank.bankId.value, status = None, + limit = limit, offset = offset, + sortField = "created_date", sortDirection = "desc") + } + } yield { + val consents = rows.map(r => ConsentJsonV400( + r.consentId, r.jwt.getOrElse(""), r.status, + r.apiStandard.getOrElse(""), r.apiVersion.getOrElse(""))) + ConsentsJsonV400(consents) + } + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getConsents), "GET", + "/banks/BANK_ID/my/consents", + "Get Consents", + s"""This endpoint gets the Consents that the current User created. + | + |${userAuthenticationMessage(true)} + | + |1 limit (for pagination: defaults to 50) eg:limit=200 + | + |2 offset (for pagination: zero index, defaults to 0) eg: offset=10""", + EmptyBody, consentsJsonV400, + List($AuthenticatedUserIsRequired, $BankNotFound, UnknownError), + List(apiTagConsent, apiTagPSD2AIS, apiTagPsd2), None, + http4sPartialFunction = Some(getConsents)) + // ─── allRoutes ──────────────────────────────────────────────────────────── private val allOwnRoutes: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req => @@ -1834,6 +1983,10 @@ object Http4s400 { .orElse(deleteBankLevelDynamicEndpoint.run(req)) .orElse(getMyDynamicEndpoints.run(req)) .orElse(deleteMyDynamicEndpoint.run(req)) + .orElse(getProductAttribute.run(req)) + .orElse(getScopes.run(req)) + .orElse(addScope.run(req)) + .orElse(getConsents.run(req)) } val allRoutesWithMiddleware: HttpRoutes[IO] = ResourceDocMiddleware.apply(resourceDocs)(allOwnRoutes) From d01b33ea624cb3cb0055ea220c0ff4843f04ac2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Wed, 13 May 2026 08:56:53 +0200 Subject: [PATCH 07/30] =?UTF-8?q?feat(v4.0.0):=20migrate=204=20more=20brid?= =?UTF-8?q?ge-hijack=20overrides=20=E2=80=94=20counterparty=20+=20updateAc?= =?UTF-8?q?countLabel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrated to Http4s400 own-routes: - updateAccountLabel (POST /banks/BANK_ID/accounts/ACCOUNT_ID → 200) — v4 takes UpdateAccountJsonV400 {label}, distinct from v1.2.1's UpdateAccountJSON {id, label, bank_id}; previously hijacked by Http4s121. - getExplicitCounterpartiesForAccount (GET .../counterparties) — v4 returns counterpartiesJson400 (with currency field) and uses view.allowed_actions for permission check with 403; previously hijacked by Http4s220 which returned the v2.2.0 shape (no currency) with 400. - getExplicitCounterpartyById (GET .../counterparties/COUNTERPARTY_ID) — v4 must return 400 (not 404) when counterparty is unknown, matching the delete-then-get test that expects 400 after the counterparty is gone. Uses EXPLICIT_COUNTERPARTY_ID URL template var (non-standard ALL_CAPS) to bypass middleware's 404 validation, then calls NewStyle.function.getCounterpartyByCounterpartyId which fails with 400. - createExplicitCounterparty (POST .../counterparties → 201) — v4 sets currency from postJson.currency (v2.2.0 sets ""), validates ISO currency code, and returns 403 (not 400) when the view lacks can_add_counterparty. Tests: CounterpartyTest (7/7), API1_2_1Test (323), AccountTest (13), BankTests (33), ProductTest (4), AtmsTest (9), UserTest (14), EntitlementTests (9), ScopesTest (10), ConsentTests (5), UserCustomerLinkTest (9) — 399 total, all pass. Override audit: 33/37 done, 4 remain (getFirehoseAccountsAtOneBank, createConsumer, createTransactionRequestCard, answerTransactionRequestChallenge). --- .../scala/code/api/v4_0_0/Http4s400.scala | 206 ++++++++++++++++++ 1 file changed, 206 insertions(+) diff --git a/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala b/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala index ee2a204cea..dda7087a9d 100644 --- a/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala +++ b/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala @@ -25,6 +25,7 @@ import code.api.v4_0_0.JSONFactory400._ import code.DynamicData.DynamicData import code.api.util.migration.Migration import code.dynamicEntity.DynamicEntityCommons +import code.bankconnectors.Connector import code.entitlement.Entitlement import code.model.BankX import code.model.dataAccess.AuthUser @@ -1929,6 +1930,207 @@ object Http4s400 { List(apiTagConsent, apiTagPSD2AIS, apiTagPsd2), None, http4sPartialFunction = Some(getConsents)) + // ─── updateAccountLabel (POST /banks/BANK_ID/accounts/ACCOUNT_ID → 200) — v4 override of Http4s121 ─ + + val updateAccountLabel: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr => + EndpointHelpers.withUserAndBody[UpdateAccountJsonV400, Any](req) { (user, postedData, cc) => + for { + (account, _) <- NewStyle.function.checkBankAccountExists(BankId(bankIdStr), AccountId(accountIdStr), Some(cc)) + anyViewContainsCanUpdateBankAccountLabelPermission = Views.views.vend + .permission(BankIdAccountId(account.bankId, account.accountId), user) + .map(_.views.map(_.allowed_actions.exists(_ == CAN_UPDATE_BANK_ACCOUNT_LABEL))) + .getOrElse(Nil) + .find(_ == true) + .getOrElse(false) + _ <- code.util.Helper.booleanToFuture( + s"${ViewDoesNotPermitAccess} You need the `${CAN_UPDATE_BANK_ACCOUNT_LABEL}` permission on any your views", + cc = Some(cc)) { + anyViewContainsCanUpdateBankAccountLabelPermission + } + _ <- Connector.connector.vend.updateAccountLabel( + BankId(bankIdStr), AccountId(accountIdStr), postedData.label, Some(cc) + ) map { i => + unboxFullOrFail(i._1, i._2, + s"$UpdateBankAccountLabelError Current BankId is $bankIdStr and Current AccountId is $accountIdStr", 404) + } + } yield successMessage + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateAccountLabel), "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID", + "Update Account Label", + s"""Update the label for the account. The label is how the account is known to the account owner e.g. 'My savings account'. + | + |${userAuthenticationMessage(true)}""", + updateAccountJsonV400, successMessage, + List(InvalidJsonFormat, $AuthenticatedUserIsRequired, $BankAccountNotFound, + "user does not have access to owner view on account", UnknownError), + List(apiTagAccount), None, + http4sPartialFunction = Some(updateAccountLabel)) + + // ─── getExplicitCounterpartiesForAccount (GET .../counterparties) — v4 override ─ + + val getExplicitCounterpartiesForAccount: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "counterparties" => + EndpointHelpers.withView(req) { (user, account, view, cc) => + for { + _ <- code.util.Helper.booleanToFuture( + failMsg = s"${NoViewPermission}can_get_counterparty", failCode = 403, cc = Some(cc)) { + view.allowed_actions.exists(_ == CAN_GET_COUNTERPARTY) + } + (counterparties, _) <- NewStyle.function.getCounterparties( + account.bankId, account.accountId, view.viewId, Some(cc)) + _ <- code.util.Helper.booleanToFuture(CreateOrUpdateCounterpartyMetadataError, 400, cc = Some(cc)) { + counterparties.forall { cp => + code.metadata.counterparties.Counterparties.counterparties.vend + .getOrCreateMetadata(account.bankId, account.accountId, cp.counterpartyId, cp.name) + .isDefined + } + } + } yield JSONFactory400.createCounterpartiesJson400(counterparties) + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, "getExplicitCounterpartiesForAccount", "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/counterparties", + "Get Counterparties (Explicit)", + s"""Get the Counterparties that have been explicitly created on the specified Account / View. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, counterpartiesJson400, + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, + $UserNoPermissionAccessView, ViewNotFound, UnknownError), + List(apiTagCounterparty, apiTagPSD2PIS, apiTagPsd2, apiTagAccount), None, + http4sPartialFunction = Some(getExplicitCounterpartiesForAccount)) + + // ─── getExplicitCounterpartyById (GET .../counterparties/COUNTERPARTY_ID) — v4 override ─ + + val getExplicitCounterpartyById: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "counterparties" / counterpartyIdStr => + EndpointHelpers.withView(req) { (_, account, view, cc) => + for { + _ <- code.util.Helper.booleanToFuture( + failMsg = s"${NoViewPermission}can_get_counterparty", failCode = 403, cc = Some(cc)) { + view.allowed_actions.exists(_ == CAN_GET_COUNTERPARTY) + } + (counterparty, _) <- NewStyle.function.getCounterpartyByCounterpartyId( + CounterpartyId(counterpartyIdStr), Some(cc)) + counterpartyMetadata <- NewStyle.function.getMetadata( + account.bankId, account.accountId, counterparty.counterpartyId, Some(cc)) + } yield JSONFactory400.createCounterpartyWithMetadataJson400(counterparty, counterpartyMetadata) + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, "getExplicitCounterpartyById", "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/counterparties/EXPLICIT_COUNTERPARTY_ID", + "Get Counterparty by Id (Explicit)", + s"""This endpoint returns a single Counterparty on an Account View specified by its COUNTERPARTY_ID. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, counterpartyWithMetadataJson400, + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, + $UserNoPermissionAccessView, UnknownError), + List(apiTagCounterparty, apiTagPSD2PIS, apiTagPsd2, apiTagCounterpartyMetaData), None, + http4sPartialFunction = Some(getExplicitCounterpartyById)) + + // ─── createExplicitCounterparty (POST .../counterparties → 201) — v4 override ─ + + val createExplicitCounterparty: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr / _ / "counterparties" => + EndpointHelpers.withViewCreated(req) { (user, account, view, cc) => + val bodyStr = cc.httpBody.getOrElse("") + for { + _ <- code.util.Helper.booleanToFuture(InvalidAccountIdFormat, cc = Some(cc)) { isValidID(account.accountId.value) } + _ <- code.util.Helper.booleanToFuture(InvalidBankIdFormat, cc = Some(cc)) { isValidID(account.bankId.value) } + postJson <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the PostCounterpartyJson400", 400, Some(cc)) { + net.liftweb.json.parse(bodyStr).extract[PostCounterpartyJson400] + } + _ <- code.util.Helper.booleanToFuture( + failMsg = s"$NoViewPermission can_add_counterparty. Please use a view with that permission or add the permission to this view.", + failCode = 403, cc = Some(cc)) { + view.allowed_actions.exists(_ == CAN_ADD_COUNTERPARTY) + } + (existingCp, _) <- Connector.connector.vend.checkCounterpartyExists( + postJson.name, account.bankId.value, account.accountId.value, view.viewId.value, Some(cc)) + _ <- code.util.Helper.booleanToFuture( + CounterpartyAlreadyExists.replace("value for BANK_ID or ACCOUNT_ID or VIEW_ID or NAME.", + s"COUNTERPARTY_NAME(${postJson.name}) for the BANK_ID(${account.bankId.value}) and ACCOUNT_ID(${account.accountId.value}) and VIEW_ID(${view.viewId.value})"), + cc = Some(cc)) { existingCp.isEmpty } + _ <- code.util.Helper.booleanToFuture( + s"$InvalidValueLength. The maximum length of `description` field is ${code.metadata.counterparties.MappedCounterparty.mDescription.maxLen}", + cc = Some(cc)) { postJson.description.length <= 36 } + _ <- code.util.Helper.booleanToFuture( + s"$InvalidISOCurrencyCode Current input is: '${postJson.currency}'", + cc = Some(cc)) { APIUtil.isValidCurrencyISOCode(postJson.currency) } + (_, _) <- + if (postJson.other_bank_routing_scheme.equalsIgnoreCase("OBP") + && postJson.other_account_routing_scheme.equalsIgnoreCase("OBP")) + for { + (_, c) <- NewStyle.function.getBank(BankId(postJson.other_bank_routing_address), Some(cc)) + r <- NewStyle.function.checkBankAccountExists(BankId(postJson.other_bank_routing_address), AccountId(postJson.other_account_routing_address), c) + } yield r + else if (postJson.other_bank_routing_scheme.equalsIgnoreCase("OBP") + && postJson.other_account_secondary_routing_scheme.equalsIgnoreCase("OBP")) + for { + (_, c) <- NewStyle.function.getBank(BankId(postJson.other_bank_routing_address), Some(cc)) + r <- NewStyle.function.checkBankAccountExists(BankId(postJson.other_bank_routing_address), AccountId(postJson.other_account_secondary_routing_address), c) + } yield r + else if (postJson.other_bank_routing_scheme.equalsIgnoreCase("ACCOUNT_NUMBER") + || postJson.other_bank_routing_scheme.equalsIgnoreCase("ACCOUNT_NO")) + NewStyle.function.getBankAccountByNumber( + if (postJson.other_bank_routing_address.isEmpty) None else Some(BankId(postJson.other_bank_routing_address)), + postJson.other_bank_routing_address, Some(cc)) + else Future.successful((Full(()), Some(cc))) + otherAccountRoutingSchemeOBPFormat = + if (postJson.other_account_routing_scheme.equalsIgnoreCase("AccountNo")) "ACCOUNT_NUMBER" + else org.apache.commons.lang3.StringUtils.upperCase( + net.liftweb.util.StringHelpers.snakify(postJson.other_account_routing_scheme)) + (counterparty, _) <- NewStyle.function.createCounterparty( + name = postJson.name, + description = postJson.description, + currency = postJson.currency, + createdByUserId = user.userId, + thisBankId = account.bankId.value, + thisAccountId = account.accountId.value, + thisViewId = view.viewId.value, + otherAccountRoutingScheme = otherAccountRoutingSchemeOBPFormat, + otherAccountRoutingAddress = postJson.other_account_routing_address, + otherAccountSecondaryRoutingScheme = net.liftweb.util.StringHelpers.snakify(postJson.other_account_secondary_routing_scheme).toUpperCase, + otherAccountSecondaryRoutingAddress = postJson.other_account_secondary_routing_address, + otherBankRoutingScheme = net.liftweb.util.StringHelpers.snakify(postJson.other_bank_routing_scheme).toUpperCase, + otherBankRoutingAddress = postJson.other_bank_routing_address, + otherBranchRoutingScheme = net.liftweb.util.StringHelpers.snakify(postJson.other_branch_routing_scheme).toUpperCase, + otherBranchRoutingAddress = postJson.other_branch_routing_address, + isBeneficiary = postJson.is_beneficiary, + bespoke = postJson.bespoke.map(b => CounterpartyBespoke(b.key, b.value)), + callContext = Some(cc) + ) + (counterpartyMetadata, _) <- NewStyle.function.getOrCreateMetadata( + account.bankId, account.accountId, counterparty.counterpartyId, postJson.name, Some(cc)) + } yield JSONFactory400.createCounterpartyWithMetadataJson400(counterparty, counterpartyMetadata) + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, "createCounterparty", "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/counterparties", + "Create Counterparty (Explicit)", + s"""Create Counterparty (Explicit) for an Account. + | + |${userAuthenticationMessage(true)}""", + postCounterpartyJson400, counterpartyWithMetadataJson400, + List($AuthenticatedUserIsRequired, InvalidAccountIdFormat, InvalidBankIdFormat, + InvalidJsonFormat, NoViewPermission, CounterpartyAlreadyExists, + InvalidValueLength, InvalidISOCurrencyCode, UnknownError), + List(apiTagCounterparty, apiTagPSD2PIS, apiTagPsd2, apiTagAccount), None, + http4sPartialFunction = Some(createExplicitCounterparty)) + // ─── allRoutes ──────────────────────────────────────────────────────────── private val allOwnRoutes: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req => @@ -1987,6 +2189,10 @@ object Http4s400 { .orElse(getScopes.run(req)) .orElse(addScope.run(req)) .orElse(getConsents.run(req)) + .orElse(updateAccountLabel.run(req)) + .orElse(getExplicitCounterpartiesForAccount.run(req)) + .orElse(getExplicitCounterpartyById.run(req)) + .orElse(createExplicitCounterparty.run(req)) } val allRoutesWithMiddleware: HttpRoutes[IO] = ResourceDocMiddleware.apply(resourceDocs)(allOwnRoutes) From d62398489d77de12231d708888af2ba72a10ab4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Wed, 13 May 2026 09:48:34 +0200 Subject: [PATCH 08/30] fix(http4s): cache request body once so bridge cascade can read it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symptom: POSTs that fell through to the bridge cascade (v400 → v310 → v300 → v220 → v210 …) were producing 500 errors with empty bodies in the downstream handler. TransactionRequestsTest had 21 such failures; v5_1_0.CounterpartyLimitTest and VRPConsentRequestTest had matching ones because their POSTs also bridge down to v2.1.0 createTransactionRequest. Root cause: http4s request bodies are single-shot fs2 streams. Http4sCallContextBuilder.fromRequest is called once per version's ResourceDocMiddleware (because the bridge wraps each version's wrappedRoutes), and the first call drained the stream. Every later fromRequest in the cascade saw an empty stream and stored cc.httpBody = None. By the time v2.1.0 createTransactionRequest's handler read `req.bodyText.compile.string`, the body was gone and JSON parsing of "" failed inside createTransactionRequestImpl — which then bubbled out as an uncaught exception → 500. Fix: 1. Http4sApp.baseServices now pre-reads the body once at the top of the chain, stashes the bytes in a new `cachedBodyKey` attribute, and rebuilds the request body stream via fs2.Stream.emits so that any downstream component that still reads `req.body` (e.g. the Lift fallback bridge) gets the same payload. 2. Http4sCallContextBuilder.fromRequest looks up `cachedBodyKey` first and short-circuits when present, instead of trying to re-drain the stream. 3. ResourceDocMiddleware re-attaches the cached body before calling the inner routes so the request keeps the cache through middleware → handler transitions. 4. Updated direct callers of `req.bodyText.compile.string` (Http4s210, Http4s220, Http4s300) to read from `cc.httpBody` instead, since the stream-reading code path is no longer the canonical source after the cache lands. Also: Http4s210 createTransactionRequest's "unknown transactionRequestType" branch used to throw a plain RuntimeException, which ErrorResponseConverter mapped to 500. It now throws an Exception whose message is the JSON-encoded APIFailureNewStyle with failCode = 400, matching the convention used elsewhere in the file. Still outstanding: a few v4 tests (TransactionRequestsTest 21, v5_1_0 CounterpartyLimitTest 3, VRPConsentRequestTest 3, FirehoseTest 3, etc.) need the corresponding v4 endpoints migrated to Http4s400 own-routes so they get v4-specific behavior (e.g. ACCOUNT/REFUND/SIMPLE/AGENT_CASH_WITHDRAWAL transaction request types, v4 firehose response shape). Those endpoints are on the next-batch list — this commit fixes the underlying bridge cascade so they aren't masked by 500s. Local regression check: AccountTest (13) + BankTests (3 own) + CounterpartyTest (7) + ScopesTest (10) + ConsentTests (5) — 38 total, all pass. --- .../code/api/util/http4s/Http4sApp.scala | 60 +++++++++++++------ .../code/api/util/http4s/Http4sSupport.scala | 22 ++++++- .../util/http4s/ResourceDocMiddleware.scala | 8 ++- .../scala/code/api/v2_1_0/Http4s210.scala | 12 +++- .../scala/code/api/v2_2_0/Http4s220.scala | 6 +- .../scala/code/api/v3_0_0/Http4s300.scala | 8 +-- 6 files changed, 84 insertions(+), 32 deletions(-) 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 68a7723c06..f13a016ceb 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 @@ -3,6 +3,7 @@ package code.api.util.http4s import cats.data.{Kleisli, OptionT} import cats.effect.IO import code.api.util.APIUtil +import code.api.util.http4s.Http4sRequestAttributes import com.openbankproject.commons.util.{ApiVersion, ScannedApiVersion} import org.http4s._ import org.typelevel.ci.CIString @@ -73,25 +74,50 @@ object Http4sApp { private val v700Routes: HttpRoutes[IO] = gate(ApiVersion.v7_0_0, code.api.v7_0_0.Http4s700.wrappedRoutesV700Services) /** - * Build the base HTTP4S routes with priority-based routing + * Build the base HTTP4S routes with priority-based routing. + * + * Body caching: http4s request bodies are single-shot streams. The first version's + * `ResourceDocMiddleware.fromRequest` consumes the body to build CallContext; any later + * bridge hop (v400→v310→v300→…→v210) that re-reads `req.bodyText` gets an empty stream + * and the eventual handler returns 500 because JSON parsing fails. We pre-read the body + * here and stash it in `cachedBodyKey`, so every downstream `fromRequest` reads from the + * attribute instead of the (now-drained) stream. GETs/DELETEs/HEADs/OPTIONS skip this. */ + private val noBodyMethods: Set[Method] = Set(Method.GET, Method.DELETE, Method.HEAD, Method.OPTIONS) + + private def cacheBodyOnce(req: Request[IO]): IO[Request[IO]] = { + if (req.attributes.lookup(Http4sRequestAttributes.cachedBodyKey).isDefined) IO.pure(req) + else if (noBodyMethods.contains(req.method)) IO.pure(req.withAttribute(Http4sRequestAttributes.cachedBodyKey, Option.empty[String])) + else req.body.compile.to(Array).map { bytes => + val cached: Option[String] = if (bytes.isEmpty) None else Some(new String(bytes, "UTF-8")) + // Replay the bytes on every subsequent stream read so the Lift fallback and any + // handler that still reads req.body sees the same payload. fs2.Stream.emits is + // pure — re-evaluating it yields a fresh stream of the same bytes. + req + .withBodyStream(fs2.Stream.emits(bytes).covary[IO]) + .withAttribute(Http4sRequestAttributes.cachedBodyKey, cached) + } + } + private def baseServices: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req: Request[IO] => - corsHandler.run(req) - .orElse(AppsPage.routes.run(req)) - .orElse(StatusPage.routes.run(req)) - .orElse(v500Routes.run(req)) - .orElse(v700Routes.run(req)) - .orElse(code.api.berlin.group.v2.Http4sBGv2.wrappedRoutes.run(req)) - .orElse(v400Routes.run(req)) - .orElse(v310Routes.run(req)) - .orElse(v300Routes.run(req)) - .orElse(v220Routes.run(req)) - .orElse(v210Routes.run(req)) - .orElse(v200Routes.run(req)) - .orElse(v140Routes.run(req)) - .orElse(v130Routes.run(req)) - .orElse(v121Routes.run(req)) - .orElse(Http4sLiftWebBridge.routes.run(req)) + OptionT.liftF(cacheBodyOnce(req)).flatMap { req => + corsHandler.run(req) + .orElse(AppsPage.routes.run(req)) + .orElse(StatusPage.routes.run(req)) + .orElse(v500Routes.run(req)) + .orElse(v700Routes.run(req)) + .orElse(code.api.berlin.group.v2.Http4sBGv2.wrappedRoutes.run(req)) + .orElse(v400Routes.run(req)) + .orElse(v310Routes.run(req)) + .orElse(v300Routes.run(req)) + .orElse(v220Routes.run(req)) + .orElse(v210Routes.run(req)) + .orElse(v200Routes.run(req)) + .orElse(v140Routes.run(req)) + .orElse(v130Routes.run(req)) + .orElse(v121Routes.run(req)) + .orElse(Http4sLiftWebBridge.routes.run(req)) + } } /** diff --git a/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala b/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala index 3ce9d2e745..5b78e6b66a 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 @@ -51,8 +51,20 @@ object Http4sRequestAttributes { * Vault key for storing CallContext in http4s request attributes. * CallContext contains request data and validated entities (user, bank, account, view, counterparty). */ - val callContextKey: Key[CallContext] = + val callContextKey: Key[CallContext] = Key.newKey[IO, CallContext].unsafeRunSync()(cats.effect.unsafe.IORuntime.global) + + /** + * Vault key for caching the (already-read) request body across bridge cascade hops. + * + * Http4s body streams are single-shot: `request.bodyText.compile.string` drains the stream. + * Without a cache, the first version's middleware reads the body to build the CallContext; + * subsequent bridge calls (v400→v310→v300→v220→v210) re-read an empty stream and the + * eventual handler sees no body. The first `fromRequest` call stores the body as + * `Some(String)` (POSTs/PUTs) or `None` (GETs/etc.) — later calls return it untouched. + */ + val cachedBodyKey: Key[Option[String]] = + Key.newKey[IO, Option[String]].unsafeRunSync()(cats.effect.unsafe.IORuntime.global) /** * Implicit class that adds .callContext accessor to Request[IO]. @@ -474,8 +486,12 @@ object Http4sCallContextBuilder { def fromRequest(request: Request[IO], apiVersion: String): IO[CallContext] = { val noBody = Set(Method.GET, Method.DELETE, Method.HEAD, Method.OPTIONS) for { - body <- if (noBody.contains(request.method)) IO.pure(None) - else request.bodyText.compile.string.map(s => if (s.isEmpty) None else Some(s)) + body <- request.attributes.lookup(Http4sRequestAttributes.cachedBodyKey) match { + case Some(cached) => IO.pure(cached) + case None => + if (noBody.contains(request.method)) IO.pure(None) + else request.bodyText.compile.string.map(s => if (s.isEmpty) None else Some(s)) + } } yield CallContext( url = request.uri.renderString, verb = request.method.name, 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 9f913782a3..038e522eea 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 @@ -135,6 +135,9 @@ object ResourceDocMiddleware extends MdcLoggable { } // Build initial CallContext from request OptionT.liftF(Http4sCallContextBuilder.fromRequest(req, apiVersionFromPath)).flatMap { cc => + // Cache the body so bridge-cascade hops (v400→v310→v300→…) don't re-read the now-empty stream. + // First read won the body in fromRequest; we replay it from cc.httpBody onwards. + val reqWithCachedBody = req.withAttribute(Http4sRequestAttributes.cachedBodyKey, cc.httpBody) ResourceDocMatcher.findResourceDoc(req.method.name, req.uri.path, resourceDocIndex) match { case Some(resourceDoc) if !endpointIsEnabled(resourceDoc) => // Disabled by api_disabled_endpoints / api_enabled_endpoints / api_disabled_versions / @@ -148,7 +151,7 @@ object ResourceDocMiddleware extends MdcLoggable { // auto-commit vendor connections (same as validation). All other methods // (POST/PUT/DELETE/PATCH) wrap routes.run in withBusinessDBTransaction. val work: IO[Option[Response[IO]]] = - validateOnly(req, resourceDoc, pathParams, ccWithDoc).flatMap { + validateOnly(reqWithCachedBody, resourceDoc, pathParams, ccWithDoc).flatMap { case Left(errorResponse) => IO.pure(Option(errorResponse)) case Right(enrichedReq) => @@ -166,7 +169,8 @@ object ResourceDocMiddleware extends MdcLoggable { case None => // No matching ResourceDoc: fallback to original route (NO transaction scope opened). // Attach the basic CC so req.callContext works in the inner route even without a doc match. - routes.run(req.withAttribute(Http4sRequestAttributes.callContextKey, cc)) + // Carry the cached body forward so the bridge cascade can still read it. + routes.run(reqWithCachedBody.withAttribute(Http4sRequestAttributes.callContextKey, cc)) } } } diff --git a/obp-api/src/main/scala/code/api/v2_1_0/Http4s210.scala b/obp-api/src/main/scala/code/api/v2_1_0/Http4s210.scala index 2620118cd4..3acc469088 100644 --- a/obp-api/src/main/scala/code/api/v2_1_0/Http4s210.scala +++ b/obp-api/src/main/scala/code/api/v2_1_0/Http4s210.scala @@ -165,8 +165,10 @@ object Http4s210 { val createTransactionRequest: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ POST -> `prefixPath` / "banks" / _ / "accounts" / _ / viewIdStr / "transaction-request-types" / transactionRequestTypeStr / "transaction-requests" => implicit val cc: CallContext = req.callContext + // Use cc.httpBody (cached by ResourceDocMiddleware via cachedBodyKey) instead of re-reading + // req.bodyText, which is empty after the bridge cascade has already consumed the stream. (for { - jsonBody <- req.bodyText.compile.string + jsonBody <- IO.pure(cc.httpBody.getOrElse("")) user <- IO.fromOption(cc.user.toOption)(new RuntimeException(AuthenticatedUserIsRequired)) account <- IO.fromOption(cc.bankAccount)(new RuntimeException(AccountNotFound)) result <- code.api.util.http4s.RequestScopeConnection.fromFuture( @@ -361,7 +363,10 @@ object Http4s210 { body, serialized, sharedChargePolicy, None, None, Some(cc)) } yield result case other => - Future.failed(new RuntimeException(s"$InvalidTransactionRequestType: '$transactionRequestTypeStr'")) + // Encode as APIFailureNewStyle JSON so ErrorResponseConverter maps it to 400, not 500. + // A plain RuntimeException would fall through to unknownErrorToResponse. + val af = code.api.APIFailureNewStyle(s"$InvalidTransactionRequestType: '$transactionRequestTypeStr'", 400, Some(cc.toLight)) + Future.failed(new Exception(net.liftweb.json.JsonAST.compactRender(net.liftweb.json.Extraction.decompose(af)))) } } yield JSONFactory210.createTransactionRequestWithChargeJSON(createdTransactionRequest) } @@ -374,7 +379,8 @@ object Http4s210 { val io = for { user <- IO.fromOption(cc.user.toOption)(new RuntimeException(AuthenticatedUserIsRequired)) account <- IO.fromOption(cc.bankAccount)(new RuntimeException(AccountNotFound)) - jsonBody <- req.bodyText.compile.string + // Use cached body from cc — req.bodyText is empty after upstream bridge cascade. + jsonBody <- IO.pure(cc.httpBody.getOrElse("")) result <- code.api.util.http4s.RequestScopeConnection.fromFuture( answerChallengeImpl(jsonBody, user, account, transactionRequestTypeStr, transReqIdStr, cc)) } yield result diff --git a/obp-api/src/main/scala/code/api/v2_2_0/Http4s220.scala b/obp-api/src/main/scala/code/api/v2_2_0/Http4s220.scala index 3a89873c1d..709e47fdd7 100644 --- a/obp-api/src/main/scala/code/api/v2_2_0/Http4s220.scala +++ b/obp-api/src/main/scala/code/api/v2_2_0/Http4s220.scala @@ -116,7 +116,7 @@ object Http4s220 { bank <- IO.fromOption(cc.bank)(new RuntimeException(BankNotFound)) rawBox <- IO.fromFuture(IO(Connector.connector.vend.checkBankAccountExists(bank.bankId, AccountId(accountIdStr), Some(cc)).map(_._1))) account <- IO(unboxFullOrFail(rawBox, Some(cc), BankAccountNotFound)) - body <- req.bodyText.compile.string + body <- IO.pure(cc.httpBody.getOrElse("")) result <- code.api.util.http4s.RequestScopeConnection.fromFuture( createViewImpl(user, account, body, cc)) } yield result @@ -175,7 +175,7 @@ object Http4s220 { val io = for { user <- IO.fromOption(cc.user.toOption)(new RuntimeException(AuthenticatedUserIsRequired)) account <- IO.fromOption(cc.bankAccount)(new RuntimeException(AccountNotFound)) - body <- req.bodyText.compile.string + body <- IO.pure(cc.httpBody.getOrElse("")) result <- code.api.util.http4s.RequestScopeConnection.fromFuture( updateViewImpl(user, account, ViewId(viewIdStr), body, cc)) } yield result @@ -715,7 +715,7 @@ object Http4s220 { user <- IO.fromOption(cc.user.toOption)(new RuntimeException(AuthenticatedUserIsRequired)) account <- IO.fromOption(cc.bankAccount)(new RuntimeException(AccountNotFound)) view <- IO.fromOption(cc.view)(new RuntimeException(ViewNotFound)) - body <- req.bodyText.compile.string + body <- IO.pure(cc.httpBody.getOrElse("")) result <- code.api.util.http4s.RequestScopeConnection.fromFuture( createCounterpartyImpl(user, account, view, body, cc)) } yield result diff --git a/obp-api/src/main/scala/code/api/v3_0_0/Http4s300.scala b/obp-api/src/main/scala/code/api/v3_0_0/Http4s300.scala index 6a61ade5b1..ced4ec875e 100644 --- a/obp-api/src/main/scala/code/api/v3_0_0/Http4s300.scala +++ b/obp-api/src/main/scala/code/api/v3_0_0/Http4s300.scala @@ -121,7 +121,7 @@ object Http4s300 { bank <- IO.fromOption(cc.bank)(new RuntimeException(BankNotFound)) rawBox <- IO.fromFuture(IO(Connector.connector.vend.checkBankAccountExists(bank.bankId, AccountId(accountIdStr), Some(cc)).map(_._1))) account <- IO(unboxFullOrFail(rawBox, Some(cc), BankAccountNotFound, 404)) - body <- req.bodyText.compile.string + body <- IO.pure(cc.httpBody.getOrElse("")) result <- code.api.util.http4s.RequestScopeConnection.fromFuture( createViewImpl300(user, account, body, cc)) } yield result @@ -171,7 +171,7 @@ object Http4s300 { val io = for { user <- IO.fromOption(cc.user.toOption)(new RuntimeException(AuthenticatedUserIsRequired)) account <- IO.fromOption(cc.bankAccount)(new RuntimeException(AccountNotFound)) - body <- req.bodyText.compile.string + body <- IO.pure(cc.httpBody.getOrElse("")) result <- code.api.util.http4s.RequestScopeConnection.fromFuture( updateViewImpl300(user, account, ViewId(viewIdStr), body, cc)) } yield result @@ -553,7 +553,7 @@ object Http4s300 { implicit val cc: CallContext = req.callContext val io = for { user <- IO.fromOption(cc.user.toOption)(new RuntimeException(AuthenticatedUserIsRequired)) - bodyText <- req.bodyText.compile.string + bodyText <- IO.pure(cc.httpBody.getOrElse("")) result <- code.api.util.http4s.RequestScopeConnection.fromFuture { for { _ <- code.util.Helper.booleanToFuture(ElasticSearchDisabled, cc = Some(cc)) { esw.isEnabled() } @@ -597,7 +597,7 @@ object Http4s300 { implicit val cc: CallContext = req.callContext val io = for { user <- IO.fromOption(cc.user.toOption)(new RuntimeException(AuthenticatedUserIsRequired)) - bodyText <- req.bodyText.compile.string + bodyText <- IO.pure(cc.httpBody.getOrElse("")) result <- code.api.util.http4s.RequestScopeConnection.fromFuture { for { _ <- code.util.Helper.booleanToFuture(ElasticSearchDisabled, cc = Some(cc)) { esw.isEnabled() } From e3dfb9ee5e896fd5384fd979d227f6c760284741 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Wed, 13 May 2026 11:02:43 +0200 Subject: [PATCH 09/30] fix(http4s): exclude known all-caps literals from URL-template wildcards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `isTemplateVariable` treated every all-caps segment (uppercase letters + underscore + digits) as a template variable. That collapsed real literals like SANDBOX_TAN, COUNTERPARTY, SEPA, FREE_FORM, ACCOUNT, CARD into wildcards, so the v2.1.0 ResourceDoc /banks/BANK_ID/accounts/ACCOUNT_ID/GRANT_VIEW_ID/transaction-request-types/SANDBOX_TAN/transaction-requests matched *any* transaction-request-type URL, including v4-only ACCOUNT and CARD. The middleware then auth-checked, routed to v2.1.0's handler, which doesn't know those types — the request was either returned with the wrong status or 500'd inside v2.1.0's logic, instead of falling through the bridge cascade to the v4 Lift endpoint that handles it. Fix: keep the original "looks all-caps → wildcard" rule but exclude an explicit set of known literals (the 10 trans-req types listed in v4 plus the 4 SCA-method values EMAIL/SMS/IMPLICIT/NOT_EMAIL_NEITHER_SMS). This preserves the non-standard placeholder convention (NEW_ACCOUNT_ID, GRANT_VIEW_ID, FIREHOSE_BANK_ID, EXPLICIT_COUNTERPARTY_ID, SYS_VIEW_ID, SCA_METHOD, TRANSACTION_REQUEST_TYPE, …) without an allow-list. Also: v2.1.0's createTransactionRequest now guards its route pattern against unsupported types so the bridge-cascade fall-through is robust even if a new literal slips past the matcher. The catch-all ResourceDoc for TRANSACTION_REQUEST_TYPE in v2.1.0 has been removed; the four type-specific docs (SANDBOX_TAN, COUNTERPARTY, SEPA, FREE_FORM) are sufficient and the unknown-type branch in createTransactionRequestImpl now encodes its failure as an APIFailureNewStyle JSON so unreachable errors map to 400 rather than 500. Local results: - TransactionRequestsTest: 21 → 16 failures (5 more pass — the SEPA/ COUNTERPARTY/FREE_FORM/SANDBOX_TAN scenarios that were previously hijacked; the 16 remaining are all CARD or v4-only types that need v4's own-route migration). - v3_1_0.ConsentTest: still 0 failures (SCA_METHOD still works because it ends with _METHOD — actually it stays a wildcard because it's not in the literal allow-list, and EMAIL/SMS/IMPLICIT are listed precisely because they would otherwise fail the matcher). - v4 BankTests/AccountTest/ScopesTest/ConsentTests + v5 + v7 Http4s700RoutesTest: 146 tests, all pass. Outstanding for the remaining 16 TransactionRequestsTest failures plus v5.1 CounterpartyLimitTest/VRPConsentRequestTest/TransactionRequestTest and v4 MakerCheckerTransactionRequestTest/FirehoseTest: these all want v4-specific behaviour the bridge cascade can't deliver because v4 has no own-route yet. Migrating createTransactionRequest + answerTransactionRequestChallenge + getFirehoseAccountsAtOneBank to Http4s400 is the next step. --- .../code/api/util/http4s/Http4sSupport.scala | 31 ++++++++++++++++- .../scala/code/api/v2_1_0/Http4s210.scala | 33 ++++++++++--------- 2 files changed, 48 insertions(+), 16 deletions(-) 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 5b78e6b66a..96924d9a6c 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 @@ -677,8 +677,37 @@ object ResourceDocMatcher extends code.util.Helper.MdcLoggable { /** * Check if a template segment is a variable (uppercase) */ + /** + * All-caps URL-segment literals that historically broke the matcher. + * + * `isTemplateVariable` originally returned true for every all-caps + underscore + + * digit segment. That made literals like `SANDBOX_TAN`, `ACCOUNT`, `SEPA` etc. + * indistinguishable from real placeholders like `BANK_ID`, so a ResourceDoc URL + * `/banks/BANK_ID/.../transaction-request-types/SANDBOX_TAN/transaction-requests` + * matched any trans-req-type URL — including v4-only `ACCOUNT` — and the v4 + * request never reached the Lift fallback that knows how to handle it. + * + * We special-case the known literal segments. Anything else stays a wildcard so + * the existing non-standard placeholder convention (NEW_ACCOUNT_ID, GRANT_VIEW_ID, + * FIREHOSE_BANK_ID, EXPLICIT_COUNTERPARTY_ID, SYS_VIEW_ID, …) keeps working + * without an explicit allow-list. + * + * Add a value here when a new path uses an all-caps literal (e.g. a new + * transaction-request type or SCA method). + */ + private val literalAllCapsSegments: Set[String] = Set( + // transaction-request types + "SANDBOX_TAN", "COUNTERPARTY", "SEPA", "FREE_FORM", + "ACCOUNT", "ACCOUNT_OTP", "REFUND", "SIMPLE", + "AGENT_CASH_WITHDRAWAL", "CARD", + // SCA methods (POST /banks/BANK_ID/my/consents/{EMAIL|SMS|IMPLICIT}) + "EMAIL", "SMS", "IMPLICIT", "NOT_EMAIL_NEITHER_SMS" + ) + private def isTemplateVariable(segment: String): Boolean = { - segment.nonEmpty && segment.forall(c => c.isUpper || c == '_' || c.isDigit) + segment.nonEmpty && + segment.forall(c => c.isUpper || c == '_' || c.isDigit) && + !literalAllCapsSegments.contains(segment) } /** diff --git a/obp-api/src/main/scala/code/api/v2_1_0/Http4s210.scala b/obp-api/src/main/scala/code/api/v2_1_0/Http4s210.scala index 3acc469088..7040d9ca16 100644 --- a/obp-api/src/main/scala/code/api/v2_1_0/Http4s210.scala +++ b/obp-api/src/main/scala/code/api/v2_1_0/Http4s210.scala @@ -162,8 +162,16 @@ object Http4s210 { // checkAuthorisationToCreateTransactionRequest handles that internally and // supports canCreateAnyTransactionRequest role bypass. + // The 4 transaction request types this version knows how to handle. v4.0.0 adds more + // (ACCOUNT, ACCOUNT_OTP, REFUND, SIMPLE, AGENT_CASH_WITHDRAWAL, CARD); the route guard + // below keeps unsupported types out of v2.1.0's handler so they fall through the + // bridge cascade to the v4 Lift endpoint that knows the type. + private val v210SupportedTransactionRequestTypes: Set[String] = + Set("SANDBOX_TAN", "COUNTERPARTY", "SEPA", "FREE_FORM") + val createTransactionRequest: HttpRoutes[IO] = HttpRoutes.of[IO] { - case req @ POST -> `prefixPath` / "banks" / _ / "accounts" / _ / viewIdStr / "transaction-request-types" / transactionRequestTypeStr / "transaction-requests" => + case req @ POST -> `prefixPath` / "banks" / _ / "accounts" / _ / viewIdStr / "transaction-request-types" / transactionRequestTypeStr / "transaction-requests" + if v210SupportedTransactionRequestTypes.contains(transactionRequestTypeStr) => implicit val cc: CallContext = req.callContext // Use cc.httpBody (cached by ResourceDocMiddleware via cachedBodyKey) instead of re-reading // req.bodyText, which is empty after the bridge cascade has already consumed the stream. @@ -231,18 +239,10 @@ object Http4s210 { Some(List(canCreateAnyTransactionRequest)), http4sPartialFunction = Some(createTransactionRequest)) - // Catch-all: handles unknown/invalid transaction request types → 400 from createTransactionRequestImpl - resourceDocs += ResourceDoc( - null, implementedInApiVersion, nameOf(createTransactionRequest), "POST", - "/banks/BANK_ID/accounts/ACCOUNT_ID/GRANT_VIEW_ID/transaction-request-types/TRANSACTION_REQUEST_TYPE/transaction-requests", - "Create Transaction Request", - s"""Create a Transaction Request of the type specified in the URL. - | - |${userAuthenticationMessage(true)}""", - transactionRequestBodyJsonV200, transactionRequestWithChargeJSON210, - commonTxReqErrors, List(apiTagTransactionRequest), - Some(List(canCreateAnyTransactionRequest)), - http4sPartialFunction = Some(createTransactionRequest)) + // (Catch-all ResourceDoc for TRANSACTION_REQUEST_TYPE removed: it caused the v2.1.0 + // middleware to auth-check and route every type, including v4-only ones, then return + // 400. The four specific docs above cover what v2.1.0 actually supports; v4-only + // types miss the route guard and fall through to the Lift bridge.) private def createTransactionRequestImpl( jsonBody: String, @@ -363,8 +363,11 @@ object Http4s210 { body, serialized, sharedChargePolicy, None, None, Some(cc)) } yield result case other => - // Encode as APIFailureNewStyle JSON so ErrorResponseConverter maps it to 400, not 500. - // A plain RuntimeException would fall through to unknownErrorToResponse. + // Should be unreachable: the route guard restricts the match to the 4 + // supported types above, so this branch only fires if a new type is + // added to the guard without the corresponding case. Encoded as + // APIFailureNewStyle JSON so ErrorResponseConverter maps it to 400, + // not 500. val af = code.api.APIFailureNewStyle(s"$InvalidTransactionRequestType: '$transactionRequestTypeStr'", 400, Some(cc.toLight)) Future.failed(new Exception(net.liftweb.json.JsonAST.compactRender(net.liftweb.json.Extraction.decompose(af)))) } From 77acfe379027d216e318568c419b4498021765d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Wed, 13 May 2026 11:31:50 +0200 Subject: [PATCH 10/30] fix(http4s): let v4-only trans-req types reach Lift for answer-challenge too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pair with the earlier route-guard fix on v2.1.0 createTransactionRequest. answerTransactionRequestChallenge had the same problem: its single catch-all ResourceDoc used a TRANSACTION_REQUEST_TYPE template variable that still matched v4-only types (ACCOUNT, ACCOUNT_OTP, REFUND, SIMPLE, AGENT_CASH_WITHDRAWAL, CARD). The middleware then auth-checked, discovered the route guard rejected the type, and returned 404 from `getOrElseF(NotFound)` instead of letting the request fall through. Fix: 1. Add the same route guard to answerTransactionRequestChallenge so it only matches the four v2.1.0-supported types. 2. Replace the single TRANSACTION_REQUEST_TYPE catch-all ResourceDoc with four type-specific docs (one per supported type). Unknown types now miss the ResourceDoc lookup entirely, the middleware returns None, and the bridge cascade walks the request down to the Lift fallback where APIMethods400.answerTransactionRequestChallenge handles the v4-specific challenge logic (maker-checker, ChallengeJsonV400 shape, attribute attachment). Local results across the relevant trans-req-related suites: TransactionRequestsTest, MakerCheckerTransactionRequestTest, CounterpartyLimitTest, VRPConsentRequestTest, TransactionRequestTest → 43 passing / 15 failing (was 27 failing pre-fix, 21 before the matcher fix). The remaining 15 are about challenges not appearing in the v4 response body for the test's high-amount scenarios — Lift's v4 createTransactionRequest is reached and returns 201, but `body.challenges` comes back empty. That's a separate issue (presumably a connector/threshold config) not caused by my changes. Regressions: none. AccountTest + BankTests + ScopesTest + ConsentTests + v3_1_0 ConsentTest + AccountAttributeTest + Http4s700RoutesTest: 148 tests, all pass. --- .../scala/code/api/v2_1_0/Http4s210.scala | 45 +++++++++++++------ 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/obp-api/src/main/scala/code/api/v2_1_0/Http4s210.scala b/obp-api/src/main/scala/code/api/v2_1_0/Http4s210.scala index 7040d9ca16..c2a1792c36 100644 --- a/obp-api/src/main/scala/code/api/v2_1_0/Http4s210.scala +++ b/obp-api/src/main/scala/code/api/v2_1_0/Http4s210.scala @@ -377,7 +377,14 @@ object Http4s210 { // ─── answerTransactionRequestChallenge ──────────────────────────────────── val answerTransactionRequestChallenge: HttpRoutes[IO] = HttpRoutes.of[IO] { - case req @ POST -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "transaction-request-types" / transactionRequestTypeStr / "transaction-requests" / transReqIdStr / "challenge" => + // Same guard as createTransactionRequest: v4 trans-req types (ACCOUNT, ACCOUNT_OTP, + // REFUND, SIMPLE, AGENT_CASH_WITHDRAWAL, CARD, …) need v4's answer-challenge + // logic (maker-checker, ChallengeJsonV400 shape, attribute attachment). Routing + // them through this handler returns the v2.1.0 shape and skips v4 validation, + // so the test sees "400 did not equal 202". Let unknown types fall through to + // the Lift fallback where APIMethods400.answerTransactionRequestChallenge runs. + case req @ POST -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "transaction-request-types" / transactionRequestTypeStr / "transaction-requests" / transReqIdStr / "challenge" + if v210SupportedTransactionRequestTypes.contains(transactionRequestTypeStr) => implicit val cc: CallContext = req.callContext val io = for { user <- IO.fromOption(cc.user.toOption)(new RuntimeException(AuthenticatedUserIsRequired)) @@ -395,18 +402,30 @@ object Http4s210 { } } - resourceDocs += ResourceDoc( - null, implementedInApiVersion, nameOf(answerTransactionRequestChallenge), "POST", - "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transaction-request-types/TRANSACTION_REQUEST_TYPE/transaction-requests/TRANSACTION_REQUEST_ID/challenge", - "Answer Transaction Request Challenge", - """In Sandbox mode, any string that can be converted to a positive integer will be accepted as an answer.""", - challengeAnswerJSON, transactionRequestWithChargeJson, - List(AuthenticatedUserIsRequired, InvalidBankIdFormat, InvalidAccountIdFormat, InvalidJsonFormat, - BankNotFound, UserNoPermissionAccessView, TransactionRequestStatusNotInitiated, - TransactionRequestTypeHasChanged, InvalidTransactionRequestChallengeId, - AllowedAttemptsUsedUp, TransactionDisabled, UnknownError), - List(apiTagTransactionRequest, apiTagPSD2PIS, apiTagPsd2), None, - http4sPartialFunction = Some(answerTransactionRequestChallenge)) + // Register one ResourceDoc per supported type rather than a single + // TRANSACTION_REQUEST_TYPE wildcard. The wildcard would also match v4-only + // types (ACCOUNT, ACCOUNT_OTP, REFUND, SIMPLE, AGENT_CASH_WITHDRAWAL, CARD), + // which the route guard then rejects — leaving the middleware to return 404 + // instead of letting the request fall through to the Lift fallback that + // actually handles those types. + private val answerChallengeCommonErrors = List( + AuthenticatedUserIsRequired, InvalidBankIdFormat, InvalidAccountIdFormat, InvalidJsonFormat, + BankNotFound, UserNoPermissionAccessView, TransactionRequestStatusNotInitiated, + TransactionRequestTypeHasChanged, InvalidTransactionRequestChallengeId, + AllowedAttemptsUsedUp, TransactionDisabled, UnknownError) + + private val answerChallengeTags = List(apiTagTransactionRequest, apiTagPSD2PIS, apiTagPsd2) + + v210SupportedTransactionRequestTypes.foreach { trType => + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(answerTransactionRequestChallenge) + trType.toLowerCase.capitalize, "POST", + s"/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transaction-request-types/$trType/transaction-requests/TRANSACTION_REQUEST_ID/challenge", + s"Answer Transaction Request Challenge ($trType)", + """In Sandbox mode, any string that can be converted to a positive integer will be accepted as an answer.""", + challengeAnswerJSON, transactionRequestWithChargeJson, + answerChallengeCommonErrors, answerChallengeTags, None, + http4sPartialFunction = Some(answerTransactionRequestChallenge)) + } private def answerChallengeImpl( jsonBody: String, From 5b774e02ad7c09b0121f091d3cbbac57a8d6ad32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Wed, 13 May 2026 11:39:05 +0200 Subject: [PATCH 11/30] feat(v4.0.0): migrate getFirehoseAccountsAtOneBank to Http4s400 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /banks/BANK_ID/firehose/accounts/views/VIEW_ID was a bridge-hijack override: v4 returns ModeratedFirehoseAccountsJsonV400 (with `accounts`, `product_code`, …) but the bridge cascade routed firehose calls to Http4s300.getFirehoseAccountsAtOneBank which returns ModeratedCoreAccountsJsonV300. FirehoseTest extracts the v4 shape and failed with `MappingException: No usable value for accounts / product_code`. Migration mirrors the v3.0.0 implementation almost line-for-line — same auth + role check, same bank/view lookup, same firehose-account fetching and per-account moderation — only the final response uses `JSONFactory400.createFirehoseCoreBankAccountJSON` which returns the ModeratedFirehoseAccountsJsonV400 shape. ResourceDoc keeps `FIREHOSE_BANK_ID` / `FIREHOSE_VIEW_ID` in the URL template so middleware skips bank/view validation; the in-handler booleanToFuture checks fire in the order tests expect: 1. AccountFirehoseNotAllowedOnThisInstance → 400 2. UserHasMissingRoles (canUseAccountFirehose or *AtAnyBank) → 403 3. BankNotFound → 404 Local results: FirehoseTest 8/8 pass (3 prior failures resolved), AccountTest 13/13, BankTests 3/3 — 24 tests, no regressions. Override audit: 34/37 migrated; 3 remain (createConsumer, createTransactionRequestCard, and the underlying createTransactionRequest which needs a bigger refactor to move the SS thread-globals out of LocalMappedConnectorInternal — for now we rely on the matcher fix + v2.1.0 route guards to let v4 trans-req types fall through to the Lift fallback, which works for ACCOUNT/AGENT_CASH_WITHDRAWAL but leaves the "challenges array empty" test assertions still failing). --- .../scala/code/api/v4_0_0/Http4s400.scala | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala b/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala index dda7087a9d..bb95f3cb66 100644 --- a/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala +++ b/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala @@ -13,6 +13,7 @@ import code.api.util.Glossary import code.api.Constant import code.api.dynamic.endpoint.helper.DynamicEndpointHelper import code.api.dynamic.entity.helper.DynamicEntityInfo +import code.api.util.{ApiRole => ApiRoleObj} import code.api.util.newstyle.ViewNewStyle import code.users.Users import code.views.Views @@ -28,6 +29,7 @@ import code.dynamicEntity.DynamicEntityCommons import code.bankconnectors.Connector import code.entitlement.Entitlement import code.model.BankX +import code.model._ // implicit BankAccountExtended → moderatedBankAccount import code.model.dataAccess.AuthUser import code.ratelimiting.RateLimitingDI import com.github.dwickern.macros.NameOf.nameOf @@ -2131,6 +2133,73 @@ object Http4s400 { List(apiTagCounterparty, apiTagPSD2PIS, apiTagPsd2, apiTagAccount), None, http4sPartialFunction = Some(createExplicitCounterparty)) + // ─── getFirehoseAccountsAtOneBank ───────────────────────────────────────── + // v4 override of Http4s300: same business logic, but the response is built by + // JSONFactory400.createFirehoseCoreBankAccountJSON which returns + // ModeratedFirehoseAccountsJsonV400 (with `accounts`/`product_code` etc.) instead + // of v3.0.0's ModeratedCoreAccountsJsonV300 shape that FirehoseTest can't parse. + + val getFirehoseAccountsAtOneBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankIdStr / "firehose" / "accounts" / "views" / viewIdStr => + EndpointHelpers.withUser(req) { (user, cc) => + val roles = ApiRoleObj.canUseAccountFirehose :: canUseAccountFirehoseAtAnyBank :: Nil + val roleMsg = UserHasMissingRoles + roles.mkString(" or ") + for { + _ <- code.util.Helper.booleanToFuture(AccountFirehoseNotAllowedOnThisInstance, cc = Some(cc)) { + allowAccountFirehose + } + _ <- code.util.Helper.booleanToFuture(roleMsg, failCode = 403, cc = Some(cc)) { + APIUtil.hasAtLeastOneEntitlement(bankIdStr, user.userId, roles) + } + (bank, _) <- NewStyle.function.getBank(BankId(bankIdStr), Some(cc)) + view <- ViewNewStyle.checkViewAccessAndReturnView( + ViewId(viewIdStr), BankIdAccountId(bank.bankId, AccountId("")), Some(user), Some(cc)) + availableBankIdAccountIdList <- Future { + Views.views.vend.getAllFirehoseAccounts(bank.bankId).map(a => BankIdAccountId(a.bankId, a.accountId)) + } + params = req.uri.query.multiParams.filterNot { case (k, _) => k == PARAM_TIMESTAMP || k == PARAM_LOCALE } + filteredList <- if (params.isEmpty) { + Future.successful(availableBankIdAccountIdList) + } else { + code.accountattribute.AccountAttributeX.accountAttributeProvider.vend + .getAccountIdsByParams(bank.bankId, params.map { case (k, vs) => k -> vs.toList }) + .map { boxedAccountIds => + val accountIds = boxedAccountIds.getOrElse(Nil) + availableBankIdAccountIdList.filter(ba => accountIds.contains(ba.accountId.value)) + } + } + moderatedAccounts: List[ModeratedBankAccount] = for { + bankIdAccountId <- filteredList + (bankAccount, callContext) <- Connector.connector.vend + .getBankAccountLegacy(bankIdAccountId.bankId, bankIdAccountId.accountId, Some(cc)) ?~! + s"$BankAccountNotFound Current Bank_Id(${bankIdAccountId.bankId}), Account_Id(${bankIdAccountId.accountId})" + moderatedAccount <- bankAccount.moderatedBankAccount(view, bankIdAccountId, Full(user), Some(cc)) + } yield moderatedAccount + (accountAttributes: Option[List[AccountAttribute]], _) <- if (moderatedAccounts.nonEmpty && params.nonEmpty) { + val futures = filteredList.map { bankIdAccount => + NewStyle.function.getAccountAttributesByAccount(bankIdAccount.bankId, bankIdAccount.accountId, Some(cc)) + } + Future.reduceLeft(futures)((r, t) => r.copy(_1 = r._1 ::: t._1)) + .map(it => (Some(it._1), it._2)) + } else { + Future.successful((None, Some(cc))) + } + } yield JSONFactory400.createFirehoseCoreBankAccountJSON(moderatedAccounts, accountAttributes) + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getFirehoseAccountsAtOneBank), "GET", + "/banks/FIREHOSE_BANK_ID/firehose/accounts/views/FIREHOSE_VIEW_ID", + "Get Firehose Accounts at Bank", + s"""Get all Accounts at a Bank that have a Firehose View. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, moderatedFirehoseAccountsJsonV400, + List(AuthenticatedUserIsRequired, AccountFirehoseNotAllowedOnThisInstance, UnknownError), + List(apiTagAccountFirehose, apiTagAccount, apiTagFirehoseData, apiTagAccount), None, + http4sPartialFunction = Some(getFirehoseAccountsAtOneBank)) + // ─── allRoutes ──────────────────────────────────────────────────────────── private val allOwnRoutes: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req => @@ -2193,6 +2262,7 @@ object Http4s400 { .orElse(getExplicitCounterpartiesForAccount.run(req)) .orElse(getExplicitCounterpartyById.run(req)) .orElse(createExplicitCounterparty.run(req)) + .orElse(getFirehoseAccountsAtOneBank.run(req)) } val allRoutesWithMiddleware: HttpRoutes[IO] = ResourceDocMiddleware.apply(resourceDocs)(allOwnRoutes) From b673da1a351b9524381875ad74dea8785238fe92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Wed, 13 May 2026 12:32:39 +0200 Subject: [PATCH 12/30] feat(v4.0.0): migrate createTransactionRequest to Http4s400 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Last remaining hijack on the trans-request happy path. Before: v2.1.0 ResourceDocs for SANDBOX_TAN/COUNTERPARTY/SEPA/FREE_FORM were literal matches (after the matcher fix), so bridge-cascaded v4 URLs landed in v2.1.0's handler and returned the v2.1.0 response shape (missing the v4-only `challenges: List[ChallengeJsonV400]` field). v4- only types like ACCOUNT/AGENT_CASH_WITHDRAWAL/CARD already fell through to Lift, but the test still failed for the four overlapping types. The new route in Http4s400 owns ALL trans-req-type POSTs at v4 (no guard — also catches unknown types so the test's "invalidTransactionRequestType" scenario gets 400 from the connector rather than a 404 fall-through to Lift). Implementation: delegate to `LocalMappedConnectorInternal.createTransactionRequest` — the same helper the Lift `lazy val createTransactionRequestAccount` (and 7 sibling lazy vals) call. The helper reads `SS.user` (Lift thread- global) on its first line; we initialize it via `APIUtil.SS.init` so the synchronous read inside the for-comprehension captures the http4s cc.user before any flatMap. SS.init also needs a `View` instance, but the connector itself only consumes ViewId, so a `systemView(viewIdStr)` lookup is enough. ResourceDoc uses `GRANT_VIEW_ID` (non-standard) so middleware skips view-access validation. The connector's `checkAuthorisationToCreateTransactionRequest` returns 400 InsufficientAuthorisationToCreateTransactionRequest if the user has neither the role nor view permission, matching the Lift v4 behaviour the test expects. Local TransactionRequestsTest results: Before any of today's work : 21 / 34 failures After matcher + body-cache : 16 / 34 failures After v210 route guards : 7 / 34 failures After this commit : 7 / 34 failures (27 pass) The remaining 7 are all `400 did not equal 202` on the `answerTransactionRequestChallenge` step of the same flow — v4 challenges for SANDBOX_TAN/COUNTERPARTY/SEPA/FREE_FORM still bridge to v2.1.0's answer-challenge handler which doesn't recognize the v4 ChallengeAnswerJson400 shape and 400s. Fix needs a similar v4 own-route for the challenge endpoint; the body is ~280 lines so it's a separate commit. Override audit: 36/37 migrated; 1 remains (createConsumer). --- .../scala/code/api/v4_0_0/Http4s400.scala | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala b/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala index bb95f3cb66..6eacb24527 100644 --- a/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala +++ b/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala @@ -2200,6 +2200,85 @@ object Http4s400 { List(apiTagAccountFirehose, apiTagAccount, apiTagFirehoseData, apiTagAccount), None, http4sPartialFunction = Some(getFirehoseAccountsAtOneBank)) + // ─── createTransactionRequest (POST /banks/.../trans-request-types/TYPE/trans-requests → 201) ─ + // + // v4 supports a wider set of trans-request types than v2.1.0 — and even for the + // four that overlap (SANDBOX_TAN, COUNTERPARTY, SEPA, FREE_FORM) the v4 response + // shape differs: it has a `challenges: List[ChallengeJsonV400]` field that the + // v2.1.0 shape doesn't. The bridge cascade would otherwise route SEPA / COUNTERPARTY + // / FREE_FORM / SANDBOX_TAN URLs into the v2.1.0 handler and return the v2.1.0 JSON + // (no `challenges`), failing every TransactionRequestsTest assertion of the form + // `body.challenges.size != 0`. + // + // All v4 types delegate to the same connector helper — + // `LocalMappedConnectorInternal.createTransactionRequest` — which depends on + // `SS.user` (Lift's thread-globals). We wrap the call in `SS.init` so the helper's + // first synchronous read of `SS.user` captures the cc.user, then the Future chain + // runs normally on any thread. + + val createTransactionRequest: HttpRoutes[IO] = HttpRoutes.of[IO] { + // GRANT_VIEW_ID in the ResourceDoc URL → middleware skips view validation. + // Lift's v4 endpoint does no view-access check upfront; it lets + // `checkAuthorisationToCreateTransactionRequest` inside the connector decide + // (returns 400 InsufficientAuthorisationToCreateTransactionRequest if the user + // has neither the role nor view permission). `withViewCreated` would 403 before + // the connector ran, contradicting the test expectation. + // + // The route matches *any* trans-req-type segment (no guard) so: + // - v4-supported types route to the connector below. + // - Unknown types (e.g. "invalidTransactionRequestType") still hit this route + // and get a 400 from the connector's `transactionRequests_supported_types` + // check, matching the v210 catch-all behavior the test depends on. Without + // this catch, unknown types fall through to Lift → 404. + // + // Use `executeFutureCreated` so the response is 201; extract user/bank/account + // from cc manually (middleware populates them via the BANK_ID and ACCOUNT_ID + // template segments). + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr / viewIdStr / "transaction-request-types" / transactionRequestTypeStr / "transaction-requests" => + implicit val cc: CallContext = req.callContext + EndpointHelpers.executeFutureCreated(req) { + val bodyStr = cc.httpBody.getOrElse("") + for { + user <- Future { cc.user.openOrThrowException(AuthenticatedUserIsRequired) } + bank <- Future { cc.bank.getOrElse(throw new RuntimeException(BankNotFound)) } + account <- Future { cc.bankAccount.getOrElse(throw new RuntimeException(BankAccountNotFound)) } + json <- NewStyle.function.tryons( + s"$InvalidJsonFormat Empty or invalid request body.", 400, Some(cc)) { + net.liftweb.json.parse(bodyStr) + } + transactionRequestType = TransactionRequestType(transactionRequestTypeStr) + view <- Future { + Views.views.vend.systemView(ViewId(viewIdStr)).openOrThrowException( + s"$ViewNotFound Current view_id($viewIdStr)") + } + // SS.init populates Lift thread-globals (used by `SS.user` inside the + // connector). The connector's first line `SS.user` resolves synchronously + // inside this block, capturing the user; subsequent flatMap stages run on + // other threads but the value is already bound. + innerResult <- APIUtil.SS.init(Full(user), bank, account, view, Some(cc)) { + code.bankconnectors.LocalMappedConnectorInternal.createTransactionRequest( + BankId(bankIdStr), AccountId(accountIdStr), ViewId(viewIdStr), + transactionRequestType, json) + } + } yield innerResult._1 + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, "createTransactionRequestAccount", "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/GRANT_VIEW_ID/transaction-request-types/TRANSACTION_REQUEST_TYPE/transaction-requests", + "Create Transaction Request", + s"""Create a Transaction Request of the type specified in the URL. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, transactionRequestWithChargeJSON400, + List($AuthenticatedUserIsRequired, InvalidJsonFormat, InvalidNumber, NotPositiveAmount, + InvalidTransactionRequestType, InvalidISOCurrencyCode, + InsufficientAuthorisationToCreateTransactionRequest, + InvalidAccountIdFormat, InvalidBankIdFormat, TransactionDisabled, UnknownError), + List(apiTagTransactionRequest, apiTagPSD2PIS, apiTagPsd2), None, + http4sPartialFunction = Some(createTransactionRequest)) + // ─── allRoutes ──────────────────────────────────────────────────────────── private val allOwnRoutes: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req => @@ -2263,6 +2342,7 @@ object Http4s400 { .orElse(getExplicitCounterpartyById.run(req)) .orElse(createExplicitCounterparty.run(req)) .orElse(getFirehoseAccountsAtOneBank.run(req)) + .orElse(createTransactionRequest.run(req)) } val allRoutesWithMiddleware: HttpRoutes[IO] = ResourceDocMiddleware.apply(resourceDocs)(allOwnRoutes) From 54b13b7dbf90c578d21a81d2347bdb158c2c62e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Wed, 13 May 2026 12:46:00 +0200 Subject: [PATCH 13/30] feat(v4.0.0): migrate answerTransactionRequestChallenge to Http4s400 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v2.1.0 answer-challenge ResourceDocs (one per of the 4 v2.1.0- supported types — SANDBOX_TAN/COUNTERPARTY/SEPA/FREE_FORM, added in 77acfe379) were still hijacking the URL via the bridge cascade and returning v2.1.0's 400 (`InvalidJsonFormat … ChallengeAnswerJSON`) instead of letting the v4 Lift endpoint handle the v4-shaped ChallengeAnswerJson400. The full Lift handler is ~280 lines, so rather than duplicate it we claim the URL at the http4s layer (taking priority over the bridge cascade) and delegate to `Http4sLiftWebBridge.dispatch(req)` — which runs Lift's own dispatcher directly. Lift then picks up v4's `answerTransactionRequestChallenge` because it's registered first in `OBPAPI4_0_0.routes` via `endpointsOf4_0_0`. Local results: code.api.v4_0_0.TransactionRequestsTest — 34/34 pass (was 7 failing for the four shared types' answer-challenge step). Caveat: v5.1.0 VRPConsentRequestTest now fails 4 tests with 500 on trans-request creation. Those tests use `v4_0_0_Request` to POST a trans-request and now hit the v4 own-route added in b673da1a3, which calls `LocalMappedConnectorInternal.createTransactionRequest` for real — and that triggers `sendCustomerNotification` which tries SMTP and ConnectException's because no mail server is configured in the test env. Before b673da1a3 the URL was bridge-hijacked into v2.1.0's handler, which doesn't call the notification path, so the SMTP issue was hidden. The underlying SMTP-failure path needs a graceful fallback (swallow or log instead of raise) — separate from this commit, since it's about the notification connector, not routing. Override audit: 37/37 migrated; 1 remains (createConsumer) — and the remaining audit entry is the only v4 override left in the bridge- hijack list. --- .../scala/code/api/v4_0_0/Http4s400.scala | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala b/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala index 6eacb24527..701d0f92f0 100644 --- a/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala +++ b/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala @@ -2279,6 +2279,42 @@ object Http4s400 { List(apiTagTransactionRequest, apiTagPSD2PIS, apiTagPsd2), None, http4sPartialFunction = Some(createTransactionRequest)) + // ─── answerTransactionRequestChallenge (POST .../trans-requests/{id}/challenge → 202) ─ + // + // v4 needs its own handling for this endpoint because the v2.1.0 catch-all (one + // ResourceDoc per of the 4 supported types — SANDBOX_TAN/COUNTERPARTY/SEPA/ + // FREE_FORM, after a recent fix) would otherwise hijack the URL via the bridge + // cascade and return v2.1.0's 400 because it doesn't recognize + // `ChallengeAnswerJson400`. The Lift v4 `answerTransactionRequestChallenge` + // endpoint is ~280 lines, so rather than duplicating it we route directly to the + // Lift bridge for this URL — the bridge invokes Lift's dispatcher which will pick + // up the v4 endpoint (it's registered first in `OBPAPI4_0_0.routes` via + // `endpointsOf4_0_0`). + // + // This is the same trick the createTransactionRequest path uses: claim the URL at + // the http4s layer so the bridge cascade can't intercept it. The difference is we + // delegate the body to Lift unchanged. + + val answerTransactionRequestChallenge: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "transaction-request-types" / _ / "transaction-requests" / _ / "challenge" => + code.api.util.http4s.Http4sLiftWebBridge.dispatch(req) + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, "answerTransactionRequestChallenge", "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/GRANT_VIEW_ID/transaction-request-types/TRANSACTION_REQUEST_TYPE/transaction-requests/TRANSACTION_REQUEST_ID/challenge", + "Answer Transaction Request Challenge", + s"""In Sandbox mode, any string that can be converted to a positive integer will be accepted as an answer. + | + |${userAuthenticationMessage(true)}""", + challengeAnswerJson400, transactionRequestWithChargeJSON400, + List($AuthenticatedUserIsRequired, InvalidJsonFormat, InvalidBankIdFormat, + InvalidAccountIdFormat, InvalidTransactionRequestChallengeId, + AllowedAttemptsUsedUp, TransactionRequestStatusNotInitiatedOrPendingOrForwarded, + TransactionRequestTypeHasChanged, UnknownError), + List(apiTagTransactionRequest, apiTagPSD2PIS, apiTagPsd2), None, + http4sPartialFunction = Some(answerTransactionRequestChallenge)) + // ─── allRoutes ──────────────────────────────────────────────────────────── private val allOwnRoutes: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req => @@ -2343,6 +2379,7 @@ object Http4s400 { .orElse(createExplicitCounterparty.run(req)) .orElse(getFirehoseAccountsAtOneBank.run(req)) .orElse(createTransactionRequest.run(req)) + .orElse(answerTransactionRequestChallenge.run(req)) } val allRoutesWithMiddleware: HttpRoutes[IO] = ResourceDocMiddleware.apply(resourceDocs)(allOwnRoutes) From d4e56597ffebf173e3546335b5ef010f2fb8eb6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Wed, 13 May 2026 13:13:47 +0200 Subject: [PATCH 14/30] feat(http4s): port Force-Error/AuthType/JsonSchema interceptors into ResourceDocMiddleware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three interceptors used to live in `APIUtil.afterAuthenticateInterceptors`, fired by Lift's wrapEndpoint between auth and the endpoint body. Once an endpoint moves to http4s those interceptors stopped running, which made the corresponding test suites fail in odd ways (500 on Force-Error, 201/500 on the body validators). The same applied to v2.2.0+ endpoints that http4s now owns — like createFx — because every v4.0.0 test using those endpoints (via the bridge cascade) sees the migrated handler, not Lift's interceptor chain. Port: three new validation steps in `ResourceDocMiddleware.validateOnly`, each direct ports of the corresponding Lift logic: 1. `processForceError` — opt-in via `enable.force_error` prop. Reads `Force-Error` / `Response-Code` headers, validates name format, checks the error code is in the ResourceDoc's `errorResponseBodies`, then synthesizes the requested error. 2. `validateAuthType` — looks up `AuthenticationTypeValidationProvider` by operation id; rejects with 400 if the current request's authType isn't in the registered allow-list. Skips anonymous requests (they've already passed/failed auth elsewhere). 3. `validateJsonSchema` — looks up the registered JSON schema for this endpoint via `JsonSchemaValidationProvider`, validates `cc.httpBody` against it, returns 400 with `InvalidRequestPayload + errors` joined by `; ` — same prefix the Lift interceptor used so existing assertions on `$.from_currency_code: does not have a value …` still match. Order in the validation chain: after authenticate + authorizeRoles, before bank/account/view validation. That matches Lift's flow (auth first, then interceptors, then endpoint-specific path validation). Local results: - ForceErrorValidationTest: 35 / 35 (was 10 failing) - AuthenticationTypeValidationTest: 27 / 27 (was 1 failing) - JsonSchemaValidationTest: 27 / 27 (was 1 failing) Regression check across 184 tests (Account/Bank/Scopes/Consents/ v3_1 ConsentTest/v7 Http4s700RoutesTest/Firehose/TransactionRequests): 0 failures. Group 2 done. Remaining: group 3 (v5.1 limits, VRP consent) and the v5.1 SMTP-side-effect surfacing from group 1 — both require new own-routes or connector tolerance, not interceptor work. --- .../util/http4s/ResourceDocMiddleware.scala | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) 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 038e522eea..727324834c 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 @@ -262,6 +262,9 @@ object ResourceDocMiddleware extends MdcLoggable { val result: Validation[ValidationContext] = for { context <- authenticate(req, resourceDoc, initialContext) context <- authorizeRoles(resourceDoc, pathParams, context) + context <- processForceError(req, resourceDoc, context) + context <- validateAuthType(resourceDoc, context) + context <- validateJsonSchema(resourceDoc, context) context <- validateBank(pathParams, context) context <- validateAccount(pathParams, context) context <- validateView(pathParams, context) @@ -369,6 +372,109 @@ object ResourceDocMiddleware extends MdcLoggable { } } + /** + * Force-Error / Response-Code header processing. + * + * Port of `APIUtil.afterAuthenticateInterceptors`'s force-error case. Lets a + * caller short-circuit the endpoint and synthesize a specific error response, + * for testing / contract validation. Off by default; opt-in via the + * `enable.force_error` prop. When enabled and a `Force-Error` header is present: + * + * - Invalid OBP-error name format → 400 "Force-Error value not correct" + * - Non-numeric `Response-Code` header → 400 "Response-Code value not correct" + * - Error name not in this ResourceDoc's `errorResponseBodies` → 400 + * "Invalid Force Error Code" + * - Otherwise → look up the matching error message, return it with the + * ResourceDoc-implied status (or override from Response-Code). + * + * Without this, migrated endpoints quietly ignore the header and the test that + * asserts on the synthesized response sees a 200/201 (success) or a 500 + * (endpoint side-effect) instead. + */ + private def processForceError(req: Request[IO], resourceDoc: ResourceDoc, ctx: ValidationContext): Validation[ValidationContext] = { + import DSL._ + if (!APIUtil.getPropsAsBoolValue("enable.force_error", false)) success(ctx) + else { + val headers = req.headers + val forceError = headers.get(org.typelevel.ci.CIString("Force-Error")).map(_.head.value) + val responseCodeHeader = headers.get(org.typelevel.ci.CIString("Response-Code")).map(_.head.value) + forceError match { + case None => success(ctx) + case Some(errorName) => + val errorNamePrefix = if (errorName.endsWith(":")) errorName else errorName + ":" + val correlationId = ctx.callContext.correlationId + val cc = ctx.callContext + val responseIO: IO[Response[IO]] = { + if (!code.api.util.ErrorMessages.isValidName(errorName)) { + ErrorResponseConverter.createErrorResponse( + 400, s"${code.api.util.ErrorMessages.ForceErrorInvalid} Force-Error value not correct: $errorName", cc) + } else if (responseCodeHeader.exists(it => !org.apache.commons.lang3.StringUtils.isNumeric(it))) { + ErrorResponseConverter.createErrorResponse( + 400, s"${code.api.util.ErrorMessages.ForceErrorInvalid} Response-Code value not correct: ${responseCodeHeader.orNull}", cc) + } else if (!resourceDoc.errorResponseBodies.exists(_.startsWith(errorNamePrefix))) { + ErrorResponseConverter.createErrorResponse( + 400, s"${code.api.util.ErrorMessages.ForceErrorInvalid} Invalid Force Error Code: $errorName", cc) + } else { + val errorValue = code.api.util.ErrorMessages.getValueMatches(_.startsWith(errorNamePrefix)) + .getOrElse(throw new RuntimeException(s"force-error code $errorName matched but lookup failed")) + val statusCode = responseCodeHeader.map(_.toInt).getOrElse(code.api.util.ErrorMessages.getCode(errorValue)) + ErrorResponseConverter.createErrorResponse(statusCode, errorValue, cc) + } + } + EitherT[IO, Response[IO], ValidationContext](responseIO.map[Either[Response[IO], ValidationContext]](Left(_))) + } + } + } + + /** + * Authentication-type validation. Port of `APIUtil.validateAuthType`. If an + * operator has registered allowed auth types for this endpoint via + * `AuthenticationTypeValidationProvider`, reject any request whose authType + * isn't on the allow-list (anonymous requests skip — they already failed auth + * if the endpoint required it). + */ + private def validateAuthType(resourceDoc: ResourceDoc, ctx: ValidationContext): Validation[ValidationContext] = { + import DSL._ + val cc = ctx.callContext + val authType = cc.authType + if (authType == code.api.util.AuthenticationType.Anonymous) success(ctx) + else { + val operationId = APIUtil.buildOperationId(resourceDoc.implementedInApiVersion, resourceDoc.partialFunctionName) + code.authtypevalidation.AuthenticationTypeValidationProvider.validationProvider.vend.getByOperationId(operationId) match { + case Full(v) if !v.authTypes.contains(authType) => + val errorMsg = s"""${code.api.util.ErrorMessages.AuthenticationTypeIllegal} allowed authentication types: ${v.authTypes.mkString("[", ", ", "]")}, current request auth type: $authType""" + EitherT[IO, Response[IO], ValidationContext]( + ErrorResponseConverter.createErrorResponse(400, errorMsg, cc) + .map[Either[Response[IO], ValidationContext]](Left(_)) + ) + case _ => success(ctx) + } + } + } + + /** + * JSON-schema body validation. Port of the json-schema interceptor in + * `APIUtil.afterAuthenticateInterceptors`. Only fires when an operator has + * registered a schema for this endpoint via `JsonSchemaValidationProvider`. If + * the body fails validation, returns 400 with the concatenated schema errors; + * otherwise the request continues. + */ + private def validateJsonSchema(resourceDoc: ResourceDoc, ctx: ValidationContext): Validation[ValidationContext] = { + import DSL._ + val operationId = APIUtil.buildOperationId(resourceDoc.implementedInApiVersion, resourceDoc.partialFunctionName) + code.util.JsonSchemaUtil.validateRequest(Some(ctx.callContext))(operationId) match { + case Some(errorMsg) => + // Mirror Lift's afterAuthenticateInterceptors prefix so tests asserting on + // `$InvalidRequestPayload` still pass. + val message = s"${code.api.util.ErrorMessages.InvalidRequestPayload} $errorMsg" + EitherT[IO, Response[IO], ValidationContext]( + ErrorResponseConverter.createErrorResponse(400, message, ctx.callContext) + .map[Either[Response[IO], ValidationContext]](Left(_)) + ) + case None => success(ctx) + } + } + /** Bank validation: checks BANK_ID and fetches bank */ private def validateBank(pathParams: Map[String, String], ctx: ValidationContext): Validation[ValidationContext] = { From 1f2e6614672278a38de9e101ebce81c7ccc299a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Wed, 13 May 2026 13:33:37 +0200 Subject: [PATCH 15/30] fix(v4.0.0): support custom views in createTransactionRequest + enable mail.test.mode in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes that together close out the v5.1 VRP / counterparty-limit / trans-request CI failures. (1) Http4s400.createTransactionRequest looked up the URL's view via `Views.views.vend.systemView(viewId)` only — a hard fail for VRP consent flows whose URLs carry a custom `_vrp-…` view id rather than a system view name. The lookup threw `An Empty Box was opened. The justification was OBP-30005: View not found`, surfacing as 500. Fix: fall back to `customView` (the account-scoped lookup) when the system view miss. SS.init only needs *some* View instance; the connector itself works off the ViewId parameter, so the fallback view is purely for thread-global plumbing. (2) The connector's email branch (LocalMappedConnector. sendCustomerNotification → CommonsEmailWrapper.sendTextEmail) opens a real SMTP socket unless `mail.test.mode=true`. CI has no mail server, so it threw ConnectException → 500 on any v5 consent flow once group 1 connected those flows to the real connector. The CI workflow already generates `test.default.props` line by line in a setup step; add `mail.test.mode=true` there. Local results: - v5_1_0.CounterpartyLimitTest: 6 / 6 (was 3 failing pre-group-1) - v5_1_0.VRPConsentRequestTest: 6 / 6 (was 4 failing post-group-1) - v5_1_0.TransactionRequestTest: 8 / 8 (was 1 failing pre-group-1) - Full regression across 297 tests (groups 1+2+3 + base v4 + v3.1 ConsentTest + v7 Http4s700RoutesTest): 0 failures. Group 3 done. --- .github/workflows/build_pull_request.yml | 6 ++++++ obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala | 10 ++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build_pull_request.yml b/.github/workflows/build_pull_request.yml index db72079e2d..4efc9d1789 100644 --- a/.github/workflows/build_pull_request.yml +++ b/.github/workflows/build_pull_request.yml @@ -235,6 +235,12 @@ 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 + # Log emails instead of opening a real SMTP socket: without this, + # LocalMappedConnector.sendCustomerNotification's EMAIL branch calls + # CommonsEmailWrapper.sendTextEmail which throws ConnectException because + # 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 - name: Run tests — shard ${{ matrix.shard }} (${{ matrix.name }}) run: | diff --git a/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala b/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala index 701d0f92f0..794224993e 100644 --- a/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala +++ b/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala @@ -2248,8 +2248,14 @@ object Http4s400 { } transactionRequestType = TransactionRequestType(transactionRequestTypeStr) view <- Future { - Views.views.vend.systemView(ViewId(viewIdStr)).openOrThrowException( - s"$ViewNotFound Current view_id($viewIdStr)") + // System views (owner, accountant, etc.) and custom views (e.g. VRP + // `_vrp-…` views) are stored separately. Try system first; fall back + // to the account-scoped custom view. SS.init only needs *some* View + // instance — the connector reads viewId from the parameter, not the + // View object — so a soft fallback is fine here. + Views.views.vend.systemView(ViewId(viewIdStr)) + .or(Views.views.vend.customView(ViewId(viewIdStr), BankIdAccountId(account.bankId, account.accountId))) + .openOrThrowException(s"$ViewNotFound Current view_id($viewIdStr)") } // SS.init populates Lift thread-globals (used by `SS.user` inside the // connector). The connector's first line `SS.user` resolves synchronously From bfaaffdf617ba517d04618a75567297e40709b5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Wed, 13 May 2026 14:32:07 +0200 Subject: [PATCH 16/30] Disambiguate EMAIL template placeholder; drop spurious role on v2.1 FREE_FORM doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ResourceDoc matcher now treats a fixed set of ALL_CAPS segments (EMAIL, SMS, IMPLICIT, SANDBOX_TAN, FREE_FORM, ...) as literals rather than wildcards, because real Lift endpoints register them as literal SCA-method or transaction-request-type segments. Two collateral regressions: 1) `GET /users/email/EMAIL/terminator` used EMAIL as a placeholder variable. Once EMAIL became literal, the matcher only matched URLs whose third segment was the literal string "EMAIL" — the "with proper entitlement" scenarios send a real address and missed the doc entirely, so middleware skipped auth/role validation and the handler 500'd on the empty CallContext. Rename the placeholder to USER_EMAIL in both the http4s and Lift declarations so the matcher treats it as a wildcard. 2) The v2.1.0 FREE_FORM `createTransactionRequest` ResourceDoc carried `Some(List(canCreateAnyTransactionRequest))`. In the Lift handler that role is only used to *bypass* view-permission checks inside `checkAuthorisationToCreateTransactionRequest`; it is not a required entitlement. Once the matcher correctly resolved the FREE_FORM doc, the middleware began enforcing the role and tests running as the owner-view user got 403 instead of 201. Set the role to None and let the inline view check govern access. All 42 scenarios in v3.0/v4.0 UserTest and v2.1 TransactionRequestsTest now pass. --- obp-api/src/main/scala/code/api/v2_1_0/Http4s210.scala | 7 ++++++- obp-api/src/main/scala/code/api/v3_0_0/APIMethods300.scala | 2 +- obp-api/src/main/scala/code/api/v3_0_0/Http4s300.scala | 2 +- obp-api/src/main/scala/code/api/v4_0_0/APIMethods400.scala | 2 +- obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala | 2 +- 5 files changed, 10 insertions(+), 5 deletions(-) diff --git a/obp-api/src/main/scala/code/api/v2_1_0/Http4s210.scala b/obp-api/src/main/scala/code/api/v2_1_0/Http4s210.scala index c2a1792c36..4e2f31d65b 100644 --- a/obp-api/src/main/scala/code/api/v2_1_0/Http4s210.scala +++ b/obp-api/src/main/scala/code/api/v2_1_0/Http4s210.scala @@ -236,7 +236,12 @@ object Http4s210 { |${userAuthenticationMessage(true)}""", transactionRequestBodyFreeFormJSON, transactionRequestWithChargeJSON210, commonTxReqErrors, List(apiTagTransactionRequest, apiTagPSD2PIS), - Some(List(canCreateAnyTransactionRequest)), + // Role kept out of the ResourceDoc: in the Lift implementation + // `canCreateAnyTransactionRequest` only bypasses view-permission checks + // inside `checkAuthorisationToCreateTransactionRequest` — it is not a + // required entitlement. Owner-view users must still be able to create + // FREE_FORM requests without holding the role. + None, http4sPartialFunction = Some(createTransactionRequest)) // (Catch-all ResourceDoc for TRANSACTION_REQUEST_TYPE removed: it caused the v2.1.0 diff --git a/obp-api/src/main/scala/code/api/v3_0_0/APIMethods300.scala b/obp-api/src/main/scala/code/api/v3_0_0/APIMethods300.scala index 63a78f9387..c337751eb1 100644 --- a/obp-api/src/main/scala/code/api/v3_0_0/APIMethods300.scala +++ b/obp-api/src/main/scala/code/api/v3_0_0/APIMethods300.scala @@ -951,7 +951,7 @@ trait APIMethods300 { implementedInApiVersion, nameOf(getUser), "GET", - "/users/email/EMAIL/terminator", + "/users/email/USER_EMAIL/terminator", "Get Users by Email Address", s"""Get users by email address | diff --git a/obp-api/src/main/scala/code/api/v3_0_0/Http4s300.scala b/obp-api/src/main/scala/code/api/v3_0_0/Http4s300.scala index ced4ec875e..32aac47370 100644 --- a/obp-api/src/main/scala/code/api/v3_0_0/Http4s300.scala +++ b/obp-api/src/main/scala/code/api/v3_0_0/Http4s300.scala @@ -648,7 +648,7 @@ object Http4s300 { resourceDocs += ResourceDoc( null, implementedInApiVersion, nameOf(getUser), "GET", - "/users/email/EMAIL/terminator", + "/users/email/USER_EMAIL/terminator", "Get Users by Email Address", s"""Get users by email address. | diff --git a/obp-api/src/main/scala/code/api/v4_0_0/APIMethods400.scala b/obp-api/src/main/scala/code/api/v4_0_0/APIMethods400.scala index 50f8bc11c1..978747ed2a 100644 --- a/obp-api/src/main/scala/code/api/v4_0_0/APIMethods400.scala +++ b/obp-api/src/main/scala/code/api/v4_0_0/APIMethods400.scala @@ -4843,7 +4843,7 @@ trait APIMethods400 extends MdcLoggable { implementedInApiVersion, nameOf(getUsersByEmail), "GET", - "/users/email/EMAIL/terminator", + "/users/email/USER_EMAIL/terminator", "Get Users by Email Address", s"""Get users by email address | diff --git a/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala b/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala index 794224993e..6a9701cae3 100644 --- a/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala +++ b/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala @@ -705,7 +705,7 @@ object Http4s400 { staticResourceDocs += ResourceDoc( null, implementedInApiVersion, "getUsersByEmail", "GET", - "/users/email/EMAIL/terminator", + "/users/email/USER_EMAIL/terminator", "Get Users by Email Address", s"""Get users by email address | From 78cff1bfc1b451e6da92c816d4b4fcc1b21d5beb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Wed, 13 May 2026 15:06:05 +0200 Subject: [PATCH 17/30] Document EMAIL/SMS literal-vs-placeholder pitfall and bypass-role gotcha MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two recurring gotchas surfaced while migrating v3.0/v4.0 getUsersByEmail and v2.1.0 createTransactionRequest FREE_FORM: 1. The Http4sSupport matcher's `literalAllCapsSegments` set treats names like EMAIL, SMS, IMPLICIT, SANDBOX_TAN, FREE_FORM as concrete URL segments (because real SCA-method / transaction-request-type endpoints register them as literals). Re-using one of those names as a placeholder variable in a different endpoint's URL template silently breaks the matcher. 2. Some Lift entitlements are bypass conditions inside authorisation helpers, not required roles. Copying them into the http4s ResourceDoc role list converts them into requirements and locks out view-permission callers — http4s middleware enforces doc roles, Lift did not. Both belong with the existing "Tricky Parts" entries so future migrations don't re-derive the failure mode. --- CLAUDE.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 83eed3d25e..1f6ec5a518 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -239,6 +239,10 @@ Note: `executeFutureCreated` returns 201; pair it with `cc.user.openOrThrowExcep **Use `NEW_ACCOUNT_ID` for PUT-creates-account URLs**: When a `PUT /banks/BANK_ID/accounts/ACCOUNT_ID` *creates* the account (it doesn't exist yet), the middleware's `validateAccount` keys off the literal `ACCOUNT_ID` template var and tries to look it up → 404 before the handler runs. Change the ResourceDoc URL template to `/banks/BANK_ID/accounts/NEW_ACCOUNT_ID` (or any non-standard ALL_CAPS variant) — middleware treats it as a wildcard and skips the lookup, but the path still matches the route pattern. The handler can check "already exists" inline with `Connector.connector.vend.checkBankAccountExists(...)` and return 409/400 as needed. +**Reserved ALL_CAPS literals — don't use them as placeholders**: `ResourceDocMatcher` in `Http4sSupport.scala` keeps an explicit `literalAllCapsSegments` set: `SANDBOX_TAN`, `COUNTERPARTY`, `SEPA`, `FREE_FORM`, `ACCOUNT`, `ACCOUNT_OTP`, `REFUND`, `SIMPLE`, `AGENT_CASH_WITHDRAWAL`, `CARD`, `EMAIL`, `SMS`, `IMPLICIT`, `NOT_EMAIL_NEITHER_SMS`. These are matched as **literals** (real Lift endpoints register them as concrete SCA-method / transaction-request-type segments — e.g. `/banks/BANK_ID/my/consents/EMAIL`). Any other ALL_CAPS segment is a wildcard. If you migrate an endpoint whose URL template uses one of these names as a *placeholder variable* (e.g. v3.0/v4.0 `getUsersByEmail` had `/users/email/EMAIL/terminator` with EMAIL meaning "any email value"), the matcher will only fire when the URL segment is literally `EMAIL` — real callers pass actual addresses and miss the doc entirely → middleware skips auth/role validation → handler 500s on the empty CallContext. Rename the placeholder to something outside the literal set (e.g. `EMAIL` → `USER_EMAIL`), and apply the rename in **both** the http4s `ResourceDoc` and the original Lift `ResourceDoc` (resource-docs aggregation reads both, and `collectResourceDocs` dedup keys off URL + verb). + +**Bypass roles vs required roles**: Some Lift handlers check entitlements inline as **bypass** conditions inside authorisation helpers — e.g. `checkAuthorisationToCreateTransactionRequest` honours `canCreateAnyTransactionRequest` to let the caller skip the view-permission check, but the role is never a hard requirement. These roles are NOT in the Lift ResourceDoc role list (Lift never enforced doc roles, so it didn't matter), but they get copy-pasted into the http4s doc by reflex. The http4s middleware **does** enforce doc roles, so adding a bypass role to `Some(List(...))` converts it into a required entitlement — owner-view callers without the role get 403 instead of the expected 201/200. Audit before copying: if the role appears in the Lift handler only inside an authorisation OR-chain ("has view permission OR has role X"), it belongs as `None` in the doc with the inline view/role logic preserved. Don't enforce bypass roles in the doc. + **Bridge-cascade hijack**: when a new version (e.g. v4.0.0) *overrides* an endpoint from an earlier version with the same URL + verb (e.g. v4's `POST /banks` adds entitlement-granting that v2.2.0's `POST /banks` doesn't have), the v4 override **must** be migrated to `Http4s400`'s own-routes **before** wiring `Http4s400` into the chain. Otherwise the path-rewriting bridge cascade silently sends the request to the older handler: ``` From 29a63b1802bd7228f90a915866c09ecab409826a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Thu, 14 May 2026 06:39:41 +0200 Subject: [PATCH 18/30] =?UTF-8?q?feat(v5.0.0):=20migrate=20all=2033=20rema?= =?UTF-8?q?ining=20endpoints=20+=20add=20v500=E2=86=92v400=20bridge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the v5.0.0 Lift→http4s migration. Http4s500 now serves all 39 v5.0.0 own endpoints (9 prior + 33 new) and cascades inherited v1–v4 endpoints through the v500→v400 path-rewriting bridge instead of falling through to Http4sLiftWebBridge. Endpoints migrated (33): - 12 overrides (had to land before the bridge, else cascade hijack): createBank, updateBank, createAccount, createUserAuthContext, getUserAuthContexts, createUserAuthContextUpdateRequest, answerUserAuthContextUpdateChallenge, createCustomer, getCustomersAtOneBank, createProduct, addCardForBank, getViewsForBankAccount, getAdapterInfo - 21 new in v5.0.0: - 6 consent-request endpoints (3 SCA-method aliases — EMAIL/SMS/IMPLICIT — share the createConsentByConsentRequestId handler via a guarded route pattern) - 5 customer endpoints (getCustomerOverview, getCustomerOverviewFlat, getMyCustomersAtAnyBank, getMyCustomersAtBank, getCustomersMinimalAtOneBank) - 6 customer-account-link endpoints (CRUD) - headAtms, getMetricsAtBank, getSystemViewsIds createAccount: ResourceDoc keeps Some(List(canCreateAccount)) and relies on ResourceDocMiddleware enforcement; the inline "userIdAccountOwner == loggedInUserId" check is preserved as a no-op safety net mirroring Lift exactly. (Lift's OBPRestHelper.registerRoutes wraps every endpoint in wrappedWithAuthCheck, which DOES enforce doc roles — contrary to the CLAUDE.md "Conditional role check (403)" note. The note should be revised: bypass roles vs required roles is the real distinction.) createConsentByConsentRequestId: the VRP branch's nested for-comp hits Scala 2.12 type-inference limits when val bindings inside a deep monadic context interact with an if/else whose branches return structurally similar but separately inferred types. Refactor: extract postJson, postCounterpartyLimitV510, and the inner for-comp into explicit val bindings (vrpFlow: Future[(BankId, AccountId, ViewId, CounterpartyId)]) and annotate the else branch's tuple type. Behaviour unchanged. Bridge cascade now: v500Routes own-routes → v500ToV400Bridge → (v4 own → v4ToV310Bridge → … → v1.2.1). Tests: v5.0.0 (85 pass / 13 ignored), v5.1.0 (239 pass), v6.0.0 + v7.0.0 + http4sbridge (616 pass) — all green. --- .../scala/code/api/v5_0_0/Http4s500.scala | 1628 ++++++++++++++++- 1 file changed, 1619 insertions(+), 9 deletions(-) diff --git a/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala b/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala index 6a9659e358..d8541a051f 100644 --- a/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala +++ b/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala @@ -2,30 +2,66 @@ package code.api.v5_0_0 import cats.data.{Kleisli, OptionT} import cats.effect._ +import code.accountattribute.AccountAttributeX import code.api.Constant._ import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON._ -import code.api.util.APIUtil.{EmptyBody, ResourceDoc, getProductsIsPublic} +import code.api.util.APIUtil._ +import code.api.util.ApiRole +import code.api.util.ApiRole._ import code.api.util.ApiTag._ +import code.api.util.ErrorMessages import code.api.util.ErrorMessages._ import code.api.util.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps} -import code.api.util.http4s.{ResourceDocMiddleware} -import code.api.util.{CustomJsonFormats, NewStyle} +import code.api.util.http4s.ResourceDocMiddleware import code.api.util.newstyle.ViewNewStyle -import code.api.util.ApiRole._ +import code.api.util.{APIUtil, ConsentJWT, Consent, CustomJsonFormats, JwtUtil, NewStyle, OBPBankId, SecureRandomUtil} +import code.api.v2_1_0.JSONFactory210 +import code.api.v3_0_0.JSONFactory300 +import code.api.v3_1_0.{JSONFactory310, PostConsentBodyCommonJson, PostConsentViewJsonV310, PostUserAuthContextJson, PostUserAuthContextUpdateJsonV310} import code.api.v4_0_0.JSONFactory400 -import code.api.v5_0_0.{CreateViewJsonV500, JSONFactory500, UpdateViewJsonV500} +import code.api.v4_0_0.JSONFactory400.createCustomersMinimalJson +import code.api.v4_0_0.PostCounterpartyJson400 +import code.api.v5_0_0.JSONFactory500.{createPhysicalCardJson, createViewJsonV500, createViewsIdsJsonV500, createViewsJsonV500} +import code.api.v5_1_0.{CreateCustomViewJson, PostCounterpartyLimitV510, PostVRPConsentRequestJsonV510} +import code.bankconnectors.Connector +import code.consent.{ConsentRequests, ConsentStatus, Consents, MappedConsent} +import code.consumer.Consumers +import code.entitlement.Entitlement +import code.metadata.counterparties.MappedCounterparty +import code.metrics.APIMetrics +import code.model.dataAccess.BankAccountCreation +import code.util.Helper +import code.util.Helper.{SILENCE_IS_GOLDEN, booleanToFuture} +import code.views.Views import com.github.dwickern.macros.NameOf.nameOf import com.openbankproject.commons.ExecutionContext.Implicits.global import com.openbankproject.commons.dto.GetProductsParam -import com.openbankproject.commons.model.{BankId, ProductCode, ViewId} +import com.openbankproject.commons.model.{ + AccountId, AccountRouting, AccountRoutingJsonV121, Bank, BankAccount, BankAccountRoutings, + BankId, BankIdAccountId, BankRoutingJson, BranchRoutingJsonV141, CardAction, + CardCollectionInfo, CardPostedInfo, CardReplacementInfo, CardReplacementReason, + CounterpartyBespoke, CounterpartyId, CreditLimit, CreditRating, CustomerFaceImage, + CustomerId, PinResetInfo, PinResetReason, ProductCode, User, UserAuthContextUpdateStatus, + ViewId +} +import com.openbankproject.commons.model.enums.StrongCustomerAuthentication import com.openbankproject.commons.util.{ApiVersion, ApiVersionStatus, ScannedApiVersion} +import net.liftweb.common.{Empty, Full} +import net.liftweb.json import net.liftweb.json.JsonAST.prettyRender -import net.liftweb.json.{Extraction, Formats} -import org.http4s._ +import net.liftweb.json.{Extraction, Formats, compactRender} +import net.liftweb.mapper.By +import net.liftweb.util.{Helpers, Props, StringHelpers} +import org.http4s.{HttpRoutes, MediaType, Method, Request, Response, Status, Uri} import org.http4s.dsl.io._ import org.typelevel.ci.CIString + +import java.util.UUID +import java.util.concurrent.ThreadLocalRandom import scala.collection.mutable.ArrayBuffer +import scala.concurrent.Future import scala.language.{higherKinds, implicitConversions} +import scala.util.Random object Http4s500 { @@ -381,24 +417,1598 @@ object Http4s500 { } } + // ─── createBank (POST /banks → 201) — v5 override of v2.2.0/v4 ────────── + // v5 uses PostBankJson500 (id is Option[String], includes bank_routings). + // Must live in own routes so the bridge cascade can't hijack down to v4/v2.2. + + val createBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val failMsg = s"$InvalidJsonFormat The Json body should be the $PostBankJson500 " + for { + postJson <- NewStyle.function.tryons(failMsg, 400, Some(cc)) { + net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[PostBankJson500] + } + checkShortStringValue = APIUtil.checkOptionalShortString(postJson.id.getOrElse(SILENCE_IS_GOLDEN)) + _ <- Helper.booleanToFuture(s"$checkShortStringValue.", cc = Some(cc)) { + checkShortStringValue == SILENCE_IS_GOLDEN + } + _ <- Helper.booleanToFuture(InvalidConsumerCredentials, cc = Some(cc)) { + cc.consumer.isDefined + } + _ <- Helper.booleanToFuture(s"$InvalidJsonFormat Min length of BANK_ID should be greater than 3 characters.", cc = Some(cc)) { + postJson.id.forall(_.length > 3) + } + _ <- Helper.booleanToFuture(s"$InvalidJsonFormat BANK_ID can not contain space characters", cc = Some(cc)) { + !postJson.id.contains(" ") + } + _ <- Helper.booleanToFuture(s"$InvalidJsonFormat BANK_ID can not contain `::::` characters", cc = Some(cc)) { + !`checkIfContains::::`(postJson.id.getOrElse("")) + } + (banks, _) <- NewStyle.function.getBanks(Some(cc)) + _ <- Helper.booleanToFuture(bankIdAlreadyExists, cc = Some(cc)) { + !banks.exists(b => Some(b.bankId.value) == postJson.id) + } + (success, _) <- NewStyle.function.createOrUpdateBank( + postJson.id.getOrElse(APIUtil.generateUUID()), + postJson.full_name.getOrElse(""), + postJson.bank_code, + postJson.logo.getOrElse(""), + postJson.website.getOrElse(""), + postJson.bank_routings.getOrElse(Nil).find(_.scheme == "BIC").map(_.address).getOrElse(""), + "", + postJson.bank_routings.getOrElse(Nil).filterNot(_.scheme == "BIC").headOption.map(_.scheme).getOrElse(""), + postJson.bank_routings.getOrElse(Nil).filterNot(_.scheme == "BIC").headOption.map(_.address).getOrElse(""), + Some(cc) + ) + entitlements <- NewStyle.function.getEntitlementsByUserId(cc.userId, Some(cc)) + entitlementsByBank = entitlements.filter(_.bankId == postJson.id.getOrElse("")) + _ <- entitlementsByBank.exists(_.roleName == CanCreateEntitlementAtOneBank.toString()) match { + case true => Future.successful(()) + case false => Future(Entitlement.entitlement.vend.addEntitlement( + postJson.id.getOrElse(""), cc.userId, CanCreateEntitlementAtOneBank.toString())) + } + _ <- entitlementsByBank.exists(_.roleName == CanReadDynamicResourceDocsAtOneBank.toString()) match { + case true => Future.successful(()) + case false => Future(Entitlement.entitlement.vend.addEntitlement( + postJson.id.getOrElse(""), cc.userId, CanReadDynamicResourceDocsAtOneBank.toString())) + } + } yield JSONFactory500.createBankJSON500(success) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, "createBank", "POST", + "/banks", "Create Bank", + s"""Create a new bank (Authenticated access). + | + |The user creating this will be automatically assigned the Role CanCreateEntitlementAtOneBank. + |Thus the User can manage the bank they create and assign Roles to other Users. + |""", + postBankJson500, bankJson500, + List(InvalidJsonFormat, $AuthenticatedUserIsRequired, + InsufficientAuthorisationToCreateBank, UnknownError), + List(apiTagBank), + Some(List(canCreateBank)), + http4sPartialFunction = Some(createBank) + ) + + // ─── updateBank (PUT /banks → 200) ────────────────────────────────────── + + val updateBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val failMsg = s"$InvalidJsonFormat The Json body should be the $PostBankJson500 " + for { + bank <- NewStyle.function.tryons(failMsg, 400, Some(cc)) { + net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[PostBankJson500] + } + _ <- Helper.booleanToFuture(InvalidConsumerCredentials, cc = Some(cc)) { + cc.consumer.isDefined + } + _ <- Helper.booleanToFuture(s"$InvalidJsonFormat Min length of BANK_ID should be greater than 3 characters.", cc = Some(cc)) { + bank.id.forall(_.length > 3) + } + _ <- Helper.booleanToFuture(s"$InvalidJsonFormat BANK_ID can not contain space characters", cc = Some(cc)) { + !bank.id.contains(" ") + } + bankId <- NewStyle.function.tryons(updateBankError, 400, Some(cc)) { + bank.id.get + } + (_, _) <- NewStyle.function.getBank(BankId(bankId), Some(cc)) + (success, _) <- NewStyle.function.createOrUpdateBank( + bankId, + bank.full_name.getOrElse(""), + bank.bank_code, + bank.logo.getOrElse(""), + bank.website.getOrElse(""), + bank.bank_routings.getOrElse(Nil).find(_.scheme == "BIC").map(_.address).getOrElse(""), + "", + bank.bank_routings.getOrElse(Nil).filterNot(_.scheme == "BIC").headOption.map(_.scheme).getOrElse(""), + bank.bank_routings.getOrElse(Nil).filterNot(_.scheme == "BIC").headOption.map(_.address).getOrElse(""), + Some(cc) + ) + } yield JSONFactory500.createBankJSON500(success) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, "updateBank", "PUT", + "/banks", "Update Bank", + "Update an existing bank (Authenticated access).", + postBankJson500, bankJson500, + List(InvalidJsonFormat, $AuthenticatedUserIsRequired, BankNotFound, updateBankError, UnknownError), + List(apiTagBank), + Some(List(canCreateBank)), + http4sPartialFunction = Some(updateBank) + ) + + // ─── createAccount (PUT /banks/BANK_ID/accounts/NEW_ACCOUNT_ID → 201) ─── + // Account doesn't exist yet — use NEW_ACCOUNT_ID so middleware's + // validateAccount doesn't 404 the create. (See CLAUDE.md gotcha.) + + val createAccount: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val bankId = BankId(bankIdStr) + val accountId = AccountId(accountIdStr) + val failMsg = s"$InvalidJsonFormat The Json body should be the ${prettyRender(Extraction.decompose(createAccountRequestJsonV310))} " + for { + user <- Future.successful(cc.user.openOrThrowException(AuthenticatedUserIsRequired)) + (account, _) <- Connector.connector.vend.checkBankAccountExists(bankId, accountId, Some(cc)) + _ <- Helper.booleanToFuture(AccountIdAlreadyExists, cc = Some(cc)) { account.isEmpty } + createAccountJson <- NewStyle.function.tryons(failMsg, 400, Some(cc)) { + net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[CreateAccountRequestJsonV500] + } + loggedInUserId = user.userId + userIdAccountOwner = createAccountJson.user_id.getOrElse(loggedInUserId) + _ <- Helper.booleanToFuture(InvalidAccountIdFormat, cc = Some(cc)) { isValidID(accountId.value) } + _ <- Helper.booleanToFuture(InvalidBankIdFormat, cc = Some(cc)) { isValidID(accountId.value) } + (postedOrLoggedInUser, _) <- NewStyle.function.findByUserId(userIdAccountOwner, Some(cc)) + _ <- if (userIdAccountOwner == loggedInUserId) Future.successful(Full(())) + else Helper.booleanToFuture( + s"${UserHasMissingRoles} $canCreateAccount", failCode = 403, cc = Some(cc)) { + APIUtil.hasEntitlement(bankId.value, loggedInUserId, canCreateAccount) + } + initialBalanceAsString = createAccountJson.balance.map(_.amount).getOrElse("0") + accountType = createAccountJson.product_code + accountLabel = createAccountJson.label + initialBalanceAsNumber <- NewStyle.function.tryons(InvalidAccountInitialBalance, 400, Some(cc)) { + BigDecimal(initialBalanceAsString) + } + _ <- Helper.booleanToFuture(InitialBalanceMustBeZero, cc = Some(cc)) { 0 == initialBalanceAsNumber } + _ <- Helper.booleanToFuture(InvalidISOCurrencyCode, cc = Some(cc)) { + isValidCurrencyISOCode(createAccountJson.balance.map(_.currency).getOrElse("EUR")) + } + currency = createAccountJson.balance.map(_.currency).getOrElse("EUR") + (_, _) <- NewStyle.function.getBank(bankId, Some(cc)) + _ <- Helper.booleanToFuture(s"$InvalidAccountRoutings Duplication detected in account routings, please specify only one value per routing scheme", 400, cc = Some(cc)) { + createAccountJson.account_routings.getOrElse(Nil).map(_.scheme).distinct.size == createAccountJson.account_routings.getOrElse(Nil).size + } + alreadyExistAccountRoutings <- Future.sequence(createAccountJson.account_routings.getOrElse(Nil).map(accountRouting => + NewStyle.function.getAccountRouting(Some(bankId), accountRouting.scheme, accountRouting.address, Some(cc)) + .map(_ => Some(accountRouting)).fallbackTo(Future.successful(None)) + )) + alreadyExistingAccountRouting = alreadyExistAccountRoutings.collect { + case Some(r) => s"bankId: $bankId, scheme: ${r.scheme}, address: ${r.address}" + } + _ <- Helper.booleanToFuture(s"$AccountRoutingAlreadyExist (${alreadyExistingAccountRouting.mkString("; ")})", cc = Some(cc)) { + alreadyExistingAccountRouting.isEmpty + } + (bankAccount, _) <- NewStyle.function.createBankAccount( + bankId, accountId, accountType, accountLabel, currency, initialBalanceAsNumber, + postedOrLoggedInUser.name, + createAccountJson.branch_id.getOrElse(""), + createAccountJson.account_routings.getOrElse(Nil).map(r => AccountRouting(r.scheme, r.address)), + Some(cc) + ) + (productAttributes, _) <- NewStyle.function.getProductAttributesByBankAndCode(bankId, ProductCode(accountType), Some(cc)) + (accountAttributes, _) <- NewStyle.function.createAccountAttributes( + bankId, accountId, ProductCode(accountType), productAttributes, None, Some(cc) + ) + _ <- BankAccountCreation.setAccountHolderAndRefreshUserAccountAccess(bankId, accountId, postedOrLoggedInUser, Some(cc)) + } yield JSONFactory310.createAccountJSON(userIdAccountOwner, bankAccount, accountAttributes) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, "createAccount", "PUT", + "/banks/BANK_ID/accounts/NEW_ACCOUNT_ID", "Create Account (PUT)", + """Create Account at bank specified by BANK_ID with Id specified by ACCOUNT_ID. + | + |The User can create an Account for themself - or - the User specified in the PUT body. + |If the PUT body USER_ID is specified, the logged in user must have the Role canCreateAccount.""".stripMargin, + createAccountRequestJsonV500, createAccountResponseJsonV310, + List(InvalidJsonFormat, BankNotFound, AuthenticatedUserIsRequired, InvalidUserId, + InvalidAccountIdFormat, InvalidBankIdFormat, UserNotFoundById, UserHasMissingRoles, + InvalidAccountBalanceAmount, InvalidAccountInitialBalance, InitialBalanceMustBeZero, + InvalidAccountBalanceCurrency, AccountIdAlreadyExists, UnknownError), + List(apiTagAccount, apiTagOnboarding), + Some(List(canCreateAccount)), + http4sPartialFunction = Some(createAccount) + ) + + // ─── createUserAuthContext (POST /users/USER_ID/auth-context → 201) ───── + + val createUserAuthContext: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "users" / userId / "auth-context" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val failMsg = s"$InvalidJsonFormat The Json body should be the $PostUserAuthContextJson " + for { + postedData <- NewStyle.function.tryons(failMsg, 400, Some(cc)) { + net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[PostUserAuthContextJson] + } + (user, _) <- NewStyle.function.findByUserId(userId, Some(cc)) + (userAuthContext, _) <- NewStyle.function.createUserAuthContext( + user, postedData.key.trim, postedData.value.trim, Some(cc)) + } yield JSONFactory500.createUserAuthContextJson(userAuthContext) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createUserAuthContext), "POST", + "/users/USER_ID/auth-context", "Create User Auth Context", + s"""Create User Auth Context. These key value pairs will be propagated over connector to adapter. + | + |${userAuthenticationMessage(true)}""", + postUserAuthContextJson, userAuthContextJsonV500, + List(AuthenticatedUserIsRequired, InvalidJsonFormat, CreateUserAuthContextError, UnknownError), + List(apiTagUser), + Some(List(canCreateUserAuthContext)), + http4sPartialFunction = Some(createUserAuthContext) + ) + + // ─── getUserAuthContexts (GET /users/USER_ID/auth-context → 200) ──────── + + val getUserAuthContexts: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "users" / userId / "auth-context" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { + (_, _) <- NewStyle.function.findByUserId(userId, Some(cc)) + (userAuthContexts, _) <- NewStyle.function.getUserAuthContexts(userId, Some(cc)) + } yield JSONFactory500.createUserAuthContextsJson(userAuthContexts) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getUserAuthContexts), "GET", + "/users/USER_ID/auth-context", "Get User Auth Contexts", + s"""Get User Auth Contexts for a User. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, userAuthContextJsonV500, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagUser), + Some(canGetUserAuthContext :: Nil), + http4sPartialFunction = Some(getUserAuthContexts) + ) + + // ─── createUserAuthContextUpdateRequest ────────────────────────────────── + // POST /banks/BANK_ID/users/current/auth-context-updates/SCA_METHOD → 201 + // SCA_METHOD is a literal in {"SMS", "EMAIL"} per ResourceDocMatcher; the + // handler also validates inline. + + val createUserAuthContextUpdateRequest: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "users" / "current" / "auth-context-updates" / scaMethod => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val bankId = BankId(bankIdStr) + for { + user <- Future.successful(cc.user.openOrThrowException(AuthenticatedUserIsRequired)) + _ <- Helper.booleanToFuture(ConsumerHasMissingRoles + CanCreateUserAuthContextUpdate, cc = Some(cc)) { + checkScope(bankId.value, getConsumerPrimaryKey(Some(cc)), ApiRole.canCreateUserAuthContextUpdate) + } + _ <- Helper.booleanToFuture(UserAuthContextUpdateRequestAllowedScaMethods, cc = Some(cc)) { + List(StrongCustomerAuthentication.SMS.toString(), StrongCustomerAuthentication.EMAIL.toString()).contains(scaMethod) + } + failMsg = s"$InvalidJsonFormat The Json body should be the $PostUserAuthContextJson " + postedData <- NewStyle.function.tryons(failMsg, 400, Some(cc)) { + net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[PostUserAuthContextJson] + } + (userAuthContextUpdate, _) <- NewStyle.function.validateUserAuthContextUpdateRequest( + bankId.value, user.userId, postedData.key.trim, postedData.value.trim, scaMethod, Some(cc)) + } yield JSONFactory500.createUserAuthContextUpdateJson(userAuthContextUpdate) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createUserAuthContextUpdateRequest), "POST", + "/banks/BANK_ID/users/current/auth-context-updates/SCA_METHOD", + "Create User Auth Context Update Request", + s"""Create User Auth Context Update Request. + |${userAuthenticationMessage(true)} + | + |A One Time Password (OTP) is sent Out of Band (OOB) to the User via the transport defined in SCA_METHOD.""", + postUserAuthContextJson, userAuthContextUpdateJsonV500, + List(AuthenticatedUserIsRequired, $BankNotFound, InvalidJsonFormat, CreateUserAuthContextError, UnknownError), + List(apiTagUser), + None, + http4sPartialFunction = Some(createUserAuthContextUpdateRequest) + ) + + // ─── answerUserAuthContextUpdateChallenge ───────────────────────────── + // POST /banks/BANK_ID/users/current/auth-context-updates/AUTH_CONTEXT_UPDATE_ID/challenge → 200 + + val answerUserAuthContextUpdateChallenge: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "users" / "current" / "auth-context-updates" / authContextUpdateId / "challenge" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val bankId = BankId(bankIdStr) + val failMsg = s"$InvalidJsonFormat The Json body should be the $PostUserAuthContextUpdateJsonV310 " + for { + postUserAuthContextUpdateJson <- NewStyle.function.tryons(failMsg, 400, Some(cc)) { + net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[PostUserAuthContextUpdateJsonV310] + } + (userAuthContextUpdate, _) <- NewStyle.function.checkAnswer(authContextUpdateId, postUserAuthContextUpdateJson.answer, Some(cc)) + (user, _) <- NewStyle.function.getUserByUserId(userAuthContextUpdate.userId, Some(cc)) + _ <- userAuthContextUpdate.status match { + case status if status == UserAuthContextUpdateStatus.ACCEPTED.toString => + NewStyle.function.createUserAuthContext( + user, userAuthContextUpdate.key.trim, userAuthContextUpdate.value.trim, Some(cc)) + .map(x => (Some(x._1), x._2)) + case _ => + Future.successful((None, Some(cc))) + } + _ <- userAuthContextUpdate.key match { + case "CUSTOMER_NUMBER" => + NewStyle.function.getOCreateUserCustomerLink(bankId, userAuthContextUpdate.value, user.userId, Some(cc)) + case _ => + Future.successful((None, Some(cc))) + } + } yield JSONFactory500.createUserAuthContextUpdateJson(userAuthContextUpdate) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(answerUserAuthContextUpdateChallenge), "POST", + "/banks/BANK_ID/users/current/auth-context-updates/AUTH_CONTEXT_UPDATE_ID/challenge", + "Answer User Auth Context Update Challenge", + "Answer User Auth Context Update Challenge.", + postUserAuthContextUpdateJsonV310, userAuthContextUpdateJsonV500, + List(AuthenticatedUserIsRequired, $BankNotFound, InvalidJsonFormat, InvalidConnectorResponse, UnknownError), + apiTagUser :: Nil, + None, + http4sPartialFunction = Some(answerUserAuthContextUpdateChallenge) + ) + + // ─── createConsentRequest (POST /consumer/consent-requests → 201) ─────── + // Application-access endpoint (no user auth) — the resourceDoc has no + // AuthenticatedUserIsRequired, so middleware skips auth and we call + // applicationAccess inline. + + val createConsentRequest: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "consumer" / "consent-requests" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + for { + (_, callContextOpt) <- APIUtil.applicationAccess(cc) + _ <- APIUtil.passesPsd2Aisp(callContextOpt) + failMsg = s"$InvalidJsonFormat The Json body should be the $PostConsentBodyCommonJson " + consentJson <- NewStyle.function.tryons(failMsg, 400, callContextOpt) { + net.liftweb.json.parse(rawBody).extract[PostConsentRequestJsonV500] + } + maxTimeToLive = APIUtil.getPropsAsIntValue(nameOfProperty = "consents.max_time_to_live", defaultValue = 3600) + _ <- Helper.booleanToFuture(s"$ConsentMaxTTL ($maxTimeToLive)", cc = callContextOpt) { + consentJson.time_to_live match { + case Some(ttl) => ttl <= maxTimeToLive + case _ => true + } + } + createdConsentRequest <- Future(ConsentRequests.consentRequestProvider.vend.createConsentRequest( + callContextOpt.flatMap(_.consumer), + Some(compactRender(net.liftweb.json.parse(rawBody))) + )).map(i => connectorEmptyResponse(i, callContextOpt)) + } yield JSONFactory500.createConsentRequestResponseJson(createdConsentRequest) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createConsentRequest), "POST", + "/consumer/consent-requests", "Create Consent Request", + s"""Create a Consent Request — the first step of the OBP Consent flow. + | + |The calling application (TPP) authenticates with Client Credentials and posts the consent details. + | + |${applicationAccessMessage(true)} + | + |${userAuthenticationMessage(false)}""".stripMargin, + postConsentRequestJsonV500, consentRequestResponseJson, + List(InvalidJsonFormat, ConsentMaxTTL, X509CannotGetCertificate, X509GeneralError, InvalidConnectorResponse, UnknownError), + apiTagConsent :: apiTagPSD2AIS :: apiTagPsd2 :: Nil, + None, + http4sPartialFunction = Some(createConsentRequest) + ) + + // ─── getConsentRequest (GET /consumer/consent-requests/CONSENT_REQUEST_ID → 200) ─── + + val getConsentRequest: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "consumer" / "consent-requests" / consentRequestId => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { + (_, callContextOpt) <- APIUtil.applicationAccess(cc) + _ <- APIUtil.passesPsd2Aisp(callContextOpt) + consentRequest <- Future(ConsentRequests.consentRequestProvider.vend.getConsentRequestById(consentRequestId)) + .map(i => unboxFullOrFail(i, callContextOpt, ConsentRequestNotFound)) + } yield JSONFactory500.createConsentRequestResponseJson(consentRequest) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getConsentRequest), "GET", + "/consumer/consent-requests/CONSENT_REQUEST_ID", "Get Consent Request", + "Return the full payload of a previously-created Consent Request.", + EmptyBody, consentRequestResponseJson, + List(InvalidJsonFormat, ConsentMaxTTL, X509CannotGetCertificate, X509GeneralError, InvalidConnectorResponse, UnknownError), + apiTagConsent :: apiTagPSD2AIS :: apiTagPsd2 :: Nil, + None, + http4sPartialFunction = Some(getConsentRequest) + ) + + // ─── getConsentByConsentRequestId ──────────────────────────────────────── + // GET /consumer/consent-requests/CONSENT_REQUEST_ID/consents → 200 + + val getConsentByConsentRequestId: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "consumer" / "consent-requests" / consentRequestId / "consents" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { + (_, callContextOpt) <- APIUtil.applicationAccess(cc) + consent <- Future { Consents.consentProvider.vend.getConsentByConsentRequestId(consentRequestId) } + .map(unboxFullOrFail(_, callContextOpt, ConsentRequestNotFound)) + _ <- Helper.booleanToFuture(failMsg = ConsentNotFound, failCode = 404, cc = Some(cc)) { + consent.mConsumerId.get == cc.consumer.map(_.consumerId.get).getOrElse("None") + } + tuple <- NewStyle.function.tryons( + failMsg = Oauth2BadJWTException, 400, callContextOpt) { + val jsonWebTokenAsJValue = JwtUtil.getSignedPayloadAsJson(consent.jsonWebToken) + .map(json.parse(_).extract[ConsentJWT]) + val viewsFromJwtToken = jsonWebTokenAsJValue.head.views + val isVrpConsent = (viewsFromJwtToken.length == 1) && + viewsFromJwtToken.head.bank_id.nonEmpty && + viewsFromJwtToken.head.account_id.nonEmpty && + viewsFromJwtToken.head.view_id.startsWith("_vrp-") + if (isVrpConsent) { + val bId = BankId(viewsFromJwtToken.head.bank_id) + val aId = AccountId(viewsFromJwtToken.head.account_id) + val vId = ViewId(viewsFromJwtToken.head.view_id) + val helperInfoFromJwtToken = viewsFromJwtToken.head.helper_info + val viewCanGetCounterparty = Views.views.vend + .customView(vId, BankIdAccountId(bId, aId)) + .map(_.allowed_actions.exists(_ == CAN_GET_COUNTERPARTY)) + val helperInfo = if (viewCanGetCounterparty == Full(true)) helperInfoFromJwtToken else None + (Option(bId), Option(aId), Option(vId), helperInfo): (Option[BankId], Option[AccountId], Option[ViewId], Option[HelperInfoJson]) + } else { + (Option.empty[BankId], Option.empty[AccountId], Option.empty[ViewId], Option.empty[HelperInfoJson]) + } + } + (bankIdOpt, accountIdOpt, viewIdOpt, helperInfo) = tuple + } yield ConsentJsonV500( + consent.consentId, + consent.jsonWebToken, + consent.status, + Some(consent.consentRequestId), + if (bankIdOpt.isDefined && accountIdOpt.isDefined && viewIdOpt.isDefined) + Some(ConsentAccountAccessJson( + bank_id = bankIdOpt.get.value, + account_id = accountIdOpt.get.value, + view_id = viewIdOpt.get.value, + helper_info = helperInfo)) + else None + ) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getConsentByConsentRequestId), "GET", + "/consumer/consent-requests/CONSENT_REQUEST_ID/consents", + "Get Consent By Consent Request Id via Consumer", + s"""This endpoint gets the Consent By consent request id. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, consentJsonV500, + List($AuthenticatedUserIsRequired, UnknownError), + List(apiTagConsent, apiTagPSD2AIS, apiTagPsd2), + None, + http4sPartialFunction = Some(getConsentByConsentRequestId) + ) + + // ─── createConsentByConsentRequestId ───────────────────────────────────── + // POST /consumer/consent-requests/CONSENT_REQUEST_ID/{EMAIL|SMS|IMPLICIT}/consents → 201 + // Three ResourceDoc registrations (one per SCA literal) but one HttpRoutes pattern. + + private def sendEmailConsentNotification( + callContextOpt: Option[code.api.util.CallContext], + consentRequestJson: PostConsentRequestJsonV500, + challengeText: String + ): Future[String] = + for { + consentScaEmail <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body must contain the field email", 400, callContextOpt) { + consentRequestJson.email.head + } + (status, _) <- NewStyle.function.sendCustomerNotification( + StrongCustomerAuthentication.EMAIL, consentScaEmail, + Some("OBP Consent Challenge"), challengeText, callContextOpt) + } yield status + + private def sendSmsConsentNotification( + callContextOpt: Option[code.api.util.CallContext], + consentRequestJson: PostConsentRequestJsonV500, + challengeText: String + ): Future[String] = + for { + consentScaPhoneNumber <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body must contain the field phone_number", 400, callContextOpt) { + consentRequestJson.phone_number.head + } + (status, _) <- NewStyle.function.sendCustomerNotification( + StrongCustomerAuthentication.SMS, consentScaPhoneNumber, None, challengeText, callContextOpt) + } yield status + + val createConsentByConsentRequestId: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "consumer" / "consent-requests" / consentRequestId / scaMethod / "consents" + if scaMethod == "EMAIL" || scaMethod == "SMS" || scaMethod == "IMPLICIT" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val callContextOpt = Some(cc) + for { + user <- Future.successful(cc.user.openOrThrowException(AuthenticatedUserIsRequired)) + createdConsentRequest <- Future(ConsentRequests.consentRequestProvider.vend.getConsentRequestById(consentRequestId)) + .map(i => unboxFullOrFail(i, callContextOpt, ConsentRequestNotFound)) + _ <- Helper.booleanToFuture( + s"$ConsentRequestIsInvalid, the current CONSENT_REQUEST_ID($consentRequestId) is already used to create a consent, please provide another one!", + cc = callContextOpt) { + Consents.consentProvider.vend.getConsentByConsentRequestId(consentRequestId).isEmpty + } + _ <- Helper.booleanToFuture(ConsentAllowedScaMethods, cc = callContextOpt) { + List(StrongCustomerAuthentication.SMS.toString(), + StrongCustomerAuthentication.EMAIL.toString(), + StrongCustomerAuthentication.IMPLICIT.toString()).contains(scaMethod) + } + isVrpConsent = createdConsentRequest.payload.contains("to_account") + (consentRequestJson, isVRPConsentRequest) <- + if (isVrpConsent) { + val failMsg = s"$InvalidJsonFormat The vrp consent request json body should be the $PostVRPConsentRequestJsonV510 " + NewStyle.function.tryons(failMsg, 400, callContextOpt) { + json.parse(createdConsentRequest.payload).extract[code.api.v5_1_0.PostVRPConsentRequestJsonInternalV510] + }.map(p => (p.toPostConsentRequestJsonV500, true)) + } else { + val failMsg = s"$InvalidJsonFormat The consent request Json body should be the $PostConsentRequestJsonV500 " + NewStyle.function.tryons(failMsg, 400, callContextOpt) { + json.parse(createdConsentRequest.payload).extract[PostConsentRequestJsonV500] + }.map(p => (p, false)) + } + (bankId, accountId, viewId, counterpartyId) <- if (isVRPConsentRequest) { + val postConsentRequestJsonV510 = json.parse(createdConsentRequest.payload).extract[code.api.v5_1_0.PostVRPConsentRequestJsonV510] + val vrpViewId = s"_vrp-${UUID.randomUUID.toString}".dropRight(5) + val targetPermissions = List( + CAN_ADD_TRANSACTION_REQUEST_TO_BENEFICIARY, + CAN_GET_COUNTERPARTY, + CAN_SEE_TRANSACTION_REQUESTS + ) + val targetCreateCustomViewJson = CreateCustomViewJson( + name = vrpViewId, description = vrpViewId, metadata_view = vrpViewId, + is_public = false, which_alias_to_use = vrpViewId, + hide_metadata_if_alias_used = true, allowed_permissions = targetPermissions + ) + val fromBankAccountRoutings = BankAccountRoutings( + bank = BankRoutingJson(postConsentRequestJsonV510.from_account.bank_routing.scheme, postConsentRequestJsonV510.from_account.bank_routing.address), + account = BranchRoutingJsonV141(postConsentRequestJsonV510.from_account.account_routing.scheme, postConsentRequestJsonV510.from_account.account_routing.address), + branch = AccountRoutingJsonV121(postConsentRequestJsonV510.from_account.branch_routing.scheme, postConsentRequestJsonV510.from_account.branch_routing.address) + ) + val postJson: PostCounterpartyJson400 = PostCounterpartyJson400( + name = postConsentRequestJsonV510.to_account.counterparty_name, + description = postConsentRequestJsonV510.to_account.counterparty_name, + currency = postConsentRequestJsonV510.to_account.limit.currency, + other_account_routing_scheme = StringHelpers.snakify(postConsentRequestJsonV510.to_account.account_routing.scheme).toUpperCase, + other_account_routing_address = postConsentRequestJsonV510.to_account.account_routing.address, + other_account_secondary_routing_scheme = "", + other_account_secondary_routing_address = "", + other_bank_routing_scheme = StringHelpers.snakify(postConsentRequestJsonV510.to_account.bank_routing.scheme).toUpperCase, + other_bank_routing_address = postConsentRequestJsonV510.to_account.bank_routing.address, + other_branch_routing_scheme = StringHelpers.snakify(postConsentRequestJsonV510.to_account.branch_routing.scheme).toUpperCase, + other_branch_routing_address = postConsentRequestJsonV510.to_account.branch_routing.address, + is_beneficiary = true, bespoke = Nil + ) + val postCounterpartyLimitV510: PostCounterpartyLimitV510 = PostCounterpartyLimitV510( + currency = postConsentRequestJsonV510.to_account.limit.currency, + max_single_amount = postConsentRequestJsonV510.to_account.limit.max_single_amount, + max_monthly_amount = postConsentRequestJsonV510.to_account.limit.max_monthly_amount, + max_number_of_monthly_transactions = postConsentRequestJsonV510.to_account.limit.max_number_of_monthly_transactions, + max_yearly_amount = postConsentRequestJsonV510.to_account.limit.max_yearly_amount, + max_number_of_yearly_transactions = postConsentRequestJsonV510.to_account.limit.max_number_of_yearly_transactions, + max_total_amount = postConsentRequestJsonV510.to_account.limit.max_total_amount, + max_number_of_transactions = postConsentRequestJsonV510.to_account.limit.max_number_of_transactions + ) + val vrpFlow: Future[(BankId, AccountId, ViewId, CounterpartyId)] = for { + (fromAccount, _) <- NewStyle.function.getBankAccountByRoutings(fromBankAccountRoutings, callContextOpt) + fromBankIdAccountId: BankIdAccountId = BankIdAccountId(fromAccount.bankId, fromAccount.accountId) + permission <- NewStyle.function.permission(fromAccount.bankId, fromAccount.accountId, user, callContextOpt) + permissionsFromSource: Set[String] = permission.views.flatMap(_.allowed_actions).toSet + userMissingPermissions: Set[String] = targetCreateCustomViewJson.allowed_permissions.toSet diff permissionsFromSource + _ <- Helper.booleanToFuture(s"${ErrorMessages.UserDoesNotHavePermission} ${userMissingPermissions.toString}", cc = callContextOpt) { + userMissingPermissions.isEmpty + } + (vrpView, _) <- ViewNewStyle.createCustomView(fromBankIdAccountId, targetCreateCustomViewJson.toCreateViewJson, callContextOpt) + _ <- ViewNewStyle.grantAccessToCustomView(vrpView, user, callContextOpt) + _ <- Helper.booleanToFuture(s"$InvalidValueLength. The maximum length of `description` field is ${MappedCounterparty.mDescription.maxLen}", cc = callContextOpt) { + postJson.description.length <= 36 + } + (existingCounterparty, _) <- Connector.connector.vend.checkCounterpartyExists( + postJson.name, fromBankIdAccountId.bankId.value, fromBankIdAccountId.accountId.value, vrpView.viewId.value, callContextOpt) + _ <- Helper.booleanToFuture( + CounterpartyAlreadyExists.replace( + "value for BANK_ID or ACCOUNT_ID or VIEW_ID or NAME.", + s"COUNTERPARTY_NAME(${postJson.name}) for the BANK_ID(${fromBankIdAccountId.bankId.value}) and ACCOUNT_ID(${fromBankIdAccountId.accountId.value}) and VIEW_ID($vrpViewId)"), + cc = callContextOpt) { + existingCounterparty.isEmpty + } + _ <- Helper.booleanToFuture(s"$InvalidISOCurrencyCode Current input is: '${postJson.currency}'", cc = callContextOpt) { + isValidCurrencyISOCode(postJson.currency) + } + (counterparty, _) <- NewStyle.function.createCounterparty( + name = postJson.name, description = postJson.description, currency = postJson.currency, + createdByUserId = user.userId, + thisBankId = fromBankIdAccountId.bankId.value, + thisAccountId = fromBankIdAccountId.accountId.value, + thisViewId = vrpViewId, + otherAccountRoutingScheme = postJson.other_account_routing_scheme, + otherAccountRoutingAddress = postJson.other_account_routing_address, + otherAccountSecondaryRoutingScheme = postJson.other_account_secondary_routing_scheme, + otherAccountSecondaryRoutingAddress = postJson.other_account_secondary_routing_address, + otherBankRoutingScheme = postJson.other_bank_routing_scheme, + otherBankRoutingAddress = postJson.other_bank_routing_address, + otherBranchRoutingScheme = postJson.other_branch_routing_scheme, + otherBranchRoutingAddress = postJson.other_branch_routing_address, + isBeneficiary = postJson.is_beneficiary, + bespoke = postJson.bespoke.map(b => CounterpartyBespoke(b.key, b.value)), + callContextOpt + ) + (counterpartyLimitBox, _) <- Connector.connector.vend.getCounterpartyLimit( + fromBankIdAccountId.bankId.value, fromBankIdAccountId.accountId.value, + vrpViewId, counterparty.counterpartyId, callContextOpt) + _ <- Helper.booleanToFuture( + s"$CounterpartyLimitAlreadyExists Current BANK_ID(${fromBankIdAccountId.bankId.value}), " + + s"ACCOUNT_ID(${fromBankIdAccountId.accountId.value}), VIEW_ID($vrpViewId),COUNTERPARTY_ID(${counterparty.counterpartyId})", + cc = callContextOpt) { + counterpartyLimitBox.isEmpty + } + _ <- NewStyle.function.createOrUpdateCounterpartyLimit( + bankId = counterparty.thisBankId, accountId = counterparty.thisAccountId, + viewId = counterparty.thisViewId, counterpartyId = counterparty.counterpartyId, + postCounterpartyLimitV510.currency, + BigDecimal(postCounterpartyLimitV510.max_single_amount), + BigDecimal(postCounterpartyLimitV510.max_monthly_amount), + postCounterpartyLimitV510.max_number_of_monthly_transactions, + BigDecimal(postCounterpartyLimitV510.max_yearly_amount), + postCounterpartyLimitV510.max_number_of_yearly_transactions, + BigDecimal(postCounterpartyLimitV510.max_total_amount), + postCounterpartyLimitV510.max_number_of_transactions, + callContextOpt + ) + } yield (fromAccount.bankId, fromAccount.accountId, vrpView.viewId, CounterpartyId(counterparty.counterpartyId)) + vrpFlow + } else { + Future.successful((BankId(""), AccountId(""), ViewId(""), CounterpartyId(""))): Future[(BankId, AccountId, ViewId, CounterpartyId)] + } + maxTimeToLive = APIUtil.getPropsAsIntValue(nameOfProperty = "consents.max_time_to_live", defaultValue = 3600) + _ <- Helper.booleanToFuture(s"$ConsentMaxTTL ($maxTimeToLive)", cc = callContextOpt) { + consentRequestJson.time_to_live match { + case Some(ttl) => ttl <= maxTimeToLive + case _ => true + } + } + requestedEntitlements = consentRequestJson.entitlements.getOrElse(Nil) + myEntitlements <- Entitlement.entitlement.vend.getEntitlementsByUserIdFuture(user.userId) + _ <- Helper.booleanToFuture(RolesForbiddenInConsent, cc = callContextOpt) { + requestedEntitlements.map(_.role_name).intersect( + List(canCreateEntitlementAtOneBank.toString(), canCreateEntitlementAtAnyBank.toString()) + ).isEmpty + } + _ <- Helper.booleanToFuture(RolesAllowedInConsent, cc = callContextOpt) { + requestedEntitlements.forall(re => + myEntitlements.getOrElse(Nil).exists(e => e.roleName == re.role_name && e.bankId == re.bank_id)) + } + postConsentViewJsons <- if (isVrpConsent) { + Future.successful(List(PostConsentViewJsonV310(bankId.value, accountId.value, viewId.value))) + } else { + Future.sequence(consentRequestJson.account_access.map(access => + NewStyle.function.getBankAccountByRouting( + consentRequestJson.bank_id.map(BankId(_)), + access.account_routing.scheme, access.account_routing.address, callContextOpt) + .map(r => PostConsentViewJsonV310(r._1.bankId.value, r._1.accountId.value, access.view_id)))) + } + (_, assignedViews) <- Future(Views.views.vend.privateViewsUserCanAccess(user)) + _ <- Helper.booleanToFuture(ViewsAllowedInConsent, cc = callContextOpt) { + postConsentViewJsons.forall(rv => + assignedViews.exists(e => + e.view_id == rv.view_id && e.bank_id == rv.bank_id && e.account_id == rv.account_id)) + } + calculatedConsumerId = consentRequestJson.consumer_id.orElse(Some(createdConsentRequest.consumerId)) + (consumerIdOpt, applicationText) <- calculatedConsumerId match { + case Some(id) => + NewStyle.function.checkConsumerByConsumerId(id, callContextOpt).map { c => + (Some(c.consumerId.get), c.description) + } + case None => Future.successful((None, "Any application")) + } + challengeAnswer = Props.mode match { + case Props.RunModes.Test => Consent.challengeAnswerAtTestEnvironment + case _ => SecureRandomUtil.numeric() + } + consumer = Consumers.consumers.vend.getConsumerByConsumerId(calculatedConsumerId.getOrElse("None")) + createdConsent <- Future(Consents.consentProvider.vend.createObpConsent( + user, challengeAnswer, Some(consentRequestId), consumer)) + .map(i => connectorEmptyResponse(i, callContextOpt)) + postConsentBodyCommonJson = PostConsentBodyCommonJson( + everything = consentRequestJson.everything, + bank_id = consentRequestJson.bank_id, + views = postConsentViewJsons, + entitlements = consentRequestJson.entitlements.getOrElse(Nil), + consumer_id = consentRequestJson.consumer_id, + consent_request_id = Some(consentRequestId), + valid_from = consentRequestJson.valid_from, + time_to_live = consentRequestJson.time_to_live + ) + consentJWT = Consent.createConsentJWT( + user, postConsentBodyCommonJson, createdConsent.secret, createdConsent.consentId, + consumerIdOpt, postConsentBodyCommonJson.valid_from, + postConsentBodyCommonJson.time_to_live.getOrElse(3600), + Some(HelperInfoJson(List(counterpartyId.value))) + ) + _ <- Future(Consents.consentProvider.vend.setJsonWebToken(createdConsent.consentId, consentJWT)) + .map(i => connectorEmptyResponse(i, callContextOpt)) + validUntil = Helper.calculateValidTo(postConsentBodyCommonJson.valid_from, postConsentBodyCommonJson.time_to_live.getOrElse(3600)) + _ <- Future(Consents.consentProvider.vend.setValidUntil(createdConsent.consentId, validUntil)) + .map(i => connectorEmptyResponse(i, callContextOpt)) + grantorConsumerId = callContextOpt.flatMap(_.consumer.toOption.map(_.consumerId.get)).getOrElse("Unknown") + granteeConsumerId = postConsentBodyCommonJson.consumer_id.getOrElse("Unknown") + shouldSkipConsentScaForConsumerIdPair = APIUtil.skipConsentScaForConsumerIdPairs.contains( + APIUtil.ConsumerIdPair(grantorConsumerId, granteeConsumerId)) + mappedConsent <- if (shouldSkipConsentScaForConsumerIdPair) { + Future { + MappedConsent.find(By(MappedConsent.mConsentId, createdConsent.consentId)) + .map(_.mStatus(ConsentStatus.ACCEPTED.toString).saveMe()).head + } + } else { + val challengeText = s"Your consent challenge : ${challengeAnswer}, Application: $applicationText" + scaMethod match { + case v if v == StrongCustomerAuthentication.EMAIL.toString => + sendEmailConsentNotification(callContextOpt, consentRequestJson, challengeText) + case v if v == StrongCustomerAuthentication.SMS.toString => + sendSmsConsentNotification(callContextOpt, consentRequestJson, challengeText) + case v if v == StrongCustomerAuthentication.IMPLICIT.toString => + for { + (consentImplicitSCA, _) <- NewStyle.function.getConsentImplicitSCA(user, callContextOpt) + _ <- consentImplicitSCA.scaMethod match { + case v if v == StrongCustomerAuthentication.EMAIL => + sendEmailConsentNotification(callContextOpt, consentRequestJson.copy(email = Some(consentImplicitSCA.recipient)), challengeText) + case v if v == StrongCustomerAuthentication.SMS => + sendSmsConsentNotification(callContextOpt, consentRequestJson.copy(phone_number = Some(consentImplicitSCA.recipient)), challengeText) + case _ => Future.successful("Success") + } + } yield "Success" + case _ => Future.successful("Success") + } + Future(createdConsent) + } + } yield ConsentJsonV500( + mappedConsent.consentId, + consentJWT, + mappedConsent.status, + Some(mappedConsent.consentRequestId), + if (isVRPConsentRequest) + Some(ConsentAccountAccessJson(bankId.value, accountId.value, viewId.value, Some(HelperInfoJson(List(counterpartyId.value))))) + else None + ) + } + } + + // Three resourceDoc registrations — one per SCA literal — sharing the same handler. + private val createConsentByConsentRequestIdCommonErrors = List( + AuthenticatedUserIsRequired, BankNotFound, InvalidJsonFormat, + ConsentAllowedScaMethods, RolesAllowedInConsent, ViewsAllowedInConsent, + ConsumerNotFoundByConsumerId, ConsumerIsDisabled, + InvalidConnectorResponse, UnknownError + ) + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createConsentByConsentRequestId).replace("Id", "IdEmail"), "POST", + "/consumer/consent-requests/CONSENT_REQUEST_ID/EMAIL/consents", + "Create Consent By CONSENT_REQUEST_ID (EMAIL)", + "Answer a Consent Request and create the resulting Consent, with an EMAIL Strong Customer Authentication challenge.", + EmptyBody, consentJsonV500, + createConsentByConsentRequestIdCommonErrors, + apiTagConsent :: apiTagPSD2AIS :: apiTagPsd2 :: apiTagVrp :: Nil, + None, + http4sPartialFunction = Some(createConsentByConsentRequestId) + ) + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createConsentByConsentRequestId).replace("Id", "IdSms"), "POST", + "/consumer/consent-requests/CONSENT_REQUEST_ID/SMS/consents", + "Create Consent By CONSENT_REQUEST_ID (SMS)", + "Answer a Consent Request and create the resulting Consent, with an SMS Strong Customer Authentication challenge.", + EmptyBody, consentJsonV500, + ConsentRequestIsInvalid :: MissingPropsValueAtThisInstance :: SmsServerNotResponding :: createConsentByConsentRequestIdCommonErrors, + apiTagConsent :: apiTagPSD2AIS :: apiTagPsd2 :: Nil, + None, + http4sPartialFunction = Some(createConsentByConsentRequestId) + ) + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createConsentByConsentRequestId).replace("Id", "IdImplicit"), "POST", + "/consumer/consent-requests/CONSENT_REQUEST_ID/IMPLICIT/consents", + "Create Consent By CONSENT_REQUEST_ID (IMPLICIT)", + "Answer a Consent Request and create the resulting Consent without an SCA challenge.", + EmptyBody, consentJsonV500, + ConsentRequestIsInvalid :: MissingPropsValueAtThisInstance :: SmsServerNotResponding :: createConsentByConsentRequestIdCommonErrors, + apiTagConsent :: apiTagPSD2AIS :: apiTagPsd2 :: Nil, + None, + http4sPartialFunction = Some(createConsentByConsentRequestId) + ) + + // ─── headAtms (HEAD /banks/BANK_ID/atms → 200) ────────────────────────── + + val headAtms: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ Method.HEAD -> `prefixPath` / "banks" / _ / "atms" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { + (_, _) <- if (getAtmsIsPublic) APIUtil.anonymousAccess(cc) else APIUtil.applicationAccess(cc) + } yield "" + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(headAtms), "HEAD", + "/banks/BANK_ID/atms", "Head Bank ATMS", + "Head Bank ATMS.", + EmptyBody, atmsJsonV400, + List($BankNotFound, UnknownError), + List(apiTagATM), + None, + http4sPartialFunction = Some(headAtms) + ) + + // ─── createCustomer (POST /banks/BANK_ID/customers → 201) — v5 override ── + // v5 uses PostCustomerJsonV500 with extra fields (kyc_status default, etc.) + + val createCustomer: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "customers" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val bankId = BankId(bankIdStr) + for { + postedData <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the $PostCustomerJsonV500 ", 400, Some(cc)) { + net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[PostCustomerJsonV500] + } + _ <- Helper.booleanToFuture( + InvalidJsonContent + s" The field dependants(${postedData.dependants.getOrElse(0)}) not equal the length(${postedData.dob_of_dependants.getOrElse(Nil).length}) of dob_of_dependants array", + 400, Some(cc)) { + postedData.dependants.getOrElse(0) == postedData.dob_of_dependants.getOrElse(Nil).length + } + customerNumber = postedData.customer_number.getOrElse(Random.nextInt(Integer.MAX_VALUE).toString) + _ <- Helper.booleanToFuture(s"$InvalidJsonFormat customer_number can not contain `::::` characters", cc = Some(cc)) { + !`checkIfContains::::`(customerNumber) + } + (_, _) <- NewStyle.function.checkCustomerNumberAvailable(bankId, customerNumber, Some(cc)) + (customer, _) <- NewStyle.function.createCustomerC2( + bankId, + postedData.legal_name, customerNumber, postedData.mobile_phone_number, + postedData.email.getOrElse(""), + CustomerFaceImage( + postedData.face_image.map(_.date).orNull, + postedData.face_image.map(_.url).getOrElse("")), + postedData.date_of_birth.orNull, + postedData.relationship_status.getOrElse(""), + postedData.dependants.getOrElse(0), + postedData.dob_of_dependants.getOrElse(Nil), + postedData.highest_education_attained.getOrElse(""), + postedData.employment_status.getOrElse(""), + postedData.kyc_status.getOrElse(false), + postedData.last_ok_date.orNull, + postedData.credit_rating.map(i => CreditRating(i.rating, i.source)), + postedData.credit_limit.map(i => CreditLimit(i.currency, i.amount)), + postedData.title.getOrElse(""), + postedData.branch_id.getOrElse(""), + postedData.name_suffix.getOrElse(""), + "", "", + Some(cc) + ) + } yield JSONFactory310.createCustomerJson(customer) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createCustomer), "POST", + "/banks/BANK_ID/customers", "Create Customer", + s"""The Customer resource stores the customer number, legal name, email, phone number, date of birth, etc. + | + |If kyc_status is not provided, it defaults to false. + | + |${userAuthenticationMessage(true)}""", + postCustomerJsonV500, customerJsonV310, + List($AuthenticatedUserIsRequired, $BankNotFound, InvalidJsonFormat, + CustomerNumberAlreadyExists, UserNotFoundById, CustomerAlreadyExistsForUser, + CreateConsumerError, UnknownError), + List(apiTagCustomer, apiTagPerson), + Some(List(canCreateCustomer, canCreateCustomerAtAnyBank)), + http4sPartialFunction = Some(createCustomer) + ) + + // ─── getCustomerOverview (POST /banks/.../customers/customer-number-query/overview) ─ + + val getCustomerOverview: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "customers" / "customer-number-query" / "overview" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val bankId = BankId(bankIdStr) + for { + postedData <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the $PostCustomerOverviewJsonV500 ", 400, Some(cc)) { + net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[PostCustomerOverviewJsonV500] + } + (customer, _) <- NewStyle.function.getCustomerByCustomerNumber(postedData.customer_number, bankId, Some(cc)) + (customerAttributes, _) <- NewStyle.function.getCustomerAttributes(bankId, CustomerId(customer.customerId), Some(cc)) + accountIds <- AccountAttributeX.accountAttributeProvider.vend + .getAccountIdsByParams(bankId, List("customer_number" -> List(postedData.customer_number)).toMap) + (accounts: List[BankAccount], _) <- NewStyle.function.getBankAccounts( + accountIds.toList.flatten.map(i => BankIdAccountId(bankId, AccountId(i))), Some(cc)) + (accountAttributes, _) <- NewStyle.function.getAccountAttributesForAccounts(bankId, accounts, Some(cc)) + } yield JSONFactory500.createCustomerWithAttributesJson(customer, customerAttributes, accountAttributes) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCustomerOverview), "POST", + "/banks/BANK_ID/customers/customer-number-query/overview", "Get Customer Overview", + s"""Gets the Customer Overview specified by customer_number and bank_code. + | + |${userAuthenticationMessage(true)}""", + postCustomerOverviewJsonV500, customerOverviewJsonV500, + List(AuthenticatedUserIsRequired, UserCustomerLinksNotFoundForUser, UnknownError), + List(apiTagCustomer, apiTagKyc), + Some(List(canGetCustomerOverview)), + http4sPartialFunction = Some(getCustomerOverview) + ) + + // ─── getCustomerOverviewFlat ──────────────────────────────────────────── + + val getCustomerOverviewFlat: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "customers" / "customer-number-query" / "overview-flat" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val bankId = BankId(bankIdStr) + for { + postedData <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the $PostCustomerOverviewJsonV500 ", 400, Some(cc)) { + net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[PostCustomerOverviewJsonV500] + } + (customer, _) <- NewStyle.function.getCustomerByCustomerNumber(postedData.customer_number, bankId, Some(cc)) + (customerAttributes, _) <- NewStyle.function.getCustomerAttributes(bankId, CustomerId(customer.customerId), Some(cc)) + accountIds <- AccountAttributeX.accountAttributeProvider.vend + .getAccountIdsByParams(bankId, List("customer_number" -> List(postedData.customer_number)).toMap) + (accounts: List[BankAccount], _) <- NewStyle.function.getBankAccounts( + accountIds.toList.flatten.map(i => BankIdAccountId(bankId, AccountId(i))), Some(cc)) + (accountAttributes, _) <- NewStyle.function.getAccountAttributesForAccounts(bankId, accounts, Some(cc)) + } yield JSONFactory500.createCustomerOverviewFlatJson(customer, customerAttributes, accountAttributes) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCustomerOverviewFlat), "POST", + "/banks/BANK_ID/customers/customer-number-query/overview-flat", "Get Customer Overview Flat", + s"""Gets the Customer Overview Flat specified by customer_number and bank_code. + | + |${userAuthenticationMessage(true)}""", + postCustomerOverviewJsonV500, customerOverviewFlatJsonV500, + List(AuthenticatedUserIsRequired, UserCustomerLinksNotFoundForUser, UnknownError), + List(apiTagCustomer, apiTagKyc), + Some(List(canGetCustomerOverviewFlat)), + http4sPartialFunction = Some(getCustomerOverviewFlat) + ) + + // ─── getMyCustomersAtAnyBank (GET /my/customers) ──────────────────────── + + val getMyCustomersAtAnyBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "my" / "customers" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + (customers, _) <- Connector.connector.vend.getCustomersByUserId(user.userId, Some(cc)) + .map(connectorEmptyResponse(_, Some(cc))) + } yield JSONFactory210.createCustomersJson(customers) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getMyCustomersAtAnyBank), "GET", + "/my/customers", "Get My Customers", + "Gets all Customers that are linked to me.\n\nAuthentication via OAuth is required.", + EmptyBody, customerJsonV210, + List($AuthenticatedUserIsRequired, UserCustomerLinksNotFoundForUser, UnknownError), + List(apiTagCustomer, apiTagUser), + None, + http4sPartialFunction = Some(getMyCustomersAtAnyBank) + ) + + // ─── getMyCustomersAtBank (GET /banks/BANK_ID/my/customers) ───────────── + + val getMyCustomersAtBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankIdStr / "my" / "customers" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val bankId = BankId(bankIdStr) + for { + user <- Future.successful(cc.user.openOrThrowException(AuthenticatedUserIsRequired)) + (_, _) <- NewStyle.function.getBank(bankId, Some(cc)) + (customers, _) <- Connector.connector.vend.getCustomersByUserId(user.userId, Some(cc)) + .map(connectorEmptyResponse(_, Some(cc))) + } yield JSONFactory210.createCustomersJson(customers.filter(_.bankId == bankId.value)) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getMyCustomersAtBank), "GET", + "/banks/BANK_ID/my/customers", "Get My Customers at Bank", + s"""Returns a list of Customers at the Bank that are linked to the currently authenticated User. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, customerJSONs, + List($AuthenticatedUserIsRequired, $BankNotFound, UserCustomerLinksNotFoundForUser, UnknownError), + List(apiTagCustomer), + None, + http4sPartialFunction = Some(getMyCustomersAtBank) + ) + + // ─── getCustomersAtOneBank (GET /banks/BANK_ID/customers) — override ──── + + val getCustomersAtOneBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankIdStr / "customers" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val bankId = BankId(bankIdStr) + for { + (requestParams, _) <- NewStyle.function.extractQueryParams( + req.uri.renderString, List("limit", "offset", "sort_direction"), Some(cc)) + customers <- NewStyle.function.getCustomers(bankId, Some(cc), requestParams) + } yield JSONFactory300.createCustomersJson(customers.sortBy(_.bankId)) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCustomersAtOneBank), "GET", + "/banks/BANK_ID/customers", "Get Customers at Bank", + s"""Get Customers at Bank. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, customersJsonV300, + List(AuthenticatedUserIsRequired, UserCustomerLinksNotFoundForUser, UnknownError), + List(apiTagCustomer, apiTagUser), + Some(List(canGetCustomersAtOneBank)), + http4sPartialFunction = Some(getCustomersAtOneBank) + ) + + // ─── getCustomersMinimalAtOneBank (GET /banks/BANK_ID/customers-minimal) ─ + + val getCustomersMinimalAtOneBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankIdStr / "customers-minimal" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val bankId = BankId(bankIdStr) + for { + (requestParams, _) <- NewStyle.function.extractQueryParams( + req.uri.renderString, List("limit", "offset", "sort_direction"), Some(cc)) + customers <- NewStyle.function.getCustomers(bankId, Some(cc), requestParams) + } yield createCustomersMinimalJson(customers.sortBy(_.bankId)) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCustomersMinimalAtOneBank), "GET", + "/banks/BANK_ID/customers-minimal", "Get Customers Minimal at Bank", + "Get Customers Minimal at Bank.", + EmptyBody, customersMinimalJsonV300, + List(UserCustomerLinksNotFoundForUser, UnknownError), + List(apiTagCustomer, apiTagUser), + Some(List(canGetCustomersMinimalAtOneBank)), + http4sPartialFunction = Some(getCustomersMinimalAtOneBank) + ) + + // ─── createProduct (PUT /banks/BANK_ID/products/PRODUCT_CODE → 201) ──── + // v5 override of v3.1.0/v4.0.0 — uses PutProductJsonV500 (parent_product_code). + + val createProduct: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / bankIdStr / "products" / productCodeStr => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val bankId = BankId(bankIdStr) + val productCode = ProductCode(productCodeStr) + for { + user <- Future.successful(cc.user.openOrThrowException(AuthenticatedUserIsRequired)) + _ <- NewStyle.function.hasAtLeastOneEntitlement(failMsg = createProductEntitlementsRequiredText)( + bankId.value, user.userId, createProductEntitlements, Some(cc)) + failMsg = s"$InvalidJsonFormat The Json body should be the $PutProductJsonV500 " + product <- NewStyle.function.tryons(failMsg, 400, Some(cc)) { + net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[PutProductJsonV500] + } + (parentProduct, _) <- product.parent_product_code.trim.nonEmpty match { + case false => Future.successful((Empty, Some(cc))) + case true => + NewStyle.function.getProduct(bankId, ProductCode(product.parent_product_code), Some(cc)) + .map(p => (Full(p._1), p._2)) + } + (success, _) <- NewStyle.function.createOrUpdateProduct( + bankId = bankId.value, code = productCode.value, + parentProductCode = parentProduct.map(_.code.value).toOption, + name = product.name, category = null, family = null, superFamily = null, + moreInfoUrl = product.more_info_url.getOrElse(""), + termsAndConditionsUrl = product.terms_and_conditions_url.getOrElse(""), + details = null, + description = product.description.getOrElse(""), + metaLicenceId = product.meta.map(_.license.id).getOrElse(""), + metaLicenceName = product.meta.map(_.license.name).getOrElse(""), + Some(cc) + ) + } yield JSONFactory400.createProductJson(success) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createProduct), "PUT", + "/banks/BANK_ID/products/PRODUCT_CODE", "Create Product", + s"""Create or Update Product for the Bank. + | + |${userAuthenticationMessage(true)}""", + putProductJsonV500, productJsonV400.copy(attributes = None, fees = None), + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, UnknownError), + List(apiTagProduct), + Some(List(canCreateProduct, canCreateProductAtAnyBank)), + http4sPartialFunction = Some(createProduct) + ) + + // ─── addCardForBank (POST /management/banks/BANK_ID/cards → 201) ─────── + + val addCardForBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "banks" / bankIdStr / "cards" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val bankId = BankId(bankIdStr) + for { + user <- Future.successful(cc.user.openOrThrowException(AuthenticatedUserIsRequired)) + (_, _) <- NewStyle.function.getBank(bankId, Some(cc)) + failMsg = s"$InvalidJsonFormat The Json body should be the $CreatePhysicalCardJsonV500 " + postJson <- NewStyle.function.tryons(failMsg, 400, Some(cc)) { + net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[CreatePhysicalCardJsonV500] + } + _ <- postJson.allows match { + case Nil => Future.successful(true) + case _ => Helper.booleanToFuture( + AllowedValuesAre + CardAction.availableValues.mkString(", "), cc = Some(cc)) { + postJson.allows.forall(a => CardAction.availableValues.contains(a)) + } + } + cardReplacementReason <- NewStyle.function.tryons( + AllowedValuesAre + CardReplacementReason.availableValues.mkString(", "), 400, Some(cc)) { + postJson.replacement match { + case Some(value) => CardReplacementReason.valueOf(value.reason_requested) + case None => CardReplacementReason.valueOf(CardReplacementReason.FIRST.toString) + } + } + _ <- Helper.booleanToFuture( + s"${maximumLimitExceeded.replace("10000", "10")} Current issue_number is ${postJson.issue_number}", + cc = Some(cc)) { + postJson.issue_number.length <= 10 + } + (_, _) <- NewStyle.function.getBankAccount(bankId, AccountId(postJson.account_id), Some(cc)) + (_, _) <- NewStyle.function.getCustomerByCustomerId(postJson.customer_id, Some(cc)) + replacement = postJson.replacement.map(r => CardReplacementInfo(requestedDate = r.requested_date, cardReplacementReason)) + collected = postJson.collected.map(c => CardCollectionInfo(c)) + posted = postJson.posted.map(p => CardPostedInfo(p)) + cvv = ThreadLocalRandom.current().nextLong(100, 999) + (card, _) <- NewStyle.function.createPhysicalCard( + bankCardNumber = postJson.card_number, + nameOnCard = postJson.name_on_card, + cardType = postJson.card_type, + issueNumber = postJson.issue_number, + serialNumber = postJson.serial_number, + validFrom = postJson.valid_from_date, + expires = postJson.expires_date, + enabled = postJson.enabled, + cancelled = false, onHotList = false, + technology = postJson.technology, + networks = postJson.networks, + allows = postJson.allows, + accountId = postJson.account_id, + bankId = bankId.value, + replacement = replacement, + pinResets = postJson.pin_reset.map(e => PinResetInfo(e.requested_date, PinResetReason.valueOf(e.reason_requested.toUpperCase))), + collected = collected, posted = posted, + customerId = postJson.customer_id, + cvv = cvv.toString, + brand = postJson.brand, + Some(cc) + ) + } yield createPhysicalCardJson(card, user).copy(cvv = cvv.toString) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(addCardForBank), "POST", + "/management/banks/BANK_ID/cards", "Create Card", + s"""Create Card at bank specified by BANK_ID. + | + |${userAuthenticationMessage(true)}""", + createPhysicalCardJsonV500, physicalCardJsonV500, + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, AllowedValuesAre, UnknownError), + List(apiTagCard), + Some(List(canCreateCardsForBank)), + http4sPartialFunction = Some(addCardForBank) + ) + + // ─── getViewsForBankAccount (GET /banks/BANK_ID/accounts/ACCOUNT_ID/views) ─ + + val getViewsForBankAccount: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr / "views" => + EndpointHelpers.withBankAccount(req) { (user, _, cc) => + val bankId = BankId(bankIdStr) + val accountId = AccountId(accountIdStr) + for { + permission <- NewStyle.function.permission(bankId, accountId, user, Some(cc)) + anyViewContainsCanSeeAvailableViewsForBankAccountPermission = + permission.views.map(_.allowed_actions.exists(_ == CAN_SEE_AVAILABLE_VIEWS_FOR_BANK_ACCOUNT)) + .find(_ == true).getOrElse(false) + _ <- Helper.booleanToFuture( + s"${ErrorMessages.ViewDoesNotPermitAccess} You need the `${CAN_SEE_AVAILABLE_VIEWS_FOR_BANK_ACCOUNT}` permission on any your views", + cc = Some(cc)) { + anyViewContainsCanSeeAvailableViewsForBankAccountPermission + } + views = Views.views.vend.availableViewsForAccount(BankIdAccountId(bankId, accountId)) + } yield createViewsJsonV500(views) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getViewsForBankAccount), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/views", "Get Views for Account", + s"""Returns the list of the views created for account ACCOUNT_ID at BANK_ID. + | + |${userAuthenticationMessage(true)} and the user needs to have access to the owner view.""", + EmptyBody, viewsJsonV500, + List($AuthenticatedUserIsRequired, $BankAccountNotFound, UnknownError), + List(apiTagView, apiTagAccount), + None, + http4sPartialFunction = Some(getViewsForBankAccount) + ) + + // ─── getMetricsAtBank (GET /management/metrics/banks/BANK_ID) ────────── + + val getMetricsAtBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "metrics" / "banks" / bankIdStr => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { + httpParams <- NewStyle.function.extractHttpParamsFromUrl(req.uri.renderString) + (obpQueryParams, _) <- createQueriesByHttpParamsFuture(httpParams, Some(cc)) + metrics <- Future(APIMetrics.apiMetrics.vend.getAllMetrics(obpQueryParams ::: List(OBPBankId(bankIdStr)))) + } yield JSONFactory210.createMetricsJson(metrics) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getMetricsAtBank), "GET", + "/management/metrics/banks/BANK_ID", "Get Metrics at Bank", + "Get the all metrics at the Bank specified by BANK_ID. Requires CanReadMetrics role.", + EmptyBody, metricsJson, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagMetric, apiTagApi), + Some(List(canGetMetricsAtOneBank)), + http4sPartialFunction = Some(getMetricsAtBank) + ) + + // ─── getSystemViewsIds (GET /system-views-ids) ────────────────────────── + + val getSystemViewsIds: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "system-views-ids" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { + views <- ViewNewStyle.systemViews() + } yield createViewsIdsJsonV500(views) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getSystemViewsIds), "GET", + "/system-views-ids", "Get Ids of System Views", + s"""Get Ids of System Views. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, viewIdsJsonV500, + List($AuthenticatedUserIsRequired, $BankNotFound, UnknownError), + List(apiTagSystemView), + Some(List(canGetSystemView)), + http4sPartialFunction = Some(getSystemViewsIds) + ) + + // ─── customer-account-link endpoints (6) ──────────────────────────────── + + val createCustomerAccountLink: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "customer-account-links" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val bankId = BankId(bankIdStr) + for { + (_, _) <- NewStyle.function.getBank(bankId, Some(cc)) + postedData <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the $CreateCustomerAccountLinkJson ", 400, Some(cc)) { + net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[CreateCustomerAccountLinkJson] + } + (customer, _) <- NewStyle.function.getCustomerByCustomerId(postedData.customer_id, Some(cc)) + _ <- booleanToFuture( + s"Bank of the customer specified by the CUSTOMER_ID(${customer.bankId}) has to matches BANK_ID(${bankId.value}) in URL", + 400, Some(cc)) { customer.bankId == bankId.value } + (_, _) <- NewStyle.function.getBankAccount(bankId, AccountId(postedData.account_id), Some(cc)) + _ <- booleanToFuture("Field customer_id is not defined in the posted json!", 400, Some(cc)) { + postedData.customer_id.nonEmpty + } + (existingLink, _) <- Connector.connector.vend.getCustomerAccountLink(postedData.customer_id, postedData.account_id, Some(cc)) + _ <- booleanToFuture(AccountAlreadyExistsForCustomer, 400, Some(cc)) { existingLink.isEmpty } + (link, _) <- NewStyle.function.createCustomerAccountLink( + postedData.customer_id, postedData.bank_id, postedData.account_id, postedData.relationship_type, Some(cc)) + } yield JSONFactory500.createCustomerAccountLinkJson(link) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createCustomerAccountLink), "POST", + "/banks/BANK_ID/customer-account-links", "Create Customer Account Link", + s"""Link a Customer to a Account. + | + |${userAuthenticationMessage(true)}""", + createCustomerAccountLinkJson, customerAccountLinkJson, + List($AuthenticatedUserIsRequired, $BankNotFound, BankAccountNotFound, InvalidJsonFormat, + CustomerNotFoundByCustomerId, UserHasMissingRoles, AccountAlreadyExistsForCustomer, + CreateCustomerAccountLinkError, UnknownError), + List(apiTagCustomer, apiTagAccount), + Some(List(canCreateCustomerAccountLink)), + http4sPartialFunction = Some(createCustomerAccountLink) + ) + + val getCustomerAccountLinksByCustomerId: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankIdStr / "customers" / customerId / "customer-account-links" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val bankId = BankId(bankIdStr) + for { + (customer, _) <- NewStyle.function.getCustomerByCustomerId(customerId, Some(cc)) + _ <- booleanToFuture( + s"Bank of the customer specified by the CUSTOMER_ID(${customer.bankId}) has to matches BANK_ID(${bankId.value}) in URL", + 400, Some(cc)) { customer.bankId == bankId.value } + (links, _) <- NewStyle.function.getCustomerAccountLinksByCustomerId(customerId, Some(cc)) + } yield JSONFactory500.createCustomerAccountLinksJon(links) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCustomerAccountLinksByCustomerId), "GET", + "/banks/BANK_ID/customers/CUSTOMER_ID/customer-account-links", + "Get Customer Account Links by CUSTOMER_ID", + s"""Get Customer Account Links by CUSTOMER_ID. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, customerAccountLinksJson, + List($AuthenticatedUserIsRequired, $BankNotFound, CustomerNotFoundByCustomerId, + UserHasMissingRoles, UnknownError), + List(apiTagCustomer), + Some(List(canGetCustomerAccountLinks)), + http4sPartialFunction = Some(getCustomerAccountLinksByCustomerId) + ) + + val getCustomerAccountLinksByBankIdAccountId: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr / "customer-account-links" => + EndpointHelpers.withBankAccount(req) { (_, _, cc) => + for { + (links, _) <- NewStyle.function.getCustomerAccountLinksByBankIdAccountId(bankIdStr, accountIdStr, Some(cc)) + } yield JSONFactory500.createCustomerAccountLinksJon(links) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCustomerAccountLinksByBankIdAccountId), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/customer-account-links", + "Get Customer Account Links by ACCOUNT_ID", + s"""Get Customer Account Links by ACCOUNT_ID. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, customerAccountLinksJson, + List($AuthenticatedUserIsRequired, $BankNotFound, BankAccountNotFound, + UserHasMissingRoles, UnknownError), + List(apiTagCustomer), + Some(List(canGetCustomerAccountLinks)), + http4sPartialFunction = Some(getCustomerAccountLinksByBankIdAccountId) + ) + + val getCustomerAccountLinkById: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankIdStr / "customer-account-links" / customerAccountLinkId => + EndpointHelpers.withBank(req) { (_, cc) => + for { + (link, _) <- NewStyle.function.getCustomerAccountLinkById(customerAccountLinkId, Some(cc)) + } yield JSONFactory500.createCustomerAccountLinkJson(link) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCustomerAccountLinkById), "GET", + "/banks/BANK_ID/customer-account-links/CUSTOMER_ACCOUNT_LINK_ID", + "Get Customer Account Link by Id", + s"""Get Customer Account Link by CUSTOMER_ACCOUNT_LINK_ID. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, customerAccountLinkJson, + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, UnknownError), + List(apiTagCustomer), + Some(List(canGetCustomerAccountLink)), + http4sPartialFunction = Some(getCustomerAccountLinkById) + ) + + val updateCustomerAccountLinkById: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / bankIdStr / "customer-account-links" / customerAccountLinkId => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val bankId = BankId(bankIdStr) + for { + (_, _) <- NewStyle.function.getBank(bankId, Some(cc)) + postedData <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the $UpdateCustomerAccountLinkJson ", 400, Some(cc)) { + net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[UpdateCustomerAccountLinkJson] + } + (_, _) <- NewStyle.function.getCustomerAccountLinkById(customerAccountLinkId, Some(cc)) + (link, _) <- NewStyle.function.updateCustomerAccountLinkById(customerAccountLinkId, postedData.relationship_type, Some(cc)) + } yield JSONFactory500.createCustomerAccountLinkJson(link) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateCustomerAccountLinkById), "PUT", + "/banks/BANK_ID/customer-account-links/CUSTOMER_ACCOUNT_LINK_ID", + "Update Customer Account Link by Id", + s"""Update Customer Account Link by CUSTOMER_ACCOUNT_LINK_ID. + | + |${userAuthenticationMessage(true)}""", + updateCustomerAccountLinkJson, customerAccountLinkJson, + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, UnknownError), + List(apiTagCustomer), + Some(List(canUpdateCustomerAccountLink)), + http4sPartialFunction = Some(updateCustomerAccountLinkById) + ) + + val deleteCustomerAccountLinkById: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / bankIdStr / "customer-account-links" / customerAccountLinkId => + EndpointHelpers.withUserAndBankDelete(req) { (_, _, cc) => + for { + (_, _) <- NewStyle.function.getCustomerAccountLinkById(customerAccountLinkId, Some(cc)) + (deleted, _) <- NewStyle.function.deleteCustomerAccountLinkById(customerAccountLinkId, Some(cc)) + } yield deleted + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteCustomerAccountLinkById), "DELETE", + "/banks/BANK_ID/customer-account-links/CUSTOMER_ACCOUNT_LINK_ID", + "Delete Customer Account Link", + s"""Delete Customer Account Link by CUSTOMER_ACCOUNT_LINK_ID. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, UnknownError), + List(apiTagCustomer), + Some(List(canDeleteCustomerAccountLink)), + http4sPartialFunction = Some(deleteCustomerAccountLinkById) + ) + + // ─── getAdapterInfo (GET /adapter) — v3.1.0 override ─────────────────── + + val getAdapterInfo: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "adapter" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { + (adapterInfo, _) <- NewStyle.function.getAdapterInfo(Some(cc)) + } yield JSONFactory500.createAdapterInfoJson( + adapterInfo, cc.startTime.getOrElse(Helpers.now).getTime) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAdapterInfo), "GET", + "/adapter", "Get Adapter Info", + s"""Get basic information about the Adapter. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, adapterInfoJsonV500, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagApi), + Some(List(canGetAdapterInfo)), + http4sPartialFunction = Some(getAdapterInfo) + ) + val allRoutes: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req: Request[IO] => root(req) .orElse(getBanks(req)) .orElse(getBank(req)) + .orElse(createBank(req)) + .orElse(updateBank(req)) + .orElse(createAccount(req)) .orElse(getProducts(req)) .orElse(getProduct(req)) + .orElse(createProduct(req)) + .orElse(addCardForBank(req)) + .orElse(getViewsForBankAccount(req)) .orElse(createSystemView(req)) .orElse(getSystemView(req)) .orElse(updateSystemView(req)) .orElse(deleteSystemView(req)) + .orElse(getSystemViewsIds(req)) + .orElse(createUserAuthContext(req)) + .orElse(getUserAuthContexts(req)) + .orElse(createUserAuthContextUpdateRequest(req)) + .orElse(answerUserAuthContextUpdateChallenge(req)) + .orElse(createConsentRequest(req)) + .orElse(getConsentRequest(req)) + .orElse(getConsentByConsentRequestId(req)) + .orElse(createConsentByConsentRequestId(req)) + .orElse(headAtms(req)) + .orElse(createCustomer(req)) + .orElse(getCustomerOverview(req)) + .orElse(getCustomerOverviewFlat(req)) + .orElse(getMyCustomersAtAnyBank(req)) + .orElse(getMyCustomersAtBank(req)) + .orElse(getCustomersAtOneBank(req)) + .orElse(getCustomersMinimalAtOneBank(req)) + .orElse(createCustomerAccountLink(req)) + .orElse(getCustomerAccountLinksByCustomerId(req)) + .orElse(getCustomerAccountLinksByBankIdAccountId(req)) + .orElse(getCustomerAccountLinkById(req)) + .orElse(updateCustomerAccountLinkById(req)) + .orElse(deleteCustomerAccountLinkById(req)) + .orElse(getMetricsAtBank(req)) + .orElse(getAdapterInfo(req)) } val allRoutesWithMiddleware: HttpRoutes[IO] = ResourceDocMiddleware.apply(resourceDocs)(allRoutes) + + // ─── path-rewriting bridge: /obp/v5.0.0/… → /obp/v4.0.0/… ───────────── + // Cascades inherited (v1.2.1–v4.0.0) endpoints through the http4s versions + // instead of falling all the way through to Http4sLiftWebBridge. + val v500ToV400Bridge: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req => + val rawPath = req.uri.path.renderString + if (rawPath.startsWith("/obp/v5.0.0/")) { + val rewritten = rawPath.replaceFirst("/obp/v5\\.0\\.0/", "/obp/v4.0.0/") + val newUri = req.uri.withPath(Uri.Path.unsafeFromString(rewritten)) + val rewrittenReq = req.withUri(newUri) + code.api.v4_0_0.Http4s400.wrappedRoutesV400Services.run(rewrittenReq) + } else { + OptionT.none[IO, Response[IO]] + } + } } - val wrappedRoutesV500Services: HttpRoutes[IO] = Implementations5_0_0.allRoutesWithMiddleware + val wrappedRoutesV500Services: HttpRoutes[IO] = + Kleisli[HttpF, Request[IO], Response[IO]] { req => + Implementations5_0_0.allRoutesWithMiddleware.run(req) + .orElse(Implementations5_0_0.v500ToV400Bridge.run(req)) + } // Wrap routes with JSON not-found handler for better error responses val wrappedRoutesV500ServicesWithJsonNotFound: HttpRoutes[IO] = { From d964708e4f0bf4cd996d7f20c56313878a45376c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Thu, 14 May 2026 06:44:49 +0200 Subject: [PATCH 19/30] Correct CLAUDE.md role-enforcement claims (Lift DOES enforce doc roles) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three gotchas asserted "Lift never enforced doc roles" — wrong. The truth: OBPRestHelper.registerRoutes wraps every endpoint in ResourceDoc.wrappedWithAuthCheck (APIUtil.scala:1780), which enforces doc roles whenever rolesForCheck.nonEmpty && _autoValidateRoles. So Lift and ResourceDocMiddleware enforce doc roles the same way for practically every endpoint. Caught when v5 createAccount AccountTest's "user2 without role → 403" scenario failed against my (mis)application of the "Conditional role check" gotcha — I'd taken canCreateAccount out of the doc on the theory the inline conditional was the real gate. It wasn't; both Lift's wrappedWithAuthCheck AND the inline conditional fire, and Lift's doc enforcement is what makes the test pass. Restoring Some(List(canCreateAccount)) fixed it. Edits: - Add a new top-level note ("Lift DOES enforce ResourceDoc roles") citing APIUtil.scala:1780 and explicitly retracting the prior claim, so future migrators don't repeat the mistake. - Narrow "Conditional role check (403)" to genuinely-conditional roles (different role for different paths). Add the corollary: if the inline check uses the SAME role as the doc, the inline check is dead-code-but-harmless — keep both, mirror Lift exactly. - Rewrite "ResourceDoc role and handler role disagreement": Lift enforces both X (doc) and Y (inline). If a test "passed with only Y", it's because (a) .disableAutoValidateRoles() was set, (b) the doc role was actually different than assumed, or (c) the test granted both. The error-message wording is the more common drift to watch for. - Fix "Bypass roles vs required roles": clarify WHY bypass roles stay out of the doc — adding them would make Lift enforce them as required (since Lift does enforce doc roles), breaking the OR-chain intent. The reflex-copy trap is the real warning, not "Lift didn't enforce". --- CLAUDE.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1f6ec5a518..e16a7060ce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,7 +87,9 @@ EndpointHelpers.withCounterparty(req) { (user, account, view, cp, cc) => ... } / ## Tricky Parts (Gotchas) -**Conditional role check (403)**: `NewStyle.function.hasEntitlement` uses `booleanToFuture` with default `failCode = 400`, which gives 400 instead of 403 when the role is missing. For conditional checks (e.g. only needed when creating for another user), keep ResourceDoc roles `None` and call `booleanToFuture` directly: +**Lift DOES enforce ResourceDoc roles**: `OBPRestHelper.registerRoutes` wraps every endpoint in `ResourceDoc.wrappedWithAuthCheck` (`APIUtil.scala:1780`), which calls `checkRoles` whenever `_autoValidateRoles && rolesForCheck.nonEmpty` — i.e. whenever the doc declares `Some(List(...))` and the endpoint hasn't called `.disableAutoValidateRoles()` (rare). So Lift and `ResourceDocMiddleware` enforce doc roles **the same way** for the common case. The "Conditional / Disagreement / Bypass" gotchas below describe genuinely-quirky inline-check patterns — they are NOT about Lift skipping doc-role enforcement. Earlier revisions of this file said "Lift never enforced doc roles"; that was wrong. When migrating, copy the doc role list as-is unless you can show the inline check is doing something the doc role isn't. + +**Conditional role check (403) — only for genuinely-conditional roles**: `NewStyle.function.hasEntitlement` uses `booleanToFuture` with default `failCode = 400`, which gives 400 instead of 403 when the role is missing. If the role is genuinely conditional (different role for different paths, e.g. `canCreateProductAtAnyBank` only when bank scope is global), keep ResourceDoc roles `None` and check inline with `booleanToFuture(failCode=403)`: ```scala _ <- if (userIdAccountOwner == loggedInUserId) Future.successful(Full(())) else code.util.Helper.booleanToFuture( @@ -95,6 +97,7 @@ _ <- if (userIdAccountOwner == loggedInUserId) Future.successful(Full(())) APIUtil.hasEntitlement(bankId, loggedInUserId, canCreateAccount) } ``` +But: if the inline check uses the **same** role as the doc (e.g. v5 `createAccount` doc has `Some(List(canCreateAccount))` and the inline check also tests `canCreateAccount`), the inline check is dead code — Lift's `wrappedWithAuthCheck` already enforced the doc role before the handler ran. Mirror Lift exactly: keep the doc role AND keep the inline check (it's a no-op safety net when the doc role passes). Do NOT take the role out of the doc to "match Lift": that flips behaviour from "always required" to "only required when creating-for-another-user", which v5 `AccountTest`'s "user2 without role → 403" scenario will catch. **View permissions**: `view.canGetCounterparty` (MappedBoolean) always returns `false` for system views. Use `view.allowed_actions.exists(_ == CAN_GET_COUNTERPARTY)` instead. @@ -210,7 +213,7 @@ the `throw` synthesises a 500 response in the http4s path (test expects 400). Li **Middleware role check runs before body parsing**: When a ResourceDoc declares `Some(List(canX))`, the middleware enforces the role in the **auth/role validation** phase, which precedes the handler. Tests that send malformed JSON expecting 400 (InvalidJsonFormat) instead get 403 (UserHasMissingRoles) because the user lacks the role. Fix: when a test asserts body-validation 400s should fire *before* role 403s, take the role out of the ResourceDoc (`None` for roles) and check it inline inside the for-comp with `code.util.Helper.booleanToFuture(failMsg, failCode = 403, cc = Some(cc)) { APIUtil.hasEntitlement(...) }`. This is a generalisation of the firehose-pattern documented above — it applies to any POST/PUT where the test ordering is "bad body → 400" before "missing role → 403." -**ResourceDoc role and handler role disagreement is a Lift-only quirk**: Some Lift endpoints declare role X in the `ResourceDoc(...)` metadata but actually check role Y inline via `NewStyle.function.hasEntitlement(Y, ...)`. Example: `updateCustomerBranch` Lift had `Some(canUpdateCustomerIdentity :: Nil)` in the doc but called `hasEntitlement(canUpdateCustomerBranch, ...)` in the handler. Lift never enforced doc roles, so the inline check was the real gate and tests passed by granting role Y. The http4s middleware **does** enforce doc roles, so the discrepancy produces 403 with the wrong message. Always cross-check the test's `.addEntitlement(... CanXyz ...)` calls and put the matching role in the http4s ResourceDoc, OR set roles to `None` and rely on the inline check exclusively. +**ResourceDoc role and handler role disagreement**: Some Lift endpoints declare role X in the `ResourceDoc(...)` metadata but ALSO check role Y inline via `NewStyle.function.hasEntitlement(Y, ...)`. Example: `updateCustomerBranch` Lift had `Some(canUpdateCustomerIdentity :: Nil)` in the doc and called `hasEntitlement(canUpdateCustomerBranch, ...)` in the handler. Since Lift enforces both, the effective Lift requirement was X **and** Y — and the test that "passed with only Y" likely did so because (a) the doc had `.disableAutoValidateRoles()` set, (b) the doc role list was actually `None`/different from what was assumed, or (c) the test granted both. The http4s middleware enforces doc roles the same way, so the contract is preserved if you copy the doc role list verbatim. The error-message wording can still drift (middleware says "$UserHasMissingRoles X", inline says "$UserHasMissingRoles Y") — if a test asserts on the message, copy the inline role to the doc OR set doc roles to `None` and rely on the inline check exclusively, then verify against the test's `.addEntitlement(...)` calls. **Most v3.1.0 DELETEs return 200, not 204**: The CLAUDE.md helper matrix says "DELETE → 204" but in practice many v3.1.0 endpoints return `(Full(deletedThing), HttpCode.\`200\`(cc))` — 200 with a body. Mirror Lift: use `withUser` / `withUserAndBank` (which return 200) for these, **not** `withUserDelete` / `withUserAndBankDelete` (which return 204). Reserve the `*Delete` helpers for endpoints that genuinely return 204 (verified examples in v3.1.0: `deleteProductAttribute`, `deleteCardForBank`). The HTTP method comes from the route pattern (`case req @ DELETE -> ...`), not the helper name. @@ -241,7 +244,7 @@ Note: `executeFutureCreated` returns 201; pair it with `cc.user.openOrThrowExcep **Reserved ALL_CAPS literals — don't use them as placeholders**: `ResourceDocMatcher` in `Http4sSupport.scala` keeps an explicit `literalAllCapsSegments` set: `SANDBOX_TAN`, `COUNTERPARTY`, `SEPA`, `FREE_FORM`, `ACCOUNT`, `ACCOUNT_OTP`, `REFUND`, `SIMPLE`, `AGENT_CASH_WITHDRAWAL`, `CARD`, `EMAIL`, `SMS`, `IMPLICIT`, `NOT_EMAIL_NEITHER_SMS`. These are matched as **literals** (real Lift endpoints register them as concrete SCA-method / transaction-request-type segments — e.g. `/banks/BANK_ID/my/consents/EMAIL`). Any other ALL_CAPS segment is a wildcard. If you migrate an endpoint whose URL template uses one of these names as a *placeholder variable* (e.g. v3.0/v4.0 `getUsersByEmail` had `/users/email/EMAIL/terminator` with EMAIL meaning "any email value"), the matcher will only fire when the URL segment is literally `EMAIL` — real callers pass actual addresses and miss the doc entirely → middleware skips auth/role validation → handler 500s on the empty CallContext. Rename the placeholder to something outside the literal set (e.g. `EMAIL` → `USER_EMAIL`), and apply the rename in **both** the http4s `ResourceDoc` and the original Lift `ResourceDoc` (resource-docs aggregation reads both, and `collectResourceDocs` dedup keys off URL + verb). -**Bypass roles vs required roles**: Some Lift handlers check entitlements inline as **bypass** conditions inside authorisation helpers — e.g. `checkAuthorisationToCreateTransactionRequest` honours `canCreateAnyTransactionRequest` to let the caller skip the view-permission check, but the role is never a hard requirement. These roles are NOT in the Lift ResourceDoc role list (Lift never enforced doc roles, so it didn't matter), but they get copy-pasted into the http4s doc by reflex. The http4s middleware **does** enforce doc roles, so adding a bypass role to `Some(List(...))` converts it into a required entitlement — owner-view callers without the role get 403 instead of the expected 201/200. Audit before copying: if the role appears in the Lift handler only inside an authorisation OR-chain ("has view permission OR has role X"), it belongs as `None` in the doc with the inline view/role logic preserved. Don't enforce bypass roles in the doc. +**Bypass roles vs required roles**: Some Lift handlers check entitlements inline as **bypass** conditions inside authorisation helpers — e.g. `checkAuthorisationToCreateTransactionRequest` honours `canCreateAnyTransactionRequest` to let the caller skip the view-permission check, but the role is never a hard requirement. These roles are correctly absent from the Lift ResourceDoc role list — putting them in the doc would make Lift enforce them as required (since Lift DOES enforce doc roles by default), breaking the "view permission OR role" intent. The same holds for http4s middleware. So the trap on migration is the reflex copy: don't move a bypass role from inline-only into `Some(List(...))` just because it appears in the handler. Audit before copying: if the role appears in the Lift handler only inside an authorisation OR-chain ("has view permission OR has role X"), it belongs as `None` in the doc with the inline view/role logic preserved. Bypass roles must stay out of the doc. **Bridge-cascade hijack**: when a new version (e.g. v4.0.0) *overrides* an endpoint from an earlier version with the same URL + verb (e.g. v4's `POST /banks` adds entitlement-granting that v2.2.0's `POST /banks` doesn't have), the v4 override **must** be migrated to `Http4s400`'s own-routes **before** wiring `Http4s400` into the chain. Otherwise the path-rewriting bridge cascade silently sends the request to the older handler: From 8cea9b651e3b97b27405ed1269f81c86d42012c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Thu, 14 May 2026 07:57:43 +0200 Subject: [PATCH 20/30] feat(v5.1.0,middleware): scaffold Http4s510 + 12 overrides + authMode dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lays the foundation for the v5.1.0 Lift→http4s migration. Wires v510Routes into Http4sApp.baseServices ahead of v500Routes, with own-routes only (the v510→v500 path-rewriting bridge is deliberately left disconnected — see below). Required two infrastructure fixes along the way: ResourceDocMiddleware authMode dispatch --------------------------------------- Mirror Lift's wrappedWithAuthCheck (APIUtil.scala:1783-1788) by dispatching the auth helper on resourceDoc.authMode: ApplicationOnly | UserOrApplication → APIUtil.applicationAccess UserOnly | UserAndApplication → APIUtil.anonymousAccess Without this every endpoint behaved as UserOnly. v5.1.0 createConsumer and getConsumers (both UserOrApplication) returned AuthenticatedUserIsRequired for unauth instead of ApplicationNotIdentified, breaking the ConsumerTest "We test the authentication errors" body-content assertion. Also bypass the "empty user + needsAuth" 401 branch when isAppMode is true, so consumer-only auth (no User) passes through as Lift does. Bridge cascade is currently DISABLED for v5.1.0 ----------------------------------------------- While the bulk of the 110 v5.1.0 endpoints (96 are new in v5.1.0, 14 are overrides of older versions) still live in Lift, enabling the v510→v500 path-rewriting bridge would route unmigrated own endpoints (e.g. updateConsumerRedirectURL) down through the cascade to a wrong-version handler or a 404. Without the bridge, unmatched v5.1.0 URLs fall through to Http4sLiftWebBridge with the original path intact and Lift's OBPAPI5_1_0 dispatch picks them up — same behaviour as before this commit. The bridge code is still defined in Implementations5_1_0.v510ToV500Bridge for the eventual flip. Migrated overrides (12 of 14) ----------------------------- - root, getMyConsentsByBank, getAggregateMetrics - createAtm, updateAtm, deleteAtm - createConsumer, getConsumer, getConsumers - getTransactionRequests - getBankAccountsBalances, getAllBankAccountBalances Deliberately NOT migrated yet (2 of 14) --------------------------------------- - getAtms, getAtm: ResponseHeadersTest exercises ETag / If-None-Match / If-Modified-Since on /banks/BANK_ID/atms. Lift's response builder computes ETag (APIUtil.getRequestHeadersNewStyle:534) and honours conditional headers (APIUtil.checkConditionalRequest:471). ResourceDocMiddleware doesn't yet do either, so migrating these here would regress 4 tests. Leaving them in Lift for now; APIMethods510 still has the ResourceDocs registered so resource-docs aggregation is unaffected. Inline notes from the override batch ------------------------------------ - v5.1 PostAtmJsonV510 requires `id`; updateAtm uses AtmJsonV510 and takes the id from the URL. The v5.1 atm shape adds atm_type, license, opening hours, and attributes — wider than v4 — which is why the bridge cascade hijack to Http4s400.getAtms surfaced immediately with MappingException: "No usable value for atm_type." before this commit. - getMyConsentsByBank pulls a private rowToConsentInfoJsonV510 from APIMethods510. Copied verbatim; no shared factory exists yet. - getTransactionRequests v5.1 returns TransactionRequestsJsonV510 (with attributes), not the v4-shape. The "Get Transaction Requests with Attributes" scenario hits this path. - createConsumer / getConsumers (UserOrApplication mode) declare Some(List(canCreateConsumer)) / Some(List(canGetConsumers)) in the doc and rely on the middleware to enforce them after the new authMode dispatch. Tests ----- - v5.1.0: 239 pass / 0 fail (full suite) - v5.0.0: AccountTest, BankTests, CustomerTest, ConsentRequestTest, SystemViewsTests, UserAuthContextTest — all pass - v6.0.0: ConsumerTest, SystemViewsTest — pass - v7.0.0: Http4s700RoutesTest — pass - bridge: Http4sLiftBridgePropertyTest — pass Total cross-version: 455 tests, 0 failures. Next session(s): migrate the remaining 96 new v5.1.0 endpoints (consents family, regulated-entities + attributes, atm-attributes, agents, customer/user attribute helpers, balance CRUD, view-access, counterparty-limits, etc.), implement ETag support in the http4s response wrapper, re-migrate getAtms/getAtm, then enable v510ToV500Bridge. --- .../code/api/util/http4s/Http4sApp.scala | 2 + .../util/http4s/ResourceDocMiddleware.scala | 26 +- .../scala/code/api/v5_1_0/Http4s510.scala | 531 ++++++++++++++++++ 3 files changed, 551 insertions(+), 8 deletions(-) create mode 100644 obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala 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 f13a016ceb..4d2deddaf9 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 @@ -71,6 +71,7 @@ object Http4sApp { private val v310Routes: HttpRoutes[IO] = gate(ApiVersion.v3_1_0, code.api.v3_1_0.Http4s310.wrappedRoutesV310Services) private val v400Routes: HttpRoutes[IO] = gate(ApiVersion.v4_0_0, code.api.v4_0_0.Http4s400.wrappedRoutesV400Services) private val v500Routes: HttpRoutes[IO] = gate(ApiVersion.v5_0_0, code.api.v5_0_0.Http4s500.wrappedRoutesV500Services) + private val v510Routes: HttpRoutes[IO] = gate(ApiVersion.v5_1_0, code.api.v5_1_0.Http4s510.wrappedRoutesV510Services) private val v700Routes: HttpRoutes[IO] = gate(ApiVersion.v7_0_0, code.api.v7_0_0.Http4s700.wrappedRoutesV700Services) /** @@ -104,6 +105,7 @@ object Http4sApp { corsHandler.run(req) .orElse(AppsPage.routes.run(req)) .orElse(StatusPage.routes.run(req)) + .orElse(v510Routes.run(req)) .orElse(v500Routes.run(req)) .orElse(v700Routes.run(req)) .orElse(code.api.berlin.group.v2.Http4sBGv2.wrappedRoutes.run(req)) diff --git a/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala b/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala index 727324834c..cf8edf6ff5 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 @@ -292,13 +292,21 @@ object ResourceDocMiddleware extends MdcLoggable { val needsAuth = ResourceDocMiddleware.needsAuthentication(resourceDoc) logger.debug(s"[ResourceDocMiddleware] needsAuthentication for ${resourceDoc.partialFunctionName}: $needsAuth") - // anonymousAccess runs all auth checks (user resolution, locked/deleted check, rate limiting, - // JWS, BerlinGroup) and returns the Box[User] for authenticated or anonymous requests. - // For any Failure box (e.g. UsernameHasBeenLocked, DAuthJwtTokenIsNotValid) it converts the - // box to a thrown plain Exception(json_of_APIFailureNewStyle, hardcoded failCode=401) via - // fullBoxOrException. We catch that, parse the JSON to recover the original message, and - // return 400 — matching Lift Old Style behavior (plain Failure → errorJsonResponse default=400). - val io = IO.fromFuture(IO(APIUtil.anonymousAccess(ctx.callContext))) + // Dispatch on authMode the same way Lift's wrappedWithAuthCheck (APIUtil.scala:1783-1788) does: + // ApplicationOnly | UserOrApplication → applicationAccess (returns ApplicationNotIdentified + // when neither user nor consumer credentials are valid; also accepts consumer-only). + // UserOnly | UserAndApplication → anonymousAccess (returns AuthenticatedUserIsRequired + // when needsAuth is true and user is missing). + // Without this dispatch, every endpoint behaved as UserOnly — breaking + // ApplicationNotIdentified semantics for v5.1.0 createConsumer / getConsumers. + val isAppMode = resourceDoc.authMode match { + case APIUtil.ApplicationOnly | APIUtil.UserOrApplication => true + case _ => false + } + val io = IO.fromFuture(IO( + if (isAppMode) APIUtil.applicationAccess(ctx.callContext) + else APIUtil.anonymousAccess(ctx.callContext) + )) EitherT( io.attempt.flatMap { @@ -308,7 +316,9 @@ object ResourceDocMiddleware extends MdcLoggable { case Right((Full(user), None)) => IO.pure(Right(ctx.copy(user = Full(user)))) // Empty box — no valid credentials provided, and auth is required. - case Right((_, optCC)) if needsAuth => + // For UserOrApplication / ApplicationOnly: applicationAccess already returned + // successfully because the consumer is valid (just no user). Pass through. + case Right((_, optCC)) if needsAuth && !isAppMode => val cc2 = optCC.getOrElse(ctx.callContext) ErrorResponseConverter.createErrorResponse(401, $AuthenticatedUserIsRequired, cc2).map(Left(_)) // Anonymous endpoint — pass any box user through unchanged. diff --git a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala new file mode 100644 index 0000000000..6b028d92de --- /dev/null +++ b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala @@ -0,0 +1,531 @@ +package code.api.v5_1_0 + +import cats.data.{Kleisli, OptionT} +import cats.effect._ +import code.api.Constant._ +import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON._ +import code.api.util.APIUtil._ +import code.api.util.ApiRole +import code.api.util.ApiRole._ +import code.api.util.ApiTag._ +import code.api.util.ErrorMessages +import code.api.util.ErrorMessages._ +import code.api.util.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps} +import code.api.util.http4s.ResourceDocMiddleware +import code.api.util.newstyle.{BalanceNewStyle, RegulatedEntityAttributeNewStyle, ViewNewStyle} +import code.api.util.newstyle.RegulatedEntityNewStyle.{createRegulatedEntityNewStyle, deleteRegulatedEntityNewStyle, getRegulatedEntitiesNewStyle, getRegulatedEntityByEntityIdNewStyle} +import code.api.util.newstyle.Consumer.createConsumerNewStyle +import code.api.util.{APIUtil, ConsentJWT, CustomJsonFormats, JwtUtil, NewStyle, OBPBankId, OBPLimit, OBPOffset, OBPSortBy, X509} +import code.api.v2_0_0.AccountsHelper +import code.api.v2_1_0.{ConsumerRedirectUrlJSON, JSONFactory210} +import code.api.v3_0_0.JSONFactory300 +import code.api.v3_0_0.JSONFactory300.createAggregateMetricJson +import code.api.v3_1_0.{JSONFactory310, PostConsentBodyCommonJson, PostConsentEntitlementJsonV310, PostConsentViewJsonV310} +import code.api.v3_1_0.JSONFactory310.{createBadLoginStatusJson, createConsumerJSON} +import code.api.v4_0_0.JSONFactory400 +import code.api.v4_0_0.JSONFactory400.{createAccountBalancesJson, createBalancesJson, createNewCoreBankAccountJson} +import code.api.v5_0_0.{Http4s500, JSONFactory500} +import code.api.v5_1_0.JSONFactory510.{createCallLimitJson, createConsentsInfoJsonV510, createConsentsJsonV510, createRegulatedEntitiesJson, createRegulatedEntityJson} +import code.atmattribute.AtmAttribute +import code.bankconnectors.Connector +import code.consent.{ConsentRequests, ConsentStatus, Consents, MappedConsent} +import code.consumer.Consumers +import code.entitlement.Entitlement +import code.loginattempts.LoginAttempt +import code.metrics.APIMetrics +import code.model.dataAccess.AuthUser +import code.model.{AppType, Consumer} +import code.ratelimiting.{RateLimiting, RateLimitingDI} +import code.regulatedentities.MappedRegulatedEntityProvider +import code.userlocks.UserLocksProvider +import code.users.Users +import code.util.Helper +import code.util.Helper.SILENCE_IS_GOLDEN +import code.views.Views +import code.views.system.{AccountAccess, ViewDefinition, ViewPermission} +import code.webuiprops.{MappedWebUiPropsProvider, WebUiPropsCommons} +import com.github.dwickern.macros.NameOf.nameOf +import com.openbankproject.commons.ExecutionContext.Implicits.global +import com.openbankproject.commons.model.{ + AccountId, AccountRouting, AtmId, AtmT, Bank, BankAccount, BankId, BankIdAccountId, + CustomerId, ProductCode, TransactionRequestId, User, View, ViewId +} +import com.openbankproject.commons.model.enums.{StrongCustomerAuthentication, TransactionRequestStatus} +import com.openbankproject.commons.util.{ApiVersion, ApiVersionStatus, ScannedApiVersion} +import net.liftweb.common.{Box, Empty, Full} +import net.liftweb.json +import net.liftweb.json.JsonAST.prettyRender +import net.liftweb.json.{Extraction, Formats, compactRender} +import net.liftweb.mapper.By +import net.liftweb.util.Helpers.tryo +import net.liftweb.util.{Helpers, Props, StringHelpers} +import org.http4s.{HttpRoutes, Method, Request, Response, Uri} +import org.http4s.dsl.io._ + +import java.net.URLDecoder +import java.nio.charset.StandardCharsets +import java.util.Date +import scala.collection.mutable.ArrayBuffer +import scala.concurrent.Future +import scala.language.{higherKinds, implicitConversions} + +object Http4s510 { + + type HttpF[A] = OptionT[IO, A] + + implicit val formats: Formats = CustomJsonFormats.formats + implicit def convertAnyToJsonString(any: Any): String = prettyRender(Extraction.decompose(any)) + + val implementedInApiVersion: ScannedApiVersion = ApiVersion.v5_1_0 + val versionStatus: String = ApiVersionStatus.BLEEDING_EDGE.toString + val resourceDocs: ArrayBuffer[ResourceDoc] = ArrayBuffer[ResourceDoc]() + + object Implementations5_1_0 { + + val prefixPath = Root / ApiPathZero.toString / implementedInApiVersion.toString + + // ─── root (GET /root and GET / — v5.1 override of every prior version) ── + + val root: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` => + EndpointHelpers.executeFuture(req) { + Future.successful(JSONFactory510.getApiInfoJSON(OBPAPI5_1_0.version, OBPAPI5_1_0.versionStatus)) + } + case req @ GET -> `prefixPath` / "root" => + EndpointHelpers.executeFuture(req) { + Future.successful(JSONFactory510.getApiInfoJSON(OBPAPI5_1_0.version, OBPAPI5_1_0.versionStatus)) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(root), "GET", "/root", + "Get API Info (root)", + "Returns information about API version, hosted by, energy source, git commit.", + EmptyBody, apiInfoJson400, + List(UnknownError, MandatoryPropertyIsNotSet), + apiTagApi :: Nil, + None, + http4sPartialFunction = Some(root) + ) + + // ─── getMyConsentsByBank (GET /banks/BANK_ID/my/consents) — v5.1 override + + val getMyConsentsByBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankIdStr / "my" / "consents" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val params = req.uri.query.multiParams + val limitParam = params.get("limit").flatMap(_.headOption).flatMap(s => scala.util.Try(s.toInt).toOption).getOrElse(50) + val offsetParam = params.get("offset").flatMap(_.headOption).flatMap(s => scala.util.Try(s.toInt).toOption).getOrElse(0) + val statusParam = params.get("status").flatMap(_.headOption) + val sortByParam = params.get("sort_by").flatMap(_.headOption).getOrElse("created_date:desc") + val sortParts = sortByParam.split(":").map(_.trim.toLowerCase) + val sortField = sortParts(0) + val sortDirection = sortParts.lift(1).getOrElse("desc") + for { + user <- Future.successful(cc.user.openOrThrowException(AuthenticatedUserIsRequired)) + rows <- Future { + code.consent.DoobieConsentQueries.getConsentsByUserAndBank( + userId = user.userId, bankId = bankIdStr, + status = statusParam, limit = limitParam, offset = offsetParam, + sortField = sortField, sortDirection = sortDirection) + } + } yield ConsentsInfoJsonV510(rows.map(Implementations5_1_0.rowToConsentInfoJsonV510)) + } + } + + private[v5_1_0] def rowToConsentInfoJsonV510(row: code.consent.DoobieConsentQueries.ConsentRow): ConsentInfoJsonV510 = { + ConsentInfoJsonV510( + consent_reference_id = row.consentReferenceId.toString, + consent_id = row.consentId, + consumer_id = row.consumerId.orNull, + created_by_user_id = row.createdByUserId, + status = row.status, + last_action_date = row.lastActionDate.map(d => new java.text.SimpleDateFormat(DateWithDay).format(d)).orNull, + last_usage_date = row.lastUsageDate.map(d => new java.text.SimpleDateFormat(DateWithSeconds).format(d)).orNull, + jwt = row.jwt.orNull, + jwt_payload = row.jwtPayload.orNull, + api_standard = row.apiStandard.orNull, + api_version = row.apiVersion.orNull, + jwt_expires_at = row.jwtExpiresAt.map(d => new java.text.SimpleDateFormat(DateWithSeconds).format(d)).orNull + ) + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getMyConsentsByBank), "GET", + "/banks/BANK_ID/my/consents", "Get My Consents at Bank", + "Get All Consents that the current User has at the Bank.", + EmptyBody, consentsInfoJsonV510, + List($AuthenticatedUserIsRequired, $BankNotFound, UnknownError), + List(apiTagConsent, apiTagPSD2AIS, apiTagPsd2), + None, + http4sPartialFunction = Some(getMyConsentsByBank) + ) + + // ─── getAggregateMetrics (GET /management/aggregate-metrics) — v5.1 override + + val getAggregateMetrics: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "aggregate-metrics" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { + httpParams <- NewStyle.function.extractHttpParamsFromUrl(req.uri.renderString) + (obpQueryParams, _) <- createQueriesByHttpParamsFuture(httpParams, Some(cc)) + aggregateMetrics <- APIMetrics.apiMetrics.vend.getAllAggregateMetricsFuture(obpQueryParams, true) + .map(x => unboxFullOrFail(x, Some(cc), GetAggregateMetricsError)) + } yield createAggregateMetricJson(aggregateMetrics) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAggregateMetrics), "GET", + "/management/aggregate-metrics", "Get Aggregate Metrics", + s"""Returns aggregated metrics. Requires CanReadAggregateMetrics role. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, aggregateMetricsJSONV300, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagMetric, apiTagAggregateMetrics), + Some(List(canReadAggregateMetrics)), + http4sPartialFunction = Some(getAggregateMetrics) + ) + + // ─── ATM CRUD (createAtm/updateAtm/getAtms/getAtm/deleteAtm) — v5.1 overrides + + val createAtm: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "atms" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val bankId = BankId(bankIdStr) + for { + atmJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the ${classOf[AtmJsonV510]}", 400, Some(cc)) { + val atm = net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[PostAtmJsonV510] + atm.id.get // require id + atm + } + _ <- Helper.booleanToFuture(s"$InvalidJsonValue BANK_ID has to be the same in the URL and Body", 400, Some(cc)) { + atmJson.bank_id == bankId.value + } + atm <- NewStyle.function.tryons(CouldNotTransformJsonToInternalModel + " Atm", 400, Some(cc)) { + JSONFactory510.transformToAtmFromV510(atmJson) + } + (created, _) <- NewStyle.function.createOrUpdateAtm(atm, Some(cc)) + (atmAttributes, _) <- NewStyle.function.getAtmAttributesByAtm(bankId, created.atmId, Some(cc)) + } yield JSONFactory510.createAtmJsonV510(created, atmAttributes) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createAtm), "POST", + "/banks/BANK_ID/atms", "Create ATM", "Create ATM.", + postAtmJsonV510, atmJsonV510, + List($AuthenticatedUserIsRequired, InvalidJsonFormat, UnknownError), + List(apiTagATM), + Some(List(canCreateAtm, canCreateAtmAtAnyBank)), + http4sPartialFunction = Some(createAtm) + ) + + val updateAtm: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / bankIdStr / "atms" / atmIdStr => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val bankId = BankId(bankIdStr) + val atmId = AtmId(atmIdStr) + for { + (_, _) <- NewStyle.function.getAtm(bankId, atmId, Some(cc)) + atmJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the ${classOf[AtmJsonV510]}", 400, Some(cc)) { + net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[AtmJsonV510] + } + _ <- Helper.booleanToFuture(s"$InvalidJsonValue BANK_ID has to be the same in the URL and Body", 400, Some(cc)) { + atmJson.bank_id == bankId.value + } + atm <- NewStyle.function.tryons(CouldNotTransformJsonToInternalModel + " Atm", 400, Some(cc)) { + JSONFactory510.transformToAtmFromV510(atmJson.copy(id = Some(atmId.value))) + } + (updated, _) <- NewStyle.function.createOrUpdateAtm(atm, Some(cc)) + (atmAttributes, _) <- NewStyle.function.getAtmAttributesByAtm(bankId, updated.atmId, Some(cc)) + } yield JSONFactory510.createAtmJsonV510(updated, atmAttributes) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateAtm), "PUT", + "/banks/BANK_ID/atms/ATM_ID", "UPDATE ATM", "Update ATM.", + atmJsonV510.copy(id = None, attributes = None), atmJsonV510, + List($AuthenticatedUserIsRequired, InvalidJsonFormat, UnknownError), + List(apiTagATM), + Some(List(canUpdateAtm, canUpdateAtmAtAnyBank)), + http4sPartialFunction = Some(updateAtm) + ) + + // getAtms / getAtm intentionally left to Lift's wrappedWithAuthCheck: + // ResponseHeadersTest exercises ETag + If-None-Match + If-Modified-Since on + // /banks/BANK_ID/atms (handled by APIUtil.checkConditionalRequest + + // getRequestHeadersNewStyle in Lift's response builder). ResourceDocMiddleware + // doesn't yet emit ETag headers or honour conditional headers, so migrating + // these endpoints here would regress those tests. APIMethods510 still has its + // own ResourceDoc, so resource-docs aggregation is unaffected. + + val deleteAtm: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / bankIdStr / "atms" / atmIdStr => + EndpointHelpers.withUserDelete(req) { (_, cc) => + val bankId = BankId(bankIdStr) + val atmId = AtmId(atmIdStr) + for { + (atm, _) <- NewStyle.function.getAtm(bankId, atmId, Some(cc)) + (deleted, _) <- NewStyle.function.deleteAtm(atm, Some(cc)) + (attrsDeleted, _) <- NewStyle.function.deleteAtmAttributesByAtmId(atmId, Some(cc)) + } yield deleted && attrsDeleted + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteAtm), "DELETE", + "/banks/BANK_ID/atms/ATM_ID", "Delete ATM", + "Delete ATM. This will also delete all its attributes.", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UnknownError), + List(apiTagATM), + Some(List(canDeleteAtmAtAnyBank, canDeleteAtm)), + http4sPartialFunction = Some(deleteAtm) + ) + + // ─── createConsumer / getConsumer / getConsumers — v5.1 overrides ────── + + val createConsumer: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "consumers" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { + user <- Future.successful(cc.user.openOrThrowException(AuthenticatedUserIsRequired)) + tup <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + val js = net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[CreateConsumerRequestJsonV510] + val appType = if (js.app_type.equals("Confidential")) AppType.valueOf("Confidential") else AppType.valueOf("Public") + (js, appType) + } + (postedJson, appType) = tup + (consumer, _) <- createConsumerNewStyle( + key = Some(Helpers.randomString(40).toLowerCase), + secret = Some(Helpers.randomString(40).toLowerCase), + isActive = Some(postedJson.enabled), + name = Some(postedJson.app_name), + appType = Some(appType), + description = Some(postedJson.description), + developerEmail = Some(postedJson.developer_email), + company = Some(postedJson.company), + redirectURL = Some(postedJson.redirect_url), + createdByUserId = Some(user.userId), + clientCertificate = postedJson.client_certificate, + logoURL = postedJson.logo_url, + Some(cc) + ) + } yield JSONFactory510.createConsumerJsonOnlyForPostResponseV510(consumer, None) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createConsumer), "POST", + "/management/consumers", "Create Consumer", + s"""Create a Consumer. + | + |${userAuthenticationMessage(true)}""", + createConsumerRequestJsonV510, consumerJsonOnlyForPostResponseV510, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagConsumer), + Some(List(canCreateConsumer)), + authMode = UserOrApplication, + http4sPartialFunction = Some(createConsumer) + ) + + val getConsumer: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "consumers" / consumerId => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { + consumer <- NewStyle.function.getConsumerByConsumerId(consumerId, Some(cc)) + user <- Users.users.vend.getUserByUserIdFuture(consumer.createdByUserId.get) + } yield createConsumerJSON(consumer, user) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getConsumer), "GET", + "/management/consumers/CONSUMER_ID", "Get Consumer", + "Get the Consumer specified by CONSUMER_ID.", + EmptyBody, consumerJSON, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, ConsumerNotFoundByConsumerId, UnknownError), + List(apiTagConsumer), + Some(List(canGetConsumers)), + http4sPartialFunction = Some(getConsumer) + ) + + val getConsumers: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "consumers" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { + httpParams <- NewStyle.function.extractHttpParamsFromUrl(req.uri.renderString) + (obpQueryParams, _) <- createQueriesByHttpParamsFuture(httpParams, Some(cc)) + consumers <- Consumers.consumers.vend.getConsumersFuture(obpQueryParams, Some(cc)) + } yield JSONFactory510.createConsumersJson(consumers) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getConsumers), "GET", + "/management/consumers", "Get Consumers", + s"""Get all Consumers. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, consumersJsonV510, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagConsumer), + Some(List(canGetConsumers)), + authMode = UserOrApplication, + http4sPartialFunction = Some(getConsumers) + ) + + // ─── getTransactionRequests (v5.1 — adds attributes filter) ───────────── + + val getTransactionRequests: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr / viewIdStr / "transaction-requests" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val bankId = BankId(bankIdStr) + val accountId = AccountId(accountIdStr) + val viewId = ViewId(viewIdStr) + for { + user <- Future.successful(cc.user.openOrThrowException(AuthenticatedUserIsRequired)) + _ <- NewStyle.function.isEnabledTransactionRequests(Some(cc)) + (_, _) <- NewStyle.function.getBank(bankId, Some(cc)) + (fromAccount, _) <- NewStyle.function.checkBankAccountExists(bankId, accountId, Some(cc)) + view <- ViewNewStyle.checkAccountAccessAndGetView(viewId, BankIdAccountId(bankId, accountId), Full(user), Some(cc)) + _ <- Helper.booleanToFuture( + s"${ErrorMessages.ViewDoesNotPermitAccess} You need the `${CAN_SEE_TRANSACTION_REQUESTS}` permission on the View(${viewId.value})", + cc = Some(cc)) { + view.allowed_actions.exists(_ == CAN_SEE_TRANSACTION_REQUESTS) + } + (transactionRequests, _) <- Future(Connector.connector.vend.getTransactionRequests210(user, fromAccount, Some(cc))) + .map(unboxFullOrFail(_, Some(cc), GetTransactionRequestsException)) + paramsMap = req.uri.query.multiParams.map { case (k, vs) => k -> vs.toList } + (transactionRequestAttributes, _) <- NewStyle.function.getByAttributeNameValues(bankId, paramsMap, true, Some(cc)) + transactionRequestIds = transactionRequestAttributes.map(_.transactionRequestId) + transactionRequestsFiltered = if (paramsMap.isEmpty) transactionRequests + else transactionRequests.filter(tr => transactionRequestIds.contains(tr.id)) + } yield JSONFactory510.createTransactionRequestJSONs(transactionRequestsFiltered, transactionRequestAttributes) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getTransactionRequests), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transaction-requests", + "Get Transaction Requests.", + "Returns transaction requests for account, with attribute filter support.", + EmptyBody, transactionRequestWithChargeJSONs210, + List(AuthenticatedUserIsRequired, BankNotFound, BankAccountNotFound, + UserNoPermissionAccessView, ViewDoesNotPermitAccess, + GetTransactionRequestsException, UnknownError), + List(apiTagTransactionRequest, apiTagPSD2PIS), + None, + http4sPartialFunction = Some(getTransactionRequests) + ) + + // ─── getBankAccountsBalances (v5.1 — same shape as v4 but in v5.1) ───── + + val getBankAccountsBalances: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankIdStr / "balances" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val bankId = BankId(bankIdStr) + for { + user <- Future.successful(cc.user.openOrThrowException(AuthenticatedUserIsRequired)) + (allowedAccounts, _) <- BalanceNewStyle.getAccountAccessAtBank(user, bankId, Some(cc)) + (accountsBalances, _) <- BalanceNewStyle.getBankAccountsBalances(allowedAccounts, Some(cc)) + } yield createBalancesJson(accountsBalances) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getBankAccountsBalances), "GET", + "/banks/BANK_ID/balances", "Get Account Balances by BANK_ID", + "Get the Balances for the Account specified by BANK_ID.", + EmptyBody, accountBalancesV400Json, + List($AuthenticatedUserIsRequired, $BankNotFound, UnknownError), + apiTagAccount :: apiTagPSD2AIS :: apiTagPsd2 :: Nil, + None, + http4sPartialFunction = Some(getBankAccountsBalances) + ) + + // ─── getAllBankAccountBalances (v5.1 override returns BankAccountBalancesJsonV510) + + val getAllBankAccountBalances: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr / "balances" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val accountId = AccountId(accountIdStr) + for { + _ <- Future.successful(cc.user.openOrThrowException(AuthenticatedUserIsRequired)) + (balances, _) <- code.api.util.newstyle.BankAccountBalanceNewStyle.getBankAccountBalances(accountId, Some(cc)) + } yield JSONFactory510.createBankAccountBalancesJson(balances) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAllBankAccountBalances), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/balances", "Get Account Balances", + s"""Get all balances for the Account specified by BANK_ID and ACCOUNT_ID. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, bankAccountBalancesJsonV510, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagAccount, apiTagBalance), + None, + http4sPartialFunction = Some(getAllBankAccountBalances) + ) + + // ─── allRoutes (chained as endpoints land below) ──────────────────────── + + val allRoutes: HttpRoutes[IO] = + Kleisli[HttpF, Request[IO], Response[IO]] { req: Request[IO] => + root(req) + .orElse(getMyConsentsByBank(req)) + .orElse(getAggregateMetrics(req)) + .orElse(createAtm(req)) + .orElse(updateAtm(req)) + .orElse(deleteAtm(req)) + .orElse(createConsumer(req)) + .orElse(getConsumer(req)) + .orElse(getConsumers(req)) + .orElse(getTransactionRequests(req)) + .orElse(getBankAccountsBalances(req)) + .orElse(getAllBankAccountBalances(req)) + } + + val allRoutesWithMiddleware: HttpRoutes[IO] = + ResourceDocMiddleware.apply(resourceDocs)(allRoutes) + + // ─── path-rewriting bridge: /obp/v5.1.0/… → /obp/v5.0.0/… ───────────── + val v510ToV500Bridge: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req => + val rawPath = req.uri.path.renderString + if (rawPath.startsWith("/obp/v5.1.0/")) { + val rewritten = rawPath.replaceFirst("/obp/v5\\.1\\.0/", "/obp/v5.0.0/") + val newUri = req.uri.withPath(Uri.Path.unsafeFromString(rewritten)) + val rewrittenReq = req.withUri(newUri) + Http4s500.wrappedRoutesV500Services.run(rewrittenReq) + } else { + OptionT.none[IO, Response[IO]] + } + } + } + + // Bridge cascade is currently DISABLED for v5.1.0: + // While migrating, unmigrated v5.1.0 endpoints (e.g. updateConsumerRedirectURL) + // would otherwise be sent through v510→v500→v400→… and either land on a + // wrong-version handler or never reach Lift's OBPAPI5_1_0 dispatch correctly. + // Without the bridge, unmatched v5.1.0 URLs fall through to Http4sLiftWebBridge + // unchanged, where Lift's dispatch for OBPAPI5_1_0 picks them up. Re-enable + // the bridge once ALL v5.1.0 own endpoints (currently 110) are migrated to + // Http4s510 — then `.orElse(Implementations5_1_0.v510ToV500Bridge.run(req))`. + val wrappedRoutesV510Services: HttpRoutes[IO] = + Implementations5_1_0.allRoutesWithMiddleware +} From 4229b74f06be653913460b348ad3366dd6514ba2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Thu, 14 May 2026 08:22:33 +0200 Subject: [PATCH 21/30] feat(v5.1.0): migrate 15 more own endpoints to Http4s510 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the second migration batch on top of the scaffold commit (8cea9b651). Total v5.1.0 own endpoints now in http4s: 27 (was 12). Newly migrated (15): System / UI / metrics - suggestedSessionTimeout (GET /ui/suggested-session-timeout) - getOAuth2ServerWellKnown (GET /well-known) - waitingForGodot (GET /waiting-for-godot) - getAllApiCollections (GET /management/api-collections) - updateMyApiCollection (PUT /my/api-collections/API_COLLECTION_ID) - getApiTags (GET /tags) - getMetrics (GET /management/metrics) - getWebUiProps (GET /webui-props) - mtlsClientCertificateInfo (GET /my/mtls/certificate/current) Regulated entities - regulatedEntities, getRegulatedEntityById, createRegulatedEntity, deleteRegulatedEntity (already in earlier batch — these stay) - createRegulatedEntityAttribute, deleteRegulatedEntityAttribute, getRegulatedEntityAttributeById, getAllRegulatedEntityAttributes, updateRegulatedEntityAttribute (5 new) Log cache (6 — shared logCacheHandler helper for trace/debug/info/warning/error/all) ATM attributes (5) - createAtmAttribute, getAtmAttributes, getAtmAttribute, updateAtmAttribute, deleteAtmAttribute Agents (3 of 4) - createAgent, getAgent, getAgents Deferred to Lift (3 of 14 + previous batch's 2) - getAtms, getAtm: ETag / If-None-Match / If-Modified-Since handling in Lift's response builder (APIUtil.checkConditionalRequest + getRequestHeadersNewStyle). ResourceDocMiddleware doesn't yet emit ETag or honour conditional headers, so migrating these regresses ResponseHeadersTest. - updateAgentStatus: AgentTest "wrong Bankid" expects 404 BankNotFound for unauthorised user1, which means Lift's wrappedWithAuthCheck role check passes here even though user1 lacks canUpdateAgentStatusAtAnyBank/canUpdateAgentStatusAtOneBank. ResourceDocMiddleware applies the same access-control function (with JIT entitlements) and returns 403 — the strict reading of the doc roles. Leaving updateAgentStatus in Lift preserves the established test contract until the discrepancy is root-caused (suspect: per-version Lift environment has additional fixtures). Tests: 239 v5.1.0 tests pass, 0 failures. The v510→v500 bridge is still disabled. Re-enable once all 110 v5.1.0 endpoints are migrated, the role-check parity is resolved, and ETag support lands in the http4s response wrapper. --- .../scala/code/api/v5_1_0/Http4s510.scala | 794 +++++++++++++++++- 1 file changed, 792 insertions(+), 2 deletions(-) diff --git a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala index 6b028d92de..b811544628 100644 --- a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala +++ b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala @@ -48,9 +48,10 @@ import com.github.dwickern.macros.NameOf.nameOf import com.openbankproject.commons.ExecutionContext.Implicits.global import com.openbankproject.commons.model.{ AccountId, AccountRouting, AtmId, AtmT, Bank, BankAccount, BankId, BankIdAccountId, - CustomerId, ProductCode, TransactionRequestId, User, View, ViewId + CustomerId, ListResult, ProductCode, RegulatedEntityId, TransactionRequestId, User, + View, ViewId } -import com.openbankproject.commons.model.enums.{StrongCustomerAuthentication, TransactionRequestStatus} +import com.openbankproject.commons.model.enums.{AtmAttributeType, RegulatedEntityAttributeType, StrongCustomerAuthentication, TransactionRequestStatus, UserAttributeType} import com.openbankproject.commons.util.{ApiVersion, ApiVersionStatus, ScannedApiVersion} import net.liftweb.common.{Box, Empty, Full} import net.liftweb.json @@ -485,6 +486,763 @@ object Http4s510 { // ─── allRoutes (chained as endpoints land below) ──────────────────────── + // ─── Simple GETs: suggestedSessionTimeout, well-known, regulatedEntities, + // waitingForGodot, getApiTags, mtlsClientCertificateInfo + // (plus log-cache×6, regulated-entities CRUD, getAllApiCollections) + + val suggestedSessionTimeout: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "ui" / "suggested-session-timeout" => + EndpointHelpers.executeFuture(req) { + Future(APIUtil.getPropsAsIntValue("session_inactivity_timeout_in_seconds", 300)) + .map(t => SuggestedSessionTimeoutV510(t.toString)) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(suggestedSessionTimeout), "GET", + "/ui/suggested-session-timeout", "Get Suggested Session Timeout", + "Returns the suggested session timeout in case of user inactivity.", + EmptyBody, SuggestedSessionTimeoutV510("300"), + List(UnknownError), apiTagApi :: Nil, None, + http4sPartialFunction = Some(suggestedSessionTimeout) + ) + + val getOAuth2ServerWellKnown: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "well-known" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { + (_, _) <- APIUtil.anonymousAccess(cc) + } yield { + val providerPropBox = APIUtil.getPropsValue("oauth2.oidc_provider") + val availableProviders = Map( + "obp-oidc" -> WellKnownUriJsonV510("obp-oidc", code.api.OAuth2Login.OBPOIDC.wellKnownOpenidConfiguration.toURL.toString), + "keycloak" -> WellKnownUriJsonV510("keycloak", code.api.OAuth2Login.Keycloak.wellKnownOpenidConfiguration.toURL.toString) + ) + val providersToShow: List[WellKnownUriJsonV510] = providerPropBox match { + case Empty => Nil + case Full(value) if value.trim.isEmpty => availableProviders.values.toList + case Full(value) => + val wanted = value.split(",").map(_.trim.toLowerCase).filter(_.nonEmpty).toSet + if (wanted.contains("none")) Nil + else availableProviders.filterKeys(wanted.contains).values.toList + case _ => Nil + } + WellKnownUrisJsonV510(providersToShow) + } + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, "getOAuth2ServerWellKnown", "GET", + "/well-known", "Get Well Known URIs", + "Get the OAuth2 server's public Well Known URIs.", + EmptyBody, oAuth2ServerJwksUrisJson, + List(UnknownError), List(apiTagApi), None, + http4sPartialFunction = Some(getOAuth2ServerWellKnown) + ) + + val regulatedEntities: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "regulated-entities" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { (entities, _) <- getRegulatedEntitiesNewStyle(Some(cc)) } + yield createRegulatedEntitiesJson(entities) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(regulatedEntities), "GET", + "/regulated-entities", "Get Regulated Entities", + "Returns information about Regulated Entities.", + EmptyBody, regulatedEntitiesJsonV510, + List(UnknownError), apiTagDirectory :: apiTagApi :: Nil, None, + http4sPartialFunction = Some(regulatedEntities) + ) + + val getRegulatedEntityById: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "regulated-entities" / regulatedEntityId => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { (entity, _) <- getRegulatedEntityByEntityIdNewStyle(regulatedEntityId, Some(cc)) } + yield createRegulatedEntityJson(entity) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getRegulatedEntityById), "GET", + "/regulated-entities/REGULATED_ENTITY_ID", "Get Regulated Entity", + "Get Regulated Entity By REGULATED_ENTITY_ID.", + EmptyBody, regulatedEntityJsonV510, + List(UnknownError), apiTagDirectory :: apiTagApi :: Nil, None, + http4sPartialFunction = Some(getRegulatedEntityById) + ) + + val createRegulatedEntity: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "regulated-entities" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val parsedBody = net.liftweb.json.parse(cc.httpBody.getOrElse("")) + val failMsg = s"$InvalidJsonFormat The Json body should be the $RegulatedEntityPostJsonV510 " + for { + postedData <- NewStyle.function.tryons(failMsg, 400, Some(cc)) { + parsedBody.extract[RegulatedEntityPostJsonV510] + } + servicesString <- NewStyle.function.tryons(s"$InvalidJsonFormat The `services` field is not valid JSON", 400, Some(cc)) { + prettyRender(postedData.services) + } + (entity, _) <- createRegulatedEntityNewStyle( + certificateAuthorityCaOwnerId = Some(postedData.certificate_authority_ca_owner_id), + entityCertificatePublicKey = Some(postedData.entity_certificate_public_key), + entityName = Some(postedData.entity_name), + entityCode = Some(postedData.entity_code), + entityType = Some(postedData.entity_type), + entityAddress = Some(postedData.entity_address), + entityTownCity = Some(postedData.entity_town_city), + entityPostCode = Some(postedData.entity_post_code), + entityCountry = Some(postedData.entity_country), + entityWebSite = Some(postedData.entity_web_site), + services = Some(servicesString), + Some(cc) + ) + } yield createRegulatedEntityJson(entity) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createRegulatedEntity), "POST", + "/regulated-entities", "Create Regulated Entity", + s"""Create Regulated Entity. + | + |${userAuthenticationMessage(true)}""", + regulatedEntityPostJsonV510, regulatedEntityJsonV510, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagDirectory, apiTagApi), + Some(List(canCreateRegulatedEntity)), + http4sPartialFunction = Some(createRegulatedEntity) + ) + + val deleteRegulatedEntity: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "regulated-entities" / regulatedEntityId => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { (deleted, _) <- deleteRegulatedEntityNewStyle(regulatedEntityId, Some(cc)) } + yield Full(deleted) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteRegulatedEntity), "DELETE", + "/regulated-entities/REGULATED_ENTITY_ID", "Delete Regulated Entity", + s"""Delete Regulated Entity specified by REGULATED_ENTITY_ID. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidConnectorResponse, UnknownError), + List(apiTagDirectory, apiTagApi), + Some(List(canDeleteRegulatedEntity)), + http4sPartialFunction = Some(deleteRegulatedEntity) + ) + + // ─── log-cache×6 (single helper) ─────────────────────────────────────── + + private def logCacheHandler(req: Request[IO], level: code.api.cache.RedisLogger.LogLevel.Value): IO[Response[IO]] = + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { + httpParams <- NewStyle.function.extractHttpParamsFromUrl(req.uri.renderString) + (obpQueryParams, _) <- createQueriesByHttpParamsFuture(httpParams, Some(cc)) + limit = obpQueryParams.collectFirst { case OBPLimit(value) => value } + offset = obpQueryParams.collectFirst { case OBPOffset(value) => value } + logs <- Future(code.api.cache.RedisLogger.getLogTail(level, limit, offset)) + } yield logs + } + + val logCacheTraceEndpoint: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "system" / "log-cache" / "trace" => + logCacheHandler(req, code.api.cache.RedisLogger.LogLevel.TRACE) + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(logCacheTraceEndpoint), "GET", + "/system/log-cache/trace", "Get Trace Level Log Cache", + "Returns TRACE level logs from the system log cache.", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UnknownError), + apiTagSystem :: apiTagApi :: apiTagLogCache :: Nil, + Some(List(canGetSystemLogCacheTrace, canGetSystemLogCacheAll)), + http4sPartialFunction = Some(logCacheTraceEndpoint) + ) + + val logCacheDebugEndpoint: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "system" / "log-cache" / "debug" => + logCacheHandler(req, code.api.cache.RedisLogger.LogLevel.DEBUG) + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(logCacheDebugEndpoint), "GET", + "/system/log-cache/debug", "Get Debug Level Log Cache", + "Returns DEBUG level logs from the system log cache.", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UnknownError), + apiTagSystem :: apiTagApi :: apiTagLogCache :: Nil, + Some(List(canGetSystemLogCacheDebug, canGetSystemLogCacheAll)), + http4sPartialFunction = Some(logCacheDebugEndpoint) + ) + + val logCacheInfoEndpoint: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "system" / "log-cache" / "info" => + logCacheHandler(req, code.api.cache.RedisLogger.LogLevel.INFO) + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(logCacheInfoEndpoint), "GET", + "/system/log-cache/info", "Get Info Level Log Cache", + "Returns INFO level logs from the system log cache.", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UnknownError), + apiTagSystem :: apiTagApi :: apiTagLogCache :: Nil, + Some(List(canGetSystemLogCacheInfo, canGetSystemLogCacheAll)), + http4sPartialFunction = Some(logCacheInfoEndpoint) + ) + + val logCacheWarningEndpoint: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "system" / "log-cache" / "warning" => + logCacheHandler(req, code.api.cache.RedisLogger.LogLevel.WARNING) + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(logCacheWarningEndpoint), "GET", + "/system/log-cache/warning", "Get Warning Level Log Cache", + "Returns WARNING level logs from the system log cache.", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UnknownError), + apiTagSystem :: apiTagApi :: apiTagLogCache :: Nil, + Some(List(canGetSystemLogCacheWarning, canGetSystemLogCacheAll)), + http4sPartialFunction = Some(logCacheWarningEndpoint) + ) + + val logCacheErrorEndpoint: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "system" / "log-cache" / "error" => + logCacheHandler(req, code.api.cache.RedisLogger.LogLevel.ERROR) + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(logCacheErrorEndpoint), "GET", + "/system/log-cache/error", "Get Error Level Log Cache", + "Returns ERROR level logs from the system log cache.", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UnknownError), + apiTagSystem :: apiTagApi :: apiTagLogCache :: Nil, + Some(List(canGetSystemLogCacheError, canGetSystemLogCacheAll)), + http4sPartialFunction = Some(logCacheErrorEndpoint) + ) + + val logCacheAllEndpoint: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "system" / "log-cache" / "all" => + logCacheHandler(req, code.api.cache.RedisLogger.LogLevel.ALL) + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(logCacheAllEndpoint), "GET", + "/system/log-cache/all", "Get All Level Log Cache", + "Returns logs of all levels from the system log cache.", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UnknownError), + apiTagSystem :: apiTagApi :: apiTagLogCache :: Nil, + Some(List(canGetSystemLogCacheAll)), + http4sPartialFunction = Some(logCacheAllEndpoint) + ) + + val waitingForGodot: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "waiting-for-godot" => + EndpointHelpers.executeFuture(req) { + val sleep = req.uri.query.params.get("sleep").getOrElse("0") + val sleepInMillis: Long = scala.util.Try(sleep.trim.toLong).getOrElse(0L) + for { _ <- Future(Thread.sleep(sleepInMillis)) } + yield JSONFactory510.waitingForGodot(sleepInMillis) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(waitingForGodot), "GET", + "/waiting-for-godot", "Waiting For Godot", + "Postpones response by `?sleep=N` ms (default 0).", + EmptyBody, WaitingForGodotJsonV510(50), + List(UnknownError, MandatoryPropertyIsNotSet), + apiTagApi :: Nil, None, + http4sPartialFunction = Some(waitingForGodot) + ) + + val getAllApiCollections: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "api-collections" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { (apiCollections, _) <- NewStyle.function.getAllApiCollections(Some(cc)) } + yield JSONFactory400.createApiCollectionsJsonV400(apiCollections) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAllApiCollections), "GET", + "/management/api-collections", "Get All API Collections", + s"""Get All API Collections. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, apiCollectionsJson400, + List(UserHasMissingRoles, UnknownError), + List(apiTagApiCollection), + Some(canGetAllApiCollections :: Nil), + http4sPartialFunction = Some(getAllApiCollections) + ) + + // ─── ATM attributes (5) ──────────────────────────────────────────────── + + val createAtmAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "atms" / atmIdStr / "attributes" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val bankId = BankId(bankIdStr); val atmId = AtmId(atmIdStr) + for { + (_, _) <- NewStyle.function.getAtm(bankId, atmId, Some(cc)) + postedData <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $AtmAttributeJsonV510 ", 400, Some(cc)) { + net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[AtmAttributeJsonV510] + } + attrType <- NewStyle.function.tryons( + s"$InvalidJsonFormat The `Type` field can only accept the following field: " + + s"${AtmAttributeType.DOUBLE}(12.1234), ${AtmAttributeType.STRING}(TAX_NUMBER), ${AtmAttributeType.INTEGER}(123) and ${AtmAttributeType.DATE_WITH_DAY}(2012-04-23)", + 400, Some(cc)) { AtmAttributeType.withName(postedData.`type`) } + (atmAttribute, _) <- NewStyle.function.createOrUpdateAtmAttribute( + bankId, atmId, None, postedData.name, attrType, postedData.value, postedData.is_active, Some(cc)) + } yield JSONFactory510.createAtmAttributeJson(atmAttribute) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createAtmAttribute), "POST", + "/banks/BANK_ID/atms/ATM_ID/attributes", "Create ATM Attribute", + "Create ATM Attribute. The type field must be one of STRING/INTEGER/DOUBLE/DATE_WITH_DAY.", + atmAttributeJsonV510, atmAttributeResponseJsonV510, + List($AuthenticatedUserIsRequired, $BankNotFound, InvalidJsonFormat, UnknownError), + List(apiTagATM, apiTagAtmAttribute, apiTagAttribute), + Some(List(canCreateAtmAttribute, canCreateAtmAttributeAtAnyBank)), + http4sPartialFunction = Some(createAtmAttribute) + ) + + val getAtmAttributes: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankIdStr / "atms" / atmIdStr / "attributes" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val bankId = BankId(bankIdStr); val atmId = AtmId(atmIdStr) + for { + (_, _) <- NewStyle.function.getAtm(bankId, atmId, Some(cc)) + (attributes, _) <- NewStyle.function.getAtmAttributesByAtm(bankId, atmId, Some(cc)) + } yield JSONFactory510.createAtmAttributesJson(attributes) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAtmAttributes), "GET", + "/banks/BANK_ID/atms/ATM_ID/attributes", "Get ATM Attributes", "Get ATM Attributes.", + EmptyBody, atmAttributesResponseJsonV510, + List($AuthenticatedUserIsRequired, $BankNotFound, InvalidJsonFormat, UnknownError), + List(apiTagATM, apiTagAtmAttribute, apiTagAttribute), + Some(List(canGetAtmAttribute, canGetAtmAttributeAtAnyBank)), + http4sPartialFunction = Some(getAtmAttributes) + ) + + val getAtmAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankIdStr / "atms" / atmIdStr / "attributes" / atmAttributeId => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val bankId = BankId(bankIdStr); val atmId = AtmId(atmIdStr) + for { + (_, _) <- NewStyle.function.getAtm(bankId, atmId, Some(cc)) + (attribute, _) <- NewStyle.function.getAtmAttributeById(atmAttributeId, Some(cc)) + } yield JSONFactory510.createAtmAttributeJson(attribute) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAtmAttribute), "GET", + "/banks/BANK_ID/atms/ATM_ID/attributes/ATM_ATTRIBUTE_ID", "Get ATM Attribute By ATM_ATTRIBUTE_ID", + "Get ATM Attribute By ATM_ATTRIBUTE_ID.", + EmptyBody, atmAttributeResponseJsonV510, + List($AuthenticatedUserIsRequired, $BankNotFound, InvalidJsonFormat, UnknownError), + List(apiTagATM, apiTagAtmAttribute, apiTagAttribute), + Some(List(canGetAtmAttribute, canGetAtmAttributeAtAnyBank)), + http4sPartialFunction = Some(getAtmAttribute) + ) + + val updateAtmAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / bankIdStr / "atms" / atmIdStr / "attributes" / atmAttributeId => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val bankId = BankId(bankIdStr); val atmId = AtmId(atmIdStr) + for { + (_, _) <- NewStyle.function.getAtm(bankId, atmId, Some(cc)) + postedData <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $AtmAttributeJsonV510 ", 400, Some(cc)) { + net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[AtmAttributeJsonV510] + } + attrType <- NewStyle.function.tryons( + s"$InvalidJsonFormat The `Type` field can only accept the following field: " + + s"${AtmAttributeType.DOUBLE}(12.1234), ${AtmAttributeType.STRING}(TAX_NUMBER), ${AtmAttributeType.INTEGER}(123) and ${AtmAttributeType.DATE_WITH_DAY}(2012-04-23)", + 400, Some(cc)) { AtmAttributeType.withName(postedData.`type`) } + (_, _) <- NewStyle.function.getAtmAttributeById(atmAttributeId, Some(cc)) + (atmAttribute, _) <- NewStyle.function.createOrUpdateAtmAttribute( + bankId, atmId, Some(atmAttributeId), postedData.name, attrType, postedData.value, postedData.is_active, Some(cc)) + } yield JSONFactory510.createAtmAttributeJson(atmAttribute) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateAtmAttribute), "PUT", + "/banks/BANK_ID/atms/ATM_ID/attributes/ATM_ATTRIBUTE_ID", "Update ATM Attribute", + "Update an ATM Attribute by its id.", + atmAttributeJsonV510, atmAttributeResponseJsonV510, + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, UnknownError), + List(apiTagATM, apiTagAtmAttribute, apiTagAttribute), + Some(List(canUpdateAtmAttribute, canUpdateAtmAttributeAtAnyBank)), + http4sPartialFunction = Some(updateAtmAttribute) + ) + + val deleteAtmAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / bankIdStr / "atms" / atmIdStr / "attributes" / atmAttributeId => + EndpointHelpers.withUserAndBankDelete(req) { (_, _, cc) => + val bankId = BankId(bankIdStr); val atmId = AtmId(atmIdStr) + for { + (_, _) <- NewStyle.function.getAtm(bankId, atmId, Some(cc)) + (deleted, _) <- NewStyle.function.deleteAtmAttribute(atmAttributeId, Some(cc)) + } yield deleted + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteAtmAttribute), "DELETE", + "/banks/BANK_ID/atms/ATM_ID/attributes/ATM_ATTRIBUTE_ID", "Delete ATM Attribute", + "Delete an ATM Attribute by its id.", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, UnknownError), + List(apiTagATM, apiTagAtmAttribute, apiTagAttribute), + Some(List(canDeleteAtmAttribute, canDeleteAtmAttributeAtAnyBank)), + http4sPartialFunction = Some(deleteAtmAttribute) + ) + + // ─── Agents (4) ───────────────────────────────────────────────────────── + + val createAgent: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "agents" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val bankId = BankId(bankIdStr) + for { + putData <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $PostAgentJsonV510 ", 400, Some(cc)) { + net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[PostAgentJsonV510] + } + (available, _) <- NewStyle.function.checkAgentNumberAvailable(bankId, putData.agent_number, Some(cc)) + _ <- Helper.booleanToFuture(s"$AgentNumberAlreadyExists Current agent_number(${putData.agent_number}) and Current bank_id(${bankId.value})", cc = Some(cc)) { available } + (agent, _) <- NewStyle.function.createAgent(bankId.value, putData.legal_name, putData.mobile_phone_number, putData.agent_number, Some(cc)) + (bankAccount, _) <- NewStyle.function.createBankAccount( + bankId, AccountId(APIUtil.generateUUID()), "AGENT", "AGENT", + putData.currency, 0, putData.legal_name, null, Nil, Some(cc)) + _ <- NewStyle.function.createAgentAccountLink(agent.agentId, bankAccount.bankId.value, bankAccount.accountId.value, Some(cc)) + } yield JSONFactory510.createAgentJson(agent, bankAccount) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createAgent), "POST", + "/banks/BANK_ID/agents", "Create Agent", + s"${userAuthenticationMessage(true)}", + postAgentJsonV510, agentJsonV510, + List($AuthenticatedUserIsRequired, $BankNotFound, InvalidJsonFormat, AgentNumberAlreadyExists, CreateAgentError, UnknownError), + List(apiTagCustomer, apiTagPerson), + None, + http4sPartialFunction = Some(createAgent) + ) + + // updateAgentStatus intentionally left to Lift: AgentTest "wrong Bankid" expects + // 404 BankNotFound for unauthorised user1, which means Lift's wrappedWithAuthCheck + // role check passes here even though user1 lacks + // canUpdateAgentStatusAtAnyBank/canUpdateAgentStatusAtOneBank. ResourceDocMiddleware + // applies the same access-control function (with JIT entitlements) and returns 403 + // — i.e. it's the strict reading of the doc roles. Leaving updateAgentStatus in + // Lift preserves the established test contract until the discrepancy is + // root-caused (suspect: the Lift test environment has additional entitlements + // wired in before this scenario via class-level or default-user fixtures). + + val getAgent: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankIdStr / "agents" / agentId => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { + (agent, _) <- NewStyle.function.getAgentByAgentId(agentId, Some(cc)) + (links, _) <- NewStyle.function.getAgentAccountLinksByAgentId(agentId, Some(cc)) + link <- NewStyle.function.tryons(AgentAccountLinkNotFound, 400, Some(cc)) { links.head } + (bankAccount, _) <- NewStyle.function.getBankAccount(BankId(link.bankId), AccountId(link.accountId), Some(cc)) + } yield JSONFactory510.createAgentJson(agent, bankAccount) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAgent), "GET", + "/banks/BANK_ID/agents/AGENT_ID", "Get Agent", + s"Get Agent.\n\n${userAuthenticationMessage(true)}", + EmptyBody, agentJsonV510, + List($AuthenticatedUserIsRequired, $BankNotFound, AgentNotFound, AgentAccountLinkNotFound, UnknownError), + List(apiTagAccount), + None, + http4sPartialFunction = Some(getAgent) + ) + + val getAgents: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankIdStr / "agents" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val bankId = BankId(bankIdStr) + for { + (requestParams, _) <- NewStyle.function.extractQueryParams(req.uri.renderString, List("limit", "offset", "sort_direction"), Some(cc)) + (agents, _) <- NewStyle.function.getAgents(bankId.value, requestParams, Some(cc)) + } yield JSONFactory510.createMinimalAgentsJson(agents) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAgents), "GET", + "/banks/BANK_ID/agents", "Get Agents at Bank", + s"Get Agents at Bank.\n\n${userAuthenticationMessage(false)}", + EmptyBody, minimalAgentsJsonV510, + List($BankNotFound, AgentsNotFound, UnknownError), + List(apiTagAccount), + None, + http4sPartialFunction = Some(getAgents) + ) + + // ─── Regulated entity attributes (5) ─────────────────────────────────── + + val createRegulatedEntityAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "regulated-entities" / entityIdStr / "attributes" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { + postedData <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $RegulatedEntityAttributeRequestJsonV510 ", 400, Some(cc)) { + net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[RegulatedEntityAttributeRequestJsonV510] + } + attrType <- NewStyle.function.tryons( + s"$InvalidJsonFormat The `Type` field can only accept the following field: " + + s"${RegulatedEntityAttributeType.DOUBLE}(12.1234), ${RegulatedEntityAttributeType.STRING}(TAX_NUMBER), ${RegulatedEntityAttributeType.INTEGER}(123) and ${RegulatedEntityAttributeType.DATE_WITH_DAY}(2012-04-23)", + 400, Some(cc)) { RegulatedEntityAttributeType.withName(postedData.attribute_type) } + (attribute, _) <- RegulatedEntityAttributeNewStyle.createOrUpdateRegulatedEntityAttribute( + regulatedEntityId = RegulatedEntityId(entityIdStr), + regulatedEntityAttributeId = None, + name = postedData.name, attributeType = attrType, + value = postedData.value, isActive = postedData.is_active, + callContext = Some(cc)) + } yield JSONFactory510.createRegulatedEntityAttributeJson(attribute) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createRegulatedEntityAttribute), "POST", + "/regulated-entities/REGULATED_ENTITY_ID/attributes", "Create Regulated Entity Attribute", + "Create a new Regulated Entity Attribute. Type must be STRING/INTEGER/DOUBLE/DATE_WITH_DAY.", + regulatedEntityAttributeRequestJsonV510, regulatedEntityAttributeResponseJsonV510, + List($AuthenticatedUserIsRequired, InvalidJsonFormat, UnknownError), + List(apiTagDirectory, apiTagApi), + Some(List(canCreateRegulatedEntityAttribute)), + http4sPartialFunction = Some(createRegulatedEntityAttribute) + ) + + val deleteRegulatedEntityAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "regulated-entities" / entityIdStr / "attributes" / attributeId => + EndpointHelpers.withUserDelete(req) { (_, cc) => + for { + (_, _) <- getRegulatedEntityByEntityIdNewStyle(entityIdStr, Some(cc)) + (deleted, _) <- RegulatedEntityAttributeNewStyle.deleteRegulatedEntityAttribute(attributeId, Some(cc)) + } yield deleted + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteRegulatedEntityAttribute), "DELETE", + "/regulated-entities/REGULATED_ENTITY_ID/attributes/REGULATED_ENTITY_ATTRIBUTE_ID", + "Delete Regulated Entity Attribute", + "Delete a Regulated Entity Attribute.", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UnknownError), + List(apiTagDirectory, apiTagApi), + Some(List(canDeleteRegulatedEntityAttribute)), + http4sPartialFunction = Some(deleteRegulatedEntityAttribute) + ) + + val getRegulatedEntityAttributeById: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "regulated-entities" / entityIdStr / "attributes" / attributeId => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { + (_, _) <- getRegulatedEntityByEntityIdNewStyle(entityIdStr, Some(cc)) + (attribute, _) <- RegulatedEntityAttributeNewStyle.getRegulatedEntityAttributeById(attributeId, Some(cc)) + } yield JSONFactory510.createRegulatedEntityAttributeJson(attribute) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getRegulatedEntityAttributeById), "GET", + "/regulated-entities/REGULATED_ENTITY_ID/attributes/REGULATED_ENTITY_ATTRIBUTE_ID", + "Get Regulated Entity Attribute By ID", "Get a specific Regulated Entity Attribute by its ID.", + EmptyBody, regulatedEntityAttributeResponseJsonV510, + List($AuthenticatedUserIsRequired, UnknownError), + List(apiTagDirectory, apiTagApi), + Some(List(canGetRegulatedEntityAttribute)), + http4sPartialFunction = Some(getRegulatedEntityAttributeById) + ) + + val getAllRegulatedEntityAttributes: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "regulated-entities" / entityIdStr / "attributes" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val entityId = RegulatedEntityId(entityIdStr) + for { + (_, _) <- getRegulatedEntityByEntityIdNewStyle(entityIdStr, Some(cc)) + (attributes, _) <- RegulatedEntityAttributeNewStyle.getRegulatedEntityAttributes(entityId, Some(cc)) + } yield JSONFactory510.createRegulatedEntityAttributesJson(attributes) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAllRegulatedEntityAttributes), "GET", + "/regulated-entities/REGULATED_ENTITY_ID/attributes", "Get All Regulated Entity Attributes", + "Get all attributes for the specified Regulated Entity.", + EmptyBody, regulatedEntityAttributesJsonV510, + List($AuthenticatedUserIsRequired, UnknownError), + List(apiTagDirectory, apiTagApi), + Some(List(canGetRegulatedEntityAttributes)), + http4sPartialFunction = Some(getAllRegulatedEntityAttributes) + ) + + val updateRegulatedEntityAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "regulated-entities" / entityIdStr / "attributes" / attributeId => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { + postedData <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $RegulatedEntityAttributeRequestJsonV510 ", 400, Some(cc)) { + net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[RegulatedEntityAttributeRequestJsonV510] + } + attrType <- NewStyle.function.tryons( + s"$InvalidJsonFormat The `Type` field can only accept the following field: " + + s"${RegulatedEntityAttributeType.DOUBLE}(12.1234), ${RegulatedEntityAttributeType.STRING}(TAX_NUMBER), ${RegulatedEntityAttributeType.INTEGER}(123) and ${RegulatedEntityAttributeType.DATE_WITH_DAY}(2012-04-23)", + 400, Some(cc)) { RegulatedEntityAttributeType.withName(postedData.attribute_type) } + (_, _) <- getRegulatedEntityByEntityIdNewStyle(entityIdStr, Some(cc)) + (updated, _) <- RegulatedEntityAttributeNewStyle.createOrUpdateRegulatedEntityAttribute( + regulatedEntityId = RegulatedEntityId(entityIdStr), + regulatedEntityAttributeId = Some(attributeId), + name = postedData.name, attributeType = attrType, + value = postedData.value, isActive = postedData.is_active, + callContext = Some(cc)) + } yield JSONFactory510.createRegulatedEntityAttributeJson(updated) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateRegulatedEntityAttribute), "PUT", + "/regulated-entities/REGULATED_ENTITY_ID/attributes/REGULATED_ENTITY_ATTRIBUTE_ID", + "Update Regulated Entity Attribute", "Update an existing Regulated Entity Attribute.", + regulatedEntityAttributeRequestJsonV510, regulatedEntityAttributeResponseJsonV510, + List($AuthenticatedUserIsRequired, InvalidJsonFormat, UnknownError), + List(apiTagDirectory, apiTagApi), + Some(List(canUpdateRegulatedEntityAttribute)), + http4sPartialFunction = Some(updateRegulatedEntityAttribute) + ) + + // ─── mtls / api-collection / api-tags / metrics / webui-props (5) ───── + + val mtlsClientCertificateInfo: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "my" / "mtls" / "certificate" / "current" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { + info <- Future(X509.getCertificateInfo(APIUtil.`getPSD2-CERT`(cc.requestHeaders))) + .map(unboxFullOrFail(_, Some(cc), X509GeneralError)) + } yield info + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(mtlsClientCertificateInfo), "GET", + "/my/mtls/certificate/current", "Provide client's certificate info of a current call", + "Provide client's certificate info of a current call specified by PSD2-CERT request header.", + EmptyBody, certificateInfoJsonV510, + List(AuthenticatedUserIsRequired, BankNotFound, UnknownError), + List(apiTagConsent, apiTagPSD2AIS, apiTagPsd2), + None, + http4sPartialFunction = Some(mtlsClientCertificateInfo) + ) + + val updateMyApiCollection: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "my" / "api-collections" / apiCollectionId => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { + putJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the ${classOf[code.api.v4_0_0.PostApiCollectionJson400].getSimpleName}", 400, Some(cc)) { + net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[code.api.v4_0_0.PostApiCollectionJson400] + } + (_, _) <- NewStyle.function.getApiCollectionById(apiCollectionId, Some(cc)) + (apiCollection, _) <- NewStyle.function.updateApiCollection( + apiCollectionId, putJson.api_collection_name, putJson.is_sharable, putJson.description.getOrElse(""), Some(cc)) + } yield JSONFactory400.createApiCollectionJsonV400(apiCollection) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateMyApiCollection), "PUT", + "/my/api-collections/API_COLLECTION_ID", "Update My Api Collection By API_COLLECTION_ID", + s"Update Api Collection for logged in user.\n\n${userAuthenticationMessage(true)}", + postApiCollectionJson400, apiCollectionJson400, + List($AuthenticatedUserIsRequired, InvalidJsonFormat, UserNotFoundByUserId, UnknownError), + List(apiTagApiCollection), + None, + http4sPartialFunction = Some(updateMyApiCollection) + ) + + val getApiTags: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "tags" => + EndpointHelpers.executeFuture(req) { + Future.successful(code.api.v5_1_0.APITags(code.api.util.ApiTag.allDisplayTagNames.toList)) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getApiTags), "GET", + "/tags", "Get API Tags", + s"Get API Tags.\n\n${userAuthenticationMessage(false)}", + EmptyBody, accountsMinimalJson400, + List(UnknownError), List(apiTagApi), None, + http4sPartialFunction = Some(getApiTags) + ) + + val getMetrics: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "metrics" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { + httpParams <- NewStyle.function.extractHttpParamsFromUrl(req.uri.renderString) + (obpQueryParams, _) <- createQueriesByHttpParamsFuture(httpParams, Some(cc)) + metrics <- Future(APIMetrics.apiMetrics.vend.getAllMetrics(obpQueryParams)) + } yield JSONFactory510.createMetricsJson(metrics) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getMetrics), "GET", + "/management/metrics", "Get Metrics", + "Get API metrics rows. Requires CanReadMetrics role.", + EmptyBody, metricsJsonV510, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagMetric, apiTagApi), + Some(List(canReadMetrics)), + http4sPartialFunction = Some(getMetrics) + ) + + val getWebUiProps: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "webui-props" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val active = req.uri.query.params.get("active").getOrElse("false") + for { + invalidMsg <- Future.successful(s"$InvalidFilterParameterFormat `active` must be a boolean, but current `active` value is: $active ") + isActive <- NewStyle.function.tryons(invalidMsg, 400, Some(cc)) { active.toBoolean } + explicitWebUiProps <- Future { MappedWebUiPropsProvider.getAll() } + implicitDeduped = if (isActive) { + val implicitProps = APIUtil.getWebUIPropsPairs.map(p => WebUiPropsCommons(p._1, p._2, webUiPropsId = Some("default"))) + if (explicitWebUiProps.nonEmpty) { + val dups: List[WebUiPropsCommons] = explicitWebUiProps.flatMap(e => implicitProps.filter(_.name == e.name)) + implicitProps diff dups + } else implicitProps.distinct + } else List.empty[WebUiPropsCommons] + } yield ListResult("webui_props", explicitWebUiProps ++ implicitDeduped) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getWebUiProps), "GET", + "/webui-props", "Get WebUiProps", + "Get all WebUiProps key/values. ?active=true also includes implicit (default) props.", + EmptyBody, + ListResult("webui-props", List(WebUiPropsCommons("webui_api_explorer_url", "https://apiexplorer.openbankproject.com", Some("web-ui-props-id")))), + List(UserHasMissingRoles, UnknownError), + List(apiTagWebUiProps), + None, + http4sPartialFunction = Some(getWebUiProps) + ) + val allRoutes: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req: Request[IO] => root(req) @@ -499,6 +1257,38 @@ object Http4s510 { .orElse(getTransactionRequests(req)) .orElse(getBankAccountsBalances(req)) .orElse(getAllBankAccountBalances(req)) + .orElse(suggestedSessionTimeout(req)) + .orElse(getOAuth2ServerWellKnown(req)) + .orElse(regulatedEntities(req)) + .orElse(getRegulatedEntityById(req)) + .orElse(createRegulatedEntity(req)) + .orElse(deleteRegulatedEntity(req)) + .orElse(logCacheTraceEndpoint(req)) + .orElse(logCacheDebugEndpoint(req)) + .orElse(logCacheInfoEndpoint(req)) + .orElse(logCacheWarningEndpoint(req)) + .orElse(logCacheErrorEndpoint(req)) + .orElse(logCacheAllEndpoint(req)) + .orElse(waitingForGodot(req)) + .orElse(getAllApiCollections(req)) + .orElse(createAtmAttribute(req)) + .orElse(getAtmAttributes(req)) + .orElse(getAtmAttribute(req)) + .orElse(updateAtmAttribute(req)) + .orElse(deleteAtmAttribute(req)) + .orElse(createAgent(req)) + .orElse(getAgent(req)) + .orElse(getAgents(req)) + .orElse(createRegulatedEntityAttribute(req)) + .orElse(deleteRegulatedEntityAttribute(req)) + .orElse(getRegulatedEntityAttributeById(req)) + .orElse(getAllRegulatedEntityAttributes(req)) + .orElse(updateRegulatedEntityAttribute(req)) + .orElse(mtlsClientCertificateInfo(req)) + .orElse(updateMyApiCollection(req)) + .orElse(getApiTags(req)) + .orElse(getMetrics(req)) + .orElse(getWebUiProps(req)) } val allRoutesWithMiddleware: HttpRoutes[IO] = From 89192135202d0643d6a68d9d4d6dffe977d9e1e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Thu, 14 May 2026 08:33:18 +0200 Subject: [PATCH 22/30] feat(v5.1.0): migrate 19 more endpoints (system + user + lock + integrity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the third migration batch on top of 4229b74f0. Total v5.1.0 own endpoints now in http4s: 46. Newly migrated (19): Non-personal user attributes (3) - createNonPersonalUserAttribute, deleteNonPersonalUserAttribute, getNonPersonalUserAttributes User / lock / sync (8) - syncExternalUser, getEntitlementsAndPermissions, getUserByProviderAndUsername, getUserLockStatus, unlockUserByProviderAndUsername, lockUserByProviderAndUsername, validateUserByUserId, getAccountAccessByUserId Customer helpers (2) - getCustomersForUserIdsOnly, getCustomersByLegalName System integrity (5) - customViewNamesCheck, systemViewNamesCheck, accountAccessUniqueIndexCheck, accountCurrencyCheck, orphanedAccountCheck Currencies (1) - getCurrenciesAtBank — note: scope check uses `failCode = 403`, since `Helper.booleanToFuture` defaults to 400 and CurrenciesTest's "without a proper scope" scenario asserts 403. Deferred (2) - getAccountsHeldByUserAtBank / getAccountsHeldByUser depend on AccountsHelper.getFilteredCoreAccounts which takes a Lift `Req`. Need to port the filter logic before migrating. Tests: 239 v5.1.0 tests pass, 0 failures. --- .../scala/code/api/v5_1_0/Http4s510.scala | 478 ++++++++++++++++++ 1 file changed, 478 insertions(+) diff --git a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala index b811544628..3b57f6158f 100644 --- a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala +++ b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala @@ -1243,6 +1243,464 @@ object Http4s510 { http4sPartialFunction = Some(getWebUiProps) ) + // ─── Non-personal user attributes (3) ───────────────────────────────── + + val createNonPersonalUserAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "users" / userId / "non-personal" / "attributes" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { + (user, _) <- NewStyle.function.getUserByUserId(userId, Some(cc)) + postedData <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $UserAttributeJsonV510 ", 400, Some(cc)) { + net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[UserAttributeJsonV510] + } + attrType <- NewStyle.function.tryons( + s"$InvalidJsonFormat The `Type` field can only accept the following field: " + + s"${UserAttributeType.DOUBLE}(12.1234), ${UserAttributeType.STRING}(TAX_NUMBER), ${UserAttributeType.INTEGER} (123)and ${UserAttributeType.DATE_WITH_DAY}(2012-04-23)", + 400, Some(cc)) { UserAttributeType.withName(postedData.`type`) } + (userAttribute, _) <- NewStyle.function.createOrUpdateUserAttribute( + user.userId, None, postedData.name, attrType, postedData.value, false, Some(cc)) + } yield JSONFactory510.createUserAttributeJson(userAttribute) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createNonPersonalUserAttribute), "POST", + "/users/USER_ID/non-personal/attributes", "Create Non Personal User Attribute", + s"Create Non Personal User Attribute. Type ∈ {STRING, INTEGER, DOUBLE, DATE_WITH_DAY}.\n\n${userAuthenticationMessage(true)}", + userAttributeJsonV510, userAttributeResponseJsonV510, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagUser), + Some(List(canCreateNonPersonalUserAttribute)), + http4sPartialFunction = Some(createNonPersonalUserAttribute) + ) + + val deleteNonPersonalUserAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "users" / userId / "non-personal" / "attributes" / userAttributeId => + EndpointHelpers.withUserDelete(req) { (_, cc) => + for { + (_, _) <- NewStyle.function.getUserByUserId(userId, Some(cc)) + (deleted, _) <- Connector.connector.vend.deleteUserAttribute(userAttributeId, Some(cc)) + .map(i => (connectorEmptyResponse(i._1, Some(cc)), i._2)) + } yield deleted + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteNonPersonalUserAttribute), "DELETE", + "/users/USER_ID/non-personal/attributes/USER_ATTRIBUTE_ID", "Delete Non Personal User Attribute", + s"Delete the Non Personal User Attribute.\n\n${userAuthenticationMessage(true)}", + EmptyBody, EmptyBody, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidConnectorResponse, UnknownError), + List(apiTagUser), + Some(List(canDeleteNonPersonalUserAttribute)), + http4sPartialFunction = Some(deleteNonPersonalUserAttribute) + ) + + val getNonPersonalUserAttributes: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "users" / userId / "non-personal" / "attributes" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { + (user, _) <- NewStyle.function.getUserByUserId(userId, Some(cc)) + (userAttributes, _) <- NewStyle.function.getNonPersonalUserAttributes(user.userId, Some(cc)) + } yield JSONFactory510.createUserAttributesJson(userAttributes) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getNonPersonalUserAttributes), "GET", + "/users/USER_ID/non-personal/attributes", "Get Non Personal User Attributes", + s"Get Non Personal User Attributes for a user.\n\n${userAuthenticationMessage(true)}", + EmptyBody, EmptyBody, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidConnectorResponse, UnknownError), + List(apiTagUser), + Some(List(canGetNonPersonalUserAttributes)), + http4sPartialFunction = Some(getNonPersonalUserAttributes) + ) + + // ─── User / lock / sync (8) ─────────────────────────────────────────── + + val syncExternalUser: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "users" / provider / providerId / "sync" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { + (user, _) <- NewStyle.function.getOrCreateResourceUser(provider, providerId, Some(cc)) + _ <- AuthUser.refreshUser(user, Some(cc)) + } yield JSONFactory510.getSyncedUser(user) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(syncExternalUser), "POST", + "/users/PROVIDER/PROVIDER_ID/sync", "Sync User", + s"Create or sync an OBP User with User from an external identity provider.\n\n${userAuthenticationMessage(true)}", + EmptyBody, refresUserJson, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagUser), + Some(List(canSyncUser)), + http4sPartialFunction = Some(syncExternalUser) + ) + + val getEntitlementsAndPermissions: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "users" / userId / "entitlements-and-permissions" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { + (user, _) <- NewStyle.function.getUserByUserId(userId, Some(cc)) + entitlements <- NewStyle.function.getEntitlementsByUserId(userId, Some(cc)) + } yield { + val permissions: Option[com.openbankproject.commons.model.Permission] = + Views.views.vend.getPermissionForUser(user).toOption + JSONFactory300.createUserInfoJSON(user, entitlements, permissions) + } + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getEntitlementsAndPermissions), "GET", + "/users/USER_ID/entitlements-and-permissions", "Get Entitlements and Permissions for a User", + "", + EmptyBody, userJsonV300, + List($AuthenticatedUserIsRequired, UserNotFoundByUserId, UserHasMissingRoles, UnknownError), + List(apiTagRole, apiTagEntitlement, apiTagUser), + Some(List(canGetEntitlementsForAnyUserAtAnyBank)), + http4sPartialFunction = Some(getEntitlementsAndPermissions) + ) + + val getUserByProviderAndUsername: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "users" / "provider" / provider / "username" / username => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { + user <- Users.users.vend.getUserByProviderAndUsernameFuture(URLDecoder.decode(provider, StandardCharsets.UTF_8), username) + .map(x => unboxFullOrFail(x, Some(cc), UserNotFoundByProviderAndUsername, 404)) + entitlements <- NewStyle.function.getEntitlementsByUserId(user.userId, Some(cc)) + isLocked = LoginAttempt.userIsLocked(user.provider, user.name) + authUser = AuthUser.find(By(AuthUser.user, user.userPrimaryKey.value)) + } yield JSONFactory510.createUserWithNamesJSON( + user, + authUser.map(_.firstName.get).getOrElse(""), + authUser.map(_.lastName.get).getOrElse(""), + entitlements, None, isLocked + ) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getUserByProviderAndUsername), "GET", + "/users/provider/PROVIDER/username/USERNAME", "Get User by Provider and Username", + s"Get a User by PROVIDER + USERNAME.\n\n${userAuthenticationMessage(true)}", + EmptyBody, userWithNamesJsonV510, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UserNotFoundByProviderAndUsername, UnknownError), + List(apiTagUser), + Some(List(canGetAnyUser)), + http4sPartialFunction = Some(getUserByProviderAndUsername) + ) + + val getUserLockStatus: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "users" / provider / username / "lock-status" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { + _ <- Users.users.vend.getUserByProviderAndUsernameFuture(provider, username) + .map(x => unboxFullOrFail(x, Some(cc), UserNotFoundByProviderAndUsername, 404)) + badLoginStatus <- Future(LoginAttempt.getOrCreateBadLoginStatus(provider, username)) + .map(unboxFullOrFail(_, Some(cc), s"$UserNotFoundByProviderAndUsername provider($provider), username($username)", 404)) + } yield createBadLoginStatusJson(badLoginStatus) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getUserLockStatus), "GET", + "/users/PROVIDER/USERNAME/lock-status", "Get User Lock Status", + s"Get User Login Status.\n\n${userAuthenticationMessage(true)}", + EmptyBody, badLoginStatusJson, + List(AuthenticatedUserIsRequired, UserNotFoundByProviderAndUsername, UserHasMissingRoles, UnknownError), + List(apiTagUser), + Some(List(canReadUserLockedStatus)), + http4sPartialFunction = Some(getUserLockStatus) + ) + + val unlockUserByProviderAndUsername: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "users" / provider / username / "lock-status" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { + _ <- Users.users.vend.getUserByProviderAndUsernameFuture(provider, username) + .map(x => unboxFullOrFail(x, Some(cc), UserNotFoundByProviderAndUsername, 404)) + _ <- Future(LoginAttempt.resetBadLoginAttempts(provider, username)) + _ <- Future(UserLocksProvider.unlockUser(provider, username)) + badLoginStatus <- Future(LoginAttempt.getOrCreateBadLoginStatus(provider, username)) + .map(unboxFullOrFail(_, Some(cc), s"$UserNotFoundByProviderAndUsername provider($provider), username($username)", 404)) + } yield createBadLoginStatusJson(badLoginStatus) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(unlockUserByProviderAndUsername), "PUT", + "/users/PROVIDER/USERNAME/lock-status", "Unlock the user", + s"Unlock a User (e.g. after multiple failed login attempts).\n\n${userAuthenticationMessage(true)}", + EmptyBody, badLoginStatusJson, + List(AuthenticatedUserIsRequired, UserNotFoundByProviderAndUsername, UserHasMissingRoles, UnknownError), + List(apiTagUser), + Some(List(canUnlockUser)), + http4sPartialFunction = Some(unlockUserByProviderAndUsername) + ) + + val lockUserByProviderAndUsername: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "users" / provider / username / "locks" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { + userLocks <- Future(UserLocksProvider.lockUser(provider, username)) + .map(unboxFullOrFail(_, Some(cc), s"$UserNotFoundByProviderAndUsername provider($provider), username($username)", 404)) + } yield JSONFactory400.createUserLockStatusJson(userLocks) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(lockUserByProviderAndUsername), "POST", + "/users/PROVIDER/USERNAME/locks", "Lock the user", + s"Lock a User.\n\n${userAuthenticationMessage(true)}", + EmptyBody, userLockStatusJson, + List($AuthenticatedUserIsRequired, UserNotFoundByProviderAndUsername, UserHasMissingRoles, UnknownError), + List(apiTagUser), + Some(List(canLockUser)), + http4sPartialFunction = Some(lockUserByProviderAndUsername) + ) + + val validateUserByUserId: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "management" / "users" / userId => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { + (user, _) <- NewStyle.function.findByUserId(userId, Some(cc)) + (userValidated, _) <- NewStyle.function.validateUser(user.userPrimaryKey, Some(cc)) + } yield UserValidatedJson(userValidated.validated.get) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(validateUserByUserId), "PUT", + "/management/users/USER_ID", "Validate a user", + "Manually validate a User by USER_ID. Sets is_validated=true.", + EmptyBody, UserValidatedJson(is_validated = true), + List($AuthenticatedUserIsRequired, UserNotFoundByUserId, UserHasMissingRoles, UnknownError), + List(apiTagUser), + Some(List(canValidateUser)), + http4sPartialFunction = Some(validateUserByUserId) + ) + + val getAccountAccessByUserId: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "users" / userId / "account-access" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { + (user, _) <- NewStyle.function.getUserByUserId(userId, Some(cc)) + (_, accountAccess) <- Future(Views.views.vend.privateViewsUserCanAccess(user)) + } yield JSONFactory400.createAccountsMinimalJson400(accountAccess) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAccountAccessByUserId), "GET", + "/users/USER_ID/account-access", "Get Account Access by USER_ID", + s"Get Account Access by USER_ID.\n\n${userAuthenticationMessage(true)}", + EmptyBody, accountsMinimalJson400, + List($AuthenticatedUserIsRequired, UserNotFoundByUserId, UnknownError), + List(apiTagAccount), + Some(List(canSeeAccountAccessForAnyUser)), + http4sPartialFunction = Some(getAccountAccessByUserId) + ) + + // ─── Accounts-held (2) — left to Lift ───────────────────────────────── + // getAccountsHeldByUserAtBank / getAccountsHeldByUser depend on + // AccountsHelper.getFilteredCoreAccounts which takes a Lift `Req`. Need + // to port the filter to http4s before migrating these. + + // ─── Customer helpers (2) ───────────────────────────────────────────── + + val getCustomersForUserIdsOnly: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "users" / "current" / "customers" / "customer_ids" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { + (customers, _) <- Connector.connector.vend.getCustomersByUserId(cc.userId, Some(cc)) + .map(connectorEmptyResponse(_, Some(cc))) + } yield JSONFactory510.createCustomersIds(customers) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCustomersForUserIdsOnly), "GET", + "/users/current/customers/customer_ids", "Get Customers for Current User (IDs only)", + s"Gets all Customer IDs linked to the current User.\n\n${userAuthenticationMessage(true)}", + EmptyBody, customersWithAttributesJsonV300, + List($AuthenticatedUserIsRequired, UserCustomerLinksNotFoundForUser, UnknownError), + List(apiTagCustomer, apiTagUser), + None, + http4sPartialFunction = Some(getCustomersForUserIdsOnly) + ) + + val getCustomersByLegalName: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "customers" / "legal-name" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val bankId = BankId(bankIdStr) + for { + (bank, _) <- NewStyle.function.getBank(bankId, Some(cc)) + postedData <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $PostCustomerLegalNameJsonV510 ", 400, Some(cc)) { + net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[PostCustomerLegalNameJsonV510] + } + (customer, _) <- NewStyle.function.getCustomersByCustomerLegalName(bank.bankId, postedData.legal_name, Some(cc)) + } yield JSONFactory300.createCustomersJson(customer) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCustomersByLegalName), "POST", + "/banks/BANK_ID/customers/legal-name", "Get Customers by Legal Name", + s"Gets the Customers specified by Legal Name.\n\n${userAuthenticationMessage(true)}", + postCustomerLegalNameJsonV510, customerJsonV310, + List(AuthenticatedUserIsRequired, UserCustomerLinksNotFoundForUser, UnknownError), + List(apiTagCustomer, apiTagKyc), + Some(List(canGetCustomersAtOneBank)), + http4sPartialFunction = Some(getCustomersByLegalName) + ) + + // ─── System integrity (5) + currencies (1) ──────────────────────────── + + val customViewNamesCheck: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "system" / "integrity" / "custom-view-names-check" => + EndpointHelpers.executeFuture(req) { + for { + incorrectViews: List[ViewDefinition] <- Future { + ViewDefinition.getCustomViews().filterNot(_.viewId.value.startsWith("_")) + } + } yield JSONFactory510.getCustomViewNamesCheck(incorrectViews) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(customViewNamesCheck), "GET", + "/management/system/integrity/custom-view-names-check", "Check Custom View Names", + s"Check custom view names.\n\n${userAuthenticationMessage(true)}", + EmptyBody, CheckSystemIntegrityJsonV510(true), + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagSystemIntegrity), + Some(canGetSystemIntegrity :: Nil), + http4sPartialFunction = Some(customViewNamesCheck) + ) + + val systemViewNamesCheck: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "system" / "integrity" / "system-view-names-check" => + EndpointHelpers.executeFuture(req) { + for { + incorrectViews: List[ViewDefinition] <- Future { + ViewDefinition.getSystemViews().filter(_.viewId.value.startsWith("_")) + } + } yield JSONFactory510.getSystemViewNamesCheck(incorrectViews) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(systemViewNamesCheck), "GET", + "/management/system/integrity/system-view-names-check", "Check System View Names", + s"Check system view names.\n\n${userAuthenticationMessage(true)}", + EmptyBody, CheckSystemIntegrityJsonV510(true), + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagSystemIntegrity), + Some(canGetSystemIntegrity :: Nil), + http4sPartialFunction = Some(systemViewNamesCheck) + ) + + val accountAccessUniqueIndexCheck: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "system" / "integrity" / "account-access-unique-index-1-check" => + EndpointHelpers.executeFuture(req) { + for { + groupedRows: Map[String, List[AccountAccess]] <- Future { + AccountAccess.findAll().groupBy { a => + s"${a.bank_id.get}-${a.account_id.get}-${a.view_id.get}-${a.user_fk.get}-${a.consumer_id.get}" + }.filter(_._2.size > 1) + } + } yield JSONFactory510.getAccountAccessUniqueIndexCheck(groupedRows) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(accountAccessUniqueIndexCheck), "GET", + "/management/system/integrity/account-access-unique-index-1-check", "Check Unique Index at Account Access", + s"Check unique index at account access table.\n\n${userAuthenticationMessage(true)}", + EmptyBody, CheckSystemIntegrityJsonV510(true), + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagSystemIntegrity), + Some(canGetSystemIntegrity :: Nil), + http4sPartialFunction = Some(accountAccessUniqueIndexCheck) + ) + + val accountCurrencyCheck: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "system" / "integrity" / "banks" / bankIdStr / "account-currency-check" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val bankId = BankId(bankIdStr) + for { + currencies: List[String] <- Future { + code.model.dataAccess.MappedBankAccount.findAll().map(_.accountCurrency.get).distinct + } + (bankCurrencies, _) <- NewStyle.function.getCurrentCurrencies(bankId, Some(cc)) + } yield JSONFactory510.getSensibleCurrenciesCheck(bankCurrencies, currencies) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(accountCurrencyCheck), "GET", + "/management/system/integrity/banks/BANK_ID/account-currency-check", "Check for Sensible Currencies", + s"Check for sensible currencies at Bank Account model.\n\n${userAuthenticationMessage(true)}", + EmptyBody, CheckSystemIntegrityJsonV510(true), + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagSystemIntegrity), + Some(canGetSystemIntegrity :: Nil), + http4sPartialFunction = Some(accountCurrencyCheck) + ) + + val orphanedAccountCheck: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "system" / "integrity" / "banks" / bankIdStr / "orphaned-account-check" => + EndpointHelpers.executeFuture(req) { + val bankId = BankId(bankIdStr) + for { + accountAccesses: List[String] <- Future { + AccountAccess.findAll(By(AccountAccess.bank_id, bankId.value)).map(_.account_id.get) + } + bankAccounts <- Future { + code.model.dataAccess.MappedBankAccount.findAll(By(code.model.dataAccess.MappedBankAccount.bank, bankId.value)).map(_.accountId.value) + } + } yield { + val orphaned = accountAccesses.filterNot(bankAccounts.contains) + JSONFactory510.getOrphanedAccountsCheck(orphaned) + } + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(orphanedAccountCheck), "GET", + "/management/system/integrity/banks/BANK_ID/orphaned-account-check", "Check for Orphaned Accounts", + s"Check for orphaned accounts at Bank Account model.\n\n${userAuthenticationMessage(true)}", + EmptyBody, CheckSystemIntegrityJsonV510(true), + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagSystemIntegrity), + Some(canGetSystemIntegrity :: Nil), + http4sPartialFunction = Some(orphanedAccountCheck) + ) + + val getCurrenciesAtBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankIdStr / "currencies" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val bankId = BankId(bankIdStr) + for { + _ <- Helper.booleanToFuture(ConsumerHasMissingRoles + CanReadFx, failCode = 403, cc = Some(cc)) { + checkScope(bankId.value, getConsumerPrimaryKey(Some(cc)), ApiRole.canReadFx) + } + (_, _) <- NewStyle.function.getBank(bankId, Some(cc)) + (currencies, _) <- NewStyle.function.getCurrentCurrencies(bankId, Some(cc)) + } yield CurrenciesJsonV510(currencies.map(CurrencyJsonV510(_))) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCurrenciesAtBank), "GET", + "/banks/BANK_ID/currencies", "Get Currencies at a Bank", + "Get Currencies specified by BANK_ID.", + EmptyBody, currenciesJsonV510, + List($AuthenticatedUserIsRequired, UnknownError), + List(apiTagFx), + None, + http4sPartialFunction = Some(getCurrenciesAtBank) + ) + val allRoutes: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req: Request[IO] => root(req) @@ -1289,6 +1747,26 @@ object Http4s510 { .orElse(getApiTags(req)) .orElse(getMetrics(req)) .orElse(getWebUiProps(req)) + .orElse(createNonPersonalUserAttribute(req)) + .orElse(deleteNonPersonalUserAttribute(req)) + .orElse(getNonPersonalUserAttributes(req)) + .orElse(syncExternalUser(req)) + .orElse(getEntitlementsAndPermissions(req)) + .orElse(getUserByProviderAndUsername(req)) + .orElse(getUserLockStatus(req)) + .orElse(unlockUserByProviderAndUsername(req)) + .orElse(lockUserByProviderAndUsername(req)) + .orElse(lockUserByProviderAndUsername(req)) + .orElse(validateUserByUserId(req)) + .orElse(getAccountAccessByUserId(req)) + .orElse(getCustomersForUserIdsOnly(req)) + .orElse(getCustomersByLegalName(req)) + .orElse(customViewNamesCheck(req)) + .orElse(systemViewNamesCheck(req)) + .orElse(accountAccessUniqueIndexCheck(req)) + .orElse(accountCurrencyCheck(req)) + .orElse(orphanedAccountCheck(req)) + .orElse(getCurrenciesAtBank(req)) } val allRoutesWithMiddleware: HttpRoutes[IO] = From 6b27fd332e5feac45738bcc31283728803de4bf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Thu, 14 May 2026 08:40:43 +0200 Subject: [PATCH 23/30] feat(v5.1.0): migrate 12 more (consumer mgmt + view-access + tx mgmt) Adds the fourth migration batch on top of 891921352. Total v5.1.0 own endpoints now in http4s: 58. Newly migrated (12): Consumer mgmt (7) - updateConsumerRedirectURL, updateConsumerLogoURL, updateConsumerCertificate, updateConsumerName, getCallsLimit, createMyConsumer, createConsumerDynamicRegistration View access (3) - grantUserAccessToViewById, revokeUserAccessToViewById, createUserWithAccountAccessById Transaction request management (2) - getTransactionRequestById, updateTransactionRequestStatus Tests: 239 v5.1.0 tests pass, 0 failures. --- .../scala/code/api/v5_1_0/Http4s510.scala | 412 ++++++++++++++++++ 1 file changed, 412 insertions(+) diff --git a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala index 3b57f6158f..69e7fea9cf 100644 --- a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala +++ b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala @@ -1701,6 +1701,406 @@ object Http4s510 { http4sPartialFunction = Some(getCurrenciesAtBank) ) + // ─── Consumer mgmt PUTs (4) + getCallsLimit + createMyConsumer + + // createConsumerDynamicRegistration (7 total) ─────────────────────── + + val updateConsumerRedirectURL: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "management" / "consumers" / consumerId / "consumer" / "redirect_url" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { + user <- Future.successful(cc.user.openOrThrowException(AuthenticatedUserIsRequired)) + _ <- APIUtil.getPropsAsBoolValue("consumers_enabled_by_default", false) match { + case true => Future.successful(Full(())) + case false => NewStyle.function.hasEntitlement("", user.userId, ApiRole.canUpdateConsumerRedirectUrl, Some(cc)) + } + postJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[ConsumerRedirectUrlJSON] + } + consumer <- NewStyle.function.getConsumerByConsumerId(consumerId, Some(cc)) + _ <- Helper.booleanToFuture(UserNoPermissionUpdateConsumer, 400, Some(cc)) { + consumer.createdByUserId.equals(user.userId) + } + updatedConsumer <- NewStyle.function.updateConsumer( + id = consumer.id.get, + isActive = Some(APIUtil.getPropsAsBoolValue("consumers_enabled_by_default", defaultValue = false)), + redirectURL = Some(postJson.redirect_url), + callContext = Some(cc)) + } yield JSONFactory510.createConsumerJSON(updatedConsumer) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateConsumerRedirectURL), "PUT", + "/management/consumers/CONSUMER_ID/consumer/redirect_url", "Update Consumer RedirectURL", + "Update an existing redirectUrl for a Consumer specified by CONSUMER_ID.", + consumerRedirectUrlJSON, consumerJSON, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagConsumer), + Some(List(canUpdateConsumerRedirectUrl)), + http4sPartialFunction = Some(updateConsumerRedirectURL) + ) + + val updateConsumerLogoURL: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "management" / "consumers" / consumerId / "consumer" / "logo_url" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { + postJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[ConsumerLogoUrlJson] + } + consumer <- NewStyle.function.getConsumerByConsumerId(consumerId, Some(cc)) + updatedConsumer <- NewStyle.function.updateConsumer( + id = consumer.id.get, logoURL = Some(postJson.logo_url), callContext = Some(cc)) + } yield JSONFactory510.createConsumerJSON(updatedConsumer) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateConsumerLogoURL), "PUT", + "/management/consumers/CONSUMER_ID/consumer/logo_url", "Update Consumer LogoURL", + "Update an existing logoURL for a Consumer specified by CONSUMER_ID.", + consumerLogoUrlJson, consumerJsonV510, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagConsumer), + Some(List(canUpdateConsumerLogoUrl)), + http4sPartialFunction = Some(updateConsumerLogoURL) + ) + + val updateConsumerCertificate: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "management" / "consumers" / consumerId / "consumer" / "certificate" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { + postJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[ConsumerCertificateJson] + } + consumer <- NewStyle.function.getConsumerByConsumerId(consumerId, Some(cc)) + updatedConsumer <- NewStyle.function.updateConsumer( + id = consumer.id.get, certificate = Some(postJson.certificate), callContext = Some(cc)) + } yield JSONFactory510.createConsumerJSON(updatedConsumer) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateConsumerCertificate), "PUT", + "/management/consumers/CONSUMER_ID/consumer/certificate", "Update Consumer Certificate", + "Update Certificate for a Consumer specified by CONSUMER_ID.", + consumerCertificateJson, consumerJsonV510, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagConsumer), + Some(List(canUpdateConsumerCertificate)), + http4sPartialFunction = Some(updateConsumerCertificate) + ) + + val updateConsumerName: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "management" / "consumers" / consumerId / "consumer" / "name" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { + postJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[ConsumerNameJson] + } + consumer <- NewStyle.function.getConsumerByConsumerId(consumerId, Some(cc)) + updatedConsumer <- NewStyle.function.updateConsumer( + id = consumer.id.get, name = Some(postJson.app_name), callContext = Some(cc)) + } yield JSONFactory510.createConsumerJSON(updatedConsumer) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateConsumerName), "PUT", + "/management/consumers/CONSUMER_ID/consumer/name", "Update Consumer Name", + "Update an existing name for a Consumer specified by CONSUMER_ID.", + consumerNameJson, consumerJsonV510, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagConsumer), + Some(List(canUpdateConsumerName)), + http4sPartialFunction = Some(updateConsumerName) + ) + + val getCallsLimit: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "consumers" / consumerId / "consumer" / "rate-limits" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { + _ <- NewStyle.function.getConsumerByConsumerId(consumerId, Some(cc)) + rateLimiting <- RateLimitingDI.rateLimiting.vend.getAllByConsumerId(consumerId, None) + } yield createCallLimitJson(rateLimiting) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCallsLimit), "GET", + "/management/consumers/CONSUMER_ID/consumer/rate-limits", "Get Rate Limits for a Consumer", + s"Get Calls limits per Consumer.\n\n${userAuthenticationMessage(true)}", + EmptyBody, callLimitsJson510Example, + List($AuthenticatedUserIsRequired, InvalidJsonFormat, InvalidConsumerId, ConsumerNotFoundByConsumerId, + UserHasMissingRoles, UpdateConsumerError, UnknownError), + List(apiTagConsumer), + Some(List(canReadCallLimits)), + http4sPartialFunction = Some(getCallsLimit) + ) + + val createMyConsumer: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "my" / "consumers" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { + user <- Future.successful(cc.user.openOrThrowException(AuthenticatedUserIsRequired)) + tup <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + val js = net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[CreateConsumerRequestJsonV510] + val appType = if (js.app_type.equals("Confidential")) AppType.valueOf("Confidential") else AppType.valueOf("Public") + (js, appType) + } + (postedJson, appType) = tup + (consumer, _) <- createConsumerNewStyle( + key = Some(Helpers.randomString(40).toLowerCase), + secret = Some(Helpers.randomString(40).toLowerCase), + isActive = Some(postedJson.enabled), + name = Some(postedJson.app_name), + appType = Some(appType), + description = Some(postedJson.description), + developerEmail = Some(postedJson.developer_email), + company = Some(postedJson.company), + redirectURL = Some(postedJson.redirect_url), + createdByUserId = Some(user.userId), + clientCertificate = postedJson.client_certificate, + logoURL = postedJson.logo_url, + Some(cc) + ) + } yield JSONFactory510.createConsumerJsonOnlyForPostResponseV510(consumer, None) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createMyConsumer), "POST", + "/my/consumers", "Create a Consumer", + "Create a Consumer (Authenticated access).", + createConsumerRequestJsonV510, consumerJsonV510, + List(AuthenticatedUserIsRequired, InvalidJsonFormat, UnknownError), + List(apiTagConsumer), + None, + http4sPartialFunction = Some(createMyConsumer) + ) + + val createConsumerDynamicRegistration: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "dynamic-registration" / "consumers" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { + postedJwt <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[ConsumerJwtPostJsonV510] + } + pem = APIUtil.`getPSD2-CERT`(cc.requestHeaders) + _ <- Helper.booleanToFuture(PostJsonIsNotSigned, 400, Some(cc)) { + JwtUtil.verifyJwt(postedJwt.jwt, pem.getOrElse("")) + } + postedJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + net.liftweb.json.parse(JwtUtil.getSignedPayloadAsJson(postedJwt.jwt).getOrElse("{}")).extract[ConsumerPostJsonV510] + } + certificateInfo: CertificateInfoJsonV510 <- Future(X509.getCertificateInfo(pem)) + .map(unboxFullOrFail(_, Some(cc), X509GeneralError)) + _ <- Helper.booleanToFuture(RegulatedEntityNotFoundByCertificate, 400, Some(cc)) { + MappedRegulatedEntityProvider.getRegulatedEntities() + .exists(_.entityCertificatePublicKey.replace("""\n""", "") == pem.getOrElse("").replace("""\n""", "")) + } + (consumer, _) <- createConsumerNewStyle( + key = Some(Helpers.randomString(40).toLowerCase), + secret = Some(Helpers.randomString(40).toLowerCase), + isActive = Some(true), + name = X509.getCommonName(pem).or(postedJson.app_name), + appType = postedJson.app_type.map(AppType.valueOf).orElse(Some(AppType.valueOf("Confidential"))), + description = Some(postedJson.description), + developerEmail = X509.getEmailAddress(pem).or(postedJson.developer_email), + company = X509.getOrganization(pem), + redirectURL = postedJson.redirect_url, + createdByUserId = None, + clientCertificate = pem, + logoURL = None, + Some(cc) + ) + } yield JSONFactory510.createConsumerJSON(consumer, Some(certificateInfo)) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createConsumerDynamicRegistration), "POST", + "/dynamic-registration/consumers", "Create a Consumer(Dynamic Registration)", + "Create a Consumer with full certificate validation (mTLS access) — recommended for PSD2/Berlin Group compliance.", + ConsumerJwtPostJsonV510(""), consumerJsonV510, + List(InvalidJsonFormat, UnknownError), + List(apiTagDirectory, apiTagConsumer), + Some(Nil), + http4sPartialFunction = Some(createConsumerDynamicRegistration) + ) + + // ─── View access (3) + transaction-request mgmt (2) ─────────────────── + + val grantUserAccessToViewById: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr / "views" / viewIdStr / "account-access" / "grant" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val bankId = BankId(bankIdStr); val accountId = AccountId(accountIdStr); val viewId = ViewId(viewIdStr) + for { + user <- Future.successful(cc.user.openOrThrowException(AuthenticatedUserIsRequired)) + postJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $PostAccountAccessJsonV510 ", 400, Some(cc)) { + net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[PostAccountAccessJsonV510] + } + targetViewId = ViewId(postJson.view_id) + msg = getUserLacksGrantPermissionErrorMessage(viewId, targetViewId) + _ <- Helper.booleanToFuture(msg, 403, cc = Some(cc)) { + APIUtil.canGrantAccessToView(com.openbankproject.commons.model.BankIdAccountIdViewId(bankId, accountId, viewId), targetViewId, user, Some(cc)) + } + (targetUser, _) <- NewStyle.function.findByUserId(postJson.user_id, Some(cc)) + view <- if (isValidSystemViewId(targetViewId.value)) ViewNewStyle.systemView(targetViewId, Some(cc)) + else ViewNewStyle.customView(targetViewId, BankIdAccountId(bankId, accountId), Some(cc)) + addedView <- JSONFactory400.grantAccountAccessToUser(bankId, accountId, targetUser, view, Some(cc)) + } yield JSONFactory300.createViewJSON(addedView) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(grantUserAccessToViewById), "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/account-access/grant", "Grant User access to View", + "Grants the User identified by USER_ID access to the view on a bank account identified by VIEW_ID.", + postAccountAccessJsonV510, viewJsonV300, + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, $UserNoPermissionAccessView, + UserLacksPermissionCanGrantAccessToSystemViewForTargetAccount, + UserLacksPermissionCanGrantAccessToCustomViewForTargetAccount, + InvalidJsonFormat, UserNotFoundById, SystemViewNotFound, ViewNotFound, + CannotGrantAccountAccess, UnknownError), + List(apiTagAccountAccess, apiTagView, apiTagAccount, apiTagUser, apiTagOwnerRequired), + None, + http4sPartialFunction = Some(grantUserAccessToViewById) + ) + + val revokeUserAccessToViewById: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr / "views" / viewIdStr / "account-access" / "revoke" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val bankId = BankId(bankIdStr); val accountId = AccountId(accountIdStr); val viewId = ViewId(viewIdStr) + for { + user <- Future.successful(cc.user.openOrThrowException(AuthenticatedUserIsRequired)) + postJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the ${classOf[code.api.v4_0_0.PostAccountAccessJsonV400].getSimpleName} ", 400, Some(cc)) { + net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[PostAccountAccessJsonV510] + } + targetViewId = ViewId(postJson.view_id) + msg = getUserLacksRevokePermissionErrorMessage(viewId, targetViewId) + _ <- Helper.booleanToFuture(msg, 403, cc = Some(cc)) { + APIUtil.canRevokeAccessToView(com.openbankproject.commons.model.BankIdAccountIdViewId(bankId, accountId, viewId), targetViewId, user, Some(cc)) + } + (targetUser, _) <- NewStyle.function.findByUserId(postJson.user_id, Some(cc)) + view <- if (isValidSystemViewId(targetViewId.value)) ViewNewStyle.systemView(targetViewId, Some(cc)) + else ViewNewStyle.customView(targetViewId, BankIdAccountId(bankId, accountId), Some(cc)) + revoked <- if (isValidSystemViewId(targetViewId.value)) + ViewNewStyle.revokeAccessToSystemView(bankId, accountId, view, targetUser, Some(cc)) + else ViewNewStyle.revokeAccessToCustomView(view, targetUser, Some(cc)) + } yield code.api.v4_0_0.RevokedJsonV400(revoked) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(revokeUserAccessToViewById), "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/account-access/revoke", "Revoke User access to View", + "Revoke the User identified by USER_ID access to the view identified.", + postAccountAccessJsonV510, revokedJsonV400, + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, $UserNoPermissionAccessView, + UserLacksPermissionCanRevokeAccessToCustomViewForTargetAccount, + UserLacksPermissionCanRevokeAccessToSystemViewForTargetAccount, + InvalidJsonFormat, UserNotFoundById, SystemViewNotFound, ViewNotFound, + CannotRevokeAccountAccess, CannotFindAccountAccess, UnknownError), + List(apiTagAccountAccess, apiTagView, apiTagAccount, apiTagUser, apiTagOwnerRequired), + None, + http4sPartialFunction = Some(revokeUserAccessToViewById) + ) + + val createUserWithAccountAccessById: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr / "views" / viewIdStr / "user-account-access" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val bankId = BankId(bankIdStr); val accountId = AccountId(accountIdStr); val viewId = ViewId(viewIdStr) + for { + user <- Future.successful(cc.user.openOrThrowException(AuthenticatedUserIsRequired)) + postJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $PostCreateUserAccountAccessJsonV510 ", 400, Some(cc)) { + net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[PostCreateUserAccountAccessJsonV510] + } + _ <- Helper.booleanToFuture(s"$InvalidUserProvider The user.provider must be start with 'dauth.'", cc = Some(cc)) { + postJson.provider.startsWith("dauth.") + } + targetViewId = ViewId(postJson.view_id) + msg = getUserLacksGrantPermissionErrorMessage(viewId, targetViewId) + _ <- Helper.booleanToFuture(msg, 403, cc = Some(cc)) { + APIUtil.canGrantAccessToView(com.openbankproject.commons.model.BankIdAccountIdViewId(bankId, accountId, viewId), targetViewId, user, Some(cc)) + } + (targetUser, _) <- NewStyle.function.getOrCreateResourceUser(postJson.provider, postJson.username, Some(cc)) + view <- if (isValidSystemViewId(targetViewId.value)) ViewNewStyle.systemView(targetViewId, Some(cc)) + else ViewNewStyle.customView(targetViewId, BankIdAccountId(bankId, accountId), Some(cc)) + addedView <- if (isValidSystemViewId(targetViewId.value)) + ViewNewStyle.grantAccessToSystemView(bankId, accountId, view, targetUser, Some(cc)) + else ViewNewStyle.grantAccessToCustomView(view, targetUser, Some(cc)) + } yield JSONFactory300.createViewJSON(addedView) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createUserWithAccountAccessById), "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/user-account-access", "Create (DAuth) User with Account Access", + "Grant access to account/transaction data to a smart contract on the blockchain.", + postCreateUserAccountAccessJsonV400, List(viewJsonV300), + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, $UserNoPermissionAccessView, + UserLacksPermissionCanGrantAccessToSystemViewForTargetAccount, + UserLacksPermissionCanGrantAccessToCustomViewForTargetAccount, + InvalidJsonFormat, SystemViewNotFound, ViewNotFound, CannotGrantAccountAccess, UnknownError), + List(apiTagAccountAccess, apiTagView, apiTagAccount, apiTagUser, apiTagOwnerRequired, apiTagDAuth), + None, + http4sPartialFunction = Some(createUserWithAccountAccessById) + ) + + val getTransactionRequestById: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "transaction-requests" / requestIdStr => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val requestId = TransactionRequestId(requestIdStr) + for { + user <- Future.successful(cc.user.openOrThrowException(AuthenticatedUserIsRequired)) + (transactionRequest, _) <- NewStyle.function.getTransactionRequestImpl(requestId, Some(cc)) + _ <- NewStyle.function.hasAtLeastOneEntitlement(transactionRequest.from.bank_id, user.userId, + canGetTransactionRequestAtOneBank :: canGetTransactionRequestAtAnyBank :: Nil, Some(cc)) + } yield JSONFactory210.createTransactionRequestWithChargeJSON(transactionRequest) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getTransactionRequestById), "GET", + "/management/transaction-requests/TRANSACTION_REQUEST_ID", "Get Transaction Request by ID.", + "Returns transaction request specified by TRANSACTION_REQUEST_ID.", + EmptyBody, transactionRequestWithChargeJSON210, + List($AuthenticatedUserIsRequired, GetTransactionRequestsException, UnknownError), + List(apiTagTransactionRequest, apiTagPSD2PIS, apiTagPsd2), + Some(List(canGetTransactionRequestAtOneBank, canGetTransactionRequestAtAnyBank)), + http4sPartialFunction = Some(getTransactionRequestById) + ) + + val updateTransactionRequestStatus: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "management" / "transaction-requests" / requestIdStr => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val requestId = TransactionRequestId(requestIdStr) + for { + user <- Future.successful(cc.user.openOrThrowException(AuthenticatedUserIsRequired)) + postedData <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $PostTransactionRequestStatusJsonV510", 400, Some(cc)) { + net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[PostTransactionRequestStatusJsonV510] + } + (existing, _) <- NewStyle.function.getTransactionRequestImpl(requestId, Some(cc)) + _ <- NewStyle.function.hasAtLeastOneEntitlement(existing.from.bank_id, user.userId, + canUpdateTransactionRequestStatusAtOneBank :: canUpdateTransactionRequestStatusAtAnyBank :: Nil, Some(cc)) + _ <- NewStyle.function.saveTransactionRequestStatusImpl(requestId, postedData.status, Some(cc)) + (transactionRequest, _) <- NewStyle.function.getTransactionRequestImpl(requestId, Some(cc)) + } yield TransactionRequestStatusJsonV510(transactionRequest.id.value, transactionRequest.status) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateTransactionRequestStatus), "PUT", + "/management/transaction-requests/TRANSACTION_REQUEST_ID", "Update Transaction Request Status", + s"Update Transaction Request Status.\n\n${userAuthenticationMessage(true)}", + PostTransactionRequestStatusJsonV510(TransactionRequestStatus.COMPLETED.toString), + PostTransactionRequestStatusJsonV510(TransactionRequestStatus.COMPLETED.toString), + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, InvalidJsonFormat, UnknownError), + List(apiTagTransactionRequest), + Some(List(canUpdateTransactionRequestStatusAtOneBank, canUpdateTransactionRequestStatusAtAnyBank)), + http4sPartialFunction = Some(updateTransactionRequestStatus) + ) + val allRoutes: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req: Request[IO] => root(req) @@ -1767,6 +2167,18 @@ object Http4s510 { .orElse(accountCurrencyCheck(req)) .orElse(orphanedAccountCheck(req)) .orElse(getCurrenciesAtBank(req)) + .orElse(updateConsumerRedirectURL(req)) + .orElse(updateConsumerLogoURL(req)) + .orElse(updateConsumerCertificate(req)) + .orElse(updateConsumerName(req)) + .orElse(getCallsLimit(req)) + .orElse(createMyConsumer(req)) + .orElse(createConsumerDynamicRegistration(req)) + .orElse(grantUserAccessToViewById(req)) + .orElse(revokeUserAccessToViewById(req)) + .orElse(createUserWithAccountAccessById(req)) + .orElse(getTransactionRequestById(req)) + .orElse(updateTransactionRequestStatus(req)) } val allRoutesWithMiddleware: HttpRoutes[IO] = From c7914997b9095ee8fb1298b1e4e6795775ae9a3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Thu, 14 May 2026 09:01:46 +0200 Subject: [PATCH 24/30] feat(v5.1.0): migrate 18 more (views + counterparty-limits + balances + perms) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the fifth migration batch on top of 6b27fd332. Total v5.1.0 own endpoints now in http4s: 76 of 110. Newly migrated (18): View account/balance reads (3) - getCoreAccountByIdThroughView - getBankAccountBalances - getBankAccountsBalancesThroughView Counterparty limits (4 of 5) - createCounterpartyLimit, updateCounterpartyLimit, getCounterpartyLimit, deleteCounterpartyLimit (getCounterpartyLimitStatus deferred — 200+ line monthly/yearly transaction aggregation, lots of Date arithmetic) Custom view CRUD (4) - createCustomView (uses withViewCreated for 201), updateCustomView (200), getCustomView (200), deleteCustomView (uses executeDelete for 204 — middleware populates cc.view, cc.bankAccount; reads them inline) Bank account balance CRUD (4) - createBankAccountBalance, getBankAccountBalanceById, updateBankAccountBalance, deleteBankAccountBalance System view permissions (2) - addSystemViewPermission, deleteSystemViewPermission Note on createCustomView: original Lift handler returns 201, so used withViewCreated. Initial draft used withView (returns 200), which broke the CustomViewTest "We will call the endpoint" assertion. Tests: 239 v5.1.0 tests pass, 0 failures. Remaining unmigrated (34): consents family (12), getCounterpartyLimitStatus (1), getAtms/getAtm (2 — ETag), updateAgentStatus (1 — role-check parity), getAccountsHeldByUserAtBank/getAccountsHeldByUser (2 — account-type filter port), plus tail of various other endpoints. --- .../scala/code/api/v5_1_0/Http4s510.scala | 511 +++++++++++++++++- 1 file changed, 508 insertions(+), 3 deletions(-) diff --git a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala index 69e7fea9cf..33db004ffb 100644 --- a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala +++ b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala @@ -47,9 +47,9 @@ import code.webuiprops.{MappedWebUiPropsProvider, WebUiPropsCommons} import com.github.dwickern.macros.NameOf.nameOf import com.openbankproject.commons.ExecutionContext.Implicits.global import com.openbankproject.commons.model.{ - AccountId, AccountRouting, AtmId, AtmT, Bank, BankAccount, BankId, BankIdAccountId, - CustomerId, ListResult, ProductCode, RegulatedEntityId, TransactionRequestId, User, - View, ViewId + AccountId, AccountRouting, AtmId, AtmT, BalanceId, Bank, BankAccount, BankId, + BankIdAccountId, CounterpartyId, CustomerId, ListResult, ProductCode, + RegulatedEntityId, TransactionRequestId, User, View, ViewId } import com.openbankproject.commons.model.enums.{AtmAttributeType, RegulatedEntityAttributeType, StrongCustomerAuthentication, TransactionRequestStatus, UserAttributeType} import com.openbankproject.commons.util.{ApiVersion, ApiVersionStatus, ScannedApiVersion} @@ -2101,6 +2101,494 @@ object Http4s510 { http4sPartialFunction = Some(updateTransactionRequestStatus) ) + // ─── View account/balance reads (3) ─────────────────────────────────── + + val getCoreAccountByIdThroughView: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr / "views" / viewIdStr => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val bankId = BankId(bankIdStr); val accountId = AccountId(accountIdStr); val viewId = ViewId(viewIdStr) + for { + user <- Future.successful(cc.user.openOrThrowException(AuthenticatedUserIsRequired)) + (account, _) <- NewStyle.function.checkBankAccountExists(bankId, accountId, Some(cc)) + view <- ViewNewStyle.checkViewAccessAndReturnView(viewId, BankIdAccountId(bankId, accountId), Full(user), Some(cc)) + moderatedAccount <- NewStyle.function.moderatedBankAccountCore(account, view, Full(user), Some(cc)) + } yield { + val availableViews: List[View] = Views.views.vend.privateViewsUserCanAccessForAccount(user, BankIdAccountId(bankId, accountId)) + createNewCoreBankAccountJson(moderatedAccount, availableViews) + } + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCoreAccountByIdThroughView), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID", "Get Account by Id (Core) through the VIEW_ID", + "Information returned about the account through VIEW_ID.", + EmptyBody, moderatedCoreAccountJsonV400, + List($AuthenticatedUserIsRequired, $BankAccountNotFound, UnknownError), + apiTagAccount :: apiTagPSD2AIS :: apiTagPsd2 :: Nil, + None, + http4sPartialFunction = Some(getCoreAccountByIdThroughView) + ) + + val getBankAccountBalances: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr / "views" / viewIdStr / "balances" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val bankId = BankId(bankIdStr); val accountId = AccountId(accountIdStr); val viewId = ViewId(viewIdStr) + val bankIdAccountId = BankIdAccountId(bankId, accountId) + for { + user <- Future.successful(cc.user.openOrThrowException(AuthenticatedUserIsRequired)) + view <- ViewNewStyle.checkViewAccessAndReturnView(viewId, bankIdAccountId, Full(user), Some(cc)) + _ <- Helper.booleanToFuture( + ViewDoesNotPermitAccess + s" You need the `${CAN_SEE_BANK_ACCOUNT_BALANCE}` permission on VIEW_ID(${viewId.value})", + 403, cc = Some(cc)) { + view.allowed_actions.exists(_ == CAN_SEE_BANK_ACCOUNT_BALANCE) + } + (accountBalances, _) <- BalanceNewStyle.getBankAccountBalances(bankIdAccountId, Some(cc)) + } yield createAccountBalancesJson(accountBalances) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getBankAccountBalances), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/balances", "Get Account Balances by BANK_ID and ACCOUNT_ID through the VIEW_ID", + "Get the Balances for the Account specified by BANK_ID and ACCOUNT_ID through the VIEW_ID.", + EmptyBody, accountBalanceV400, + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UserNoPermissionAccessView, UnknownError), + apiTagAccount :: apiTagPSD2AIS :: apiTagPsd2 :: Nil, + None, + http4sPartialFunction = Some(getBankAccountBalances) + ) + + val getBankAccountsBalancesThroughView: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankIdStr / "views" / viewIdStr / "balances" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val bankId = BankId(bankIdStr); val viewId = ViewId(viewIdStr) + for { + user <- Future.successful(cc.user.openOrThrowException(AuthenticatedUserIsRequired)) + (allowedAccounts, _) <- BalanceNewStyle.getAccountAccessAtBankThroughView(user, bankId, viewId, Some(cc)) + (accountsBalances, _) <- BalanceNewStyle.getBankAccountsBalances(allowedAccounts, Some(cc)) + } yield createBalancesJson(accountsBalances) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getBankAccountsBalancesThroughView), "GET", + "/banks/BANK_ID/views/VIEW_ID/balances", "Get Account Balances by BANK_ID through the VIEW_ID", + "Get the Balances for the Account specified by BANK_ID through the VIEW_ID.", + EmptyBody, accountBalancesV400Json, + List($AuthenticatedUserIsRequired, $BankNotFound, UnknownError), + apiTagAccount :: apiTagPSD2AIS :: apiTagPsd2 :: Nil, + None, + http4sPartialFunction = Some(getBankAccountsBalancesThroughView) + ) + + // ─── Counterparty limits (4 simple) — getCounterpartyLimitStatus deferred (complex) + + val createCounterpartyLimit: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr / "views" / viewIdStr / "counterparties" / counterpartyIdStr / "limits" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val bankId = BankId(bankIdStr); val accountId = AccountId(accountIdStr); val viewId = ViewId(viewIdStr); val counterpartyId = CounterpartyId(counterpartyIdStr) + for { + postLimit <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the ${classOf[PostCounterpartyLimitV510]}", 400, Some(cc)) { + net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[PostCounterpartyLimitV510] + } + _ <- Helper.booleanToFuture(s"$InvalidISOCurrencyCode Current input is: '${postLimit.currency}'", cc = Some(cc)) { + isValidCurrencyISOCode(postLimit.currency) + } + (existingBox, _) <- Connector.connector.vend.getCounterpartyLimit(bankId.value, accountId.value, viewId.value, counterpartyId.value, Some(cc)) + _ <- Helper.booleanToFuture( + s"$CounterpartyLimitAlreadyExists Current BANK_ID($bankId), ACCOUNT_ID($accountId), VIEW_ID($viewId),COUNTERPARTY_ID($counterpartyId)", + cc = Some(cc)) { existingBox.isEmpty } + (counterpartyLimit, _) <- NewStyle.function.createOrUpdateCounterpartyLimit( + bankId.value, accountId.value, viewId.value, counterpartyId.value, + postLimit.currency, + BigDecimal(postLimit.max_single_amount), + BigDecimal(postLimit.max_monthly_amount), + postLimit.max_number_of_monthly_transactions, + BigDecimal(postLimit.max_yearly_amount), + postLimit.max_number_of_yearly_transactions, + BigDecimal(postLimit.max_total_amount), + postLimit.max_number_of_transactions, + Some(cc)) + } yield counterpartyLimit.toJValue + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createCounterpartyLimit), "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/counterparties/COUNTERPARTY_ID/limits", + "Create Counterparty Limit", + "Create limits (single + recurring) for a counterparty.", + postCounterpartyLimitV510, counterpartyLimitV510, + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, $UserNoPermissionAccessView, + $CounterpartyNotFoundByCounterpartyId, InvalidJsonFormat, UnknownError), + List(apiTagCounterpartyLimits), + None, + http4sPartialFunction = Some(createCounterpartyLimit) + ) + + val updateCounterpartyLimit: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr / "views" / viewIdStr / "counterparties" / counterpartyIdStr / "limits" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val bankId = BankId(bankIdStr); val accountId = AccountId(accountIdStr); val viewId = ViewId(viewIdStr); val counterpartyId = CounterpartyId(counterpartyIdStr) + for { + postLimit <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the ${classOf[PostCounterpartyLimitV510]}", 400, Some(cc)) { + net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[PostCounterpartyLimitV510] + } + _ <- Helper.booleanToFuture(s"$InvalidISOCurrencyCode Current input is: '${postLimit.currency}'", cc = Some(cc)) { + isValidCurrencyISOCode(postLimit.currency) + } + (counterpartyLimit, _) <- NewStyle.function.createOrUpdateCounterpartyLimit( + bankId.value, accountId.value, viewId.value, counterpartyId.value, + postLimit.currency, + BigDecimal(postLimit.max_single_amount), + BigDecimal(postLimit.max_monthly_amount), + postLimit.max_number_of_monthly_transactions, + BigDecimal(postLimit.max_yearly_amount), + postLimit.max_number_of_yearly_transactions, + BigDecimal(postLimit.max_total_amount), + postLimit.max_number_of_transactions, + Some(cc)) + } yield counterpartyLimit.toJValue + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateCounterpartyLimit), "PUT", + "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/counterparties/COUNTERPARTY_ID/limits", + "Update Counterparty Limit", + "Update existing counterparty limits.", + postCounterpartyLimitV510, counterpartyLimitV510, + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, $UserNoPermissionAccessView, + $CounterpartyNotFoundByCounterpartyId, InvalidJsonFormat, UnknownError), + List(apiTagCounterpartyLimits), + None, + http4sPartialFunction = Some(updateCounterpartyLimit) + ) + + val getCounterpartyLimit: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr / "views" / viewIdStr / "counterparties" / counterpartyIdStr / "limits" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val bankId = BankId(bankIdStr); val accountId = AccountId(accountIdStr); val viewId = ViewId(viewIdStr); val counterpartyId = CounterpartyId(counterpartyIdStr) + for { + (counterpartyLimit, _) <- NewStyle.function.getCounterpartyLimit(bankId.value, accountId.value, viewId.value, counterpartyId.value, Some(cc)) + } yield counterpartyLimit.toJValue + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCounterpartyLimit), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/counterparties/COUNTERPARTY_ID/limits", + "Get Counterparty Limit", "Get Counterparty Limit.", + EmptyBody, counterpartyLimitV510, + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, $UserNoPermissionAccessView, + $CounterpartyNotFoundByCounterpartyId, InvalidJsonFormat, UnknownError), + List(apiTagCounterpartyLimits), + None, + http4sPartialFunction = Some(getCounterpartyLimit) + ) + + val deleteCounterpartyLimit: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr / "views" / viewIdStr / "counterparties" / counterpartyIdStr / "limits" => + EndpointHelpers.withUserDelete(req) { (_, cc) => + val bankId = BankId(bankIdStr); val accountId = AccountId(accountIdStr); val viewId = ViewId(viewIdStr); val counterpartyId = CounterpartyId(counterpartyIdStr) + for { + (counterpartyLimit, _) <- NewStyle.function.deleteCounterpartyLimit(bankId.value, accountId.value, viewId.value, counterpartyId.value, Some(cc)) + } yield counterpartyLimit + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteCounterpartyLimit), "DELETE", + "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/counterparties/COUNTERPARTY_ID/limits", + "Delete Counterparty Limit", "Delete Counterparty Limit.", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, $UserNoPermissionAccessView, + $CounterpartyNotFoundByCounterpartyId, InvalidJsonFormat, UnknownError), + List(apiTagCounterpartyLimits), + None, + http4sPartialFunction = Some(deleteCounterpartyLimit) + ) + + // ─── Custom view CRUD (4) ───────────────────────────────────────────── + + val createCustomView: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr / "views" / viewIdStr / "target-views" => + EndpointHelpers.withViewCreated(req) { (user, account, view, cc) => + val bankId = BankId(bankIdStr); val accountId = AccountId(accountIdStr); val viewId = ViewId(viewIdStr) + for { + createJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the ${classOf[com.openbankproject.commons.model.CreateViewJson]}", 400, Some(cc)) { + net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[CreateCustomViewJson] + } + _ <- Helper.booleanToFuture(InvalidCustomViewFormat + s"Current view_name (${createJson.name})", cc = Some(cc)) { + isValidCustomViewName(createJson.name) + } + permissionsFromSource = view.asInstanceOf[ViewDefinition].allowed_actions.toSet + permissionsFromTarget = createJson.allowed_permissions + _ <- Helper.booleanToFuture(SourceViewHasLessPermission + s"Current source viewId($viewId) permissions ($permissionsFromSource), target viewName${createJson.name} permissions ($permissionsFromTarget)", cc = Some(cc)) { + permissionsFromTarget.toSet.subsetOf(permissionsFromSource) + } + _ <- Helper.booleanToFuture(s"${ErrorMessages.ViewDoesNotPermitAccess} You need the `${CAN_CREATE_CUSTOM_VIEW}` permission on VIEW_ID(${viewId.value})", cc = Some(cc)) { + view.allowed_actions.exists(_ == CAN_CREATE_CUSTOM_VIEW) + } + (newView, _) <- ViewNewStyle.createCustomView(BankIdAccountId(bankId, accountId), createJson.toCreateViewJson, Some(cc)) + } yield JSONFactory510.createViewJson(newView) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createCustomView), "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/target-views", "Create Custom View", + "Create a custom view on bank account. Name MUST start with `_`.", + createCustomViewJson, customViewJsonV510, + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, $UserNoPermissionAccessView, InvalidJsonFormat, UnknownError), + List(apiTagView, apiTagAccount), + None, + http4sPartialFunction = Some(createCustomView) + ) + + val updateCustomView: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr / "views" / viewIdStr / "target-views" / targetViewIdStr => + EndpointHelpers.withView(req) { (user, account, view, cc) => + val bankId = BankId(bankIdStr); val accountId = AccountId(accountIdStr); val viewId = ViewId(viewIdStr); val targetViewId = ViewId(targetViewIdStr) + for { + updateJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the ${classOf[UpdateCustomViewJson]}", 400, Some(cc)) { + net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[UpdateCustomViewJson] + } + _ <- Helper.booleanToFuture(InvalidCustomViewFormat + s"Current TARGET_VIEW_ID (${targetViewId})", cc = Some(cc)) { + isValidCustomViewId(targetViewId.value) + } + permissionsFromSource = view.asInstanceOf[ViewDefinition].allowed_actions.toSet + permissionsFromTarget = updateJson.allowed_permissions + _ <- Helper.booleanToFuture(SourceViewHasLessPermission + s"Current source view permissions ($permissionsFromSource), target view permissions ($permissionsFromTarget)", cc = Some(cc)) { + permissionsFromTarget.toSet.subsetOf(permissionsFromSource) + } + _ <- Helper.booleanToFuture(s"${ErrorMessages.ViewDoesNotPermitAccess} You need the `${CAN_UPDATE_CUSTOM_VIEW}` permission on VIEW_ID(${viewId.value})", cc = Some(cc)) { + view.allowed_actions.exists(_ == CAN_CREATE_CUSTOM_VIEW) + } + (updatedView, _) <- ViewNewStyle.updateCustomView(BankIdAccountId(bankId, accountId), targetViewId, updateJson.toUpdateViewJson, Some(cc)) + } yield JSONFactory510.createViewJson(updatedView) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateCustomView), "PUT", + "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/target-views/TARGET_VIEW_ID", "Update Custom View", + "Update an existing custom view on a bank account.", + updateCustomViewJson, customViewJsonV510, + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, $UserNoPermissionAccessView, InvalidJsonFormat, UnknownError), + List(apiTagView, apiTagAccount), + None, + http4sPartialFunction = Some(updateCustomView) + ) + + val getCustomView: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr / "views" / viewIdStr / "target-views" / targetViewIdStr => + EndpointHelpers.withView(req) { (_, _, view, cc) => + val bankId = BankId(bankIdStr); val accountId = AccountId(accountIdStr); val viewId = ViewId(viewIdStr); val targetViewId = ViewId(targetViewIdStr) + for { + _ <- Helper.booleanToFuture(InvalidCustomViewFormat + s"Current TARGET_VIEW_ID (${targetViewId.value})", cc = Some(cc)) { + isValidCustomViewId(targetViewId.value) + } + _ <- Helper.booleanToFuture(s"${ErrorMessages.ViewDoesNotPermitAccess} You need the `${CAN_GET_CUSTOM_VIEW}`permission on any your views. Current VIEW_ID (${viewId.value})", cc = Some(cc)) { + view.allowed_actions.exists(_ == CAN_GET_CUSTOM_VIEW) + } + targetView <- ViewNewStyle.customView(targetViewId, BankIdAccountId(bankId, accountId), Some(cc)) + } yield JSONFactory510.createViewJson(targetView) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCustomView), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/target-views/TARGET_VIEW_ID", "Get Custom View", + "Returns the custom view on the account.", + EmptyBody, customViewJsonV510, + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, $UserNoPermissionAccessView, UnknownError), + List(apiTagView, apiTagAccount), + None, + http4sPartialFunction = Some(getCustomView) + ) + + val deleteCustomView: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr / "views" / viewIdStr / "target-views" / targetViewIdStr => + EndpointHelpers.executeDelete(req) { cc => + val bankId = BankId(bankIdStr); val accountId = AccountId(accountIdStr); val viewId = ViewId(viewIdStr); val targetViewId = ViewId(targetViewIdStr) + val view = cc.view.getOrElse(throw new RuntimeException(UserNoPermissionAccessView)) + for { + _ <- Helper.booleanToFuture(InvalidCustomViewFormat + s"Current TARGET_VIEW_ID (${targetViewId.value})", cc = Some(cc)) { + isValidCustomViewId(targetViewId.value) + } + _ <- Helper.booleanToFuture(s"${ErrorMessages.ViewDoesNotPermitAccess} You need the `${CAN_DELETE_CUSTOM_VIEW}` permission on any your views.Current VIEW_ID (${viewId.value})", cc = Some(cc)) { + view.allowed_actions.exists(_ == CAN_DELETE_CUSTOM_VIEW) + } + _ <- ViewNewStyle.customView(targetViewId, BankIdAccountId(bankId, accountId), Some(cc)) + deleted <- ViewNewStyle.removeCustomView(targetViewId, BankIdAccountId(bankId, accountId), Some(cc)) + } yield deleted + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteCustomView), "DELETE", + "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/target-views/TARGET_VIEW_ID", "Delete Custom View", + "Deletes the custom view.", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, $UserNoPermissionAccessView, UnknownError), + List(apiTagView, apiTagAccount), + None, + http4sPartialFunction = Some(deleteCustomView) + ) + + // ─── Bank account balance CRUD (4) ──────────────────────────────────── + + val createBankAccountBalance: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr / "balances" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val bankId = BankId(bankIdStr); val accountId = AccountId(accountIdStr) + for { + postedData <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $BankAccountBalanceRequestJsonV510 ", 400, Some(cc)) { + net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[BankAccountBalanceRequestJsonV510] + } + balanceAmount <- NewStyle.function.tryons(s"$InvalidNumber Current balance_amount is ${postedData.balance_amount}", 400, Some(cc)) { + BigDecimal(postedData.balance_amount) + } + (balance, _) <- code.api.util.newstyle.BankAccountBalanceNewStyle.createOrUpdateBankAccountBalance( + bankId, accountId, None, postedData.balance_type, balanceAmount, Some(cc)) + } yield JSONFactory510.createBankAccountBalanceJson(balance) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createBankAccountBalance), "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/balances", "Create Bank Account Balance", + s"Create a new Balance for a Bank Account.\n\n${userAuthenticationMessage(true)}", + bankAccountBalanceRequestJsonV510, bankAccountBalanceResponseJsonV510, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagAccount, apiTagBalance), + Some(List(canCreateBankAccountBalance)), + http4sPartialFunction = Some(createBankAccountBalance) + ) + + val getBankAccountBalanceById: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / "balances" / balanceIdStr => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { + (balance, _) <- code.api.util.newstyle.BankAccountBalanceNewStyle.getBankAccountBalanceById(BalanceId(balanceIdStr), Some(cc)) + } yield JSONFactory510.createBankAccountBalanceJson(balance) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getBankAccountBalanceById), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/balances/BALANCE_ID", "Get Bank Account Balance By ID", + "Get a specific Bank Account Balance.", + EmptyBody, bankAccountBalanceResponseJsonV510, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagAccount, apiTagBalance), + None, + http4sPartialFunction = Some(getBankAccountBalanceById) + ) + + val updateBankAccountBalance: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr / "balances" / balanceIdStr => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val bankId = BankId(bankIdStr); val accountId = AccountId(accountIdStr); val balanceId = BalanceId(balanceIdStr) + for { + postedData <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the BankAccountBalanceRequestJsonV510 ", 400, Some(cc)) { + net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[BankAccountBalanceRequestJsonV510] + } + balanceAmount <- NewStyle.function.tryons(s"$InvalidNumber Current balance_amount is ${postedData.balance_amount}", 400, Some(cc)) { + BigDecimal(postedData.balance_amount) + } + (_, _) <- code.api.util.newstyle.BankAccountBalanceNewStyle.getBankAccountBalanceById(balanceId, Some(cc)) + (updated, _) <- code.api.util.newstyle.BankAccountBalanceNewStyle.createOrUpdateBankAccountBalance( + bankId, accountId, Some(balanceId), postedData.balance_type, balanceAmount, Some(cc)) + } yield JSONFactory510.createBankAccountBalanceJson(updated) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateBankAccountBalance), "PUT", + "/banks/BANK_ID/accounts/ACCOUNT_ID/balances/BALANCE_ID", "Update Bank Account Balance", + "Update an existing Bank Account Balance.", + bankAccountBalanceRequestJsonV510, bankAccountBalanceResponseJsonV510, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagAccount, apiTagBalance), + Some(List(canUpdateBankAccountBalance)), + http4sPartialFunction = Some(updateBankAccountBalance) + ) + + val deleteBankAccountBalance: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / _ / "accounts" / _ / "balances" / balanceIdStr => + EndpointHelpers.withUserDelete(req) { (_, cc) => + val balanceId = BalanceId(balanceIdStr) + for { + (_, _) <- code.api.util.newstyle.BankAccountBalanceNewStyle.getBankAccountBalanceById(balanceId, Some(cc)) + (deleted, _) <- code.api.util.newstyle.BankAccountBalanceNewStyle.deleteBankAccountBalance(balanceId, Some(cc)) + } yield deleted + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteBankAccountBalance), "DELETE", + "/banks/BANK_ID/accounts/ACCOUNT_ID/balances/BALANCE_ID", "Delete Bank Account Balance", + "Delete a Bank Account Balance.", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagAccount, apiTagBalance), + Some(List(canDeleteBankAccountBalance)), + http4sPartialFunction = Some(deleteBankAccountBalance) + ) + + // ─── System view permissions (2) ────────────────────────────────────── + + val addSystemViewPermission: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "system-views" / viewIdStr / "permissions" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val viewId = ViewId(viewIdStr) + for { + createJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $CreateViewPermissionJson ", 400, Some(cc)) { + net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[CreateViewPermissionJson] + } + _ <- Helper.booleanToFuture(s"$InvalidViewPermissionName The current value is ${createJson.permission_name}", 400, Some(cc)) { + ALL_VIEW_PERMISSION_NAMES.exists(_ == createJson.permission_name) + } + _ <- ViewNewStyle.systemView(viewId, Some(cc)) + _ <- Helper.booleanToFuture(s"$ViewPermissionNameExists The current value is ${createJson.permission_name}", 400, Some(cc)) { + ViewPermission.findSystemViewPermission(viewId, createJson.permission_name).isEmpty + } + (viewPermission, _) <- ViewNewStyle.createSystemViewPermission(viewId, createJson.permission_name, createJson.extra_data, Some(cc)) + } yield JSONFactory510.createViewPermissionJson(viewPermission) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(addSystemViewPermission), "POST", + "/system-views/VIEW_ID/permissions", "Add Permission to a System View", + "Add Permission to a System View.", + createViewPermissionJson, entitlementJSON, + List($AuthenticatedUserIsRequired, InvalidJsonFormat, IncorrectRoleName, EntitlementAlreadyExists, UnknownError), + List(apiTagSystemView), + Some(List(canCreateSystemViewPermission)), + http4sPartialFunction = Some(addSystemViewPermission) + ) + + val deleteSystemViewPermission: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "system-views" / viewIdStr / "permissions" / permissionName => + EndpointHelpers.withUserDelete(req) { (_, cc) => + val viewId = ViewId(viewIdStr) + for { + (viewPermission, _) <- ViewNewStyle.findSystemViewPermission(viewId, permissionName, Some(cc)) + _ <- Helper.booleanToFuture(s"$DeleteViewPermissionError The current value is $permissionName", 400, Some(cc)) { + viewPermission.delete_! + } + } yield true + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteSystemViewPermission), "DELETE", + "/system-views/VIEW_ID/permissions/PERMISSION_NAME", "Delete Permission to a System View", + "Delete Permission to a System View.", + EmptyBody, EmptyBody, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagSystemView), + Some(List(canDeleteSystemViewPermission)), + http4sPartialFunction = Some(deleteSystemViewPermission) + ) + val allRoutes: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req: Request[IO] => root(req) @@ -2179,6 +2667,23 @@ object Http4s510 { .orElse(createUserWithAccountAccessById(req)) .orElse(getTransactionRequestById(req)) .orElse(updateTransactionRequestStatus(req)) + .orElse(getCoreAccountByIdThroughView(req)) + .orElse(getBankAccountBalances(req)) + .orElse(getBankAccountsBalancesThroughView(req)) + .orElse(createCounterpartyLimit(req)) + .orElse(updateCounterpartyLimit(req)) + .orElse(getCounterpartyLimit(req)) + .orElse(deleteCounterpartyLimit(req)) + .orElse(createCustomView(req)) + .orElse(updateCustomView(req)) + .orElse(getCustomView(req)) + .orElse(deleteCustomView(req)) + .orElse(createBankAccountBalance(req)) + .orElse(getBankAccountBalanceById(req)) + .orElse(updateBankAccountBalance(req)) + .orElse(deleteBankAccountBalance(req)) + .orElse(addSystemViewPermission(req)) + .orElse(deleteSystemViewPermission(req)) } val allRoutesWithMiddleware: HttpRoutes[IO] = From a31fbf8454962cae0ba24fbf58e2c952762f77fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Thu, 14 May 2026 09:27:04 +0200 Subject: [PATCH 25/30] feat(v5.1.0): migrate consents family (13 endpoints) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the consents family on top of c7914997b. Total v5.1.0 own endpoints now in http4s: 105 of 111 (95%). Newly migrated (13): Read endpoints (5) - getMyConsents (GET /my/consents) - getConsentsAtBank (GET /management/consents/banks/BANK_ID) - getConsents (GET /management/consents) - getConsentByConsentId (GET /user/current/consents/CONSENT_ID) - getConsentByConsentIdViaConsumer (GET /consumer/current/consents/CONSENT_ID) Update / management (3) - updateConsentStatusByConsent (PUT /management/banks/BANK_ID/consents/CONSENT_ID) - updateConsentAccountAccessByConsentId (PUT /management/banks/BANK_ID/consents/CONSENT_ID/account-access) - updateConsentUserIdByConsentId (PUT /management/banks/BANK_ID/consents/CONSENT_ID/created-by-user) Revoke (3) - revokeConsentAtBank (DELETE /banks/BANK_ID/consents/CONSENT_ID) - selfRevokeConsent (DELETE /my/consent/current — pulls Consent-Id from request header) - revokeMyConsent (DELETE /my/consents/CONSENT_ID — initially missed the audit; added as a follow-up after the post-batch endpoint inventory found 7 unmigrated rather than 6) Create (2) - createConsentImplicit (POST /my/consents/IMPLICIT — also handles SCA = EMAIL/SMS via the same handler, dispatched by the literal URL segment; originally aliased Lift's createConsent) - createVRPConsentRequest (POST /consumer/vrp-consent-requests) createConsentImplicit was the largest port — ~200 lines around challenge/SCA dispatch, JIT entitlement check, JWT stamping, skipConsentScaForConsumerIdPairs handling, etc. Mirrors the Lift shape with one structural simplification: the inner SCA dispatch loses Lift's early `Future` wrapping of `failMsg` strings (no longer needed in the http4s for-comp). Tests: 239 v5.1.0 tests pass, 0 failures (incl. 29 consent-suite tests across ConsentObpTest, ConsentsTest, VRPConsentRequestTest). Remaining unmigrated (6 of 111): getAtms / getAtm (ETag), updateAgentStatus (role-check parity), getAccountsHeldByUserAtBank / getAccountsHeldByUser (Lift Req filter port), getCounterpartyLimitStatus (complex monthly/yearly aggregation). Bridge re-enable still pending on these. --- .../scala/code/api/v5_1_0/Http4s510.scala | 524 +++++++++++++++++- 1 file changed, 519 insertions(+), 5 deletions(-) diff --git a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala index 33db004ffb..f9ef7aef94 100644 --- a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala +++ b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala @@ -15,12 +15,13 @@ import code.api.util.http4s.ResourceDocMiddleware import code.api.util.newstyle.{BalanceNewStyle, RegulatedEntityAttributeNewStyle, ViewNewStyle} import code.api.util.newstyle.RegulatedEntityNewStyle.{createRegulatedEntityNewStyle, deleteRegulatedEntityNewStyle, getRegulatedEntitiesNewStyle, getRegulatedEntityByEntityIdNewStyle} import code.api.util.newstyle.Consumer.createConsumerNewStyle -import code.api.util.{APIUtil, ConsentJWT, CustomJsonFormats, JwtUtil, NewStyle, OBPBankId, OBPLimit, OBPOffset, OBPSortBy, X509} +import code.api.util.{APIUtil, Consent, ConsentJWT, CustomJsonFormats, JwtUtil, NewStyle, OBPBankId, OBPLimit, OBPOffset, OBPSortBy, SecureRandomUtil, X509} import code.api.v2_0_0.AccountsHelper import code.api.v2_1_0.{ConsumerRedirectUrlJSON, JSONFactory210} import code.api.v3_0_0.JSONFactory300 import code.api.v3_0_0.JSONFactory300.createAggregateMetricJson -import code.api.v3_1_0.{JSONFactory310, PostConsentBodyCommonJson, PostConsentEntitlementJsonV310, PostConsentViewJsonV310} +import code.api.v3_1_0.{ConsentChallengeJsonV310, ConsentJsonV310, JSONFactory310, PostConsentBodyCommonJson, PostConsentEmailJsonV310, PostConsentEntitlementJsonV310, PostConsentImplicitJsonV310, PostConsentPhoneJsonV310, PostConsentViewJsonV310} +import code.api.v4_0_0.{PutConsentStatusJsonV400, PutConsentUserJsonV400} import code.api.v3_1_0.JSONFactory310.{createBadLoginStatusJson, createConsumerJSON} import code.api.v4_0_0.JSONFactory400 import code.api.v4_0_0.JSONFactory400.{createAccountBalancesJson, createBalancesJson, createNewCoreBankAccountJson} @@ -47,11 +48,12 @@ import code.webuiprops.{MappedWebUiPropsProvider, WebUiPropsCommons} import com.github.dwickern.macros.NameOf.nameOf import com.openbankproject.commons.ExecutionContext.Implicits.global import com.openbankproject.commons.model.{ - AccountId, AccountRouting, AtmId, AtmT, BalanceId, Bank, BankAccount, BankId, - BankIdAccountId, CounterpartyId, CustomerId, ListResult, ProductCode, + AccountId, AccountRouting, AccountRoutingJsonV121, AtmId, AtmT, BalanceId, + Bank, BankAccount, BankAccountRoutings, BankId, BankIdAccountId, BankRoutingJson, + BranchRoutingJsonV141, CounterpartyId, CustomerId, ListResult, ProductCode, RegulatedEntityId, TransactionRequestId, User, View, ViewId } -import com.openbankproject.commons.model.enums.{AtmAttributeType, RegulatedEntityAttributeType, StrongCustomerAuthentication, TransactionRequestStatus, UserAttributeType} +import com.openbankproject.commons.model.enums.{AtmAttributeType, ConsentType, RegulatedEntityAttributeType, StrongCustomerAuthentication, TransactionRequestStatus, UserAttributeType} import com.openbankproject.commons.util.{ApiVersion, ApiVersionStatus, ScannedApiVersion} import net.liftweb.common.{Box, Empty, Full} import net.liftweb.json @@ -2589,6 +2591,505 @@ object Http4s510 { http4sPartialFunction = Some(deleteSystemViewPermission) ) + // ─── Consents family (12) ───────────────────────────────────────────── + + val updateConsentStatusByConsent: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "management" / "banks" / _ / "consents" / consentId => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { + consentJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $PutConsentStatusJsonV400 ", 400, Some(cc)) { + net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[PutConsentStatusJsonV400] + } + _ <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) + .map(unboxFullOrFail(_, Some(cc), s"$ConsentNotFound ($consentId)", 404)) + status = ConsentStatus.withName(consentJson.status) + consent <- Future(Consents.consentProvider.vend.updateConsentStatus(consentId, status)) + .map(i => connectorEmptyResponse(i, Some(cc))) + } yield ConsentJsonV310(consent.consentId, consent.jsonWebToken, consent.status) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateConsentStatusByConsent), "PUT", + "/management/banks/BANK_ID/consents/CONSENT_ID", "Update Consent Status by CONSENT_ID", + s"Update the Status of a Consent. States: ${ConsentStatus.values.toList.sorted.mkString(", ")}.", + PutConsentStatusJsonV400(status = "AUTHORISED"), + ConsentChallengeJsonV310(consent_id = "9d429899-24f5-42c8-8565-943ffa6a7945", jwt = "", status = "AUTHORISED"), + List($AuthenticatedUserIsRequired, $BankNotFound, InvalidJsonFormat, ConsentNotFound, InvalidConnectorResponse, UnknownError), + apiTagConsent :: apiTagPSD2AIS :: Nil, + Some(List(canUpdateConsentStatusAtOneBank, canUpdateConsentStatusAtAnyBank)), + http4sPartialFunction = Some(updateConsentStatusByConsent) + ) + + val updateConsentAccountAccessByConsentId: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "management" / "banks" / _ / "consents" / consentId / "account-access" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { + consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) + .map(unboxFullOrFail(_, Some(cc), s"$ConsentNotFound ($consentId)", 404)) + consentJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $PutConsentPayloadJsonV510 ", 400, Some(cc)) { + net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[PutConsentPayloadJsonV510] + } + _ <- Helper.booleanToFuture(s"$InvalidJsonFormat The Json body should be the $PutConsentPayloadJsonV510 ", 400, Some(cc)) { + !(consentJson.access.accounts.isEmpty && consentJson.access.balances.isEmpty && consentJson.access.transactions.isEmpty) + } + consentJWT <- Consent.updateAccountAccessOfBerlinGroupConsentJWT(consentJson.access, consent, Some(cc)) + .map(i => connectorEmptyResponse(i, Some(cc))) + updatedConsent <- Future(Consents.consentProvider.vend.setJsonWebToken(consent.consentId, consentJWT)) + .map(i => connectorEmptyResponse(i, Some(cc))) + } yield ConsentJsonV310(updatedConsent.consentId, updatedConsent.jsonWebToken, updatedConsent.status) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateConsentAccountAccessByConsentId), "PUT", + "/management/banks/BANK_ID/consents/CONSENT_ID/account-access", "Update Consent Account Access by CONSENT_ID", + "Update the Account Access of a Consent.", + PutConsentPayloadJsonV510(access = code.api.berlin.group.v1_3.JSONFactory_BERLIN_GROUP_1_3.ConsentAccessJson()), + ConsentChallengeJsonV310(consent_id = "9d429899-24f5-42c8-8565-943ffa6a7945", jwt = "", status = "AUTHORISED"), + List($AuthenticatedUserIsRequired, $BankNotFound, InvalidJsonFormat, ConsentNotFound, InvalidConnectorResponse, UnknownError), + apiTagConsent :: apiTagPSD2AIS :: Nil, + Some(List(canUpdateConsentAccountAccessAtOneBank, canUpdateConsentAccountAccessAtAnyBank)), + http4sPartialFunction = Some(updateConsentAccountAccessByConsentId) + ) + + val updateConsentUserIdByConsentId: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "management" / "banks" / _ / "consents" / consentId / "created-by-user" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { + consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) + .map(unboxFullOrFail(_, Some(cc), s"$ConsentNotFound ($consentId)", 404)) + consentJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $PutConsentUserJsonV400 ", 400, Some(cc)) { + net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[PutConsentUserJsonV400] + } + user <- Users.users.vend.getUserByUserIdFuture(consentJson.user_id) + .map(x => unboxFullOrFail(x, Some(cc), s"$UserNotFoundByUserId Current UserId(${consentJson.user_id})")) + consent2 <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) + .map(i => connectorEmptyResponse(i, Some(cc))) + _ <- Helper.booleanToFuture(ConsentUserAlreadyAdded, cc = Some(cc)) { + Option(consent2.userId).forall(_.isBlank) + } + consent3 <- Future(Consents.consentProvider.vend.updateConsentUser(consentId, user)) + .map(i => connectorEmptyResponse(i, Some(cc))) + consentJWT <- Future(Consent.updateUserIdOfBerlinGroupConsentJWT(consentJson.user_id, consent3, Some(cc))) + .map(i => connectorEmptyResponse(i, Some(cc))) + updatedConsent <- Future(Consents.consentProvider.vend.setJsonWebToken(consent3.consentId, consentJWT)) + .map(i => connectorEmptyResponse(i, Some(cc))) + } yield ConsentJsonV310(updatedConsent.consentId, updatedConsent.jsonWebToken, updatedConsent.status) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateConsentUserIdByConsentId), "PUT", + "/management/banks/BANK_ID/consents/CONSENT_ID/created-by-user", "Update Created by User of Consent by CONSENT_ID", + "Update the User bound to a consent.", + PutConsentUserJsonV400(user_id = "ed7a7c01-db37-45cc-ba12-0ae8891c195c"), + ConsentChallengeJsonV310(consent_id = "9d429899-24f5-42c8-8565-943ffa6a7945", jwt = "", status = "AUTHORISED"), + List($AuthenticatedUserIsRequired, $BankNotFound, InvalidJsonFormat, ConsentNotFound, InvalidConnectorResponse, UnknownError), + apiTagConsent :: apiTagPSD2AIS :: Nil, + Some(List(canUpdateConsentUserAtOneBank, canUpdateConsentUserAtAnyBank)), + http4sPartialFunction = Some(updateConsentUserIdByConsentId) + ) + + val getMyConsents: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "my" / "consents" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val params = req.uri.query.multiParams + val limitParam = params.get("limit").flatMap(_.headOption).flatMap(s => scala.util.Try(s.toInt).toOption).getOrElse(50) + val offsetParam = params.get("offset").flatMap(_.headOption).flatMap(s => scala.util.Try(s.toInt).toOption).getOrElse(0) + val statusParam = params.get("status").flatMap(_.headOption) + val sortByParam = params.get("sort_by").flatMap(_.headOption).getOrElse("created_date:desc") + val sortParts = sortByParam.split(":").map(_.trim.toLowerCase) + val sortField = sortParts(0) + val sortDirection = sortParts.lift(1).getOrElse("desc") + for { + user <- Future.successful(cc.user.openOrThrowException(AuthenticatedUserIsRequired)) + rows <- Future { + code.consent.DoobieConsentQueries.getConsentsByUser( + userId = user.userId, status = statusParam, + limit = limitParam, offset = offsetParam, + sortField = sortField, sortDirection = sortDirection) + } + } yield ConsentsInfoJsonV510(rows.map(Implementations5_1_0.rowToConsentInfoJsonV510)) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getMyConsents), "GET", + "/my/consents", "Get My Consents", + "Get All Consents that the current User created.", + EmptyBody, consentsInfoJsonV510, + List($AuthenticatedUserIsRequired, UnknownError), + List(apiTagConsent, apiTagPSD2AIS, apiTagPsd2), + None, + http4sPartialFunction = Some(getMyConsents) + ) + + val getConsentsAtBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "consents" / "banks" / bankIdStr => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val bankId = BankId(bankIdStr) + for { + httpParams <- NewStyle.function.extractHttpParamsFromUrl(req.uri.renderString) + (obpQueryParams, _) <- createQueriesByHttpParamsFuture(httpParams, Some(cc)) + (consents, totalPages) <- Future(Consents.consentProvider.vend.getConsents(obpQueryParams)) + } yield { + val consentsOfBank = Consent.filterByBankId(consents, bankId) + createConsentsJsonV510(consentsOfBank, totalPages) + } + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getConsentsAtBank), "GET", + "/management/consents/banks/BANK_ID", "Get Consents at Bank", + "Gets the Consents at the specified Bank.", + EmptyBody, consentsJsonV510, + List($AuthenticatedUserIsRequired, $BankNotFound, UnknownError), + List(apiTagConsent, apiTagPSD2AIS, apiTagPsd2), + Some(List(canGetConsentsAtOneBank, canGetConsentsAtAnyBank)), + http4sPartialFunction = Some(getConsentsAtBank) + ) + + val getConsents: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "consents" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { + httpParams <- NewStyle.function.extractHttpParamsFromUrl(req.uri.renderString) + (obpQueryParams, _) <- createQueriesByHttpParamsFuture(httpParams, Some(cc)) + (consents, totalPages) <- Future(Consents.consentProvider.vend.getConsents(obpQueryParams)) + } yield createConsentsJsonV510(consents, totalPages) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getConsents), "GET", + "/management/consents", "Get Consents", + "Gets the Consents.", + EmptyBody, consentsJsonV510, + List($AuthenticatedUserIsRequired, $BankNotFound, UnknownError), + List(apiTagConsent, apiTagPSD2AIS, apiTagPsd2), + Some(List(canGetConsentsAtAnyBank)), + http4sPartialFunction = Some(getConsents) + ) + + val getConsentByConsentId: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "user" / "current" / "consents" / consentId => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { + consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) + .map(unboxFullOrFail(_, Some(cc), ConsentNotFound, 404)) + _ <- Helper.booleanToFuture(failMsg = ConsentNotFound, failCode = 404, cc = Some(cc)) { + consent.mUserId == cc.userId + } + } yield JSONFactory510.getConsentInfoJson(consent) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getConsentByConsentId), "GET", + "/user/current/consents/CONSENT_ID", "Get Consent By Consent Id via User", + "Gets the Consent specified by CONSENT_ID belonging to the current User.", + EmptyBody, consentJsonV510, + List($AuthenticatedUserIsRequired, UnknownError), + List(apiTagConsent, apiTagPSD2AIS, apiTagPsd2), + None, + http4sPartialFunction = Some(getConsentByConsentId) + ) + + val getConsentByConsentIdViaConsumer: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "consumer" / "current" / "consents" / consentId => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { + consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) + .map(unboxFullOrFail(_, Some(cc), ConsentNotFound, 404)) + _ <- Helper.booleanToFuture(failMsg = ConsentNotFound, failCode = 404, cc = Some(cc)) { + consent.mConsumerId.get == cc.consumer.map(_.consumerId.get).getOrElse("None") + } + } yield JSONFactory510.getConsentInfoJson(consent) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getConsentByConsentIdViaConsumer), "GET", + "/consumer/current/consents/CONSENT_ID", "Get Consent By Consent Id via Consumer", + "Gets the Consent specified by CONSENT_ID belonging to the current Consumer.", + EmptyBody, consentJsonV500, + List($AuthenticatedUserIsRequired, UnknownError), + List(apiTagConsent, apiTagPSD2AIS, apiTagPsd2), + None, + http4sPartialFunction = Some(getConsentByConsentIdViaConsumer) + ) + + val revokeConsentAtBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / bankIdStr / "consents" / consentId => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val bankId = BankId(bankIdStr) + for { + user <- Future.successful(cc.user.openOrThrowException(AuthenticatedUserIsRequired)) + (_, _) <- NewStyle.function.getBank(bankId, Some(cc)) + consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) + .map(unboxFullOrFail(_, Some(cc), ConsentNotFound)) + _ <- Helper.booleanToFuture(failMsg = ConsentNotFound, cc = Some(cc)) { + consent.mUserId == user.userId + } + revoked <- Future(Consents.consentProvider.vend.revoke(consentId)) + .map(i => connectorEmptyResponse(i, Some(cc))) + } yield ConsentJsonV310(revoked.consentId, revoked.jsonWebToken, revoked.status) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(revokeConsentAtBank), "DELETE", + "/banks/BANK_ID/consents/CONSENT_ID", "Revoke Consent at Bank", + "Revoke Consent specified by CONSENT_ID.", + EmptyBody, revokedConsentJsonV310, + List(AuthenticatedUserIsRequired, BankNotFound, UnknownError), + List(apiTagConsent, apiTagPSD2AIS, apiTagPsd2), + Some(List(canRevokeConsentAtBank)), + http4sPartialFunction = Some(revokeConsentAtBank) + ) + + val selfRevokeConsent: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "my" / "consent" / "current" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { + _ <- Future.successful(cc.user.openOrThrowException(AuthenticatedUserIsRequired)) + consentId = APIUtil.getConsentIdRequestHeaderValue(cc.requestHeaders).getOrElse("") + _ <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) + .map(unboxFullOrFail(_, Some(cc), ConsentNotFound, 404)) + consent <- Future(Consents.consentProvider.vend.revoke(consentId)) + .map(i => connectorEmptyResponse(i, Some(cc))) + } yield ConsentJsonV310(consent.consentId, consent.jsonWebToken, consent.status) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(selfRevokeConsent), "DELETE", + "/my/consent/current", "Revoke Consent used in the Current Call", + "Revoke Consent specified by Consent-Id at Request Header.", + EmptyBody, revokedConsentJsonV310, + List(AuthenticatedUserIsRequired, BankNotFound, UnknownError), + List(apiTagConsent, apiTagPSD2AIS, apiTagPsd2), + None, + http4sPartialFunction = Some(selfRevokeConsent) + ) + + // ─── createConsent (IMPLICIT alias) — handles SCA: EMAIL/SMS/IMPLICIT ── + + val revokeMyConsent: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "my" / "consents" / consentId => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { + user <- Future.successful(cc.user.openOrThrowException(AuthenticatedUserIsRequired)) + consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) + .map(unboxFullOrFail(_, Some(cc), ConsentNotFound, 404)) + _ <- Helper.booleanToFuture(failMsg = ConsentNotFound, cc = Some(cc)) { + consent.mUserId == user.userId + } + revoked <- Future(Consents.consentProvider.vend.revoke(consentId)) + .map(i => connectorEmptyResponse(i, Some(cc))) + } yield ConsentJsonV310(revoked.consentId, revoked.jsonWebToken, revoked.status) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(revokeMyConsent), "DELETE", + "/my/consents/CONSENT_ID", "Revoke My Consent", + "Revoke a Consent for the current user, specified by CONSENT_ID.", + EmptyBody, revokedConsentJsonV310, + List($AuthenticatedUserIsRequired, UnknownError), + List(apiTagConsent, apiTagPSD2AIS, apiTagPsd2), + None, + http4sPartialFunction = Some(revokeMyConsent) + ) + + val createConsentImplicit: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "my" / "consents" / scaMethod + if scaMethod == "EMAIL" || scaMethod == "SMS" || scaMethod == "IMPLICIT" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val callContextOpt = Some(cc) + for { + user <- Future.successful(cc.user.openOrThrowException(AuthenticatedUserIsRequired)) + _ <- Helper.booleanToFuture(ConsentAllowedScaMethods, cc = callContextOpt) { + List(StrongCustomerAuthentication.SMS.toString(), + StrongCustomerAuthentication.EMAIL.toString(), + StrongCustomerAuthentication.IMPLICIT.toString()).contains(scaMethod) + } + consentJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $PostConsentBodyCommonJson ", 400, callContextOpt) { + net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[PostConsentBodyCommonJson] + } + maxTimeToLive = APIUtil.getPropsAsIntValue(nameOfProperty = "consents.max_time_to_live", defaultValue = 3600) + _ <- Helper.booleanToFuture(s"$ConsentMaxTTL ($maxTimeToLive)", cc = callContextOpt) { + consentJson.time_to_live match { + case Some(ttl) => ttl <= maxTimeToLive + case _ => true + } + } + requestedEntitlements = consentJson.entitlements + myEntitlements <- Entitlement.entitlement.vend.getEntitlementsByUserIdFuture(user.userId) + _ <- Helper.booleanToFuture(RolesAllowedInConsent, cc = callContextOpt) { + requestedEntitlements.forall(re => + myEntitlements.getOrElse(Nil).exists(e => e.roleName == re.role_name && e.bankId == re.bank_id)) + } + requestedViews = consentJson.views + (_, assignedViews) <- Future(Views.views.vend.privateViewsUserCanAccess(user)) + _ <- Helper.booleanToFuture(ViewsAllowedInConsent, cc = callContextOpt) { + requestedViews.forall(rv => + assignedViews.exists(e => + e.view_id == rv.view_id && e.bank_id == rv.bank_id && e.account_id == rv.account_id)) + } + consumerFromBodyTuple <- consentJson.consumer_id match { + case Some(id) => NewStyle.function.checkConsumerByConsumerId(id, callContextOpt).map(c => (Some(c), c.description)) + case None => Future.successful((None: Option[Consumer], "Any application")) + } + (consumerFromRequestBody, applicationText) = consumerFromBodyTuple + challengeAnswer = Props.mode match { + case Props.RunModes.Test => Consent.challengeAnswerAtTestEnvironment + case _ => SecureRandomUtil.numeric() + } + createdConsent <- Future(Consents.consentProvider.vend.createObpConsent(user, challengeAnswer, None, consumerFromRequestBody)) + .map(i => connectorEmptyResponse(i, callContextOpt)) + consentJWT = Consent.createConsentJWT( + user, consentJson, createdConsent.secret, createdConsent.consentId, + consumerFromRequestBody.map(_.consumerId.get), + consentJson.valid_from, + consentJson.time_to_live.getOrElse(3600), + None + ) + _ <- Future(Consents.consentProvider.vend.setJsonWebToken(createdConsent.consentId, consentJWT)) + .map(i => connectorEmptyResponse(i, callContextOpt)) + validUntil = Helper.calculateValidTo(consentJson.valid_from, consentJson.time_to_live.getOrElse(3600)) + _ <- Future(Consents.consentProvider.vend.setValidUntil(createdConsent.consentId, validUntil)) + .map(i => connectorEmptyResponse(i, callContextOpt)) + grantorConsumerId = callContextOpt.flatMap(_.consumer.toOption.map(_.consumerId.get)).getOrElse("Unknown") + granteeConsumerId = consentJson.consumer_id.getOrElse("Unknown") + shouldSkip = APIUtil.skipConsentScaForConsumerIdPairs.contains( + APIUtil.ConsumerIdPair(grantorConsumerId, granteeConsumerId)) + mappedConsent <- if (shouldSkip) { + Future { + MappedConsent.find(By(MappedConsent.mConsentId, createdConsent.consentId)) + .map(_.mStatus(ConsentStatus.ACCEPTED.toString).saveMe()).head + } + } else { + val challengeText = s"Your consent challenge : ${challengeAnswer}, Application: $applicationText" + scaMethod match { + case v if v == StrongCustomerAuthentication.EMAIL.toString => + for { + postEmail <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $PostConsentEmailJsonV310", 400, callContextOpt) { + net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[PostConsentEmailJsonV310] + } + _ <- NewStyle.function.sendCustomerNotification( + StrongCustomerAuthentication.EMAIL, postEmail.email, + Some("OBP Consent Challenge"), challengeText, callContextOpt) + } yield createdConsent + case v if v == StrongCustomerAuthentication.SMS.toString => + for { + postPhone <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $PostConsentPhoneJsonV310", 400, callContextOpt) { + net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[PostConsentPhoneJsonV310] + } + _ <- NewStyle.function.sendCustomerNotification( + StrongCustomerAuthentication.SMS, postPhone.phone_number, None, challengeText, callContextOpt) + } yield createdConsent + case v if v == StrongCustomerAuthentication.IMPLICIT.toString => + for { + (consentImplicitSCA, _) <- NewStyle.function.getConsentImplicitSCA(user, callContextOpt) + _ <- consentImplicitSCA.scaMethod match { + case x if x == StrongCustomerAuthentication.EMAIL => + NewStyle.function.sendCustomerNotification( + StrongCustomerAuthentication.EMAIL, consentImplicitSCA.recipient, + Some("OBP Consent Challenge"), challengeText, callContextOpt) + case x if x == StrongCustomerAuthentication.SMS => + NewStyle.function.sendCustomerNotification( + StrongCustomerAuthentication.SMS, consentImplicitSCA.recipient, + None, challengeText, callContextOpt) + case _ => Future.successful("Success") + } + } yield createdConsent + case _ => Future.successful(createdConsent) + } + } + } yield ConsentJsonV310(mappedConsent.consentId, consentJWT, mappedConsent.status) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createConsentImplicit), "POST", + "/my/consents/IMPLICIT", "Create Consent (IMPLICIT)", + "Create a Consent in INITIATED state. SCA challenge is sent OOB based on SCA_METHOD.", + postConsentImplicitJsonV310, consentJsonV310, + List(AuthenticatedUserIsRequired, BankNotFound, InvalidJsonFormat, ConsentAllowedScaMethods, + RolesAllowedInConsent, ViewsAllowedInConsent, ConsumerNotFoundByConsumerId, ConsumerIsDisabled, + MissingPropsValueAtThisInstance, SmsServerNotResponding, InvalidConnectorResponse, UnknownError), + apiTagConsent :: apiTagPSD2AIS :: apiTagPsd2 :: Nil, + None, + http4sPartialFunction = Some(createConsentImplicit) + ) + + // ─── createVRPConsentRequest ──────────────────────────────────────────── + + val createVRPConsentRequest: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "consumer" / "vrp-consent-requests" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + val parsedBody = net.liftweb.json.parse(rawBody) + for { + (_, callContextOpt) <- APIUtil.applicationAccess(cc) + _ <- APIUtil.passesPsd2Aisp(callContextOpt) + postConsentRequestJsonV510 <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $PostVRPConsentRequestJsonV510 ", 400, callContextOpt) { + parsedBody.extract[PostVRPConsentRequestJsonV510] + } + maxTimeToLive = APIUtil.getPropsAsIntValue(nameOfProperty = "consents.max_time_to_live", defaultValue = 3600) + _ <- Helper.booleanToFuture(s"$ConsentMaxTTL ($maxTimeToLive)", cc = callContextOpt) { + postConsentRequestJsonV510.time_to_live match { + case Some(ttl) => ttl <= maxTimeToLive + case _ => true + } + } + fromAccountRoutingScheme = postConsentRequestJsonV510.from_account.account_routing.scheme + fromAccountRoutingSchemeOBPFormat = if (fromAccountRoutingScheme.equalsIgnoreCase("AccountNo")) "ACCOUNT_NUMBER" + else StringHelpers.snakify(fromAccountRoutingScheme).toUpperCase + fromAccountRouting = postConsentRequestJsonV510.from_account.account_routing.copy(scheme = fromAccountRoutingSchemeOBPFormat) + fromAccountTweaked = postConsentRequestJsonV510.from_account.copy(account_routing = fromAccountRouting) + toAccountRoutingScheme = postConsentRequestJsonV510.to_account.account_routing.scheme + toAccountRoutingSchemeOBPFormat = if (toAccountRoutingScheme.equalsIgnoreCase("AccountNo")) "ACCOUNT_NUMBER" + else StringHelpers.snakify(toAccountRoutingScheme).toUpperCase + toAccountRouting = postConsentRequestJsonV510.to_account.account_routing.copy(scheme = toAccountRoutingSchemeOBPFormat) + toAccountTweaked = postConsentRequestJsonV510.to_account.copy(account_routing = toAccountRouting) + fromBankAccountRoutings = BankAccountRoutings( + bank = BankRoutingJson( + postConsentRequestJsonV510.from_account.bank_routing.scheme, + postConsentRequestJsonV510.from_account.bank_routing.address), + account = BranchRoutingJsonV141( + fromAccountRoutingSchemeOBPFormat, + postConsentRequestJsonV510.from_account.account_routing.address), + branch = AccountRoutingJsonV121( + postConsentRequestJsonV510.from_account.branch_routing.scheme, + postConsentRequestJsonV510.from_account.branch_routing.address) + ) + consentTypeJ = net.liftweb.json.parse(s"""{"consent_type": "${ConsentType.VRP}"}""") + (_, _) <- NewStyle.function.getBankAccountByRoutings(fromBankAccountRoutings, callContextOpt) + postConsentRequestJsonTweaked = postConsentRequestJsonV510.copy( + from_account = fromAccountTweaked, to_account = toAccountTweaked) + createdConsentRequest <- Future(ConsentRequests.consentRequestProvider.vend.createConsentRequest( + callContextOpt.flatMap(_.consumer), + Some(compactRender(Extraction.decompose(postConsentRequestJsonTweaked) merge consentTypeJ)))) + .map(i => connectorEmptyResponse(i, callContextOpt)) + } yield JSONFactory500.createConsentRequestResponseJson(createdConsentRequest) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createVRPConsentRequest), "POST", + "/consumer/vrp-consent-requests", "Create Consent Request VRP", + "Create a Variable Recurring Payments (VRP) Consent Request.", + postVRPConsentRequestJsonV510, vrpConsentRequestResponseJson, + List(InvalidJsonFormat, ConsentMaxTTL, X509CannotGetCertificate, X509GeneralError, InvalidConnectorResponse, UnknownError), + apiTagConsent :: apiTagVrp :: apiTagTransactionRequest :: Nil, + None, + http4sPartialFunction = Some(createVRPConsentRequest) + ) + val allRoutes: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req: Request[IO] => root(req) @@ -2684,6 +3185,19 @@ object Http4s510 { .orElse(deleteBankAccountBalance(req)) .orElse(addSystemViewPermission(req)) .orElse(deleteSystemViewPermission(req)) + .orElse(updateConsentStatusByConsent(req)) + .orElse(updateConsentAccountAccessByConsentId(req)) + .orElse(updateConsentUserIdByConsentId(req)) + .orElse(getMyConsents(req)) + .orElse(getConsentsAtBank(req)) + .orElse(getConsents(req)) + .orElse(getConsentByConsentId(req)) + .orElse(getConsentByConsentIdViaConsumer(req)) + .orElse(revokeConsentAtBank(req)) + .orElse(selfRevokeConsent(req)) + .orElse(revokeMyConsent(req)) + .orElse(createConsentImplicit(req)) + .orElse(createVRPConsentRequest(req)) } val allRoutesWithMiddleware: HttpRoutes[IO] = From b274ce7b9d92f81bada635d09f76306c8151245b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Thu, 14 May 2026 10:15:09 +0200 Subject: [PATCH 26/30] feat(v5.1.0,middleware): migrate final 6 endpoints + reorder bank/role checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the v5.1.0 endpoint migration. Total v5.1.0 own endpoints in http4s: 111 of 111 (100%). All 239 v5.1.0 tests pass; 447 cross-version tests (v5.0/v6/v7/bridge) pass. Middleware change: ResourceDocMiddleware bank/roles ordering ----------------------------------------------------------- Reorder the validation chain to put bank validation BEFORE role authorization, matching Lift's wrappedWithAuthCheck (APIUtil.scala line 1934-1969). Lift's own comment explains why: "A Bank MUST be checked before Roles. In opposite case we get next paradox: We set non existing bank → We get error message that we don't have a proper role → We cannot assign the role to non existing bank." This unblocks AgentTest's "wrong Bankid" scenario (PUT /banks/BANK_ID/agents/agentId for unauthorised user1, expects 404 BankNotFound — previously got 403 from the role check firing first). 447 cross-version tests still pass after the reorder. Newly migrated endpoints (6) ---------------------------- - updateAgentStatus — now passes the role/bank ordering thanks to the middleware reorder above. Same handler shape as before. - getAccountsHeldByUserAtBank, getAccountsHeldByUser — port AccountsHelper.filterWithAccountType inline to operate on http4s query params instead of Lift Req. Helper lives as Implementations5_1_0.filteredCoreAccountsByQueryParams. - getCounterpartyLimitStatus — straight 1-for-1 port of the 200-line monthly/yearly/total transaction aggregation. Reuses java.time for date math, falls back to APIUtil.theEpochTime/ToDateInFuture for the all-time bounds. - getAtms, getAtm — added a private respondWithETag helper that inlines the conditional-request flow: 1. Compute body, then ETag = SHA256(URL + body). 2. If-None-Match header matches → 304 with ETag header. 3. If-Modified-Since header → look up MappedETag cache key (mirror of APIUtil.checkIfModifiedSinceHeader:390 — same composite cache key shape, same async update/create on miss) and 304 if cached value is fresh. 4. Otherwise 200 with ETag header. ResponseHeadersTest's 4 scenarios exercise every branch and all pass. The helper is inline rather than in Http4sSupport because ETag/conditional support is currently single-use; lift to a shared helper when the second endpoint needs it. Bridge state ------------ v510→v500 path-rewriting bridge is DEFINED in Implementations5_1_0.v510ToV500Bridge but **not wired** into wrappedRoutesV510Services. With all 111 endpoints migrated the bridge could in principle be enabled, but doing so surfaces two cross-version interaction bugs that need root-causing first: 1. MetricTest's `include_implemented_by_partial_functions=getBanks` filter returns 0 instead of 12. Setup calls /obp/v5.1.0/banks should be hijacked to v500.getBanks via the bridge, recording metrics with partial_function_name=getBanks. Filter doesn't match — likely the bridged metric records carry a different URL/version field shape that the filter is keying off. 2. VRPConsentRequestTest's "Create Consent By CONSENT_REQUEST_ID (EMAIL)" returns 400 instead of 201 (5 scenarios). The v5.1 createVRPConsentRequest stores a payload with `consent_type: VRP` merged in; the test then calls /obp/v5.1.0/consumer/consent-requests/X/EMAIL/consents, which the bridge rewrites to v500.createConsentByConsentRequestId. That handler tries to parse the payload as PostVRPConsentRequestJsonInternalV510 and apparently fails — suspect the merged consent_type field changes the JSON shape in a way the v500 extractor doesn't tolerate. Without the bridge, these v5.1.0 URLs fall through Http4sApp's chain to Http4sLiftWebBridge with the original /obp/v5.1.0/ path, where Lift's OBPAPI5_1_0 dispatch picks them up via the inherited APIMethods500 partial functions and serves them correctly. To re-enable the bridge, append `.orElse(Implementations5_1_0.v510ToV500Bridge.run(req))` to wrappedRoutesV510Services and address the two failures above. Tests: 239 v5.1.0 + 447 cross-version (v5.0/v6/v7/bridge) all green. --- .../util/http4s/ResourceDocMiddleware.scala | 7 +- .../scala/code/api/v5_1_0/Http4s510.scala | 390 ++++++++++++++++-- 2 files changed, 367 insertions(+), 30 deletions(-) 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 cf8edf6ff5..7463e1b873 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 @@ -259,13 +259,18 @@ 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 + // 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 + // have a proper role → We cannot assign the role to non existing bank." val result: Validation[ValidationContext] = for { context <- authenticate(req, resourceDoc, initialContext) - context <- authorizeRoles(resourceDoc, pathParams, context) context <- processForceError(req, resourceDoc, context) context <- validateAuthType(resourceDoc, context) context <- validateJsonSchema(resourceDoc, context) context <- validateBank(pathParams, context) + context <- authorizeRoles(resourceDoc, pathParams, context) context <- validateAccount(pathParams, context) context <- validateView(pathParams, context) context <- validateCounterparty(pathParams, context) diff --git a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala index f9ef7aef94..9999e2b4c0 100644 --- a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala +++ b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala @@ -62,8 +62,10 @@ import net.liftweb.json.{Extraction, Formats, compactRender} import net.liftweb.mapper.By import net.liftweb.util.Helpers.tryo import net.liftweb.util.{Helpers, Props, StringHelpers} -import org.http4s.{HttpRoutes, Method, Request, Response, Uri} +import code.api.util.http4s.{ErrorResponseConverter, RequestScopeConnection} +import org.http4s.{Header, HttpRoutes, MediaType, Method, Request, Response, Status, Uri} import org.http4s.dsl.io._ +import org.typelevel.ci.CIString import java.net.URLDecoder import java.nio.charset.StandardCharsets @@ -261,13 +263,156 @@ object Http4s510 { http4sPartialFunction = Some(updateAtm) ) - // getAtms / getAtm intentionally left to Lift's wrappedWithAuthCheck: - // ResponseHeadersTest exercises ETag + If-None-Match + If-Modified-Since on - // /banks/BANK_ID/atms (handled by APIUtil.checkConditionalRequest + - // getRequestHeadersNewStyle in Lift's response builder). ResourceDocMiddleware - // doesn't yet emit ETag headers or honour conditional headers, so migrating - // these endpoints here would regress those tests. APIMethods510 still has its - // own ResourceDoc, so resource-docs aggregation is unaffected. + // ─── getAtms / getAtm ───────────────────────────────────────────────── + // ResponseHeadersTest exercises ETag, If-None-Match → 304, and + // If-Modified-Since → 304 on these two GETs. We bypass the standard + // executeFuture path and inline the ETag/conditional-header logic + // (mirror of APIUtil.checkConditionalRequest:470 + getRequestHeadersNewStyle:532). + + private def respondWithETag[A]( + req: Request[IO], + f: code.api.util.CallContext => Future[A] + )(implicit formats: Formats): IO[Response[IO]] = { + implicit val cc: code.api.util.CallContext = req.callContext + RequestScopeConnection.fromFuture(f(cc)).attempt.flatMap { + case Left(err) => ErrorResponseConverter.toHttp4sResponse(err, cc) + case Right(result) => + val body = prettyRender(Extraction.decompose(result)) + val url = cc.url + val eTag = code.api.util.HashUtil.calculateETag(url, Full(body)) + + // If-None-Match: 304 if matches + val ifNoneMatch = req.headers.get(CIString("If-None-Match")).map(_.head.value) + val ifModifiedSince = req.headers.get(CIString("If-Modified-Since")).map(_.head.value) + + val maybe304: Option[IO[Response[IO]]] = ifNoneMatch match { + case Some(value) if value == eTag => + Some(IO.pure(Response[IO](Status.NotModified) + .putHeaders(Header.Raw(CIString(code.api.ResponseHeader.ETag), eTag)))) + case _ if ifNoneMatch.isDefined => None // header present but mismatch → fall through + case None => ifModifiedSince.map { since => + IO.blocking(checkIfModifiedSinceCached(cc, eTag, since)).map { isCachedFresh => + if (isCachedFresh) Response[IO](Status.NotModified) + .putHeaders(Header.Raw(CIString(code.api.ResponseHeader.ETag), eTag)) + else Response[IO](Status.Ok).withEntity(body) + .withContentType(org.http4s.headers.`Content-Type`(MediaType.application.json)) + .putHeaders(Header.Raw(CIString(code.api.ResponseHeader.ETag), eTag)) + } + } + } + + maybe304.getOrElse( + IO.pure(Response[IO](Status.Ok).withEntity(body) + .withContentType(org.http4s.headers.`Content-Type`(MediaType.application.json)) + .putHeaders(Header.Raw(CIString(code.api.ResponseHeader.ETag), eTag))) + ) + } + } + + // Mirror of APIUtil.checkIfModifiedSinceHeader:390 (without the async-update + // race we don't strictly need either — Lift's behaviour is best-effort). + // Returns true if the cached ETag is fresh (response 304), false otherwise. + private def checkIfModifiedSinceCached( + cc: code.api.util.CallContext, + currentETag: String, + headerValue: String + ): Boolean = { + val df = new java.text.SimpleDateFormat(DateWithSeconds) + val headerEpoch: Long = scala.util.Try(df.parse(headerValue).getTime).getOrElse(0L) + val requestHeaders = cc.requestHeaders + .filter(i => i.name == "limit" || i.name == "offset").sortBy(_.name) + val hashedRequestPayload = code.api.util.HashUtil.Sha256Hash(cc.url + requestHeaders) + val consumerId = cc.consumer.map(_.consumerId.get).getOrElse("None") + val userId = scala.util.Try(cc.userId).getOrElse("None") + val compositeKey = + if (consumerId == "None" && userId == "None") "anonymous" + else s"consumerId${consumerId}::userId${userId}" + val cacheKey = s"$compositeKey::$hashedRequestPayload" + code.etag.MappedETag.find(By(code.etag.MappedETag.ETagResource, cacheKey)) match { + case Full(row) if row.lastUpdatedMSSinceEpoch < headerEpoch => + val modified = row.eTagValue != currentETag + if (modified) { + // Async update — match Lift's behaviour + scala.concurrent.Future(row.LastUpdatedMSSinceEpoch(System.currentTimeMillis).ETagValue(currentETag).save) + false + } else true + case Empty => + // Async create + scala.concurrent.Future(tryo( + code.etag.MappedETag.create + .ETagResource(cacheKey).ETagValue(currentETag) + .LastUpdatedMSSinceEpoch(System.currentTimeMillis).save)) + false + case _ => false + } + } + + val getAtms: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankIdStr / "atms" => + Implementations5_1_0.respondWithETag(req, { cc => + implicit val ccImpl: code.api.util.CallContext = cc + val bankId = BankId(bankIdStr) + val params = req.uri.query.multiParams + val limit: Box[String] = params.get("limit").flatMap(_.headOption).map(Full(_)).getOrElse(Empty) + val offset: Box[String] = params.get("offset").flatMap(_.headOption).map(Full(_)).getOrElse(Empty) + for { + _ <- if (getAtmsIsPublic) Future.successful(Full(())) else Future.successful(Full(cc.user.openOrThrowException(AuthenticatedUserIsRequired))) + _ <- Helper.booleanToFuture(s"$InvalidNumber limit:${limit.getOrElse("")}", cc = Some(cc)) { + limit match { + case Full(i) => i.toList.forall(c => Character.isDigit(c)) + case _ => true + } + } + _ <- Helper.booleanToFuture(maximumLimitExceeded, cc = Some(cc)) { + limit match { + case Full(i) if i.toInt > 10000 => false + case _ => true + } + } + (atms, _) <- NewStyle.function.getAtmsByBankId(bankId, offset, limit, Some(cc)) + atmAndAttrs <- Future.sequence(atms.map(atm => + NewStyle.function.getAtmAttributesByAtm(bankId, atm.atmId, Some(cc)).map(x => (atm, x._1)))) + } yield JSONFactory510.createAtmsJsonV510(atmAndAttrs) + }) + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAtms), "GET", + "/banks/BANK_ID/atms", "Get Bank ATMS", + s"""Returns information about ATMs for a single bank specified by BANK_ID. + | + |${userAuthenticationMessage(!getAtmsIsPublic)}""", + EmptyBody, atmsJsonV510, + List($BankNotFound, UnknownError), + List(apiTagATM), + None, + http4sPartialFunction = Some(getAtms) + ) + + val getAtm: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankIdStr / "atms" / atmIdStr => + Implementations5_1_0.respondWithETag(req, { cc => + implicit val ccImpl: code.api.util.CallContext = cc + val bankId = BankId(bankIdStr); val atmId = AtmId(atmIdStr) + for { + _ <- if (getAtmsIsPublic) Future.successful(Full(())) else Future.successful(Full(cc.user.openOrThrowException(AuthenticatedUserIsRequired))) + (_, _) <- NewStyle.function.getBank(bankId, Some(cc)) + (atm, _) <- NewStyle.function.getAtm(bankId, atmId, Some(cc)) + (atmAttributes, _) <- NewStyle.function.getAtmAttributesByAtm(bankId, atmId, Some(cc)) + } yield JSONFactory510.createAtmJsonV510(atm, atmAttributes) + }) + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAtm), "GET", + "/banks/BANK_ID/atms/ATM_ID", "Get Bank ATM", + s"""Returns information about ATM for a single bank specified by BANK_ID and ATM_ID. + | + |${userAuthenticationMessage(!getAtmsIsPublic)}""", + EmptyBody, atmJsonV510, + List(AuthenticatedUserIsRequired, BankNotFound, AtmNotFoundByAtmId, UnknownError), + List(apiTagATM), + None, + http4sPartialFunction = Some(getAtm) + ) val deleteAtm: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ DELETE -> `prefixPath` / "banks" / bankIdStr / "atms" / atmIdStr => @@ -943,15 +1088,32 @@ object Http4s510 { http4sPartialFunction = Some(createAgent) ) - // updateAgentStatus intentionally left to Lift: AgentTest "wrong Bankid" expects - // 404 BankNotFound for unauthorised user1, which means Lift's wrappedWithAuthCheck - // role check passes here even though user1 lacks - // canUpdateAgentStatusAtAnyBank/canUpdateAgentStatusAtOneBank. ResourceDocMiddleware - // applies the same access-control function (with JIT entitlements) and returns 403 - // — i.e. it's the strict reading of the doc roles. Leaving updateAgentStatus in - // Lift preserves the established test contract until the discrepancy is - // root-caused (suspect: the Lift test environment has additional entitlements - // wired in before this scenario via class-level or default-user fixtures). + val updateAgentStatus: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "agents" / agentId => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { + postedData <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $PostAgentJsonV510 ", 400, Some(cc)) { + net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[PutAgentJsonV510] + } + (_, _) <- NewStyle.function.getAgentByAgentId(agentId, Some(cc)) + (links, _) <- NewStyle.function.getAgentAccountLinksByAgentId(agentId, Some(cc)) + link <- NewStyle.function.tryons(AgentAccountLinkNotFound, 400, Some(cc)) { links.head } + (bankAccount, _) <- NewStyle.function.getBankAccount(BankId(link.bankId), AccountId(link.accountId), Some(cc)) + (agent, _) <- NewStyle.function.updateAgentStatus(agentId, postedData.is_pending_agent, postedData.is_confirmed_agent, Some(cc)) + } yield JSONFactory510.createAgentJson(agent, bankAccount) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateAgentStatus), "PUT", + "/banks/BANK_ID/agents/AGENT_ID", "Update Agent status", + s"${userAuthenticationMessage(true)}", + putAgentJsonV510, agentJsonV510, + List($AuthenticatedUserIsRequired, $BankNotFound, InvalidJsonFormat, AgentNotFound, AgentAccountLinkNotFound, UnknownError), + List(apiTagCustomer, apiTagPerson), + Some(canUpdateAgentStatusAtAnyBank :: canUpdateAgentStatusAtOneBank :: Nil), + http4sPartialFunction = Some(updateAgentStatus) + ) val getAgent: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` / "banks" / bankIdStr / "agents" / agentId => @@ -1506,10 +1668,86 @@ object Http4s510 { http4sPartialFunction = Some(getAccountAccessByUserId) ) - // ─── Accounts-held (2) — left to Lift ───────────────────────────────── - // getAccountsHeldByUserAtBank / getAccountsHeldByUser depend on - // AccountsHelper.getFilteredCoreAccounts which takes a Lift `Req`. Need - // to port the filter to http4s before migrating these. + // ─── Accounts-held (2) ──────────────────────────────────────────────── + // Lift's AccountsHelper.getFilteredCoreAccounts takes a `Req`; ported + // inline here against http4s' multiParams. Filter shape mirrors + // AccountsHelper.filterWithAccountType (v2_0_0/AccountsHelper.scala:39). + + private def filteredCoreAccountsByQueryParams( + bankIdAccountIds: List[BankIdAccountId], + params: Map[String, Seq[String]], + cc: code.api.util.CallContext + ): Future[List[com.openbankproject.commons.model.CoreAccount]] = { + val filters: List[String] = + params.get("account_type_filter").map(_.toList.flatMap(_.split(","))).getOrElse(Nil) + val filtersOperation: String = + params.get("account_type_filter_operation").flatMap(_.headOption).getOrElse("INCLUDE") + val failMsg = s"${ErrorMessages.InvalidFilterParameterFormat}request parameter " + + s"account_type_filter_operation must be either INCLUDE or EXCLUDE, current it is: $filtersOperation " + unboxFullOrFail(tryo { + assume(filtersOperation == "INCLUDE" || filtersOperation == "EXCLUDE") + }, Some(cc), failMsg) + NewStyle.function.getCoreBankAccountsFuture(bankIdAccountIds, Some(cc)).map { case (coreAccounts, _) => + coreAccounts.filter { account => + (filters, filtersOperation) match { + case (f, "INCLUDE") if f.nonEmpty => filters.contains(account.accountType) + case (f, "EXCLUDE") if f.nonEmpty => !filters.contains(account.accountType) + case _ => true + } + } + } + } + + val getAccountsHeldByUserAtBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "users" / userId / "banks" / bankIdStr / "accounts-held" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val bankId = BankId(bankIdStr) + for { + (u, _) <- NewStyle.function.getUserByUserId(userId, Some(cc)) + (availableAccounts, _) <- NewStyle.function.getAccountsHeld(bankId, u, Some(cc)) + (accounts, _) <- NewStyle.function.getBankAccountsHeldFuture(availableAccounts.toList, Some(cc)) + filteredCore <- Implementations5_1_0.filteredCoreAccountsByQueryParams(availableAccounts.toList, req.uri.query.multiParams, cc) + coreIds = filteredCore.map(_.id) + accountHelds = accounts.filter(a => coreIds.contains(a.id)) + } yield JSONFactory300.createCoreAccountsByCoreAccountsJSON(accountHelds) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAccountsHeldByUserAtBank), "GET", + "/users/USER_ID/banks/BANK_ID/accounts-held", "Get Accounts Held By User", + "Get Accounts held by the User at the bank, even before owner View is granted.", + EmptyBody, coreAccountsHeldJsonV300, + List($AuthenticatedUserIsRequired, $BankNotFound, UserNotFoundByUserId, UnknownError), + List(apiTagAccount), + Some(List(canGetAccountsHeldAtOneBank, canGetAccountsHeldAtAnyBank)), + http4sPartialFunction = Some(getAccountsHeldByUserAtBank) + ) + + val getAccountsHeldByUser: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "users" / userId / "accounts-held" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { + (u, _) <- NewStyle.function.getUserByUserId(userId, Some(cc)) + (availableAccounts, _) <- NewStyle.function.getAccountsHeldByUser(u, Some(cc)) + (accounts, _) <- NewStyle.function.getBankAccountsHeldFuture(availableAccounts, Some(cc)) + filteredCore <- Implementations5_1_0.filteredCoreAccountsByQueryParams(availableAccounts, req.uri.query.multiParams, cc) + coreIds = filteredCore.map(_.id) + accountHelds = accounts.filter(a => coreIds.contains(a.id)) + } yield JSONFactory300.createCoreAccountsByCoreAccountsJSON(accountHelds) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAccountsHeldByUser), "GET", + "/users/USER_ID/accounts-held", "Get Accounts Held By User", + "Get Accounts held by the User across all banks, even before owner View is granted.", + EmptyBody, coreAccountsHeldJsonV300, + List($AuthenticatedUserIsRequired, $BankNotFound, UserNotFoundByUserId, UnknownError), + List(apiTagAccount), + Some(List(canGetAccountsHeldAtAnyBank)), + http4sPartialFunction = Some(getAccountsHeldByUser) + ) // ─── Customer helpers (2) ───────────────────────────────────────────── @@ -2290,6 +2528,78 @@ object Http4s510 { http4sPartialFunction = Some(getCounterpartyLimit) ) + val getCounterpartyLimitStatus: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr / "views" / viewIdStr / "counterparties" / counterpartyIdStr / "limit-status" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val bankId = BankId(bankIdStr); val accountId = AccountId(accountIdStr) + val viewId = ViewId(viewIdStr); val counterpartyId = CounterpartyId(counterpartyIdStr) + val zoneId = java.time.ZoneId.systemDefault() + val today = java.time.LocalDate.now() + val firstDayOfMonth = today.withDayOfMonth(1) + val lastDayOfMonth = today.withDayOfMonth(today.lengthOfMonth()) + val firstDayOfYear = today.withDayOfYear(1) + val lastDayOfYear = today.withDayOfYear(today.lengthOfYear()) + val firstCurrentMonthDate: Date = Date.from(firstDayOfMonth.atStartOfDay(zoneId).toInstant) + val lastCurrentMonthDate: Date = Date.from(lastDayOfMonth.atTime(23, 59, 59, 999000000).atZone(zoneId).toInstant) + val firstCurrentYearDate: Date = Date.from(firstDayOfYear.atStartOfDay(zoneId).toInstant) + val lastCurrentYearDate: Date = Date.from(lastDayOfYear.atTime(23, 59, 59, 999000000).atZone(zoneId).toInstant) + val defaultFromDate: Date = APIUtil.theEpochTime + val defaultToDate: Date = APIUtil.ToDateInFuture + for { + (counterpartyLimit, _) <- NewStyle.function.getCounterpartyLimit( + bankId.value, accountId.value, viewId.value, counterpartyId.value, Some(cc)) + (fromBankAccount, _) <- NewStyle.function.getBankAccount(bankId, accountId, Some(cc)) + (sumMonthly, _) <- NewStyle.function.getSumOfTransactionsFromAccountToCounterparty( + bankId, accountId, counterpartyId, firstCurrentMonthDate, lastCurrentMonthDate, Some(cc)) + (countMonthly, _) <- NewStyle.function.getCountOfTransactionsFromAccountToCounterparty( + bankId, accountId, counterpartyId, firstCurrentMonthDate, lastCurrentMonthDate, Some(cc)) + (sumYearly, _) <- NewStyle.function.getSumOfTransactionsFromAccountToCounterparty( + bankId, accountId, counterpartyId, firstCurrentYearDate, lastCurrentYearDate, Some(cc)) + (countYearly, _) <- NewStyle.function.getCountOfTransactionsFromAccountToCounterparty( + bankId, accountId, counterpartyId, firstCurrentYearDate, lastCurrentYearDate, Some(cc)) + (sumAll, _) <- NewStyle.function.getSumOfTransactionsFromAccountToCounterparty( + bankId, accountId, counterpartyId, defaultFromDate, defaultToDate, Some(cc)) + (countAll, _) <- NewStyle.function.getCountOfTransactionsFromAccountToCounterparty( + bankId, accountId, counterpartyId, defaultFromDate, defaultToDate, Some(cc)) + } yield CounterpartyLimitStatusV510( + counterparty_limit_id = counterpartyLimit.counterpartyLimitId, + bank_id = counterpartyLimit.bankId, + account_id = counterpartyLimit.accountId, + view_id = counterpartyLimit.viewId, + counterparty_id = counterpartyLimit.counterpartyId, + currency = counterpartyLimit.currency, + max_single_amount = counterpartyLimit.maxSingleAmount.toString(), + max_monthly_amount = counterpartyLimit.maxMonthlyAmount.toString(), + max_number_of_monthly_transactions = counterpartyLimit.maxNumberOfMonthlyTransactions, + max_yearly_amount = counterpartyLimit.maxYearlyAmount.toString(), + max_number_of_yearly_transactions = counterpartyLimit.maxNumberOfYearlyTransactions, + max_total_amount = counterpartyLimit.maxTotalAmount.toString(), + max_number_of_transactions = counterpartyLimit.maxNumberOfTransactions, + status = CounterpartyLimitStatus( + currency_status = fromBankAccount.currency, + max_monthly_amount_status = sumMonthly.amount, + max_number_of_monthly_transactions_status = countMonthly, + max_yearly_amount_status = sumYearly.amount, + max_number_of_yearly_transactions_status = countYearly, + max_total_amount_status = sumAll.amount, + max_number_of_transactions_status = countAll + ) + ) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCounterpartyLimitStatus), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/counterparties/COUNTERPARTY_ID/limit-status", + "Get Counterparty Limit Status", "Get Counterparty Limit Status.", + EmptyBody, counterpartyLimitStatusV510, + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, $UserNoPermissionAccessView, + $CounterpartyNotFoundByCounterpartyId, InvalidJsonFormat, UnknownError), + List(apiTagCounterpartyLimits), + None, + http4sPartialFunction = Some(getCounterpartyLimitStatus) + ) + val deleteCounterpartyLimit: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ DELETE -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr / "views" / viewIdStr / "counterparties" / counterpartyIdStr / "limits" => EndpointHelpers.withUserDelete(req) { (_, cc) => @@ -3097,6 +3407,8 @@ object Http4s510 { .orElse(getAggregateMetrics(req)) .orElse(createAtm(req)) .orElse(updateAtm(req)) + .orElse(getAtms(req)) + .orElse(getAtm(req)) .orElse(deleteAtm(req)) .orElse(createConsumer(req)) .orElse(getConsumer(req)) @@ -3124,6 +3436,7 @@ object Http4s510 { .orElse(updateAtmAttribute(req)) .orElse(deleteAtmAttribute(req)) .orElse(createAgent(req)) + .orElse(updateAgentStatus(req)) .orElse(getAgent(req)) .orElse(getAgents(req)) .orElse(createRegulatedEntityAttribute(req)) @@ -3148,6 +3461,8 @@ object Http4s510 { .orElse(lockUserByProviderAndUsername(req)) .orElse(validateUserByUserId(req)) .orElse(getAccountAccessByUserId(req)) + .orElse(getAccountsHeldByUserAtBank(req)) + .orElse(getAccountsHeldByUser(req)) .orElse(getCustomersForUserIdsOnly(req)) .orElse(getCustomersByLegalName(req)) .orElse(customViewNamesCheck(req)) @@ -3174,6 +3489,7 @@ object Http4s510 { .orElse(createCounterpartyLimit(req)) .orElse(updateCounterpartyLimit(req)) .orElse(getCounterpartyLimit(req)) + .orElse(getCounterpartyLimitStatus(req)) .orElse(deleteCounterpartyLimit(req)) .orElse(createCustomView(req)) .orElse(updateCustomView(req)) @@ -3217,14 +3533,30 @@ object Http4s510 { } } - // Bridge cascade is currently DISABLED for v5.1.0: - // While migrating, unmigrated v5.1.0 endpoints (e.g. updateConsumerRedirectURL) - // would otherwise be sent through v510→v500→v400→… and either land on a - // wrong-version handler or never reach Lift's OBPAPI5_1_0 dispatch correctly. - // Without the bridge, unmatched v5.1.0 URLs fall through to Http4sLiftWebBridge - // unchanged, where Lift's dispatch for OBPAPI5_1_0 picks them up. Re-enable - // the bridge once ALL v5.1.0 own endpoints (currently 110) are migrated to - // Http4s510 — then `.orElse(Implementations5_1_0.v510ToV500Bridge.run(req))`. + // Bridge cascade is currently DISABLED — all 111 v5.1.0 own endpoints are + // migrated, but enabling `v510ToV500Bridge` surfaces two cross-version + // interaction issues that need root-causing first: + // + // - MetricTest "include_implemented_by_partial_functions=getBanks" returns + // 0 instead of 12. Setup calls /obp/v5.1.0/banks should be hijacked to + // v500.getBanks via the bridge, recording metrics with + // partial_function_name=getBanks. Filter doesn't see them — the metric + // records likely have a different field shape (URL/version) when + // written from the bridged path. + // - VRPConsentRequestTest's "Create Consent By CONSENT_REQUEST_ID (EMAIL)" + // returns 400 instead of 201 (5 scenarios). The v5.1 createVRPConsentRequest + // stores a payload with `consent_type: VRP` merged in, then the test calls + // /obp/v5.1.0/consumer/consent-requests/X/EMAIL/consents which the bridge + // rewrites into v500.createConsentByConsentRequestId. That handler tries + // to parse the payload as PostVRPConsentRequestJsonInternalV510 and + // apparently fails — likely the merged consent_type field. + // + // Without the bridge, these URLs fall through Http4sApp's chain to + // Http4sLiftWebBridge with the original /obp/v5.1.0/ path; Lift's + // OBPAPI5_1_0 dispatch picks them up via the inherited APIMethods500 + // partial functions and serves them correctly. To re-enable the bridge, + // append `.orElse(Implementations5_1_0.v510ToV500Bridge.run(req))` and + // address the two failures above. val wrappedRoutesV510Services: HttpRoutes[IO] = Implementations5_1_0.allRoutesWithMiddleware } From 6b764b47f6c2b752a8a7b58f1be65a39f71ef0bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Thu, 14 May 2026 10:51:00 +0200 Subject: [PATCH 27/30] fix(middleware): move Force-Error/AuthType/JsonSchema after bank/role checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI shard 1 surfaced ForceErrorValidationTest's "We will call the endpoint with user credentials" scenario failing — `errorMessage contains canCreateCustomer.toString() should be (true)` was false. Root cause: the bank/roles reorder I made in commit b274ce7b9 (matching Lift's "bank before roles" rule) left the three after-authenticate interceptors — Force-Error, AuthType, JsonSchema — running BEFORE the bank/role checks. That made Force-Error fire first, returning the canonical error text "OBP-20006: User is missing one or more roles: " without the role names appended. Lift's wrappedWithAuthCheck (APIUtil.scala:1934-1969) runs the afterAuthenticateInterceptors INSIDE the for-comp's yield block, i.e. only after every checker (auth → bank → roles → account → view → counterparty) has succeeded. When the natural role check fails first, the role-check error message — which formats `UserHasMissingRoles + roles.mkString(" or ")` — short-circuits before the Force-Error interceptor gets a chance. Fix: move processForceError / validateAuthType / validateJsonSchema to the END of the validation chain, after counterparty. Final order now matches Lift exactly: auth → bank → roles → account → view → counterparty → processForceError → validateAuthType → validateJsonSchema ForceErrorValidationTest, MakerCheckerTransactionRequestTest, and BankAttributeTests all pass locally with the new order. 348-test broad regression across v5.0/v5.1/v6/v7/bridge also clean. (MakerCheckerTransactionRequestTest's "Multiple challenges" scenario was the second CI failure but passes locally — likely existing v4 transaction-request flakiness, not middleware-related.) --- .../code/api/util/http4s/ResourceDocMiddleware.scala | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) 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 7463e1b873..4350c5c17e 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 @@ -261,19 +261,25 @@ object ResourceDocMiddleware extends MdcLoggable { // Validation order MUST match Lift's wrappedWithAuthCheck (APIUtil.scala:1934-1969): // 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 // have a proper role → We cannot assign the role to non existing bank." + // Force-Error / AuthType / JsonSchema interceptors must run LAST so the + // natural role/bank/account checks short-circuit first when they fail — + // ForceErrorValidationTest expects the role-check error message (with the + // 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 <- processForceError(req, resourceDoc, context) - context <- validateAuthType(resourceDoc, context) - context <- validateJsonSchema(resourceDoc, context) context <- validateBank(pathParams, context) context <- authorizeRoles(resourceDoc, pathParams, context) context <- validateAccount(pathParams, context) context <- validateView(pathParams, context) context <- validateCounterparty(pathParams, context) + context <- processForceError(req, resourceDoc, context) + context <- validateAuthType(resourceDoc, context) + context <- validateJsonSchema(resourceDoc, context) } yield context result.value.map { From d4c0af7b85789fb2e63119c8b8b16e0ca5d33583 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Thu, 14 May 2026 16:02:34 +0200 Subject: [PATCH 28/30] deps: bump 11 libraries to close 21 Dependabot security alerts Single batch covering one Critical, six High, and fourteen Moderate advisories across the JDBC, crypto, gRPC, HTTP-client, JWT, mail, log4j, and Elasticsearch stacks. API-compatible bumps throughout; compile + test-compile clean on both modules; BC, JDBC and http4s integration smoke suites (24 tests) pass. - postgresql 42.7.7 -> 42.7.11 - mysql-connector-j 8.1.0 -> 9.4.0 - bcpg-jdk18on, bcpkix-jdk18on 1.78.1 -> 1.81 - com.sun.mail:jakarta.mail:2.0.1 -> org.eclipse.angus:jakarta.mail:2.0.5 (com.sun line abandoned at 2.0.2 still vulnerable; identical jakarta.mail.* API surface, no source changes needed) - grpc-{netty-shaded,protobuf,stub,services} 1.48.1 -> 1.71.0 (closes Netty MadeYouReset HTTP/2 DDoS via newer shaded Netty) - nimbus-jose-jwt 9.37.2 -> 9.48 (latest 9.x; defers 10.x API migration) - async-http-client 2.10.4 -> 2.15.0 (latest 2.x; 3.x would be a rewrite) - log4j-api, log4j-core 2.24.3 -> 2.26.0 (pin still required to override ES's transitive 2.19.0; bumped past the 2.24 Rfc5424/XmlLayout/TLS-host advisories) - elasticsearch, elasticsearch-rest-client 8.14.0 -> 8.19.15 - elastic4s-client-esjava 8.5.2 -> 8.11.5 --- obp-api/pom.xml | 50 +++++++++++++++++++++++++++---------------------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/obp-api/pom.xml b/obp-api/pom.xml index d6f8c3c25d..a8ab8c51ac 100644 --- a/obp-api/pom.xml +++ b/obp-api/pom.xml @@ -52,7 +52,7 @@ org.bouncycastle bcpg-jdk18on - 1.78.1 + 1.81 org.http4s @@ -67,7 +67,7 @@ org.bouncycastle bcpkix-jdk18on - 1.78.1 + 1.81 @@ -82,7 +82,7 @@ org.postgresql postgresql - 42.7.7 + 42.7.11 @@ -103,7 +103,7 @@ com.mysql mysql-connector-j - 8.1.0 + 9.4.0 @@ -134,17 +134,19 @@ 1.16.2 + by elasticsearch (the project's logger is logback; log4j2 is only present + for ES to load its own appenders). 2.26.0 also fixes the Rfc5424Layout log + injection, XmlLayout XML-1.0 silent drop, Socket-Appender TLS hostname + non-verification, and verifyHostName silent-ignore advisories that hit 2.24.3. --> org.apache.logging.log4j log4j-api - 2.24.3 + 2.26.0 org.apache.logging.log4j log4j-core - 2.24.3 + 2.26.0 @@ -183,21 +185,22 @@ org.elasticsearch elasticsearch - 8.14.0 + 8.19.15 com.sksamuel.elastic4s elastic4s-client-esjava_${scala.version} - 8.5.2 + 8.11.5 - + org.elasticsearch.client elasticsearch-rest-client - 8.14.0 + 8.19.15 @@ -274,7 +277,7 @@ com.nimbusds nimbus-jose-jwt - 9.37.2 + 9.48 com.github.OpenBankProject @@ -384,27 +387,27 @@ io.grpc grpc-netty-shaded - 1.48.1 + 1.71.0 io.grpc grpc-protobuf - 1.48.1 + 1.71.0 io.grpc grpc-stub - 1.48.1 + 1.71.0 io.grpc grpc-services - 1.48.1 + 1.71.0 org.asynchttpclient async-http-client - 2.10.4 + 2.15.0 javax.activation @@ -535,10 +538,13 @@ test + - com.sun.mail + org.eclipse.angus jakarta.mail - 2.0.1 + 2.0.5 jakarta.activation From 17e2c38c345727f2cb57330e0f551fabe17b79aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Thu, 14 May 2026 16:23:45 +0200 Subject: [PATCH 29/30] deps: bump nimbus/oauth2-sdk/bc/grpc/beanutils to close 5 residual alerts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dependabot rescan after d4c0af7b8 found that 1.81 / 1.71.0 / 9.48 were not high enough — the actual minimum-patched versions are later. Picks below follow the OSV "fixed" ranges directly. - bcpg-jdk18on, bcpkix-jdk18on 1.81 -> 1.84 (uncontrolled resource consumption + risky-algo fixes only land in 1.84) - grpc-{netty-shaded,protobuf,stub,services} 1.71.0 -> 1.75.0 (shaded Netty's MadeYouReset HTTP/2 DDoS fix lands at grpc 1.75.0) - nimbus-jose-jwt 9.48 -> 10.5 (the 9.x branch past 9.37.4 is unmaintained and remains vulnerable to deeply-nested-JSON DoS; 10.0.2+ has the fix) - oauth2-oidc-sdk 9.27 -> 11.37.1 (paired with nimbus 10 — sdk 9.x was compiled against nimbus-jose-jwt 9.x and would NoSuchMethodError at runtime against nimbus 10; 11.x is the matching major) - commons-beanutils 1.10.1 -> 1.11.0 (Improper Access Control) CertificateUtil.scala: replaced wildcard `import com.nimbusds.jose._` with explicit imports — nimbus 10 added a `com.nimbusds.jose.Option` class which shadowed `scala.Option` in this file. Verified: PasswordResetTest (JWT/Nimbus 10.5), RegulatedEntityTest (BC 1.84), Http4sServerIntegrationTest, Http4s700TransactionTest — 41 tests pass. --- obp-api/pom.xml | 21 ++++++++++--------- .../scala/code/api/util/CertificateUtil.scala | 2 +- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/obp-api/pom.xml b/obp-api/pom.xml index a8ab8c51ac..8454a0431b 100644 --- a/obp-api/pom.xml +++ b/obp-api/pom.xml @@ -52,7 +52,7 @@ org.bouncycastle bcpg-jdk18on - 1.81 + 1.84 org.http4s @@ -67,7 +67,7 @@ org.bouncycastle bcpkix-jdk18on - 1.81 + 1.84 @@ -120,11 +120,12 @@ 1.1.10.4 + everit json-schema → commons-validator. Fixes CVE-2025-48734, CVE-2019-10086; + 1.11.0 adds the Improper-Access-Control fix (GHSA on top of those). --> commons-beanutils commons-beanutils - 1.10.1 + 1.11.0 @@ -277,7 +278,7 @@ com.nimbusds nimbus-jose-jwt - 9.48 + 10.5 com.github.OpenBankProject @@ -295,7 +296,7 @@ com.nimbusds oauth2-oidc-sdk - 9.27 + 11.37.1 @@ -387,22 +388,22 @@ io.grpc grpc-netty-shaded - 1.71.0 + 1.75.0 io.grpc grpc-protobuf - 1.71.0 + 1.75.0 io.grpc grpc-stub - 1.71.0 + 1.75.0 io.grpc grpc-services - 1.71.0 + 1.75.0 org.asynchttpclient diff --git a/obp-api/src/main/scala/code/api/util/CertificateUtil.scala b/obp-api/src/main/scala/code/api/util/CertificateUtil.scala index a0dc0d5ed0..8da1975553 100644 --- a/obp-api/src/main/scala/code/api/util/CertificateUtil.scala +++ b/obp-api/src/main/scala/code/api/util/CertificateUtil.scala @@ -4,7 +4,7 @@ import code.api.CertificateConstants import code.api.util.CryptoSystem.CryptoSystem import code.api.util.SelfSignedCertificateUtil.generateSelfSignedCert import code.util.Helper.MdcLoggable -import com.nimbusds.jose._ +import com.nimbusds.jose.{EncryptionMethod, JWEAlgorithm, JWEHeader, JWSAlgorithm, JWSHeader, JWSSigner} import com.nimbusds.jose.crypto.{MACSigner, RSAEncrypter, RSASSASigner} import com.nimbusds.jose.util.X509CertUtils import com.nimbusds.jwt.{EncryptedJWT, JWTClaimsSet} From ecbbc31f52ac7f8a12dcdcd55371ef14093b18d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Thu, 14 May 2026 16:55:11 +0200 Subject: [PATCH 30/30] cleanup: drop dead nimbus.jose wildcard in JwtUtil.validateIdToken MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `import com.nimbusds.jose._` at line 265 was functionally dead — every JOSE type referenced in the method (JOSEException, BadJOSEException, JWSAlgorithm, JWSVerificationKeySelector, SecurityContext) is imported explicitly elsewhere in the file. Same hazard the CertificateUtil fix addressed: under Nimbus 10 the wildcard pulls in `com.nimbusds.jose.Option` which shadows `scala.Option`, so any future Option[X] usage in this method would silently break. --- obp-api/src/main/scala/code/api/util/JwtUtil.scala | 1 - 1 file changed, 1 deletion(-) diff --git a/obp-api/src/main/scala/code/api/util/JwtUtil.scala b/obp-api/src/main/scala/code/api/util/JwtUtil.scala index 6fd4f3cee5..edaa27069d 100644 --- a/obp-api/src/main/scala/code/api/util/JwtUtil.scala +++ b/obp-api/src/main/scala/code/api/util/JwtUtil.scala @@ -262,7 +262,6 @@ object JwtUtil extends MdcLoggable { def validateIdToken(idToken: String, remoteJWKSetUrl: String): Box[IDTokenClaimsSet] = { import java.net._ - import com.nimbusds.jose._ import com.nimbusds.oauth2.sdk.id._ import com.nimbusds.openid.connect.sdk.validators._