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/CLAUDE.md b/CLAUDE.md index b8978a66fb..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. @@ -149,7 +152,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. @@ -157,6 +181,87 @@ case req @ POST -> `prefixPath` / "banks" / _ / "accounts" / accountIdStr / "vie **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 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 +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=`. 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 +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**: 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. + +**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. + +**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 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: + +``` +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 6e5e6a3377..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` → `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 @@ -119,8 +119,8 @@ 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 | | -| 9 | `APIMethods400` | ~258 total | Largest file; may need splitting into sub-traits | +| 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 | **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 | @@ -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/pom.xml b/obp-api/pom.xml index b12349c9fc..0e4fead345 100644 --- a/obp-api/pom.xml +++ b/obp-api/pom.xml @@ -52,7 +52,7 @@ org.bouncycastle bcpg-jdk18on - 1.78.1 + 1.84 org.http4s @@ -67,7 +67,7 @@ org.bouncycastle bcpkix-jdk18on - 1.78.1 + 1.84 @@ -82,7 +82,7 @@ org.postgresql postgresql - 42.7.7 + 42.7.11 @@ -122,11 +122,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 + 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 @@ -281,7 +284,7 @@ com.nimbusds nimbus-jose-jwt - 9.37.2 + 10.5 com.github.OpenBankProject @@ -299,7 +302,7 @@ com.nimbusds oauth2-oidc-sdk - 9.27 + 11.37.1 @@ -391,22 +394,22 @@ io.grpc grpc-netty-shaded - 1.68.3 + 1.75.0 io.grpc grpc-protobuf - 1.68.3 + 1.75.0 io.grpc grpc-stub - 1.68.3 + 1.75.0 io.grpc grpc-services - 1.68.3 + 1.75.0 org.asynchttpclient @@ -542,10 +545,13 @@ test + - com.sun.mail + org.eclipse.angus jakarta.mail - 2.0.1 + 2.0.5 jakarta.activation 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} 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._ 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..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 @@ -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 @@ -67,27 +68,58 @@ 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 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) /** - * 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(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(v510Routes.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 d63f3f665a..6262c6246a 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]. @@ -498,8 +510,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, @@ -685,8 +701,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/util/http4s/ResourceDocMiddleware.scala b/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala index 6ae7cfe362..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 @@ -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)) } } } @@ -255,13 +259,27 @@ 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 + // → 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 <- authorizeRoles(resourceDoc, pathParams, 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 { @@ -285,13 +303,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 { @@ -301,7 +327,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. @@ -347,13 +375,11 @@ 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(", "), ctx.callContext) + ErrorResponseConverter.createErrorResponse(403, UserHasMissingRoles + roles.mkString(" or "), ctx.callContext) .map[Either[Response[IO], ValidationContext]](Left(_)) ) case _ => @@ -367,6 +393,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] = { 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/v2_1_0/Http4s210.scala b/obp-api/src/main/scala/code/api/v2_1_0/Http4s210.scala index 2620118cd4..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 @@ -162,11 +162,21 @@ 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. (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( @@ -226,21 +236,18 @@ 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: 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, @@ -361,7 +368,13 @@ object Http4s210 { body, serialized, sharedChargePolicy, None, None, Some(cc)) } yield result case other => - Future.failed(new RuntimeException(s"$InvalidTransactionRequestType: '$transactionRequestTypeStr'")) + // 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)))) } } yield JSONFactory210.createTransactionRequestWithChargeJSON(createdTransactionRequest) } @@ -369,12 +382,20 @@ 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)) 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 @@ -386,18 +407,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, 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/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 6a61ade5b1..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 @@ -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() } @@ -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/v3_1_0/Http4s310.scala b/obp-api/src/main/scala/code/api/v3_1_0/Http4s310.scala new file mode 100644 index 0000000000..e8a4e90132 --- /dev/null +++ b/obp-api/src/main/scala/code/api/v3_1_0/Http4s310.scala @@ -0,0 +1,3650 @@ +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" => + // 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 { + 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)) + } +} 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 new file mode 100644 index 0000000000..6a9701cae3 --- /dev/null +++ b/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala @@ -0,0 +1,2413 @@ +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.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 +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.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 +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)) + + // ─── 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/USER_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] { + 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)) + + // ─── 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)) + + // ─── 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)) + + // ─── 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)) + + // ─── 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 { + // 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 + // 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)) + + // ─── 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 => + 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(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)) + .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)) + .orElse(getProductAttribute.run(req)) + .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)) + .orElse(getFirehoseAccountsAtOneBank.run(req)) + .orElse(createTransactionRequest.run(req)) + .orElse(answerTransactionRequestChallenge.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)) + } +} 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] = { 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..9999e2b4c0 --- /dev/null +++ b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala @@ -0,0 +1,3562 @@ +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, 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.{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} +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, 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, ConsentType, RegulatedEntityAttributeType, StrongCustomerAuthentication, TransactionRequestStatus, UserAttributeType} +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 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 +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 ───────────────────────────────────────────────── + // 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 => + 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) ──────────────────────── + + // ─── 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) + ) + + 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 => + 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) + ) + + // ─── 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) ──────────────────────────────────────────────── + // 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) ───────────────────────────────────────────── + + 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) + ) + + // ─── 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) + ) + + // ─── 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 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) => + 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) + ) + + // ─── 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) + .orElse(getMyConsentsByBank(req)) + .orElse(getAggregateMetrics(req)) + .orElse(createAtm(req)) + .orElse(updateAtm(req)) + .orElse(getAtms(req)) + .orElse(getAtm(req)) + .orElse(deleteAtm(req)) + .orElse(createConsumer(req)) + .orElse(getConsumer(req)) + .orElse(getConsumers(req)) + .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(updateAgentStatus(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)) + .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(getAccountsHeldByUserAtBank(req)) + .orElse(getAccountsHeldByUser(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)) + .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)) + .orElse(getCoreAccountByIdThroughView(req)) + .orElse(getBankAccountBalances(req)) + .orElse(getBankAccountsBalancesThroughView(req)) + .orElse(createCounterpartyLimit(req)) + .orElse(updateCounterpartyLimit(req)) + .orElse(getCounterpartyLimit(req)) + .orElse(getCounterpartyLimitStatus(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)) + .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] = + 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 — 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 +}