diff --git a/CLAUDE.md b/CLAUDE.md index 0e0e4127d8..b8978a66fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,20 +2,27 @@ ## Working Style - Never blame pre-existing issues or other commits. No excuses, no finger-pointing — diagnose and resolve. +- Never add `Co-Authored-By` trailers to commit messages. +- **Goal is full http4s migration** — eliminate Lift Web and all deprecated libraries entirely. Treat Lift code as temporary scaffolding to be removed, not maintained. When fixing bugs or adding features, always prefer the http4s path. +- **Versioning is tech-agnostic** — API version numbers reflect API signature changes (new/changed fields, new behaviour), never the underlying framework. A framework migration (Lift → http4s) happens in-place at the existing version; it does not justify a version bump. ## Architecture (Onboarding) -v7.0.0 is a Lift Web → http4s migration. Not a replacement for v6.0.0 yet — 27 of 633 endpoints migrated. +> **Migration plan**: see [`LIFT_HTTP4S_MIGRATION.md`](LIFT_HTTP4S_MIGRATION.md) for the full in-place Lift → http4s strategy, file order, auth stack workstream, and progress tracker. -**Request priority chain** (Http4sServer): `corsHandler` (OPTIONS) → StatusPage → Http4s500 → Http4s700 → Http4sBGv2 → Http4sLiftWebBridge (Lift fallback). Unhandled `/obp/v7.0.0/*` paths fall through silently to Lift — they do not 404. +The goal is a full http4s migration — replace Lift Web across all version files and remove it entirely. **API versions are tech-agnostic**: a version bump means a changed/new API signature, never a framework change. Framework migration happens in-place inside the existing version file. v7.0.0 currently serves 45 endpoints; most arrived there for historical reasons and stay as-is. -**Key files**: `Http4s700.scala` (endpoints), `Http4sSupport.scala` (EndpointHelpers + recordMetric), `ResourceDocMiddleware.scala` (auth, entity resolution, transaction wrapper), `RequestScopeConnection.scala` (DB transaction propagation to Futures). +**Request priority chain** (Http4sServer): `corsHandler` (OPTIONS) → StatusPage → Http4s500 → Http4s700 → Http4sBGv2 → Http4s200 → Http4s140 → Http4s130 → Http4s121 → Http4sLiftWebBridge (Lift fallback). Unhandled `/obp/v7.0.0/*` paths fall through silently to Lift — they do not 404. -**Migrated endpoints** (27): root, getBanks, getCards, getCardsForBank, getResourceDocsObpV700, getBank, getCurrentUser, getCoreAccountById, getPrivateAccountByIdFull, getExplicitCounterpartyById, deleteEntitlement, addEntitlement, getFeatures, getScannedApiVersions, getConnectors, getProviders, getUsers, getCustomersAtOneBank, getCustomerByCustomerId, getAccountsAtBank, getUserByUserId, getCacheConfig, getCacheInfo, getDatabasePoolInfo, getStoredProcedureConnectorHealth, getMigrations, getCacheNamespaces. +**Key files**: `Http4s700.scala` (v7.0.0 endpoints), `Http4s200.scala` (v2.0.0 endpoints — 37 own + path-rewriting bridge to Http4s140), `Http4s140.scala` (v1.4.0 endpoints — 11 own + path-rewriting bridge to Http4s130), `Http4s130.scala` (v1.3.0 endpoints — 3 own + path-rewriting bridge to Http4s121), `Http4s121.scala` (v1.2.1 endpoints — all 323 API1_2_1Test scenarios), `Http4sSupport.scala` (EndpointHelpers + recordMetric), `ResourceDocMiddleware.scala` (auth, entity resolution, transaction wrapper), `IdempotencyMiddleware.scala` (Redis-backed idempotency, opt-in via `Idempotency-Key` header, nested inside ResourceDocMiddleware), `RequestScopeConnection.scala` (DB transaction propagation to Futures). -**Tests**: `Http4s700RoutesTest` (93 scenarios, port 8087). `makeHttpRequest` returns `(Int, JValue, Map[String, String])`. `makeHttpRequestWithBody(method, path, body, headers)` for POST/PUT. +**Migrated endpoints** (45): root, getBanks, getCards, getCardsForBank, getResourceDocsObpV700, getBank, getCurrentUser, getCoreAccountById, getPrivateAccountByIdFull, getExplicitCounterpartyById, deleteEntitlement, addEntitlement, getAccountAccessTrace, getFeatures, getScannedApiVersions, getConnectors, getErrorMessages, getProviders, getUsers, getUserByUserId, getCustomersAtOneBank, getCustomerByCustomerId, getAccountsAtBank, createTradingOffer, getTradingOffer, getTradingOffers, cancelTradingOffer, createMarketOrder, getMarketOrder, cancelMarketOrder, createMarketMatch, getMarketTrade, requestSettlement, requestWithdrawal, getCacheConfig, getCacheInfo, getDatabasePoolInfo, getStoredProcedureConnectorHealth, getMigrations, getCacheNamespaces, createOrganisation, getOrganisations, getOrganisation, updateOrganisation, deleteOrganisation. -## Migrating a v6.0.0 Endpoint to v7.0.0 +**Tests**: `Http4s700RoutesTest` (111 scenarios, port 8087). `makeHttpRequest` returns `(Int, JValue, Map[String, String])`. `makeHttpRequestWithBody(method, path, body, headers)` for POST/PUT. + +## Migrating a Lift Endpoint to http4s + +Rules apply regardless of which version file the endpoint lives in. Use v7.0.0 only when the API signature is new or changed; otherwise migrate in-place in the original version file. ### Rule 1 — ResourceDoc registration ```scala @@ -71,7 +78,7 @@ EndpointHelpers.withBankAccount(req) { (user, account, cc) => ... } / EndpointHelpers.withView(req) { (user, account, view, cc) => ... } // + VIEW_ID EndpointHelpers.withCounterparty(req) { (user, account, view, cp, cc) => ... } // + COUNTERPARTY_ID ``` -**POST → 201**: `executeFutureWithBodyCreated[B,A]` / `withUserAndBodyCreated[B,A]` / `withUserAndBankAndBodyCreated[B,A]` +**POST → 201**: `executeFutureWithBodyCreated[B,A]` / `withUserAndBodyCreated[B,A]` / `withUserAndBankAndBodyCreated[B,A]` / `withViewCreated[A]` (when view context is needed) **PUT → 200**: `executeFutureWithBody[B,A]` / `withUserAndBody[B,A]` / `withUserAndBankAndBody[B,A]` **DELETE → 204**: `executeDelete` / `withUserDelete` / `withUserAndBankDelete` @@ -80,6 +87,15 @@ 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: +```scala +_ <- if (userIdAccountOwner == loggedInUserId) Future.successful(Full(())) + else code.util.Helper.booleanToFuture( + s"$UserHasMissingRoles $canCreateAccount", failCode = 403, cc = Some(cc)) { + APIUtil.hasEntitlement(bankId, loggedInUserId, canCreateAccount) + } +``` + **View permissions**: `view.canGetCounterparty` (MappedBoolean) always returns `false` for system views. Use `view.allowed_actions.exists(_ == CAN_GET_COUNTERPARTY)` instead. **BankExtended**: `privateAccountsFuture`, `privateAccounts`, `publicAccounts` are on `code.model.BankExtended`, not `commons.Bank`. Wrap: `code.model.BankExtended(bank).privateAccountsFuture(...)`. @@ -115,6 +131,30 @@ EndpointHelpers.withCounterparty(req) { (user, account, view, cp, cc) => ... } / **Creating test data**: use provider directly — e.g. `CustomerX.customerProvider.vend.addCustomer(...)`. Do not call v6 endpoints via HTTP in v7 tests. +**`NewStyle.function.getBankAccount` returns 404**: The `unboxFullOrFail` inside hardcodes code 404. When your endpoint must return 400 for a missing account (e.g. v1.2.1 tests), bypass it: use `Connector.connector.vend.checkBankAccountExists(bankId, accountId, cc)` then `Future { unboxFullOrFail(rawBox, cc, msg) }` — the default code is 400. + +**Middleware URL template bypass** (non-standard uppercase vars): `validateAccount` checks `pathParams.get("ACCOUNT_ID")` and `validateView` checks `pathParams.get("VIEW_ID")` by exact key. Any other all-caps segment (e.g. `BANK_ACCOUNT_ID`, `CUSTOM_VIEW_ID`, `GRANT_VIEW_ID`, `NEW_ACCOUNT_ID`, `VIEW_ACCOUNT_ID`, `UPD_VIEW_ID`) is still matched as a template variable (wildcard) but skips the 404/403 validation. Use this when your handler does inline validation returning 400 but middleware would return 404 or 403 first. + +For IO-based handlers that bypass `ACCOUNT_ID`, look up the account inline and return 400 for missing accounts (matching Lift behaviour): +```scala +// ResourceDoc URL: "/banks/BANK_ID/accounts/VIEW_ACCOUNT_ID/views" +case req @ POST -> `prefixPath` / "banks" / _ / "accounts" / accountIdStr / "views" => + implicit val cc: CallContext = req.callContext + val io = for { + 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)) // default emptyBoxErrorCode=400 + ... + } yield result +``` +`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. + +**`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. + +**v1.2.1 test framework sends filter params as HTTP headers**: `makeGetRequest(req, params)` puts `params` into `extra_headers`, not the URL query string. This means `obp_limit`, `obp_sort_direction`, `obp_from_date`, etc. arrive as request headers. Do NOT use `createHttpParamsByUrl(req.uri.renderString)` — it only scans the URL for non-prefixed names. Instead: `req.headers.headers.toList.map(h => HTTPParam(h.name.toString, h.value))`, then pass to `createQueriesByHttpParamsFuture`. + **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). ## CI Performance Profile @@ -147,9 +187,10 @@ Compile times are consistent across all three shards — Zinc cache restores cor At the integration level both frameworks are similarly server/DB-bound (~0.32–0.45 s/test). The real http4s gain is the **unit/pure tier** — tests that don't need a running server are 54× faster. As more logic moves into pure functions (request parsing, response building, auth checks) these unit tests replace integration tests and the savings compound. -The 5 integration suites (160 tests, 66.9s total): +The 6 integration suites (pre-merge timings; Http4s700RoutesTest has grown to 111 scenarios): - `obp-api/src/test/scala/code/api/http4sbridge/Http4sLiftBridgePropertyTest.scala` — 51 tests, 31.9s -- `obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala` — 75 tests, 23.8s +- `obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala` — 111 tests (was 75, 23.8s pre-merge) +- `obp-api/src/test/scala/code/api/v7_0_0/V7ResourceDocsAggregationTest.scala` — intentionally failing until resource-docs aggregation bug is fixed - `obp-api/src/test/scala/code/api/http4sbridge/Http4sServerIntegrationTest.scala` — 16 tests, 5.0s - `obp-api/src/test/scala/code/api/v5_0_0/Http4s500SystemViewsTest.scala` — 13 tests, 4.4s - `obp-api/src/test/scala/code/api/v7_0_0/Http4s700TransactionTest.scala` — 5 tests, 1.9s @@ -170,7 +211,7 @@ The 12 pure-unit suites (172 tests, 1.3s total): ### Known bottlenecks -**`API1_2_1Test`** (Lift v1) — 143s for 323 tests, 36% of shard2's entire test time. Larger than the full http4s v7 budget. The first test in the suite (`"base line URL works"`) takes 0.97s — Lift's lazy init cost. Moving this suite to its own shard would reduce pipeline wall-clock by ~90s. +**`API1_2_1Test`** (now http4s-backed via `Http4s121`) — was 143s for 323 tests on the Lift path; expected to improve as Lift bridge overhead is eliminated. The suite is in shard 3 (`code.api.v1_2_1` prefix). **`Http4sLiftBridgePropertyTest`** — 31.9s for 51 tests. Property 7 ("Session and Context Adapter Correctness") accounts for 13.4s of that: three ScalaCheck properties exercise concurrent requests through the Lift/http4s bridge, hitting real lock contention between Lift's session manager and the http4s fiber scheduler. Property 7.4 alone is 8.54s. These are the most meaningful slow tests — they exercise a genuine concurrency boundary. @@ -215,6 +256,7 @@ Body helpers and DELETE 204 helpers ready. Velocity: 6–8 endpoints/day. Dynamic entities, ABAC rules, mandate workflows, polymorphic bodies. ~45–60 min each. ### Other TODOs -- **OBP-Trading** (at `/home/marko/Tesobe/GitHub/constantine2nd/OBP-Trading`): pending team decision — port trading endpoints into `Http4s700.scala` or keep as a separate service that OBP-API proxies to. Connectors (`ObpApiUserConnector`, `ObpPaymentsConnector`) are currently in-memory stubs. +- **OBP-Trading**: trading endpoints (createTradingOffer, getTradingOffer, getTradingOffers, cancelTradingOffer, createMarketOrder, getMarketOrder, cancelMarketOrder, createMarketMatch, getMarketTrade, requestSettlement, requestWithdrawal) are now in `Http4s700.scala`. 5 payment-auth endpoints remain commented out (notifyDeposit, createPaymentAuth, capturePaymentAuth, releasePaymentAuth, getPaymentAuth) — see `ideas/CAPTURE_RELEASE_TRANSACTION_REQUEST_TYPES.md`. - **CI speed-up** (not done): two-tier fast gate + full suite; surefire parallel forks. - **Disabled tests to fix**: `Http4s500RoutesTest` (@Ignore, in-process issue), `RootAndBanksTest` (@Ignore), `V500ContractParityTest` (@Ignore), `CardTest` (fully commented out). `v5_0_0`: 13 skipped tests (setup cost paid, no value). +- **`V7ResourceDocsAggregationTest`**: intentionally failing — encodes the fix for the resource-docs aggregation bug (v7 endpoint returns only ~10 own docs instead of 500+ aggregated). Fix the bug to make this suite pass. diff --git a/LIFT_HTTP4S_COEXISTENCE.md b/LIFT_HTTP4S_COEXISTENCE.md deleted file mode 100644 index 2d171cfe8f..0000000000 --- a/LIFT_HTTP4S_COEXISTENCE.md +++ /dev/null @@ -1,103 +0,0 @@ -# Architecture: http4s and Lift Coexistence - -## Current State - -OBP-API runs as a **single http4s Ember server** (single process, single port). The application entry point is a Cats Effect `IOApp` (`Http4sServer`). Lift is no longer used as an HTTP server — Jetty and the servlet container have been removed. - -Lift still plays two roles: - -1. **ORM / Database** — Lift Mapper manages schema creation, migrations, and data access. -2. **Legacy endpoint dispatch** — Older API versions are handled through a bridge (`Http4sLiftWebBridge`) that converts http4s requests into Lift requests, runs them through Lift's dispatch tables, and converts the responses back. - -New API versions are implemented as native http4s routes and do not pass through the Lift bridge. - -## How It Works - -### Entry Point — `Http4sServer.scala` - -`Http4sServer` extends `IOApp`. On startup it: - -1. Calls `bootstrap.liftweb.Boot().boot()` to initialise Lift Mapper, connectors, and OBP configuration. -2. Parses the configured `hostname` and `dev.port` props (defaults: `127.0.0.1`, `8080`). -3. Starts an http4s Ember server with the application defined in `Http4sApp.httpApp`. - -### Priority Routing — `Http4sApp.scala` - -`Http4sApp` builds the `HttpApp[IO]` that the Ember server uses. Routes are tried in priority order: - -| Priority | Routes | Source | -|----------|--------|--------| -| 1 | v5.0.0 native http4s endpoints | `Http4s500.wrappedRoutesV500Services` | -| 2 | v7.0.0 native http4s endpoints | `Http4s700.wrappedRoutesV700Services` | -| 3 | Berlin Group v2 endpoints | `Http4sBGv2.wrappedRoutes` | -| 4 | **Lift bridge fallback** | `Http4sLiftWebBridge.routes` | - -If none of the above match, a 404 is returned. The routing uses `OptionT`-based `orElse` chaining so that each layer can decline a request and pass it to the next. - -Standard security headers (Cache-Control, X-Frame-Options, Correlation-Id, etc.) are applied to every response via `Http4sLiftWebBridge.withStandardHeaders`. - -### Lift Bridge — `Http4sLiftWebBridge.scala` - -The bridge handles any request that is not matched by a native http4s route. It: - -1. Reads the http4s request body. -2. Constructs a Lift `Req` object from the http4s `Request[IO]`. -3. Creates a stateless Lift session. -4. Initialises a Lift `S` context and runs `LiftRules.statelessDispatch` / `LiftRules.dispatch` handlers. -5. Handles Lift's `ContinuationException` pattern for async responses (configurable timeout via `http4s.continuation.timeout.ms`, default 60 s). -6. Converts the Lift response back to an http4s `Response[IO]`. - -``` -HTTP Request - | - v -Http4sServer (IOApp / Ember) - | - v -Http4sApp.httpApp — priority router + standard headers - | - |---> v5.0.0 native routes - |---> v7.0.0 native routes - |---> Berlin Group v2 routes - |---> Http4sLiftWebBridge (fallback) - | | - | +---> Lift statelessDispatch handlers - | +---> Lift dispatch handlers (REST API) - | - v -HTTP Response (with standard headers) -``` - -## What Lift Still Does - -| Area | Role | -|------|------| -| **Mapper ORM** | Database schema creation, migrations, and all data access (`MappedBank`, `AuthUser`, etc.) | -| **Boot** | `bootstrap.liftweb.Boot` initialises OBP configuration, connectors, resource docs, and Mapper schemifier | -| **Dispatch tables** | `LiftRules.statelessDispatch` / `LiftRules.dispatch` hold the endpoint definitions for API versions not yet ported to native http4s | -| **JSON utilities** | Some serialisation helpers from `net.liftweb.json` are still in use | - -## Why http4s? - -- **Non-blocking I/O** — http4s with Cats Effect uses a small, fixed thread pool (typically equal to CPU cores) and suspends fibres on I/O instead of blocking threads. This allows thousands of concurrent requests without thread-pool tuning. -- **Lower memory** — No thread-per-request overhead. -- **Modern Scala ecosystem** — First-class support for Cats Effect, fs2 streaming, and functional programming patterns. -- **No servlet container** — Removes the Jetty dependency and WAR packaging. - -## Future Direction - -The plan is to gradually port remaining Lift-dispatched endpoints to native http4s routes. As each API version is migrated: - -1. New native http4s routes are added to `Http4sApp` at a higher priority than the bridge. -2. The corresponding Lift dispatch registrations can be removed. -3. Once all endpoints are native, the Lift bridge and Lift dispatch dependencies can be removed entirely (Lift Mapper may remain for ORM). - -## Running - -```sh -MAVEN_OPTS="-Xms3G -Xmx6G -XX:MaxMetaspaceSize=2G" \ - mvn -pl obp-api -am clean package -DskipTests=true -Dmaven.test.skip=true && \ - java -jar obp-api/target/obp-api.jar -``` - -The server binds to the `hostname` / `dev.port` values in your props file (defaults: `127.0.0.1:8080`). diff --git a/LIFT_HTTP4S_MIGRATION.md b/LIFT_HTTP4S_MIGRATION.md new file mode 100644 index 0000000000..6e5e6a3377 --- /dev/null +++ b/LIFT_HTTP4S_MIGRATION.md @@ -0,0 +1,263 @@ +# Lift → http4s Migration + +## Principle + +API version numbers reflect **API contract changes** (new/changed fields, new behaviour). The underlying framework is invisible to clients. Lift → http4s is a refactoring: it happens **in-place** inside the existing version file at the existing URL. No version bump. + +Use a new version (e.g. v7.0.0) only when the API contract itself changes — new fields, changed request/response shape, new behaviour. + +--- + +## Current Architecture + +OBP-API runs as a **single http4s Ember server** (single process, single port). The application entry point is a Cats Effect `IOApp` (`Http4sServer`). Lift is no longer used as an HTTP server — Jetty and the servlet container have been removed. + +Lift still plays two roles: + +1. **ORM / Database** — Lift Mapper manages schema creation, migrations, and data access. +2. **Legacy endpoint dispatch** — Older API versions are handled through a bridge (`Http4sLiftWebBridge`) that converts http4s requests into Lift requests, runs them through Lift's dispatch tables, and converts the responses back. + +New API versions are implemented as native http4s routes and do not pass through the bridge. + +### Entry point — `Http4sServer.scala` + +`Http4sServer` extends `IOApp`. On startup it: + +1. Calls `bootstrap.liftweb.Boot().boot()` to initialise Lift Mapper, connectors, and OBP configuration. +2. Parses the configured `hostname` and `dev.port` props (defaults: `127.0.0.1`, `8080`). +3. Starts an Ember server with the application defined in `Http4sApp.httpApp`. + +### 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. + +``` +HTTP Request + │ + ▼ +Http4sServer (IOApp / Ember) + │ + ▼ +corsHandler → AppsPage → StatusPage → Http4s500 → Http4s700 → Http4sBGv2 + │ + Http4s300 → Http4s220 → Http4s210 → Http4s200 → Http4s140 → Http4s130 → Http4s121 → Http4sLiftWebBridge + │ │ │ │ │ │ │ + v3.0.0 v2.2.0 v2.1.0 v2.0.0 v1.4.0 v1.3.0 v1.2.1 routes + own routes own routes own routes own routes own routes own routes (all 323 scenarios) + + v2.2.0 + v2.1.0 + v2.0.0 + v1.4.0 + v1.3.0 + v1.2.1 + bridge bridge bridge bridge bridge + │ + LiftRules.statelessDispatch + LiftRules.dispatch (REST API) + │ + ▼ +HTTP Response (with standard headers) +``` + +### Lift bridge — `Http4sLiftWebBridge.scala` + +Handles any request not matched by a native http4s route: + +1. Reads the http4s request body. +2. Constructs a Lift `Req` from the http4s `Request[IO]`. +3. Creates a stateless Lift session. +4. Initialises a Lift `S` context and runs `LiftRules.statelessDispatch` / `LiftRules.dispatch`. +5. Handles Lift's `ContinuationException` pattern for async responses (timeout: `http4s.continuation.timeout.ms`, default 60 s). +6. Converts the Lift response back to http4s. + +### What Lift still does + +| Area | Role | +|------|------| +| **Mapper ORM** | Database schema creation, migrations, and all data access (`MappedBank`, `AuthUser`, etc.) | +| **Boot** | Initialises OBP configuration, connectors, resource docs, and Mapper schemifier | +| **Dispatch tables** | `LiftRules.statelessDispatch` / `LiftRules.dispatch` hold endpoint definitions for versions not yet ported | +| **JSON utilities** | Some serialisation helpers from `net.liftweb.json` are still in use | + +--- + +## What "in-place migration" means per file + +### `APIMethods{version}.scala` + +| Before (Lift) | After (http4s) | +|---|---| +| `self: RestHelper =>` on the trait | removed | +| `lazy val xyz: OBPEndpoint` | `val xyz: HttpRoutes[IO]` | +| `case "path" :: Nil JsonGet _` | `case req @ GET -> \`prefixPath\` / "path"` | +| `authenticatedAccess(cc)` in for-comp | pick the right `EndpointHelpers.*` helper | +| `implicit val ec = EndpointContext(Some(cc))` | removed | +| `yield (json, HttpCode.\`200\`(cc))` | `yield json` | +| `ResourceDoc(root, ...)` | `ResourceDoc(null, ..., http4sPartialFunction = Some(root))` | + +### `OBPAPI{version}.scala` + +| Before | After | +|---|---| +| `extends OBPRestHelper` | removed | +| `registerRoutes(routes, allResourceDocs, apiPrefix)` | expose `val allRoutes: HttpRoutes[IO]` | +| registered via Boot / LiftRules | wired into `Http4sServer` chain | + +See `CLAUDE.md § Migrating a Lift Endpoint to http4s` for the full Rule 1–5 reference. + +--- + +## Migration Order + +Bottom-up — each version depends on the one below it being done. + +**Rule: one file = one PR. A file is either fully Lift or fully http4s — no half-converted state.** + +**Note on `APIMethods121`**: v1.2.1 was implemented as a new parallel file `Http4s121.scala` (rather than converting the Lift trait in-place) because `APIMethods121` is a mixin trait inherited by `APIMethods130`, `APIMethods140`, etc. Converting the trait in-place would require all inheriting versions to be migrated simultaneously. The parallel file approach lets v1.2.1 go first — http4s routes take priority in the chain; the Lift trait remains until all inheriting versions are done, at which point the Lift trait can be deleted. + +| # | File | Own endpoints | Notes | +|---|---|---|---| +| 1 | `APIMethods121` | 70 | **Done** — `Http4s121.scala` serves all endpoints; 323 tests pass | +| 2 | `APIMethods130` | 3 | **Done** — `Http4s130.scala`: 3 own endpoints + path-rewriting bridge to `Http4s121`; 2 PhysicalCardsTest scenarios pass | +| 3 | `APIMethods140` | 11 | **Done** — `Http4s140.scala`: 11 own endpoints + path-rewriting bridge to `Http4s130` | +| 4 | `APIMethods200` | 40 | **Done** — `Http4s200.scala`: 37 own endpoints + path-rewriting bridge to `Http4s140` | +| 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 | +| 10 | `APIMethods500` | 37 | | +| 11 | `APIMethods510` | 111 | | +| 12 | `APIMethods600` | ~244 total | Final Lift endpoint file | + +--- + +## Resource-docs (separate workstream) + +Resource-docs endpoints are **version-polymorphic**: `GET /obp/v6.0.0/resource-docs/v3.0.0/obp` returns v3.0.0 docs. The URL prefix is cosmetically version-specific but functionally irrelevant — the `API_VERSION` path segment controls the output. This makes resource-docs a natural candidate for a single centralized http4s service rather than per-version handlers. + +### Strategy: centralized `Http4sResourceDocs` + +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/openapi.yaml +``` + +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. + +### Prerequisite: fix the aggregation bug + +`V7ResourceDocsAggregationTest` is intentionally failing. The current `getResourceDocsObpV700` has a broken branch for `requestedApiVersion == v7.0.0` that manually iterates `allResourceDocs` (~45 own docs) instead of calling `getResourceDocsList`, which aggregates all 500+. Fix this first — it is the same defect the centralized service must not repeat. + +### `openapi.yaml` + +Currently served via a raw Lift `serve { case Req(..., "openapi.yaml", ...) }` block that bypasses `registerRoutes` entirely. Needs a dedicated http4s route (no ResourceDocMiddleware) added to the centralized service. + +### Caching + +`Caching.getStaticSwaggerDocCache()` / `setStaticSwaggerDocCache()` are framework-agnostic and already used from within the http4s path. No migration work needed. + +### Steps + +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. + +--- + +## Auth Stack (separate workstream) + +Token-generation paths — not version-file endpoints. Each `extends RestHelper` and needs to become an http4s route or middleware independently. Can run in parallel with the APIMethods migration. + +| Component | Path | Notes | +|---|---|---| +| `DirectLogin` | `POST /my/logins/direct` | | +| `GatewayLogin` | gateway JWT exchange | | +| `DAuth` | dAuth JWT exchange | | +| `OAuth` | OAuth 1.0a token endpoints | Most complex | + +These are the last hard dependency on Lift Web in the request path. The Lift bridge cannot be removed until all four are done. + +--- + +## Server Chain After Full Migration + +``` +corsHandler + → Http4sResourceDocs (/obp/*/resource-docs/*) ← centralized, all version prefixes + → Http4s700 (/obp/v7.0.0/*) + → Http4s600 (/obp/v6.0.0/*) + → Http4s510 (/obp/v5.1.0/*) + → Http4s500 (/obp/v5.0.0/*) + → Http4s400 (/obp/v4.0.0/*) + → Http4s310 (/obp/v3.1.0/*) + → Http4s300 (/obp/v3.0.0/*) + → Http4s220 (/obp/v2.2.0/*) + → Http4s210 (/obp/v2.1.0/*) + → Http4s200 (/obp/v2.0.0/*) + → Http4s140 (/obp/v1.4.0/*) ← done + → Http4s130 (/obp/v1.3.0/*) ← done + → Http4s121 (/obp/v1.2.1/*) ← done + → Http4sBGv2 + ← Lift bridge removed +``` + +--- + +## Done Criteria + +| 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 | + +--- + +## Why http4s? + +- **Non-blocking I/O** — Uses a small fixed thread pool (CPU cores) and suspends fibres on I/O. Thousands of concurrent requests without thread-pool tuning. +- **Lower memory** — No thread-per-request overhead. +- **Modern Scala ecosystem** — First-class Cats Effect, fs2 streaming, and functional patterns. +- **No servlet container** — Removes Jetty and WAR packaging entirely. + +--- + +## Running + +```sh +MAVEN_OPTS="-Xms3G -Xmx6G -XX:MaxMetaspaceSize=2G" \ + mvn -pl obp-api -am clean package -DskipTests=true -Dmaven.test.skip=true && \ + java -jar obp-api/target/obp-api.jar +``` + +Binds to `hostname` / `dev.port` from your props file (defaults: `127.0.0.1:8080`). + +--- + +## Progress + +| File | Status | +|---|---| +| `APIMethods121` | done — `Http4s121.scala` (all 323 API1_2_1Test scenarios pass) | +| `APIMethods130` | done — `Http4s130.scala` (2 PhysicalCardsTest scenarios pass) | +| `APIMethods140` | done — `Http4s140.scala` (all 11 own endpoints; path-rewriting bridge to Http4s130) | +| `APIMethods200` | done — `Http4s200.scala` (37 own endpoints; path-rewriting bridge to Http4s140) | +| `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 | +| `APIMethods400` | todo | +| `APIMethods500` | todo | +| `APIMethods510` | todo | +| `APIMethods600` | todo | +| Auth: DirectLogin | todo | +| Auth: GatewayLogin | todo | +| Auth: DAuth | todo | +| Auth: OAuth | todo | +| Resource-docs: aggregation bug fix | done | +| Resource-docs: `Http4sResourceDocs` service | todo | +| Resource-docs: `openapi.yaml` route | todo | + +### Cleanup done + +- `getCards` and `getCardsForBank` removed from `Http4s700` — these had the same API signature as the v1.3.0 originals and belonged in `APIMethods130`, not v7.0.0. The Lift implementation in `APIMethods130` serves them at `/obp/v1.3.0/` until that file is migrated. diff --git a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocsAPIMethods.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocsAPIMethods.scala index 267fcc1529..f4e3cbb6c5 100644 --- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocsAPIMethods.scala +++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocsAPIMethods.scala @@ -135,7 +135,7 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth case ApiVersion.v2_0_0 => Implementations2_0_0.resourceDocs ++ Implementations1_4_0.resourceDocs ++ Implementations1_3_0.resourceDocs ++ Implementations1_2_1.resourceDocs case ApiVersion.v1_4_0 => Implementations1_4_0.resourceDocs ++ Implementations1_3_0.resourceDocs ++ Implementations1_2_1.resourceDocs case ApiVersion.v1_3_0 => Implementations1_3_0.resourceDocs ++ Implementations1_2_1.resourceDocs - case ApiVersion.v1_2_1 => Implementations1_2_1.resourceDocs + case ApiVersion.v1_2_1 => code.api.v1_2_1.Http4s121.resourceDocs case ApiVersion.`dynamic-endpoint` => OBPAPIDynamicEndpoint.allResourceDocs case ApiVersion.`dynamic-entity` => OBPAPIDynamicEntity.allResourceDocs case version: ScannedApiVersion => ScannedApis.versionMapScannedApis.get(version).map(_.allResourceDocs).getOrElse(ArrayBuffer.empty[ResourceDoc]) @@ -158,7 +158,7 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth case ApiVersion.v2_0_0 => OBPAPI2_0_0.routes case ApiVersion.v1_4_0 => OBPAPI1_4_0.routes case ApiVersion.v1_3_0 => OBPAPI1_3_0.routes - case ApiVersion.v1_2_1 => OBPAPI1_2_1.routes + case ApiVersion.v1_2_1 => Nil case ApiVersion.`dynamic-endpoint` => OBPAPIDynamicEndpoint.routes case ApiVersion.`dynamic-entity` => OBPAPIDynamicEntity.routes case version: ScannedApiVersion => ScannedApis.versionMapScannedApis.get(version).map(_.routes).getOrElse(Nil) @@ -176,6 +176,7 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth val activeResourceDocs = requestedApiVersion match { case ApiVersion.v7_0_0 => resourceDocs case ConstantsBG.`berlinGroupVersion2` => resourceDocs + case ApiVersion.v1_2_1 => resourceDocs case _ => resourceDocs.filter(rd => versionRoutesClasses.contains(rd.partialFunction.getClass)) } diff --git a/obp-api/src/main/scala/code/api/util/NewStyle.scala b/obp-api/src/main/scala/code/api/util/NewStyle.scala index 941cfedb30..77571b44ea 100644 --- a/obp-api/src/main/scala/code/api/util/NewStyle.scala +++ b/obp-api/src/main/scala/code/api/util/NewStyle.scala @@ -525,11 +525,16 @@ object NewStyle extends MdcLoggable{ } def moderatedOtherBankAccount(account: BankAccount, - counterpartyId: String, - view: View, - user: Box[User], - callContext: Option[CallContext]): OBPReturnType[ModeratedOtherBankAccount] = - account.moderatedOtherBankAccount(counterpartyId, view, BankIdAccountId(account.bankId, account.accountId), user, callContext) map { i =>(connectorEmptyResponse(i._1, i._2), i._2) } + counterpartyId: String, + view: View, + user: Box[User], + callContext: Option[CallContext]): OBPReturnType[ModeratedOtherBankAccount] = + account.moderatedOtherBankAccount(counterpartyId, view, BankIdAccountId(account.bankId, account.accountId), user, callContext) + .map { i => (connectorEmptyResponse(i._1, i._2), i._2) } + .recoverWith { case _: NoSuchElementException => + val json = s"""{"failCode":400,"failMsg":"${CounterpartyNotFound.replace("\"", "\\\"")}"}""" + Future.failed(new Exception(json)) + } def getTransactionsCore(bankId: BankId, accountId: AccountId, queryParams: List[OBPQueryParam], callContext: Option[CallContext]): OBPReturnType[List[TransactionCore]] = Connector.connector.vend.getTransactionsCore(bankId: BankId, accountId: AccountId, queryParams: List[OBPQueryParam], callContext: Option[CallContext]) map { i => diff --git a/obp-api/src/main/scala/code/api/util/http4s/ErrorResponseConverter.scala b/obp-api/src/main/scala/code/api/util/http4s/ErrorResponseConverter.scala index b7ffa5a948..5acb196d15 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/ErrorResponseConverter.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/ErrorResponseConverter.scala @@ -36,7 +36,10 @@ object ErrorResponseConverter { val msg = Option(error.getMessage).getOrElse("").trim if (msg.startsWith("{") && msg.contains("\"failCode\"") && msg.contains("\"failMsg\"")) { try { - Some(parse(msg).extract[APIFailureNewStyle]) + val jv = parse(msg) + val failCode = (jv \ "failCode").extract[Int] + val failMsg = (jv \ "failMsg").extract[String] + Some(APIFailureNewStyle(failMsg, failCode)) } catch { case _: Throwable => None } 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 1dd8ef1135..c4898f28ff 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 @@ -60,6 +60,13 @@ object Http4sApp { .orElse(code.api.v5_0_0.Http4s500.wrappedRoutesV500Services.run(req)) .orElse(code.api.v7_0_0.Http4s700.wrappedRoutesV700Services.run(req)) .orElse(code.api.berlin.group.v2.Http4sBGv2.wrappedRoutes.run(req)) + .orElse(code.api.v3_0_0.Http4s300.wrappedRoutesV300Services.run(req)) + .orElse(code.api.v2_2_0.Http4s220.wrappedRoutesV220Services.run(req)) + .orElse(code.api.v2_1_0.Http4s210.wrappedRoutesV210Services.run(req)) + .orElse(code.api.v2_0_0.Http4s200.wrappedRoutesV200Services.run(req)) + .orElse(code.api.v1_4_0.Http4s140.wrappedRoutesV140Services.run(req)) + .orElse(code.api.v1_3_0.Http4s130.wrappedRoutesV130Services.run(req)) + .orElse(code.api.v1_2_1.Http4s121.wrappedRoutesV121Services.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 d951d2a7d5..3ce9d2e745 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 @@ -194,7 +194,7 @@ object Http4sRequestAttributes { def executeFutureWithBody[B, A](req: Request[IO])(f: (B, CallContext) => Future[A])(implicit formats: Formats, mf: Manifest[B]): IO[Response[IO]] = { implicit val cc: CallContext = req.callContext parseBody[B](cc) match { - case Left(msg) => BadRequest(msg).flatTap(recordMetric(msg, _)) + case Left(msg) => ErrorResponseConverter.createErrorResponse(400, msg, cc).flatTap(recordMetric(msg, _)) case Right(body) => RequestScopeConnection.fromFuture(f(body, cc)).attempt.flatMap { case Right(result) => toJsonOk(result).flatTap(recordMetric(result, _)) @@ -210,7 +210,7 @@ object Http4sRequestAttributes { def executeFutureWithBodyCreated[B, A](req: Request[IO])(f: (B, CallContext) => Future[A])(implicit formats: Formats, mf: Manifest[B]): IO[Response[IO]] = { implicit val cc: CallContext = req.callContext parseBody[B](cc) match { - case Left(msg) => BadRequest(msg).flatTap(recordMetric(msg, _)) + case Left(msg) => ErrorResponseConverter.createErrorResponse(400, msg, cc).flatTap(recordMetric(msg, _)) case Right(body) => RequestScopeConnection.fromFuture(f(body, cc)).attempt.flatMap { case Right(result) => @@ -228,7 +228,7 @@ object Http4sRequestAttributes { def withUserAndBody[B, A](req: Request[IO])(f: (User, B, CallContext) => Future[A])(implicit formats: Formats, mf: Manifest[B]): IO[Response[IO]] = { implicit val cc: CallContext = req.callContext parseBody[B](cc) match { - case Left(msg) => BadRequest(msg).flatTap(recordMetric(msg, _)) + case Left(msg) => ErrorResponseConverter.createErrorResponse(400, msg, cc).flatTap(recordMetric(msg, _)) case Right(body) => val io = for { user <- IO.fromOption(cc.user.toOption)(new RuntimeException("User not found in CallContext")) @@ -248,7 +248,7 @@ object Http4sRequestAttributes { def withUserAndBodyCreated[B, A](req: Request[IO])(f: (User, B, CallContext) => Future[A])(implicit formats: Formats, mf: Manifest[B]): IO[Response[IO]] = { implicit val cc: CallContext = req.callContext parseBody[B](cc) match { - case Left(msg) => BadRequest(msg).flatTap(recordMetric(msg, _)) + case Left(msg) => ErrorResponseConverter.createErrorResponse(400, msg, cc).flatTap(recordMetric(msg, _)) case Right(body) => val io = for { user <- IO.fromOption(cc.user.toOption)(new RuntimeException("User not found in CallContext")) @@ -270,7 +270,7 @@ object Http4sRequestAttributes { def withUserAndBankAndBody[B, A](req: Request[IO])(f: (User, Bank, B, CallContext) => Future[A])(implicit formats: Formats, mf: Manifest[B]): IO[Response[IO]] = { implicit val cc: CallContext = req.callContext parseBody[B](cc) match { - case Left(msg) => BadRequest(msg).flatTap(recordMetric(msg, _)) + case Left(msg) => ErrorResponseConverter.createErrorResponse(400, msg, cc).flatTap(recordMetric(msg, _)) case Right(body) => val io = for { user <- IO.fromOption(cc.user.toOption)(new RuntimeException("User not found in CallContext")) @@ -291,7 +291,7 @@ object Http4sRequestAttributes { def withUserAndBankAndBodyCreated[B, A](req: Request[IO])(f: (User, Bank, B, CallContext) => Future[A])(implicit formats: Formats, mf: Manifest[B]): IO[Response[IO]] = { implicit val cc: CallContext = req.callContext parseBody[B](cc) match { - case Left(msg) => BadRequest(msg).flatTap(recordMetric(msg, _)) + case Left(msg) => ErrorResponseConverter.createErrorResponse(400, msg, cc).flatTap(recordMetric(msg, _)) case Right(body) => val io = for { user <- IO.fromOption(cc.user.toOption)(new RuntimeException("User not found in CallContext")) @@ -324,6 +324,26 @@ object Http4sRequestAttributes { } } + /** + * Execute POST business logic requiring validated User, BankAccount, and View (URL must contain VIEW_ID). + * Returns 201 Created on success, converts errors via ErrorResponseConverter. + */ + def withViewCreated[A](req: Request[IO])(f: (User, BankAccount, View, CallContext) => Future[A])(implicit formats: Formats): IO[Response[IO]] = { + implicit val cc: CallContext = req.callContext + val io = for { + user <- IO.fromOption(cc.user.toOption)(new RuntimeException("User not found in CallContext")) + bankAccount <- IO.fromOption(cc.bankAccount)(new RuntimeException("BankAccount not found in CallContext")) + view <- IO.fromOption(cc.view)(new RuntimeException("View not found in CallContext")) + result <- RequestScopeConnection.fromFuture(f(user, bankAccount, view, cc)) + } yield result + io.attempt.flatMap { + case Right(result) => + val jsonString = prettyRender(Extraction.decompose(result)) + Created(jsonString).flatTap(recordMetric(result, _)) + case Left(err) => ErrorResponseConverter.toHttp4sResponse(err, cc).flatTap(recordMetric(err.getMessage, _)) + } + } + /** * Execute business logic requiring validated User, BankAccount, and View (URL must contain VIEW_ID). * Returns 200 OK on success, converts errors via ErrorResponseConverter. 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 2bffe0e0e4..f778a2c8a0 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 @@ -13,7 +13,7 @@ import code.util.Helper.MdcLoggable import com.openbankproject.commons.model._ import com.openbankproject.commons.util.ApiShortVersions import com.github.dwickern.macros.NameOf.nameOf -import net.liftweb.common.{Box, Empty, Full} +import net.liftweb.common.{Box, Empty, Failure, Full} import org.http4s._ import org.http4s.headers.`Content-Type` @@ -128,11 +128,8 @@ object ResourceDocMiddleware extends MdcLoggable { case None => // No matching ResourceDoc: fallback to original route (NO transaction scope opened). - // ResourceDocMatcher.findResourceDoc already logged a WARN with full key/index detail. - // Any background DB calls triggered by the Lift bridge for this request will use - // RequestAwareConnectionManager, which now falls back to a fresh vendor connection - // when the TTL-stale proxy is detected as closed. - routes.run(req) + // Attach the basic CC so req.callContext works in the inner route even without a doc match. + routes.run(req.withAttribute(Http4sRequestAttributes.callContextKey, cc)) } } } @@ -251,20 +248,48 @@ object ResourceDocMiddleware extends MdcLoggable { val needsAuth = ResourceDocMiddleware.needsAuthentication(resourceDoc) logger.debug(s"[ResourceDocMiddleware] needsAuthentication for ${resourceDoc.partialFunctionName}: $needsAuth") - val io = - if (needsAuth) IO.fromFuture(IO(APIUtil.authenticatedAccess(ctx.callContext))) - else IO.fromFuture(IO(APIUtil.anonymousAccess(ctx.callContext))) + // 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))) EitherT( io.attempt.flatMap { + // Fully authenticated — happy path. + case Right((Full(user), Some(updatedCC))) => + IO.pure(Right(ctx.copy(user = Full(user), callContext = updatedCC))) + 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 => + val cc2 = optCC.getOrElse(ctx.callContext) + ErrorResponseConverter.createErrorResponse(401, $AuthenticatedUserIsRequired, cc2).map(Left(_)) + // Anonymous endpoint — pass any box user through unchanged. case Right((boxUser, Some(updatedCC))) => IO.pure(Right(ctx.copy(user = boxUser, callContext = updatedCC))) case Right((boxUser, None)) => IO.pure(Right(ctx.copy(user = boxUser))) case Left(e: APIFailureNewStyle) => ErrorResponseConverter.createErrorResponse(e.failCode, e.failMsg, ctx.callContext).map(Left(_)) - case Left(_) => - ErrorResponseConverter.createErrorResponse(401, $AuthenticatedUserIsRequired, ctx.callContext).map(Left(_)) + case Left(e) => + // anonymousAccess threw a plain Exception(json_of_APIFailureNewStyle). + // Parse the JSON to recover the original message and failCode (typically 401). + // Old Style endpoints (v1.x, v2.0.0) keep 400 to match Lift Old Style behavior. + // New Style endpoints (v2.1.0+) use the original failCode from the exception. + val (failMsg, parsedCode) = scala.util.Try { + implicit val formats = net.liftweb.json.DefaultFormats + val parsed = net.liftweb.json.parse(e.getMessage).extract[APIFailureNewStyle] + (parsed.failMsg, parsed.failCode) + }.getOrElse(($AuthenticatedUserIsRequired, 401)) + val oldStyleShortVersions = Set("v1.2.1", "v1.3.0", "v1.4.0", "v2.0.0") + val versionStr = resourceDoc.implementedInApiVersion.apiShortVersion + val isOldStyle = oldStyleShortVersions.contains(versionStr) + val effectiveCode = if (isOldStyle) 400 else parsedCode + logger.debug(s"[ResourceDocMiddleware.authenticate] version=$versionStr isOldStyle=$isOldStyle parsedCode=$parsedCode effectiveCode=$effectiveCode") + ErrorResponseConverter.createErrorResponse(effectiveCode, failMsg, ctx.callContext).map(Left(_)) } ) } diff --git a/obp-api/src/main/scala/code/api/v1_2_1/Http4s121.scala b/obp-api/src/main/scala/code/api/v1_2_1/Http4s121.scala new file mode 100644 index 0000000000..dc058dc309 --- /dev/null +++ b/obp-api/src/main/scala/code/api/v1_2_1/Http4s121.scala @@ -0,0 +1,2476 @@ +package code.api.v1_2_1 + +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.ApiTag._ +import code.api.util.ErrorMessages._ +import code.api.util.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps, callContextKey} +import code.api.util.http4s.Http4sCallContextBuilder +import code.api.util.http4s.ResourceDocMiddleware +import code.api.util.newstyle.ViewNewStyle +import code.api.util.{CallContext, CustomJsonFormats, NewStyle} +import code.bankconnectors.Connector +import code.metadata.counterparties.Counterparties +import code.model.{BankAccountX, BankX, ModeratedTransactionMetadata, UserX, toBankAccountExtended, toBankExtended} +import code.util.Helper +import code.views.Views +import com.github.dwickern.macros.NameOf.nameOf +import com.openbankproject.commons.ExecutionContext.Implicits.global +import com.openbankproject.commons.model._ +import com.openbankproject.commons.util.{ApiVersion, ApiVersionStatus, ScannedApiVersion} +import net.liftweb.common.{Box, Full} +import net.liftweb.http.provider.HTTPParam +import net.liftweb.json.{Extraction, Formats} +import net.liftweb.util.Helpers._ +import org.http4s._ +import org.http4s.dsl.io._ + +import java.net.URL +import scala.collection.mutable.ArrayBuffer +import scala.concurrent.Future +import scala.language.{higherKinds, implicitConversions} + +object Http4s121 { + + type HttpF[A] = OptionT[IO, A] + + implicit val formats: Formats = CustomJsonFormats.formats + + val implementedInApiVersion: ScannedApiVersion = ApiVersion.v1_2_1 + val versionStatus: String = ApiVersionStatus.DEPRECATED.toString + val resourceDocs: ArrayBuffer[ResourceDoc] = ArrayBuffer[ResourceDoc]() + + object Implementations1_2_1 { + + val prefixPath = Root / ApiPathZero.toString / implementedInApiVersion.toString + + private def fail400(msg: String): Future[Nothing] = { + val json = s"""{"failCode":400,"failMsg":"${msg.replace("\"", "\\\"")}"}""" + Future.failed(new Exception(json)) + } + + private def privateBankAccountsListToJson(bankAccounts: List[BankAccount], privateViewsUserCanAccess: List[View]) = { + val accJson = bankAccounts.map { account => + val viewsAvailable = + (privateViewsUserCanAccess + .filter(v => v.bankId == account.bankId && v.accountId == account.accountId && v.isPrivate) + .map(JSONFactory.createViewJSON(_)) + .distinct) ++ + (privateViewsUserCanAccess + .filter(v => v.isSystem && v.isPrivate) + .map(JSONFactory.createViewJSON(_)) + .distinct) + JSONFactory.createAccountJSON(account, viewsAvailable) + } + new AccountsJSON(accJson) + } + + private def publicBankAccountsListToJson(bankAccounts: List[BankAccount], publicViews: List[View]) = { + val accJson = bankAccounts.map { account => + val viewsAvailable = + publicViews + .filter(v => v.bankId == account.bankId && v.accountId == account.accountId && v.isPublic) + .map(v => JSONFactory.createViewJSON(v)) + .distinct + JSONFactory.createAccountJSON(account, viewsAvailable) + } + new AccountsJSON(accJson) + } + + private def checkIfLocationPossible(lat: Double, lon: Double): Boolean = + scala.math.abs(lat) <= 90 && scala.math.abs(lon) <= 180 + + private def moderatedTransactionMetadataFuture( + bankId: BankId, accountId: AccountId, viewId: ViewId, + transactionID: TransactionId, user: Box[User], callContext: Option[CallContext] + ): Future[ModeratedTransactionMetadata] = + for { + (account, cc2) <- NewStyle.function.checkBankAccountExists(bankId, accountId, callContext) + view <- ViewNewStyle.checkViewAccessAndReturnView(viewId, BankIdAccountId(bankId, accountId), user, cc2) + (moderatedTransaction, cc3) <- account.moderatedTransactionFuture(transactionID, view, user, cc2) map { + unboxFullOrFail(_, cc2, GetTransactionsException) + } + metadata <- Future(moderatedTransaction.metadata) map { + unboxFullOrFail(_, cc3, s"$NoViewPermission can_see_transaction_metadata. Current ViewId($viewId)") + } + } yield metadata + + // ─── root ─────────────────────────────────────────────────────────────── + + val root: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "root" => + EndpointHelpers.executeAndRespond(req) { cc => + Future.successful(JSONFactory.getApiInfoJSON(ApiVersion.v1_2_1, "STABLE")) + } + } + + 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, + http4sPartialFunction = Some(root) + ) + + // ─── getBanks ──────────────────────────────────────────────────────────── + + val getBanks: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" => + EndpointHelpers.executeAndRespond(req) { cc => + for { + (banks, _) <- NewStyle.function.getBanks(Some(cc)) + } yield { + val banksJSON = banks.map(b => JSONFactory.createBankJSON(b)) + new BanksJSON(banksJSON) + } + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getBanks), + "GET", + "/banks", + "Get Banks", + """Get banks on this API instance + |Returns a list of banks supported on this server: + | + |* ID used as parameter in URLs + |* Short and full name of bank + |* Logo URL + |* Website""", + EmptyBody, + banksJSON, + List(UnknownError), + apiTagBank :: apiTagPsd2 :: apiTagOldStyle :: Nil, + http4sPartialFunction = Some(getBanks) + ) + + // ─── bankById ──────────────────────────────────────────────────────────── + + // bankById runs outside ResourceDocMiddleware so it can return 400 (not 464) for unknown bank, + // preserving the v1.2.1 Lift behavior. Builds its own CallContext via Http4sCallContextBuilder. + val bankById: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankId => + Http4sCallContextBuilder.fromRequest(req, implementedInApiVersion.apiShortVersion).flatMap { cc => + val reqWithCc = req.withAttribute(callContextKey, cc) + EndpointHelpers.executeAndRespond(reqWithCc) { _ => + Future { + unboxFullOrFail(BankX(BankId(bankId), Some(cc)), Some(cc), BankNotFound, 400) + }.map { case (bank, _) => JSONFactory.createBankJSON(bank) } + } + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(bankById), + "GET", + "/banks/BANK_ID", + "Get Bank", + """Get the bank specified by BANK_ID + |Returns information about a single bank specified by BANK_ID including: + | + |* Short and full name of bank + |* Logo URL + |* Website""", + EmptyBody, + bankJSON, + List(BankNotFound, UnknownError), + apiTagBank :: apiTagPsd2 :: apiTagOldStyle :: Nil, + http4sPartialFunction = Some(bankById) + ) + + // ─── getPrivateAccountsAllBanks ────────────────────────────────────────── + + val getPrivateAccountsAllBanks: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "accounts" => + EndpointHelpers.withUser(req) { (user, cc) => + Future { + val (privateViewsUserCanAccess, privateAccountAccess) = Views.views.vend.privateViewsUserCanAccess(user) + val availablePrivateAccounts = BankAccountX.privateAccounts(privateAccountAccess) + privateBankAccountsListToJson(availablePrivateAccounts, privateViewsUserCanAccess) + } + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getPrivateAccountsAllBanks), + "GET", + "/accounts", + "Get accounts at all banks (Private, inc views)", + s"""Returns the list of accounts at that the user has access to at all banks. + |For each account the API returns the account ID and the available views. + | + |${userAuthenticationMessage(true)} + |""".stripMargin, + EmptyBody, + accountJSON, + List(AuthenticatedUserIsRequired, UnknownError), + apiTagAccount :: apiTagPsd2 :: apiTagOldStyle :: Nil, + http4sPartialFunction = Some(getPrivateAccountsAllBanks) + ) + + // ─── privateAccountsAllBanks ───────────────────────────────────────────── + + val privateAccountsAllBanks: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "accounts" / "private" => + EndpointHelpers.withUser(req) { (user, cc) => + Future { + val (privateViewsUserCanAccess, privateAccountAccess) = Views.views.vend.privateViewsUserCanAccess(user) + val privateAccounts = BankAccountX.privateAccounts(privateAccountAccess) + privateBankAccountsListToJson(privateAccounts, privateViewsUserCanAccess) + } + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(privateAccountsAllBanks), + "GET", + "/accounts/private", + "Get private accounts at all banks (Authenticated access)", + """Returns the list of private accounts the user has access to at all banks. + |For each account the API returns the ID and the available views. + | + |Authentication via OAuth is required.""".stripMargin, + EmptyBody, + accountJSON, + List(AuthenticatedUserIsRequired, UnknownError), + apiTagAccount :: apiTagPsd2 :: apiTagOldStyle :: Nil, + http4sPartialFunction = Some(privateAccountsAllBanks) + ) + + // ─── publicAccountsAllBanks ────────────────────────────────────────────── + + val publicAccountsAllBanks: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "accounts" / "public" => + EndpointHelpers.executeAndRespond(req) { cc => + Future { + val (publicViews, publicAccountAccess) = Views.views.vend.publicViews + val publicAccounts = BankAccountX.publicAccounts(publicAccountAccess) + publicBankAccountsListToJson(publicAccounts, publicViews) + } + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(publicAccountsAllBanks), + "GET", + "/accounts/public", + "Get public accounts at all banks (Anonymous access)", + """Returns the list of public accounts at all banks. + |For each account the API returns the ID and the available views. + |Authentication via OAuth is not required.""".stripMargin, + EmptyBody, + accountJSON, + List(UnknownError), + apiTagAccount :: apiTagOldStyle :: Nil, + http4sPartialFunction = Some(publicAccountsAllBanks) + ) + + // ─── getPrivateAccountsAtOneBank ───────────────────────────────────────── + + val getPrivateAccountsAtOneBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" => + EndpointHelpers.withUserAndBank(req) { (user, bank, cc) => + Future { + val (privateViewsUserCanAccessAtOneBank, privateAccountAccess) = Views.views.vend.privateViewsUserCanAccessAtBank(user, bank.bankId) + val availablePrivateAccounts = toBankExtended(bank).privateAccounts(privateAccountAccess) + privateBankAccountsListToJson(availablePrivateAccounts, privateViewsUserCanAccessAtOneBank) + } + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getPrivateAccountsAtOneBank), + "GET", + "/banks/BANK_ID/accounts", + "Get accounts at bank (Private)", + s"""Returns the list of accounts at BANK_ID that the user has access to. + |For each account the API returns the account ID and the available views. + | + |${userAuthenticationMessage(true)} + |""".stripMargin, + EmptyBody, + accountJSON, + List(AuthenticatedUserIsRequired, UnknownError, BankNotFound), + apiTagAccount :: apiTagOldStyle :: Nil, + http4sPartialFunction = Some(getPrivateAccountsAtOneBank) + ) + + // ─── privateAccountsAtOneBank ──────────────────────────────────────────── + + val privateAccountsAtOneBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / "private" => + EndpointHelpers.withUserAndBank(req) { (user, bank, cc) => + Future { + val (privateViewsUserCanAccessAtOneBank, privateAccountAccess) = Views.views.vend.privateViewsUserCanAccessAtBank(user, bank.bankId) + val availablePrivateAccounts = toBankExtended(bank).privateAccounts(privateAccountAccess) + privateBankAccountsListToJson(availablePrivateAccounts, privateViewsUserCanAccessAtOneBank) + } + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(privateAccountsAtOneBank), + "GET", + "/banks/BANK_ID/accounts/private", + "Get private accounts at one bank", + s"""Returns the list of private accounts at BANK_ID that the user has access to. + |For each account the API returns the ID and the available views. + | + |${userAuthenticationMessage(true)} + |""".stripMargin, + EmptyBody, + accountJSON, + List(AuthenticatedUserIsRequired, UnknownError, BankNotFound), + List(apiTagAccount, apiTagPsd2, apiTagOldStyle), + http4sPartialFunction = Some(privateAccountsAtOneBank) + ) + + // ─── publicAccountsAtOneBank ───────────────────────────────────────────── + + val publicAccountsAtOneBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / "public" => + EndpointHelpers.withBank(req) { (bank, cc) => + Future { + val (publicViewsForBank, publicAccountAccess) = Views.views.vend.publicViewsForBank(bank.bankId) + val publicAccounts = toBankExtended(bank).publicAccounts(publicAccountAccess) + publicBankAccountsListToJson(publicAccounts, publicViewsForBank) + } + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(publicAccountsAtOneBank), + "GET", + "/banks/BANK_ID/accounts/public", + "Get public accounts at one bank (Anonymous access)", + """Returns a list of the public accounts at BANK_ID. For each account the API returns the ID and the available views. + | + |Authentication via OAuth is not required.""".stripMargin, + EmptyBody, + accountJSON, + List(UnknownError, BankNotFound), + apiTagAccountPublic :: apiTagAccount :: apiTagPublicData :: apiTagOldStyle :: Nil, + http4sPartialFunction = Some(publicAccountsAtOneBank) + ) + + // ─── accountById ───────────────────────────────────────────────────────── + + val accountById: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "account" => + EndpointHelpers.withView(req) { (user, account, view, cc) => + for { + availableViews <- Future(Views.views.vend.privateViewsUserCanAccessForAccount(user, BankIdAccountId(account.bankId, account.accountId))) + moderatedAccount <- Future(account.moderatedBankAccount(view, BankIdAccountId(account.bankId, account.accountId), Full(user), Some(cc))) map { + unboxFullOrFail(_, Some(cc), BankAccountNotFound) + } + } yield { + val viewsAvailable = availableViews.map(JSONFactory.createViewJSON) + JSONFactory.createBankAccountJSON(moderatedAccount, viewsAvailable) + } + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(accountById), + "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/account", + "Get account by id", + s"""Information returned about an account specified by ACCOUNT_ID as moderated by the view (VIEW_ID): + | + |* Number + |* Owners + |* Type + |* Balance + |* IBAN + |* Available views + | + |${userAuthenticationMessage(false)} + | + |Authentication is required if the 'is_public' field in view (VIEW_ID) is not set to `true`. + |""".stripMargin, + EmptyBody, + moderatedAccountJSON, + List(AuthenticatedUserIsRequired, UnknownError, BankAccountNotFound), + apiTagAccount :: apiTagOldStyle :: Nil, + http4sPartialFunction = Some(accountById) + ) + + // ─── updateAccountLabel ────────────────────────────────────────────────── + + val updateAccountLabel: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankId / "accounts" / accountId => + EndpointHelpers.executeFuture(req) { + val cc = req.callContext + val bodyStr = cc.httpBody.getOrElse("") + for { + user <- cc.user match { + case Full(u) => Future.successful(u) + case _ => Future.failed(new RuntimeException(AuthenticatedUserIsRequired)) + } + json <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + net.liftweb.json.parse(bodyStr).extract[UpdateAccountJSON] + } + (account, callContext) <- NewStyle.function.checkBankAccountExists(BankId(bankId), AccountId(accountId), Some(cc)) + permission <- NewStyle.function.permission(account.bankId, account.accountId, user, callContext) + anyViewContainsPermission = permission.views.map(_.allowed_actions.exists(_ == CAN_UPDATE_BANK_ACCOUNT_LABEL)).find(_ == true).getOrElse(false) + _ <- Helper.booleanToFuture( + s"${ViewDoesNotPermitAccess} You need the `${CAN_UPDATE_BANK_ACCOUNT_LABEL}` permission on any your views", + cc = callContext + )(anyViewContainsPermission) + _ <- Connector.connector.vend.updateAccountLabel(BankId(bankId), AccountId(accountId), json.label, callContext) map { i => + unboxFullOrFail(i._1, i._2, s"$UpdateBankAccountLabelError Current BankId is $bankId and Current AccountId is $accountId", 404) + } + } yield successMessage + } + } + + resourceDocs += 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)} + |""".stripMargin, + updateAccountJSON, + successMessage, + List(InvalidJsonFormat, AuthenticatedUserIsRequired, UnknownError, BankAccountNotFound, "user does not have access to owner view on account"), + List(apiTagAccount), + http4sPartialFunction = Some(updateAccountLabel) + ) + + // ─── getViewsForBankAccount ─────────────────────────────────────────────── + + val getViewsForBankAccount: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / "views" => + EndpointHelpers.withBankAccount(req) { (user, account, cc) => + for { + permission <- Future(Views.views.vend.permission(BankIdAccountId(account.bankId, account.accountId), user)) map { + unboxFullOrFail(_, Some(cc), BankAccountNotFound) + } + anyViewContainsPermission = permission.views.map(_.allowed_actions.exists(_ == CAN_SEE_AVAILABLE_VIEWS_FOR_BANK_ACCOUNT)).find(_ == true).getOrElse(false) + _ <- Helper.booleanToFuture( + s"${ViewDoesNotPermitAccess} You need the `${CAN_SEE_AVAILABLE_VIEWS_FOR_BANK_ACCOUNT}` permission on any your views", + cc = Some(cc) + )(anyViewContainsPermission) + views <- Future(Views.views.vend.availableViewsForAccount(BankIdAccountId(account.bankId, account.accountId))) + } yield JSONFactory.createViewsJSON(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, + viewsJSONV121, + List(AuthenticatedUserIsRequired, BankAccountNotFound, UnknownError, "user does not have owner access"), + List(apiTagView, apiTagAccount, apiTagOldStyle), + http4sPartialFunction = Some(getViewsForBankAccount) + ) + + // ─── createViewForBankAccount ───────────────────────────────────────────── + + val createViewForBankAccount: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankId / "accounts" / accountId / "views" => + EndpointHelpers.executeFutureCreated(req) { + val cc = req.callContext + val bodyStr = cc.httpBody.getOrElse("") + for { + user <- cc.user match { + case Full(u) => Future.successful(u) + case _ => Future.failed(new RuntimeException(AuthenticatedUserIsRequired)) + } + (rawAccountBox, _) <- Connector.connector.vend.checkBankAccountExists(BankId(bankId), AccountId(accountId), Some(cc)) + account <- Future { unboxFullOrFail(rawAccountBox, Some(cc), s"$BankAccountNotFound Current BankId is $bankId and Current AccountId is $accountId") } + createViewJsonV121 <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + net.liftweb.json.parse(bodyStr).extract[CreateViewJsonV121] + } + _ <- Helper.booleanToFuture(InvalidCustomViewFormat + s"Current view_name (${createViewJsonV121.name})", cc = Some(cc)) { + isValidCustomViewName(createViewJsonV121.name) + } + createViewJson = CreateViewJson( + createViewJsonV121.name, createViewJsonV121.description, + metadata_view = "", + createViewJsonV121.is_public, createViewJsonV121.which_alias_to_use, + createViewJsonV121.hide_metadata_if_alias_used, createViewJsonV121.allowed_actions + ) + anyViewContainsPermission = Views.views.vend.permission(BankIdAccountId(account.bankId, account.accountId), user) + .map(_.views.map(_.allowed_actions.exists(_ == CAN_CREATE_CUSTOM_VIEW))).getOrElse(Nil).find(_ == true).getOrElse(false) + _ <- Helper.booleanToFuture( + s"${CreateCustomViewError} You need the `${CAN_CREATE_CUSTOM_VIEW}` permission on any your views", + cc = Some(cc) + )(anyViewContainsPermission) + view <- Future(Views.views.vend.createCustomView(BankIdAccountId(account.bankId, account.accountId), createViewJson)) map { + unboxFullOrFail(_, Some(cc), CreateCustomViewError) + } + } yield JSONFactory.createViewJSON(view) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(createViewForBankAccount), + "POST", + "/banks/BANK_ID/accounts/BANK_ACCOUNT_ID/views", + "Create View", + s"""Create a view on bank account + | + |${userAuthenticationMessage(true)} and the user needs to have access to the owner view.""", + createViewJsonV121, + viewJSONV121, + List(AuthenticatedUserIsRequired, InvalidJsonFormat, BankAccountNotFound, UnknownError, "user does not have owner access"), + List(apiTagAccount, apiTagView, apiTagOldStyle), + http4sPartialFunction = Some(createViewForBankAccount) + ) + + // ─── updateViewForBankAccount ───────────────────────────────────────────── + + val updateViewForBankAccount: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / bankId / "accounts" / accountId / "views" / viewId => + EndpointHelpers.executeFutureWithBody[UpdateViewJsonV121, ViewJSONV121](req) { (updateJsonV121, cc) => + for { + user <- cc.user match { + case Full(u) => Future.successful(u) + case _ => Future.failed(new RuntimeException(AuthenticatedUserIsRequired)) + } + (rawAccountBox, _) <- Connector.connector.vend.checkBankAccountExists(BankId(bankId), AccountId(accountId), Some(cc)) + account <- Future { unboxFullOrFail(rawAccountBox, Some(cc), s"$BankAccountNotFound Current BankId is $bankId and Current AccountId is $accountId") } + _ <- Helper.booleanToFuture(InvalidCustomViewFormat + s"Current view_id ($viewId)", cc = Some(cc)) { + viewId.startsWith("_") + } + view <- Future(Views.views.vend.customView(ViewId(viewId), BankIdAccountId(account.bankId, account.accountId))) map { + unboxFullOrFail(_, Some(cc), ViewNotFound) + } + _ <- Helper.booleanToFuture(SystemViewsCanNotBeModified, cc = Some(cc))(!view.isSystem) + updateViewJson = UpdateViewJSON( + description = updateJsonV121.description, + metadata_view = view.metadataView, + is_public = updateJsonV121.is_public, + which_alias_to_use = updateJsonV121.which_alias_to_use, + hide_metadata_if_alias_used = updateJsonV121.hide_metadata_if_alias_used, + allowed_actions = updateJsonV121.allowed_actions + ) + anyViewContainsPermission = Views.views.vend.permission(BankIdAccountId(account.bankId, account.accountId), user) + .map(_.views.map(_.allowed_actions.exists(_ == CAN_UPDATE_CUSTOM_VIEW))).getOrElse(Nil).find(_ == true).getOrElse(false) + _ <- Helper.booleanToFuture( + s"${CreateCustomViewError} You need the `${CAN_UPDATE_CUSTOM_VIEW}` permission on any your views", + cc = Some(cc) + )(anyViewContainsPermission) + updatedView <- Future(Views.views.vend.updateCustomView(BankIdAccountId(account.bankId, account.accountId), ViewId(viewId), updateViewJson)) map { + unboxFullOrFail(_, Some(cc), CreateCustomViewError) + } + } yield JSONFactory.createViewJSON(updatedView) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(updateViewForBankAccount), + "PUT", + "/banks/BANK_ID/accounts/BANK_ACCOUNT_ID/views/CUSTOM_VIEW_ID", + "Update View", + s"""Update an existing view on a bank account + | + |${userAuthenticationMessage(true)} and the user needs to have access to the owner view.""", + updateViewJsonV121, + viewJSONV121, + List(InvalidJsonFormat, AuthenticatedUserIsRequired, BankAccountNotFound, ViewNotFound, UnknownError, "user does not have owner access"), + List(apiTagAccount, apiTagView, apiTagOldStyle), + http4sPartialFunction = Some(updateViewForBankAccount) + ) + + // ─── deleteViewForBankAccount ───────────────────────────────────────────── + + val deleteViewForBankAccount: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / bankId / "accounts" / accountId / "views" / viewId => + EndpointHelpers.withUserDelete(req) { (user, cc) => + for { + (_, callContext) <- NewStyle.function.getBank(BankId(bankId), Some(cc)) + (account, callContext) <- NewStyle.function.getBankAccount(BankId(bankId), AccountId(accountId), callContext) + _ <- Helper.booleanToFuture(InvalidCustomViewFormat + s"Current view_name ($viewId)", cc = callContext)(viewId.startsWith("_")) + _ <- ViewNewStyle.customView(ViewId(viewId), BankIdAccountId(BankId(bankId), AccountId(accountId)), callContext) + anyViewContainsPermission = Views.views.vend.permission(BankIdAccountId(account.bankId, account.accountId), user) + .map(_.views.map(_.allowed_actions.exists(_ == CAN_DELETE_CUSTOM_VIEW))).getOrElse(Nil).find(_ == true).getOrElse(false) + _ <- Helper.booleanToFuture( + s"${ViewDoesNotPermitAccess} You need the `${CAN_DELETE_CUSTOM_VIEW}` permission on any your views", + cc = callContext + )(anyViewContainsPermission) + _ <- ViewNewStyle.removeCustomView(ViewId(viewId), BankIdAccountId(BankId(bankId), AccountId(accountId)), callContext) + } yield () + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(deleteViewForBankAccount), + "DELETE", + "/banks/BANK_ID/accounts/BANK_ACCOUNT_ID/views/CUSTOM_VIEW_ID", + "Delete Custom View", + "Deletes the custom view specified by VIEW_ID on the bank account specified by ACCOUNT_ID at bank BANK_ID", + EmptyBody, + EmptyBody, + List(AuthenticatedUserIsRequired, BankAccountNotFound, UnknownError, "user does not have owner access"), + List(apiTagView, apiTagAccount), + http4sPartialFunction = Some(deleteViewForBankAccount) + ) + + // ─── getPermissionsForBankAccount ───────────────────────────────────────── + + val getPermissionsForBankAccount: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / "permissions" => + EndpointHelpers.withBankAccount(req) { (user, account, cc) => + val permissionBox = Views.views.vend.permission(BankIdAccountId(account.bankId, account.accountId), user) + val anyViewContainsPermission = permissionBox.map(_.views.map(_.allowed_actions.exists(_ == CAN_SEE_VIEWS_WITH_PERMISSIONS_FOR_ALL_USERS))).getOrElse(Nil).find(_ == true).getOrElse(false) + for { + _ <- Helper.booleanToFuture( + s"${CreateCustomViewError} You need the `${CAN_SEE_VIEWS_WITH_PERMISSIONS_FOR_ALL_USERS}` permission on any your views", + cc = Some(cc) + )(anyViewContainsPermission) + permissions = Views.views.vend.permissions(BankIdAccountId(account.bankId, account.accountId)) + } yield JSONFactory.createPermissionsJSON(permissions) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getPermissionsForBankAccount), + "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/permissions", + "Get access", + s"""Returns the list of the permissions at BANK_ID for account ACCOUNT_ID. + | + |${userAuthenticationMessage(true)} and the user needs to have access to the owner view.""", + EmptyBody, + permissionsJSON, + List(AuthenticatedUserIsRequired, UnknownError), + List(apiTagView, apiTagAccount, apiTagEntitlement, apiTagOldStyle), + http4sPartialFunction = Some(getPermissionsForBankAccount) + ) + + // ─── getPermissionForUserForBankAccount ─────────────────────────────────── + + val getPermissionForUserForBankAccount: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / "permissions" / provider / providerId => + EndpointHelpers.withBankAccount(req) { (loggedInUser, account, cc) => + val loggedInUserPermissionBox = Views.views.vend.permission(BankIdAccountId(account.bankId, account.accountId), loggedInUser) + val anyViewContainsPermission = loggedInUserPermissionBox.map(_.views.map(_.allowed_actions.exists(_ == CAN_SEE_VIEWS_WITH_PERMISSIONS_FOR_ONE_USER))) + .getOrElse(Nil).find(_ == true).getOrElse(false) + for { + _ <- Helper.booleanToFuture( + s"${CreateCustomViewError} You need the `${CAN_SEE_VIEWS_WITH_PERMISSIONS_FOR_ONE_USER}` permission on any your views", + cc = Some(cc) + )(anyViewContainsPermission) + userFromURL <- Future(UserX.findByProviderId(provider, providerId)) map { + unboxFullOrFail(_, Some(cc), UserNotFoundByProviderAndProvideId) + } + permission <- Future(Views.views.vend.permission(BankIdAccountId(account.bankId, account.accountId), userFromURL)) map { + unboxFullOrFail(_, Some(cc), UnknownError) + } + } yield JSONFactory.createViewsJSON(permission.views) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getPermissionForUserForBankAccount), + "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/permissions/PROVIDER_ID/USER_ID", + "Get access for specific user", + s"""Returns the list of the views at BANK_ID for account ACCOUNT_ID that a USER_ID at their provider PROVIDER_ID has access to. + | + |${userAuthenticationMessage(true)} and the user needs to have access to the owner view.""", + EmptyBody, + viewsJSONV121, + List(AuthenticatedUserIsRequired, BankAccountNotFound, UnknownError, "user does not have access to owner view on account"), + List(apiTagAccount, apiTagView, apiTagEntitlement, apiTagOldStyle), + http4sPartialFunction = Some(getPermissionForUserForBankAccount) + ) + + // ─── addPermissionForUserForBankAccountForMultipleViews ─────────────────── + + val addPermissionForUserForBankAccountForMultipleViews: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankId / "accounts" / accountId / "permissions" / provider / providerId / "views" => + EndpointHelpers.executeFutureCreated(req) { + val cc = req.callContext + val bodyStr = cc.httpBody.getOrElse("") + for { + user <- cc.user match { + case Full(u) => Future.successful(u) + case _ => Future.failed(new RuntimeException(AuthenticatedUserIsRequired)) + } + (_, callContext) <- NewStyle.function.getBank(BankId(bankId), Some(cc)) + (account, callContext) <- NewStyle.function.getBankAccount(BankId(bankId), AccountId(accountId), callContext) + viewIds <- NewStyle.function.tryons("wrong format JSON", 400, callContext) { + net.liftweb.json.parse(bodyStr).extract[ViewIdsJson] + } + (addedViews, callContext) <- ViewNewStyle.grantAccessToMultipleViews( + account, user, + viewIds.views.map(viewIdString => BankIdAccountIdViewId(BankId(bankId), AccountId(accountId), ViewId(viewIdString))), + provider, providerId, callContext + ) + } yield JSONFactory.createViewsJSON(addedViews) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(addPermissionForUserForBankAccountForMultipleViews), + "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/permissions/PROVIDER/PROVIDER_ID/views", + "Grant User access to a list of views", + s"""Grants the user identified by PROVIDER_ID at their provider PROVIDER access to a list of views at BANK_ID for account ACCOUNT_ID. + | + |${userAuthenticationMessage(true)} + | + |The User needs to have access to the owner view.""", + viewIdsJson, + viewsJSONV121, + List(AuthenticatedUserIsRequired, BankAccountNotFound, UnknownError, "wrong format JSON", "could not save the privilege", "user does not have access to owner view on account"), + List(apiTagView, apiTagAccount, apiTagUser, apiTagOwnerRequired), + http4sPartialFunction = Some(addPermissionForUserForBankAccountForMultipleViews) + ) + + // ─── addPermissionForUserForBankAccountForOneView ───────────────────────── + + val addPermissionForUserForBankAccountForOneView: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankId / "accounts" / accountId / "permissions" / provider / providerId / "views" / viewId => + EndpointHelpers.executeFutureCreated(req) { + val cc = req.callContext + for { + user <- cc.user match { + case Full(u) => Future.successful(u) + case _ => Future.failed(new RuntimeException(AuthenticatedUserIsRequired)) + } + (_, callContext) <- NewStyle.function.getBank(BankId(bankId), Some(cc)) + (account, callContext) <- NewStyle.function.getBankAccount(BankId(bankId), AccountId(accountId), callContext) + (addedView, callContext) <- ViewNewStyle.grantAccessToView(account, user, BankIdAccountIdViewId(BankId(bankId), AccountId(accountId), ViewId(viewId)), provider, providerId, callContext) + } yield JSONFactory.createViewJSON(addedView) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(addPermissionForUserForBankAccountForOneView), + "POST", + "/banks/BANK_ID/accounts/BANK_ACCOUNT_ID/permissions/PROVIDER/PROVIDER_ID/views/GRANT_VIEW_ID", + "Grant User access to View", + s"""Grants the User identified by PROVIDER_ID at PROVIDER access to the view VIEW_ID at BANK_ID for account ACCOUNT_ID. + | + |${userAuthenticationMessage(true)} and the user needs to have access to the owner view.""", + EmptyBody, + viewJSONV121, + List(AuthenticatedUserIsRequired, BankAccountNotFound, UnknownError, UserLacksPermissionCanGrantAccessToViewForTargetAccount, "could not save the privilege", "user does not have access to owner view on account"), + List(apiTagView, apiTagAccount, apiTagUser, apiTagOwnerRequired), + http4sPartialFunction = Some(addPermissionForUserForBankAccountForOneView) + ) + + // ─── removePermissionForUserForBankAccountForOneView ────────────────────── + + val removePermissionForUserForBankAccountForOneView: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / bankId / "accounts" / accountId / "permissions" / provider / providerId / "views" / viewId => + EndpointHelpers.withUserDelete(req) { (user, cc) => + for { + (_, callContext) <- NewStyle.function.getBank(BankId(bankId), Some(cc)) + (account, callContext) <- NewStyle.function.getBankAccount(BankId(bankId), AccountId(accountId), callContext) + _ <- ViewNewStyle.revokeAccessToView(account, user, BankIdAccountIdViewId(BankId(bankId), AccountId(accountId), ViewId(viewId)), provider, providerId, callContext) + } yield () + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(removePermissionForUserForBankAccountForOneView), + "DELETE", + "/banks/BANK_ID/accounts/ACCOUNT_ID/permissions/PROVIDER/PROVIDER_ID/views/VIEW_ID", + "Revoke access to one View", + s"""Revokes access to a View on an Account for a certain User. + | + |${userAuthenticationMessage(true)} and the user needs to have access to the owner view.""", + EmptyBody, + EmptyBody, + List(AuthenticatedUserIsRequired, BankAccountNotFound, "could not save the privilege", "user does not have access to owner view on account", UnknownError), + List(apiTagView, apiTagAccount, apiTagUser, apiTagEntitlement, apiTagOwnerRequired), + http4sPartialFunction = Some(removePermissionForUserForBankAccountForOneView) + ) + + // ─── removePermissionForUserForBankAccountForAllViews ──────────────────── + + val removePermissionForUserForBankAccountForAllViews: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / bankId / "accounts" / accountId / "permissions" / provider / providerId / "views" => + EndpointHelpers.withUserDelete(req) { (user, cc) => + for { + (_, callContext) <- NewStyle.function.getBank(BankId(bankId), Some(cc)) + (account, callContext) <- NewStyle.function.getBankAccount(BankId(bankId), AccountId(accountId), callContext) + _ <- NewStyle.function.revokeAllAccountAccess(account, user, provider, providerId, callContext) + } yield () + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(removePermissionForUserForBankAccountForAllViews), + "DELETE", + "/banks/BANK_ID/accounts/ACCOUNT_ID/permissions/PROVIDER/PROVIDER_ID/views", + "Revoke access to all Views on Account", + s"""Revokes access to all Views on an Account for a certain User. + | + |${userAuthenticationMessage(true)} and the user needs to have access to the owner view.""", + EmptyBody, + EmptyBody, + List(AuthenticatedUserIsRequired, BankAccountNotFound, UnknownError, "user does not have access to owner view on account"), + List(apiTagView, apiTagAccount, apiTagUser, apiTagOwnerRequired), + http4sPartialFunction = Some(removePermissionForUserForBankAccountForAllViews) + ) + + // ─── getOtherAccountsForBankAccount ────────────────────────────────────── + + val getOtherAccountsForBankAccount: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "other_accounts" => + EndpointHelpers.withView(req) { (user, account, view, cc) => + for { + (otherBankAccounts, _) <- NewStyle.function.moderatedOtherBankAccounts(account, view, Full(user), Some(cc)) + } yield JSONFactory.createOtherBankAccountsJSON(otherBankAccounts) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getOtherAccountsForBankAccount), + "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/other_accounts", + "Get Other Accounts of one Account", + s"""Returns data about all the other accounts that have shared at least one transaction with the ACCOUNT_ID at BANK_ID. + |${userAuthenticationMessage(false)} + |Authentication is required if the view VIEW_ID is not public.""", + EmptyBody, + otherAccountsJSON, + List(BankAccountNotFound, UnknownError), + List(apiTagCounterparty, apiTagAccount, apiTagPsd2, apiTagOldStyle), + http4sPartialFunction = Some(getOtherAccountsForBankAccount) + ) + + // ─── getOtherAccountByIdForBankAccount ─────────────────────────────────── + + val getOtherAccountByIdForBankAccount: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "other_accounts" / otherAccountId => + EndpointHelpers.withView(req) { (user, account, view, cc) => + for { + (otherBankAccount, _) <- NewStyle.function.moderatedOtherBankAccount(account, otherAccountId, view, Full(user), Some(cc)) + } yield JSONFactory.createOtherBankAccount(otherBankAccount) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getOtherAccountByIdForBankAccount), + "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/other_accounts/OTHER_ACCOUNT_ID", + "Get Other Account by Id", + """Returns data about the Other Account that has shared at least one transaction with ACCOUNT_ID at BANK_ID. + |Authentication is required if the view is not public.""", + EmptyBody, + otherAccountJSON, + List(AuthenticatedUserIsRequired, BankAccountNotFound, UnknownError), + List(apiTagCounterparty, apiTagAccount), + http4sPartialFunction = Some(getOtherAccountByIdForBankAccount) + ) + + // ─── getOtherAccountMetadata ────────────────────────────────────────────── + + val getOtherAccountMetadata: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "other_accounts" / otherAccountId / "metadata" => + EndpointHelpers.withView(req) { (user, account, view, cc) => + for { + (otherBankAccount, _) <- NewStyle.function.moderatedOtherBankAccount(account, otherAccountId, view, Full(user), Some(cc)) + _ <- Helper.booleanToFuture(s"$NoViewPermission can_see_other_account_metadata. Current ViewId(${view.viewId})", cc = Some(cc)) { + otherBankAccount.metadata.isDefined + } + } yield JSONFactory.createOtherAccountMetaDataJSON(otherBankAccount.metadata.get) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getOtherAccountMetadata), + "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/other_accounts/OTHER_ACCOUNT_ID/metadata", + "Get Other Account Metadata", + """Get metadata of one other account. + |Authentication via OAuth is required if the view is not public.""", + EmptyBody, + otherAccountMetadataJSON, + List(AuthenticatedUserIsRequired, UnknownError, "the view does not allow metadata access"), + List(apiTagCounterpartyMetaData, apiTagCounterparty), + http4sPartialFunction = Some(getOtherAccountMetadata) + ) + + // ─── getCounterpartyPublicAlias ─────────────────────────────────────────── + + val getCounterpartyPublicAlias: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "other_accounts" / otherAccountId / "public_alias" => + EndpointHelpers.withView(req) { (user, account, view, cc) => + for { + (otherBankAccount, _) <- NewStyle.function.moderatedOtherBankAccount(account, otherAccountId, view, Full(user), Some(cc)) + _ <- Helper.booleanToFuture(s"$NoViewPermission can_see_other_account_metadata. Current ViewId(${view.viewId})", cc = Some(cc))(otherBankAccount.metadata.isDefined) + _ <- Helper.booleanToFuture(s"the view ${view.viewId} does not allow adding a public alias", cc = Some(cc))(otherBankAccount.metadata.get.publicAlias.isDefined) + } yield JSONFactory.createAliasJSON(otherBankAccount.metadata.get.publicAlias.get) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCounterpartyPublicAlias), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/other_accounts/OTHER_ACCOUNT_ID/public_alias", + "Get public alias of other bank account", + s"""Returns the public alias of the other account OTHER_ACCOUNT_ID.""", + EmptyBody, aliasJSON, + List(AuthenticatedUserIsRequired, BankAccountNotFound, UnknownError, "the view does not allow metadata access", "the view does not allow public alias access"), + List(apiTagCounterpartyMetaData, apiTagCounterparty), + http4sPartialFunction = Some(getCounterpartyPublicAlias) + ) + + // ─── addCounterpartyPublicAlias ─────────────────────────────────────────── + + val addCounterpartyPublicAlias: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "other_accounts" / otherAccountId / "public_alias" => + EndpointHelpers.withViewCreated(req) { (user, account, view, cc) => + val bodyStr = cc.httpBody.getOrElse("") + for { + (otherBankAccount, _) <- NewStyle.function.moderatedOtherBankAccount(account, otherAccountId, view, Full(user), Some(cc)) + _ <- Helper.booleanToFuture(s"$NoViewPermission can_see_other_account_metadata. Current ViewId(${view.viewId})", cc = Some(cc))(otherBankAccount.metadata.isDefined) + _ <- Helper.booleanToFuture(s"the view ${view.viewId} does not allow adding a public alias", cc = Some(cc))(otherBankAccount.metadata.get.addPublicAlias.isDefined) + aliasJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { net.liftweb.json.parse(bodyStr).extract[AliasJSON] } + added <- Future(Counterparties.counterparties.vend.addPublicAlias(otherAccountId, aliasJson.alias)) map { unboxFullOrFail(_, Some(cc), "Alias cannot be added", 400) } + _ <- Helper.booleanToFuture("Alias cannot be added", 400, Some(cc))(added) + } yield SuccessMessage("public alias added") + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(addCounterpartyPublicAlias), "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/other_accounts/OTHER_ACCOUNT_ID/public_alias", + "Add public alias to other bank account", s"""Creates the public alias for the other account OTHER_ACCOUNT_ID.""", + aliasJSON, successMessage, + List(AuthenticatedUserIsRequired, BankAccountNotFound, InvalidJsonFormat, UnknownError, "the view does not allow metadata access", "the view does not allow adding a public alias", "Alias cannot be added", "public alias added"), + List(apiTagCounterpartyMetaData, apiTagCounterparty), + http4sPartialFunction = Some(addCounterpartyPublicAlias) + ) + + // ─── updateCounterpartyPublicAlias ──────────────────────────────────────── + + val updateCounterpartyPublicAlias: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "other_accounts" / otherAccountId / "public_alias" => + EndpointHelpers.withView(req) { (user, account, view, cc) => + val bodyStr = cc.httpBody.getOrElse("") + for { + (otherBankAccount, _) <- NewStyle.function.moderatedOtherBankAccount(account, otherAccountId, view, Full(user), Some(cc)) + _ <- Helper.booleanToFuture(s"$NoViewPermission can_see_other_account_metadata. Current ViewId(${view.viewId})", cc = Some(cc))(otherBankAccount.metadata.isDefined) + _ <- Helper.booleanToFuture(s"the view ${view.viewId} does not allow updating a public alias", cc = Some(cc))(otherBankAccount.metadata.get.addPublicAlias.isDefined) + aliasJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { net.liftweb.json.parse(bodyStr).extract[AliasJSON] } + updated <- Future(Counterparties.counterparties.vend.addPublicAlias(otherAccountId, aliasJson.alias)) map { unboxFullOrFail(_, Some(cc), "Alias cannot be updated", 400) } + _ <- Helper.booleanToFuture("Alias cannot be updated", 400, Some(cc))(updated) + } yield SuccessMessage("public alias updated") + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateCounterpartyPublicAlias), "PUT", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/other_accounts/OTHER_ACCOUNT_ID/public_alias", + "Update public alias of other bank account", s"""Updates the public alias of the other account / counterparty OTHER_ACCOUNT_ID.""", + aliasJSON, successMessage, + List(BankAccountNotFound, InvalidJsonFormat, AuthenticatedUserIsRequired, "the view does not allow metadata access", "the view does not allow updating the public alias", "Alias cannot be updated", UnknownError), + List(apiTagCounterpartyMetaData, apiTagCounterparty), + http4sPartialFunction = Some(updateCounterpartyPublicAlias) + ) + + // ─── deleteCounterpartyPublicAlias ──────────────────────────────────────── + + val deleteCounterpartyPublicAlias: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "other_accounts" / otherAccountId / "public_alias" => + EndpointHelpers.withUserDelete(req) { (user, cc) => + for { + (account, callContext) <- NewStyle.function.checkBankAccountExists(cc.bankAccount.get.bankId, cc.bankAccount.get.accountId, Some(cc)) + view <- ViewNewStyle.checkViewAccessAndReturnView(cc.view.get.viewId, BankIdAccountId(account.bankId, account.accountId), Full(user), callContext) + (otherBankAccount, _) <- NewStyle.function.moderatedOtherBankAccount(account, otherAccountId, view, Full(user), callContext) + _ <- Helper.booleanToFuture(s"$NoViewPermission can_see_other_account_metadata. Current ViewId(${view.viewId})", cc = callContext)(otherBankAccount.metadata.isDefined) + _ <- Helper.booleanToFuture(s"the view ${view.viewId} does not allow deleting a public alias", cc = callContext)(otherBankAccount.metadata.get.addPublicAlias.isDefined) + deleted <- Future(Counterparties.counterparties.vend.addPublicAlias(otherAccountId, "")) map { unboxFullOrFail(_, callContext, "Alias cannot be deleted", 400) } + _ <- Helper.booleanToFuture("Alias cannot be deleted", 400, callContext)(deleted) + } yield () + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteCounterpartyPublicAlias), "DELETE", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/other_accounts/OTHER_ACCOUNT_ID/public_alias", + "Delete Counterparty Public Alias", s"""Deletes the public alias of the other account OTHER_ACCOUNT_ID.""", + EmptyBody, EmptyBody, + List(AuthenticatedUserIsRequired, BankAccountNotFound, "the view does not allow metadata access", "the view does not allow deleting the public alias", "Alias cannot be deleted", UnknownError), + List(apiTagCounterpartyMetaData, apiTagCounterparty), + http4sPartialFunction = Some(deleteCounterpartyPublicAlias) + ) + + // ─── getOtherAccountPrivateAlias ────────────────────────────────────────── + + val getOtherAccountPrivateAlias: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "other_accounts" / otherAccountId / "private_alias" => + EndpointHelpers.withView(req) { (user, account, view, cc) => + for { + (otherBankAccount, _) <- NewStyle.function.moderatedOtherBankAccount(account, otherAccountId, view, Full(user), Some(cc)) + _ <- Helper.booleanToFuture(s"$NoViewPermission can_see_other_account_metadata. Current ViewId(${view.viewId})", cc = Some(cc))(otherBankAccount.metadata.isDefined) + _ <- Helper.booleanToFuture(s"the view ${view.viewId} does not allow adding a private alias", cc = Some(cc))(otherBankAccount.metadata.get.privateAlias.isDefined) + } yield JSONFactory.createAliasJSON(otherBankAccount.metadata.get.privateAlias.get) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getOtherAccountPrivateAlias), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/other_accounts/OTHER_ACCOUNT_ID/private_alias", + "Get Other Account Private Alias", s"""Returns the private alias of the other account OTHER_ACCOUNT_ID.""", + EmptyBody, aliasJSON, + List(AuthenticatedUserIsRequired, BankAccountNotFound, "the view does not allow metadata access", "the view does not allow private alias access", UnknownError), + List(apiTagCounterpartyMetaData, apiTagCounterparty), + http4sPartialFunction = Some(getOtherAccountPrivateAlias) + ) + + // ─── addOtherAccountPrivateAlias ────────────────────────────────────────── + + val addOtherAccountPrivateAlias: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "other_accounts" / otherAccountId / "private_alias" => + EndpointHelpers.withViewCreated(req) { (user, account, view, cc) => + val bodyStr = cc.httpBody.getOrElse("") + for { + (otherBankAccount, _) <- NewStyle.function.moderatedOtherBankAccount(account, otherAccountId, view, Full(user), Some(cc)) + _ <- Helper.booleanToFuture(s"$NoViewPermission can_see_other_account_metadata. Current ViewId(${view.viewId})", cc = Some(cc))(otherBankAccount.metadata.isDefined) + _ <- Helper.booleanToFuture(s"the view ${view.viewId} does not allow adding a private alias", cc = Some(cc))(otherBankAccount.metadata.get.addPrivateAlias.isDefined) + aliasJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { net.liftweb.json.parse(bodyStr).extract[AliasJSON] } + added <- Future(Counterparties.counterparties.vend.addPrivateAlias(otherAccountId, aliasJson.alias)) map { unboxFullOrFail(_, Some(cc), "Alias cannot be added", 400) } + _ <- Helper.booleanToFuture("Alias cannot be added", 400, Some(cc))(added) + } yield SuccessMessage("private alias added") + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(addOtherAccountPrivateAlias), "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/other_accounts/OTHER_ACCOUNT_ID/private_alias", + "Create Other Account Private Alias", s"""Creates a private alias for the other account OTHER_ACCOUNT_ID.""", + aliasJSON, successMessage, + List(AuthenticatedUserIsRequired, BankAccountNotFound, InvalidJsonFormat, "the view does not allow metadata access", "the view does not allow adding a private alias", "Alias cannot be added", UnknownError), + List(apiTagCounterpartyMetaData, apiTagCounterparty), + http4sPartialFunction = Some(addOtherAccountPrivateAlias) + ) + + // ─── updateCounterpartyPrivateAlias ─────────────────────────────────────── + + val updateCounterpartyPrivateAlias: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "other_accounts" / otherAccountId / "private_alias" => + EndpointHelpers.withView(req) { (user, account, view, cc) => + val bodyStr = cc.httpBody.getOrElse("") + for { + (otherBankAccount, _) <- NewStyle.function.moderatedOtherBankAccount(account, otherAccountId, view, Full(user), Some(cc)) + _ <- Helper.booleanToFuture(s"$NoViewPermission can_see_other_account_metadata. Current ViewId(${view.viewId})", cc = Some(cc))(otherBankAccount.metadata.isDefined) + _ <- Helper.booleanToFuture(s"the view ${view.viewId} does not allow updating a private alias", cc = Some(cc))(otherBankAccount.metadata.get.addPrivateAlias.isDefined) + aliasJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { net.liftweb.json.parse(bodyStr).extract[AliasJSON] } + updated <- Future(Counterparties.counterparties.vend.addPrivateAlias(otherAccountId, aliasJson.alias)) map { unboxFullOrFail(_, Some(cc), "Alias cannot be updated", 400) } + _ <- Helper.booleanToFuture("Alias cannot be updated", 400, Some(cc))(updated) + } yield SuccessMessage("private alias updated") + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateCounterpartyPrivateAlias), "PUT", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/other_accounts/OTHER_ACCOUNT_ID/private_alias", + "Update Counterparty Private Alias", s"""Updates the private alias of the counterparty OTHER_ACCOUNT_ID.""", + aliasJSON, successMessage, + List(AuthenticatedUserIsRequired, BankAccountNotFound, InvalidJsonFormat, "the view does not allow metadata access", "the view does not allow updating the private alias", "Alias cannot be updated", UnknownError), + List(apiTagCounterpartyMetaData, apiTagCounterparty), + http4sPartialFunction = Some(updateCounterpartyPrivateAlias) + ) + + // ─── deleteCounterpartyPrivateAlias ─────────────────────────────────────── + + val deleteCounterpartyPrivateAlias: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "other_accounts" / otherAccountId / "private_alias" => + EndpointHelpers.withUserDelete(req) { (user, cc) => + for { + (account, callContext) <- NewStyle.function.checkBankAccountExists(cc.bankAccount.get.bankId, cc.bankAccount.get.accountId, Some(cc)) + view <- ViewNewStyle.checkViewAccessAndReturnView(cc.view.get.viewId, BankIdAccountId(account.bankId, account.accountId), Full(user), callContext) + (otherBankAccount, _) <- NewStyle.function.moderatedOtherBankAccount(account, otherAccountId, view, Full(user), callContext) + _ <- Helper.booleanToFuture(s"$NoViewPermission can_see_other_account_metadata. Current ViewId(${view.viewId})", cc = callContext)(otherBankAccount.metadata.isDefined) + _ <- Helper.booleanToFuture(s"the view ${view.viewId} does not allow deleting a private alias", cc = callContext)(otherBankAccount.metadata.get.addPrivateAlias.isDefined) + deleted <- Future(Counterparties.counterparties.vend.addPrivateAlias(otherAccountId, "")) map { unboxFullOrFail(_, callContext, "Alias cannot be deleted", 400) } + _ <- Helper.booleanToFuture("Alias cannot be deleted", 400, callContext)(deleted) + } yield () + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteCounterpartyPrivateAlias), "DELETE", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/other_accounts/OTHER_ACCOUNT_ID/private_alias", + "Delete Counterparty Private Alias", s"""Deletes the private alias of the other account OTHER_ACCOUNT_ID.""", + EmptyBody, EmptyBody, + List(AuthenticatedUserIsRequired, BankAccountNotFound, "the view does not allow metadata access", "the view does not allow deleting the private alias", "Alias cannot be deleted", UnknownError), + List(apiTagCounterpartyMetaData, apiTagCounterparty), + http4sPartialFunction = Some(deleteCounterpartyPrivateAlias) + ) + + // ─── addCounterpartyMoreInfo ────────────────────────────────────────────── + + val addCounterpartyMoreInfo: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "other_accounts" / otherAccountId / "metadata" / "more_info" => + EndpointHelpers.withViewCreated(req) { (user, account, view, cc) => + val bodyStr = cc.httpBody.getOrElse("") + for { + (otherBankAccount, _) <- NewStyle.function.moderatedOtherBankAccount(account, otherAccountId, view, Full(user), Some(cc)) + _ <- Helper.booleanToFuture(s"$NoViewPermission can_see_other_account_metadata. Current ViewId(${view.viewId})", cc = Some(cc))(otherBankAccount.metadata.isDefined) + _ <- Helper.booleanToFuture(s"the view ${view.viewId} does not allow adding more info", cc = Some(cc))(otherBankAccount.metadata.get.addMoreInfo.isDefined) + moreInfoJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { net.liftweb.json.parse(bodyStr).extract[MoreInfoJSON] } + added <- Future(Counterparties.counterparties.vend.addMoreInfo(otherAccountId, moreInfoJson.more_info)) map { unboxFullOrFail(_, Some(cc), "More Info cannot be added", 400) } + _ <- Helper.booleanToFuture("More Info cannot be added", 400, Some(cc))(added) + } yield SuccessMessage("more info added") + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(addCounterpartyMoreInfo), "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/other_accounts/OTHER_ACCOUNT_ID/metadata/more_info", + "Add Counterparty More Info", "Add a description of the counter party from the perspective of the account e.g. My dentist", + moreInfoJSON, successMessage, + List(AuthenticatedUserIsRequired, BankAccountNotFound, InvalidJsonFormat, NoViewPermission, "the view does not allow adding more info", "More Info cannot be added", UnknownError), + List(apiTagCounterpartyMetaData, apiTagCounterparty), + http4sPartialFunction = Some(addCounterpartyMoreInfo) + ) + + // ─── updateCounterpartyMoreInfo ─────────────────────────────────────────── + + val updateCounterpartyMoreInfo: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "other_accounts" / otherAccountId / "metadata" / "more_info" => + EndpointHelpers.withView(req) { (user, account, view, cc) => + val bodyStr = cc.httpBody.getOrElse("") + for { + (otherBankAccount, _) <- NewStyle.function.moderatedOtherBankAccount(account, otherAccountId, view, Full(user), Some(cc)) + _ <- Helper.booleanToFuture(s"$NoViewPermission can_see_other_account_metadata. Current ViewId(${view.viewId})", cc = Some(cc))(otherBankAccount.metadata.isDefined) + _ <- Helper.booleanToFuture(s"the view ${view.viewId} does not allow updating more info", cc = Some(cc))(otherBankAccount.metadata.get.addMoreInfo.isDefined) + moreInfoJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { net.liftweb.json.parse(bodyStr).extract[MoreInfoJSON] } + updated <- Future(Counterparties.counterparties.vend.addMoreInfo(otherAccountId, moreInfoJson.more_info)) map { unboxFullOrFail(_, Some(cc), "More Info cannot be updated", 400) } + _ <- Helper.booleanToFuture("More Info cannot be updated", 400, Some(cc))(updated) + } yield SuccessMessage("more info updated") + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateCounterpartyMoreInfo), "PUT", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/other_accounts/OTHER_ACCOUNT_ID/metadata/more_info", + "Update Counterparty More Info", "Update the more info description of the counter party", + moreInfoJSON, successMessage, + List(AuthenticatedUserIsRequired, BankAccountNotFound, InvalidJsonFormat, "the view does not allow metadata access", "the view does not allow updating more info", "More Info cannot be updated", UnknownError), + List(apiTagCounterpartyMetaData, apiTagCounterparty), + http4sPartialFunction = Some(updateCounterpartyMoreInfo) + ) + + // ─── deleteCounterpartyMoreInfo ─────────────────────────────────────────── + + val deleteCounterpartyMoreInfo: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "other_accounts" / otherAccountId / "metadata" / "more_info" => + EndpointHelpers.withUserDelete(req) { (user, cc) => + for { + (account, callContext) <- NewStyle.function.checkBankAccountExists(cc.bankAccount.get.bankId, cc.bankAccount.get.accountId, Some(cc)) + view <- ViewNewStyle.checkViewAccessAndReturnView(cc.view.get.viewId, BankIdAccountId(account.bankId, account.accountId), Full(user), callContext) + (otherBankAccount, _) <- NewStyle.function.moderatedOtherBankAccount(account, otherAccountId, view, Full(user), callContext) + _ <- Helper.booleanToFuture(s"$NoViewPermission can_see_other_account_metadata. Current ViewId(${view.viewId})", cc = callContext)(otherBankAccount.metadata.isDefined) + _ <- Helper.booleanToFuture(s"the view ${view.viewId} does not allow deleting more info", cc = callContext)(otherBankAccount.metadata.get.addMoreInfo.isDefined) + deleted <- Future(Counterparties.counterparties.vend.addMoreInfo(otherAccountId, "")) map { unboxFullOrFail(_, callContext, "More Info cannot be deleted", 400) } + _ <- Helper.booleanToFuture("More Info cannot be deleted", 400, callContext)(deleted) + } yield () + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteCounterpartyMoreInfo), "DELETE", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/other_accounts/OTHER_ACCOUNT_ID/metadata/more_info", + "Delete more info of other bank account", "", + EmptyBody, EmptyBody, + List(AuthenticatedUserIsRequired, BankAccountNotFound, "the view does not allow metadata access", "the view does not allow deleting more info", "More Info cannot be deleted", UnknownError), + List(apiTagCounterpartyMetaData, apiTagCounterparty), + http4sPartialFunction = Some(deleteCounterpartyMoreInfo) + ) + + // ─── addCounterpartyUrl ─────────────────────────────────────────────────── + + val addCounterpartyUrl: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "other_accounts" / otherAccountId / "metadata" / "url" => + EndpointHelpers.withViewCreated(req) { (user, account, view, cc) => + val bodyStr = cc.httpBody.getOrElse("") + for { + (otherBankAccount, _) <- NewStyle.function.moderatedOtherBankAccount(account, otherAccountId, view, Full(user), Some(cc)) + _ <- Helper.booleanToFuture(s"$NoViewPermission can_see_other_account_metadata. Current ViewId(${view.viewId})", cc = Some(cc))(otherBankAccount.metadata.isDefined) + _ <- Helper.booleanToFuture(s"the view ${view.viewId} does not allow adding a url", cc = Some(cc))(otherBankAccount.metadata.get.addURL.isDefined) + urlJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { net.liftweb.json.parse(bodyStr).extract[UrlJSON] } + added <- Future(Counterparties.counterparties.vend.addURL(otherAccountId, urlJson.URL)) map { unboxFullOrFail(_, Some(cc), "URL cannot be added", 400) } + _ <- Helper.booleanToFuture("URL cannot be added", 400, Some(cc))(added) + } yield SuccessMessage("url added") + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(addCounterpartyUrl), "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/other_accounts/OTHER_ACCOUNT_ID/metadata/url", + "Add url to other bank account", "A url which represents the counterparty (home page url etc.)", + urlJSON, successMessage, + List(AuthenticatedUserIsRequired, BankAccountNotFound, InvalidJsonFormat, "the view does not allow metadata access", "the view does not allow adding a url", "URL cannot be added", UnknownError), + List(apiTagCounterpartyMetaData, apiTagCounterparty), + http4sPartialFunction = Some(addCounterpartyUrl) + ) + + // ─── updateCounterpartyUrl ──────────────────────────────────────────────── + + val updateCounterpartyUrl: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "other_accounts" / otherAccountId / "metadata" / "url" => + EndpointHelpers.withView(req) { (user, account, view, cc) => + val bodyStr = cc.httpBody.getOrElse("") + for { + (otherBankAccount, _) <- NewStyle.function.moderatedOtherBankAccount(account, otherAccountId, view, Full(user), Some(cc)) + _ <- Helper.booleanToFuture(s"$NoViewPermission can_see_other_account_metadata. Current ViewId(${view.viewId})", cc = Some(cc))(otherBankAccount.metadata.isDefined) + _ <- Helper.booleanToFuture(s"the view ${view.viewId} does not allow updating a url", cc = Some(cc))(otherBankAccount.metadata.get.addURL.isDefined) + urlJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { net.liftweb.json.parse(bodyStr).extract[UrlJSON] } + updated <- Future(Counterparties.counterparties.vend.addURL(otherAccountId, urlJson.URL)) map { unboxFullOrFail(_, Some(cc), "URL cannot be updated", 400) } + _ <- Helper.booleanToFuture("URL cannot be updated", 400, Some(cc))(updated) + } yield SuccessMessage("url updated") + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateCounterpartyUrl), "PUT", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/other_accounts/OTHER_ACCOUNT_ID/metadata/url", + "Update url of other bank account", "A url which represents the counterparty (home page url etc.)", + urlJSON, successMessage, + List(AuthenticatedUserIsRequired, BankAccountNotFound, InvalidJsonFormat, NoViewPermission, ViewNotFound, "URL cannot be updated", UnknownError), + List(apiTagCounterpartyMetaData, apiTagCounterparty), + http4sPartialFunction = Some(updateCounterpartyUrl) + ) + + // ─── deleteCounterpartyUrl ──────────────────────────────────────────────── + + val deleteCounterpartyUrl: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "other_accounts" / otherAccountId / "metadata" / "url" => + EndpointHelpers.withUserDelete(req) { (user, cc) => + for { + (account, callContext) <- NewStyle.function.checkBankAccountExists(cc.bankAccount.get.bankId, cc.bankAccount.get.accountId, Some(cc)) + view <- ViewNewStyle.checkViewAccessAndReturnView(cc.view.get.viewId, BankIdAccountId(account.bankId, account.accountId), Full(user), callContext) + (otherBankAccount, _) <- NewStyle.function.moderatedOtherBankAccount(account, otherAccountId, view, Full(user), callContext) + _ <- Helper.booleanToFuture(s"$NoViewPermission can_see_other_account_metadata. Current ViewId(${view.viewId})", cc = callContext)(otherBankAccount.metadata.isDefined) + _ <- Helper.booleanToFuture(s"the view ${view.viewId} does not allow deleting a url", cc = callContext)(otherBankAccount.metadata.get.addURL.isDefined) + deleted <- Future(Counterparties.counterparties.vend.addURL(otherAccountId, "")) map { unboxFullOrFail(_, callContext, "URL cannot be deleted", 400) } + _ <- Helper.booleanToFuture("URL cannot be deleted", 400, callContext)(deleted) + } yield () + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteCounterpartyUrl), "DELETE", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/other_accounts/OTHER_ACCOUNT_ID/metadata/url", + "Delete url of other bank account", "", + EmptyBody, EmptyBody, + List(AuthenticatedUserIsRequired, BankAccountNotFound, "the view does not allow metadata access", "the view does not allow deleting a url", "URL cannot be deleted", UnknownError), + List(apiTagCounterpartyMetaData, apiTagCounterparty), + http4sPartialFunction = Some(deleteCounterpartyUrl) + ) + + // ─── addCounterpartyImageUrl ────────────────────────────────────────────── + + val addCounterpartyImageUrl: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "other_accounts" / otherAccountId / "metadata" / "image_url" => + EndpointHelpers.withViewCreated(req) { (user, account, view, cc) => + val bodyStr = cc.httpBody.getOrElse("") + for { + (otherBankAccount, _) <- NewStyle.function.moderatedOtherBankAccount(account, otherAccountId, view, Full(user), Some(cc)) + _ <- Helper.booleanToFuture(s"$NoViewPermission can_see_other_account_metadata. Current ViewId(${view.viewId})", cc = Some(cc))(otherBankAccount.metadata.isDefined) + _ <- Helper.booleanToFuture(s"the view ${view.viewId} does not allow adding an image url", cc = Some(cc))(otherBankAccount.metadata.get.addImageURL.isDefined) + imageUrlJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { net.liftweb.json.parse(bodyStr).extract[ImageUrlJSON] } + added <- Future(Counterparties.counterparties.vend.addImageURL(otherAccountId, imageUrlJson.image_URL)) map { unboxFullOrFail(_, Some(cc), "URL cannot be added", 400) } + _ <- Helper.booleanToFuture("URL cannot be added", 400, Some(cc))(added) + } yield SuccessMessage("image url added") + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(addCounterpartyImageUrl), "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/other_accounts/OTHER_ACCOUNT_ID/metadata/image_url", + "Add image url to other bank account", "Add a url that points to the logo of the counterparty", + imageUrlJSON, successMessage, + List(AuthenticatedUserIsRequired, BankAccountNotFound, InvalidJsonFormat, "the view does not allow metadata access", "the view does not allow adding an image url", "URL cannot be added", UnknownError), + List(apiTagCounterpartyMetaData, apiTagCounterparty), + http4sPartialFunction = Some(addCounterpartyImageUrl) + ) + + // ─── updateCounterpartyImageUrl ─────────────────────────────────────────── + + val updateCounterpartyImageUrl: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "other_accounts" / otherAccountId / "metadata" / "image_url" => + EndpointHelpers.withView(req) { (user, account, view, cc) => + val bodyStr = cc.httpBody.getOrElse("") + for { + (otherBankAccount, _) <- NewStyle.function.moderatedOtherBankAccount(account, otherAccountId, view, Full(user), Some(cc)) + _ <- Helper.booleanToFuture(s"$NoViewPermission can_see_other_account_metadata. Current ViewId(${view.viewId})", cc = Some(cc))(otherBankAccount.metadata.isDefined) + _ <- Helper.booleanToFuture(s"the view ${view.viewId} does not allow updating an image url", cc = Some(cc))(otherBankAccount.metadata.get.addImageURL.isDefined) + imageUrlJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { net.liftweb.json.parse(bodyStr).extract[ImageUrlJSON] } + updated <- Future(Counterparties.counterparties.vend.addImageURL(otherAccountId, imageUrlJson.image_URL)) map { unboxFullOrFail(_, Some(cc), "URL cannot be updated", 400) } + _ <- Helper.booleanToFuture("URL cannot be updated", 400, Some(cc))(updated) + } yield SuccessMessage("image url updated") + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateCounterpartyImageUrl), "PUT", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/other_accounts/OTHER_ACCOUNT_ID/metadata/image_url", + "Update Counterparty Image Url", "Update the url that points to the logo of the counterparty", + imageUrlJSON, successMessage, + List(AuthenticatedUserIsRequired, BankAccountNotFound, InvalidJsonFormat, "the view does not allow metadata access", "the view does not allow updating an image url", "URL cannot be updated", UnknownError), + List(apiTagCounterpartyMetaData, apiTagCounterparty), + http4sPartialFunction = Some(updateCounterpartyImageUrl) + ) + + // ─── deleteCounterpartyImageUrl ─────────────────────────────────────────── + + val deleteCounterpartyImageUrl: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "other_accounts" / otherAccountId / "metadata" / "image_url" => + EndpointHelpers.withUserDelete(req) { (user, cc) => + for { + (account, callContext) <- NewStyle.function.checkBankAccountExists(cc.bankAccount.get.bankId, cc.bankAccount.get.accountId, Some(cc)) + view <- ViewNewStyle.checkViewAccessAndReturnView(cc.view.get.viewId, BankIdAccountId(account.bankId, account.accountId), Full(user), callContext) + (otherBankAccount, _) <- NewStyle.function.moderatedOtherBankAccount(account, otherAccountId, view, Full(user), callContext) + _ <- Helper.booleanToFuture(s"$NoViewPermission can_see_other_account_metadata. Current ViewId(${view.viewId})", cc = callContext)(otherBankAccount.metadata.isDefined) + _ <- Helper.booleanToFuture(s"the view ${view.viewId} does not allow deleting an image url", cc = callContext)(otherBankAccount.metadata.get.addImageURL.isDefined) + deleted <- Future(Counterparties.counterparties.vend.addImageURL(otherAccountId, "")) map { unboxFullOrFail(_, callContext, "URL cannot be deleted", 400) } + _ <- Helper.booleanToFuture("URL cannot be deleted", 400, callContext)(deleted) + } yield () + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteCounterpartyImageUrl), "DELETE", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/other_accounts/OTHER_ACCOUNT_ID/metadata/image_url", + "Delete Counterparty Image URL", "Delete image url of other bank account", + EmptyBody, EmptyBody, + List(AuthenticatedUserIsRequired, BankAccountNotFound, UnknownError), + List(apiTagCounterpartyMetaData, apiTagCounterparty), + http4sPartialFunction = Some(deleteCounterpartyImageUrl) + ) + + // ─── addCounterpartyOpenCorporatesUrl ───────────────────────────────────── + + val addCounterpartyOpenCorporatesUrl: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "other_accounts" / otherAccountId / "metadata" / "open_corporates_url" => + EndpointHelpers.withViewCreated(req) { (user, account, view, cc) => + val bodyStr = cc.httpBody.getOrElse("") + for { + (otherBankAccount, _) <- NewStyle.function.moderatedOtherBankAccount(account, otherAccountId, view, Full(user), Some(cc)) + _ <- Helper.booleanToFuture(s"$NoViewPermission can_see_other_account_metadata. Current ViewId(${view.viewId})", cc = Some(cc))(otherBankAccount.metadata.isDefined) + _ <- Helper.booleanToFuture(s"the view ${view.viewId} does not allow adding an open corporate url", cc = Some(cc))(otherBankAccount.metadata.get.addOpenCorporatesURL.isDefined) + openCorpUrl <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { net.liftweb.json.parse(bodyStr).extract[OpenCorporateUrlJSON] } + added <- Future(Counterparties.counterparties.vend.addOpenCorporatesURL(otherAccountId, openCorpUrl.open_corporates_URL)) map { unboxFullOrFail(_, Some(cc), "URL cannot be added", 400) } + _ <- Helper.booleanToFuture("URL cannot be added", 400, Some(cc))(added) + } yield SuccessMessage("open corporate url added") + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(addCounterpartyOpenCorporatesUrl), "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/other_accounts/OTHER_ACCOUNT_ID/metadata/open_corporates_url", + "Add Open Corporates URL to Counterparty", "Add open corporates url to other bank account", + openCorporateUrlJSON, successMessage, + List(AuthenticatedUserIsRequired, BankAccountNotFound, InvalidJsonFormat, "the view does not allow metadata access", "the view does not allow adding an open corporate url", "URL cannot be added", UnknownError), + List(apiTagCounterpartyMetaData, apiTagCounterparty), + http4sPartialFunction = Some(addCounterpartyOpenCorporatesUrl) + ) + + // ─── updateCounterpartyOpenCorporatesUrl ────────────────────────────────── + + val updateCounterpartyOpenCorporatesUrl: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "other_accounts" / otherAccountId / "metadata" / "open_corporates_url" => + EndpointHelpers.withView(req) { (user, account, view, cc) => + val bodyStr = cc.httpBody.getOrElse("") + for { + (otherBankAccount, _) <- NewStyle.function.moderatedOtherBankAccount(account, otherAccountId, view, Full(user), Some(cc)) + _ <- Helper.booleanToFuture(s"$NoViewPermission can_see_other_account_metadata. Current ViewId(${view.viewId})", cc = Some(cc))(otherBankAccount.metadata.isDefined) + _ <- Helper.booleanToFuture(s"the view ${view.viewId} does not allow updating an open corporate url", cc = Some(cc))(otherBankAccount.metadata.get.addOpenCorporatesURL.isDefined) + openCorpUrl <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { net.liftweb.json.parse(bodyStr).extract[OpenCorporateUrlJSON] } + updated <- Future(Counterparties.counterparties.vend.addOpenCorporatesURL(otherAccountId, openCorpUrl.open_corporates_URL)) map { unboxFullOrFail(_, Some(cc), "URL cannot be updated", 400) } + _ <- Helper.booleanToFuture("URL cannot be updated", 400, Some(cc))(updated) + } yield SuccessMessage("open corporate url updated") + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateCounterpartyOpenCorporatesUrl), "PUT", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/other_accounts/OTHER_ACCOUNT_ID/metadata/open_corporates_url", + "Update Open Corporates Url of Counterparty", "Update open corporate url of other bank account", + openCorporateUrlJSON, successMessage, + List(AuthenticatedUserIsRequired, BankAccountNotFound, InvalidJsonFormat, "the view does not allow metadata access", "the view does not allow updating an open corporate url", "URL cannot be updated", UnknownError), + List(apiTagCounterpartyMetaData, apiTagCounterparty), + http4sPartialFunction = Some(updateCounterpartyOpenCorporatesUrl) + ) + + // ─── deleteCounterpartyOpenCorporatesUrl ────────────────────────────────── + + val deleteCounterpartyOpenCorporatesUrl: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "other_accounts" / otherAccountId / "metadata" / "open_corporates_url" => + EndpointHelpers.withUserDelete(req) { (user, cc) => + for { + (account, callContext) <- NewStyle.function.checkBankAccountExists(cc.bankAccount.get.bankId, cc.bankAccount.get.accountId, Some(cc)) + view <- ViewNewStyle.checkViewAccessAndReturnView(cc.view.get.viewId, BankIdAccountId(account.bankId, account.accountId), Full(user), callContext) + (otherBankAccount, _) <- NewStyle.function.moderatedOtherBankAccount(account, otherAccountId, view, Full(user), callContext) + _ <- Helper.booleanToFuture(s"$NoViewPermission can_see_other_account_metadata. Current ViewId(${view.viewId})", cc = callContext)(otherBankAccount.metadata.isDefined) + _ <- Helper.booleanToFuture(s"the view ${view.viewId} does not allow deleting an open corporate url", cc = callContext)(otherBankAccount.metadata.get.addOpenCorporatesURL.isDefined) + deleted <- Future(Counterparties.counterparties.vend.addOpenCorporatesURL(otherAccountId, "")) map { unboxFullOrFail(_, callContext, "URL cannot be deleted", 400) } + _ <- Helper.booleanToFuture("URL cannot be deleted", 400, callContext)(deleted) + } yield () + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteCounterpartyOpenCorporatesUrl), "DELETE", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/other_accounts/OTHER_ACCOUNT_ID/metadata/open_corporates_url", + "Delete Counterparty Open Corporates URL", "Delete open corporate url of other bank account", + EmptyBody, EmptyBody, + List(AuthenticatedUserIsRequired, BankAccountNotFound, "the view does not allow metadata access", "the view does not allow deleting an open corporate url", "URL cannot be deleted", UnknownError), + List(apiTagCounterpartyMetaData, apiTagCounterparty), + http4sPartialFunction = Some(deleteCounterpartyOpenCorporatesUrl) + ) + + // ─── addCounterpartyCorporateLocation ──────────────────────────────────── + + val addCounterpartyCorporateLocation: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "other_accounts" / otherAccountId / "metadata" / "corporate_location" => + EndpointHelpers.withViewCreated(req) { (user, account, view, cc) => + val bodyStr = cc.httpBody.getOrElse("") + for { + (otherBankAccount, _) <- NewStyle.function.moderatedOtherBankAccount(account, otherAccountId, view, Full(user), Some(cc)) + _ <- Helper.booleanToFuture(s"$NoViewPermission can_see_other_account_metadata. Current ViewId(${view.viewId})", cc = Some(cc))(otherBankAccount.metadata.isDefined) + _ <- Helper.booleanToFuture(s"the view ${view.viewId} does not allow adding a corporate location", cc = Some(cc))(otherBankAccount.metadata.get.addCorporateLocation.isDefined) + corpLocationJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { net.liftweb.json.parse(bodyStr).extract[CorporateLocationJSON] } + _ <- Helper.booleanToFuture("Coordinates not possible", 400, Some(cc)) { checkIfLocationPossible(corpLocationJson.corporate_location.latitude, corpLocationJson.corporate_location.longitude) } + added <- Future(Counterparties.counterparties.vend.addCorporateLocation(otherAccountId, user.userPrimaryKey, (now: TimeSpan), corpLocationJson.corporate_location.longitude, corpLocationJson.corporate_location.latitude)) map { unboxFullOrFail(_, Some(cc), "Corporate Location cannot be added", 400) } + _ <- Helper.booleanToFuture("Corporate Location cannot be added", 400, Some(cc))(added) + } yield SuccessMessage("corporate location added") + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(addCounterpartyCorporateLocation), "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/other_accounts/OTHER_ACCOUNT_ID/metadata/corporate_location", + "Add Corporate Location to Counterparty", "Add the geolocation of the counterparty's registered address", + corporateLocationJSON, successMessage, + List(AuthenticatedUserIsRequired, BankAccountNotFound, "the view does not allow metadata access", "the view does not allow adding a corporate location", "Coordinates not possible", "Corporate Location cannot be deleted", UnknownError), + List(apiTagCounterpartyMetaData, apiTagCounterparty), + http4sPartialFunction = Some(addCounterpartyCorporateLocation) + ) + + // ─── updateCounterpartyCorporateLocation ────────────────────────────────── + + val updateCounterpartyCorporateLocation: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "other_accounts" / otherAccountId / "metadata" / "corporate_location" => + EndpointHelpers.withView(req) { (user, account, view, cc) => + val bodyStr = cc.httpBody.getOrElse("") + for { + (otherBankAccount, _) <- NewStyle.function.moderatedOtherBankAccount(account, otherAccountId, view, Full(user), Some(cc)) + _ <- Helper.booleanToFuture(s"$NoViewPermission can_see_other_account_metadata. Current ViewId(${view.viewId})", cc = Some(cc))(otherBankAccount.metadata.isDefined) + _ <- Helper.booleanToFuture(s"the view ${view.viewId} does not allow updating a corporate location", cc = Some(cc))(otherBankAccount.metadata.get.addCorporateLocation.isDefined) + corpLocationJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { net.liftweb.json.parse(bodyStr).extract[CorporateLocationJSON] } + _ <- Helper.booleanToFuture("Coordinates not possible", 400, Some(cc)) { checkIfLocationPossible(corpLocationJson.corporate_location.latitude, corpLocationJson.corporate_location.longitude) } + updated <- Future(Counterparties.counterparties.vend.addCorporateLocation(otherAccountId, user.userPrimaryKey, (now: TimeSpan), corpLocationJson.corporate_location.longitude, corpLocationJson.corporate_location.latitude)) map { unboxFullOrFail(_, Some(cc), "Corporate Location cannot be updated", 400) } + _ <- Helper.booleanToFuture("Corporate Location cannot be updated", 400, Some(cc))(updated) + } yield SuccessMessage("corporate location updated") + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateCounterpartyCorporateLocation), "PUT", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/other_accounts/OTHER_ACCOUNT_ID/metadata/corporate_location", + "Update Counterparty Corporate Location", "Update the geolocation of the counterparty's registered address", + corporateLocationJSON, successMessage, + List(AuthenticatedUserIsRequired, BankAccountNotFound, InvalidJsonFormat, "the view does not allow metadata access", "the view does not allow updating a corporate location", "Coordinates not possible", "Corporate Location cannot be updated", UnknownError), + List(apiTagCounterpartyMetaData, apiTagCounterparty), + http4sPartialFunction = Some(updateCounterpartyCorporateLocation) + ) + + // ─── deleteCounterpartyCorporateLocation ────────────────────────────────── + + val deleteCounterpartyCorporateLocation: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "other_accounts" / otherAccountId / "metadata" / "corporate_location" => + EndpointHelpers.withUserDelete(req) { (user, cc) => + for { + (account, callContext) <- NewStyle.function.checkBankAccountExists(cc.bankAccount.get.bankId, cc.bankAccount.get.accountId, Some(cc)) + view <- ViewNewStyle.checkViewAccessAndReturnView(cc.view.get.viewId, BankIdAccountId(account.bankId, account.accountId), Full(user), callContext) + (otherBankAccount, _) <- NewStyle.function.moderatedOtherBankAccount(account, otherAccountId, view, Full(user), callContext) + _ <- Helper.booleanToFuture(s"$NoViewPermission can_see_other_account_metadata. Current ViewId(${view.viewId})", cc = callContext)(otherBankAccount.metadata.isDefined) + _ <- Helper.booleanToFuture(s"the view ${view.viewId} does not allow deleting a Corporate Location", cc = callContext)(otherBankAccount.metadata.get.deleteCorporateLocation.isDefined) + deleted <- Future(Counterparties.counterparties.vend.deleteCorporateLocation(otherAccountId)) map { unboxFullOrFail(_, callContext, "Corporate Location cannot be deleted", 400) } + _ <- Helper.booleanToFuture("Delete not completed", cc = callContext)(deleted) + } yield () + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteCounterpartyCorporateLocation), "DELETE", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/other_accounts/OTHER_ACCOUNT_ID/metadata/corporate_location", + "Delete Counterparty Corporate Location", "Delete corporate location of other bank account. Delete the geolocation of the counterparty's registered address", + EmptyBody, EmptyBody, + List(AuthenticatedUserIsRequired, BankAccountNotFound, "the view does not allow metadata access", "Corporate Location cannot be deleted", "Delete not completed", UnknownError), + List(apiTagCounterpartyMetaData, apiTagCounterparty), + http4sPartialFunction = Some(deleteCounterpartyCorporateLocation) + ) + + // ─── addCounterpartyPhysicalLocation ────────────────────────────────────── + + val addCounterpartyPhysicalLocation: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "other_accounts" / otherAccountId / "metadata" / "physical_location" => + EndpointHelpers.withViewCreated(req) { (user, account, view, cc) => + val bodyStr = cc.httpBody.getOrElse("") + for { + (otherBankAccount, _) <- NewStyle.function.moderatedOtherBankAccount(account, otherAccountId, view, Full(user), Some(cc)) + _ <- Helper.booleanToFuture(s"$NoViewPermission can_see_other_account_metadata. Current ViewId(${view.viewId})", cc = Some(cc))(otherBankAccount.metadata.isDefined) + _ <- Helper.booleanToFuture(s"the view ${view.viewId} does not allow adding a physical location", cc = Some(cc))(otherBankAccount.metadata.get.addPhysicalLocation.isDefined) + physicalLocationJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { net.liftweb.json.parse(bodyStr).extract[PhysicalLocationJSON] } + _ <- Helper.booleanToFuture("Coordinates not possible", 400, Some(cc)) { checkIfLocationPossible(physicalLocationJson.physical_location.latitude, physicalLocationJson.physical_location.longitude) } + added <- Future(Counterparties.counterparties.vend.addPhysicalLocation(otherAccountId, user.userPrimaryKey, (now: TimeSpan), physicalLocationJson.physical_location.longitude, physicalLocationJson.physical_location.latitude)) map { unboxFullOrFail(_, Some(cc), "Physical Location cannot be added", 400) } + _ <- Helper.booleanToFuture("Physical Location cannot be added", 400, Some(cc))(added) + } yield SuccessMessage("physical location added") + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(addCounterpartyPhysicalLocation), "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/other_accounts/OTHER_ACCOUNT_ID/metadata/physical_location", + "Add physical location to other bank account", "Add geocoordinates of the counterparty's main location", + physicalLocationJSON, successMessage, + List(AuthenticatedUserIsRequired, BankAccountNotFound, InvalidJsonFormat, "the view does not allow metadata access", "the view does not allow adding a physical location", "Coordinates not possible", "Physical Location cannot be added", UnknownError), + List(apiTagCounterpartyMetaData, apiTagCounterparty), + http4sPartialFunction = Some(addCounterpartyPhysicalLocation) + ) + + // ─── updateCounterpartyPhysicalLocation ─────────────────────────────────── + + val updateCounterpartyPhysicalLocation: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "other_accounts" / otherAccountId / "metadata" / "physical_location" => + EndpointHelpers.withView(req) { (user, account, view, cc) => + val bodyStr = cc.httpBody.getOrElse("") + for { + (otherBankAccount, _) <- NewStyle.function.moderatedOtherBankAccount(account, otherAccountId, view, Full(user), Some(cc)) + _ <- Helper.booleanToFuture(s"$NoViewPermission can_see_other_account_metadata. Current ViewId(${view.viewId})", cc = Some(cc))(otherBankAccount.metadata.isDefined) + _ <- Helper.booleanToFuture(s"the view ${view.viewId} does not allow updating a physical location", cc = Some(cc))(otherBankAccount.metadata.get.addPhysicalLocation.isDefined) + physicalLocationJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { net.liftweb.json.parse(bodyStr).extract[PhysicalLocationJSON] } + _ <- Helper.booleanToFuture("Coordinates not possible", 400, Some(cc)) { checkIfLocationPossible(physicalLocationJson.physical_location.latitude, physicalLocationJson.physical_location.longitude) } + updated <- Future(Counterparties.counterparties.vend.addPhysicalLocation(otherAccountId, user.userPrimaryKey, (now: TimeSpan), physicalLocationJson.physical_location.longitude, physicalLocationJson.physical_location.latitude)) map { unboxFullOrFail(_, Some(cc), "Physical Location cannot be updated", 400) } + _ <- Helper.booleanToFuture("Physical Location cannot be updated", 400, Some(cc))(updated) + } yield SuccessMessage("physical location updated") + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateCounterpartyPhysicalLocation), "PUT", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/other_accounts/OTHER_ACCOUNT_ID/metadata/physical_location", + "Update Counterparty Physical Location", "Update geocoordinates of the counterparty's main location", + physicalLocationJSON, successMessage, + List(AuthenticatedUserIsRequired, BankAccountNotFound, InvalidJsonFormat, "the view does not allow metadata access", "the view does not allow updating a physical location", "Coordinates not possible", "Physical Location cannot be updated", UnknownError), + List(apiTagCounterpartyMetaData, apiTagCounterparty), + http4sPartialFunction = Some(updateCounterpartyPhysicalLocation) + ) + + // ─── deleteCounterpartyPhysicalLocation ─────────────────────────────────── + + val deleteCounterpartyPhysicalLocation: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "other_accounts" / otherAccountId / "metadata" / "physical_location" => + EndpointHelpers.withUserDelete(req) { (user, cc) => + for { + (account, callContext) <- NewStyle.function.checkBankAccountExists(cc.bankAccount.get.bankId, cc.bankAccount.get.accountId, Some(cc)) + view <- ViewNewStyle.checkViewAccessAndReturnView(cc.view.get.viewId, BankIdAccountId(account.bankId, account.accountId), Full(user), callContext) + (otherBankAccount, _) <- NewStyle.function.moderatedOtherBankAccount(account, otherAccountId, view, Full(user), callContext) + _ <- Helper.booleanToFuture(s"$NoViewPermission can_see_other_account_metadata. Current ViewId(${view.viewId})", cc = callContext)(otherBankAccount.metadata.isDefined) + _ <- Helper.booleanToFuture(s"the view ${view.viewId} does not allow deleting a Physical Location", cc = callContext)(otherBankAccount.metadata.get.deletePhysicalLocation.isDefined) + deleted <- Future(Counterparties.counterparties.vend.deletePhysicalLocation(otherAccountId)) map { unboxFullOrFail(_, callContext, "Physical Location cannot be deleted", 400) } + _ <- Helper.booleanToFuture("Delete not completed", cc = callContext)(deleted) + } yield () + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteCounterpartyPhysicalLocation), "DELETE", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/other_accounts/OTHER_ACCOUNT_ID/metadata/physical_location", + "Delete Counterparty Physical Location", "Delete physical location of other bank account", + EmptyBody, EmptyBody, + List(AuthenticatedUserIsRequired, BankAccountNotFound, NoViewPermission, "Physical Location cannot be deleted", "Delete not completed", UnknownError), + List(apiTagCounterpartyMetaData, apiTagCounterparty), + http4sPartialFunction = Some(deleteCounterpartyPhysicalLocation) + ) + + // ─── getTransactionsForBankAccount ─────────────────────────────────────── + + val getTransactionsForBankAccount: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankId / "accounts" / accountId / viewId / "transactions" => + EndpointHelpers.executeAndRespond(req) { cc => + val httpParams: List[HTTPParam] = req.headers.headers.toList.map(h => HTTPParam(h.name.toString, h.value)) + for { + (rawAccountBox, callContext) <- Connector.connector.vend.checkBankAccountExists(BankId(bankId), AccountId(accountId), Some(cc)) + account <- Future { unboxFullOrFail(rawAccountBox, callContext, s"$BankAccountNotFound Current BankId is $bankId and Current AccountId is $accountId") } + view <- ViewNewStyle.checkViewAccessAndReturnView(ViewId(viewId), BankIdAccountId(BankId(bankId), AccountId(accountId)), cc.user.toOption, callContext) + (bank, callContext2) <- NewStyle.function.getBank(BankId(bankId), callContext) + (params, callContext3) <- createQueriesByHttpParamsFuture(httpParams, callContext2) + (transactions, _) <- account.getModeratedTransactionsFuture(bank, cc.user, view, callContext3, params) map { + unboxFullOrFail(_, callContext3, GetTransactionsException) + } + } yield JSONFactory.createTransactionsJSON(transactions) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getTransactionsForBankAccount), "GET", + "/banks/BANK_ID/accounts/BANK_ACCOUNT_ID/TRANSACTIONS_VIEW_ID/transactions", + "Get Transactions for Account (Full)", + s"""Returns transactions list of the account specified by ACCOUNT_ID and moderated by the view (VIEW_ID). + | + |Authentication via OAuth is required if the view is not public. + | + |${urlParametersDocument(true, true)} + |""", + EmptyBody, transactionsJSON, + List(BankAccountNotFound, UnknownError), + List(apiTagTransaction, apiTagAccount, apiTagPsd2, apiTagOldStyle), + http4sPartialFunction = Some(getTransactionsForBankAccount) + ) + + // ─── getTransactionByIdForBankAccount ───────────────────────────────────── + + val getTransactionByIdForBankAccount: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankId / "accounts" / accountId / viewId / "transactions" / transactionId / "transaction" => + EndpointHelpers.executeAndRespond(req) { cc => + for { + (rawAccountBox, callContext) <- Connector.connector.vend.checkBankAccountExists(BankId(bankId), AccountId(accountId), Some(cc)) + account <- Future { unboxFullOrFail(rawAccountBox, callContext, s"$BankAccountNotFound Current BankId is $bankId and Current AccountId is $accountId") } + view <- ViewNewStyle.checkViewAccessAndReturnView(ViewId(viewId), BankIdAccountId(BankId(bankId), AccountId(accountId)), cc.user.toOption, callContext) + (moderatedTransaction, _) <- account.moderatedTransactionFuture(TransactionId(transactionId), view, cc.user, callContext) map { + unboxFullOrFail(_, callContext, GetTransactionsException) + } + } yield JSONFactory.createTransactionJSON(moderatedTransaction) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getTransactionByIdForBankAccount), "GET", + "/banks/BANK_ID/accounts/BANK_ACCOUNT_ID/TRANSACTIONS_VIEW_ID/transactions/TRANSACTION_ID/transaction", + "Get Transaction by Id", + """Returns one transaction specified by TRANSACTION_ID of the account ACCOUNT_ID and moderated by the view (VIEW_ID). + | + |Authentication is required if the view is not public. + |""", + EmptyBody, transactionJSON, + List(BankAccountNotFound, UnknownError), + List(apiTagTransaction, apiTagPsd2, apiTagOldStyle), + http4sPartialFunction = Some(getTransactionByIdForBankAccount) + ) + + // ─── getTransactionNarrative ────────────────────────────────────────────── + + val getTransactionNarrative: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "transactions" / transactionId / "metadata" / "narrative" => + EndpointHelpers.withView(req) { (user, account, view, cc) => + for { + metadata <- moderatedTransactionMetadataFuture(account.bankId, account.accountId, view.viewId, TransactionId(transactionId), Full(user), Some(cc)) + narrative <- Future(metadata.ownerComment) map { + unboxFullOrFail(_, Some(cc), s"$NoViewPermission can_see_owner_comment. Current ViewId(${view.viewId})") + } + } yield JSONFactory.createTransactionNarrativeJSON(narrative) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getTransactionNarrative), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transactions/TRANSACTION_ID/metadata/narrative", + "Get a Transaction Narrative", + """Returns the account owner description of the transaction moderated by the view. + | + |Authentication via OAuth is required if the view is not public.""", + EmptyBody, transactionNarrativeJSON, + List(AuthenticatedUserIsRequired, BankAccountNotFound, NoViewPermission, ViewNotFound, UnknownError), + List(apiTagTransactionMetaData, apiTagTransaction), + http4sPartialFunction = Some(getTransactionNarrative) + ) + + // ─── addTransactionNarrative ────────────────────────────────────────────── + + val addTransactionNarrative: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "transactions" / transactionId / "metadata" / "narrative" => + EndpointHelpers.executeFutureCreated(req) { + val cc = req.callContext + val bodyStr = cc.httpBody.getOrElse("") + for { + user <- cc.user match { + case Full(u) => Future.successful(u) + case _ => Future.failed(new RuntimeException(AuthenticatedUserIsRequired)) + } + account <- cc.bankAccount match { + case Some(a) => Future.successful(a) + case None => Future.failed(new RuntimeException(BankAccountNotFound)) + } + view <- cc.view match { + case Some(v) => Future.successful(v) + case None => Future.failed(new RuntimeException(ViewNotFound)) + } + narrativeJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + net.liftweb.json.parse(bodyStr).extract[TransactionNarrativeJSON] + } + metadata <- moderatedTransactionMetadataFuture(account.bankId, account.accountId, view.viewId, TransactionId(transactionId), Full(user), Some(cc)) + addNarrative <- Future(metadata.addOwnerComment) map { + unboxFullOrFail(_, Some(cc), s"$NoViewPermission can_add_owner_comment. Current ViewId(${view.viewId})") + } + } yield { + addNarrative(narrativeJson.narrative) + SuccessMessage("narrative added") + } + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(addTransactionNarrative), "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transactions/TRANSACTION_ID/metadata/narrative", + "Add a Transaction Narrative", + """Creates a description of the transaction TRANSACTION_ID. + | + |Authentication is required if the view is not public. + |""", + transactionNarrativeJSON, successMessage, + List(AuthenticatedUserIsRequired, InvalidJsonFormat, BankAccountNotFound, NoViewPermission, ViewNotFound, UnknownError), + List(apiTagTransactionMetaData, apiTagTransaction), + http4sPartialFunction = Some(addTransactionNarrative) + ) + + // ─── updateTransactionNarrative ─────────────────────────────────────────── + + val updateTransactionNarrative: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "transactions" / transactionId / "metadata" / "narrative" => + EndpointHelpers.executeFuture(req) { + val cc = req.callContext + val bodyStr = cc.httpBody.getOrElse("") + for { + user <- cc.user match { + case Full(u) => Future.successful(u) + case _ => Future.failed(new RuntimeException(AuthenticatedUserIsRequired)) + } + account <- cc.bankAccount match { + case Some(a) => Future.successful(a) + case None => Future.failed(new RuntimeException(BankAccountNotFound)) + } + view <- cc.view match { + case Some(v) => Future.successful(v) + case None => Future.failed(new RuntimeException(ViewNotFound)) + } + narrativeJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + net.liftweb.json.parse(bodyStr).extract[TransactionNarrativeJSON] + } + metadata <- moderatedTransactionMetadataFuture(account.bankId, account.accountId, view.viewId, TransactionId(transactionId), Full(user), Some(cc)) + addNarrative <- Future(metadata.addOwnerComment) map { + unboxFullOrFail(_, Some(cc), s"$NoViewPermission can_add_owner_comment. Current ViewId(${view.viewId})") + } + } yield { + addNarrative(narrativeJson.narrative) + SuccessMessage("narrative updated") + } + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateTransactionNarrative), "PUT", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transactions/TRANSACTION_ID/metadata/narrative", + "Update a Transaction Narrative", + """Updates the description of the transaction TRANSACTION_ID. + | + |Authentication via OAuth is required if the view is not public.""", + transactionNarrativeJSON, successMessage, + List(AuthenticatedUserIsRequired, InvalidJsonFormat, BankAccountNotFound, NoViewPermission, ViewNotFound, UnknownError), + List(apiTagTransactionMetaData, apiTagTransaction), + http4sPartialFunction = Some(updateTransactionNarrative) + ) + + // ─── deleteTransactionNarrative ─────────────────────────────────────────── + + val deleteTransactionNarrative: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "transactions" / transactionId / "metadata" / "narrative" => + EndpointHelpers.withUserDelete(req) { (user, cc) => + val bankAccount = cc.bankAccount.get + val view = cc.view.get + for { + metadata <- moderatedTransactionMetadataFuture(bankAccount.bankId, bankAccount.accountId, view.viewId, TransactionId(transactionId), Full(user), Some(cc)) + addNarrative <- Future(metadata.addOwnerComment) map { + unboxFullOrFail(_, Some(cc), s"$NoViewPermission can_see_owner_comment. Current ViewId(${view.viewId})") + } + } yield addNarrative("") + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteTransactionNarrative), "DELETE", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transactions/TRANSACTION_ID/metadata/narrative", + "Delete a Transaction Narrative", + """Deletes the description of the transaction TRANSACTION_ID. + | + |Authentication via OAuth is required if the view is not public.""", + EmptyBody, EmptyBody, + List(AuthenticatedUserIsRequired, BankAccountNotFound, NoViewPermission, UnknownError), + List(apiTagTransactionMetaData, apiTagTransaction), + http4sPartialFunction = Some(deleteTransactionNarrative) + ) + + // ─── getCommentsForViewOnTransaction ───────────────────────────────────── + + val getCommentsForViewOnTransaction: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "transactions" / transactionId / "metadata" / "comments" => + EndpointHelpers.withView(req) { (user, account, view, cc) => + for { + metadata <- moderatedTransactionMetadataFuture(account.bankId, account.accountId, view.viewId, TransactionId(transactionId), Full(user), Some(cc)) + comments <- Future(metadata.comments) map { + unboxFullOrFail(_, Some(cc), s"$NoViewPermission can_see_owner_comment. Current ViewId(${view.viewId})") + } + } yield JSONFactory.createTransactionCommentsJSON(comments) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCommentsForViewOnTransaction), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transactions/TRANSACTION_ID/metadata/comments", + "Get Transaction Comments", + """Returns the transaction TRANSACTION_ID comments made on a view (VIEW_ID). + | + |Authentication via OAuth is required if the view is not public.""", + EmptyBody, transactionCommentsJSON, + List(AuthenticatedUserIsRequired, BankAccountNotFound, NoViewPermission, ViewNotFound, UnknownError), + List(apiTagTransactionMetaData, apiTagTransaction), + http4sPartialFunction = Some(getCommentsForViewOnTransaction) + ) + + // ─── addCommentForViewOnTransaction ─────────────────────────────────────── + + val addCommentForViewOnTransaction: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "transactions" / transactionId / "metadata" / "comments" => + EndpointHelpers.executeFutureCreated(req) { + val cc = req.callContext + val bodyStr = cc.httpBody.getOrElse("") + for { + user <- cc.user match { + case Full(u) => Future.successful(u) + case _ => Future.failed(new RuntimeException(AuthenticatedUserIsRequired)) + } + account <- cc.bankAccount match { + case Some(a) => Future.successful(a) + case None => Future.failed(new RuntimeException(BankAccountNotFound)) + } + view <- cc.view match { + case Some(v) => Future.successful(v) + case None => Future.failed(new RuntimeException(ViewNotFound)) + } + commentJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + net.liftweb.json.parse(bodyStr).extract[PostTransactionCommentJSON] + } + metadata <- moderatedTransactionMetadataFuture(account.bankId, account.accountId, view.viewId, TransactionId(transactionId), Full(user), Some(cc)) + addCommentFunc <- Future(metadata.addComment) map { + unboxFullOrFail(_, Some(cc), s"$NoViewPermission can_see_owner_comment. Current ViewId(${view.viewId})") + } + postedComment <- Future(addCommentFunc(user.userPrimaryKey, view.viewId, commentJson.value, now)) map { + unboxFullOrFail(_, Some(cc), s"Cannot add the comment ${commentJson.value}") + } + } yield JSONFactory.createTransactionCommentJSON(postedComment) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(addCommentForViewOnTransaction), "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transactions/TRANSACTION_ID/metadata/comments", + "Add a Transaction Comment", + """Posts a comment about a transaction TRANSACTION_ID on a view VIEW_ID. + | + |Authentication is required since the comment is linked with the user.""", + postTransactionCommentJSON, transactionCommentJSON, + List(AuthenticatedUserIsRequired, InvalidJsonFormat, BankAccountNotFound, NoViewPermission, ViewNotFound, UnknownError), + List(apiTagTransactionMetaData, apiTagTransaction), + http4sPartialFunction = Some(addCommentForViewOnTransaction) + ) + + // ─── deleteCommentForViewOnTransaction ──────────────────────────────────── + + val deleteCommentForViewOnTransaction: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "transactions" / transactionId / "metadata" / "comments" / commentId => + EndpointHelpers.withUserDelete(req) { (user, cc) => + val bankAccount = cc.bankAccount.get + val view = cc.view.get + for { + (account, callContext) <- NewStyle.function.checkBankAccountExists(bankAccount.bankId, bankAccount.accountId, Some(cc)) + metadata <- moderatedTransactionMetadataFuture(account.bankId, account.accountId, view.viewId, TransactionId(transactionId), Full(user), callContext) + _ <- Future(metadata.deleteComment(commentId, Some(user), account, view, callContext)) map { + unboxFullOrFail(_, callContext, "Comment could not be deleted") + } + } yield () + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteCommentForViewOnTransaction), "DELETE", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transactions/TRANSACTION_ID/metadata/comments/COMMENT_ID", + "Delete a Transaction Comment", + """Delete the comment COMMENT_ID about the transaction TRANSACTION_ID made on a view. + | + |Authentication via OAuth is required. The user must either have owner privileges for this account, or must be the user that posted the comment.""", + EmptyBody, EmptyBody, + List(BankAccountNotFound, NoViewPermission, ViewNotFound, AuthenticatedUserIsRequired, UnknownError), + List(apiTagTransactionMetaData, apiTagTransaction), + http4sPartialFunction = Some(deleteCommentForViewOnTransaction) + ) + + // ─── getTagsForViewOnTransaction ───────────────────────────────────────── + + val getTagsForViewOnTransaction: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "transactions" / transactionId / "metadata" / "tags" => + EndpointHelpers.withView(req) { (user, account, view, cc) => + for { + metadata <- moderatedTransactionMetadataFuture(account.bankId, account.accountId, view.viewId, TransactionId(transactionId), Full(user), Some(cc)) + tags <- Future(metadata.tags) map { + unboxFullOrFail(_, Some(cc), s"$NoViewPermission can_see_owner_comment. Current ViewId(${view.viewId})") + } + } yield JSONFactory.createTransactionTagsJSON(tags) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getTagsForViewOnTransaction), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transactions/TRANSACTION_ID/metadata/tags", + "Get Transaction Tags", + """Returns the transaction TRANSACTION_ID tags made on a view (VIEW_ID). + |Authentication via OAuth is required if the view is not public.""", + EmptyBody, transactionTagJSON, + List(AuthenticatedUserIsRequired, BankAccountNotFound, NoViewPermission, ViewNotFound, UnknownError), + List(apiTagTransactionMetaData, apiTagTransaction), + http4sPartialFunction = Some(getTagsForViewOnTransaction) + ) + + // ─── addTagForViewOnTransaction ─────────────────────────────────────────── + + val addTagForViewOnTransaction: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "transactions" / transactionId / "metadata" / "tags" => + EndpointHelpers.executeFutureCreated(req) { + val cc = req.callContext + val bodyStr = cc.httpBody.getOrElse("") + for { + user <- cc.user match { + case Full(u) => Future.successful(u) + case _ => Future.failed(new RuntimeException(AuthenticatedUserIsRequired)) + } + account <- cc.bankAccount match { + case Some(a) => Future.successful(a) + case None => Future.failed(new RuntimeException(BankAccountNotFound)) + } + view <- cc.view match { + case Some(v) => Future.successful(v) + case None => Future.failed(new RuntimeException(ViewNotFound)) + } + tagJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + net.liftweb.json.parse(bodyStr).extract[PostTransactionTagJSON] + } + metadata <- moderatedTransactionMetadataFuture(account.bankId, account.accountId, view.viewId, TransactionId(transactionId), Full(user), Some(cc)) + addTagFunc <- Future(metadata.addTag) map { + unboxFullOrFail(_, Some(cc), s"$NoViewPermission can_see_owner_comment. Current ViewId(${view.viewId})") + } + postedTag <- Future(addTagFunc(user.userPrimaryKey, view.viewId, tagJson.value, now)) map { + unboxFullOrFail(_, Some(cc), s"Cannot add the tag ${tagJson.value}") + } + } yield JSONFactory.createTransactionTagJSON(postedTag) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(addTagForViewOnTransaction), "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transactions/TRANSACTION_ID/metadata/tags", + "Add a Transaction Tag", + s"""Posts a tag about a transaction TRANSACTION_ID on a view VIEW_ID. + | + |${userAuthenticationMessage(true)} + | + |Authentication is required as the tag is linked with the user.""", + postTransactionTagJSON, transactionTagJSON, + List(AuthenticatedUserIsRequired, BankAccountNotFound, InvalidJsonFormat, NoViewPermission, ViewNotFound, UnknownError), + List(apiTagTransactionMetaData, apiTagTransaction), + http4sPartialFunction = Some(addTagForViewOnTransaction) + ) + + // ─── deleteTagForViewOnTransaction ──────────────────────────────────────── + + val deleteTagForViewOnTransaction: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "transactions" / transactionId / "metadata" / "tags" / tagId => + EndpointHelpers.withUserDelete(req) { (user, cc) => + val bankAccount = cc.bankAccount.get + val view = cc.view.get + for { + (account, callContext) <- NewStyle.function.checkBankAccountExists(bankAccount.bankId, bankAccount.accountId, Some(cc)) + metadata <- moderatedTransactionMetadataFuture(account.bankId, account.accountId, view.viewId, TransactionId(transactionId), Full(user), callContext) + _ <- Future(metadata.deleteTag(tagId, Some(user), account, view, callContext)) map { + unboxFullOrFail(_, callContext, "Tag could not be deleted") + } + } yield () + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteTagForViewOnTransaction), "DELETE", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transactions/TRANSACTION_ID/metadata/tags/TAG_ID", + "Delete a Transaction Tag", + """Deletes the tag TAG_ID about the transaction TRANSACTION_ID made on a view. + |Authentication via OAuth is required. The user must either have owner privileges for this account, + |or must be the user that posted the tag.""", + EmptyBody, EmptyBody, + List(AuthenticatedUserIsRequired, NoViewPermission, ViewNotFound, UnknownError), + List(apiTagTransactionMetaData, apiTagTransaction), + http4sPartialFunction = Some(deleteTagForViewOnTransaction) + ) + + // ─── getImagesForViewOnTransaction ──────────────────────────────────────── + + val getImagesForViewOnTransaction: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "transactions" / transactionId / "metadata" / "images" => + EndpointHelpers.withView(req) { (user, account, view, cc) => + for { + metadata <- moderatedTransactionMetadataFuture(account.bankId, account.accountId, view.viewId, TransactionId(transactionId), Full(user), Some(cc)) + images <- Future(metadata.images) map { + unboxFullOrFail(_, Some(cc), s"$NoViewPermission can_see_owner_comment. Current ViewId(${view.viewId})") + } + } yield JSONFactory.createTransactionImagesJSON(images) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getImagesForViewOnTransaction), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transactions/TRANSACTION_ID/metadata/images", + "Get Transaction Images", + """Returns the transaction TRANSACTION_ID images made on a view (VIEW_ID). + |Authentication via OAuth is required if the view is not public.""", + EmptyBody, transactionImagesJSON, + List(AuthenticatedUserIsRequired, BankAccountNotFound, NoViewPermission, ViewNotFound, UnknownError), + List(apiTagTransactionMetaData, apiTagTransaction), + http4sPartialFunction = Some(getImagesForViewOnTransaction) + ) + + // ─── addImageForViewOnTransaction ───────────────────────────────────────── + + val addImageForViewOnTransaction: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "transactions" / transactionId / "metadata" / "images" => + EndpointHelpers.executeFutureCreated(req) { + val cc = req.callContext + val bodyStr = cc.httpBody.getOrElse("") + for { + user <- cc.user match { + case Full(u) => Future.successful(u) + case _ => Future.failed(new RuntimeException(AuthenticatedUserIsRequired)) + } + account <- cc.bankAccount match { + case Some(a) => Future.successful(a) + case None => Future.failed(new RuntimeException(BankAccountNotFound)) + } + view <- cc.view match { + case Some(v) => Future.successful(v) + case None => Future.failed(new RuntimeException(ViewNotFound)) + } + imageJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + net.liftweb.json.parse(bodyStr).extract[PostTransactionImageJSON] + } + metadata <- moderatedTransactionMetadataFuture(account.bankId, account.accountId, view.viewId, TransactionId(transactionId), Full(user), Some(cc)) + addImageFunc <- Future(metadata.addImage) map { + unboxFullOrFail(_, Some(cc), s"$NoViewPermission can_see_owner_comment. Current ViewId(${view.viewId})") + } + url <- NewStyle.function.tryons(s"$InvalidUrl Could not parse url string as a valid URL", 400, Some(cc)) { + new URL(imageJson.URL) + } + postedImage <- Future(addImageFunc(user.userPrimaryKey, view.viewId, imageJson.label, now, url.toString)) map { + unboxFullOrFail(_, Some(cc), s"Cannot add the image ${imageJson.label}") + } + } yield JSONFactory.createTransactionImageJSON(postedImage) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(addImageForViewOnTransaction), "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transactions/TRANSACTION_ID/metadata/images", + "Add a Transaction Image", + s"""Posts an image about a transaction TRANSACTION_ID on a view VIEW_ID. + | + |${userAuthenticationMessage(true)} + | + |The image is linked with the user.""", + postTransactionImageJSON, transactionImageJSON, + List(AuthenticatedUserIsRequired, InvalidJsonFormat, BankAccountNotFound, NoViewPermission, ViewNotFound, InvalidUrl, UnknownError), + List(apiTagTransactionMetaData, apiTagTransaction), + http4sPartialFunction = Some(addImageForViewOnTransaction) + ) + + // ─── deleteImageForViewOnTransaction ───────────────────────────────────── + + val deleteImageForViewOnTransaction: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "transactions" / transactionId / "metadata" / "images" / imageId => + EndpointHelpers.withUserDelete(req) { (user, cc) => + val bankAccount = cc.bankAccount.get + val view = cc.view.get + for { + (account, callContext) <- NewStyle.function.checkBankAccountExists(bankAccount.bankId, bankAccount.accountId, Some(cc)) + metadata <- moderatedTransactionMetadataFuture(account.bankId, account.accountId, view.viewId, TransactionId(transactionId), Full(user), callContext) + _ <- Future(metadata.deleteImage(imageId, Some(user), account, view, callContext)) map { + unboxFullOrFail(_, callContext, "Image could not be deleted") + } + } yield () + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteImageForViewOnTransaction), "DELETE", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transactions/TRANSACTION_ID/metadata/images/IMAGE_ID", + "Delete a Transaction Image", + """Deletes the image IMAGE_ID about the transaction TRANSACTION_ID made on a view. + | + |Authentication via OAuth is required. The user must either have owner privileges for this account, or must be the user that posted the image.""", + EmptyBody, EmptyBody, + List(BankAccountNotFound, NoViewPermission, AuthenticatedUserIsRequired, UnknownError), + List(apiTagTransactionMetaData, apiTagTransaction), + http4sPartialFunction = Some(deleteImageForViewOnTransaction) + ) + + // ─── getWhereTagForViewOnTransaction ───────────────────────────────────── + + val getWhereTagForViewOnTransaction: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "transactions" / transactionId / "metadata" / "where" => + EndpointHelpers.withView(req) { (user, account, view, cc) => + for { + metadata <- moderatedTransactionMetadataFuture(account.bankId, account.accountId, view.viewId, TransactionId(transactionId), Full(user), Some(cc)) + where <- Future(metadata.whereTag) map { + unboxFullOrFail(_, Some(cc), s"$NoViewPermission can_see_owner_comment. Current ViewId(${view.viewId})") + } + } yield { + val json = JSONFactory.createLocationJSON(where) + TransactionWhereJSON(json) + } + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getWhereTagForViewOnTransaction), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transactions/TRANSACTION_ID/metadata/where", + "Get a Transaction where Tag", + """Returns the "where" Geo tag added to the transaction TRANSACTION_ID made on a view (VIEW_ID). + |It represents the location where the transaction has been initiated. + | + |Authentication via OAuth is required if the view is not public.""", + EmptyBody, transactionWhereJSON, + List(AuthenticatedUserIsRequired, BankAccountNotFound, NoViewPermission, ViewNotFound, UnknownError), + List(apiTagTransactionMetaData, apiTagTransaction), + http4sPartialFunction = Some(getWhereTagForViewOnTransaction) + ) + + // ─── addWhereTagForViewOnTransaction ───────────────────────────────────── + + val addWhereTagForViewOnTransaction: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "transactions" / transactionId / "metadata" / "where" => + EndpointHelpers.executeFutureCreated(req) { + val cc = req.callContext + val bodyStr = cc.httpBody.getOrElse("") + for { + user <- cc.user match { + case Full(u) => Future.successful(u) + case _ => Future.failed(new RuntimeException(AuthenticatedUserIsRequired)) + } + account <- cc.bankAccount match { + case Some(a) => Future.successful(a) + case None => Future.failed(new RuntimeException(BankAccountNotFound)) + } + view <- cc.view match { + case Some(v) => Future.successful(v) + case None => Future.failed(new RuntimeException(ViewNotFound)) + } + metadata <- moderatedTransactionMetadataFuture(account.bankId, account.accountId, view.viewId, TransactionId(transactionId), Full(user), Some(cc)) + addWhereTagFunc <- Future(metadata.addWhereTag) map { + unboxFullOrFail(_, Some(cc), s"$NoViewPermission can_add_where_tag. Current ViewId(${view.viewId})") + } + whereJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + net.liftweb.json.parse(bodyStr).extract[PostTransactionWhereJSON] + } + _ <- Helper.booleanToFuture("Coordinates not possible", 400, Some(cc)) { + checkIfLocationPossible(whereJson.where.latitude, whereJson.where.longitude) + } + _ <- Helper.booleanToFuture("Where tag could not be saved", 400, Some(cc)) { + addWhereTagFunc(user.userPrimaryKey, view.viewId, now, whereJson.where.longitude, whereJson.where.latitude) + } + } yield SuccessMessage("where tag added") + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(addWhereTagForViewOnTransaction), "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transactions/TRANSACTION_ID/metadata/where", + "Add a Transaction where Tag", + s"""Creates a "where" Geo tag on a transaction TRANSACTION_ID in a view. + | + |${userAuthenticationMessage(true)} + | + |The geo tag is linked with the user.""", + postTransactionWhereJSON, successMessage, + List(AuthenticatedUserIsRequired, BankAccountNotFound, InvalidJsonFormat, ViewNotFound, NoViewPermission, "Coordinates not possible", UnknownError), + List(apiTagTransactionMetaData, apiTagTransaction), + http4sPartialFunction = Some(addWhereTagForViewOnTransaction) + ) + + // ─── updateWhereTagForViewOnTransaction ─────────────────────────────────── + + val updateWhereTagForViewOnTransaction: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "transactions" / transactionId / "metadata" / "where" => + EndpointHelpers.executeFuture(req) { + val cc = req.callContext + val bodyStr = cc.httpBody.getOrElse("") + for { + user <- cc.user match { + case Full(u) => Future.successful(u) + case _ => Future.failed(new RuntimeException(AuthenticatedUserIsRequired)) + } + account <- cc.bankAccount match { + case Some(a) => Future.successful(a) + case None => Future.failed(new RuntimeException(BankAccountNotFound)) + } + view <- cc.view match { + case Some(v) => Future.successful(v) + case None => Future.failed(new RuntimeException(ViewNotFound)) + } + metadata <- moderatedTransactionMetadataFuture(account.bankId, account.accountId, view.viewId, TransactionId(transactionId), Full(user), Some(cc)) + addWhereTagFunc <- Future(metadata.addWhereTag) map { + unboxFullOrFail(_, Some(cc), s"$NoViewPermission can_add_where_tag. Current ViewId(${view.viewId})") + } + whereJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + net.liftweb.json.parse(bodyStr).extract[PostTransactionWhereJSON] + } + _ <- Helper.booleanToFuture("Coordinates not possible", 400, Some(cc)) { + checkIfLocationPossible(whereJson.where.latitude, whereJson.where.longitude) + } + _ <- Helper.booleanToFuture("Where tag could not be saved", 400, Some(cc)) { + addWhereTagFunc(user.userPrimaryKey, view.viewId, now, whereJson.where.longitude, whereJson.where.latitude) + } + } yield SuccessMessage("where tag updated") + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateWhereTagForViewOnTransaction), "PUT", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transactions/TRANSACTION_ID/metadata/where", + "Update a Transaction where Tag", + s"""Updates the "where" Geo tag on a transaction TRANSACTION_ID in a view. + | + |${userAuthenticationMessage(true)} + | + |The geo tag is linked with the user.""", + postTransactionWhereJSON, successMessage, + List(AuthenticatedUserIsRequired, BankAccountNotFound, InvalidJsonFormat, ViewNotFound, NoViewPermission, "Coordinates not possible", UnknownError), + List(apiTagTransactionMetaData, apiTagTransaction), + http4sPartialFunction = Some(updateWhereTagForViewOnTransaction) + ) + + // ─── deleteWhereTagForViewOnTransaction ─────────────────────────────────── + + val deleteWhereTagForViewOnTransaction: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "transactions" / transactionId / "metadata" / "where" => + EndpointHelpers.withUserDelete(req) { (user, cc) => + val bankAccount = cc.bankAccount.get + val view = cc.view.get + for { + (account, callContext) <- NewStyle.function.checkBankAccountExists(bankAccount.bankId, bankAccount.accountId, Some(cc)) + metadata <- moderatedTransactionMetadataFuture(account.bankId, account.accountId, view.viewId, TransactionId(transactionId), Full(user), callContext) + _ <- Future(metadata.deleteWhereTag(view.viewId, Some(user), account, view, callContext)) map { + unboxFullOrFail(_, callContext, "Delete not completed") + } + } yield () + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteWhereTagForViewOnTransaction), "DELETE", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transactions/TRANSACTION_ID/metadata/where", + "Delete a Transaction where Tag", + s"""Deletes the where tag of the transaction TRANSACTION_ID made on a view. + | + |${userAuthenticationMessage(true)} + | + |The user must either have owner privileges for this account, or must be the user that posted the geo tag.""", + EmptyBody, EmptyBody, + List(AuthenticatedUserIsRequired, BankAccountNotFound, NoViewPermission, ViewNotFound, "there is no tag to delete", "Delete not completed", UnknownError), + List(apiTagTransactionMetaData, apiTagTransaction), + http4sPartialFunction = Some(deleteWhereTagForViewOnTransaction) + ) + + // ─── getOtherAccountForTransaction ─────────────────────────────────────── + + val getOtherAccountForTransaction: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "transactions" / transactionId / "other_account" => + EndpointHelpers.withView(req) { (user, account, view, cc) => + for { + (moderatedTransaction, _) <- account.moderatedTransactionFuture(TransactionId(transactionId), view, Full(user), Some(cc)) map { + unboxFullOrFail(_, Some(cc), GetTransactionsException) + } + _ <- Helper.booleanToFuture(GetTransactionsException, 400, Some(cc)) { + moderatedTransaction.otherBankAccount.isDefined + } + } yield JSONFactory.createOtherBankAccount(moderatedTransaction.otherBankAccount.get) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getOtherAccountForTransaction), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transactions/TRANSACTION_ID/other_account", + "Get Other Account of Transaction", + """Get other account of a transaction. + |Returns details of the other party involved in the transaction, moderated by the view (VIEW_ID). + |Authentication via OAuth is required if the view is not public.""", + EmptyBody, otherAccountJSON, + List(AuthenticatedUserIsRequired, BankAccountNotFound, UnknownError), + List(apiTagTransaction, apiTagCounterparty), + http4sPartialFunction = Some(getOtherAccountForTransaction) + ) + + // ─── allRoutes ──────────────────────────────────────────────────────────── + + val allRoutes: HttpRoutes[IO] = + Kleisli[HttpF, Request[IO], Response[IO]] { req: Request[IO] => + root(req) + .orElse(getBanks(req)) + // bankById is intentionally absent — it runs outside middleware (see allRoutesWithMiddleware) + .orElse(getPrivateAccountsAllBanks(req)) + .orElse(privateAccountsAllBanks(req)) + .orElse(publicAccountsAllBanks(req)) + .orElse(getPrivateAccountsAtOneBank(req)) + .orElse(privateAccountsAtOneBank(req)) + .orElse(publicAccountsAtOneBank(req)) + .orElse(accountById(req)) + .orElse(updateAccountLabel(req)) + .orElse(getViewsForBankAccount(req)) + .orElse(createViewForBankAccount(req)) + .orElse(updateViewForBankAccount(req)) + .orElse(deleteViewForBankAccount(req)) + .orElse(getPermissionsForBankAccount(req)) + .orElse(getPermissionForUserForBankAccount(req)) + .orElse(addPermissionForUserForBankAccountForMultipleViews(req)) + .orElse(addPermissionForUserForBankAccountForOneView(req)) + .orElse(removePermissionForUserForBankAccountForOneView(req)) + .orElse(removePermissionForUserForBankAccountForAllViews(req)) + .orElse(getOtherAccountsForBankAccount(req)) + .orElse(getOtherAccountByIdForBankAccount(req)) + .orElse(getOtherAccountMetadata(req)) + .orElse(getCounterpartyPublicAlias(req)) + .orElse(addCounterpartyPublicAlias(req)) + .orElse(updateCounterpartyPublicAlias(req)) + .orElse(deleteCounterpartyPublicAlias(req)) + .orElse(getOtherAccountPrivateAlias(req)) + .orElse(addOtherAccountPrivateAlias(req)) + .orElse(updateCounterpartyPrivateAlias(req)) + .orElse(deleteCounterpartyPrivateAlias(req)) + .orElse(addCounterpartyMoreInfo(req)) + .orElse(updateCounterpartyMoreInfo(req)) + .orElse(deleteCounterpartyMoreInfo(req)) + .orElse(addCounterpartyUrl(req)) + .orElse(updateCounterpartyUrl(req)) + .orElse(deleteCounterpartyUrl(req)) + .orElse(addCounterpartyImageUrl(req)) + .orElse(updateCounterpartyImageUrl(req)) + .orElse(deleteCounterpartyImageUrl(req)) + .orElse(addCounterpartyOpenCorporatesUrl(req)) + .orElse(updateCounterpartyOpenCorporatesUrl(req)) + .orElse(deleteCounterpartyOpenCorporatesUrl(req)) + .orElse(addCounterpartyCorporateLocation(req)) + .orElse(updateCounterpartyCorporateLocation(req)) + .orElse(deleteCounterpartyCorporateLocation(req)) + .orElse(addCounterpartyPhysicalLocation(req)) + .orElse(updateCounterpartyPhysicalLocation(req)) + .orElse(deleteCounterpartyPhysicalLocation(req)) + .orElse(getTransactionsForBankAccount(req)) + .orElse(getTransactionByIdForBankAccount(req)) + .orElse(getTransactionNarrative(req)) + .orElse(addTransactionNarrative(req)) + .orElse(updateTransactionNarrative(req)) + .orElse(deleteTransactionNarrative(req)) + .orElse(getCommentsForViewOnTransaction(req)) + .orElse(addCommentForViewOnTransaction(req)) + .orElse(deleteCommentForViewOnTransaction(req)) + .orElse(getTagsForViewOnTransaction(req)) + .orElse(addTagForViewOnTransaction(req)) + .orElse(deleteTagForViewOnTransaction(req)) + .orElse(getImagesForViewOnTransaction(req)) + .orElse(addImageForViewOnTransaction(req)) + .orElse(deleteImageForViewOnTransaction(req)) + .orElse(getWhereTagForViewOnTransaction(req)) + .orElse(addWhereTagForViewOnTransaction(req)) + .orElse(updateWhereTagForViewOnTransaction(req)) + .orElse(deleteWhereTagForViewOnTransaction(req)) + .orElse(getOtherAccountForTransaction(req)) + } + + val allRoutesWithMiddleware: HttpRoutes[IO] = { + val middlewareWrapped = ResourceDocMiddleware.apply(resourceDocs)(allRoutes) + // bankById runs before middleware so it can return 400 (not 404) for unknown bank + Kleisli[HttpF, Request[IO], Response[IO]] { req => + bankById.run(req).orElse(middlewareWrapped.run(req)) + } + } + } + + val wrappedRoutesV121Services: HttpRoutes[IO] = Implementations1_2_1.allRoutesWithMiddleware +} diff --git a/obp-api/src/main/scala/code/api/v1_3_0/Http4s130.scala b/obp-api/src/main/scala/code/api/v1_3_0/Http4s130.scala new file mode 100644 index 0000000000..da82178ec6 --- /dev/null +++ b/obp-api/src/main/scala/code/api/v1_3_0/Http4s130.scala @@ -0,0 +1,157 @@ +package code.api.v1_3_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.ApiTag._ +import code.api.util.ErrorMessages._ +import code.api.util.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps} +import code.api.util.http4s.ResourceDocMiddleware +import code.api.util.NewStyle +import code.api.v1_2_1.JSONFactory +import com.github.dwickern.macros.NameOf.nameOf +import com.openbankproject.commons.ExecutionContext.Implicits.global +import com.openbankproject.commons.util.{ApiVersion, ApiVersionStatus, ScannedApiVersion} +import org.http4s._ +import org.http4s.dsl.io._ + +import scala.collection.mutable.ArrayBuffer +import scala.concurrent.Future + +object Http4s130 { + val implementedInApiVersion: ScannedApiVersion = ApiVersion.v1_3_0 + val versionStatus: String = ApiVersionStatus.DEPRECATED.toString + val resourceDocs: ArrayBuffer[ResourceDoc] = ArrayBuffer[ResourceDoc]() + + import code.api.util.ApiRole._ + + type HttpF[A] = OptionT[IO, A] + + object Implementations1_3_0 { + val prefixPath = 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.v1_3_0, versionStatus)) + } + case req @ GET -> `prefixPath` / "root" => + EndpointHelpers.executeAndRespond(req) { _ => + Future.successful(JSONFactory.getApiInfoJSON(ApiVersion.v1_3_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) + ) + + // ─── getCards ──────────────────────────────────────────────────────────── + + val getCards: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "cards" => + EndpointHelpers.withUser(req) { (u, cc) => + NewStyle.function.getPhysicalCardsForUser(u, Some(cc)).map { + case (cards, _) => JSONFactory1_3_0.createPhysicalCardsJSON(cards, u) + } + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getCards), + "GET", + "/cards", + "Get cards for the current user", + "Returns data about all the physical cards a user has been issued. These could be debit cards, credit cards, etc.", + EmptyBody, + physicalCardsJSON, + List(AuthenticatedUserIsRequired, UnknownError), + List(apiTagCard), + None, + http4sPartialFunction = Some(getCards) + ) + + // ─── getCardsForBank ───────────────────────────────────────────────────── + + val getCardsForBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "cards" => + EndpointHelpers.withUserAndBank(req) { (u, bank, cc) => + for { + httpParams <- NewStyle.function.extractHttpParamsFromUrl(cc.url) + (obpQueryParams, cc2) <- createQueriesByHttpParamsFuture(httpParams, Some(cc)) + (cards, _) <- NewStyle.function.getPhysicalCardsForBank(bank, u, obpQueryParams, cc2) + } yield JSONFactory1_3_0.createPhysicalCardsJSON(cards, u) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getCardsForBank), + "GET", + "/banks/BANK_ID/cards", + "Get cards for the specified bank", + "", + EmptyBody, + physicalCardsJSON, + List(AuthenticatedUserIsRequired, BankNotFound, UnknownError), + List(apiTagCard), + Some(List(canGetCardsForBank)), + http4sPartialFunction = Some(getCardsForBank) + ) + + // ─── allRoutes ─────────────────────────────────────────────────────────── + + private val allOwnRoutes: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req => + root.run(req) + .orElse(getCards.run(req)) + .orElse(getCardsForBank.run(req)) + } + + val allRoutesWithMiddleware: HttpRoutes[IO] = ResourceDocMiddleware.apply(resourceDocs)(allOwnRoutes) + + // ─── path-rewriting bridge: /obp/v1.3.0/… → /obp/v1.2.1/… ───────────── + // Delegates to Http4s121 so all inherited v1.2.1 endpoints are served + // under the v1.3.0 URL prefix without duplicating any logic. + + val v130ToV121Bridge: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req => + val rawPath = req.uri.path.renderString + if (rawPath.startsWith("/obp/v1.3.0/")) { + val rewritten = rawPath.replaceFirst("/obp/v1\\.3\\.0/", "/obp/v1.2.1/") + val newUri = req.uri.withPath(Uri.Path.unsafeFromString(rewritten)) + val rewrittenReq = req.withUri(newUri) + code.api.v1_2_1.Http4s121.wrappedRoutesV121Services.run(rewrittenReq) + } else { + OptionT.none[IO, Response[IO]] + } + } + } + + // Own middleware-wrapped routes take priority; inherited v1.2.1 paths follow. + val wrappedRoutesV130Services: HttpRoutes[IO] = + Kleisli[HttpF, Request[IO], Response[IO]] { req => + Implementations1_3_0.allRoutesWithMiddleware.run(req) + .orElse(Implementations1_3_0.v130ToV121Bridge.run(req)) + } +} diff --git a/obp-api/src/main/scala/code/api/v1_4_0/Http4s140.scala b/obp-api/src/main/scala/code/api/v1_4_0/Http4s140.scala new file mode 100644 index 0000000000..dce06d4b3a --- /dev/null +++ b/obp-api/src/main/scala/code/api/v1_4_0/Http4s140.scala @@ -0,0 +1,513 @@ +package code.api.v1_4_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.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps} +import code.api.util.http4s.ResourceDocMiddleware +import code.api.util.{APIUtil, NewStyle} +import code.api.v1_2_1.{JSONFactory, SuccessMessage} +import code.atms.Atms +import code.bankconnectors.Connector +import code.branches.Branches +import code.customer.{CustomerMessages, CustomerX} +import code.products.Products +import code.usercustomerlinks.UserCustomerLink +import code.views.system.ViewPermission +import com.github.dwickern.macros.NameOf.nameOf +import com.openbankproject.commons.ExecutionContext.Implicits.global +import com.openbankproject.commons.model.CustomerFaceImage +import com.openbankproject.commons.util.{ApiVersion, ApiVersionStatus, ScannedApiVersion} +import org.http4s._ +import org.http4s.dsl.io._ + +import scala.collection.mutable.ArrayBuffer +import scala.concurrent.Future + +object Http4s140 { + val implementedInApiVersion: ScannedApiVersion = ApiVersion.v1_4_0 + val versionStatus: String = ApiVersionStatus.DEPRECATED.toString + val resourceDocs: ArrayBuffer[ResourceDoc] = ArrayBuffer[ResourceDoc]() + + type HttpF[A] = OptionT[IO, A] + + object Implementations1_4_0 { + val prefixPath = 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.v1_4_0, versionStatus)) + } + case req @ GET -> `prefixPath` / "root" => + EndpointHelpers.executeAndRespond(req) { _ => + Future.successful(JSONFactory.getApiInfoJSON(ApiVersion.v1_4_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) + ) + + // ─── getCustomer ────────────────────────────────────────────────────────── + + val getCustomer: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "customer" => + EndpointHelpers.withUserAndBank(req) { (user, bank, cc) => + for { + ucls <- Future { UserCustomerLink.userCustomerLink.vend.getUserCustomerLinksByUserId(user.userId) } + matchingUcl <- Future { + ucls.find(x => CustomerX.customerProvider.vend.getBankIdByCustomerId(x.customerId) == bank.bankId.value) + .getOrElse(throw new RuntimeException(UserCustomerLinksNotFoundForUser)) + } + (customer, _) <- NewStyle.function.getCustomerByCustomerId(matchingUcl.customerId, Some(cc)) + } yield JSONFactory1_4_0.createCustomerJson(customer) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getCustomer), + "GET", + "/banks/BANK_ID/customer", + "Get customer for logged in user", + """Information about the currently authenticated user. + | + |Authentication via OAuth is required.""", + EmptyBody, + customerJsonV140, + List(AuthenticatedUserIsRequired, UnknownError), + List(apiTagCustomer, apiTagOldStyle), + None, + http4sPartialFunction = Some(getCustomer) + ) + + // ─── getCustomersMessages ───────────────────────────────────────────────── + + val getCustomersMessages: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "customer" / "messages" => + EndpointHelpers.withUserAndBank(req) { (user, bank, cc) => + Future { + val messages = CustomerMessages.customerMessageProvider.vend.getMessages(user, bank.bankId) + JSONFactory1_4_0.createCustomerMessagesJson(messages) + } + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getCustomersMessages), + "GET", + "/banks/BANK_ID/customer/messages", + "Get Customer Messages for all Customers", + """Get messages for the logged in customer + |Messages sent to the currently authenticated user. + | + |Authentication via OAuth is required.""", + EmptyBody, + customerMessagesJson, + List(AuthenticatedUserIsRequired, UnknownError), + List(apiTagMessage, apiTagCustomer), + None, + http4sPartialFunction = Some(getCustomersMessages) + ) + + // ─── addCustomerMessage ─────────────────────────────────────────────────── + + val addCustomerMessage: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "customer" / customerId / "messages" => + EndpointHelpers.withUserAndBankAndBodyCreated[JSONFactory1_4_0.AddCustomerMessageJson, SuccessMessage](req) { (_, bank, body, cc) => + for { + (_, cc2) <- NewStyle.function.getCustomerByCustomerId(customerId, Some(cc)) + (ucl, cc3) <- NewStyle.function.getUserCustomerLinkByCustomerId(customerId, cc2) + (targetUser, cc4) <- NewStyle.function.findByUserId(ucl.userId, cc3) + (_, _) <- NewStyle.function.createMessage(targetUser, bank.bankId, body.message, body.from_department, body.from_person, cc4) + } yield SuccessMessage("Success") + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(addCustomerMessage), + "POST", + "/banks/BANK_ID/customer/CUSTOMER_ID/messages", + "Create Customer Message", + "Create a message for the customer specified by CUSTOMER_ID", + addCustomerMessageJson, + successMessage, + List(AuthenticatedUserIsRequired, UnknownError), + List(apiTagMessage, apiTagCustomer, apiTagPerson), + None, + http4sPartialFunction = Some(addCustomerMessage) + ) + + // ─── getBranches ────────────────────────────────────────────────────────── + + private val getBranchesIsPublic = APIUtil.getPropsAsBoolValue("apiOptions.getBranchesIsPublic", true) + + val getBranches: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "branches" => + EndpointHelpers.withBank(req) { (bank, cc) => + for { + httpParams <- NewStyle.function.extractHttpParamsFromUrl(cc.url) + (obpQueryParams, _) <- createQueriesByHttpParamsFuture(httpParams, Some(cc)) + branches <- Future { + Branches.branchesProvider.vend.getBranches(bank.bankId, obpQueryParams) + .getOrElse(throw new RuntimeException("No branches available. License may not be set.")) + } + } yield JSONFactory1_4_0.createBranchesJson(branches) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getBranches), + "GET", + "/banks/BANK_ID/branches", + "Get Bank Branches", + s"""Returns information about branches for a single bank specified by BANK_ID including: + | + |* Name + |* Address + |* Geo Location + |* License the data under this endpoint is released under + | + ${urlParametersDocument(false, false)} + | + |You can use the url query parameters *limit* and *offset* for pagination + | + |${userAuthenticationMessage(!getBranchesIsPublic)}""".stripMargin, + EmptyBody, + branchesJson, + List(AuthenticatedUserIsRequired, BankNotFound, "No branches available. License may not be set.", UnknownError), + List(apiTagBranch, apiTagOldStyle), + None, + http4sPartialFunction = Some(getBranches) + ) + + // ─── getAtms ────────────────────────────────────────────────────────────── + + private val getAtmsIsPublic = APIUtil.getPropsAsBoolValue("apiOptions.getAtmsIsPublic", true) + + val getAtms: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "atms" => + EndpointHelpers.withBank(req) { (bank, cc) => + for { + httpParams <- NewStyle.function.extractHttpParamsFromUrl(cc.url) + (obpQueryParams, _) <- createQueriesByHttpParamsFuture(httpParams, Some(cc)) + atms <- Future { + Atms.atmsProvider.vend.getAtms(bank.bankId, obpQueryParams) + .getOrElse(throw new RuntimeException("No ATMs available. License may not be set.")) + } + } yield JSONFactory1_4_0.createAtmsJson(atms) + } + } + + 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 including: + | + |* Address + |* Geo Location + |* License the data under this endpoint is released under + | + |${urlParametersDocument(false, false)} + | + |${userAuthenticationMessage(!getAtmsIsPublic)}""".stripMargin, + EmptyBody, + atmsJson, + List(AuthenticatedUserIsRequired, BankNotFound, "No ATMs available. License may not be set.", UnknownError), + List(apiTagBank, apiTagOldStyle), + None, + http4sPartialFunction = Some(getAtms) + ) + + // ─── getProducts ────────────────────────────────────────────────────────── + + private val getProductsIsPublic = APIUtil.getPropsAsBoolValue("apiOptions.getProductsIsPublic", true) + + val getProducts: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "products" => + EndpointHelpers.withBank(req) { (bank, cc) => + Future { + val products = Products.productsProvider.vend.getProducts(bank.bankId) + .getOrElse(throw new RuntimeException("No products available. License may not be set.")) + JSONFactory1_4_0.createProductsJson(products) + } + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getProducts), + "GET", + "/banks/BANK_ID/products", + "Get Bank Products", + s"""Returns information about the financial products offered by a bank specified by BANK_ID including: + | + |* Name + |* Code + |* Category + |* Family + |* Super Family + |* More info URL + |* Description + |* Terms and Conditions + |* License the data under this endpoint is released under + |${userAuthenticationMessage(!getProductsIsPublic)}""".stripMargin, + EmptyBody, + productsJson, + List(AuthenticatedUserIsRequired, BankNotFound, "No products available.", "License may not be set.", UnknownError), + List(apiTagBank, apiTagOldStyle), + None, + http4sPartialFunction = Some(getProducts) + ) + + // ─── getCrmEvents ───────────────────────────────────────────────────────── + + val getCrmEvents: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "crm-events" => + EndpointHelpers.withUserAndBank(req) { (_, bank, cc) => + NewStyle.function.getCrmEvents(bank.bankId, Some(cc)) + .map(JSONFactory1_4_0.createCrmEventsJson) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getCrmEvents), + "GET", + "/banks/BANK_ID/crm-events", + "Get CRM Events", + "", + EmptyBody, + crmEventsJson, + List(AuthenticatedUserIsRequired, BankNotFound, "No CRM Events available.", UnknownError), + List(apiTagCustomer), + None, + http4sPartialFunction = Some(getCrmEvents) + ) + + // ─── getTransactionRequestTypes ─────────────────────────────────────────── + + val getTransactionRequestTypes: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "transaction-request-types" => + EndpointHelpers.withView(req) { (user, fromAccount, view, cc) => + for { + _ <- NewStyle.function.isEnabledTransactionRequests(Some(cc)) + failMsg = InvalidISOCurrencyCode.concat("Please specify a valid value for CURRENCY of your Bank Account. ") + _ <- NewStyle.function.isValidCurrencyISOCode(fromAccount.currency, failMsg, Some(cc)) + _ <- code.util.Helper.booleanToFuture( + s"$ViewDoesNotPermitAccess You need the `$CAN_SEE_TRANSACTION_REQUEST_TYPES` permission on the View(${view.viewId.value})", + cc = Some(cc) + ) { + ViewPermission.findViewPermissions(view).exists(_.permission.get == CAN_SEE_TRANSACTION_REQUEST_TYPES) + } + (transactionRequestTypes, cc2) <- Future { + connectorEmptyResponse( + Connector.connector.vend.getTransactionRequestTypes(user, fromAccount, Some(cc)), + Some(cc) + ) + } + (charges, _) <- NewStyle.function.getTransactionRequestTypeCharges( + fromAccount.bankId, fromAccount.accountId, view.viewId, transactionRequestTypes, cc2 + ) + } yield JSONFactory1_4_0.createTransactionRequestTypesJSONs(charges) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getTransactionRequestTypes), + "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transaction-request-types", + "Get Transaction Request Types for Account", + """Returns the Transaction Request Types that the account specified by ACCOUNT_ID and view specified by VIEW_ID has access to. + | + |These are the ways this API Server can create a Transaction via a Transaction Request + |(as opposed to Transaction Types which include external types too e.g. for Transactions created by core banking etc.) + | + | A Transaction Request Type internally determines: + | + | * the required Transaction Request 'body' i.e. fields that define the 'what' and 'to' of a Transaction Request, + | * the type of security challenge that may be be raised before the Transaction Request proceeds, and + | * the threshold of that challenge. + | + | For instance in a 'SANDBOX_TAN' Transaction Request, for amounts over 1000 currency units, the user must supply a positive integer to complete the Transaction Request and create a Transaction. + | + | This approach aims to provide only one endpoint for initiating transactions, and one that handles challenges, whilst still allowing flexibility with the payload and internal logic. + """.stripMargin, + EmptyBody, + transactionRequestTypesJsonV140, + List( + AuthenticatedUserIsRequired, + BankNotFound, + AccountNotFound, + "Please specify a valid value for CURRENCY of your Bank Account. ", + "Current user does not have access to the view ", + TransactionRequestsNotEnabled, + UnknownError), + List(apiTagTransactionRequest, apiTagPSD2PIS, apiTagPsd2), + None, + http4sPartialFunction = Some(getTransactionRequestTypes) + ) + + // ─── addCustomer ───────────────────────────────────────────────────────── + + val addCustomer: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "customer" => + EndpointHelpers.withUserAndBankAndBody[code.api.v2_0_0.CreateCustomerJson, JSONFactory1_4_0.CustomerJsonV140](req) { (user, bank, body, cc) => + for { + _ <- code.util.Helper.booleanToFuture( + s"${UserHasMissingRoles}${canCreateCustomer} and ${canCreateUserCustomerLink} entitlements are required for BankId(${bank.bankId.value}).", + failCode = 403, + cc = Some(cc) + ) { + APIUtil.hasAllEntitlements(bank.bankId.value, user.userId, canCreateCustomer :: canCreateUserCustomerLink :: Nil) + } + _ <- code.util.Helper.booleanToFuture(CustomerNumberAlreadyExists, cc = Some(cc)) { + CustomerX.customerProvider.vend.checkCustomerNumberAvailable(bank.bankId, body.customer_number) + } + userId = if (body.user_id.nonEmpty) body.user_id else user.userId + (_, _) <- NewStyle.function.findByUserId(userId, Some(cc)) + customer <- Future { + CustomerX.customerProvider.vend.addCustomer( + bankId = bank.bankId, + number = body.customer_number, + legalName = body.legal_name, + mobileNumber = body.mobile_phone_number, + email = body.email, + faceImage = CustomerFaceImage(body.face_image.date, body.face_image.url), + dateOfBirth = body.date_of_birth, + relationshipStatus = body.relationship_status, + dependents = body.dependants, + dobOfDependents = body.dob_of_dependants, + highestEducationAttained = body.highest_education_attained, + employmentStatus = body.employment_status, + kycStatus = body.kyc_status, + lastOkDate = body.last_ok_date, + creditRating = None, + creditLimit = None, + title = body.title, + branchId = body.branchId, + nameSuffix = body.nameSuffix + ).getOrElse(throw new RuntimeException("Could not create customer")) + } + _ <- Future { + UserCustomerLink.userCustomerLink.vend + .createUserCustomerLink(userId, customer.customerId, DateWithMsExampleObject, true) + .getOrElse(throw new RuntimeException("Could not create user_customer_links")) + } + } yield JSONFactory1_4_0.createCustomerJson(customer) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(addCustomer), + "POST", + "/banks/BANK_ID/customer", + "Add a customer.", + s"""Add a customer linked to the currently authenticated user. + |The Customer resource stores the customer number, legal name, email, phone number, their date of birth, relationship status, education attained, a url for a profile image, KYC status etc. + |This call may require additional permissions/role in the future. + |For now the authenticated user can create at most one linked customer. + |Dates need to be in the format 2013-01-21T23:08:00Z + |${userAuthenticationMessage(true)} + |Note: This call is depreciated in favour of v.2.0.0 createCustomer + |""", + code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON.createCustomerJson, + customerJsonV140, + List( + AuthenticatedUserIsRequired, + BankNotFound, + InvalidJsonFormat, + "entitlements required", + CustomerNumberAlreadyExists, + "Problem getting user_id", + UserNotFoundById, + "Could not create customer", + "Could not create user_customer_links", + UnknownError), + List(apiTagCustomer, apiTagOldStyle), + Some(List(canCreateCustomer, canCreateUserCustomerLink)), + http4sPartialFunction = Some(addCustomer) + ) + + // ─── allRoutes ──────────────────────────────────────────────────────────── + + private val allOwnRoutes: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req => + root.run(req) + .orElse(getCustomer.run(req)) + .orElse(getCustomersMessages.run(req)) + .orElse(addCustomerMessage.run(req)) + .orElse(getBranches.run(req)) + .orElse(getAtms.run(req)) + .orElse(getProducts.run(req)) + .orElse(getCrmEvents.run(req)) + .orElse(getTransactionRequestTypes.run(req)) + .orElse(addCustomer.run(req)) + } + + val allRoutesWithMiddleware: HttpRoutes[IO] = ResourceDocMiddleware.apply(resourceDocs)(allOwnRoutes) + + // ─── path-rewriting bridge: /obp/v1.4.0/… → /obp/v1.3.0/… ────────────── + // Delegates to Http4s130 so all inherited v1.3.0 and v1.2.1 endpoints are + // served under the v1.4.0 URL prefix without duplicating any logic. + + val v140ToV130Bridge: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req => + val rawPath = req.uri.path.renderString + if (rawPath.startsWith("/obp/v1.4.0/")) { + val rewritten = rawPath.replaceFirst("/obp/v1\\.4\\.0/", "/obp/v1.3.0/") + val newUri = req.uri.withPath(Uri.Path.unsafeFromString(rewritten)) + val rewrittenReq = req.withUri(newUri) + code.api.v1_3_0.Http4s130.wrappedRoutesV130Services.run(rewrittenReq) + } else { + OptionT.none[IO, Response[IO]] + } + } + } + + // Own middleware-wrapped routes take priority; inherited v1.3.0 and v1.2.1 paths follow. + val wrappedRoutesV140Services: HttpRoutes[IO] = + Kleisli[HttpF, Request[IO], Response[IO]] { req => + Implementations1_4_0.allRoutesWithMiddleware.run(req) + .orElse(Implementations1_4_0.v140ToV130Bridge.run(req)) + } +} 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 new file mode 100644 index 0000000000..4ccfaee2de --- /dev/null +++ b/obp-api/src/main/scala/code/api/v2_0_0/Http4s200.scala @@ -0,0 +1,1378 @@ +package code.api.v2_0_0 + +import cats.data.{Kleisli, OptionT} +import cats.effect._ +import code.TransactionTypes.TransactionType +import code.api.APIFailureNewStyle +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.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps} +import code.api.util.http4s.ResourceDocMiddleware +import code.api.util.newstyle.ViewNewStyle +import code.api.util.{APIUtil, ApiRole, CustomJsonFormats, NewStyle} +import code.api.v1_2_1.{JSONFactory => JSONFactory121, SuccessMessage} +import code.api.v1_4_0.JSONFactory1_4_0 +import code.api.v2_0_0.JSONFactory200 +import code.api.v2_0_0.JSONFactory200._ +import code.customer.CustomerX +import code.entitlement.Entitlement +import code.model.dataAccess.{AuthUser, BankAccountCreation} +import code.model.{BankAccountX, BankExtended, UserX, _} +import code.search.{elasticsearchMetrics, elasticsearchWarehouse} +import code.socialmedia.SocialMediaHandle +import code.usercustomerlinks.UserCustomerLink +import code.users.Users +import code.views.Views +import com.github.dwickern.macros.NameOf.nameOf +import com.openbankproject.commons.ExecutionContext.Implicits.global +import com.openbankproject.commons.model.{AccountId, AmountOfMoneyJsonV121, BankId, BankIdAccountId, CustomerFaceImage} +import com.openbankproject.commons.util.{ApiVersion, ApiVersionStatus, ScannedApiVersion} +import net.liftweb.common._ +import net.liftweb.http.InMemoryResponse +import net.liftweb.json.JsonAST.JValue +import net.liftweb.json.{Extraction, Formats} +import net.liftweb.mapper.By +import org.http4s._ +import org.http4s.dsl.io._ + +import java.util.Date +import scala.collection.mutable.ArrayBuffer +import scala.concurrent.Future + +object Http4s200 { + val implementedInApiVersion: ScannedApiVersion = ApiVersion.v2_0_0 + val versionStatus: String = ApiVersionStatus.DEPRECATED.toString + val resourceDocs: ArrayBuffer[ResourceDoc] = ArrayBuffer[ResourceDoc]() + + implicit val formats: Formats = CustomJsonFormats.formats + + type HttpF[A] = OptionT[IO, A] + + object Implementations2_0_0 { + val prefixPath = Root / ApiPathZero.toString / implementedInApiVersion.toString + + // ─── root ───────────────────────────────────────────────────────────────── + + val root: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` => + EndpointHelpers.executeAndRespond(req) { _ => + Future.successful(JSONFactory121.getApiInfoJSON(ApiVersion.v2_0_0, versionStatus)) + } + case req @ GET -> `prefixPath` / "root" => + EndpointHelpers.executeAndRespond(req) { _ => + Future.successful(JSONFactory121.getApiInfoJSON(ApiVersion.v2_0_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)) + + // ─── getPrivateAccountsAllBanks ─────────────────────────────────────────── + + val getPrivateAccountsAllBanks: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "accounts" => + EndpointHelpers.withUser(req) { (user, cc) => + Future { + val (privateViewsUserCanAccess, privateAccountAccess) = Views.views.vend.privateViewsUserCanAccess(user) + val privateAccounts = BankAccountX.privateAccounts(privateAccountAccess) + privateBankAccountsListToJson(privateAccounts, privateViewsUserCanAccess) + } + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getPrivateAccountsAllBanks), "GET", "/accounts", + "Get all Accounts at all Banks", + s"""Get all accounts at all banks the User has access to. + |Returns the list of accounts at that the user has access to at all banks. + |For each account the API returns the account ID and the available views. + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, basicAccountsJSON, + List(AuthenticatedUserIsRequired, UnknownError), + List(apiTagAccount, apiTagPrivateData, apiTagPublicData, apiTagOldStyle), None, + http4sPartialFunction = Some(getPrivateAccountsAllBanks)) + + // ─── corePrivateAccountsAllBanks ────────────────────────────────────────── + + val corePrivateAccountsAllBanks: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "my" / "accounts" => + EndpointHelpers.withUser(req) { (user, cc) => + Future { + val (privateViewsUserCanAccess, privateAccountAccess) = Views.views.vend.privateViewsUserCanAccess(user) + val privateAccounts = BankAccountX.privateAccounts(privateAccountAccess) + privateAccounts.map { account => + createCoreAccountJSON(account, net.liftweb.json.JObject(Nil)) + } + } + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(corePrivateAccountsAllBanks), "GET", "/my/accounts", + "Get Accounts at all Banks (Private)", + s"""Get private accounts at all banks (Authenticated access) + |Returns the list of accounts containing private views for the user at all banks. + |For each account the API returns the ID and the available views. + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, coreAccountsJSON, + List(UnknownError), + List(apiTagAccount, apiTagPrivateData, apiTagPsd2, apiTagOldStyle), None, + http4sPartialFunction = Some(corePrivateAccountsAllBanks)) + + // ─── publicAccountsAllBanks ─────────────────────────────────────────────── + + val publicAccountsAllBanks: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "accounts" / "public" => + EndpointHelpers.executeAndRespond(req) { _ => + Future { + val (publicViews, publicAccountAccess) = Views.views.vend.publicViews + val accounts = BankAccountX.publicAccounts(publicAccountAccess) + val accJson: List[BasicAccountJSON] = accounts.map { account => + val viewsAvailable = publicViews + .filter(v => v.bankId == account.bankId && v.accountId == account.accountId && v.isPublic) + .map(createBasicViewJSON) + .distinct + createBasicAccountJSON(account, viewsAvailable) + } + BasicAccountsJSON(accJson) + } + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(publicAccountsAllBanks), "GET", "/accounts/public", + "Get Public Accounts at all Banks", + s"""Get public accounts at all banks (Anonymous access). + |Returns accounts that contain at least one public view (a view where is_public is true) + |For each account the API returns the ID and the available views. + | + |${userAuthenticationMessage(false)}""".stripMargin, + EmptyBody, basicAccountsJSON, + List(AuthenticatedUserIsRequired, CannotGetAccounts, UnknownError), + List(apiTagAccountPublic, apiTagAccount, apiTagPublicData), None, + http4sPartialFunction = Some(publicAccountsAllBanks)) + + // ─── getPrivateAccountsAtOneBank ────────────────────────────────────────── + + val getPrivateAccountsAtOneBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" => + EndpointHelpers.withUserAndBank(req) { (user, bank, cc) => + for { + (privateViewsUserCanAccessAtOneBank, privateAccountAccess) <- Future { + Views.views.vend.privateViewsUserCanAccessAtBank(user, bank.bankId) + } + (availablePrivateAccounts, _) <- BankExtended(bank).privateAccountsFuture(privateAccountAccess, Some(cc)) + } yield privateBankAccountsListToJson(availablePrivateAccounts, privateViewsUserCanAccessAtOneBank) + } + } + + resourceDocs += 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. + |For each account the API returns the account ID and the views available to the user. + |Each account must have at least one private View. + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, basicAccountsJSON, + List(BankNotFound, UnknownError), + List(apiTagAccount, apiTagPrivateData, apiTagPublicData), None, + http4sPartialFunction = Some(getPrivateAccountsAtOneBank)) + + // ─── corePrivateAccountsAtOneBank ───────────────────────────────────────── + + val corePrivateAccountsAtOneBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "my" / "banks" / _ / "accounts" => + EndpointHelpers.withUserAndBank(req) { (user, bank, cc) => + for { + (privateViewsUserCanAccessAtOneBank, privateAccountAccess) <- Future { + Views.views.vend.privateViewsUserCanAccessAtBank(user, bank.bankId) + } + (privateAccountsForOneBank, _) <- BankExtended(bank).privateAccountsFuture(privateAccountAccess, Some(cc)) + } yield privateAccountsForOneBank.map(account => + createCoreAccountJSON(account, net.liftweb.json.JObject(Nil))) + } + case req @ GET -> `prefixPath` / "my" / "banks" / _ / "accounts" / "private" => + EndpointHelpers.withUserAndBank(req) { (user, bank, cc) => + for { + (privateViewsUserCanAccessAtOneBank, privateAccountAccess) <- Future { + Views.views.vend.privateViewsUserCanAccessAtBank(user, bank.bankId) + } + (privateAccountsForOneBank, _) <- BankExtended(bank).privateAccountsFuture(privateAccountAccess, Some(cc)) + } yield privateAccountsForOneBank.map(account => + createCoreAccountJSON(account, net.liftweb.json.JObject(Nil))) + } + case req @ GET -> `prefixPath` / "bank" / "accounts" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + (bank, _) <- NewStyle.function.getBank(BankId(APIUtil.defaultBankId), Some(cc)) + (privateViewsUserCanAccessAtOneBank, privateAccountAccess) <- Future { + Views.views.vend.privateViewsUserCanAccessAtBank(user, bank.bankId) + } + (availablePrivateAccounts, _) <- BankExtended(bank).privateAccountsFuture(privateAccountAccess, Some(cc)) + } yield availablePrivateAccounts.map(account => + createCoreAccountJSON(account, net.liftweb.json.JObject(Nil))) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(corePrivateAccountsAtOneBank), "GET", "/my/banks/BANK_ID/accounts", + "Get Accounts at Bank (Private)", + s"""Get private accounts at one bank (Authenticated access). + |Returns the list of accounts containing private views for the user at BANK_ID. + |For each account the API returns the ID and label. + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, coreAccountsJSON, + List(AuthenticatedUserIsRequired, UnknownError), + List(apiTagAccount, apiTagPrivateData, apiTagPsd2), None, + http4sPartialFunction = Some(corePrivateAccountsAtOneBank)) + + // ─── privateAccountsAtOneBank ───────────────────────────────────────────── + + val privateAccountsAtOneBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / "private" => + EndpointHelpers.withUserAndBank(req) { (user, bank, cc) => + for { + (privateViewsUserCanAccessAtOneBank, privateAccountAccess) <- Future { + Views.views.vend.privateViewsUserCanAccessAtBank(user, bank.bankId) + } + (availablePrivateAccounts, _) <- BankExtended(bank).privateAccountsFuture(privateAccountAccess, Some(cc)) + } yield privateBankAccountsListToJson(availablePrivateAccounts, privateViewsUserCanAccessAtOneBank) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(privateAccountsAtOneBank), "GET", "/banks/BANK_ID/accounts/private", + "Get private accounts at one bank", + s"""Returns the list of private accounts at BANK_ID that the user has access to. + |For each account the API returns the ID and the available views. + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, basicAccountsJSON, + List(AuthenticatedUserIsRequired, BankNotFound, UnknownError), + List(apiTagAccount, apiTagPsd2), None, + http4sPartialFunction = Some(privateAccountsAtOneBank)) + + // ─── publicAccountsAtOneBank ────────────────────────────────────────────── + + val publicAccountsAtOneBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / "public" => + EndpointHelpers.withBank(req) { (bank, cc) => + Future { + val (publicViewsForBank, publicAccountAccess) = Views.views.vend.publicViewsForBank(bank.bankId) + val accounts = bank.publicAccounts(publicAccountAccess) + val accJson = accounts.map { account => + val viewsAvailable = publicViewsForBank + .filter(v => v.bankId == account.bankId && v.accountId == account.accountId && v.isPublic) + .map(createBasicViewJSON) + .distinct + createBasicAccountJSON(account, viewsAvailable) + } + BasicAccountsJSON(accJson) + } + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(publicAccountsAtOneBank), "GET", "/banks/BANK_ID/accounts/public", + "Get Public Accounts at Bank", + s"""Returns a list of the public accounts (Anonymous access) at BANK_ID. + |For each account the API returns the ID and the available views. + | + |${userAuthenticationMessage(false)}""".stripMargin, + EmptyBody, basicAccountsJSON, + List(UnknownError), + List(apiTagAccountPublic, apiTagAccount, apiTagPublicData), None, + http4sPartialFunction = Some(publicAccountsAtOneBank)) + + // ─── getKycDocuments ────────────────────────────────────────────────────── + + val getKycDocuments: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "customers" / customerId / "kyc_documents" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + _ <- code.util.Helper.booleanToFuture(UserHasMissingRoles + canGetAnyKycDocuments, failCode = 403, cc = Some(cc)) { + APIUtil.hasEntitlement("", user.userId, canGetAnyKycDocuments) + } + (_, cc2) <- NewStyle.function.getCustomerByCustomerId(customerId, Some(cc)) + (kycDocuments, _) <- NewStyle.function.getKycDocuments(customerId, cc2) + } yield createKycDocumentsJSON(kycDocuments) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getKycDocuments), "GET", "/customers/CUSTOMER_ID/kyc_documents", + "Get Customer KYC Documents", + s"""Get KYC (know your customer) documents for a customer specified by CUSTOMER_ID + |Get a list of documents that affirm the identity of the customer + |Passport, driving licence etc. + |${userAuthenticationMessage(false)}""".stripMargin, + EmptyBody, kycDocumentsJSON, + List(AuthenticatedUserIsRequired, CustomerNotFoundByCustomerId, UnknownError), + List(apiTagKyc, apiTagCustomer), + Some(List(canGetAnyKycDocuments)), + http4sPartialFunction = Some(getKycDocuments)) + + // ─── getKycMedia ────────────────────────────────────────────────────────── + + val getKycMedia: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "customers" / customerId / "kyc_media" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + _ <- code.util.Helper.booleanToFuture(UserHasMissingRoles + canGetAnyKycMedia, failCode = 403, cc = Some(cc)) { + APIUtil.hasEntitlement("", user.userId, canGetAnyKycMedia) + } + (_, cc2) <- NewStyle.function.getCustomerByCustomerId(customerId, Some(cc)) + (kycMedias, _) <- NewStyle.function.getKycMedias(customerId, cc2) + } yield createKycMediasJSON(kycMedias) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getKycMedia), "GET", "/customers/CUSTOMER_ID/kyc_media", + "Get KYC Media for a customer", + s"""Get KYC media (scans, pictures, videos) that affirms the identity of the customer. + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, kycMediasJSON, + List(AuthenticatedUserIsRequired, CustomerNotFoundByCustomerId, UnknownError), + List(apiTagKyc, apiTagCustomer), + Some(List(canGetAnyKycMedia)), + http4sPartialFunction = Some(getKycMedia)) + + // ─── getKycChecks ───────────────────────────────────────────────────────── + + val getKycChecks: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "customers" / customerId / "kyc_checks" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + _ <- code.util.Helper.booleanToFuture(UserHasMissingRoles + canGetAnyKycChecks, failCode = 403, cc = Some(cc)) { + APIUtil.hasEntitlement("", user.userId, canGetAnyKycChecks) + } + (_, cc2) <- NewStyle.function.getCustomerByCustomerId(customerId, Some(cc)) + (kycChecks, _) <- NewStyle.function.getKycChecks(customerId, cc2) + } yield createKycChecksJSON(kycChecks) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getKycChecks), "GET", "/customers/CUSTOMER_ID/kyc_checks", + "Get Customer KYC Checks", + s"""Get KYC checks for the Customer specified by CUSTOMER_ID. + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, kycChecksJSON, + List(AuthenticatedUserIsRequired, CustomerNotFoundByCustomerId, UnknownError), + List(apiTagKyc, apiTagCustomer), + Some(List(canGetAnyKycChecks)), + http4sPartialFunction = Some(getKycChecks)) + + // ─── getKycStatuses ─────────────────────────────────────────────────────── + + val getKycStatuses: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "customers" / customerId / "kyc_statuses" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + _ <- code.util.Helper.booleanToFuture(UserHasMissingRoles + canGetAnyKycStatuses, failCode = 403, cc = Some(cc)) { + APIUtil.hasEntitlement("", user.userId, canGetAnyKycStatuses) + } + (_, cc2) <- NewStyle.function.getCustomerByCustomerId(customerId, Some(cc)) + (kycStatuses, _) <- NewStyle.function.getKycStatuses(customerId, cc2) + } yield createKycStatusesJSON(kycStatuses) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getKycStatuses), "GET", "/customers/CUSTOMER_ID/kyc_statuses", + "Get Customer KYC statuses", + s"""Get the KYC statuses for a customer specified by CUSTOMER_ID over time. + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, kycStatusesJSON, + List(AuthenticatedUserIsRequired, CustomerNotFoundByCustomerId, UnknownError), + List(apiTagKyc, apiTagCustomer), + Some(List(canGetAnyKycStatuses)), + http4sPartialFunction = Some(getKycStatuses)) + + // ─── getSocialMediaHandles ──────────────────────────────────────────────── + + val getSocialMediaHandles: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "customers" / customerId / "social_media_handles" => + EndpointHelpers.withUserAndBank(req) { (user, bank, cc) => + for { + _ <- code.util.Helper.booleanToFuture(UserHasMissingRoles + canGetSocialMediaHandles, failCode = 403, cc = Some(cc)) { + APIUtil.hasEntitlement(bank.bankId.value, user.userId, canGetSocialMediaHandles) + } + (customer, _) <- NewStyle.function.getCustomerByCustomerId(customerId, Some(cc)) + } yield { + val socialMedias = SocialMediaHandle.socialMediaHandleProvider.vend.getSocialMedias(customer.number) + createSocialMediasJSON(socialMedias) + } + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getSocialMediaHandles), "GET", + "/banks/BANK_ID/customers/CUSTOMER_ID/social_media_handles", + "Get Customer Social Media Handles", + s"""Get social media handles for a customer specified by CUSTOMER_ID. + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, socialMediasJSON, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, CustomerNotFoundByCustomerId, UnknownError), + List(apiTagCustomer), + Some(List(canGetSocialMediaHandles)), + http4sPartialFunction = Some(getSocialMediaHandles)) + + // ─── addKycDocument ─────────────────────────────────────────────────────── + + val addKycDocument: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "customers" / customerId / "kyc_documents" / documentId => + EndpointHelpers.withUserAndBankAndBodyCreated[PostKycDocumentJSON, KycDocumentJSON](req) { (user, bank, body, cc) => + for { + _ <- code.util.Helper.booleanToFuture(UserHasMissingRoles + canAddKycDocument, failCode = 403, cc = Some(cc)) { + APIUtil.hasEntitlement(bank.bankId.value, user.userId, canAddKycDocument) + } + (_, cc2) <- NewStyle.function.getCustomerByCustomerId(customerId, Some(cc)) + (kycDocumentCreated, _) <- NewStyle.function.createOrUpdateKycDocument( + bank.bankId.value, customerId, documentId, + body.customer_number, body.`type`, body.number, + body.issue_date, body.issue_place, body.expiry_date, cc2) + } yield createKycDocumentJSON(kycDocumentCreated) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(addKycDocument), "PUT", + "/banks/BANK_ID/customers/CUSTOMER_ID/kyc_documents/KYC_DOCUMENT_ID", + "Add KYC Document", + "Add a KYC document for the customer specified by CUSTOMER_ID.", + postKycDocumentJSON, kycDocumentJSON, + List(AuthenticatedUserIsRequired, InvalidJsonFormat, BankNotFound, CustomerNotFoundByCustomerId, UnknownError), + List(apiTagKyc, apiTagCustomer), + Some(List(canAddKycDocument)), + http4sPartialFunction = Some(addKycDocument)) + + // ─── addKycMedia ────────────────────────────────────────────────────────── + + val addKycMedia: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "customers" / customerId / "kyc_media" / mediaId => + EndpointHelpers.withUserAndBankAndBodyCreated[PostKycMediaJSON, KycMediaJSON](req) { (user, bank, body, cc) => + for { + _ <- code.util.Helper.booleanToFuture(UserHasMissingRoles + canAddKycMedia, failCode = 403, cc = Some(cc)) { + APIUtil.hasEntitlement(bank.bankId.value, user.userId, canAddKycMedia) + } + (_, cc2) <- NewStyle.function.getCustomerByCustomerId(customerId, Some(cc)) + (kycMediaCreated, _) <- NewStyle.function.createOrUpdateKycMedia( + bank.bankId.value, customerId, mediaId, + body.customer_number, body.`type`, body.url, body.date, + body.relates_to_kyc_document_id, body.relates_to_kyc_check_id, cc2) + } yield createKycMediaJSON(kycMediaCreated) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(addKycMedia), "PUT", + "/banks/BANK_ID/customers/CUSTOMER_ID/kyc_media/KYC_MEDIA_ID", + "Add KYC Media", + "Add some KYC media for the customer specified by CUSTOMER_ID.", + postKycMediaJSON, kycMediaJSON, + List(AuthenticatedUserIsRequired, InvalidJsonFormat, CustomerNotFoundByCustomerId, ServerAddDataError, UnknownError), + List(apiTagKyc, apiTagCustomer), + Some(List(canAddKycMedia)), + http4sPartialFunction = Some(addKycMedia)) + + // ─── addKycCheck ────────────────────────────────────────────────────────── + + val addKycCheck: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "customers" / customerId / "kyc_check" / checkId => + EndpointHelpers.withUserAndBankAndBodyCreated[PostKycCheckJSON, KycCheckJSON](req) { (user, bank, body, cc) => + for { + _ <- code.util.Helper.booleanToFuture(UserHasMissingRoles + canAddKycCheck, failCode = 403, cc = Some(cc)) { + APIUtil.hasEntitlement(bank.bankId.value, user.userId, canAddKycCheck) + } + (_, cc2) <- NewStyle.function.getCustomerByCustomerId(customerId, Some(cc)) + (kycCheck, _) <- NewStyle.function.createOrUpdateKycCheck( + bank.bankId.value, customerId, checkId, + body.customer_number, body.date, body.how, + body.staff_user_id, body.staff_name, body.satisfied, body.comments, cc2) + } yield createKycCheckJSON(kycCheck) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(addKycCheck), "PUT", + "/banks/BANK_ID/customers/CUSTOMER_ID/kyc_check/KYC_CHECK_ID", + "Add KYC Check", + "Add a KYC check for the customer specified by CUSTOMER_ID.", + postKycCheckJSON, kycCheckJSON, + List(AuthenticatedUserIsRequired, InvalidJsonFormat, BankNotFound, CustomerNotFoundByCustomerId, ServerAddDataError, UnknownError), + List(apiTagKyc, apiTagCustomer), + Some(List(canAddKycCheck)), + http4sPartialFunction = Some(addKycCheck)) + + // ─── addKycStatus ───────────────────────────────────────────────────────── + + val addKycStatus: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "customers" / customerId / "kyc_statuses" => + EndpointHelpers.withUserAndBankAndBodyCreated[PostKycStatusJSON, KycStatusJSON](req) { (user, bank, body, cc) => + for { + _ <- code.util.Helper.booleanToFuture(UserHasMissingRoles + canAddKycStatus, failCode = 403, cc = Some(cc)) { + APIUtil.hasEntitlement(bank.bankId.value, user.userId, canAddKycStatus) + } + (_, cc2) <- NewStyle.function.getCustomerByCustomerId(customerId, Some(cc)) + (kycStatus, _) <- NewStyle.function.createOrUpdateKycStatus( + bank.bankId.value, customerId, + body.customer_number, body.ok, body.date, cc2) + } yield createKycStatusJSON(kycStatus) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(addKycStatus), "PUT", + "/banks/BANK_ID/customers/CUSTOMER_ID/kyc_statuses", + "Add KYC Status", + "Add a kyc_status for the customer specified by CUSTOMER_ID.", + postKycStatusJSON, kycStatusJSON, + List(AuthenticatedUserIsRequired, InvalidJsonFormat, InvalidBankIdFormat, UnknownError, BankNotFound, ServerAddDataError, CustomerNotFoundByCustomerId), + List(apiTagKyc, apiTagCustomer), + Some(List(canAddKycStatus)), + http4sPartialFunction = Some(addKycStatus)) + + // ─── addSocialMediaHandle ───────────────────────────────────────────────── + + val addSocialMediaHandle: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "customers" / customerId / "social_media_handles" => + EndpointHelpers.withUserAndBankAndBodyCreated[SocialMediaJSON, SuccessMessage](req) { (user, bank, body, cc) => + for { + _ <- code.util.Helper.booleanToFuture(InvalidBankIdFormat, cc = Some(cc)) { + isValidID(bank.bankId.value) + } + _ <- code.util.Helper.booleanToFuture(UserHasMissingRoles + canAddSocialMediaHandle, failCode = 403, cc = Some(cc)) { + APIUtil.hasEntitlement(bank.bankId.value, user.userId, canAddSocialMediaHandle) + } + (_, _) <- NewStyle.function.getCustomerByCustomerId(customerId, Some(cc)) + _ <- code.util.Helper.booleanToFuture("Server error: could not add", cc = Some(cc)) { + SocialMediaHandle.socialMediaHandleProvider.vend.addSocialMedias( + body.customer_number, body.`type`, body.handle, + body.date_added, body.date_activated) + } + } yield SuccessMessage("Success") + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(addSocialMediaHandle), "POST", + "/banks/BANK_ID/customers/CUSTOMER_ID/social_media_handles", + "Create Customer Social Media Handle", + "Create a customer social media handle for the customer specified by CUSTOMER_ID", + socialMediaJSON, successMessage, + List(AuthenticatedUserIsRequired, InvalidJsonFormat, InvalidBankIdFormat, UserHasMissingRoles, CustomerNotFoundByCustomerId, UnknownError), + List(apiTagCustomer), + Some(List(canAddSocialMediaHandle)), + http4sPartialFunction = Some(addSocialMediaHandle)) + + // ─── getCoreAccountById ─────────────────────────────────────────────────── + + val getCoreAccountById: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "my" / "banks" / _ / "accounts" / _ / "account" => + EndpointHelpers.withBankAccount(req) { (user, account, cc) => + for { + view <- ViewNewStyle.checkOwnerViewAccessAndReturnOwnerView(user, BankIdAccountId(account.bankId, account.accountId), Some(cc)) + moderatedAccount <- Future { + unboxFullOrFail( + account.moderatedBankAccount(view, BankIdAccountId(account.bankId, account.accountId), Full(user), Some(cc)), + Some(cc), UnknownError) + } + } yield createCoreBankAccountJSON(moderatedAccount) + } + } + + resourceDocs += 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: + | + |* Number + |* Owners + |* Type + |* Balance + |* IBAN + | + |This call returns the owner view and requires access to that view. + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, moderatedCoreAccountJSON, + List(BankAccountNotFound, UnknownError), + List(apiTagAccount, apiTagPsd2, apiTagOldStyle), None, + http4sPartialFunction = Some(getCoreAccountById)) + + // ─── getCoreTransactionsForBankAccount ──────────────────────────────────── + + val getCoreTransactionsForBankAccount: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "my" / "banks" / _ / "accounts" / _ / "transactions" => + EndpointHelpers.withBankAccount(req) { (user, account, cc) => + for { + view <- ViewNewStyle.checkOwnerViewAccessAndReturnOwnerView(user, BankIdAccountId(account.bankId, account.accountId), Some(cc)) + (bank, _) <- NewStyle.function.getBank(account.bankId, Some(cc)) + httpParams <- NewStyle.function.extractHttpParamsFromUrl(cc.url) + (obpQueryParams, _) <- createQueriesByHttpParamsFuture(httpParams, Some(cc)) + (transactions, _) <- Future { + unboxFullOrFail( + account.getModeratedTransactions(bank, Full(user), view, BankIdAccountId(account.bankId, account.accountId), Some(cc), obpQueryParams), + Some(cc), UnknownError) + } + } yield createCoreTransactionsJSON(transactions) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCoreTransactionsForBankAccount), "GET", + "/my/banks/BANK_ID/accounts/ACCOUNT_ID/transactions", + "Get Transactions for Account (Core)", + s"""Returns transactions list (Core info) of the account specified by ACCOUNT_ID. + | + |Authentication is required. + | + |${urlParametersDocument(true, true)}""", + EmptyBody, coreTransactionsJSON, + List(BankAccountNotFound, UnknownError), + List(apiTagTransaction, apiTagAccount, apiTagPsd2, apiTagOldStyle), None, + http4sPartialFunction = Some(getCoreTransactionsForBankAccount)) + + // ─── accountById ────────────────────────────────────────────────────────── + + val accountById: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "account" => + EndpointHelpers.withView(req) { (user, account, view, cc) => + for { + availableViews <- Future { + Views.views.vend.privateViewsUserCanAccessForAccount(user, BankIdAccountId(account.bankId, account.accountId)) + } + moderatedAccount <- Future { + unboxFullOrFail( + account.moderatedBankAccount(view, BankIdAccountId(account.bankId, account.accountId), Full(user), Some(cc)), + Some(cc), UnknownError) + } + } yield { + val viewsAvailable = availableViews.map(JSONFactory121.createViewJSON).sortBy(_.short_name) + JSONFactory121.createBankAccountJSON(moderatedAccount, viewsAvailable) + } + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(accountById), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/account", + "Get Account by Id (Full)", + s"""Information returned about an account specified by ACCOUNT_ID as moderated by the view (VIEW_ID). + | + |${userAuthenticationMessage(true)} if the 'is_public' field in view (VIEW_ID) is not set to `true`.""".stripMargin, + EmptyBody, moderatedAccountJSON, + List(BankNotFound, AccountNotFound, ViewNotFound, UserNoPermissionAccessView, UnknownError), + List(apiTagAccount, apiTagOldStyle), None, + http4sPartialFunction = Some(accountById)) + + // ─── getPermissionsForBankAccount ───────────────────────────────────────── + + val getPermissionsForBankAccount: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / "permissions" => + EndpointHelpers.withBankAccount(req) { (user, account, cc) => + val bankIdAccountId = BankIdAccountId(account.bankId, account.accountId) + for { + hasPermission <- Future { + Views.views.vend.permission(bankIdAccountId, user) + .map(_.views.map(_.allowed_actions.exists(_ == CAN_SEE_VIEWS_WITH_PERMISSIONS_FOR_ALL_USERS))) + .getOrElse(Nil).find(_ == true).getOrElse(false) + } + _ <- code.util.Helper.booleanToFuture( + s"${ViewDoesNotPermitAccess} You need the `$CAN_SEE_VIEWS_WITH_PERMISSIONS_FOR_ALL_USERS` permission on any your views", + cc = Some(cc) + ) { hasPermission } + permissions <- Future { Views.views.vend.permissions(bankIdAccountId) } + } yield JSONFactory121.createPermissionsJSON(permissions.sortBy(_.user.emailAddress)) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getPermissionsForBankAccount), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/permissions", + "Get access", + s"""Returns the list of the permissions at BANK_ID for account ACCOUNT_ID, with each time a pair composed of the user and the views that he has access to. + | + |${userAuthenticationMessage(true)} + |and the user needs to have access to the owner view.""", + EmptyBody, permissionsJSON, + List(AuthenticatedUserIsRequired, BankNotFound, AccountNotFound, UnknownError), + List(apiTagView, apiTagAccount, apiTagUser, apiTagEntitlement), None, + http4sPartialFunction = Some(getPermissionsForBankAccount)) + + // ─── getPermissionForUserForBankAccount ─────────────────────────────────── + + val getPermissionForUserForBankAccount: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / "permissions" / provider / providerId => + EndpointHelpers.withBankAccount(req) { (user, account, cc) => + val bankIdAccountId = BankIdAccountId(account.bankId, account.accountId) + for { + hasPermission <- Future { + Views.views.vend.permission(bankIdAccountId, user) + .map(_.views.map(_.allowed_actions.exists(_ == CAN_SEE_VIEWS_WITH_PERMISSIONS_FOR_ONE_USER))) + .getOrElse(Nil).find(_ == true).getOrElse(false) + } + _ <- code.util.Helper.booleanToFuture( + s"${CreateCustomViewError} You need the `$CAN_SEE_VIEWS_WITH_PERMISSIONS_FOR_ONE_USER` permission on any your views", + cc = Some(cc) + ) { hasPermission } + userFromURL <- Future { + unboxFullOrFail( + UserX.findByProviderId(provider, providerId), + Some(cc), UserNotFoundByProviderAndProvideId) + } + permission <- Future { + unboxFullOrFail( + Views.views.vend.permission(bankIdAccountId, userFromURL), + Some(cc), UserNotFoundByProviderAndProvideId) + } + } yield JSONFactory121.createViewsJSON(permission.views.sortBy(_.viewId.value)) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getPermissionForUserForBankAccount), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/permissions/PROVIDER/PROVIDER_ID", + "Get Account access for User", + s"""Returns the list of the views at BANK_ID for account ACCOUNT_ID that a user identified by PROVIDER_ID at their provider PROVIDER has access to. + | + |${userAuthenticationMessage(true)} + | + |The user needs to have access to the owner view.""", + EmptyBody, viewsJSONV121, + List(AuthenticatedUserIsRequired, BankNotFound, AccountNotFound, UnknownError), + List(apiTagView, apiTagAccount, apiTagUser, apiTagOldStyle), None, + http4sPartialFunction = Some(getPermissionForUserForBankAccount)) + + // ─── createAccount ──────────────────────────────────────────────────────── + + val createAccount: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "accounts" / _ => + EndpointHelpers.withUserAndBankAndBody[CreateAccountJSON, CoreAccountJSON](req) { (user, bank, body, cc) => + for { + _ <- code.util.Helper.booleanToFuture(InvalidAccountIdFormat, cc = Some(cc)) { + isValidID(cc.bankAccount.map(_.accountId.value).getOrElse( + req.uri.path.segments.lastOption.map(_.encoded).getOrElse(""))) + } + _ <- code.util.Helper.booleanToFuture(InvalidBankIdFormat, cc = Some(cc)) { + isValidID(bank.bankId.value) + } + loggedInUserId = user.userId + userIdAccountOwner = if (body.user_id.nonEmpty) body.user_id else loggedInUserId + (postedOrLoggedInUser, cc2) <- 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, cc2) { + BigDecimal(body.balance.amount) + } + _ <- code.util.Helper.booleanToFuture(InitialBalanceMustBeZero, cc = cc2) { + initialBalanceAsNumber == 0 + } + _ <- code.util.Helper.booleanToFuture(InvalidISOCurrencyCode, cc = cc2) { + isValidCurrencyISOCode(body.balance.currency) + } + accountId = cc.bankAccount.map(_.accountId).getOrElse( + AccountId(req.uri.path.segments.lastOption.map(_.encoded).getOrElse(""))) + (bankAccount, cc3) <- NewStyle.function.createBankAccount( + bank.bankId, accountId, body.`type`, body.label, body.balance.currency, + initialBalanceAsNumber, postedOrLoggedInUser.name, "", List.empty, cc2) + _ <- BankAccountCreation.setAccountHolderAndRefreshUserAccountAccess( + bank.bankId, accountId, postedOrLoggedInUser, cc3) + } yield createCoreAccountJSON(bankAccount, net.liftweb.json.JObject(Nil)) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(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 an Account for another User if they have CanCreateAccount role. + | + |If USER_ID is not specified the account will be owned by the logged in User. + | + |Note: The Amount must be zero.""".stripMargin, + CreateAccountJSON("A user_id", "CURRENT", "Label", AmountOfMoneyJsonV121("EUR", "0")), + coreAccountJSON, + List(AuthenticatedUserIsRequired, InvalidJsonFormat, InvalidUserId, InvalidAccountIdFormat, InvalidBankIdFormat, + UserNotFoundById, InvalidAccountBalanceAmount, InvalidAccountType, InvalidAccountInitialBalance, + InvalidAccountBalanceCurrency, UnknownError), + List(apiTagAccount, apiTagOldStyle), + None, + http4sPartialFunction = Some(createAccount)) + + // ─── getTransactionTypes ────────────────────────────────────────────────── + + private val getTransactionTypesIsPublic = APIUtil.getPropsAsBoolValue("apiOptions.getTransactionTypesIsPublic", true) + + val getTransactionTypes: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "transaction-types" => + EndpointHelpers.withBank(req) { (bank, cc) => + Future { + val types = TransactionType.TransactionTypeProvider.vend.getTransactionTypesForBank(bank.bankId) + JSONFactory200.createTransactionTypeJSON(connectorEmptyResponse(types, Some(cc))) + } + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getTransactionTypes), "GET", + "/banks/BANK_ID/transaction-types", + "Get Transaction Types at Bank", + s"""Get Transaction Types for the bank specified by BANK_ID. + | + |${userAuthenticationMessage(!getTransactionTypesIsPublic)}""".stripMargin, + EmptyBody, transactionTypesJsonV200, + List(BankNotFound, UnknownError), + List(apiTagBank, apiTagPSD2AIS, apiTagPsd2), None, + http4sPartialFunction = Some(getTransactionTypes)) + + // ─── createUser ─────────────────────────────────────────────────────────── + + val createUser: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "users" => + EndpointHelpers.executeFutureWithBodyCreated[CreateUserJson, JSONFactory200.UserJsonV200](req) { (body, cc) => + for { + _ <- code.util.Helper.booleanToFuture(InvalidStrongPasswordFormat, cc = Some(cc)) { + fullPasswordValidation(body.password) + } + _ <- code.util.Helper.booleanToFuture(DuplicateUsername, failCode = 409, cc = Some(cc)) { + AuthUser.find(By(AuthUser.username, body.username)).isEmpty + } + userCreated <- Future { + AuthUser.create + .firstName(body.first_name) + .lastName(body.last_name) + .username(body.username) + .email(body.email) + .password(body.password) + .validated(APIUtil.getPropsAsBoolValue("authUser.skipEmailValidation", defaultValue = false)) + } + _ <- code.util.Helper.booleanToFuture( + InvalidJsonFormat + userCreated.validate.map(_.msg).mkString(";"), cc = Some(cc)) { + userCreated.validate.isEmpty + } + savedUser <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + userCreated.saveMe() + } + _ <- code.util.Helper.booleanToFuture(s"$UnknownError Error occurred during user creation.", cc = Some(cc)) { + userCreated.saved_? + } + } yield { + val skipEmailValidation = APIUtil.getPropsAsBoolValue("authUser.skipEmailValidation", defaultValue = false) + if (!skipEmailValidation) AuthUser.sendValidationEmail(savedUser) + AuthUser.grantDefaultEntitlementsToAuthUser(savedUser) + createUserJSONfromAuthUser(userCreated) + } + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createUser), "POST", "/users", + "Create User", + s"""Creates OBP user. No authorisation required.""", + createUserJson, userJsonV200, + List(InvalidJsonFormat, InvalidStrongPasswordFormat, DuplicateUsername, ExternalUserCheckFailed, UnknownError), + List(apiTagUser, apiTagOnboarding), None, + http4sPartialFunction = Some(createUser)) + + // ─── createCustomer ─────────────────────────────────────────────────────── + + val createCustomer: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "customers" => + EndpointHelpers.withUserAndBankAndBodyCreated[CreateCustomerJson, JSONFactory1_4_0.CustomerJsonV140](req) { (user, bank, body, cc) => + for { + _ <- code.util.Helper.booleanToFuture(InvalidBankIdFormat, cc = Some(cc)) { + isValidID(bank.bankId.value) + } + _ <- code.util.Helper.booleanToFuture( + s"${InvalidJsonFormat} customer_number can not contain `::::` characters", cc = Some(cc)) { + !`checkIfContains::::` (body.customer_number) + } + _ <- code.util.Helper.booleanToFuture( + s"${UserHasMissingRoles}${canCreateCustomer} and ${canCreateUserCustomerLink} entitlements are required for BankId(${bank.bankId.value}).", + failCode = 403, cc = Some(cc) + ) { + APIUtil.hasAllEntitlements(bank.bankId.value, user.userId, canCreateCustomer :: canCreateUserCustomerLink :: Nil) + } + _ <- code.util.Helper.booleanToFuture(CustomerNumberAlreadyExists, cc = Some(cc)) { + CustomerX.customerProvider.vend.checkCustomerNumberAvailable(bank.bankId, body.customer_number) + } + userId = if (body.user_id.nonEmpty) body.user_id else user.userId + (_, _) <- NewStyle.function.findByUserId(userId, Some(cc)) + customer <- Future { + CustomerX.customerProvider.vend.addCustomer( + bank.bankId, body.customer_number, body.legal_name, body.mobile_phone_number, body.email, + CustomerFaceImage(body.face_image.date, body.face_image.url), + body.date_of_birth, body.relationship_status, body.dependants, body.dob_of_dependants, + body.highest_education_attained, body.employment_status, body.kyc_status, body.last_ok_date, + None, None, "", "", "" + ).getOrElse(throw new RuntimeException(CreateConsumerError)) + } + _ <- code.util.Helper.booleanToFuture(CustomerAlreadyExistsForUser, cc = Some(cc)) { + UserCustomerLink.userCustomerLink.vend.getUserCustomerLink(userId, customer.customerId).isEmpty + } + _ <- Future { + UserCustomerLink.userCustomerLink.vend + .createUserCustomerLink(userId, customer.customerId, new Date(), true) + .getOrElse(throw new RuntimeException(CreateUserCustomerLinksError)) + } + } yield JSONFactory1_4_0.createCustomerJson(customer) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createCustomer), "POST", + "/banks/BANK_ID/customers", + "Create Customer", + s"""Add a customer linked to the user specified by user_id. + |Dates need to be in the format 2013-01-21T23:08:00Z + |${userAuthenticationMessage(true)}""", + createCustomerJson, customerJsonV140, + List(InvalidBankIdFormat, AuthenticatedUserIsRequired, BankNotFound, CustomerNumberAlreadyExists, + UserHasMissingRoles, UserNotFoundById, CreateConsumerError, CustomerAlreadyExistsForUser, + CreateUserCustomerLinksError, UnknownError), + List(apiTagCustomer, apiTagPerson, apiTagOldStyle), + Some(List(canCreateCustomer, canCreateUserCustomerLink)), + http4sPartialFunction = Some(createCustomer)) + + // ─── getCurrentUser ─────────────────────────────────────────────────────── + + val getCurrentUser: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "users" / "current" => + EndpointHelpers.withUser(req) { (user, cc) => + Future.successful(createUserJSON(user)) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCurrentUser), "GET", "/users/current", + "Get User (Current)", + """Get the logged in user + | + |Login is required.""".stripMargin, + EmptyBody, userJsonV200, + List(AuthenticatedUserIsRequired, UnknownError), + List(apiTagUser, apiTagOldStyle), None, + http4sPartialFunction = Some(getCurrentUser)) + + // ─── getUser ────────────────────────────────────────────────────────────── + + val getUser: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "users" / userEmail => + EndpointHelpers.withUser(req) { (user, cc) => + for { + _ <- code.util.Helper.booleanToFuture(UserHasMissingRoles + canGetAnyUser, failCode = 403, cc = Some(cc)) { + APIUtil.hasEntitlement("", user.userId, canGetAnyUser) + } + users <- Future { + AuthUser.getResourceUsersByEmail(userEmail) + } + } yield JSONFactory200.createUserJSONs(users) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getUser), "GET", "/users/USER_EMAIL", + "Get Users by Email Address", + """Get users by email address + | + |Login is required. + |CanGetAnyUser entitlement is required.""".stripMargin, + EmptyBody, usersJsonV200, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, UserNotFoundByEmail, UnknownError), + List(apiTagUser, apiTagOldStyle), + Some(List(canGetAnyUser)), + http4sPartialFunction = Some(getUser)) + + // ─── createUserCustomerLinks ────────────────────────────────────────────── + + val createUserCustomerLinks: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "user_customer_links" => + EndpointHelpers.withUserAndBankAndBody[CreateUserCustomerLinkJson, UserCustomerLinkJson](req) { (_, bank, body, cc) => + for { + _ <- code.util.Helper.booleanToFuture(s"$InvalidBankIdFormat", cc = Some(cc)) { + isValidID(bank.bankId.value) + } + _ <- code.util.Helper.booleanToFuture("Field customer_id is not defined in the posted json!", cc = Some(cc)) { + body.customer_id.nonEmpty + } + targetUser <- Users.users.vend.getUserByUserIdFuture(body.user_id) map { + x => unboxFullOrFail(x, Some(cc), UserNotFoundByUserId, 404) + } + (customer, cc2) <- NewStyle.function.getCustomerByCustomerId(body.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", + cc = cc2) { + customer.bankId == bank.bankId.value + } + _ <- code.util.Helper.booleanToFuture(CustomerAlreadyExistsForUser, cc = cc2) { + UserCustomerLink.userCustomerLink.vend.getUserCustomerLink(body.user_id, body.customer_id).isEmpty + } + userCustomerLink <- Future { + unboxFullOrFail( + UserCustomerLink.userCustomerLink.vend.createUserCustomerLink(body.user_id, body.customer_id, new Date(), true), + cc2, CreateUserCustomerLinksError, 400) + } + _ <- AuthUser.refreshUser(targetUser, cc2) + } yield createUserCustomerLinkJSON(userCustomerLink) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(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, apiTagOldStyle), + Some(List(canCreateUserCustomerLink, canCreateUserCustomerLinkAtAnyBank)), + http4sPartialFunction = Some(createUserCustomerLinks)) + + // ─── addEntitlement ─────────────────────────────────────────────────────── + + val addEntitlement: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "users" / userId / "entitlements" => + EndpointHelpers.withUserAndBodyCreated[CreateEntitlementJSON, EntitlementJSON](req) { (user, body, cc) => + for { + (_, cc2) <- NewStyle.function.findByUserId(userId, Some(cc)) + role <- Future { + unboxFullOrFail( + net.liftweb.util.Helpers.tryo { ApiRole.valueOf(body.role_name) }, + Some(cc), IncorrectRoleName + body.role_name + ". Possible roles are " + ApiRole.availableRoles.sorted.mkString(", ")) + } + _ <- code.util.Helper.booleanToFuture( + if (ApiRole.valueOf(body.role_name).requiresBankId) EntitlementIsBankRole else EntitlementIsSystemRole, + cc = cc2) { + ApiRole.valueOf(body.role_name).requiresBankId == body.bank_id.nonEmpty + } + requiredEntitlements = canCreateEntitlementAtOneBank :: canCreateEntitlementAtAnyBank :: Nil + requiredEntitlementsTxt = UserNotSuperAdmin + " or" + UserHasMissingRoles + canCreateEntitlementAtOneBank + + s" BankId(${body.bank_id})." + " or" + UserHasMissingRoles + canCreateEntitlementAtAnyBank + _ <- if (isSuperAdmin(user.userId)) Future.successful(Full(())) + else code.util.Helper.booleanToFuture(requiredEntitlementsTxt, failCode = 403, cc = cc2) { + APIUtil.hasAtLeastOneEntitlement(body.bank_id, user.userId, requiredEntitlements) + } + _ <- code.util.Helper.booleanToFuture(BankNotFound, cc = cc2) { + body.bank_id.isEmpty || BankX(BankId(body.bank_id), cc2).map(_._1).isDefined + } + _ <- code.util.Helper.booleanToFuture(EntitlementAlreadyExists, cc = cc2) { + !hasEntitlement(body.bank_id, userId, role) + } + addedEntitlement <- Future { + unboxFull(Entitlement.entitlement.vend.addEntitlement(body.bank_id, userId, body.role_name)) + } + } yield JSONFactory200.createEntitlementJSON(addedEntitlement) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(addEntitlement), "POST", + "/users/USER_ID/entitlements", + "Add Entitlement for a User", + """Create Entitlement. Grant Role to User. + | + |Entitlements are used to grant System or Bank level roles to Users. + | + |Authentication is required and the user needs to be a Super Admin.""".stripMargin, + code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON.createEntitlementJSON, entitlementJSON, + List(AuthenticatedUserIsRequired, UserNotFoundById, UserNotSuperAdmin, InvalidJsonFormat, + IncorrectRoleName, EntitlementIsBankRole, EntitlementIsSystemRole, EntitlementAlreadyExists, UnknownError), + List(apiTagRole, apiTagEntitlement, apiTagUser), + None, // bank comes from request body, not URL — middleware can't check, handler does it inline + http4sPartialFunction = Some(addEntitlement)) + + // ─── getEntitlements ────────────────────────────────────────────────────── + + val getEntitlements: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "users" / userId / "entitlements" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + _ <- code.util.Helper.booleanToFuture(UserHasMissingRoles + canGetEntitlementsForAnyUserAtAnyBank, failCode = 403, cc = Some(cc)) { + APIUtil.hasEntitlement("", user.userId, canGetEntitlementsForAnyUserAtAnyBank) + } + entitlements <- Entitlement.entitlement.vend.getEntitlementsByUserIdFuture(userId) map { + connectorEmptyResponse(_, Some(cc)) + } + } yield { + if (isSuperAdmin(userId)) JSONFactory200.withVirtualEntitlements(entitlements, APIUtil.superAdminVirtualRoles) + else if (isOidcOperator(userId)) JSONFactory200.withVirtualEntitlements(entitlements, APIUtil.oidcOperatorVirtualRoles) + else JSONFactory200.createEntitlementJSONs(entitlements) + } + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getEntitlements), "GET", + "/users/USER_ID/entitlements", + "Get Entitlements for User", + s"""${userAuthenticationMessage(true)}""", + EmptyBody, entitlementJSONs, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagRole, apiTagEntitlement, apiTagUser, apiTagOldStyle), + Some(List(canGetEntitlementsForAnyUserAtAnyBank)), + http4sPartialFunction = Some(getEntitlements)) + + // ─── deleteEntitlement ──────────────────────────────────────────────────── + + val deleteEntitlement: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "users" / userId / "entitlement" / entitlementId => + EndpointHelpers.withUserDelete(req) { (user, cc) => + for { + _ <- code.util.Helper.booleanToFuture(UserHasMissingRoles + canDeleteEntitlementAtAnyBank, failCode = 403, cc = Some(cc)) { + APIUtil.hasEntitlement("", user.userId, canDeleteEntitlementAtAnyBank) + } + entitlement <- Future { + unboxFullOrFail( + Entitlement.entitlement.vend.getEntitlementById(entitlementId), + Some(cc), EntitlementNotFound, 404) + } + _ <- code.util.Helper.booleanToFuture(UserDoesNotHaveEntitlement, cc = Some(cc)) { + entitlement.userId == userId + } + _ <- Future { + fullBoxOrException( + Entitlement.entitlement.vend.deleteEntitlement(Some(entitlement)) + ~> APIFailureNewStyle(EntitlementCannotBeDeleted, 500, Some(cc.toLight))) + } + } yield () + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteEntitlement), "DELETE", + "/users/USER_ID/entitlement/ENTITLEMENT_ID", + "Delete Entitlement", + """Delete Entitlement specified by ENTITLEMENT_ID for an user specified by USER_ID + | + |Authentication is required and the user needs to be a Super Admin.""".stripMargin, + EmptyBody, EmptyBody, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, EntitlementNotFound, UnknownError), + List(apiTagRole, apiTagUser, apiTagEntitlement), + Some(List(canDeleteEntitlementAtAnyBank)), + http4sPartialFunction = Some(deleteEntitlement)) + + // ─── getAllEntitlements ──────────────────────────────────────────────────── + + val getAllEntitlements: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "entitlements" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + _ <- code.util.Helper.booleanToFuture(UserHasMissingRoles + canGetEntitlementsForAnyUserAtAnyBank, failCode = 403, cc = Some(cc)) { + APIUtil.hasEntitlement("", user.userId, canGetEntitlementsForAnyUserAtAnyBank) + } + entitlements <- Entitlement.entitlement.vend.getEntitlementsFuture() map { + connectorEmptyResponse(_, Some(cc)) + } + } yield JSONFactory200.createEntitlementJSONs(entitlements) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAllEntitlements), "GET", "/entitlements", + "Get all Entitlements", + """Login is required.""", + EmptyBody, entitlementJSONs, + List(AuthenticatedUserIsRequired, UnknownError), + List(apiTagRole, apiTagEntitlement), + Some(List(canGetEntitlementsForAnyUserAtAnyBank)), + http4sPartialFunction = Some(getAllEntitlements)) + + // ─── elasticSearchWarehouse ─────────────────────────────────────────────── + + val esw = new elasticsearchWarehouse + + val elasticSearchWarehouse: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "search" / "warehouse" / queryString => + EndpointHelpers.withUser(req) { (user, cc) => + for { + _ <- code.util.Helper.booleanToFuture(UserHasMissingRoles + canSearchWarehouse, failCode = 403, cc = Some(cc)) { + APIUtil.hasEntitlement("", user.userId, canSearchWarehouse) + } + } yield { + val liftResp = esw.searchProxy(user.userId, queryString) + liftResp.toResponse match { + case InMemoryResponse(data, _, _, _) => + net.liftweb.json.parse(new String(data, "UTF-8")) + case _ => net.liftweb.json.JNull + } + } + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(elasticSearchWarehouse), "GET", + "/search/warehouse", + "Search Warehouse Data Via Elasticsearch", + """Search warehouse data via Elastic Search. + | + |Login is required. + |CanSearchWarehouse entitlement is required.""", + EmptyBody, emptyElasticSearch, + List(AuthenticatedUserIsRequired, BankNotFound, UserHasMissingRoles, UnknownError), + List(apiTagSearchWarehouse, apiTagOldStyle), + Some(List(canSearchWarehouse)), + http4sPartialFunction = Some(elasticSearchWarehouse)) + + // ─── elasticSearchMetrics ───────────────────────────────────────────────── + + val esm = new elasticsearchMetrics + + val elasticSearchMetrics: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "search" / "metrics" / queryString => + EndpointHelpers.withUser(req) { (user, cc) => + for { + _ <- code.util.Helper.booleanToFuture(UserHasMissingRoles + canSearchMetrics, failCode = 403, cc = Some(cc)) { + APIUtil.hasEntitlement("", user.userId, canSearchMetrics) + } + } yield { + val liftResp = esm.searchProxy(user.userId, queryString) + liftResp.toResponse match { + case InMemoryResponse(data, _, _, _) => + net.liftweb.json.parse(new String(data, "UTF-8")) + case _ => net.liftweb.json.JNull + } + } + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(elasticSearchMetrics), "GET", + "/search/metrics", + "Search API Metrics via Elasticsearch", + """Search the API calls made to this API instance via Elastic Search. + | + |Login is required. + |CanSearchMetrics entitlement is required.""", + EmptyBody, emptyElasticSearch, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagMetric, apiTagApi, apiTagOldStyle), + Some(List(canSearchMetrics)), + http4sPartialFunction = Some(elasticSearchMetrics)) + + // ─── getCustomers ───────────────────────────────────────────────────────── + + val getCustomers: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "users" / "current" / "customers" => + EndpointHelpers.withUser(req) { (user, cc) => + Future { + val customers = CustomerX.customerProvider.vend.getCustomersByUserId(user.userId) + JSONFactory1_4_0.createCustomersJson(customers) + } + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCustomers), "GET", + "/users/current/customers", + "Get all customers for logged in user", + """Information about the currently authenticated user. + | + |Authentication via OAuth is required.""", + EmptyBody, customersJsonV140, + List(AuthenticatedUserIsRequired, UserCustomerLinksNotFoundForUser, UnknownError), + List(apiTagPerson, apiTagCustomer, apiTagOldStyle), None, + http4sPartialFunction = Some(getCustomers)) + + // ─── allRoutes ──────────────────────────────────────────────────────────── + + private val allOwnRoutes: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req => + root.run(req) + .orElse(getPrivateAccountsAllBanks.run(req)) + .orElse(corePrivateAccountsAllBanks.run(req)) + .orElse(publicAccountsAllBanks.run(req)) + .orElse(getPrivateAccountsAtOneBank.run(req)) + .orElse(corePrivateAccountsAtOneBank.run(req)) + .orElse(privateAccountsAtOneBank.run(req)) + .orElse(publicAccountsAtOneBank.run(req)) + .orElse(getKycDocuments.run(req)) + .orElse(getKycMedia.run(req)) + .orElse(getKycChecks.run(req)) + .orElse(getKycStatuses.run(req)) + .orElse(getSocialMediaHandles.run(req)) + .orElse(addKycDocument.run(req)) + .orElse(addKycMedia.run(req)) + .orElse(addKycCheck.run(req)) + .orElse(addKycStatus.run(req)) + .orElse(addSocialMediaHandle.run(req)) + .orElse(getCoreAccountById.run(req)) + .orElse(getCoreTransactionsForBankAccount.run(req)) + .orElse(accountById.run(req)) + .orElse(getPermissionsForBankAccount.run(req)) + .orElse(getPermissionForUserForBankAccount.run(req)) + .orElse(createAccount.run(req)) + .orElse(getTransactionTypes.run(req)) + .orElse(createUser.run(req)) + .orElse(createCustomer.run(req)) + .orElse(getCustomers.run(req)) + .orElse(getCurrentUser.run(req)) + .orElse(getUser.run(req)) + .orElse(createUserCustomerLinks.run(req)) + .orElse(addEntitlement.run(req)) + .orElse(getEntitlements.run(req)) + .orElse(deleteEntitlement.run(req)) + .orElse(getAllEntitlements.run(req)) + .orElse(elasticSearchWarehouse.run(req)) + .orElse(elasticSearchMetrics.run(req)) + } + + val allRoutesWithMiddleware: HttpRoutes[IO] = ResourceDocMiddleware.apply(resourceDocs)(allOwnRoutes) + + // ─── path-rewriting bridge: /obp/v2.0.0/… → /obp/v1.4.0/… ────────────── + // Delegates to Http4s140 so all inherited v1.4.0/v1.3.0/v1.2.1 endpoints are + // served under the v2.0.0 URL prefix without duplicating any logic. + + val v200ToV140Bridge: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req => + val rawPath = req.uri.path.renderString + if (rawPath.startsWith("/obp/v2.0.0/")) { + val rewritten = rawPath.replaceFirst("/obp/v2\\.0\\.0/", "/obp/v1.4.0/") + val newUri = req.uri.withPath(Uri.Path.unsafeFromString(rewritten)) + val rewrittenReq = req.withUri(newUri) + code.api.v1_4_0.Http4s140.wrappedRoutesV140Services.run(rewrittenReq) + } else { + OptionT.none[IO, Response[IO]] + } + } + } + + // Own middleware-wrapped routes take priority; inherited v1.4.0/v1.3.0/v1.2.1 paths follow. + val wrappedRoutesV200Services: HttpRoutes[IO] = + Kleisli[HttpF, Request[IO], Response[IO]] { req => + Implementations2_0_0.allRoutesWithMiddleware.run(req) + .orElse(Implementations2_0_0.v200ToV140Bridge.run(req)) + } +} 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 new file mode 100644 index 0000000000..2620118cd4 --- /dev/null +++ b/obp-api/src/main/scala/code/api/v2_1_0/Http4s210.scala @@ -0,0 +1,1205 @@ +package code.api.v2_1_0 + +import cats.data.{Kleisli, OptionT} +import cats.effect._ +import code.TransactionTypes.TransactionType +import code.api.Constant.{ApiPathZero, CAN_SEE_TRANSACTION_REQUESTS} +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.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps} +import code.api.util.http4s.ResourceDocMiddleware +import code.api.util.newstyle.ViewNewStyle +import code.api.util.{APIUtil, ApiRole, CallContext, CustomJsonFormats, NewStyle} +import code.api.v1_2_1.{JSONFactory => JSONFactory121, SuccessMessage} +import code.api.v1_3_0.{JSONFactory1_3_0, PhysicalCardJSON, PostPhysicalCardJSON} +import code.api.v1_4_0.JSONFactory1_4_0 +import code.api.v2_0_0.{JSONFactory200, TransactionTypeJsonV200} +import code.api.v2_1_0.JSONFactory210._ +import code.atms.Atms +import code.bankconnectors.Connector +import code.branches.Branches +import code.consumer.Consumers +import code.customer.CustomerX +import code.entitlement.Entitlement +import code.metrics.APIMetrics +import code.model.{BankX, Consumer, UserX} +import code.products.Products +import code.sandbox.{OBPDataImport, SandboxDataImport} +import code.usercustomerlinks.UserCustomerLink +import code.users.Users +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._ +import com.openbankproject.commons.model.enums.TransactionRequestTypes._ +import com.openbankproject.commons.model.enums.{ChallengeType, SuppliedAnswerType, TransactionRequestTypes} +import com.openbankproject.commons.util.{ApiVersion, ApiVersionStatus, ScannedApiVersion} +import net.liftweb.common.{Failure, Full} +import net.liftweb.json.JsonAST.{compactRender, prettyRender} +import net.liftweb.json.JsonDSL._ +import net.liftweb.json.{Extraction, Formats, Serialization} +import net.liftweb.json.Serialization.{write => liftWrite} +import org.http4s._ +import org.http4s.dsl.io._ + +import java.util.Date +import scala.collection.mutable.ArrayBuffer +import scala.concurrent.Future + +object Http4s210 { + val implementedInApiVersion: ScannedApiVersion = ApiVersion.v2_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] + + val createCustomerEntitlementsRequiredForSpecificBank: List[ApiRole] = + canCreateCustomer :: canCreateUserCustomerLink :: Nil + val createCustomerEntitlementsRequiredForAnyBank: List[ApiRole] = + canCreateCustomerAtAnyBank :: canCreateUserCustomerLinkAtAnyBank :: Nil + val createCustomerEntitlementsRequiredText: String = + createCustomerEntitlementsRequiredForSpecificBank.mkString(" and ") + + " OR " + createCustomerEntitlementsRequiredForAnyBank.mkString(" and ") + + object Implementations2_1_0 { + val prefixPath = Root / ApiPathZero.toString / implementedInApiVersion.toString + + // ─── root ───────────────────────────────────────────────────────────────── + + val root: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` => + EndpointHelpers.executeAndRespond(req) { _ => + Future.successful(JSONFactory121.getApiInfoJSON(ApiVersion.v2_1_0, versionStatus)) + } + case req @ GET -> `prefixPath` / "root" => + EndpointHelpers.executeAndRespond(req) { _ => + Future.successful(JSONFactory121.getApiInfoJSON(ApiVersion.v2_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)) + + // ─── sandboxDataImport ──────────────────────────────────────────────────── + + val sandboxDataImport: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "sandbox" / "data-import" => + EndpointHelpers.withUserAndBodyCreated[SandboxDataImport, SuccessMessage](req) { (user, body, cc) => + for { + _ <- code.util.Helper.booleanToFuture(s"$DataImportDisabled", failCode = 403, cc = Some(cc)) { + APIUtil.getPropsAsBoolValue("allow_sandbox_data_import", defaultValue = false) + } + _ <- code.util.Helper.booleanToFuture(UserHasMissingRoles + canCreateSandbox, failCode = 403, cc = Some(cc)) { + APIUtil.hasEntitlement("", user.userId, canCreateSandbox) + } + _ <- code.util.Helper.booleanToFuture("Cannot import the sandbox data", cc = Some(cc)) { + scala.util.Try(OBPDataImport.importer.vend.importData(body)).toOption.exists(_.isDefined) + } + } yield SuccessMessage("Success") + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(sandboxDataImport), "POST", "/sandbox/data-import", + "Create sandbox", + s"""Import bulk data into the sandbox (Authenticated access). + | + |${userAuthenticationMessage(true)}""", + SandboxDataImport(Nil, Nil, Nil, Nil, Nil, Nil, Nil, Nil), successMessage, + List(AuthenticatedUserIsRequired, InvalidJsonFormat, DataImportDisabled, UserHasMissingRoles, UnknownError), + List(apiTagSandbox), + Some(List(canCreateSandbox)), + http4sPartialFunction = Some(sandboxDataImport)) + + // ─── getTransactionRequestTypesSupportedByBank ──────────────────────────── + + private val getTransactionRequestTypesIsPublic = + APIUtil.getPropsAsBoolValue("apiOptions.getTransactionRequestTypesIsPublic", true) + + val getTransactionRequestTypesSupportedByBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "transaction-request-types" => + EndpointHelpers.withBank(req) { (bank, cc) => + for { + _ <- if (!getTransactionRequestTypesIsPublic) + code.util.Helper.booleanToFuture(AuthenticatedUserIsRequired, failCode = 401, cc = Some(cc)) { cc.user.isDefined } + else Future.unit + transactionRequestTypes <- Future { + APIUtil.getPropsValue("transactionRequests_supported_types", "") + } + } yield JSONFactory210.createTransactionRequestTypeJSON(transactionRequestTypes.split(",").toList) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getTransactionRequestTypesSupportedByBank), "GET", + "/banks/BANK_ID/transaction-request-types", + "Get Transaction Request Types at Bank", + s"""Get the list of the Transaction Request Types supported by the bank. + | + |${userAuthenticationMessage(!getTransactionRequestTypesIsPublic)}""", + EmptyBody, transactionRequestTypesJSON, + List(AuthenticatedUserIsRequired, UnknownError), + List(apiTagTransactionRequest, apiTagBank), None, + http4sPartialFunction = Some(getTransactionRequestTypesSupportedByBank)) + + // ─── createTransactionRequest ───────────────────────────────────────────── + // Single handler for all transaction request types. Uses GRANT_VIEW_ID in + // ResourceDoc templates so middleware bypasses view-access validation — + // checkAuthorisationToCreateTransactionRequest handles that internally and + // supports canCreateAnyTransactionRequest role bypass. + + val createTransactionRequest: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "accounts" / _ / viewIdStr / "transaction-request-types" / transactionRequestTypeStr / "transaction-requests" => + implicit val cc: CallContext = req.callContext + (for { + jsonBody <- req.bodyText.compile.string + user <- IO.fromOption(cc.user.toOption)(new RuntimeException(AuthenticatedUserIsRequired)) + account <- IO.fromOption(cc.bankAccount)(new RuntimeException(AccountNotFound)) + result <- code.api.util.http4s.RequestScopeConnection.fromFuture( + createTransactionRequestImpl(jsonBody, user, account, ViewId(viewIdStr), transactionRequestTypeStr, cc)) + } yield result).attempt.flatMap { + case Right(result) => + Created(prettyRender(Extraction.decompose(result))) + case Left(err) => + code.api.util.http4s.ErrorResponseConverter.toHttp4sResponse(err, cc) + } + } + + val commonTxReqErrors = List(AuthenticatedUserIsRequired, InvalidBankIdFormat, InvalidAccountIdFormat, + InvalidJsonFormat, BankNotFound, AccountNotFound, InsufficientAuthorisationToCreateTransactionRequest, + InvalidTransactionRequestType, InvalidNumber, NotPositiveAmount, InvalidTransactionRequestCurrency, + TransactionDisabled, UnknownError) + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createTransactionRequest) + "SandboxTan", "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/GRANT_VIEW_ID/transaction-request-types/SANDBOX_TAN/transaction-requests", + "Create Transaction Request (SANDBOX_TAN)", + s"""When using SANDBOX_TAN, the payee is set in the request body. + | + |${userAuthenticationMessage(true)}""", + transactionRequestBodyJsonV200, transactionRequestWithChargeJSON210, + commonTxReqErrors, List(apiTagTransactionRequest, apiTagPSD2PIS, apiTagPsd2), None, + http4sPartialFunction = Some(createTransactionRequest)) + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createTransactionRequest) + "Counterparty", "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/GRANT_VIEW_ID/transaction-request-types/COUNTERPARTY/transaction-requests", + "Create Transaction Request (COUNTERPARTY)", + s"""When using COUNTERPARTY, specify the counterparty_id in the body. + | + |${userAuthenticationMessage(true)}""", + transactionRequestBodyCounterpartyJSON, transactionRequestWithChargeJSON210, + commonTxReqErrors, List(apiTagTransactionRequest, apiTagPSD2PIS, apiTagPsd2), None, + http4sPartialFunction = Some(createTransactionRequest)) + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createTransactionRequest) + "Sepa", "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/GRANT_VIEW_ID/transaction-request-types/SEPA/transaction-requests", + "Create Transaction Request (SEPA)", + s"""When using SEPA, specify the IBAN of a Counterparty in the body. + | + |${userAuthenticationMessage(true)}""", + transactionRequestBodySEPAJSON, transactionRequestWithChargeJSON210, + commonTxReqErrors, List(apiTagTransactionRequest, apiTagPSD2PIS, apiTagPsd2), None, + http4sPartialFunction = Some(createTransactionRequest)) + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createTransactionRequest) + "FreeForm", "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/GRANT_VIEW_ID/transaction-request-types/FREE_FORM/transaction-requests", + "Create Transaction Request (FREE_FORM)", + s"""Create a FREE_FORM Transaction Request. + | + |${userAuthenticationMessage(true)}""", + transactionRequestBodyFreeFormJSON, transactionRequestWithChargeJSON210, + commonTxReqErrors, List(apiTagTransactionRequest, apiTagPSD2PIS), + Some(List(canCreateAnyTransactionRequest)), + http4sPartialFunction = Some(createTransactionRequest)) + + // Catch-all: handles unknown/invalid transaction request types → 400 from createTransactionRequestImpl + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createTransactionRequest), "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/GRANT_VIEW_ID/transaction-request-types/TRANSACTION_REQUEST_TYPE/transaction-requests", + "Create Transaction Request", + s"""Create a Transaction Request of the type specified in the URL. + | + |${userAuthenticationMessage(true)}""", + transactionRequestBodyJsonV200, transactionRequestWithChargeJSON210, + commonTxReqErrors, List(apiTagTransactionRequest), + Some(List(canCreateAnyTransactionRequest)), + http4sPartialFunction = Some(createTransactionRequest)) + + private def createTransactionRequestImpl( + jsonBody: String, + user: User, + fromAccount: BankAccount, + viewId: ViewId, + transactionRequestTypeStr: String, + cc: CallContext + ): Future[TransactionRequestWithChargeJSON210] = { + val sharedChargePolicy = code.api.ChargePolicy.withName("SHARED").toString + for { + _ <- NewStyle.function.isEnabledTransactionRequests(Some(cc)) + _ <- code.util.Helper.booleanToFuture(InvalidAccountIdFormat, cc = Some(cc)) { isValidID(fromAccount.accountId.value) } + _ <- code.util.Helper.booleanToFuture(InvalidBankIdFormat, cc = Some(cc)) { isValidID(fromAccount.bankId.value) } + _ <- code.util.Helper.booleanToFuture( + s"${InvalidTransactionRequestType}: '$transactionRequestTypeStr'", cc = Some(cc)) { + APIUtil.getPropsValue("transactionRequests_supported_types", "").split(",").contains(transactionRequestTypeStr) + } + account = BankIdAccountId(fromAccount.bankId, fromAccount.accountId) + _ <- NewStyle.function.checkAuthorisationToCreateTransactionRequest(viewId, account, user, Some(cc)) + transDetailsJson <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the $TransactionRequestBodyCommonJSON", 400, Some(cc)) { + net.liftweb.json.parse(jsonBody).extract[TransactionRequestBodyCommonJSON] + } + isValidAmountNumber <- NewStyle.function.tryons( + s"$InvalidNumber Current input is ${transDetailsJson.value.amount}", 400, Some(cc)) { + BigDecimal(transDetailsJson.value.amount) + } + _ <- code.util.Helper.booleanToFuture( + s"${NotPositiveAmount} Current input is: '$isValidAmountNumber'", cc = Some(cc)) { + isValidAmountNumber > BigDecimal("0") + } + _ <- code.util.Helper.booleanToFuture( + s"${InvalidISOCurrencyCode} Current input is: '${transDetailsJson.value.currency}'", cc = Some(cc)) { + isValidCurrencyISOCode(transDetailsJson.value.currency) + } + _ <- code.util.Helper.booleanToFuture( + s"$InvalidTransactionRequestCurrency From Account Currency is ${fromAccount.currency}, but Requested Transaction Currency is: ${transDetailsJson.value.currency}", + cc = Some(cc)) { + transDetailsJson.value.currency == fromAccount.currency + } + parsedJson = net.liftweb.json.parse(jsonBody) + (createdTransactionRequest, _) <- TransactionRequestTypes.withName(transactionRequestTypeStr) match { + case SANDBOX_TAN => + for { + body <- NewStyle.function.tryons( + s"${InvalidJsonFormat}, it should be $SANDBOX_TAN json format", 400, Some(cc)) { + parsedJson.extract[TransactionRequestBodySandBoxTanJSON] + } + toBankId = BankId(body.to.bank_id) + toAccountId = AccountId(body.to.account_id) + (toAccount, _) <- NewStyle.function.checkBankAccountExists(toBankId, toAccountId, Some(cc)) + serialized <- NewStyle.function.tryons(UnknownError, 400, Some(cc)) { + liftWrite(body)(Serialization.formats(net.liftweb.json.NoTypeHints)) + } + result <- NewStyle.function.createTransactionRequestv210( + user, viewId, fromAccount, toAccount, + com.openbankproject.commons.model.TransactionRequestType(transactionRequestTypeStr), + body, serialized, sharedChargePolicy, None, None, Some(cc)) + } yield result + case COUNTERPARTY => + for { + body <- NewStyle.function.tryons( + s"${InvalidJsonFormat}, it should be $COUNTERPARTY json format", 400, Some(cc)) { + parsedJson.extract[TransactionRequestBodyCounterpartyJSON] + } + (toCounterparty, _) <- NewStyle.function.getCounterpartyByCounterpartyId( + CounterpartyId(body.to.counterparty_id), Some(cc)) + (toAccount, _) <- NewStyle.function.getBankAccountFromCounterparty(toCounterparty, true, Some(cc)) + _ <- code.util.Helper.booleanToFuture(s"$CounterpartyBeneficiaryPermit", cc = Some(cc)) { + toCounterparty.isBeneficiary + } + _ <- code.util.Helper.booleanToFuture(s"$InvalidChargePolicy", cc = Some(cc)) { + code.api.ChargePolicy.values.contains(code.api.ChargePolicy.withName(body.charge_policy)) + } + serialized <- NewStyle.function.tryons(UnknownError, 400, Some(cc)) { + liftWrite(body)(Serialization.formats(net.liftweb.json.NoTypeHints)) + } + result <- NewStyle.function.createTransactionRequestv210( + user, viewId, fromAccount, toAccount, + com.openbankproject.commons.model.TransactionRequestType(transactionRequestTypeStr), + body, serialized, body.charge_policy, None, None, Some(cc)) + } yield result + case SEPA => + for { + body <- NewStyle.function.tryons( + s"${InvalidJsonFormat}, it should be $SEPA json format", 400, Some(cc)) { + parsedJson.extract[TransactionRequestBodySEPAJSON] + } + (toCounterparty, _) <- NewStyle.function.getCounterpartyByIban(body.to.iban, Some(cc)) + (toAccount, _) <- NewStyle.function.getBankAccountFromCounterparty(toCounterparty, true, Some(cc)) + _ <- code.util.Helper.booleanToFuture(s"$CounterpartyBeneficiaryPermit", cc = Some(cc)) { + toCounterparty.isBeneficiary + } + _ <- code.util.Helper.booleanToFuture(s"$InvalidChargePolicy", cc = Some(cc)) { + code.api.ChargePolicy.values.contains(code.api.ChargePolicy.withName(body.charge_policy)) + } + serialized <- NewStyle.function.tryons(UnknownError, 400, Some(cc)) { + liftWrite(body)(Serialization.formats(net.liftweb.json.NoTypeHints)) + } + result <- NewStyle.function.createTransactionRequestv210( + user, viewId, fromAccount, toAccount, + com.openbankproject.commons.model.TransactionRequestType(transactionRequestTypeStr), + body, serialized, body.charge_policy, None, None, Some(cc)) + } yield result + case FREE_FORM => + for { + body <- NewStyle.function.tryons( + s"${InvalidJsonFormat}, it should be $FREE_FORM json format", 400, Some(cc)) { + parsedJson.extract[TransactionRequestBodyFreeFormJSON] + } + serialized <- NewStyle.function.tryons(UnknownError, 400, Some(cc)) { + liftWrite(body)(Serialization.formats(net.liftweb.json.NoTypeHints)) + } + result <- NewStyle.function.createTransactionRequestv210( + user, viewId, fromAccount, fromAccount, + com.openbankproject.commons.model.TransactionRequestType(transactionRequestTypeStr), + body, serialized, sharedChargePolicy, None, None, Some(cc)) + } yield result + case other => + Future.failed(new RuntimeException(s"$InvalidTransactionRequestType: '$transactionRequestTypeStr'")) + } + } yield JSONFactory210.createTransactionRequestWithChargeJSON(createdTransactionRequest) + } + + // ─── answerTransactionRequestChallenge ──────────────────────────────────── + + val answerTransactionRequestChallenge: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "transaction-request-types" / transactionRequestTypeStr / "transaction-requests" / transReqIdStr / "challenge" => + 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 + result <- code.api.util.http4s.RequestScopeConnection.fromFuture( + answerChallengeImpl(jsonBody, user, account, transactionRequestTypeStr, transReqIdStr, cc)) + } yield result + io.attempt.flatMap { + case Right(result) => + Accepted(prettyRender(Extraction.decompose(result))) + case Left(err) => + code.api.util.http4s.ErrorResponseConverter.toHttp4sResponse(err, cc) + } + } + + 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)) + + private def answerChallengeImpl( + jsonBody: String, + user: User, + fromAccount: BankAccount, + transactionRequestTypeStr: String, + transReqIdStr: String, + cc: CallContext + ): Future[TransactionRequestWithChargeJSON210] = { + val transReqId = TransactionRequestId(transReqIdStr) + for { + _ <- NewStyle.function.isEnabledTransactionRequests(Some(cc)) + _ <- code.util.Helper.booleanToFuture(InvalidAccountIdFormat, cc = Some(cc)) { isValidID(fromAccount.accountId.value) } + _ <- code.util.Helper.booleanToFuture(InvalidBankIdFormat, cc = Some(cc)) { isValidID(fromAccount.bankId.value) } + challengeAnswerJson <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the ChallengeAnswerJSON", 400, Some(cc)) { + net.liftweb.json.parse(jsonBody).extract[code.api.v1_4_0.JSONFactory1_4_0.ChallengeAnswerJSON] + } + account = BankIdAccountId(fromAccount.bankId, fromAccount.accountId) + viewId <- Future { + val viewIdStr = cc.view.map(_.viewId.value).getOrElse("owner") + ViewId(viewIdStr) + } + _ <- NewStyle.function.checkAuthorisationToCreateTransactionRequest(viewId, account, user, Some(cc)) + (existingTransactionRequest, _) <- NewStyle.function.getTransactionRequestImpl(transReqId, Some(cc)) + _ <- code.util.Helper.booleanToFuture(TransactionRequestStatusNotInitiated, cc = Some(cc)) { + existingTransactionRequest.status.equals("INITIATED") + } + existingType = existingTransactionRequest.`type` + _ <- code.util.Helper.booleanToFuture( + s"${TransactionRequestTypeHasChanged} It should be: '$existingType', but current value ($transactionRequestTypeStr)", + cc = Some(cc)) { + existingType.equals(transactionRequestTypeStr) + } + _ <- code.util.Helper.booleanToFuture(s"${InvalidTransactionRequestChallengeId}", cc = Some(cc)) { + existingTransactionRequest.challenge.id.equals(challengeAnswerJson.id) + } + _ <- code.util.Helper.booleanToFuture(s"${InvalidChallengeType}", cc = Some(cc)) { + existingTransactionRequest.challenge.challenge_type == ChallengeType.OBP_TRANSACTION_REQUEST_CHALLENGE.toString + } + (isChallengeAnswerValidated, _) <- NewStyle.function.validateChallengeAnswer( + challengeAnswerJson.id, challengeAnswerJson.answer, SuppliedAnswerType.PLAIN_TEXT_VALUE, Some(cc)) + _ <- code.util.Helper.booleanToFuture( + s"${InvalidChallengeAnswer.replace("answer may be expired.", s"answer may be expired ($transactionRequestChallengeTtl seconds).") + .replace("up your allowed attempts.", s"up your allowed attempts ($allowedAnswerTransactionRequestChallengeAttempts times).")} ", + cc = Some(cc)) { + isChallengeAnswerValidated == true + } + (transactionRequest, _) <- TransactionRequestTypes.withName(transactionRequestTypeStr) match { + case TRANSFER_TO_PHONE | TRANSFER_TO_ATM | TRANSFER_TO_ACCOUNT => + NewStyle.function.createTransactionAfterChallengeV300( + user, fromAccount, transReqId, + com.openbankproject.commons.model.TransactionRequestType(transactionRequestTypeStr), Some(cc)) + case _ => + NewStyle.function.createTransactionAfterChallengeV210(fromAccount, existingTransactionRequest, Some(cc)) + } + } yield JSONFactory210.createTransactionRequestWithChargeJSON(transactionRequest) + } + + // ─── getTransactionRequests ─────────────────────────────────────────────── + + val getTransactionRequests: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "transaction-requests" => + EndpointHelpers.withView(req) { (user, account, view, cc) => + for { + _ <- code.util.Helper.booleanToFuture(TransactionRequestsNotEnabled, cc = Some(cc)) { + APIUtil.getPropsAsBoolValue("transactionRequests_enabled", false) + } + _ <- code.util.Helper.booleanToFuture( + s"${ViewDoesNotPermitAccess} You need the `$CAN_SEE_TRANSACTION_REQUESTS` permission on the View(${view.viewId.value})", + cc = Some(cc)) { + view.allowed_actions.exists(_ == CAN_SEE_TRANSACTION_REQUESTS) + } + (transactionRequests, _) <- Future { + unboxFullOrFail( + Connector.connector.vend.getTransactionRequests210(user, account, Some(cc)), + Some(cc), UnknownError, 500) + } + } 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.""", + EmptyBody, transactionRequestWithChargeJSONs210, + List(AuthenticatedUserIsRequired, BankNotFound, AccountNotFound, UserHasMissingRoles, UnknownError), + List(apiTagTransactionRequest, apiTagPsd2, apiTagOldStyle), None, + http4sPartialFunction = Some(getTransactionRequests)) + + // ─── getRoles ───────────────────────────────────────────────────────────── + + val getRoles: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "roles" => + EndpointHelpers.withUser(req) { (_, _) => + Future.successful(JSONFactory210.createAvailableRolesJSON(ApiRole.availableRoles.sorted)) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getRoles), "GET", "/roles", + "Get Roles", + s"""Returns all available roles + | + |${userAuthenticationMessage(true)}""", + EmptyBody, availableRolesJSON, + List(AuthenticatedUserIsRequired, UnknownError), + List(apiTagRole), + None, + http4sPartialFunction = Some(getRoles)) + + // ─── getEntitlementsByBankAndUser ───────────────────────────────────────── + + val getEntitlementsByBankAndUser: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "users" / userId / "entitlements" => + EndpointHelpers.withUserAndBank(req) { (user, bank, cc) => + val allowedEntitlements = canGetEntitlementsForAnyUserAtOneBank :: + canGetEntitlementsForAnyUserAtAnyBank :: Nil + val allowedEntitlementsTxt = UserHasMissingRoles + allowedEntitlements.mkString(" or ") + for { + _ <- code.util.Helper.booleanToFuture(allowedEntitlementsTxt, failCode = 403, cc = Some(cc)) { + APIUtil.hasAtLeastOneEntitlement(bank.bankId.value, user.userId, allowedEntitlements) + } + (_, _) <- NewStyle.function.findByUserId(userId, Some(cc)) + entitlements <- Entitlement.entitlement.vend.getEntitlementsByUserIdFuture(userId) map { + connectorEmptyResponse(_, Some(cc)) + } + } yield { + val filteredEntitlements = entitlements.filter(_.bankId == bank.bankId.value) + if (isSuperAdmin(userId)) + JSONFactory200.withVirtualEntitlements(filteredEntitlements, JSONFactory200.superAdminVirtualRoles) + else if (isOidcOperator(userId)) + JSONFactory200.withVirtualEntitlements(filteredEntitlements, JSONFactory200.oidcOperatorVirtualRoles) + else + JSONFactory200.createEntitlementJSONs(filteredEntitlements) + } + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getEntitlementsByBankAndUser), "GET", + "/banks/BANK_ID/users/USER_ID/entitlements", + "Get Entitlements for User at Bank", + s"""Get Entitlements specified by BANK_ID and USER_ID + | + |${userAuthenticationMessage(true)}""", + EmptyBody, entitlementJSONs, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagRole, apiTagEntitlement, apiTagUser), + None, + http4sPartialFunction = Some(getEntitlementsByBankAndUser)) + + // ─── getConsumer ────────────────────────────────────────────────────────── + + val getConsumer: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "consumers" / consumerId => + EndpointHelpers.withUser(req) { (user, cc) => + for { + _ <- code.util.Helper.booleanToFuture(UserHasMissingRoles + canGetConsumers, failCode = 403, cc = Some(cc)) { + APIUtil.hasEntitlement("", user.userId, canGetConsumers) + } + consumerIdLong <- NewStyle.function.tryons(InvalidConsumerId, 400, Some(cc)) { + consumerId.toLong + } + consumer <- NewStyle.function.getConsumerByPrimaryId(consumerIdLong, Some(cc)) + } yield JSONFactory210.createConsumerJSON(consumer) + } + } + + 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, InvalidConsumerId, UnknownError), + List(apiTagConsumer, apiTagOldStyle), + Some(List(canGetConsumers)), + http4sPartialFunction = Some(getConsumer)) + + // ─── getConsumers ───────────────────────────────────────────────────────── + + val getConsumers: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "consumers" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + _ <- code.util.Helper.booleanToFuture(UserHasMissingRoles + canGetConsumers, failCode = 403, cc = Some(cc)) { + APIUtil.hasEntitlement("", user.userId, canGetConsumers) + } + } yield { + val consumers = Consumer.findAll() + JSONFactory210.createConsumerJSONs(consumers.sortWith(_.id.get < _.id.get)) + } + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getConsumers), "GET", + "/management/consumers", + "Get Consumers", + s"""Get the all Consumers.""", + EmptyBody, consumersJson, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagConsumer, apiTagOldStyle), + Some(List(canGetConsumers)), + http4sPartialFunction = Some(getConsumers)) + + // ─── enableDisableConsumers ─────────────────────────────────────────────── + + val enableDisableConsumers: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "management" / "consumers" / consumerId => + EndpointHelpers.withUserAndBody[PutEnabledJSON, PutEnabledJSON](req) { (user, body, cc) => + for { + _ <- body.enabled match { + case true => + code.util.Helper.booleanToFuture(UserHasMissingRoles + canEnableConsumers, failCode = 403, cc = Some(cc)) { + APIUtil.hasEntitlement("", user.userId, canEnableConsumers) + } + case false => + code.util.Helper.booleanToFuture(UserHasMissingRoles + canDisableConsumers, failCode = 403, cc = Some(cc)) { + APIUtil.hasEntitlement("", user.userId, canDisableConsumers) + } + } + consumer <- Future { + unboxFullOrFail( + Consumers.consumers.vend.getConsumerByPrimaryId(consumerId.toLong), + Some(cc), InvalidConsumerId, 400) + } + updatedConsumer <- Future { + unboxFullOrFail( + Consumers.consumers.vend.updateConsumer( + consumer.id.get, None, None, Some(body.enabled), + None, None, None, None, None, None, None, None), + Some(cc), "Cannot update Consumer", 400) + } + } yield PutEnabledJSON(updatedConsumer.isActive.get) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(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, apiTagOldStyle), + None, + http4sPartialFunction = Some(enableDisableConsumers)) + + // ─── addCardForBank ─────────────────────────────────────────────────────── + + val addCardForBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "cards" => + EndpointHelpers.withUserAndBankAndBodyCreated[PostPhysicalCardJSON, PhysicalCardJSON](req) { (user, bank, body, cc) => + for { + _ <- code.util.Helper.booleanToFuture(UserHasMissingRoles + canCreateCardsForBank, failCode = 403, cc = Some(cc)) { + APIUtil.hasEntitlement(bank.bankId.value, user.userId, canCreateCardsForBank) + } + _ <- code.util.Helper.booleanToFuture( + s"${maximumLimitExceeded.replace("10000", "10")} Current issue_number is ${body.issue_number}", + cc = Some(cc)) { + body.issue_number.length <= 10 + } + _ <- body.allows match { + case Nil => Future.successful(()) + case _ => + code.util.Helper.booleanToFuture(AllowedValuesAre + CardAction.availableValues.mkString(", "), cc = Some(cc)) { + body.allows.forall(a => CardAction.availableValues.contains(a)) + } + } + replacementReason <- NewStyle.function.tryons(AllowedValuesAre + CardReplacementReason.availableValues.mkString(", "), 400, Some(cc)) { + CardReplacementReason.valueOf(body.replacement.reason_requested) + } + (_, _) <- NewStyle.function.getBankAccount(bank.bankId, AccountId(body.account_id), Some(cc)) + (card, _) <- NewStyle.function.createPhysicalCard( + bankCardNumber = body.bank_card_number, + nameOnCard = body.name_on_card, + cardType = "", + issueNumber = body.issue_number, + serialNumber = body.serial_number, + validFrom = body.valid_from_date, + expires = body.expires_date, + enabled = body.enabled, + cancelled = false, + onHotList = false, + technology = body.technology, + networks = body.networks, + allows = body.allows, + accountId = body.account_id, + bankId = bank.bankId.value, + replacement = Some(CardReplacementInfo(requestedDate = body.replacement.requested_date, reasonRequested = replacementReason)), + pinResets = body.pin_reset.map(e => PinResetInfo(e.requested_date, PinResetReason.valueOf(e.reason_requested.toUpperCase))), + collected = Option(CardCollectionInfo(body.collected)), + posted = Option(CardPostedInfo(body.posted)), + customerId = "", + cvv = "", + brand = "", + callContext = Some(cc) + ) + } yield JSONFactory1_3_0.createPhysicalCardJSON(card, user) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(addCardForBank), "POST", + "/banks/BANK_ID/cards", + "Create Card", + s"""Create Card at bank specified by BANK_ID. + | + |${userAuthenticationMessage(true)}""", + postPhysicalCardJSON, physicalCardJSON, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, AllowedValuesAre, UnknownError), + List(apiTagCard), + Some(List(canCreateCardsForBank)), + http4sPartialFunction = Some(addCardForBank)) + + // ─── getUsers ───────────────────────────────────────────────────────────── + + val getUsers: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "users" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + _ <- code.util.Helper.booleanToFuture(UserHasMissingRoles + canGetAnyUser, failCode = 403, cc = Some(cc)) { + APIUtil.hasEntitlement("", user.userId, canGetAnyUser) + } + httpParams <- NewStyle.function.extractHttpParamsFromUrl(req.uri.renderString) + (queryParams, _) <- createQueriesByHttpParamsFuture(httpParams, Some(cc)) + users <- Users.users.vend.getAllUsersF(queryParams) + } yield JSONFactory210.createUserJSONs(users) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getUsers), "GET", "/users", + "Get all Users", + s"""Get all users + | + |Login is required. + |CanGetAnyUser entitlement is required. + | + |${urlParametersDocument(false, false)}""", + EmptyBody, usersJsonV200, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagUser), + Some(List(canGetAnyUser)), + http4sPartialFunction = Some(getUsers)) + + // ─── createTransactionType ──────────────────────────────────────────────── + + val createTransactionType: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "transaction-types" => + EndpointHelpers.withUserAndBankAndBody[TransactionTypeJsonV200, TransactionTypeJsonV200](req) { (user, bank, body, cc) => + for { + _ <- code.util.Helper.booleanToFuture(InsufficientAuthorisationToCreateTransactionType, failCode = 400, cc = Some(cc)) { + APIUtil.hasEntitlement(bank.bankId.value, user.userId, canCreateTransactionType) + } + returnTransactionType <- Future { + TransactionType.TransactionTypeProvider.vend.createOrUpdateTransactionType(body) match { + case Full(t) => t + case Failure(msg, _, _) => + throw new Exception(compactRender(("failCode" -> 400) ~ ("failMsg" -> msg))) + case _ => + throw new Exception(compactRender(("failCode" -> 400) ~ ("failMsg" -> CreateTransactionTypeInsertError))) + } + } + } yield JSONFactory200.createTransactionTypeJSON(returnTransactionType) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createTransactionType), "PUT", + "/banks/BANK_ID/transaction-types", + "Create Transaction Type at bank", + s"""Create Transaction Types for the bank specified by BANK_ID.""", + transactionTypeJsonV200, transactionType, + List(AuthenticatedUserIsRequired, BankNotFound, InvalidJsonFormat, + InsufficientAuthorisationToCreateTransactionType, UnknownError), + List(apiTagBank), + None, + http4sPartialFunction = Some(createTransactionType)) + + // ─── getAtm ─────────────────────────────────────────────────────────────── + + private val getAtmsIsPublic = APIUtil.getPropsAsBoolValue("apiOptions.getAtmsIsPublic", true) + + val getAtm: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "atms" / atmIdStr => + EndpointHelpers.withBank(req) { (bank, cc) => + for { + _ <- if (!getAtmsIsPublic) + code.util.Helper.booleanToFuture(AuthenticatedUserIsRequired, failCode = 401, cc = Some(cc)) { cc.user.isDefined } + else Future.unit + atm <- Future { + unboxFullOrFail( + Atms.atmsProvider.vend.getAtm(bank.bankId, AtmId(atmIdStr)), + Some(cc), AtmNotFoundByAtmId, 404) + } + } yield JSONFactory1_4_0.createAtmJson(atm) + } + } + + 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.""", + EmptyBody, atmJson, + List(AuthenticatedUserIsRequired, BankNotFound, AtmNotFoundByAtmId, UnknownError), + List(apiTagATM, apiTagOldStyle), None, + http4sPartialFunction = Some(getAtm)) + + // ─── getBranch ──────────────────────────────────────────────────────────── + + private val getBranchesIsPublic = APIUtil.getPropsAsBoolValue("apiOptions.getBranchesIsPublic", true) + + val getBranch: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "branches" / branchIdStr => + EndpointHelpers.withBank(req) { (bank, cc) => + for { + _ <- if (!getBranchesIsPublic) + code.util.Helper.booleanToFuture(AuthenticatedUserIsRequired, failCode = 401, cc = Some(cc)) { cc.user.isDefined } + else Future.unit + branch <- Future { + unboxFullOrFail( + Branches.branchesProvider.vend.getBranch(bank.bankId, BranchId(branchIdStr)), + Some(cc), BranchNotFoundByBranchId, 404) + } + } yield JSONFactory1_4_0.createBranchJson(branch) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getBranch), "GET", + "/banks/BANK_ID/branches/BRANCH_ID", + "Get Bank Branch", + s"""Returns information about branch for a single bank specified by BANK_ID and BRANCH_ID.""", + EmptyBody, branchJson, + List(AuthenticatedUserIsRequired, BranchNotFoundByBranchId, UnknownError), + List(apiTagBranch, apiTagOldStyle), None, + http4sPartialFunction = Some(getBranch)) + + // ─── getProduct ─────────────────────────────────────────────────────────── + + private val getProductsIsPublic = APIUtil.getPropsAsBoolValue("apiOptions.getProductsIsPublic", true) + + val getProduct: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "products" / productCodeStr => + EndpointHelpers.withBank(req) { (bank, cc) => + for { + _ <- if (!getProductsIsPublic) + code.util.Helper.booleanToFuture(AuthenticatedUserIsRequired, failCode = 401, cc = Some(cc)) { cc.user.isDefined } + else Future.unit + (product, _) <- NewStyle.function.getProduct(bank.bankId, ProductCode(productCodeStr), Some(cc)) + } yield JSONFactory210.createProductJson(product) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getProduct), "GET", + "/banks/BANK_ID/products/PRODUCT_CODE", + "Get Bank Product", + s"""Returns information about the financial products offered by a bank specified by BANK_ID and PRODUCT_CODE.""", + EmptyBody, productJsonV210, + List(AuthenticatedUserIsRequired, ProductNotFoundByProductCode, UnknownError), + List(apiTagProduct), None, + http4sPartialFunction = Some(getProduct)) + + // ─── getProducts ────────────────────────────────────────────────────────── + + val getProducts: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "products" => + EndpointHelpers.withBank(req) { (bank, cc) => + for { + _ <- if (!getProductsIsPublic) + code.util.Helper.booleanToFuture(AuthenticatedUserIsRequired, failCode = 401, cc = Some(cc)) { cc.user.isDefined } + else Future.unit + params = req.uri.query.multiParams.map { case (k, vs) => GetProductsParam(k, vs.toList) }.toList + (products, _) <- NewStyle.function.getProducts(bank.bankId, params, Some(cc)) + } yield JSONFactory210.createProductsJson(products) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getProducts), "GET", + "/banks/BANK_ID/products", + "Get Bank Products", + s"""Returns information about the financial products offered by a bank specified by BANK_ID.""", + EmptyBody, productsJsonV210, + List(AuthenticatedUserIsRequired, BankNotFound, ProductNotFoundByProductCode, UnknownError), + List(apiTagProduct), None, + http4sPartialFunction = Some(getProducts)) + + // ─── createCustomer ─────────────────────────────────────────────────────── + + val createCustomer: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "customers" => + EndpointHelpers.withUserAndBankAndBodyCreated[PostCustomerJsonV210, CustomerJsonV210](req) { (user, bank, body, cc) => + for { + _ <- code.util.Helper.booleanToFuture(InvalidBankIdFormat, cc = Some(cc)) { + isValidID(bank.bankId.value) + } + _ <- code.util.Helper.booleanToFuture( + s"${InvalidJsonFormat} customer_number can not contain `::::` characters", cc = Some(cc)) { + !`checkIfContains::::` (body.customer_number) + } + _ <- code.util.Helper.booleanToFuture(createCustomerEntitlementsRequiredText, failCode = 403, cc = Some(cc)) { + APIUtil.hasAllEntitlements(bank.bankId.value, user.userId, createCustomerEntitlementsRequiredForSpecificBank) || + APIUtil.hasAllEntitlements("", user.userId, createCustomerEntitlementsRequiredForAnyBank) + } + _ <- code.util.Helper.booleanToFuture(CustomerNumberAlreadyExists, cc = Some(cc)) { + CustomerX.customerProvider.vend.checkCustomerNumberAvailable(bank.bankId, body.customer_number) + } + userId = if (body.user_id.nonEmpty) body.user_id else user.userId + (customerUser, _) <- NewStyle.function.findByUserId(userId, Some(cc)) + customer <- Future { + CustomerX.customerProvider.vend.addCustomer( + bank.bankId, body.customer_number, body.legal_name, body.mobile_phone_number, body.email, + CustomerFaceImage(body.face_image.date, body.face_image.url), + body.date_of_birth, body.relationship_status, body.dependants, body.dob_of_dependants, + body.highest_education_attained, body.employment_status, body.kyc_status, body.last_ok_date, + Option(CreditRating(body.credit_rating.rating, body.credit_rating.source)), + Option(CreditLimit(body.credit_limit.currency, body.credit_limit.amount)), + "", "", "" + ).getOrElse(throw new RuntimeException(CreateConsumerError)) + } + _ <- code.util.Helper.booleanToFuture(CustomerAlreadyExistsForUser, cc = Some(cc)) { + UserCustomerLink.userCustomerLink.vend.getUserCustomerLink(userId, customer.customerId).isEmpty + } + _ <- Future { + UserCustomerLink.userCustomerLink.vend + .createUserCustomerLink(userId, customer.customerId, new Date(), true) + .getOrElse(throw new RuntimeException(CreateUserCustomerLinksError)) + } + } yield JSONFactory210.createCustomerJson(customer) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createCustomer), "POST", + "/banks/BANK_ID/customers", + "Create Customer", + s"""Add a customer linked to the user specified by user_id + | + |${userAuthenticationMessage(true)} + | + |$createCustomerEntitlementsRequiredText""", + postCustomerJsonV210, customerJsonV210, + List(AuthenticatedUserIsRequired, BankNotFound, InvalidJsonFormat, CustomerNumberAlreadyExists, + UserNotFoundById, CustomerAlreadyExistsForUser, CreateConsumerError, UnknownError), + List(apiTagCustomer, apiTagPerson, apiTagOldStyle), + None, + http4sPartialFunction = Some(createCustomer)) + + // ─── getCustomersForUser ────────────────────────────────────────────────── + + val getCustomersForUser: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "users" / "current" / "customers" => + EndpointHelpers.withUser(req) { (user, cc) => + Future { + val customers = CustomerX.customerProvider.vend.getCustomersByUserId(user.userId) + JSONFactory210.createCustomersJson(customers) + } + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCustomersForUser), "GET", + "/users/current/customers", + "Get Customers for Current User", + """Gets all Customers that are linked to a User. + | + |Authentication via OAuth is required.""", + EmptyBody, customerJsonV210, + List(AuthenticatedUserIsRequired, UserCustomerLinksNotFoundForUser, UnknownError), + List(apiTagCustomer, apiTagUser, apiTagOldStyle), None, + http4sPartialFunction = Some(getCustomersForUser)) + + // ─── getCustomersForCurrentUserAtBank ────────────────────────────────────── + + val getCustomersForCurrentUserAtBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "customers" => + EndpointHelpers.withUserAndBank(req) { (user, bank, cc) => + for { + (customers, _) <- Connector.connector.vend.getCustomersByUserId(user.userId, Some(cc)) map { + connectorEmptyResponse(_, Some(cc)) + } + } yield { + val bankCustomers = customers.filter(_.bankId == bank.bankId.value) + JSONFactory210.createCustomersJson(bankCustomers) + } + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCustomersForCurrentUserAtBank), "GET", + "/banks/BANK_ID/customers", + "Get Customers for current User 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, CustomerNotFoundByCustomerId, UnknownError), + List(apiTagCustomer), None, + http4sPartialFunction = Some(getCustomersForCurrentUserAtBank)) + + // ─── updateBranch ───────────────────────────────────────────────────────── + + val updateBranch: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "branches" / branchIdStr => + EndpointHelpers.withUserAndBankAndBodyCreated[BranchJsonPutV210, JSONFactory1_4_0.BranchJson](req) { (user, bank, body, cc) => + for { + _ <- code.util.Helper.booleanToFuture( + s"$InvalidJsonValue BANK_ID has to be the same in the URL and Body", failCode = 400, cc = Some(cc)) { + body.bank_id == bank.bankId.value + } + _ <- code.util.Helper.booleanToFuture(UserHasMissingRoles + canUpdateBranch, failCode = 403, cc = Some(cc)) { + APIUtil.hasEntitlement(bank.bankId.value, user.userId, canUpdateBranch) + } + branch <- NewStyle.function.tryons(CouldNotTransformJsonToInternalModel + " Branch", 400, Some(cc)) { + JSONFactory210.transformToBranch(BranchId(branchIdStr), body).head + } + (success, _) <- NewStyle.function.createOrUpdateBranch(branch, Some(cc)) + } yield JSONFactory1_4_0.createBranchJson(success) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateBranch), "PUT", + "/banks/BANK_ID/branches/BRANCH_ID", + "Update Branch", + s"""Update an existing branch for a bank account (Authenticated access). + |${userAuthenticationMessage(true)}""", + branchJsonPut, branchJson, + List(AuthenticatedUserIsRequired, BankNotFound, InvalidJsonFormat, UserHasMissingRoles, UnknownError), + List(apiTagBranch), + Some(List(canUpdateBranch)), + http4sPartialFunction = Some(updateBranch)) + + // ─── createBranch ───────────────────────────────────────────────────────── + + val createBranch: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "branches" => + EndpointHelpers.withUserAndBankAndBodyCreated[BranchJsonPostV210, JSONFactory1_4_0.BranchJson](req) { (user, bank, body, cc) => + for { + _ <- code.util.Helper.booleanToFuture( + s"$InvalidJsonValue BANK_ID has to be the same in the URL and Body", failCode = 400, cc = Some(cc)) { + body.bank_id == bank.bankId.value + } + _ <- code.util.Helper.booleanToFuture( + s"${InsufficientAuthorisationToCreateBranch}", + failCode = 403, cc = Some(cc)) { + APIUtil.hasAllEntitlements(bank.bankId.value, user.userId, canCreateBranch :: Nil) || + APIUtil.hasAllEntitlements("", user.userId, canCreateBranchAtAnyBank :: Nil) + } + branch <- NewStyle.function.tryons(CouldNotTransformJsonToInternalModel + " Branch", 400, Some(cc)) { + JSONFactory210.transformToBranch(body).head + } + (success, _) <- NewStyle.function.createOrUpdateBranch(branch, Some(cc)) + } yield JSONFactory1_4_0.createBranchJson(success) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createBranch), "POST", + "/banks/BANK_ID/branches", + "Create Branch", + s"""Create branch for the bank (Authenticated access). + |${userAuthenticationMessage(true)}""", + branchJsonPost, branchJson, + List(AuthenticatedUserIsRequired, BankNotFound, InvalidJsonFormat, InsufficientAuthorisationToCreateBranch, UnknownError), + List(apiTagBranch, apiTagOpenData), + None, + http4sPartialFunction = Some(createBranch)) + + // ─── updateConsumerRedirectUrl ──────────────────────────────────────────── + + val updateConsumerRedirectUrl: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "management" / "consumers" / consumerId / "consumer" / "redirect_url" => + EndpointHelpers.withUserAndBody[ConsumerRedirectUrlJSON, ConsumerJsonV210](req) { (user, body, cc) => + for { + _ <- APIUtil.getPropsAsBoolValue("consumers_enabled_by_default", false) match { + case true => Future.unit + case false => + code.util.Helper.booleanToFuture(UserHasMissingRoles + canUpdateConsumerRedirectUrl, failCode = 403, cc = Some(cc)) { + APIUtil.hasEntitlement("", user.userId, canUpdateConsumerRedirectUrl) + } + } + consumerIdLong <- NewStyle.function.tryons(InvalidConsumerId, 400, Some(cc)) { + consumerId.toLong + } + consumer <- NewStyle.function.getConsumerByPrimaryId(consumerIdLong, Some(cc)) + _ <- code.util.Helper.booleanToFuture(UserNoPermissionUpdateConsumer, failCode = 400, cc = Some(cc)) { + consumer.createdByUserId.equals(user.userId) + } + updatedConsumer <- NewStyle.function.updateConsumer( + id = consumer.id.get, + isActive = Some(APIUtil.getPropsAsBoolValue("consumers_enabled_by_default", false)), + redirectURL = Some(body.redirect_url), + callContext = Some(cc) + ) + } yield JSONFactory210.createConsumerJSON(updatedConsumer) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateConsumerRedirectUrl), "PUT", + "/management/consumers/CONSUMER_ID/consumer/redirect_url", + "Update Consumer RedirectUrl", + s"""Update an existing redirectUrl for a Consumer specified by CONSUMER_ID.""", + consumerRedirectUrlJSON, consumerJSON, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagConsumer), + None, + http4sPartialFunction = Some(updateConsumerRedirectUrl)) + + // ─── getMetrics ─────────────────────────────────────────────────────────── + + val getMetrics: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "metrics" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + _ <- code.util.Helper.booleanToFuture(UserHasMissingRoles + canReadMetrics, failCode = 403, cc = Some(cc)) { + APIUtil.hasEntitlement("", user.userId, canReadMetrics) + } + httpParams <- NewStyle.function.extractHttpParamsFromUrl(req.uri.renderString) + (obpQueryParams, _) <- createQueriesByHttpParamsFuture(httpParams, Some(cc)) + metrics <- Future(APIMetrics.apiMetrics.vend.getAllMetrics(obpQueryParams)) + } yield JSONFactory210.createMetricsJson(metrics) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getMetrics), "GET", + "/management/metrics", + "Get Metrics", + s"""Get the all metrics + | + |require CanReadMetrics role""", + EmptyBody, metricsJson, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagMetric, apiTagApi), + Some(List(canReadMetrics)), + http4sPartialFunction = Some(getMetrics)) + + // ─── allRoutes ──────────────────────────────────────────────────────────── + + private val allOwnRoutes: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req => + root.run(req) + .orElse(sandboxDataImport.run(req)) + .orElse(getTransactionRequestTypesSupportedByBank.run(req)) + .orElse(createTransactionRequest.run(req)) + .orElse(answerTransactionRequestChallenge.run(req)) + .orElse(getTransactionRequests.run(req)) + .orElse(getRoles.run(req)) + .orElse(getEntitlementsByBankAndUser.run(req)) + .orElse(getConsumers.run(req)) + .orElse(getConsumer.run(req)) + .orElse(enableDisableConsumers.run(req)) + .orElse(addCardForBank.run(req)) + .orElse(getUsers.run(req)) + .orElse(createTransactionType.run(req)) + .orElse(getAtm.run(req)) + .orElse(getBranch.run(req)) + .orElse(getProduct.run(req)) + .orElse(getProducts.run(req)) + .orElse(createCustomer.run(req)) + .orElse(getCustomersForUser.run(req)) + .orElse(getCustomersForCurrentUserAtBank.run(req)) + .orElse(updateBranch.run(req)) + .orElse(createBranch.run(req)) + .orElse(updateConsumerRedirectUrl.run(req)) + .orElse(getMetrics.run(req)) + } + + val allRoutesWithMiddleware: HttpRoutes[IO] = ResourceDocMiddleware.apply(resourceDocs)(allOwnRoutes) + + // ─── path-rewriting bridge: /obp/v2.1.0/… → /obp/v2.0.0/… ────────────── + // Delegates to Http4s200 so all inherited v2.0.0/v1.4.0/v1.3.0/v1.2.1 endpoints + // are served under the v2.1.0 URL prefix without duplicating logic. + + val v210ToV200Bridge: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req => + val rawPath = req.uri.path.renderString + if (rawPath.startsWith("/obp/v2.1.0/")) { + val rewritten = rawPath.replaceFirst("/obp/v2\\.1\\.0/", "/obp/v2.0.0/") + val newUri = req.uri.withPath(Uri.Path.unsafeFromString(rewritten)) + val rewrittenReq = req.withUri(newUri) + code.api.v2_0_0.Http4s200.wrappedRoutesV200Services.run(rewrittenReq) + } else { + OptionT.none[IO, Response[IO]] + } + } + } + + // Own middleware-wrapped routes take priority; inherited v2.0.0/v1.4.0/v1.3.0/v1.2.1 paths follow. + val wrappedRoutesV210Services: HttpRoutes[IO] = + Kleisli[HttpF, Request[IO], Response[IO]] { req => + Implementations2_1_0.allRoutesWithMiddleware.run(req) + .orElse(Implementations2_1_0.v210ToV200Bridge.run(req)) + } +} 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 new file mode 100644 index 0000000000..3a89873c1d --- /dev/null +++ b/obp-api/src/main/scala/code/api/v2_2_0/Http4s220.scala @@ -0,0 +1,855 @@ +package code.api.v2_2_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.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps} +import code.api.util.http4s.ResourceDocMiddleware +import code.api.util.newstyle.ViewNewStyle +import code.api.util.{APIUtil, CallContext, CustomJsonFormats, NewStyle} +import code.api.v1_2_1.{CreateViewJsonV121, JSONFactory => JSONFactory121, UpdateViewJsonV121} +import code.api.v2_1_0.{ConsumerPostJSON, JSONFactory210, PostCounterpartyJSON} + +import code.bankconnectors.Connector +import code.consumer.Consumers +import code.metadata.counterparties.{Counterparties, MappedCounterparty} +import code.metrics.ConnectorMetricsProvider +import code.model.{BankX, Consumer} +import code.model.dataAccess.BankAccountCreation +import code.views.Views +import code.views.system.ViewPermission +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.Full +import net.liftweb.json.JsonAST.prettyRender +import net.liftweb.json.{Extraction, Formats, Serialization} +import net.liftweb.util.StringHelpers +import org.http4s._ +import org.http4s.dsl.io._ + +import scala.collection.mutable.ArrayBuffer +import scala.concurrent.Future + +object Http4s220 { + val implementedInApiVersion: ScannedApiVersion = ApiVersion.v2_2_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 Implementations2_2_0 { + val prefixPath = Root / ApiPathZero.toString / implementedInApiVersion.toString + + // ─── root ───────────────────────────────────────────────────────────────── + + val root: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` => + EndpointHelpers.executeAndRespond(req) { _ => + Future.successful(JSONFactory121.getApiInfoJSON(ApiVersion.v2_2_0, versionStatus)) + } + case req @ GET -> `prefixPath` / "root" => + EndpointHelpers.executeAndRespond(req) { _ => + Future.successful(JSONFactory121.getApiInfoJSON(ApiVersion.v2_2_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)) + + // ─── getViewsForBankAccount ─────────────────────────────────────────────── + + val getViewsForBankAccount: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / "views" => + EndpointHelpers.withBankAccount(req) { (user, account, cc) => + for { + permission <- NewStyle.function.permission(account.bankId, account.accountId, user, Some(cc)) + anyCanSee = permission.views + .map(_.allowed_actions.exists(_ == CAN_SEE_AVAILABLE_VIEWS_FOR_BANK_ACCOUNT)) + .contains(true) + _ <- code.util.Helper.booleanToFuture( + s"${ViewDoesNotPermitAccess} You need the `${CAN_SEE_AVAILABLE_VIEWS_FOR_BANK_ACCOUNT}` permission on any your views", + cc = Some(cc)) { anyCanSee } + views <- Future(Views.views.vend.availableViewsForAccount(BankIdAccountId(account.bankId, account.accountId))) + } yield JSONFactory220.createViewsJSON(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, viewsJSONV220, + List(AuthenticatedUserIsRequired, BankAccountNotFound, UnknownError), + List(apiTagView, apiTagAccount), None, + http4sPartialFunction = Some(getViewsForBankAccount)) + + // ─── createViewForBankAccount ───────────────────────────────────────────── + + val createViewForBankAccount: HttpRoutes[IO] = HttpRoutes.of[IO] { + // VIEW_ACCOUNT_ID (non-standard name) bypasses middleware account-existence check so the + // handler can return 400 (not 404) for a missing account, matching Lift behaviour. + case req @ POST -> `prefixPath` / "banks" / _ / "accounts" / accountIdStr / "views" => + implicit val cc: CallContext = req.callContext + val io = for { + user <- IO.fromOption(cc.user.toOption)(new RuntimeException(AuthenticatedUserIsRequired)) + 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 + result <- code.api.util.http4s.RequestScopeConnection.fromFuture( + createViewImpl(user, account, body, cc)) + } yield result + io.attempt.flatMap { + case Right(result) => + Created(prettyRender(Extraction.decompose(result))) + case Left(err) => + code.api.util.http4s.ErrorResponseConverter.toHttp4sResponse(err, cc) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createViewForBankAccount), "POST", + "/banks/BANK_ID/accounts/VIEW_ACCOUNT_ID/views", + "Create View", + s"""Create a view on bank account. + | + |${userAuthenticationMessage(true)} and the user needs to have access to the owner view.""", + createViewJsonV121, viewJSONV220, + List(AuthenticatedUserIsRequired, InvalidJsonFormat, BankAccountNotFound, UnknownError), + List(apiTagAccount, apiTagView, apiTagOldStyle), None, + http4sPartialFunction = Some(createViewForBankAccount)) + + private def createViewImpl(user: User, account: BankAccount, body: String, cc: CallContext): Future[ViewJSONV220] = { + for { + createBodyJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + net.liftweb.json.parse(body).extract[CreateViewJsonV121] + } + _ <- code.util.Helper.booleanToFuture( + s"$InvalidCustomViewFormat Current view_name (${createBodyJson.name})", cc = Some(cc)) { + isValidCustomViewName(createBodyJson.name) + } + permission <- NewStyle.function.permission(account.bankId, account.accountId, user, Some(cc)) + anyCanCreate = permission.views.map(_.allowed_actions.exists(_ == CAN_CREATE_CUSTOM_VIEW)).contains(true) + _ <- code.util.Helper.booleanToFuture( + s"${CreateCustomViewError} You need the `${CAN_CREATE_CUSTOM_VIEW}` permission on any your views", + cc = Some(cc)) { anyCanCreate } + createViewJson = CreateViewJson( + createBodyJson.name, + createBodyJson.description, + metadata_view = "", + createBodyJson.is_public, + createBodyJson.which_alias_to_use, + createBodyJson.hide_metadata_if_alias_used, + createBodyJson.allowed_actions + ) + (view, _) <- ViewNewStyle.createCustomView(BankIdAccountId(account.bankId, account.accountId), createViewJson, Some(cc)) + } yield JSONFactory220.createViewJSON(view) + } + + // ─── updateViewForBankAccount ───────────────────────────────────────────── + + val updateViewForBankAccount: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "accounts" / _ / "views" / viewIdStr => + 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)) + body <- req.bodyText.compile.string + result <- code.api.util.http4s.RequestScopeConnection.fromFuture( + updateViewImpl(user, account, ViewId(viewIdStr), body, cc)) + } yield result + io.attempt.flatMap { + case Right(result) => + Ok(prettyRender(Extraction.decompose(result))) + case Left(err) => + code.api.util.http4s.ErrorResponseConverter.toHttp4sResponse(err, cc) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateViewForBankAccount), "PUT", + "/banks/BANK_ID/accounts/ACCOUNT_ID/views/UPD_VIEW_ID", + "Update View", + s"""Update an existing view on a bank account. + | + |${userAuthenticationMessage(true)} and the user needs to have access to the owner view.""", + updateViewJsonV121, viewJSONV220, + List(InvalidJsonFormat, AuthenticatedUserIsRequired, BankAccountNotFound, UnknownError), + List(apiTagAccount, apiTagView, apiTagOldStyle), None, + http4sPartialFunction = Some(updateViewForBankAccount)) + + private def updateViewImpl(user: User, account: BankAccount, viewId: ViewId, body: String, cc: CallContext): Future[ViewJSONV220] = { + for { + updateBodyJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + net.liftweb.json.parse(body).extract[UpdateViewJsonV121] + } + _ <- code.util.Helper.booleanToFuture( + s"$InvalidCustomViewFormat Current view_name (${viewId.value})", cc = Some(cc)) { + viewId.value.startsWith("_") + } + view <- ViewNewStyle.checkViewAccessAndReturnView(viewId, BankIdAccountId(account.bankId, account.accountId), Some(user), Some(cc)) + _ <- code.util.Helper.booleanToFuture(SystemViewsCanNotBeModified, cc = Some(cc)) { !view.isSystem } + permission <- NewStyle.function.permission(account.bankId, account.accountId, user, Some(cc)) + anyCanUpdate = permission.views.map(_.allowed_actions.exists(_ == CAN_UPDATE_CUSTOM_VIEW)).contains(true) + _ <- code.util.Helper.booleanToFuture( + s"${CreateCustomViewError} You need the `${CAN_UPDATE_CUSTOM_VIEW}` permission on any your views", + cc = Some(cc)) { anyCanUpdate } + updateViewJson = UpdateViewJSON( + description = updateBodyJson.description, + metadata_view = view.metadataView, + is_public = updateBodyJson.is_public, + which_alias_to_use = updateBodyJson.which_alias_to_use, + hide_metadata_if_alias_used = updateBodyJson.hide_metadata_if_alias_used, + allowed_actions = updateBodyJson.allowed_actions + ) + (updatedView, _) <- ViewNewStyle.updateCustomView(BankIdAccountId(account.bankId, account.accountId), viewId, updateViewJson, Some(cc)) + } yield JSONFactory220.createViewJSON(updatedView) + } + + // ─── getCurrentFxRate ───────────────────────────────────────────────────── + + private val getCurrentFxRateIsPublic = APIUtil.getPropsAsBoolValue("apiOptions.getCurrentFxRateIsPublic", false) + + val getCurrentFxRate: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "fx" / fromCurrencyCode / toCurrencyCode => + EndpointHelpers.withBank(req) { (bank, cc) => + val fromUpper = fromCurrencyCode.toUpperCase + val toUpper = toCurrencyCode.toUpperCase + for { + _ <- if (!getCurrentFxRateIsPublic) + code.util.Helper.booleanToFuture(AuthenticatedUserIsRequired, failCode = 401, cc = Some(cc)) { cc.user.isDefined } + else Future.unit + _ <- code.util.Helper.booleanToFuture(ConsumerHasMissingRoles + canReadFx, cc = Some(cc)) { + checkScope(bank.bankId.value, getConsumerPrimaryKey(Some(cc)), canReadFx) + } + _ <- NewStyle.function.isValidCurrencyISOCode(fromUpper, Some(cc)) + _ <- NewStyle.function.isValidCurrencyISOCode(toUpper, Some(cc)) + fxRate <- NewStyle.function.getExchangeRate(bank.bankId, fromUpper, toUpper, Some(cc)) + } yield JSONFactory220.createFXRateJSON(fxRate) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCurrentFxRate), "GET", + "/banks/BANK_ID/fx/FROM_CURRENCY_CODE/TO_CURRENCY_CODE", + "Get Current FxRate", + """Get the latest FX rate specified by BANK_ID, FROM_CURRENCY_CODE and TO_CURRENCY_CODE.""", + EmptyBody, fXRateJSON, + List(InvalidISOCurrencyCode, AuthenticatedUserIsRequired, FXCurrencyCodeCombinationsNotSupported, UnknownError), + List(apiTagFx), None, + http4sPartialFunction = Some(getCurrentFxRate)) + + // ─── getExplicitCounterpartiesForAccount ────────────────────────────────── + + 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( + s"${NoViewPermission} You need the `${CAN_GET_COUNTERPARTY}` permission on the View(${view.viewId.value})", + cc = Some(cc)) { + ViewPermission.findViewPermissions(view).exists(_.permission.get == 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 => + Counterparties.counterparties.vend + .getOrCreateMetadata(account.bankId, account.accountId, cp.counterpartyId, cp.name) + .isDefined + } + } + } yield JSONFactory220.createCounterpartiesJSON(counterparties) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getExplicitCounterpartiesForAccount), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/counterparties", + "Get Counterparties (Explicit)", + s"""Gets the explicit Counterparties on an Account / View. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, counterpartiesJsonV220, + List(AuthenticatedUserIsRequired, BankAccountNotFound, ViewNotFound, NoViewPermission, + UserNoPermissionAccessView, UnknownError), + List(apiTagCounterparty, apiTagPSD2PIS, apiTagAccount, apiTagPsd2), None, + http4sPartialFunction = Some(getExplicitCounterpartiesForAccount)) + + // ─── getExplicitCounterpartyById ────────────────────────────────────────── + + val getExplicitCounterpartyById: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "counterparties" / _ => + EndpointHelpers.withCounterparty(req) { (_, account, view, counterparty, cc) => + for { + _ <- code.util.Helper.booleanToFuture( + s"${NoViewPermission} You need the `${CAN_GET_COUNTERPARTY}` permission on the View(${view.viewId.value})", + cc = Some(cc)) { + ViewPermission.findViewPermissions(view).exists(_.permission.get == CAN_GET_COUNTERPARTY) + } + counterpartyMetadata <- NewStyle.function.getMetadata( + account.bankId, account.accountId, counterparty.counterpartyId, Some(cc)) + } yield JSONFactory220.createCounterpartyWithMetadataJSON(counterparty, counterpartyMetadata) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getExplicitCounterpartyById), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/counterparties/COUNTERPARTY_ID", + "Get Counterparty by Counterparty Id (Explicit)", + s"""Information returned about the Counterparty specified by COUNTERPARTY_ID. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, counterpartyWithMetadataJson, + List(AuthenticatedUserIsRequired, BankNotFound, UnknownError), + List(apiTagCounterparty, apiTagPSD2PIS, apiTagCounterpartyMetaData, apiTagPsd2), None, + http4sPartialFunction = Some(getExplicitCounterpartyById)) + + // ─── getMessageDocs ─────────────────────────────────────────────────────── + + val getMessageDocs: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "message-docs" / connector => + EndpointHelpers.executeAndRespond(req) { cc => + Future { + val connectorObject = unboxFullOrFail( + net.liftweb.util.Helpers.tryo { Connector.getConnectorInstance(connector) }, + Some(cc), + s"$InvalidConnector Current Input is $connector. It should be eg: rest_vMar2019..." + ) + JSONFactory220.createMessageDocsJson(connectorObject.messageDocs.toList) + } + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getMessageDocs), "GET", + "/message-docs/CONNECTOR", + "Get Message Docs", + """These message docs provide example messages sent by OBP to the message queue for processing by the Adapter. + |`CONNECTOR`: rest_vMar2019, stored_procedure_vDec2019 ...""", + EmptyBody, messageDocsJson, + List(InvalidConnector, UnknownError), + List(apiTagMessageDoc, apiTagDocumentation, apiTagApi), None, + http4sPartialFunction = Some(getMessageDocs)) + + // ─── createBank ─────────────────────────────────────────────────────────── + + val createBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" => + EndpointHelpers.withUserAndBodyCreated[BankJSONV220, BankJSONV220](req) { (user, bank, cc) => + val checkShort = APIUtil.checkShortString(bank.id) + for { + _ <- code.util.Helper.booleanToFuture( + s"$InvalidJsonFormat Min length of BANK_ID should be 5 characters.", cc = Some(cc)) { + bank.id.length > 5 + } + _ <- code.util.Helper.booleanToFuture(s"$checkShort.", cc = Some(cc)) { checkShort.isEmpty } + _ <- code.util.Helper.booleanToFuture( + s"$InvalidJsonFormat BANK_ID can not contain `::::` characters", cc = Some(cc)) { + !`checkIfContains::::` (bank.id) + } + consumer <- Future { unboxFullOrFail(cc.consumer, Some(cc), InvalidConsumerCredentials) } + _ <- Future { + unboxFullOrFail( + NewStyle.function.hasEntitlementAndScope("", user.userId, consumer.id.get.toString, canCreateBank, Some(cc)), + Some(cc), UserHasMissingRoles + canCreateBank) + } + (success, _) <- NewStyle.function.createOrUpdateBank( + bank.id, bank.full_name, bank.short_name, bank.logo_url, bank.website_url, + bank.swift_bic, bank.national_identifier, + bank.bank_routing.scheme, bank.bank_routing.address, Some(cc) + ) + entitlements <- Future { + unboxFullOrFail( + code.entitlement.Entitlement.entitlement.vend.getEntitlementsByUserId(user.userId), + Some(cc), UnknownError) + } + _ <- Future { + val bankEntitlements = entitlements.filter(_.bankId == bank.id) + if (!bankEntitlements.exists(_.roleName == canCreateEntitlementAtOneBank.toString())) + code.entitlement.Entitlement.entitlement.vend.addEntitlement(bank.id, user.userId, canCreateEntitlementAtOneBank.toString()) + if (!bankEntitlements.exists(_.roleName == canReadDynamicResourceDocsAtOneBank.toString())) + code.entitlement.Entitlement.entitlement.vend.addEntitlement(bank.id, user.userId, canReadDynamicResourceDocsAtOneBank.toString()) + } + } yield JSONFactory220.createBankJSON(success) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createBank), "POST", + "/banks", + "Create Bank", + s"""Create a new bank (Authenticated access). + |${userAuthenticationMessage(true)}""", + bankJSONV220, bankJSONV220, + List(InvalidJsonFormat, AuthenticatedUserIsRequired, InsufficientAuthorisationToCreateBank, UnknownError), + List(apiTagBank, apiTagOldStyle), + Some(List(canCreateBank)), + http4sPartialFunction = Some(createBank)) + + // ─── createBranch ───────────────────────────────────────────────────────── + + val createBranch: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "branches" => + EndpointHelpers.withUserAndBankAndBodyCreated[BranchJsonV220, BranchJsonV220](req) { (user, bank, body, cc) => + for { + _ <- code.util.Helper.booleanToFuture( + s"$InvalidJsonValue BANK_ID has to be the same in the URL and Body", failCode = 400, cc = Some(cc)) { + body.bank_id == bank.bankId.value + } + _ <- Future { + NewStyle.function.hasAllEntitlements(bank.bankId.value, user.userId, canCreateBranch :: Nil, canCreateBranchAtAnyBank :: Nil, Some(cc)) + } map { unboxFullOrFail(_, Some(cc), s"$InsufficientAuthorisationToCreateBranch") } + branch <- NewStyle.function.tryons(CouldNotTransformJsonToInternalModel + " Branch", 400, Some(cc)) { + JSONFactory220.transformV220ToBranch(body).head + } + (success, _) <- NewStyle.function.createOrUpdateBranch(branch, Some(cc)) + } yield JSONFactory220.createBranchJson(success) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createBranch), "POST", + "/banks/BANK_ID/branches", + "Create Branch", + s"""Create Branch for the Bank. + | + |${userAuthenticationMessage(true)}""", + branchJsonV220, branchJsonV220, + List(AuthenticatedUserIsRequired, BankNotFound, InsufficientAuthorisationToCreateBranch, UnknownError), + List(apiTagBranch, apiTagOpenData), + Some(List(canCreateBranch, canCreateBranchAtAnyBank)), + http4sPartialFunction = Some(createBranch)) + + // ─── createAtm ──────────────────────────────────────────────────────────── + + val createAtm: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "atms" => + EndpointHelpers.withUserAndBankAndBodyCreated[AtmJsonV220, AtmJsonV220](req) { (user, bank, body, cc) => + for { + _ <- NewStyle.function.hasAtLeastOneEntitlement(createAtmEntitlementsRequiredText)( + bank.bankId.value, user.userId, createAtmEntitlements, Some(cc)) + _ <- code.util.Helper.booleanToFuture( + s"$InvalidJsonValue BANK_ID has to be the same in the URL and Body", 400, cc = Some(cc)) { + body.bank_id == bank.bankId.value + } + atm <- NewStyle.function.tryons(CouldNotTransformJsonToInternalModel + " Atm", 400, Some(cc)) { + JSONFactory220.transformToAtmFromV220(body).head + } + (createdAtm, _) <- NewStyle.function.createOrUpdateAtm(atm, Some(cc)) + } yield JSONFactory220.createAtmJson(createdAtm) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createAtm), "POST", + "/banks/BANK_ID/atms", + "Create ATM", + s"""Create ATM for the Bank. + | + |${userAuthenticationMessage(true)}""", + atmJsonV220, atmJsonV220, + List(AuthenticatedUserIsRequired, BankNotFound, UserHasMissingRoles, UnknownError), + List(apiTagATM), + Some(List(canCreateAtm, canCreateAtmAtAnyBank)), + http4sPartialFunction = Some(createAtm)) + + // ─── createProduct ──────────────────────────────────────────────────────── + + val createProduct: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "products" => + EndpointHelpers.withUserAndBankAndBodyCreated[ProductJsonV220, ProductJsonV220](req) { (user, bank, body, cc) => + for { + _ <- NewStyle.function.hasAtLeastOneEntitlement(createProductEntitlementsRequiredText)( + bank.bankId.value, user.userId, createProductEntitlements, Some(cc)) + (success, _) <- NewStyle.function.createOrUpdateProduct( + bankId = bank.bankId.value, + code = body.code, + parentProductCode = None, + name = body.name, + category = body.category, + family = body.family, + superFamily = body.super_family, + moreInfoUrl = body.more_info_url, + termsAndConditionsUrl = null, + details = body.details, + description = body.description, + metaLicenceId = body.meta.license.id, + metaLicenceName = body.meta.license.name, + callContext = Some(cc) + ) + } yield JSONFactory220.createProductJson(success) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createProduct), "PUT", + "/banks/BANK_ID/products", + "Create Product", + s"""Create or Update Product for the Bank. + | + |${userAuthenticationMessage(true)}""", + productJsonV220, productJsonV220, + List(AuthenticatedUserIsRequired, BankNotFound, UserHasMissingRoles, UnknownError), + List(apiTagProduct), + Some(List(canCreateProduct, canCreateProductAtAnyBank)), + http4sPartialFunction = Some(createProduct)) + + // ─── createFx ───────────────────────────────────────────────────────────── + + val createFxEntitlementsRequiredForSpecificBank = canCreateFxRate :: Nil + val createFxEntitlementsRequiredForAnyBank = canCreateFxRateAtAnyBank :: Nil + + val createFx: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "fx" => + EndpointHelpers.withUserAndBankAndBodyCreated[FXRateJsonV220, FXRateJsonV220](req) { (user, bank, body, cc) => + for { + _ <- Future { + NewStyle.function.hasAllEntitlements( + bank.bankId.value, user.userId, + createFxEntitlementsRequiredForSpecificBank, + createFxEntitlementsRequiredForAnyBank, + Some(cc)) + } map { unboxFullOrFail(_, Some(cc), UserHasMissingRoles + (createFxEntitlementsRequiredForSpecificBank ::: createFxEntitlementsRequiredForAnyBank).mkString(" or ")) } + _ <- NewStyle.function.isValidCurrencyISOCode(body.from_currency_code, Some(cc)) + _ <- NewStyle.function.isValidCurrencyISOCode(body.to_currency_code, Some(cc)) + (fxRate, _) <- NewStyle.function.createOrUpdateFXRate( + bankId = body.bank_id, + fromCurrencyCode = body.from_currency_code, + toCurrencyCode = body.to_currency_code, + conversionValue = body.conversion_value, + inverseConversionValue = body.inverse_conversion_value, + effectiveDate = body.effective_date, + callContext = Some(cc) + ) + } yield JSONFactory220.createFXRateJSON(fxRate) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createFx), "PUT", + "/banks/BANK_ID/fx", + "Create Fx", + s"""Create or Update Fx for the Bank. + | + |${userAuthenticationMessage(true)}""", + fxJsonV220, fxJsonV220, + List(AuthenticatedUserIsRequired, BankNotFound, UserHasMissingRoles, UnknownError), + List(apiTagFx), + Some(List(canCreateFxRate, canCreateFxRateAtAnyBank)), + http4sPartialFunction = Some(createFx)) + + // ─── createAccount ──────────────────────────────────────────────────────── + + val createAccount: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "accounts" / accountIdStr => + EndpointHelpers.withUserAndBankAndBody[CreateAccountJSONV220, CreateAccountJSONV220](req) { (user, bank, body, cc) => + for { + _ <- code.util.Helper.booleanToFuture(InvalidAccountIdFormat, cc = Some(cc)) { + isValidID(accountIdStr) + } + _ <- code.util.Helper.booleanToFuture(InvalidBankIdFormat, cc = Some(cc)) { isValidID(bank.bankId.value) } + accountId = AccountId(accountIdStr) + loggedInUserId = user.userId + userIdAccountOwner = if (body.user_id.nonEmpty) body.user_id else loggedInUserId + (postedOrLoggedInUser, _) <- NewStyle.function.findByUserId(userIdAccountOwner, Some(cc)) + _ <- if (userIdAccountOwner == loggedInUserId) Future.successful(Full(())) + else code.util.Helper.booleanToFuture( + s"$UserHasMissingRoles $canCreateAccount", failCode = 403, cc = Some(cc)) { + APIUtil.hasEntitlement(bank.bankId.value, loggedInUserId, canCreateAccount) + } + _ <- code.util.Helper.booleanToFuture(InitialBalanceMustBeZero, cc = Some(cc)) { + BigDecimal(body.balance.amount) == 0 + } + _ <- code.util.Helper.booleanToFuture(InvalidISOCurrencyCode, cc = Some(cc)) { + isValidCurrencyISOCode(body.balance.currency) + } + (bankAccount, _) <- NewStyle.function.createBankAccount( + bank.bankId, accountId, body.`type`, body.label, + body.balance.currency, BigDecimal(body.balance.amount), + postedOrLoggedInUser.name, body.branch_id, + List(AccountRouting(body.account_routing.scheme, body.account_routing.address)), + Some(cc) + ) + _ <- BankAccountCreation.setAccountHolderAndRefreshUserAccountAccess(bank.bankId, accountId, postedOrLoggedInUser, Some(cc)) + } yield JSONFactory220.createAccountJSON(userIdAccountOwner, bankAccount) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(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 an Account for another User if they have CanCreateAccount role. + | + |Note: The Amount must be zero.""", + createAccountJSONV220, createAccountJSONV220, + List(InvalidJsonFormat, BankNotFound, AuthenticatedUserIsRequired, InvalidUserId, + InvalidAccountIdFormat, InvalidBankIdFormat, UserNotFoundById, UserHasMissingRoles, + InvalidAccountBalanceAmount, InvalidAccountInitialBalance, InitialBalanceMustBeZero, + InvalidAccountBalanceCurrency, AccountIdAlreadyExists, UnknownError), + List(apiTagAccount, apiTagOnboarding), + None, + http4sPartialFunction = Some(createAccount)) + + // ─── config ─────────────────────────────────────────────────────────────── + + val config: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "config" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + _ <- NewStyle.function.hasEntitlement("", user.userId, canGetConfig, Some(cc)) + } yield JSONFactory220.getConfigInfoJSON() + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(config), "GET", + "/config", + "Get API Configuration", + """Returns information about API Config, Akka ports, Elastic search ports, Cached functions.""", + EmptyBody, configurationJSON, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagApi :: Nil, + Some(List(canGetConfig)), + http4sPartialFunction = Some(config)) + + // ─── getConnectorMetrics ────────────────────────────────────────────────── + + val getConnectorMetrics: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "connector" / "metrics" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + _ <- NewStyle.function.hasEntitlement("", user.userId, canGetConnectorMetrics, Some(cc)) + httpParams <- NewStyle.function.extractHttpParamsFromUrl(req.uri.renderString) + (obpQueryParams, _) <- createQueriesByHttpParamsFuture(httpParams, Some(cc)) + metrics <- Future(ConnectorMetricsProvider.metrics.vend.getAllConnectorMetrics(obpQueryParams)) + } yield JSONFactory220.createConnectorMetricsJson(metrics) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getConnectorMetrics), "GET", + "/management/connector/metrics", + "Get Connector Metrics", + s"""Get all connector metrics. Requires CanGetConnectorMetrics role.""", + EmptyBody, connectorMetricsJson, + List(InvalidDateFormat, UnknownError), + List(apiTagMetric, apiTagApi), + Some(List(canGetConnectorMetrics)), + http4sPartialFunction = Some(getConnectorMetrics)) + + // ─── createConsumer ─────────────────────────────────────────────────────── + + val createConsumer: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "consumers" => + EndpointHelpers.withUserAndBodyCreated[ConsumerPostJSON, ConsumerJson](req) { (user, body, cc) => + for { + _ <- Future { + unboxFullOrFail( + NewStyle.function.ownEntitlement("", user.userId, canCreateConsumer, Some(cc)), + Some(cc), UserHasMissingRoles + canCreateConsumer) + } + consumer <- Future { + Consumers.consumers.vend.createConsumer( + Some(generateUUID()), Some(generateUUID()), + Some(body.enabled), + Some(body.app_name), None, + Some(body.description), + Some(body.developer_email), + Some(body.redirect_url), + Some(user.userId), + Some(body.clientCertificate), + None, None + ).getOrElse(throw new RuntimeException(UnknownError)) + } + } yield JSONFactory220.createConsumerJSON(consumer) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createConsumer), "POST", + "/management/consumers", + "Post a Consumer", + s"""Create a Consumer (Authenticated access). + | + |${userAuthenticationMessage(true)}""", + ConsumerPostJSON("Test", "Test", "Description", "some@email.com", "redirecturl", "createdby", true, new java.util.Date(), + "-----BEGIN CERTIFICATE-----\nclient_certificate_content\n-----END CERTIFICATE-----"), + ConsumerPostJSON("Some app name", "App type", "Description", "some.email@example.com", "Some redirect url", + "Created by UUID", true, new java.util.Date(), + "-----BEGIN CERTIFICATE-----\nclient_certificate_content\n-----END CERTIFICATE-----"), + List(AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagConsumer, apiTagOldStyle), + Some(List(canCreateConsumer)), + http4sPartialFunction = Some(createConsumer)) + + // ─── createCounterparty ─────────────────────────────────────────────────── + + val createCounterparty: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "counterparties" => + 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)) + view <- IO.fromOption(cc.view)(new RuntimeException(ViewNotFound)) + body <- req.bodyText.compile.string + result <- code.api.util.http4s.RequestScopeConnection.fromFuture( + createCounterpartyImpl(user, account, view, body, cc)) + } yield result + io.attempt.flatMap { + case Right(result) => + Created(prettyRender(Extraction.decompose(result))) + case Left(err) => + code.api.util.http4s.ErrorResponseConverter.toHttp4sResponse(err, cc) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createCounterparty), "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/counterparties", + "Create Counterparty (Explicit)", + s"""Create Counterparty (Explicit) for an Account. + | + |${userAuthenticationMessage(true)}""", + postCounterpartyJSON, counterpartyWithMetadataJson, + List(AuthenticatedUserIsRequired, InvalidAccountIdFormat, InvalidBankIdFormat, BankNotFound, + AccountNotFound, InvalidJsonFormat, ViewNotFound, CounterpartyAlreadyExists, UnknownError), + List(apiTagCounterparty, apiTagAccount), None, + http4sPartialFunction = Some(createCounterparty)) + + private def createCounterpartyImpl( + user: User, account: BankAccount, view: View, body: String, cc: CallContext + ): Future[CounterpartyWithMetadataJson] = { + 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 $PostCounterpartyJSON", 400, Some(cc)) { + net.liftweb.json.parse(body).extract[PostCounterpartyJSON] + } + _ <- code.util.Helper.booleanToFuture( + s"${NoViewPermission} You need the `${CAN_ADD_COUNTERPARTY}` permission on the View(${view.viewId.value})", + cc = Some(cc)) { + ViewPermission.findViewPermissions(view).exists(_.permission.get == 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 } + (_, _) <- 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))) + otherAccountRoutingScheme = if (postJson.other_account_routing_scheme.equalsIgnoreCase("AccountNo")) + "ACCOUNT_NUMBER" + else StringHelpers.snakify(postJson.other_account_routing_scheme).toUpperCase + (counterparty, _) <- NewStyle.function.createCounterparty( + name = postJson.name, + description = postJson.description, + currency = "", + createdByUserId = user.userId, + thisBankId = account.bankId.value, + thisAccountId = account.accountId.value, + thisViewId = view.viewId.value, + otherAccountRoutingScheme = otherAccountRoutingScheme, + 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)), + callContext = Some(cc) + ) + (counterpartyMetadata, _) <- NewStyle.function.getOrCreateMetadata( + account.bankId, account.accountId, counterparty.counterpartyId, postJson.name, Some(cc)) + } yield JSONFactory220.createCounterpartyWithMetadataJSON(counterparty, counterpartyMetadata) + } + + // ─── allRoutes ──────────────────────────────────────────────────────────── + + private val allOwnRoutes: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req => + root.run(req) + .orElse(getViewsForBankAccount.run(req)) + .orElse(createViewForBankAccount.run(req)) + .orElse(updateViewForBankAccount.run(req)) + .orElse(getCurrentFxRate.run(req)) + .orElse(getExplicitCounterpartiesForAccount.run(req)) + .orElse(getExplicitCounterpartyById.run(req)) + .orElse(getMessageDocs.run(req)) + .orElse(createBank.run(req)) + .orElse(createBranch.run(req)) + .orElse(createAtm.run(req)) + .orElse(createProduct.run(req)) + .orElse(createFx.run(req)) + .orElse(createAccount.run(req)) + .orElse(config.run(req)) + .orElse(getConnectorMetrics.run(req)) + .orElse(createConsumer.run(req)) + .orElse(createCounterparty.run(req)) + } + + val allRoutesWithMiddleware: HttpRoutes[IO] = ResourceDocMiddleware.apply(resourceDocs)(allOwnRoutes) + + // ─── path-rewriting bridge: /obp/v2.2.0/… → /obp/v2.1.0/… ────────────── + + val v220ToV210Bridge: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req => + val rawPath = req.uri.path.renderString + if (rawPath.startsWith("/obp/v2.2.0/")) { + val rewritten = rawPath.replaceFirst("/obp/v2\\.2\\.0/", "/obp/v2.1.0/") + val newUri = req.uri.withPath(Uri.Path.unsafeFromString(rewritten)) + val rewrittenReq = req.withUri(newUri) + code.api.v2_1_0.Http4s210.wrappedRoutesV210Services.run(rewrittenReq) + } else { + OptionT.none[IO, Response[IO]] + } + } + } + + val wrappedRoutesV220Services: HttpRoutes[IO] = + Kleisli[HttpF, Request[IO], Response[IO]] { req => + Implementations2_2_0.allRoutesWithMiddleware.run(req) + .orElse(Implementations2_2_0.v220ToV210Bridge.run(req)) + } +} 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 new file mode 100644 index 0000000000..6a61ade5b1 --- /dev/null +++ b/obp-api/src/main/scala/code/api/v3_0_0/Http4s300.scala @@ -0,0 +1,1715 @@ +package code.api.v3_0_0 + +import cats.data.{Kleisli, OptionT} +import cats.effect._ +import code.api.Constant +import code.api.Constant._ +import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON._ +import code.api.util.APIUtil.{EmptyBody, ResourceDoc, _} +import code.api.util.{ApiRole, FutureUtil} +import code.api.util.ApiRole._ +import code.api.util.ApiTag._ +import code.api.util.ErrorMessages._ +import code.api.util.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps} +import code.api.util.http4s.ResourceDocMiddleware +import code.api.util.newstyle.ViewNewStyle +import code.api.util.{APIUtil, CallContext, CustomJsonFormats, NewStyle} +import code.api.v1_2_1.JSONFactory +import code.api.v2_0_0.JSONFactory200 +import code.api.v3_0_0.JSONFactory300._ +import code.bankconnectors.Connector +import code.consumer.Consumers +import code.entitlementrequest.EntitlementRequest +import code.metrics.APIMetrics +import code.model._ +import code.scope.Scope +import code.search.elasticsearchWarehouse +import code.users.Users +import code.views.Views +import com.github.dwickern.macros.NameOf.nameOf +import com.grum.geocalc.{Coordinate, EarthCalc, Point} +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, Failure, Full, ParamFailure} +import net.liftweb.http.provider.HTTPParam +import net.liftweb.json.JsonAST.JField +import net.liftweb.json.compactRender +import net.liftweb.json.{Extraction, Formats, Serialization} +import net.liftweb.util.Helpers.tryo +import org.http4s._ +import org.http4s.dsl.io._ + +import java.util.regex.Pattern +import scala.collection.mutable.ArrayBuffer +import scala.concurrent.Future + +object Http4s300 { + val implementedInApiVersion: ScannedApiVersion = ApiVersion.v3_0_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_0_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_0_0, versionStatus)) + } + case req @ GET -> `prefixPath` / "root" => + EndpointHelpers.executeAndRespond(req) { _ => + Future.successful(JSONFactory.getApiInfoJSON(ApiVersion.v3_0_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)) + + // ─── getViewsForBankAccount ─────────────────────────────────────────────── + + val getViewsForBankAccount: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / "views" => + EndpointHelpers.withBankAccount(req) { (user, account, cc) => + for { + permission <- NewStyle.function.permission(account.bankId, account.accountId, user, Some(cc)) + anyCanSee = permission.views + .map(_.allowed_actions.exists(_ == CAN_SEE_AVAILABLE_VIEWS_FOR_BANK_ACCOUNT)) + .contains(true) + _ <- code.util.Helper.booleanToFuture( + s"${ViewDoesNotPermitAccess} You need the `${CAN_SEE_AVAILABLE_VIEWS_FOR_BANK_ACCOUNT}` permission on any your views", + cc = Some(cc)) { anyCanSee } + views <- Future(Views.views.vend.availableViewsForAccount(BankIdAccountId(account.bankId, account.accountId))) + } yield JSONFactory300.createViewsJSON(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, viewsJsonV300, + List(AuthenticatedUserIsRequired, BankAccountNotFound, UnknownError), + List(apiTagView, apiTagAccount), None, + http4sPartialFunction = Some(getViewsForBankAccount)) + + // ─── createViewForBankAccount ───────────────────────────────────────────── + + val createViewForBankAccount: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "accounts" / accountIdStr / "views" => + implicit val cc: CallContext = req.callContext + val io = for { + user <- IO.fromOption(cc.user.toOption)(new RuntimeException(AuthenticatedUserIsRequired)) + 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 + result <- code.api.util.http4s.RequestScopeConnection.fromFuture( + createViewImpl300(user, account, body, cc)) + } yield result + io.attempt.flatMap { + case Right(result) => + Created(net.liftweb.json.prettyRender(Extraction.decompose(result))) + case Left(err) => + code.api.util.http4s.ErrorResponseConverter.toHttp4sResponse(err, cc) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createViewForBankAccount), "POST", + "/banks/BANK_ID/accounts/VIEW_ACCOUNT_ID/views", + "Create Custom View", + s"""Create a custom view on bank account. + | + |${userAuthenticationMessage(true)} and the user needs to have access to the owner view.""", + createViewJsonV300, viewJsonV300, + List(AuthenticatedUserIsRequired, InvalidJsonFormat, BankAccountNotFound, UnknownError), + List(apiTagView, apiTagAccount), None, + http4sPartialFunction = Some(createViewForBankAccount)) + + private def createViewImpl300(user: User, account: BankAccount, body: String, cc: CallContext): Future[ViewJsonV300] = { + for { + createBodyJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $CreateViewJsonV300", 400, Some(cc)) { + net.liftweb.json.parse(body).extract[CreateViewJsonV300] + } + _ <- code.util.Helper.booleanToFuture( + s"$InvalidCustomViewFormat Current view_name (${createBodyJson.name})", cc = Some(cc)) { + isValidCustomViewName(createBodyJson.name) + } + permission <- NewStyle.function.permission(account.bankId, account.accountId, user, Some(cc)) + anyCanCreate = permission.views.map(_.allowed_actions.exists(_ == CAN_CREATE_CUSTOM_VIEW)).contains(true) + _ <- code.util.Helper.booleanToFuture( + s"${ViewDoesNotPermitAccess} You need the `${CAN_CREATE_CUSTOM_VIEW}` permission on any your views", + cc = Some(cc)) { anyCanCreate } + (view, _) <- ViewNewStyle.createCustomView(BankIdAccountId(account.bankId, account.accountId), createBodyJson.toCreateViewJson, Some(cc)) + } yield JSONFactory300.createViewJSON(view) + } + + // ─── updateViewForBankAccount ───────────────────────────────────────────── + + val updateViewForBankAccount: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "accounts" / _ / "views" / viewIdStr => + 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)) + body <- req.bodyText.compile.string + result <- code.api.util.http4s.RequestScopeConnection.fromFuture( + updateViewImpl300(user, account, ViewId(viewIdStr), body, cc)) + } yield result + io.attempt.flatMap { + case Right(result) => + Ok(net.liftweb.json.prettyRender(Extraction.decompose(result))) + case Left(err) => + code.api.util.http4s.ErrorResponseConverter.toHttp4sResponse(err, cc) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateViewForBankAccount), "PUT", + "/banks/BANK_ID/accounts/ACCOUNT_ID/views/UPD_VIEW_ID", + "Update Custom View", + s"""Update an existing custom view on a bank account. + | + |${userAuthenticationMessage(true)} and the user needs to have access to the owner view.""", + updateViewJsonV300, viewJsonV300, + List(InvalidJsonFormat, AuthenticatedUserIsRequired, BankAccountNotFound, UnknownError), + List(apiTagView, apiTagAccount), None, + http4sPartialFunction = Some(updateViewForBankAccount)) + + private def updateViewImpl300(user: User, account: BankAccount, viewId: ViewId, body: String, cc: CallContext): Future[ViewJsonV300] = { + for { + updateBodyJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $UpdateViewJsonV300", 400, Some(cc)) { + net.liftweb.json.parse(body).extract[UpdateViewJsonV300] + } + _ <- code.util.Helper.booleanToFuture( + s"$InvalidCustomViewFormat Current view_name (${viewId.value})", cc = Some(cc)) { + updateBodyJson.metadata_view.startsWith("_") + } + _ <- Views.views.vend.customViewFuture(viewId, BankIdAccountId(account.bankId, account.accountId)) map { x => + unboxFull(fullBoxOrException(x ~> code.api.APIFailureNewStyle( + s"$ViewNotFound. Check your post json body, metadata_view = ${updateBodyJson.metadata_view}. It should be an existing VIEW_ID, eg: owner", + 400, Some(cc.toLight)))) + } + view <- ViewNewStyle.checkViewAccessAndReturnView(viewId, BankIdAccountId(account.bankId, account.accountId), Some(user), Some(cc)) + _ <- code.util.Helper.booleanToFuture(SystemViewsCanNotBeModified, cc = Some(cc)) { !view.isSystem } + permission <- NewStyle.function.permission(account.bankId, account.accountId, user, Some(cc)) + anyCanUpdate = permission.views.map(_.allowed_actions.exists(_ == CAN_UPDATE_CUSTOM_VIEW)).contains(true) + _ <- code.util.Helper.booleanToFuture( + s"${ViewDoesNotPermitAccess} You need the `${CAN_UPDATE_CUSTOM_VIEW}` permission on any your views", + cc = Some(cc)) { anyCanUpdate } + (updatedView, _) <- ViewNewStyle.updateCustomView(BankIdAccountId(account.bankId, account.accountId), viewId, updateBodyJson.toUpdateViewJson, Some(cc)) + } yield JSONFactory300.createViewJSON(updatedView) + } + + // ─── getPermissionForUserForBankAccount ─────────────────────────────────── + + val getPermissionForUserForBankAccount: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / "permissions" / providerStr / providerIdStr => + EndpointHelpers.withBankAccount(req) { (user, account, cc) => + for { + permission <- NewStyle.function.permission(account.bankId, account.accountId, user, Some(cc)) + anyCanSeePermissions = permission.views + .map(_.allowed_actions.exists(_ == CAN_SEE_VIEWS_WITH_PERMISSIONS_FOR_ONE_USER)) + .contains(true) + _ <- code.util.Helper.booleanToFuture( + s"${ViewDoesNotPermitAccess} You need the `${CAN_SEE_VIEWS_WITH_PERMISSIONS_FOR_ONE_USER}` permission on any your views", + cc = Some(cc)) { anyCanSeePermissions } + userFromURL <- Future { + unboxFullOrFail( + UserX.findByProviderId(providerStr, providerIdStr), + Some(cc), UserNotFoundByProviderAndProvideId, 404) + } + userPermission <- NewStyle.function.permission(account.bankId, account.accountId, userFromURL, Some(cc)) + } yield createViewsJSON(userPermission.views.sortBy(_.viewId.value)) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getPermissionForUserForBankAccount), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/permissions/PROVIDER/PROVIDER_ID", + "Get Account access for User", + s"""Returns the list of the views at BANK_ID for account ACCOUNT_ID that a user identified by PROVIDER_ID at their provider PROVIDER has access to. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, viewsJsonV300, + List(AuthenticatedUserIsRequired, BankNotFound, AccountNotFound, UnknownError), + List(apiTagView, apiTagAccount, apiTagUser), None, + http4sPartialFunction = Some(getPermissionForUserForBankAccount)) + + // ─── getPrivateAccountById ──────────────────────────────────────────────── + + val getPrivateAccountById: 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)) + } yield createCoreBankAccountJSON(moderatedAccount) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getPrivateAccountById), "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).""", + EmptyBody, moderatedCoreAccountJsonV300, + List(BankNotFound, AccountNotFound, ViewNotFound, UserNoPermissionAccessView, UnknownError), + apiTagAccount :: Nil, None, + http4sPartialFunction = Some(getPrivateAccountById)) + + // ─── getPublicAccountById ───────────────────────────────────────────────── + + val getPublicAccountById: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "public" / "accounts" / _ / _ / "account" => + EndpointHelpers.executeAndRespond(req) { cc => + for { + (bank, _) <- NewStyle.function.getBank(cc.bank.map(_.bankId).getOrElse(BankId("")), Some(cc)) + (account, _) <- NewStyle.function.getBankAccount(bank.bankId, cc.bankAccount.map(_.accountId).getOrElse(AccountId("")), Some(cc)) + view <- ViewNewStyle.checkViewAccessAndReturnView(cc.view.map(_.viewId).getOrElse(ViewId("")), BankIdAccountId(account.bankId, account.accountId), cc.user, Some(cc)) + moderatedAccount <- NewStyle.function.moderatedBankAccountCore(account, view, Empty, Some(cc)) + } yield createCoreBankAccountJSON(moderatedAccount) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getPublicAccountById), "GET", + "/banks/BANK_ID/public/accounts/ACCOUNT_ID/VIEW_ID/account", + "Get Public Account by Id", + s"""Returns information about an account that has a public view. + | + |${userAuthenticationMessage(false)}""", + EmptyBody, moderatedCoreAccountJsonV300, + List(BankNotFound, AccountNotFound, ViewNotFound, UnknownError), + apiTagAccountPublic :: apiTagAccount :: Nil, None, + http4sPartialFunction = Some(getPublicAccountById)) + + // ─── getCoreAccountById ─────────────────────────────────────────────────── + + val getCoreAccountById: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "my" / "banks" / _ / "accounts" / _ / "account" => + EndpointHelpers.withBankAccount(req) { (user, account, cc) => + for { + view <- ViewNewStyle.checkOwnerViewAccessAndReturnOwnerView(user, BankIdAccountId(account.bankId, account.accountId), Some(cc)) + moderatedAccount <- NewStyle.function.moderatedBankAccountCore(account, view, Full(user), Some(cc)) + } yield { + val availableViews = Views.views.vend.privateViewsUserCanAccessForAccount(user, BankIdAccountId(account.bankId, account.accountId)) + createNewCoreBankAccountJson(moderatedAccount, availableViews) + } + } + } + + resourceDocs += 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, newModeratedCoreAccountJsonV300, + List(BankAccountNotFound, UnknownError), + apiTagAccount :: apiTagPSD2AIS :: apiTagPsd2 :: Nil, None, + http4sPartialFunction = Some(getCoreAccountById)) + + // ─── corePrivateAccountsAllBanks ────────────────────────────────────────── + + val corePrivateAccountsAllBanks: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "my" / "accounts" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + availablePrivateAccounts <- Views.views.vend.getPrivateBankAccountsFuture(user) + (coreAccounts, _) <- NewStyle.function.getCoreBankAccountsFuture(availablePrivateAccounts, Some(cc)) + filtered = filterCoreAccountsByType(coreAccounts, req) + } yield JSONFactory300.createCoreAccountsByCoreAccountsJSON(filtered, user) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(corePrivateAccountsAllBanks), "GET", + "/my/accounts", + "Get Accounts at all Banks (private)", + s"""Returns the list of accounts containing private views for the user. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, coreAccountsJsonV300, + List(AuthenticatedUserIsRequired, UnknownError), + List(apiTagAccount, apiTagPSD2AIS, apiTagPrivateData, apiTagPsd2), None, + http4sPartialFunction = Some(corePrivateAccountsAllBanks)) + + // ─── getFirehoseAccountsAtOneBank ───────────────────────────────────────── + // Uses FIREHOSE_BANK_ID / FIREHOSE_VIEW_ID in the ResourceDoc URL template so middleware + // does NOT resolve the bank/view (validateBank checks pathParams("BANK_ID") exactly). + // Order: prop check (400) → role check (403) → bank lookup (404) — matches test expectations. + + val getFirehoseAccountsAtOneBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankIdStr / "firehose" / "accounts" / "views" / viewIdStr => + 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)) + 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 JSONFactory300.createFirehoseCoreBankAccountJSON(moderatedAccounts, accountAttributes) + } + } + + resourceDocs += 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, moderatedCoreAccountsJsonV300, + List(AuthenticatedUserIsRequired, AccountFirehoseNotAllowedOnThisInstance, UnknownError), + List(apiTagAccount, apiTagAccountFirehose, apiTagFirehoseData), + None, + http4sPartialFunction = Some(getFirehoseAccountsAtOneBank)) + + // ─── getFirehoseTransactionsForBankAccount ──────────────────────────────── + // Uses non-standard FIREHOSE_* vars so middleware skips bank/account/view validation. + // Order: prop check (400) → role check (403) → bank/account/view lookups — matches tests. + + val getFirehoseTransactionsForBankAccount: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankIdStr / "firehose" / "accounts" / accountIdStr / "views" / viewIdStr / "transactions" => + 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)) + (account, _) <- NewStyle.function.getBankAccount(BankId(bankIdStr), AccountId(accountIdStr), Some(cc)) + view <- ViewNewStyle.checkViewAccessAndReturnView(ViewId(viewIdStr), BankIdAccountId(bank.bankId, account.accountId), Some(user), Some(cc)) + allowedParams = List("sort_direction", "limit", "offset", "from_date", "to_date") + httpParams = req.uri.query.multiParams.flatMap { case (k, vs) => vs.map(v => HTTPParam(k, v)) }.toList + (obpQueryParams, _) <- NewStyle.function.createObpParams(httpParams, allowedParams, Some(cc)) + reqParams = req.uri.query.multiParams.filterNot { case (k, _) => allowedParams.contains(k) } + (transactionIds, _) <- if (reqParams.nonEmpty) + NewStyle.function.getTransactionIdsByAttributeNameValues(account.bankId, reqParams.map { case (k, vs) => k -> vs.toList }, Some(cc)) + else + Future((List.empty[TransactionId], Some(cc))) + (transactions, _) <- Future(account.getModeratedTransactions(bank, Full(user), view, BankIdAccountId(account.bankId, account.accountId), Some(cc), obpQueryParams)) map { + unboxFullOrFail(_, Some(cc), UnknownError) + } + moderatedTransactionsWithAttributes <- Future.sequence( + transactions.map(transaction => + NewStyle.function.getTransactionAttributes(account.bankId, transaction.id, Some(cc)) + .map(attributes => ModeratedTransactionWithAttributes(transaction, attributes._1)) + ) + ) + transactionsFiltered = if (reqParams.isEmpty) moderatedTransactionsWithAttributes + else moderatedTransactionsWithAttributes.filter(t => transactionIds.contains(t.transaction.id)) + } yield createTransactionsJson(transactionsFiltered) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getFirehoseTransactionsForBankAccount), "GET", + "/banks/FIREHOSE_BANK_ID/firehose/accounts/FIREHOSE_ACCOUNT_ID/views/FIREHOSE_VIEW_ID/transactions", + "Get Firehose Transactions for Account", + s"""Get Transactions for an Account that has a firehose View. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, transactionsJsonV300, + List(AuthenticatedUserIsRequired, AccountFirehoseNotAllowedOnThisInstance, UserHasMissingRoles, UnknownError), + List(apiTagTransaction, apiTagAccountFirehose, apiTagTransactionFirehose, apiTagFirehoseData), + None, + http4sPartialFunction = Some(getFirehoseTransactionsForBankAccount)) + + // ─── getCoreTransactionsForBankAccount ──────────────────────────────────── + + val getCoreTransactionsForBankAccount: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "my" / "banks" / _ / "accounts" / _ / "transactions" => + EndpointHelpers.withBankAccount(req) { (user, account, cc) => + for { + (bank, _) <- NewStyle.function.getBank(account.bankId, Some(cc)) + view <- ViewNewStyle.checkOwnerViewAccessAndReturnOwnerView(user, BankIdAccountId(account.bankId, account.accountId), Some(cc)) + httpParams = req.uri.query.multiParams.flatMap { case (k, vs) => vs.map(v => HTTPParam(k, v)) }.toList + (params, _) <- createQueriesByHttpParamsFuture(httpParams, Some(cc)) + (transactionsCore, _) <- account.getModeratedTransactionsCore(bank, Some(user), view, BankIdAccountId(account.bankId, account.accountId), params, Some(cc)) map { + i => (unboxFullOrFail(i._1, Some(cc), UnknownError), i._2) + } + moderatedTransactionsCoreWithAttributes <- Future.sequence( + transactionsCore.map(transaction => + NewStyle.function.getTransactionAttributes(account.bankId, transaction.id, Some(cc)) + .map(attributes => ModeratedTransactionCoreWithAttributes(transaction, attributes._1)) + ) + ) + } yield createCoreTransactionsJSON(moderatedTransactionsCoreWithAttributes) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCoreTransactionsForBankAccount), "GET", + "/my/banks/BANK_ID/accounts/ACCOUNT_ID/transactions", + "Get Transactions for Account (Core)", + s"""Returns transactions list (Core info) of the account specified by ACCOUNT_ID. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, coreTransactionsJsonV300, + List(FilterSortDirectionError, FilterOffersetError, FilterLimitError, FilterDateFormatError, + AuthenticatedUserIsRequired, BankAccountNotFound, ViewNotFound, UnknownError), + List(apiTagTransaction, apiTagPSD2AIS, apiTagAccount, apiTagPsd2), None, + http4sPartialFunction = Some(getCoreTransactionsForBankAccount)) + + // ─── getTransactionsForBankAccount ──────────────────────────────────────── + + val getTransactionsForBankAccount: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "transactions" => + EndpointHelpers.withView(req) { (user, account, view, cc) => + for { + (bank, _) <- NewStyle.function.getBank(account.bankId, Some(cc)) + httpParams = req.headers.headers.toList.map(h => HTTPParam(h.name.toString, h.value)) + (params, _) <- createQueriesByHttpParamsFuture(httpParams, Some(cc)) + (transactions, _) <- account.getModeratedTransactionsFuture(bank, Some(user), view, Some(cc), params) map { + connectorEmptyResponse(_, Some(cc)) + } + moderatedTransactionsWithAttributes <- Future.sequence( + transactions.map(transaction => + NewStyle.function.getTransactionAttributes(account.bankId, transaction.id, Some(cc)) + .map(attributes => ModeratedTransactionWithAttributes(transaction, attributes._1)) + ) + ) + } yield createTransactionsJson(moderatedTransactionsWithAttributes) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getTransactionsForBankAccount), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transactions", + "Get Transactions for Account (Full)", + s"""Returns transactions list of the account specified by ACCOUNT_ID and moderated by the view (VIEW_ID). + | + |${userAuthenticationMessage(false)}""", + EmptyBody, transactionsJsonV300, + List(FilterSortDirectionError, FilterOffersetError, FilterLimitError, FilterDateFormatError, + AuthenticatedUserIsRequired, BankAccountNotFound, ViewNotFound, UnknownError), + List(apiTagTransaction, apiTagAccount), None, + http4sPartialFunction = Some(getTransactionsForBankAccount)) + + // ─── dataWarehouseSearch ────────────────────────────────────────────────── + + private val esw = new elasticsearchWarehouse + + val dataWarehouseSearch: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "search" / "warehouse" / indexStr => + implicit val cc: CallContext = req.callContext + val io = for { + user <- IO.fromOption(cc.user.toOption)(new RuntimeException(AuthenticatedUserIsRequired)) + bodyText <- req.bodyText.compile.string + result <- code.api.util.http4s.RequestScopeConnection.fromFuture { + for { + _ <- code.util.Helper.booleanToFuture(ElasticSearchDisabled, cc = Some(cc)) { esw.isEnabled() } + json <- Future { unboxFullOrFail(tryo(net.liftweb.json.parse(bodyText)), Some(cc), ElasticSearchEmptyQueryBody) } + maximumSize = APIUtil.getPropsAsIntValue("es.warehouse.allowed.maximum.pagesize", 10000) + _ <- code.util.Helper.booleanToFuture( + maximumLimitExceeded.replace("Maximum number is 10000.", s"Please check query body, the maximum size is $maximumSize."), + cc = Some(cc)) { + val allSizeFields = json filterField { case JField(key, _) => key.equals("size") } + allSizeFields.map(_.value.values.toString.toInt).find(_ > maximumSize).isEmpty + } + indexPart <- Future { unboxFullOrFail(esw.getElasticSearchUri(indexStr), Some(cc), ElasticSearchIndexNotFound) } + bodyPart <- Future { unboxFullOrFail(tryo(compactRender(json)), Some(cc), ElasticSearchEmptyQueryBody) } + result <- esw.searchProxyAsyncV300(user.userId, indexPart, bodyPart) + } yield esw.parseResponse(result) + } + } yield result + io.attempt.flatMap { + case Right(r) => Ok(net.liftweb.json.prettyRender(net.liftweb.json.Extraction.decompose(r))) + case Left(e) => code.api.util.http4s.ErrorResponseConverter.toHttp4sResponse(e, cc) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(dataWarehouseSearch), "POST", + "/search/warehouse/INDEX", + "Data Warehouse Search", + s"""Search the data warehouse and get row level results. + | + |${userAuthenticationMessage(true)}""", + elasticSearchJsonV300, emptyElasticSearch, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagSearchWarehouse), + Some(List(canSearchWarehouse)), + http4sPartialFunction = Some(dataWarehouseSearch)) + + // ─── dataWarehouseStatistics ────────────────────────────────────────────── + + val dataWarehouseStatistics: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "search" / "warehouse" / "statistics" / indexStr / fieldStr => + implicit val cc: CallContext = req.callContext + val io = for { + user <- IO.fromOption(cc.user.toOption)(new RuntimeException(AuthenticatedUserIsRequired)) + bodyText <- req.bodyText.compile.string + result <- code.api.util.http4s.RequestScopeConnection.fromFuture { + for { + _ <- code.util.Helper.booleanToFuture(ElasticSearchDisabled, cc = Some(cc)) { esw.isEnabled() } + json <- Future { unboxFullOrFail(tryo(net.liftweb.json.parse(bodyText)), Some(cc), ElasticSearchEmptyQueryBody) } + maximumSize = APIUtil.getPropsAsIntValue("es.warehouse.allowed.maximum.pagesize", 10000) + _ <- code.util.Helper.booleanToFuture( + maximumLimitExceeded.replace("Maximum number is 10000.", s"Please check query body, the maximum size is $maximumSize."), + cc = Some(cc)) { + val allSizeFields = json filterField { case JField(key, _) => key.equals("size") } + allSizeFields.map(_.value.values.toString.toInt).find(_ > maximumSize).isEmpty + } + indexPart <- Future { unboxFullOrFail(esw.getElasticSearchUri(indexStr), Some(cc), ElasticSearchIndexNotFound) } + bodyPart <- Future { unboxFullOrFail(tryo(compactRender(json)), Some(cc), ElasticSearchEmptyQueryBody) } + result <- esw.searchProxyStatsAsyncV300(user.userId, indexPart, bodyPart, fieldStr) + } yield esw.parseResponse(result, true) + } + } yield result + io.attempt.flatMap { + case Right(r) => Ok(net.liftweb.json.prettyRender(net.liftweb.json.Extraction.decompose(r))) + case Left(e) => code.api.util.http4s.ErrorResponseConverter.toHttp4sResponse(e, cc) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(dataWarehouseStatistics), "POST", + "/search/warehouse/statistics/INDEX/FIELD", + "Data Warehouse Statistics", + s"""Search the data warehouse and get statistical aggregations over a warehouse field. + | + |${userAuthenticationMessage(true)}""", + elasticSearchJsonV300, emptyElasticSearch, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagSearchWarehouse), + Some(List(canSearchWarehouseStatistics)), + http4sPartialFunction = Some(dataWarehouseStatistics)) + + // ─── getUser (by email) ─────────────────────────────────────────────────── + + val getUser: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "users" / "email" / emailStr / "terminator" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + _ <- NewStyle.function.hasEntitlement("", user.userId, ApiRole.canGetAnyUser, Some(cc)) + users <- Users.users.vend.getUserByEmailFuture(emailStr) + } yield JSONFactory300.createUserJSONs(users) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getUser), "GET", + "/users/email/EMAIL/terminator", + "Get Users by Email Address", + s"""Get users by email address. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, usersJsonV200, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, UserNotFoundByEmail, UnknownError), + List(apiTagUser), + Some(List(canGetAnyUser)), + http4sPartialFunction = Some(getUser)) + + // ─── getUserByUserId ────────────────────────────────────────────────────── + + val getUserByUserId: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "users" / "user_id" / userIdStr => + EndpointHelpers.withUser(req) { (user, cc) => + for { + _ <- NewStyle.function.hasEntitlement("", user.userId, ApiRole.canGetAnyUser, Some(cc)) + targetUser <- Users.users.vend.getUserByUserIdFuture(userIdStr) map { + x => unboxFullOrFail(x, Some(cc), s"$UserNotFoundByUserId Current UserId($userIdStr)") + } + entitlements <- NewStyle.function.getEntitlementsByUserId(targetUser.userId, Some(cc)) + } yield JSONFactory300.createUserJSON(targetUser, entitlements) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getUserByUserId), "GET", + "/users/user_id/USER_ID", + "Get User by USER_ID", + s"""Get user by USER_ID. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, usersJsonV200, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, UserNotFoundById, UnknownError), + List(apiTagUser), + Some(List(canGetAnyUser)), + http4sPartialFunction = Some(getUserByUserId)) + + // ─── getUserByUsername ──────────────────────────────────────────────────── + + val getUserByUsername: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "users" / "username" / usernameStr => + EndpointHelpers.withUser(req) { (user, cc) => + for { + _ <- NewStyle.function.hasEntitlement("", user.userId, ApiRole.canGetAnyUser, Some(cc)) + targetUser <- Users.users.vend.getUserByProviderAndUsernameFuture(Constant.localIdentityProvider, usernameStr) map { + x => unboxFullOrFail(x, Some(cc), UserNotFoundByProviderAndUsername, 404) + } + entitlements <- NewStyle.function.getEntitlementsByUserId(targetUser.userId, Some(cc)) + } yield JSONFactory300.createUserJSON(targetUser, entitlements) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getUserByUsername), "GET", + "/users/username/USERNAME", + "Get User by USERNAME", + s"""Get user by USERNAME. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, usersJsonV200, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, UserNotFoundByProviderAndUsername, UnknownError), + List(apiTagUser), + Some(List(canGetAnyUser)), + http4sPartialFunction = Some(getUserByUsername)) + + // ─── getAdapterInfoForBank ──────────────────────────────────────────────── + + val getAdapterInfoForBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "adapter" => + EndpointHelpers.withUserAndBank(req) { (user, bank, cc) => + for { + _ <- NewStyle.function.hasEntitlement(bank.bankId.value, user.userId, ApiRole.canGetAdapterInfoAtOneBank, Some(cc)) + (ai, _) <- NewStyle.function.getAdapterInfo(Some(cc)) + } yield createAdapterInfoJson(ai) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAdapterInfoForBank), "GET", + "/banks/BANK_ID/adapter", + "Get Adapter Info for a bank", + s"""Get basic information about the Adapter listening on behalf of this bank. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, adapterInfoJsonV300, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagApi), + Some(List(canGetAdapterInfoAtOneBank)), + http4sPartialFunction = Some(getAdapterInfoForBank)) + + // ─── createBranch ───────────────────────────────────────────────────────── + + val createBranch: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "branches" => + EndpointHelpers.withUserAndBankAndBodyCreated[BranchJsonV300, BranchJsonV300](req) { (user, bank, body, cc) => + for { + _ <- NewStyle.function.hasAtLeastOneEntitlement(InsufficientAuthorisationToCreateBranch)( + bank.bankId.value, user.userId, canCreateBranch :: canCreateBranchAtAnyBank :: Nil, Some(cc)) + _ <- code.util.Helper.booleanToFuture("BANK_ID has to be the same in the URL and Body", 400, cc = Some(cc)) { + body.bank_id == bank.bankId.value + } + branch <- NewStyle.function.tryons(CouldNotTransformJsonToInternalModel + " Branch", 400, Some(cc)) { + transformToBranch(body) + } + (success, _) <- NewStyle.function.createOrUpdateBranch(branch, Some(cc)) + } yield JSONFactory300.createBranchJsonV300(success) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createBranch), "POST", + "/banks/BANK_ID/branches", + "Create Branch", + s"""Create Branch for the Bank. + | + |${userAuthenticationMessage(true)}""", + branchJsonV300, branchJsonV300, + List(AuthenticatedUserIsRequired, BankNotFound, InsufficientAuthorisationToCreateBranch, UnknownError), + List(apiTagBranch), + Some(List(canCreateBranch, canCreateBranchAtAnyBank)), + http4sPartialFunction = Some(createBranch)) + + // ─── updateBranch ───────────────────────────────────────────────────────── + + val updateBranch: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "branches" / branchIdStr => + EndpointHelpers.withUserAndBankAndBodyCreated[PostBranchJsonV300, BranchJsonV300](req) { (user, bank, body, cc) => + for { + _ <- NewStyle.function.hasEntitlement(bank.bankId.value, user.userId, canUpdateBranch, Some(cc)) + _ <- code.util.Helper.booleanToFuture("BANK_ID has to be the same in the URL and Body", 400, cc = Some(cc)) { + body.bank_id == bank.bankId.value + } + branchJson = BranchJsonV300( + id = branchIdStr, + body.bank_id, body.name, body.address, body.location, body.meta, + body.lobby, body.drive_up, body.branch_routing, + body.is_accessible, body.accessibleFeatures, body.branch_type, body.more_info, body.phone_number) + branch <- NewStyle.function.tryons(CouldNotTransformJsonToInternalModel + " Branch", 400, Some(cc)) { + transformToBranchFromV300(branchJson).head + } + (success, _) <- NewStyle.function.createOrUpdateBranch(branch, Some(cc)) + } yield JSONFactory300.createBranchJsonV300(success) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateBranch), "PUT", + "/banks/BANK_ID/branches/BRANCH_ID", + "Update Branch", + s"""Update an existing branch for a bank account. + | + |${userAuthenticationMessage(true)}""", + postBranchJsonV300, branchJsonV300, + List(AuthenticatedUserIsRequired, BankNotFound, InsufficientAuthorisationToCreateBranch, UnknownError), + List(apiTagBranch), + Some(List(canUpdateBranch)), + http4sPartialFunction = Some(updateBranch)) + + // ─── createAtm ──────────────────────────────────────────────────────────── + + val createAtm: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "atms" => + EndpointHelpers.withUserAndBankAndBodyCreated[AtmJsonV300, AtmJsonV300](req) { (user, bank, body, cc) => + for { + _ <- NewStyle.function.hasAtLeastOneEntitlement(createAtmEntitlementsRequiredText)( + bank.bankId.value, user.userId, createAtmEntitlements, Some(cc)) + _ <- code.util.Helper.booleanToFuture(s"$InvalidJsonValue BANK_ID has to be the same in the URL and Body", 400, cc = Some(cc)) { + body.bank_id == bank.bankId.value + } + atm <- NewStyle.function.tryons(CouldNotTransformJsonToInternalModel + " Atm", 400, Some(cc)) { + transformToAtmFromV300(body).head + } + (createdAtm, _) <- NewStyle.function.createOrUpdateAtm(atm, Some(cc)) + } yield JSONFactory300.createAtmJsonV300(createdAtm) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createAtm), "POST", + "/banks/BANK_ID/atms", + "Create ATM", + s"""Create ATM for the Bank. + | + |${userAuthenticationMessage(true)}""", + atmJsonV300, atmJsonV300, + List(AuthenticatedUserIsRequired, BankNotFound, UserHasMissingRoles, UnknownError), + List(apiTagATM), + Some(List(canCreateAtm, canCreateAtmAtAnyBank)), + http4sPartialFunction = Some(createAtm)) + + // ─── getBranch ──────────────────────────────────────────────────────────── + + private val getBranchesIsPublic = APIUtil.getPropsAsBoolValue("apiOptions.getBranchesIsPublic", true) + + val getBranch: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "branches" / branchIdStr => + EndpointHelpers.withBank(req) { (bank, cc) => + for { + _ <- if (!getBranchesIsPublic) + code.util.Helper.booleanToFuture(AuthenticatedUserIsRequired, failCode = 401, cc = Some(cc)) { cc.user.isDefined } + else Future.unit + (branch, _) <- NewStyle.function.getBranch(bank.bankId, BranchId(branchIdStr), Some(cc)) + } yield JSONFactory300.createBranchJsonV300(branch) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getBranch), "GET", + "/banks/BANK_ID/branches/BRANCH_ID", + "Get Branch", + s"""Returns information about a single Branch specified by BANK_ID and BRANCH_ID. + | + |${userAuthenticationMessage(!getBranchesIsPublic)}""", + EmptyBody, branchJsonV300, + List(AuthenticatedUserIsRequired, BranchNotFoundByBranchId, UnknownError), + List(apiTagBranch, apiTagBank), None, + http4sPartialFunction = Some(getBranch)) + + // ─── getBranches ────────────────────────────────────────────────────────── + + private[this] val branchCityPredicate = (city: Option[String], branchCity: String) => + city.isEmpty || city.contains(branchCity) + + private[this] val reg = Pattern.compile("^[-+]?(\\d+\\.?\\d*$|\\d*\\.?\\d+$)") + + private[this] def distancePredicate( + withinMetersOf: Option[String], nearLatitude: Option[String], nearLongitude: Option[String], + latitude: Double, longitude: Double): Boolean = { + (withinMetersOf, nearLatitude, nearLongitude) match { + case (None, None, None) => true + case (Some(wm), Some(nlat), Some(nlng)) => + val fromLat = Coordinate.fromDegrees(nlat.toDouble) + val fromLng = Coordinate.fromDegrees(nlng.toDouble) + val fromPoint = Point.at(fromLat, fromLng) + val branchLat = Coordinate.fromDegrees(latitude) + val branchLng = Coordinate.fromDegrees(longitude) + val branchPoint = Point.at(branchLat, branchLng) + val distance = EarthCalc.harvesineDistance(branchPoint, fromPoint) + wm.toDouble >= distance + case _ => true + } + } + + val getBranches: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "branches" => + EndpointHelpers.withBank(req) { (bank, cc) => + val qp = req.uri.query.params + val limit = qp.get("limit") + val offset = qp.get("offset") + val city = qp.get("city") + val withinMetersOf = qp.get("withinMetersOf") + val nearLatitude = qp.get("nearLatitude") + val nearLongitude = qp.get("nearLongitude") + for { + _ <- if (!getBranchesIsPublic) + code.util.Helper.booleanToFuture(AuthenticatedUserIsRequired, failCode = 401, cc = Some(cc)) { cc.user.isDefined } + else Future.unit + _ <- code.util.Helper.booleanToFuture(s"${InvalidNumber} limit:${limit.getOrElse("")}", cc = Some(cc)) { + limit.forall(_.forall(Character.isDigit)) + } + _ <- code.util.Helper.booleanToFuture(maximumLimitExceeded, cc = Some(cc)) { + !limit.exists(_.toInt > 10000) + } + _ <- code.util.Helper.booleanToFuture(s"${InvalidNumber} offset:${offset.getOrElse("")}", cc = Some(cc)) { + offset.forall(_.forall(Character.isDigit)) + } + _ <- code.util.Helper.booleanToFuture( + s"${MissingQueryParams} withinMetersOf, nearLatitude and nearLongitude must be either all empty or all float value", + cc = Some(cc)) { + (withinMetersOf, nearLatitude, nearLongitude) match { + case (Some(i), Some(j), Some(k)) => reg.matcher(i).matches() && reg.matcher(j).matches() && reg.matcher(k).matches() + case (None, None, None) => true + case _ => false + } + } + branches <- Connector.connector.vend.getBranches(bank.bankId, Some(cc)) map { + case Empty => unboxFullOrFail(Empty ?~! BranchesNotFound, Some(cc), BranchesNotFound) + case Full((Nil, _)) => Nil + case Full((list, _)) => list + case Failure(msg, _, _) => unboxFullOrFail(Empty ?~! msg, Some(cc), msg) + case ParamFailure(msg, _, _, _) => unboxFullOrFail(Empty ?~! msg, Some(cc), msg) + } map { branches => + branches + .sortWith(_.branchId.value < _.branchId.value) + .filter(_.isDeleted != Some(true)) + .filter(b => branchCityPredicate(city, b.address.city)) + .filter(b => distancePredicate(withinMetersOf, nearLatitude, nearLongitude, b.location.latitude, b.location.longitude)) + .slice(offset.getOrElse("0").toInt, offset.getOrElse("0").toInt + limit.getOrElse("100").toInt) + } + } yield JSONFactory300.createBranchesJson(branches) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getBranches), "GET", + "/banks/BANK_ID/branches", + "Get Branches for a Bank", + s"""Returns information about branches for a single bank specified by BANK_ID. + | + |${userAuthenticationMessage(!getBranchesIsPublic)}""", + EmptyBody, branchesJsonV300, + List(AuthenticatedUserIsRequired, BankNotFound, BranchesNotFoundLicense, UnknownError), + List(apiTagBranch, apiTagBank), None, + http4sPartialFunction = Some(getBranches)) + + // ─── getAtm ─────────────────────────────────────────────────────────────── + + private val getAtmsIsPublic = APIUtil.getPropsAsBoolValue("apiOptions.getAtmsIsPublic", true) + + val getAtm: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "atms" / atmIdStr => + EndpointHelpers.withBank(req) { (bank, cc) => + for { + _ <- if (!getAtmsIsPublic) + code.util.Helper.booleanToFuture(AuthenticatedUserIsRequired, failCode = 401, cc = Some(cc)) { cc.user.isDefined } + else Future.unit + (atm, _) <- NewStyle.function.getAtm(bank.bankId, AtmId(atmIdStr), Some(cc)) + } yield JSONFactory300.createAtmJsonV300(atm) + } + } + + 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, atmJsonV300, + List(AuthenticatedUserIsRequired, BankNotFound, AtmNotFoundByAtmId, UnknownError), + List(apiTagATM), None, + http4sPartialFunction = Some(getAtm)) + + // ─── getAtms ────────────────────────────────────────────────────────────── + + val getAtms: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "atms" => + EndpointHelpers.withBank(req) { (bank, cc) => + val qp = req.uri.query.params + val limit = qp.get("limit") + val offset = qp.get("offset") + for { + _ <- if (!getAtmsIsPublic) + code.util.Helper.booleanToFuture(AuthenticatedUserIsRequired, failCode = 401, cc = Some(cc)) { cc.user.isDefined } + else Future.unit + _ <- code.util.Helper.booleanToFuture(s"${InvalidNumber} limit:${limit.getOrElse("")}", cc = Some(cc)) { + limit.forall(_.forall(Character.isDigit)) + } + _ <- code.util.Helper.booleanToFuture(maximumLimitExceeded, cc = Some(cc)) { + !limit.exists(_.toInt > 10000) + } + _ <- code.util.Helper.booleanToFuture(s"${InvalidNumber} offset:${offset.getOrElse("")}", cc = Some(cc)) { + offset.forall(_.forall(Character.isDigit)) + } + atms <- Connector.connector.vend.getAtms(bank.bankId, Some(cc)) map { + case Empty => unboxFullOrFail(Empty ?~! atmsNotFound, Some(cc), atmsNotFound) + case Full((Nil, _)) => Nil + case Full((list, _)) => list + case Failure(msg, _, _) => unboxFullOrFail(Empty ?~! msg, Some(cc), msg) + case ParamFailure(msg, _, _, _) => unboxFullOrFail(Empty ?~! msg, Some(cc), msg) + } map { atms => + atms + .sortWith(_.atmId.value < _.atmId.value) + .slice(offset.getOrElse("0").toInt, offset.getOrElse("0").toInt + limit.getOrElse("100").toInt) + } + } yield JSONFactory300.createAtmsJsonV300(atms) + } + } + + 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, atmJsonV300, + List(AuthenticatedUserIsRequired, BankNotFound, UnknownError), + List(apiTagATM), None, + http4sPartialFunction = Some(getAtms)) + + // ─── getUsers ───────────────────────────────────────────────────────────── + + val getUsers: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "users" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + _ <- NewStyle.function.hasEntitlement("", user.userId, ApiRole.canGetAnyUser, Some(cc)) + httpParams = req.uri.query.multiParams.flatMap { case (k, vs) => vs.map(v => HTTPParam(k, v)) }.toList + (obpQueryParams, _) <- createQueriesByHttpParamsFuture(httpParams, Some(cc)) + users <- Users.users.vend.getAllUsersF(obpQueryParams) + } yield JSONFactory300.createUserJSONs(users) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getUsers), "GET", + "/users", + "Get all Users", + s"""Get all users. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, usersJsonV200, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagUser), + Some(List(canGetAnyUser)), + http4sPartialFunction = Some(getUsers)) + + // ─── getCustomersForUser ────────────────────────────────────────────────── + + val getCustomersForUser: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "users" / "current" / "customers" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + (customers, _) <- Connector.connector.vend.getCustomersByUserId(user.userId, Some(cc)) map { + connectorEmptyResponse(_, Some(cc)) + } + (customersAndAttributes, _) <- NewStyle.function.getCustomerAttributesForCustomers(customers, Some(cc)) + } yield JSONFactory300.createCustomersWithAttributesJson(customersAndAttributes) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCustomersForUser), "GET", + "/users/current/customers", + "Get Customers for Current User", + s"""Gets all Customers that are linked to a User. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, customersWithAttributesJsonV300, + List(AuthenticatedUserIsRequired, UserCustomerLinksNotFoundForUser, UnknownError), + List(apiTagCustomer, apiTagUser), None, + http4sPartialFunction = Some(getCustomersForUser)) + + // ─── getCurrentUser ─────────────────────────────────────────────────────── + + val getCurrentUser: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "users" / "current" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + entitlements <- NewStyle.function.getEntitlementsByUserId(user.userId, Some(cc)) + } yield { + val permissions = Views.views.vend.getPermissionForUser(user).toOption + JSONFactory300.createUserInfoJSON(user, entitlements, permissions) + } + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCurrentUser), "GET", + "/users/current", + "Get User (Current)", + s"""Get the logged in user. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, userJsonV300, + List(AuthenticatedUserIsRequired, UnknownError), + List(apiTagUser), None, + http4sPartialFunction = Some(getCurrentUser)) + + // ─── privateAccountsAtOneBank ───────────────────────────────────────────── + + val privateAccountsAtOneBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / "private" => + EndpointHelpers.withUserAndBank(req) { (user, bank, cc) => + for { + availablePrivateAccounts <- Views.views.vend.getPrivateBankAccountsFuture(user, bank.bankId) + (accounts, _) <- NewStyle.function.getCoreBankAccountsFuture(availablePrivateAccounts, Some(cc)) + filtered = filterCoreAccountsByType(accounts, req) + } yield JSONFactory300.createCoreAccountsByCoreAccountsJSON(filtered, user) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(privateAccountsAtOneBank), "GET", + "/banks/BANK_ID/accounts/private", + "Get Accounts at Bank (Minimal)", + s"""Returns the minimal list of private accounts at BANK_ID that the user has access to. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, coreAccountsJsonV300, + List(AuthenticatedUserIsRequired, BankNotFound, UnknownError), + List(apiTagAccount, apiTagPSD2AIS, apiTagPsd2), None, + http4sPartialFunction = Some(privateAccountsAtOneBank)) + + // ─── getPrivateAccountIdsbyBankId ───────────────────────────────────────── + + val getPrivateAccountIdsbyBankId: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / "account_ids" / "private" => + EndpointHelpers.withUserAndBank(req) { (user, bank, cc) => + for { + availablePrivateAccounts <- Views.views.vend.getPrivateBankAccountsFuture(user, bank.bankId) + (coreAccounts, _) <- NewStyle.function.getCoreBankAccountsFuture(availablePrivateAccounts, Some(cc)) + filtered = filterCoreAccountsByType(coreAccounts, req) + bankIdAccountIds = filtered.map(a => BankIdAccountId(bank.bankId, AccountId(a.id))) + } yield JSONFactory300.createAccountsIdsByBankIdAccountIds(bankIdAccountIds) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getPrivateAccountIdsbyBankId), "GET", + "/banks/BANK_ID/accounts/account_ids/private", + "Get Accounts at Bank (IDs only)", + s"""Returns only the list of accounts ids at BANK_ID that the user has access to. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, accountsIdsJsonV300, + List(AuthenticatedUserIsRequired, BankNotFound, UnknownError), + List(apiTagAccount, apiTagPSD2AIS, apiTagPsd2), None, + http4sPartialFunction = Some(getPrivateAccountIdsbyBankId)) + + // ─── getOtherAccountsForBankAccount ─────────────────────────────────────── + + val getOtherAccountsForBankAccount: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "other_accounts" => + EndpointHelpers.withView(req) { (user, account, view, cc) => + for { + (otherBankAccounts, _) <- NewStyle.function.moderatedOtherBankAccounts(account, view, Some(user), Some(cc)) + } yield createOtherBankAccountsJson(otherBankAccounts) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getOtherAccountsForBankAccount), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/other_accounts", + "Get Other Accounts of one Account", + s"""Returns data about all the other accounts that have shared at least one transaction with the ACCOUNT_ID at BANK_ID. + | + |${userAuthenticationMessage(false)}""", + EmptyBody, otherAccountsJsonV300, + List(AuthenticatedUserIsRequired, BankAccountNotFound, ViewNotFound, InvalidConnectorResponse, UnknownError), + List(apiTagCounterparty, apiTagAccount), None, + http4sPartialFunction = Some(getOtherAccountsForBankAccount)) + + // ─── getOtherAccountByIdForBankAccount ──────────────────────────────────── + + val getOtherAccountByIdForBankAccount: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "other_accounts" / otherAccountIdStr => + EndpointHelpers.withView(req) { (user, account, view, cc) => + for { + (otherBankAccount, _) <- NewStyle.function.moderatedOtherBankAccount(account, otherAccountIdStr, view, Some(user), Some(cc)) + } yield createOtherBankAccount(otherBankAccount) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getOtherAccountByIdForBankAccount), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/other_accounts/OTHER_ACCOUNT_ID", + "Get Other Account by Id", + s"""Returns data about the Other Account that has shared at least one transaction with ACCOUNT_ID at BANK_ID. + | + |${userAuthenticationMessage(false)}""", + EmptyBody, otherAccountJsonV300, + List(AuthenticatedUserIsRequired, BankAccountNotFound, ViewNotFound, InvalidConnectorResponse, UnknownError), + List(apiTagCounterparty, apiTagAccount), None, + http4sPartialFunction = Some(getOtherAccountByIdForBankAccount)) + + // ─── addEntitlementRequest ──────────────────────────────────────────────── + + val addEntitlementRequest: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "entitlement-requests" => + EndpointHelpers.withUserAndBodyCreated[CreateEntitlementRequestJSON, EntitlementRequestJSON](req) { (user, body, cc) => + for { + _ <- if (body.bank_id.isEmpty) Future.successful(()) + else NewStyle.function.getBank(BankId(body.bank_id), Some(cc)).map(_ => ()) + _ <- code.util.Helper.booleanToFuture( + IncorrectRoleName + body.role_name + ". Possible roles are " + ApiRole.availableRoles.sorted.mkString(", "), + cc = Some(cc)) { availableRoles.exists(_ == body.role_name) } + _ <- code.util.Helper.booleanToFuture( + if (ApiRole.valueOf(body.role_name).requiresBankId) EntitlementIsBankRole else EntitlementIsSystemRole, + cc = Some(cc)) { ApiRole.valueOf(body.role_name).requiresBankId == body.bank_id.nonEmpty } + _ <- code.util.Helper.booleanToFuture(EntitlementRequestAlreadyExists, cc = Some(cc)) { + EntitlementRequest.entitlementRequest.vend.getEntitlementRequest(body.bank_id, user.userId, body.role_name).isEmpty + } + addedEntitlementRequest <- EntitlementRequest.entitlementRequest.vend.addEntitlementRequestFuture(body.bank_id, user.userId, body.role_name) map { + x => unboxFullOrFail(x, Some(cc), EntitlementRequestCannotBeAdded) + } + } yield JSONFactory300.createEntitlementRequestJSON(addedEntitlementRequest) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(addEntitlementRequest), "POST", + "/entitlement-requests", + "Create Entitlement Request for current User", + s"""Create Entitlement Request. + | + |${userAuthenticationMessage(true)}""", + code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON.createEntitlementJSON, entitlementRequestJSON, + List(AuthenticatedUserIsRequired, UserNotFoundById, InvalidJsonFormat, IncorrectRoleName, + EntitlementIsBankRole, EntitlementIsSystemRole, EntitlementRequestAlreadyExists, EntitlementRequestCannotBeAdded, UnknownError), + List(apiTagRole, apiTagEntitlement, apiTagUser), None, + http4sPartialFunction = Some(addEntitlementRequest)) + + // ─── getAllEntitlementRequests ───────────────────────────────────────────── + + val getAllEntitlementRequests: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "entitlement-requests" => + EndpointHelpers.withUser(req) { (user, cc) => + val allowedEntitlements = canGetEntitlementRequestsAtAnyBank :: Nil + val allowedEntitlementsTxt = allowedEntitlements.mkString(" or ") + for { + _ <- NewStyle.function.hasAtLeastOneEntitlement(s"$UserHasMissingRoles $allowedEntitlementsTxt")("", user.userId, allowedEntitlements, Some(cc)) + httpParams = req.uri.query.multiParams.flatMap { case (k, vs) => vs.map(v => HTTPParam(k, v)) }.toList + (requestParams, _) <- NewStyle.function.extractQueryParams(req.uri.renderString, List("limit", "offset", "sort_direction", "from_date", "to_date"), Some(cc)) + entitlementRequests <- NewStyle.function.getEntitlementRequestsFuture(requestParams, Some(cc)) + } yield JSONFactory300.createEntitlementRequestsJSON(entitlementRequests) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAllEntitlementRequests), "GET", + "/entitlement-requests", + "Get all Entitlement Requests", + s"""Get all Entitlement Requests. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, entitlementRequestsJSON, + List(AuthenticatedUserIsRequired, InvalidConnectorResponse, UnknownError), + List(apiTagRole, apiTagEntitlement, apiTagUser), + Some(List(canGetEntitlementRequestsAtOneBank, canGetEntitlementRequestsAtAnyBank)), + http4sPartialFunction = Some(getAllEntitlementRequests)) + + // ─── getEntitlementRequests ─────────────────────────────────────────────── + + val getEntitlementRequests: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "users" / userIdStr / "entitlement-requests" => + EndpointHelpers.withUser(req) { (user, cc) => + val allowedEntitlements = canGetEntitlementRequestsAtAnyBank :: Nil + val allowedEntitlementsTxt = allowedEntitlements.mkString(" or ") + for { + _ <- NewStyle.function.hasAtLeastOneEntitlement(s"$UserHasMissingRoles $allowedEntitlementsTxt")("", user.userId, allowedEntitlements, Some(cc)) + (requestParams, _) <- NewStyle.function.extractQueryParams(req.uri.renderString, List("limit", "offset", "sort_direction", "from_date", "to_date"), Some(cc)) + entitlementRequests <- NewStyle.function.getEntitlementRequestsFuture(userIdStr, requestParams, Some(cc)) + } yield JSONFactory300.createEntitlementRequestsJSON(entitlementRequests) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getEntitlementRequests), "GET", + "/users/USER_ID/entitlement-requests", + "Get Entitlement Requests for a User", + s"""Get Entitlement Requests for a User. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, entitlementRequestsJSON, + List(AuthenticatedUserIsRequired, InvalidConnectorResponse, UnknownError), + List(apiTagRole, apiTagEntitlement, apiTagUser), + Some(List(canGetEntitlementRequestsAtOneBank, canGetEntitlementRequestsAtAnyBank)), + http4sPartialFunction = Some(getEntitlementRequests)) + + // ─── getEntitlementRequestsForCurrentUser ───────────────────────────────── + + val getEntitlementRequestsForCurrentUser: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "my" / "entitlement-requests" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + (requestParams, _) <- NewStyle.function.extractQueryParams(req.uri.renderString, List("limit", "offset", "sort_direction", "from_date", "to_date"), Some(cc)) + entitlementRequests <- NewStyle.function.getEntitlementRequestsFuture(user.userId, requestParams, Some(cc)) + } yield JSONFactory300.createEntitlementRequestsJSON(entitlementRequests) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getEntitlementRequestsForCurrentUser), "GET", + "/my/entitlement-requests", + "Get Entitlement Requests for the current User", + s"""Get Entitlement Requests for the current User. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, entitlementRequestsJSON, + List(AuthenticatedUserIsRequired, InvalidConnectorResponse, UnknownError), + List(apiTagRole, apiTagEntitlement, apiTagUser), None, + http4sPartialFunction = Some(getEntitlementRequestsForCurrentUser)) + + // ─── deleteEntitlementRequest ───────────────────────────────────────────── + + val deleteEntitlementRequest: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "entitlement-requests" / entitlementRequestIdStr => + EndpointHelpers.withUser(req) { (user, cc) => + val allowedEntitlements = canDeleteEntitlementRequestsAtOneBank :: canDeleteEntitlementRequestsAtAnyBank :: Nil + val allowedEntitlementsTxt = s"$UserHasMissingRoles ${allowedEntitlements.mkString(" or ")}" + for { + entitlementRequest <- EntitlementRequest.entitlementRequest.vend.getEntitlementRequestFuture(entitlementRequestIdStr) map { + connectorEmptyResponse(_, Some(cc)) + } + _ <- NewStyle.function.hasAtLeastOneEntitlement(allowedEntitlementsTxt)(entitlementRequest.bankId, user.userId, allowedEntitlements, Some(cc)) + result <- EntitlementRequest.entitlementRequest.vend.deleteEntitlementRequestFuture(entitlementRequestIdStr) map { + connectorEmptyResponse(_, Some(cc)) + } + } yield net.liftweb.json.JBool(result) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteEntitlementRequest), "DELETE", + "/entitlement-requests/ENTITLEMENT_REQUEST_ID", + "Delete Entitlement Request", + s"""Delete the Entitlement Request specified by ENTITLEMENT_REQUEST_ID. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, EmptyBody, + List(AuthenticatedUserIsRequired, InvalidConnectorResponse, UnknownError), + List(apiTagRole, apiTagEntitlement, apiTagUser), + Some(List(canDeleteEntitlementRequestsAtOneBank, canDeleteEntitlementRequestsAtAnyBank)), + http4sPartialFunction = Some(deleteEntitlementRequest)) + + // ─── getEntitlementsForCurrentUser ──────────────────────────────────────── + + val getEntitlementsForCurrentUser: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "my" / "entitlements" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + entitlements <- NewStyle.function.getEntitlementsByUserId(user.userId, Some(cc)) + } yield { + if (isSuperAdmin(user.userId)) + JSONFactory200.withVirtualEntitlements(entitlements, JSONFactory200.superAdminVirtualRoles) + else if (isOidcOperator(user.userId)) + JSONFactory200.withVirtualEntitlements(entitlements, JSONFactory200.oidcOperatorVirtualRoles) + else + JSONFactory200.createEntitlementJSONs(entitlements) + } + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getEntitlementsForCurrentUser), "GET", + "/my/entitlements", + "Get Entitlements for the current User", + s"""Get Entitlements for the current User. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, entitlementJSONs, + List(AuthenticatedUserIsRequired, InvalidConnectorResponse, UnknownError), + List(apiTagRole, apiTagEntitlement, apiTagUser), None, + http4sPartialFunction = Some(getEntitlementsForCurrentUser)) + + // ─── getApiGlossary ─────────────────────────────────────────────────────── + + private val glossaryDocsRequireRole = APIUtil.getPropsAsBoolValue("apiOptions.glossaryDocsRequireRole", false) + private lazy val cachedGlossaryJson = JSONFactory300.createGlossaryItemsJsonV300(getGlossaryItems) + + val getApiGlossary: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "api" / "glossary" => + EndpointHelpers.executeAndRespond(req) { cc => + for { + _ <- if (glossaryDocsRequireRole) { + code.util.Helper.booleanToFuture(AuthenticatedUserIsRequired, failCode = 401, cc = Some(cc)) { cc.user.isDefined }.flatMap { _ => + NewStyle.function.hasEntitlement("", cc.user.openOrThrowException("user required").userId, ApiRole.canReadGlossary, Some(cc)) + } + } else Future.unit + } yield cachedGlossaryJson + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getApiGlossary), "GET", + "/api/glossary", + "Get Glossary of the API", + """Get API Glossary. Returns the glossary of the API.""", + EmptyBody, glossaryItemsJsonV300, + List(UnknownError), + apiTagDocumentation :: Nil, None, + http4sPartialFunction = Some(getApiGlossary)) + + // ─── getAccountsHeld ────────────────────────────────────────────────────── + + val getAccountsHeld: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts-held" => + EndpointHelpers.withUserAndBank(req) { (user, bank, cc) => + for { + (availableAccounts, _) <- NewStyle.function.getAccountsHeld(bank.bankId, user, Some(cc)) + (accounts, _) <- NewStyle.function.getBankAccountsHeldFuture(availableAccounts.toList, Some(cc)) + (coreAccounts, _) <- NewStyle.function.getCoreBankAccountsFuture(availableAccounts.toList, Some(cc)) + filtered = filterCoreAccountsByType(coreAccounts, req) + accountHelds = accounts.filter(a => filtered.map(_.id).contains(a.id)) + } yield JSONFactory300.createCoreAccountsByCoreAccountsJSON(accountHelds) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAccountsHeld), "GET", + "/banks/BANK_ID/accounts-held", + "Get Accounts Held", + s"""Get Accounts held by the current User if even the User has not been assigned the owner View yet. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, coreAccountsHeldJsonV300, + List(AuthenticatedUserIsRequired, UnknownError), + List(apiTagAccount, apiTagPSD2AIS, apiTagView, apiTagPsd2), None, + http4sPartialFunction = Some(getAccountsHeld)) + + // ─── getAggregateMetrics ────────────────────────────────────────────────── + + val getAggregateMetrics: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "aggregate-metrics" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + _ <- NewStyle.function.hasEntitlement("", user.userId, ApiRole.canReadAggregateMetrics, Some(cc)) + httpParams <- NewStyle.function.extractHttpParamsFromUrl(req.uri.renderString) + (obpQueryParams, _) <- createQueriesByHttpParamsFuture(httpParams, Some(cc)) + aggregateMetrics <- APIMetrics.apiMetrics.vend.getAllAggregateMetricsFuture(obpQueryParams, false) 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 aggregate metrics on api usage. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, aggregateMetricsJSONV300, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagMetric, apiTagAggregateMetrics), + Some(List(canReadAggregateMetrics)), + http4sPartialFunction = Some(getAggregateMetrics)) + + // ─── addScope ───────────────────────────────────────────────────────────── + + val addScope: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "consumers" / consumerIdStr / "scopes" => + EndpointHelpers.withUserAndBodyCreated[CreateScopeJson, ScopeJson](req) { (user, body, cc) => + for { + consumerIdInt <- Future { tryo(consumerIdStr.toInt) } map { + x => unboxFullOrFail(x, Some(cc), s"$ConsumerNotFoundById Current Value is $consumerIdStr") + } + _ <- Future { Consumers.consumers.vend.getConsumerByPrimaryId(consumerIdInt) } map { + x => unboxFullOrFail(x, Some(cc), ConsumerNotFoundById) + } + role <- Future { tryo(valueOf(body.role_name)) } map { + x => unboxFullOrFail(x, Some(cc), IncorrectRoleName + body.role_name + ". Possible roles are " + ApiRole.availableRoles.sorted.mkString(", ")) + } + _ <- code.util.Helper.booleanToFuture( + if (ApiRole.valueOf(body.role_name).requiresBankId) EntitlementIsBankRole else EntitlementIsSystemRole, + cc = Some(cc)) { ApiRole.valueOf(body.role_name).requiresBankId == body.bank_id.nonEmpty } + allowedEntitlements = canCreateScopeAtOneBank :: canCreateScopeAtAnyBank :: Nil + allowedEntitlementsTxt = s"$UserHasMissingRoles ${allowedEntitlements.mkString(", ")}!" + _ <- NewStyle.function.hasAtLeastOneEntitlement(allowedEntitlementsTxt)(body.bank_id, user.userId, allowedEntitlements, Some(cc)) + _ <- code.util.Helper.booleanToFuture(BankNotFound, cc = Some(cc)) { + body.bank_id.nonEmpty == false || BankX(BankId(body.bank_id), Some(cc)).map(_._1).isDefined + } + _ <- code.util.Helper.booleanToFuture(EntitlementAlreadyExists, cc = Some(cc)) { + !hasScope(body.bank_id, consumerIdStr, role) + } + addedScope <- Future { Scope.scope.vend.addScope(body.bank_id, consumerIdStr, body.role_name) } map { unboxFull(_) } + } yield JSONFactory300.createScopeJson(addedScope) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(addScope), "POST", + "/consumers/CONSUMER_ID/scopes", + "Create Scope for a Consumer", + """Create Scope. Grant Role to Consumer.""", + code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON.createScopeJson, scopeJson, + List(AuthenticatedUserIsRequired, ConsumerNotFoundById, InvalidJsonFormat, IncorrectRoleName, + EntitlementIsBankRole, EntitlementIsSystemRole, EntitlementAlreadyExists, UnknownError), + List(apiTagScope, apiTagConsumer), + Some(List(canCreateScopeAtOneBank, canCreateScopeAtAnyBank)), + http4sPartialFunction = Some(addScope)) + + // ─── deleteScope ────────────────────────────────────────────────────────── + + val deleteScope: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "consumers" / consumerIdStr / "scope" / scopeIdStr => + EndpointHelpers.withUser(req) { (user, cc) => + for { + consumer <- Future { cc.consumer } map { + x => unboxFullOrFail(x, Some(cc), InvalidConsumerCredentials) + } + scope <- Future { Scope.scope.vend.getScopeById(scopeIdStr) ?~! ScopeNotFound } map { + x => unboxFullOrFail(x, Some(cc), s"$ScopeNotFound Current Value is $scopeIdStr") + } + _ <- Future { + NewStyle.function.hasEntitlementAndScope(scope.bankId, user.userId, consumer.id.get.toString, canDeleteScopeAtOneBank, Some(cc)) + } map (fullBoxOrException(_)) recoverWith { + case _ => Future { + NewStyle.function.hasEntitlementAndScope("", user.userId, consumer.id.get.toString, canDeleteScopeAtAnyBank, Some(cc)) + } map (fullBoxOrException(_)) + } + _ <- code.util.Helper.booleanToFuture(ConsumerDoesNotHaveScope, cc = Some(cc)) { scope.scopeId == scopeIdStr } + _ <- Future { Scope.scope.vend.deleteScope(Full(scope)) } + } yield net.liftweb.json.JObject(Nil) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteScope), "DELETE", + "/consumers/CONSUMER_ID/scope/SCOPE_ID", + "Delete Consumer Scope", + """Delete Consumer Scope specified by SCOPE_ID for a consumer specified by CONSUMER_ID.""", + EmptyBody, EmptyBody, + List(AuthenticatedUserIsRequired, EntitlementNotFound, UnknownError), + List(apiTagScope, apiTagConsumer), + Some(List(canDeleteScopeAtOneBank, canDeleteScopeAtAnyBank)), + http4sPartialFunction = Some(deleteScope)) + + // ─── getScopes ──────────────────────────────────────────────────────────── + + val getScopes: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "consumers" / consumerIdStr / "scopes" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + consumer <- Future { cc.consumer } map { + x => unboxFullOrFail(x, Some(cc), InvalidConsumerCredentials) + } + _ <- Future { + NewStyle.function.hasEntitlementAndScope("", user.userId, consumer.id.get.toString, canGetEntitlementsForAnyUserAtAnyBank, Some(cc)) + } flatMap { unboxFullAndWrapIntoFuture(_) } + scopes <- Future { Scope.scope.vend.getScopesByConsumerId(consumerIdStr) } map { unboxFull(_) } + } yield JSONFactory300.createScopeJSONs(scopes) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getScopes), "GET", + "/consumers/CONSUMER_ID/scopes", + "Get Scopes for Consumer", + s"""Get all the scopes for a consumer specified by CONSUMER_ID. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, scopeJsons, + List(AuthenticatedUserIsRequired, EntitlementNotFound, UnknownError), + List(apiTagScope, apiTagConsumer), None, + http4sPartialFunction = Some(getScopes)) + + // ─── getBanks ───────────────────────────────────────────────────────────── + + val getBanks: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" => + EndpointHelpers.executeAndRespond(req) { cc => + for { + _ <- code.util.Helper.booleanToFuture(ServiceIsTooBusy + "Current Service(NewStyle.function.getBanks)", 503, cc = Some(cc)) { + canOpenFuture("NewStyle.function.getBanks") + } + (banks, _) <- FutureUtil.futureWithLimits(NewStyle.function.getBanks(Some(cc)), "NewStyle.function.getBanks") + } yield JSONFactory300.createBanksJson(banks) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getBanks), "GET", + "/banks", + "Get Banks", + """Get banks on this API instance. Returns a list of banks supported on this server.""", + EmptyBody, banksJSON, + List(UnknownError), + apiTagBank :: apiTagPSD2AIS :: apiTagPsd2 :: Nil, None, + http4sPartialFunction = Some(getBanks)) + + // ─── bankById ───────────────────────────────────────────────────────────── + + val bankById: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ => + EndpointHelpers.withBank(req) { (bank, cc) => + Future.successful(code.api.v4_0_0.JSONFactory400.createBankJSON400(bank)) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(bankById), "GET", + "/banks/BANK_ID", + "Get Bank", + """Get the bank specified by BANK_ID.""", + EmptyBody, bankJson400, + List(AuthenticatedUserIsRequired, UnknownError, BankNotFound), + apiTagBank :: apiTagPSD2AIS :: apiTagPsd2 :: Nil, None, + http4sPartialFunction = Some(bankById)) + + // ─── helpers ────────────────────────────────────────────────────────────── + + private def filterCoreAccountsByType(accounts: List[CoreAccount], req: Request[IO]): List[CoreAccount] = { + val qp = req.uri.query.multiParams + val filters = qp.get("account_type_filter").toList.flatMap(_.flatMap(_.split(","))).filter(_.nonEmpty) + val filtersOperation = qp.get("account_type_filter_operation").flatMap(_.headOption).getOrElse("INCLUDE") + accounts.filter { account => + (filters, filtersOperation) match { + case (f, "INCLUDE") if f.nonEmpty => f.contains(account.accountType) + case (f, "EXCLUDE") if f.nonEmpty => !f.contains(account.accountType) + case _ => true + } + } + } + + // ─── allRoutes ──────────────────────────────────────────────────────────── + + private val allOwnRoutes: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req => + root.run(req) + .orElse(getViewsForBankAccount.run(req)) + .orElse(createViewForBankAccount.run(req)) + .orElse(updateViewForBankAccount.run(req)) + .orElse(getPermissionForUserForBankAccount.run(req)) + .orElse(getPrivateAccountById.run(req)) + .orElse(getPublicAccountById.run(req)) + .orElse(getCoreAccountById.run(req)) + .orElse(corePrivateAccountsAllBanks.run(req)) + .orElse(getFirehoseAccountsAtOneBank.run(req)) + .orElse(getFirehoseTransactionsForBankAccount.run(req)) + .orElse(getCoreTransactionsForBankAccount.run(req)) + .orElse(getTransactionsForBankAccount.run(req)) + .orElse(dataWarehouseSearch.run(req)) + .orElse(dataWarehouseStatistics.run(req)) + .orElse(getUser.run(req)) + .orElse(getUserByUserId.run(req)) + .orElse(getUserByUsername.run(req)) + .orElse(getAdapterInfoForBank.run(req)) + .orElse(createBranch.run(req)) + .orElse(updateBranch.run(req)) + .orElse(createAtm.run(req)) + .orElse(getBranch.run(req)) + .orElse(getBranches.run(req)) + .orElse(getAtm.run(req)) + .orElse(getAtms.run(req)) + .orElse(getUsers.run(req)) + .orElse(getCustomersForUser.run(req)) + .orElse(getCurrentUser.run(req)) + .orElse(privateAccountsAtOneBank.run(req)) + .orElse(getPrivateAccountIdsbyBankId.run(req)) + .orElse(getOtherAccountsForBankAccount.run(req)) + .orElse(getOtherAccountByIdForBankAccount.run(req)) + .orElse(addEntitlementRequest.run(req)) + .orElse(getAllEntitlementRequests.run(req)) + .orElse(getEntitlementRequests.run(req)) + .orElse(getEntitlementRequestsForCurrentUser.run(req)) + .orElse(deleteEntitlementRequest.run(req)) + .orElse(getEntitlementsForCurrentUser.run(req)) + .orElse(getApiGlossary.run(req)) + .orElse(getAccountsHeld.run(req)) + .orElse(getAggregateMetrics.run(req)) + .orElse(addScope.run(req)) + .orElse(deleteScope.run(req)) + .orElse(getScopes.run(req)) + .orElse(getBanks.run(req)) + .orElse(bankById.run(req)) + } + + val allRoutesWithMiddleware: HttpRoutes[IO] = ResourceDocMiddleware.apply(resourceDocs)(allOwnRoutes) + + // ─── path-rewriting bridge: /obp/v3.0.0/… → /obp/v2.2.0/… ────────────── + + val v300ToV220Bridge: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req => + val rawPath = req.uri.path.renderString + if (rawPath.startsWith("/obp/v3.0.0/")) { + val rewritten = rawPath.replaceFirst("/obp/v3\\.0\\.0/", "/obp/v2.2.0/") + val newUri = req.uri.withPath(Uri.Path.unsafeFromString(rewritten)) + val rewrittenReq = req.withUri(newUri) + code.api.v2_2_0.Http4s220.wrappedRoutesV220Services.run(rewrittenReq) + } else { + OptionT.none[IO, Response[IO]] + } + } + } + + val wrappedRoutesV300Services: HttpRoutes[IO] = + Kleisli[HttpF, Request[IO], Response[IO]] { req => + Implementations3_0_0.allRoutesWithMiddleware.run(req) + .orElse(Implementations3_0_0.v300ToV220Bridge.run(req)) + } +} diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala index efcf8be17e..add1f90131 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala @@ -8,13 +8,12 @@ import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON._ import code.api.ResourceDocs1_4_0.{ResourceDocs140, ResourceDocsAPIMethodsUtil} import code.api.util.APIUtil.{EmptyBody, _} import code.api.util.{APIUtil, ApiRole, ApiVersionUtils, CallContext, CustomJsonFormats, Glossary, NewStyle} -import code.api.util.ApiRole.{canCreateEntitlementAtAnyBank, canCreateEntitlementAtOneBank, canCreateOrganisation, canDeleteEntitlementAtAnyBank, canDeleteOrganisation, canGetAccountAccessTrace, canGetAnyOrganisation, canGetAnyUser, canGetCacheConfig, canGetCacheInfo, canGetCacheNamespaces, canGetCardsForBank, canGetConnectorHealth, canGetCustomersAtOneBank, canGetDatabasePoolInfo, canGetMigrations, canUpdateOrganisation} +import code.api.util.ApiRole.{canCreateEntitlementAtAnyBank, canCreateEntitlementAtOneBank, canCreateOrganisation, canDeleteEntitlementAtAnyBank, canDeleteOrganisation, canGetAccountAccessTrace, canGetAnyOrganisation, canGetAnyUser, canGetCacheConfig, canGetCacheInfo, canGetCacheNamespaces, canGetConnectorHealth, canGetCustomersAtOneBank, canGetDatabasePoolInfo, canGetMigrations, canUpdateOrganisation} import code.api.util.ApiTag._ import code.api.util.ErrorMessages._ import code.api.util.http4s.{ErrorResponseConverter, Http4sRequestAttributes, IdempotencyMiddleware, RequestScopeConnection, ResourceDocMiddleware} import code.api.util.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps} import code.api.util.newstyle.ViewNewStyle -import code.api.v1_3_0.JSONFactory1_3_0 import code.api.v1_4_0.JSONFactory1_4_0 import code.api.v2_0_0.{BasicViewJson, CreateEntitlementJSON, JSONFactory200} import code.api.v4_0_0.JSONFactory400 @@ -226,61 +225,6 @@ object Http4s700 { http4sPartialFunction = Some(getBanks) ) - // Route: GET /obp/v7.0.0/cards - // Authentication handled by ResourceDocMiddleware based on AuthenticatedUserIsRequired - val getCards: HttpRoutes[IO] = HttpRoutes.of[IO] { - case req @ GET -> `prefixPath` / "cards" => - EndpointHelpers.withUser(req) { (user, cc) => - for { - (cards, callContext) <- NewStyle.function.getPhysicalCardsForUser(user, Some(cc)) - } yield JSONFactory1_3_0.createPhysicalCardsJSON(cards, user) - } - } - - resourceDocs += ResourceDoc( - null, - implementedInApiVersion, - nameOf(getCards), - "GET", - "/cards", - "Get cards for the current user", - "Returns data about all the physical cards a user has been issued. These could be debit cards, credit cards, etc.", - EmptyBody, - physicalCardsJSON, - List(AuthenticatedUserIsRequired, UnknownError), - apiTagCard :: Nil, - http4sPartialFunction = Some(getCards) - ) - - // Route: GET /obp/v7.0.0/banks/BANK_ID/cards - // Authentication and bank validation handled by ResourceDocMiddleware - val getCardsForBank: HttpRoutes[IO] = HttpRoutes.of[IO] { - case req @ GET -> `prefixPath` / "banks" / bankId / "cards" => - EndpointHelpers.withUserAndBank(req) { (user, bank, cc) => - for { - httpParams <- NewStyle.function.extractHttpParamsFromUrl(req.uri.renderString) - (obpQueryParams, callContext) <- createQueriesByHttpParamsFuture(httpParams, Some(cc)) - (cards, callContext) <- NewStyle.function.getPhysicalCardsForBank(bank, user, obpQueryParams, callContext) - } yield JSONFactory1_3_0.createPhysicalCardsJSON(cards, user) - } - } - - resourceDocs += ResourceDoc( - null, - implementedInApiVersion, - nameOf(getCardsForBank), - "GET", - "/banks/BANK_ID/cards", - "Get cards for the specified bank", - "", - EmptyBody, - physicalCardsJSON, - List(AuthenticatedUserIsRequired, BankNotFound, UnknownError), - apiTagCard :: Nil, - Some(List(canGetCardsForBank)), - http4sPartialFunction = Some(getCardsForBank) - ) - // Route: GET /obp/v7.0.0/resource-docs/API_VERSION/obp val getResourceDocsObpV700: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` / "resource-docs" / requestedApiVersionString / "obp" => @@ -308,20 +252,10 @@ object Http4s700 { ) { ApiVersionUtils.valueOf(requestedApiVersionString) } - // Use aggregated docs for v7.0.0, version-specific docs for other versions - allDocs = if (requestedApiVersion == ApiVersion.v7_0_0) { - // For v7.0.0, update requestUrl and specifiedUrl for all aggregated docs - // This mirrors the logic in ResourceDocsAPIMethods.getResourceDocsList - allResourceDocs.toList.map { doc => - // Save original requestUrl before modification (it's in short form like "/banks") - val originalRequestUrl = doc.requestUrl - doc.copy( - requestUrl = s"/${doc.implementedInApiVersion.urlPrefix}/${doc.implementedInApiVersion.vDottedApiVersion}${originalRequestUrl}", - specifiedUrl = Some(s"/${doc.implementedInApiVersion.urlPrefix}/${requestedApiVersion.vDottedApiVersion}${originalRequestUrl}") - ) - } - } else { - ResourceDocs140.ImplementationsResourceDocs.getResourceDocsList(requestedApiVersion).getOrElse(Nil) + allDocs = { + val raw = ResourceDocs140.ImplementationsResourceDocs.getResourceDocsList(requestedApiVersion).getOrElse(Nil) + val seen = scala.collection.mutable.HashSet[(String, String)]() + raw.filter(doc => seen.add((doc.requestVerb, doc.requestUrl))) } filteredDocs = ResourceDocsAPIMethodsUtil.filterResourceDocs(allDocs, tags, functions) } yield JSONFactory1_4_0.createResourceDocsJson(filteredDocs, isVersion4OrHigher = true, localeParam, includeTechnology = true) diff --git a/obp-api/src/test/scala/code/api/http4sbridge/Http4sServerIntegrationTest.scala b/obp-api/src/test/scala/code/api/http4sbridge/Http4sServerIntegrationTest.scala index bd2e4ef402..b7b171bfbc 100644 --- a/obp-api/src/test/scala/code/api/http4sbridge/Http4sServerIntegrationTest.scala +++ b/obp-api/src/test/scala/code/api/http4sbridge/Http4sServerIntegrationTest.scala @@ -9,6 +9,7 @@ import net.liftweb.json.JsonAST.JObject import net.liftweb.json.JsonParser.parse import org.scalatest.Tag +import scala.collection.JavaConverters._ import scala.concurrent.Await import scala.concurrent.duration._ @@ -107,6 +108,16 @@ class Http4sServerIntegrationTest extends ServerSetup with DefaultUsers with Ser } } + private def makeHttp4sOptionsRequest(path: String): (Int, Map[String, String]) = { + val request = url(s"$baseUrl$path").OPTIONS + val response = Http.default( + request.setHeader("Accept", "*/*") > as.Response(p => + (p.getStatusCode, p.getHeaders.iterator().asScala.map(e => e.getKey -> e.getValue).toMap) + ) + ) + Await.result(response, 10.seconds) + } + private def makeHttp4sDeleteRequest(path: String, headers: Map[String, String] = Map.empty): (Int, String) = { val request = url(s"$baseUrl$path").DELETE val requestWithHeaders = headers.foldLeft(request) { case (req, (key, value)) => @@ -224,24 +235,6 @@ class Http4sServerIntegrationTest extends ServerSetup with DefaultUsers with Ser json \ "banks" should not equal JObject(Nil) } - scenario("GET /obp/v7.0.0/cards requires authentication", Http4sServerIntegrationTag) { - When("We request cards list without authentication") - val (status, body) = makeHttp4sGetRequest("/obp/v7.0.0/cards") - - Then("We should get a 401 response") - status should equal(401) - info("Authentication is required for this endpoint") - } - - scenario("GET /obp/v7.0.0/banks/BANK_ID/cards requires authentication", Http4sServerIntegrationTag) { - When("We request cards for a specific bank without authentication") - val (status, body) = makeHttp4sGetRequest(s"/obp/v7.0.0/banks/testBank0/cards") - - Then("We should get a 401 response") - status should equal(401) - info("Authentication is required for this endpoint") - } - scenario("GET /obp/v7.0.0/resource-docs/v7.0.0/obp returns resource docs", Http4sServerIntegrationTag) { When("We request resource documentation") val (status, body) = makeHttp4sGetRequest("/obp/v7.0.0/resource-docs/v7.0.0/obp") @@ -361,7 +354,7 @@ class Http4sServerIntegrationTest extends ServerSetup with DefaultUsers with Ser scenario("Server handles Lift bridge routes for v3.1.0 (known limitation)", Http4sServerIntegrationTag) { Given("HTTP4S test server is running with Lift bridge") - + When("We make a GET request to a v3.1.0 endpoint (Lift bridge)") try { makeHttp4sGetRequest("/obp/v3.1.0/banks") @@ -374,4 +367,27 @@ class Http4sServerIntegrationTest extends ServerSetup with DefaultUsers with Ser } } } + + // ─── CORS preflight ────────────────────────────────────────────────────────── + // corsHandler sits above Http4s700 in Http4sApp and is only reachable via the + // real server — in-process route tests cannot exercise it. + + feature("HTTP4S CORS preflight") { + + scenario("OPTIONS /obp/v7.0.0/banks returns 204 with CORS headers", Http4sServerIntegrationTag) { + When("OPTIONS /obp/v7.0.0/banks — a browser preflight request") + val (statusCode, headers) = makeHttp4sOptionsRequest("/obp/v7.0.0/banks") + + Then("Response is 204 No Content") + statusCode should equal(204) + + And("All required CORS headers are present") + headers.find { case (k, _) => k.equalsIgnoreCase("Access-Control-Allow-Origin") } + .map(_._2) should equal(Some("*")) + headers.exists { case (k, _) => k.equalsIgnoreCase("Access-Control-Allow-Methods") } should be(true) + headers.exists { case (k, _) => k.equalsIgnoreCase("Access-Control-Allow-Headers") } should be(true) + headers.exists { case (k, _) => k.equalsIgnoreCase("Access-Control-Allow-Credentials") } should be(true) + } + + } } diff --git a/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala b/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala index bc9e299170..284029e3ae 100644 --- a/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala +++ b/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala @@ -1,6 +1,8 @@ package code.api.v7_0_0 -import code.Http4sTestServer +import cats.effect.IO +import cats.effect.unsafe.IORuntime +import code.api.util.http4s.Http4sLiftWebBridge import code.api.Constant.SYSTEM_OWNER_VIEW_ID import code.api.ResponseHeader import code.api.util.APIUtil @@ -11,99 +13,74 @@ import code.entitlement.Entitlement import code.organisation.OrganisationX import code.metadata.counterparties.Counterparties import com.openbankproject.commons.model.{BankId => CommBankId, CreditLimit, CreditRating, CustomerFaceImage} +import fs2.Stream +import org.http4s.{Header, Headers, Method, Request, Uri} +import org.typelevel.ci.CIString import java.util.Date import code.setup.ServerSetupWithTestData -import dispatch.Defaults._ -import dispatch._ import net.liftweb.json.JValue import net.liftweb.json.JsonAST.{JArray, JBool, JField, JObject, JString} import net.liftweb.json.JsonParser.parse import org.scalatest.Tag -import scala.collection.JavaConverters._ -import scala.concurrent.Await -import scala.concurrent.duration._ - /** - * HTTP4S v7.0.0 Routes Integration Test + * HTTP4S v7.0.0 Routes Test + * + * Drives Http4s700.wrappedRoutesV700Services (routes + ResourceDocMiddleware) in-process — + * no TCP, no server startup. Auth/role scenarios are ~5 ms each; DB-touching 200 scenarios + * stay at ~400 ms but there are far fewer of them. * - * Uses Http4sTestServer (singleton) to test v7.0.0 endpoints through real HTTP requests. - * This ensures we test the complete server stack including middleware, error handling, etc. + * CORS preflight behaviour is tested at the server level in Http4sServerIntegrationTest — + * the CORS middleware sits above Http4s700 in the Http4sServer pipeline and is not reachable + * from here. */ class Http4s700RoutesTest extends ServerSetupWithTestData { object Http4s700RoutesTag extends Tag("Http4s700Routes") - // Use Http4sTestServer for full integration testing - private val http4sServer = Http4sTestServer - private val baseUrl = s"http://${http4sServer.host}:${http4sServer.port}" + implicit val runtime: IORuntime = IORuntime.global + private val app = Http4s700.wrappedRoutesV700Services.orNotFound - private def makeHttpRequest( + private def run( + method: Method, path: String, - headers: Map[String, String] = Map.empty + headers: Map[String, String] = Map.empty, + body: String = "" ): (Int, JValue, Map[String, String]) = { - val request = url(s"$baseUrl$path") - val requestWithHeaders = headers.foldLeft(request) { case (req, (key, value)) => - req.addHeader(key, value) - } - - try { - val response = Http.default( - requestWithHeaders.setHeader("Accept", "*/*") > as.Response(p => - (p.getStatusCode, p.getResponseBody, p.getHeaders.iterator().asScala.map(e => e.getKey -> e.getValue).toMap) - ) - ) - val (statusCode, body, responseHeaders) = Await.result(response, 10.seconds) - val json = if (body.trim.isEmpty) JObject(Nil) else parse(body) - (statusCode, json, responseHeaders) - } catch { - case e: java.util.concurrent.ExecutionException => - val statusPattern = """(\d{3})""".r - statusPattern.findFirstIn(e.getCause.getMessage) match { - case Some(code) => (code.toInt, JObject(Nil), Map.empty) - case None => throw e - } - case e: Exception => - throw e - } + val uri = Uri.unsafeFromString(path) + val allHdrs = if (body.nonEmpty) headers + ("Content-Type" -> "application/json") else headers + val hdrs = Headers(allHdrs.map { case (k, v) => Header.Raw(CIString(k), v) }.toList) + val bodyStream: fs2.Stream[IO, Byte] = + if (body.nonEmpty) Stream.emits(body.getBytes("UTF-8")).covary[IO] else Stream.empty + val req = Request[IO](method, uri, headers = hdrs, body = bodyStream) + val baseResp = app.run(req).unsafeRunSync() + // Mirror Http4sApp: apply standard response headers (Correlation-Id, Cache-Control, etc.) + val resp = Http4sLiftWebBridge.ensureStandardHeaders(req, baseResp) + val bodyStr = resp.bodyText.compile.string.unsafeRunSync() + val json = try { + if (bodyStr.trim.isEmpty) JObject(Nil) else parse(bodyStr) + } catch { case _: Exception => JObject(Nil) } + val respHeaders = resp.headers.headers.map(h => h.name.toString -> h.value).toMap + (resp.status.code, json, respHeaders) } + private def makeHttpRequest( + path: String, + headers: Map[String, String] = Map.empty + ): (Int, JValue, Map[String, String]) = run(Method.GET, path, headers) + private def makeHttpRequestWithBody( method: String, path: String, body: String, headers: Map[String, String] = Map.empty ): (Int, JValue, Map[String, String]) = { - val base = url(s"$baseUrl$path") - val withHeaders = (headers + ("Content-Type" -> "application/json")).foldLeft(base) { - case (req, (key, value)) => req.addHeader(key, value) - } - val methodReq = method.toUpperCase match { - case "POST" => withHeaders.POST << body - case "PUT" => withHeaders.PUT << body - case _ => withHeaders << body - } - - try { - val response = Http.default( - methodReq.setHeader("Accept", "*/*") > as.Response(p => - (p.getStatusCode, p.getResponseBody, p.getHeaders.iterator().asScala.map(e => e.getKey -> e.getValue).toMap) - ) - ) - val (statusCode, responseBody, responseHeaders) = Await.result(response, 10.seconds) - val json = if (responseBody.trim.isEmpty) JObject(Nil) else parse(responseBody) - (statusCode, json, responseHeaders) - } catch { - case e: java.util.concurrent.ExecutionException => - val statusPattern = """(\d{3})""".r - statusPattern.findFirstIn(e.getCause.getMessage) match { - case Some(code) => (code.toInt, JObject(Nil), Map.empty) - case None => throw e - } - case e: Exception => - throw e + val m = method.toUpperCase match { + case "PUT" => Method.PUT + case _ => Method.POST } + run(m, path, headers, body) } private def makeHttpRequestWithMethod( @@ -111,37 +88,15 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { path: String, headers: Map[String, String] = Map.empty ): (Int, JValue, Map[String, String]) = { - val base = url(s"$baseUrl$path") - val withHeaders = headers.foldLeft(base) { case (req, (key, value)) => req.addHeader(key, value) } - val methodReq = method.toUpperCase match { - case "POST" => withHeaders.POST - case "PUT" => withHeaders.PUT - case "DELETE" => withHeaders.DELETE - case "OPTIONS" => withHeaders.OPTIONS - case "PATCH" => withHeaders.PATCH - case "HEAD" => withHeaders.HEAD - case _ => withHeaders - } - - try { - val response = Http.default( - methodReq.setHeader("Accept", "*/*") > as.Response(p => - (p.getStatusCode, p.getResponseBody, p.getHeaders.iterator().asScala.map(e => e.getKey -> e.getValue).toMap) - ) - ) - val (statusCode, body, responseHeaders) = Await.result(response, 10.seconds) - val json = if (body.trim.isEmpty) JObject(Nil) else parse(body) - (statusCode, json, responseHeaders) - } catch { - case e: java.util.concurrent.ExecutionException => - val statusPattern = """(\d{3})""".r - statusPattern.findFirstIn(e.getCause.getMessage) match { - case Some(code) => (code.toInt, JObject(Nil), Map.empty) - case None => throw e - } - case e: Exception => - throw e - } + val m = method.toUpperCase match { + case "POST" => Method.POST + case "PUT" => Method.PUT + case "DELETE" => Method.DELETE + case "PATCH" => Method.PATCH + case "HEAD" => Method.HEAD + case _ => Method.GET + } + run(m, path, headers) } private def toFieldMap(fields: List[JField]): Map[String, JValue] = @@ -281,147 +236,6 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { } } - // ─── cards ─────────────────────────────────────────────────────────────────── - - feature("Http4s700 cards endpoint") { - - scenario("Reject unauthenticated access to cards", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/cards request without auth headers") - When("Making HTTP request to server") - val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/cards") - - Then("Response is 401 Unauthorized with appropriate error message") - statusCode shouldBe 401 - json match { - case JObject(fields) => - toFieldMap(fields).get("message") match { - case Some(JString(message)) => - message should include(AuthenticatedUserIsRequired) - case _ => - fail("Expected message field as JSON string for cards unauthorized response") - } - case _ => - fail("Expected JSON object for cards unauthorized response") - } - } - - scenario("Return cards list JSON when authenticated", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/cards request with DirectLogin header") - When("Making HTTP request to server") - val headers = Map("DirectLogin" -> s"token=${token1.value}") - val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/cards", headers) - - Then("Response is 200 OK with cards array") - statusCode shouldBe 200 - json match { - case JObject(fields) => - toFieldMap(fields).get("cards") match { - case Some(JArray(_)) => succeed - case _ => fail("Expected cards field to be an array") - } - case _ => fail("Expected JSON object for cards endpoint") - } - } - } - - // ─── bank cards ────────────────────────────────────────────────────────────── - - feature("Http4s700 bank cards endpoint") { - - scenario("Return bank cards list JSON when authenticated and entitled", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/banks/BANK_ID/cards request with DirectLogin header and role") - val bankId = testBankId1.value - addEntitlement(bankId, resourceUser1.userId, canGetCardsForBank.toString) - - When("Making HTTP request to server") - val headers = Map("DirectLogin" -> s"token=${token1.value}") - val (statusCode, json, _) = makeHttpRequest(s"/obp/v7.0.0/banks/$bankId/cards?limit=10&offset=0", headers) - - Then("Response is 200 OK with cards array") - statusCode shouldBe 200 - json match { - case JObject(fields) => - toFieldMap(fields).get("cards") match { - case Some(JArray(_)) => succeed - case _ => fail("Expected cards field to be an array") - } - case _ => fail("Expected JSON object for bank cards endpoint") - } - } - - scenario("Return empty cards array when bank has no cards", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/banks/BANK_ID/cards for a bank with no cards") - val bankId = testBankId2.value - addEntitlement(bankId, resourceUser1.userId, canGetCardsForBank.toString) - - When("Making HTTP request to server") - val headers = Map("DirectLogin" -> s"token=${token1.value}") - val (statusCode, json, _) = makeHttpRequest(s"/obp/v7.0.0/banks/$bankId/cards", headers) - - Then("Response is 200 OK with empty cards array") - statusCode shouldBe 200 - json match { - case JObject(fields) => - toFieldMap(fields).get("cards") match { - case Some(JArray(cards)) => - cards shouldBe empty - case _ => - fail("Expected cards field to be an array") - } - case _ => - fail("Expected JSON object") - } - } - - scenario("Reject bank cards access when missing required role", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/banks/BANK_ID/cards request with DirectLogin header but no role") - val bankId = testBankId1.value - - When("Making HTTP request to server") - val headers = Map("DirectLogin" -> s"token=${token1.value}") - val (statusCode, json, _) = makeHttpRequest(s"/obp/v7.0.0/banks/$bankId/cards", headers) - - Then("Response is 403 Forbidden") - statusCode shouldBe 403 - json match { - case JObject(fields) => - toFieldMap(fields).get("message") match { - case Some(JString(message)) => - message should include(UserHasMissingRoles) - message should include(canGetCardsForBank.toString) - case _ => - fail("Expected message field as JSON string for missing-role response") - } - case _ => - fail("Expected JSON object for missing-role response") - } - } - - scenario("Return BankNotFound when bank does not exist and user is entitled", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/banks/BANK_ID/cards request for non-existing bank") - val bankId = "non-existing-bank-id" - addEntitlement(bankId, resourceUser1.userId, canGetCardsForBank.toString) - - When("Making HTTP request to server") - val headers = Map("DirectLogin" -> s"token=${token1.value}") - val (statusCode, json, _) = makeHttpRequest(s"/obp/v7.0.0/banks/$bankId/cards", headers) - - Then("Response is 404 Not Found with BankNotFound message") - statusCode shouldBe 404 - json match { - case JObject(fields) => - toFieldMap(fields).get("message") match { - case Some(JString(message)) => - message should include(BankNotFound) - case _ => - fail("Expected message field as JSON string for BankNotFound response") - } - case _ => - fail("Expected JSON object for BankNotFound response") - } - } - } - // ─── resource-docs ─────────────────────────────────────────────────────────── feature("Http4s700 resource-docs endpoint") { @@ -708,8 +522,8 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { } scenario("Error responses also include Correlation-Id header", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/cards without auth (will 401)") - val (statusCode, _, headers) = makeHttpRequest("/obp/v7.0.0/cards") + Given("GET /obp/v7.0.0/users/current without auth (will 401)") + val (statusCode, _, headers) = makeHttpRequest("/obp/v7.0.0/users/current") Then("401 error response still has Correlation-Id") statusCode shouldBe 401 @@ -718,41 +532,8 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { } // ─── CORS preflight ────────────────────────────────────────────────────────── - - feature("Http4s700 CORS preflight") { - - scenario("OPTIONS /obp/v7.0.0/banks returns 204 with CORS headers without Lift overhead", Http4s700RoutesTag) { - Given("OPTIONS /obp/v7.0.0/banks — a browser preflight request") - val (statusCode, _, headers) = makeHttpRequestWithMethod("OPTIONS", "/obp/v7.0.0/banks") - - Then("Response is 204 No Content with all required CORS headers") - statusCode shouldBe 204 - headers.find { case (k, _) => k.equalsIgnoreCase("Access-Control-Allow-Origin") } - .map(_._2) shouldBe Some("*") - hasHeader(headers, "Access-Control-Allow-Methods") shouldBe true - hasHeader(headers, "Access-Control-Allow-Headers") shouldBe true - hasHeader(headers, "Access-Control-Allow-Credentials") shouldBe true - } - - scenario("OPTIONS /obp/v7.0.0/cards returns 204 with CORS headers", Http4s700RoutesTag) { - Given("OPTIONS /obp/v7.0.0/cards — preflight for an authenticated endpoint") - val (statusCode, _, headers) = makeHttpRequestWithMethod("OPTIONS", "/obp/v7.0.0/cards") - - Then("Response is 204 No Content — no auth required for preflight") - statusCode shouldBe 204 - hasHeader(headers, "Access-Control-Allow-Origin") shouldBe true - } - - scenario("OPTIONS /obp/v7.0.0/banks/BANK_ID/cards returns 204 with CORS headers", Http4s700RoutesTag) { - Given("OPTIONS /obp/v7.0.0/banks/BANK_ID/cards — preflight for a nested endpoint") - val bankId = testBankId1.value - val (statusCode, _, headers) = makeHttpRequestWithMethod("OPTIONS", s"/obp/v7.0.0/banks/$bankId/cards") - - Then("Response is 204 No Content with CORS headers") - statusCode shouldBe 204 - hasHeader(headers, "Access-Control-Allow-Origin") shouldBe true - } - } + // CORS is applied by Http4sServer above Http4s700 and is not reachable via in-process + // route testing. OPTIONS preflight scenarios live in Http4sServerIntegrationTest. // ─── routing priority guard ─────────────────────────────────────────────────── // @@ -762,39 +543,17 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { feature("Http4s700 routing priority") { - scenario("GET /banks/BANK_ID/cards is served by getCardsForBank, not getBanks", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/banks/BANK_ID/cards without auth") - val bankId = testBankId1.value - - When("Making HTTP request — if getBanks shadowed getCardsForBank this would return 200 with banks array") - val (statusCode, json, _) = makeHttpRequest(s"/obp/v7.0.0/banks/$bankId/cards") - - Then("Response is 401 (auth required) — proving getCardsForBank matched, not getBanks") - statusCode shouldBe 401 - json match { - case JObject(fields) => - toFieldMap(fields).get("message") match { - case Some(JString(message)) => - message should include(AuthenticatedUserIsRequired) - case _ => - fail("Expected message field — if this is a banks list, getBanks is shadowing getCardsForBank") - } - case _ => - fail("Expected JSON object") - } - } - - scenario("GET /banks returns banks list, not intercepted by getCardsForBank", Http4s700RoutesTag) { + scenario("GET /banks returns banks list", Http4s700RoutesTag) { Given("GET /obp/v7.0.0/banks without auth") val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/banks") - Then("Response is 200 with banks array — proving getBanks matched") + Then("Response is 200 with banks array") statusCode shouldBe 200 json match { case JObject(fields) => toFieldMap(fields).get("banks") match { case Some(JArray(_)) => succeed - case _ => fail("Expected banks array — getBanks may not have matched") + case _ => fail("Expected banks array") } case _ => fail("Expected JSON object")