From 569d99edbcde2eeedf7714610c9a57e8c8af50a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Tue, 21 Apr 2026 14:42:14 +0200 Subject: [PATCH 01/18] refactor: drive Http4s700RoutesTest in-process (no TCP), add CORS scenarios to Http4sServerIntegrationTest --- .../Http4sServerIntegrationTest.scala | 53 ++++- .../code/api/v7_0_0/Http4s700RoutesTest.scala | 188 +++++------------- 2 files changed, 107 insertions(+), 134 deletions(-) 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..86fcf0df25 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)) => @@ -361,7 +372,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 +385,44 @@ 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) + } + + scenario("OPTIONS /obp/v7.0.0/cards returns 204 — no auth required for preflight", Http4sServerIntegrationTag) { + When("OPTIONS /obp/v7.0.0/cards — preflight for an authenticated endpoint") + val (statusCode, headers) = makeHttp4sOptionsRequest("/obp/v7.0.0/cards") + + Then("Response is 204 No Content without requiring authentication") + statusCode should equal(204) + headers.exists { case (k, _) => k.equalsIgnoreCase("Access-Control-Allow-Origin") } should be(true) + } + + scenario("OPTIONS /obp/v7.0.0/banks/BANK_ID/cards returns 204 with CORS headers", Http4sServerIntegrationTag) { + When("OPTIONS /obp/v7.0.0/banks/BANK_ID/cards — preflight for a nested endpoint") + val (statusCode, headers) = makeHttp4sOptionsRequest("/obp/v7.0.0/banks/testBank0/cards") + + Then("Response is 204 No Content with CORS headers") + statusCode should equal(204) + headers.exists { case (k, _) => k.equalsIgnoreCase("Access-Control-Allow-Origin") } 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 ef1506a5ed..53cd8af432 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 @@ -10,99 +12,74 @@ import code.customer.CustomerX import code.entitlement.Entitlement 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( @@ -110,37 +87,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] = @@ -717,41 +672,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 ─────────────────────────────────────────────────── // From c27ef2b9cd1750c9c54eae0134b1782b10ebb822 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Tue, 5 May 2026 14:49:02 +0200 Subject: [PATCH 02/18] docs: update CLAUDE.md after upstream/develop merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migrated endpoint count 27 → 45, full list updated with trading, market, settlement, organisation, and diagnostic endpoints - Test scenario count 93 → 111 in Http4s700RoutesTest - Add IdempotencyMiddleware to key files - CI profile: add V7ResourceDocsAggregationTest (intentionally failing), update Http4s700RoutesTest count, note timings are pre-merge - OBP-Trading TODO: replace stale pending-decision note with current state - Add V7ResourceDocsAggregationTest to known-failing tests list - Add working style rule: no Co-Authored-By trailers in commits --- CLAUDE.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0e0e4127d8..dadc73c6c1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,18 +2,19 @@ ## 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. ## Architecture (Onboarding) -v7.0.0 is a Lift Web → http4s migration. Not a replacement for v6.0.0 yet — 27 of 633 endpoints migrated. +v7.0.0 is a Lift Web → http4s migration. Not a replacement for v6.0.0 yet — 45 of 633 endpoints migrated. **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. -**Key files**: `Http4s700.scala` (endpoints), `Http4sSupport.scala` (EndpointHelpers + recordMetric), `ResourceDocMiddleware.scala` (auth, entity resolution, transaction wrapper), `RequestScopeConnection.scala` (DB transaction propagation to Futures). +**Key files**: `Http4s700.scala` (endpoints), `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). -**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. +**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. -**Tests**: `Http4s700RoutesTest` (93 scenarios, port 8087). `makeHttpRequest` returns `(Int, JValue, Map[String, String])`. `makeHttpRequestWithBody(method, path, body, headers)` for POST/PUT. +**Tests**: `Http4s700RoutesTest` (111 scenarios, port 8087). `makeHttpRequest` returns `(Int, JValue, Map[String, String])`. `makeHttpRequestWithBody(method, path, body, headers)` for POST/PUT. ## Migrating a v6.0.0 Endpoint to v7.0.0 @@ -147,9 +148,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 @@ -215,6 +217,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. From 16e29357dc22aacc93e172b72debb5d4484004f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Tue, 5 May 2026 16:59:19 +0200 Subject: [PATCH 03/18] =?UTF-8?q?docs:=20add=20MIGRATION.md=20with=20full?= =?UTF-8?q?=20Lift=E2=86=92http4s=20plan;=20reference=20it=20from=20CLAUDE?= =?UTF-8?q?.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 10 +++- MIGRATION.md | 128 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 MIGRATION.md diff --git a/CLAUDE.md b/CLAUDE.md index dadc73c6c1..b814c0c5dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,10 +3,14 @@ ## 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 — 45 of 633 endpoints migrated. +> **Migration plan**: see [`MIGRATION.md`](MIGRATION.md) for the full in-place Lift → http4s strategy, file order, auth stack workstream, and progress tracker. + +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. **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. @@ -16,7 +20,9 @@ v7.0.0 is a Lift Web → http4s migration. Not a replacement for v6.0.0 yet — **Tests**: `Http4s700RoutesTest` (111 scenarios, port 8087). `makeHttpRequest` returns `(Int, JValue, Map[String, String])`. `makeHttpRequestWithBody(method, path, body, headers)` for POST/PUT. -## Migrating a v6.0.0 Endpoint to v7.0.0 +## 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 diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000000..d630cc011b --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,128 @@ +# Lift → http4s Migration Plan + +## 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. + +--- + +## 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.** + +| # | File | Own endpoints | Notes | +|---|---|---|---| +| 1 | `APIMethods121` | 70 | Largest; everything inherits from it | +| 2 | `APIMethods130` | 3 | Small; good smoke-test after #1 | +| 3 | `APIMethods140` | 11 | | +| 4 | `APIMethods200` | 40 | | +| 5 | `APIMethods210` | 28 | | +| 6 | `APIMethods220` | 19 | | +| 7 | `APIMethods300` | 47 | | +| 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 | + +--- + +## Auth stack (separate workstream) + +These are 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 + → 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/*) + → Http4s130 (/obp/v1.3.0/*) + → Http4s121 (/obp/v1.2.1/*) + → 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 | + +--- + +## Progress + +| File | Status | +|---|---| +| `APIMethods121` | todo | +| `APIMethods130` | todo | +| `APIMethods140` | todo | +| `APIMethods200` | todo | +| `APIMethods210` | todo | +| `APIMethods220` | todo | +| `APIMethods300` | todo | +| `APIMethods310` | todo | +| `APIMethods400` | todo | +| `APIMethods500` | todo | +| `APIMethods510` | todo | +| `APIMethods600` | todo | +| Auth: DirectLogin | todo | +| Auth: GatewayLogin | todo | +| Auth: DAuth | todo | +| Auth: OAuth | todo | From efbb40348bbb7a2ba36b24131cab39e1e2d72b1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Wed, 6 May 2026 10:32:56 +0200 Subject: [PATCH 04/18] =?UTF-8?q?docs:=20Add=20Lift=20=E2=86=92=20http4s?= =?UTF-8?q?=20Migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 2 +- LIFT_HTTP4S_COEXISTENCE.md | 103 ----------------------- MIGRATION.md => LIFT_HTTP4S_MIGRATION.md | 94 +++++++++++++++++++-- 3 files changed, 89 insertions(+), 110 deletions(-) delete mode 100644 LIFT_HTTP4S_COEXISTENCE.md rename MIGRATION.md => LIFT_HTTP4S_MIGRATION.md (50%) diff --git a/CLAUDE.md b/CLAUDE.md index b814c0c5dd..d73cedc029 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ ## Architecture (Onboarding) -> **Migration plan**: see [`MIGRATION.md`](MIGRATION.md) for the full in-place Lift → http4s strategy, file order, auth stack workstream, and progress tracker. +> **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. 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. 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/MIGRATION.md b/LIFT_HTTP4S_MIGRATION.md similarity index 50% rename from MIGRATION.md rename to LIFT_HTTP4S_MIGRATION.md index d630cc011b..f207d5c135 100644 --- a/MIGRATION.md +++ b/LIFT_HTTP4S_MIGRATION.md @@ -1,4 +1,4 @@ -# Lift → http4s Migration Plan +# Lift → http4s Migration ## Principle @@ -8,6 +8,67 @@ Use a new version (e.g. v7.0.0) only when the API contract itself changes — ne --- +## 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) → `StatusPage` → `Http4s500` → `Http4s700` → `Http4sBGv2` → `Http4sLiftWebBridge` (Lift fallback). Unhandled `/obp/v7.0.0/*` paths fall through silently to Lift — they do not 404. + +``` +HTTP Request + │ + ▼ +Http4sServer (IOApp / Ember) + │ + ▼ +corsHandler → StatusPage → Http4s500 → Http4s700 → Http4sBGv2 → Http4sLiftWebBridge + │ + 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` @@ -34,7 +95,7 @@ See `CLAUDE.md § Migrating a Lift Endpoint to http4s` for the full Rule 1–5 r --- -## Migration order +## Migration Order Bottom-up — each version depends on the one below it being done. @@ -57,9 +118,9 @@ Bottom-up — each version depends on the one below it being done. --- -## Auth stack (separate workstream) +## Auth Stack (separate workstream) -These are 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. +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 | |---|---|---| @@ -72,7 +133,7 @@ These are the last hard dependency on Lift Web in the request path. The Lift bri --- -## Server chain after full migration +## Server Chain After Full Migration ``` corsHandler @@ -95,7 +156,7 @@ corsHandler --- -## Done criteria +## Done Criteria | Milestone | Condition | |---|---| @@ -106,6 +167,27 @@ corsHandler --- +## 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 | From e1c5793df12a7900405e84f288981590379d931c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Wed, 6 May 2026 15:43:21 +0200 Subject: [PATCH 05/18] =?UTF-8?q?refactor:=20Remove=20getCards=20and=20get?= =?UTF-8?q?CardsForBank=20from=20Http4s700.scala=20=E2=80=94=20they=20don'?= =?UTF-8?q?t=20belong=20there.=20The=20Lift=20implementation=20in=20APIMet?= =?UTF-8?q?hods130=20already=20serves=20them=20correctly=20at=20/obp/v1.3.?= =?UTF-8?q?0/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scala/code/api/v7_0_0/Http4s700.scala | 58 +----- .../Http4sServerIntegrationTest.scala | 35 ---- .../code/api/v7_0_0/Http4s700RoutesTest.scala | 173 +----------------- 3 files changed, 6 insertions(+), 260 deletions(-) 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..afa21f5d16 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" => 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 86fcf0df25..b7b171bfbc 100644 --- a/obp-api/src/test/scala/code/api/http4sbridge/Http4sServerIntegrationTest.scala +++ b/obp-api/src/test/scala/code/api/http4sbridge/Http4sServerIntegrationTest.scala @@ -235,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") @@ -407,22 +389,5 @@ class Http4sServerIntegrationTest extends ServerSetup with DefaultUsers with Ser headers.exists { case (k, _) => k.equalsIgnoreCase("Access-Control-Allow-Credentials") } should be(true) } - scenario("OPTIONS /obp/v7.0.0/cards returns 204 — no auth required for preflight", Http4sServerIntegrationTag) { - When("OPTIONS /obp/v7.0.0/cards — preflight for an authenticated endpoint") - val (statusCode, headers) = makeHttp4sOptionsRequest("/obp/v7.0.0/cards") - - Then("Response is 204 No Content without requiring authentication") - statusCode should equal(204) - headers.exists { case (k, _) => k.equalsIgnoreCase("Access-Control-Allow-Origin") } should be(true) - } - - scenario("OPTIONS /obp/v7.0.0/banks/BANK_ID/cards returns 204 with CORS headers", Http4sServerIntegrationTag) { - When("OPTIONS /obp/v7.0.0/banks/BANK_ID/cards — preflight for a nested endpoint") - val (statusCode, headers) = makeHttp4sOptionsRequest("/obp/v7.0.0/banks/testBank0/cards") - - Then("Response is 204 No Content with CORS headers") - statusCode should equal(204) - headers.exists { case (k, _) => k.equalsIgnoreCase("Access-Control-Allow-Origin") } 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 e8ebc7e121..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 @@ -236,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") { @@ -663,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 @@ -684,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") From 63af52a728af3877df1e4628748d2bd6b6b6c5ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Wed, 6 May 2026 16:09:10 +0200 Subject: [PATCH 06/18] =?UTF-8?q?fix:=20resource-docs=20aggregation=20in?= =?UTF-8?q?=20getResourceDocsObpV700=20=E2=80=94=20delegate=20to=20getReso?= =?UTF-8?q?urceDocsList=20for=20all=20versions=20and=20deduplicate=20by=20?= =?UTF-8?q?(verb,=20url)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- LIFT_HTTP4S_MIGRATION.md | 46 ++++++++++++++++++- .../scala/code/api/v7_0_0/Http4s700.scala | 18 ++------ 2 files changed, 49 insertions(+), 15 deletions(-) diff --git a/LIFT_HTTP4S_MIGRATION.md b/LIFT_HTTP4S_MIGRATION.md index f207d5c135..960e8f2d7b 100644 --- a/LIFT_HTTP4S_MIGRATION.md +++ b/LIFT_HTTP4S_MIGRATION.md @@ -104,7 +104,7 @@ Bottom-up — each version depends on the one below it being done. | # | File | Own endpoints | Notes | |---|---|---|---| | 1 | `APIMethods121` | 70 | Largest; everything inherits from it | -| 2 | `APIMethods130` | 3 | Small; good smoke-test after #1 | +| 2 | `APIMethods130` | 3 | Must follow #1 — `OBPAPI1_3_0` mixes in all of `APIMethods121` and registers those ~60 endpoints under the `/obp/v1.3.0/` prefix. Migrating v1.3.0 before v1.2.1 would require porting all inherited endpoints anyway. | | 3 | `APIMethods140` | 11 | | | 4 | `APIMethods200` | 40 | | | 5 | `APIMethods210` | 28 | | @@ -118,6 +118,42 @@ Bottom-up — each version depends on the one below it being done. --- +## 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. @@ -137,6 +173,7 @@ These are the last hard dependency on Lift Web in the request path. The Lift bri ``` corsHandler + → Http4sResourceDocs (/obp/*/resource-docs/*) ← centralized, all version prefixes → Http4s700 (/obp/v7.0.0/*) → Http4s600 (/obp/v6.0.0/*) → Http4s510 (/obp/v5.1.0/*) @@ -208,3 +245,10 @@ Binds to `hostname` / `dev.port` from your props file (defaults: `127.0.0.1:8080 | 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/v7_0_0/Http4s700.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala index afa21f5d16..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 @@ -252,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) From 296654e33c8da70eb85ac3153b8b31a878f6e98a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Thu, 7 May 2026 14:30:56 +0200 Subject: [PATCH 07/18] feat: migrate OBP v1.2.1 endpoints to http4s (Http4s121.scala) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All 323 API1_2_1Test scenarios now pass. Key fixes required: - ErrorResponseConverter: extract only failCode/failMsg from exception JSON to avoid parse failures on complex ccl field - NewStyle.moderatedOtherBankAccount: recover NoSuchElementException → 400 JSON so counterparty-not-found returns 400 instead of 500 - Http4sSupport: add withViewCreated helper (201 Created) - Http4s121: new file — 60+ v1.2.1 endpoints in http4s Http4s121 specifics: - POST counterparty alias/metadata endpoints use withViewCreated (201 not 200) - Descriptions no longer include userAuthenticationMessage(false) where AuthenticatedUserIsRequired must remain in the error list (401 fix) - View/account management endpoints use non-standard URL template vars (BANK_ACCOUNT_ID, CUSTOM_VIEW_ID, GRANT_VIEW_ID) to bypass middleware validateAccount (404) and validateView (403); handlers do inline validation returning 400 - createViewForBankAccount and updateViewForBankAccount capture bankId/accountId from route and call checkBankAccountExists inline (400 on missing account) - getTransactionsForBankAccount and getTransactionByIdForBankAccount rewritten with executeAndRespond + inline account/view validation; transaction query params extracted from request headers (test framework sends them as headers, not query string) via req.headers.headers → HTTPParam list - createQueriesByHttpParamsFuture used for obp_* param validation (400 on invalid) --- CLAUDE.md | 16 +- LIFT_HTTP4S_MIGRATION.md | 16 +- .../ResourceDocsAPIMethods.scala | 5 +- .../main/scala/code/api/util/NewStyle.scala | 15 +- .../util/http4s/ErrorResponseConverter.scala | 5 +- .../code/api/util/http4s/Http4sApp.scala | 1 + .../code/api/util/http4s/Http4sSupport.scala | 20 + .../scala/code/api/v1_2_1/Http4s121.scala | 2476 +++++++++++++++++ 8 files changed, 2535 insertions(+), 19 deletions(-) create mode 100644 obp-api/src/main/scala/code/api/v1_2_1/Http4s121.scala diff --git a/CLAUDE.md b/CLAUDE.md index d73cedc029..25510221a5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,9 +12,9 @@ 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. -**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. +**Request priority chain** (Http4sServer): `corsHandler` (OPTIONS) → StatusPage → Http4s500 → Http4s700 → Http4sBGv2 → Http4s121 → Http4sLiftWebBridge (Lift fallback). Unhandled `/obp/v7.0.0/*` paths fall through silently to Lift — they do not 404. -**Key files**: `Http4s700.scala` (endpoints), `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). +**Key files**: `Http4s700.scala` (v7.0.0 endpoints), `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). **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. @@ -78,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` @@ -122,6 +122,14 @@ 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`) 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. + +**`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 @@ -178,7 +186,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. diff --git a/LIFT_HTTP4S_MIGRATION.md b/LIFT_HTTP4S_MIGRATION.md index 960e8f2d7b..bbc437e7cd 100644 --- a/LIFT_HTTP4S_MIGRATION.md +++ b/LIFT_HTTP4S_MIGRATION.md @@ -29,7 +29,7 @@ New API versions are implemented as native http4s routes and do not pass through ### Priority routing -Routes are tried in order: `corsHandler` (OPTIONS) → `StatusPage` → `Http4s500` → `Http4s700` → `Http4sBGv2` → `Http4sLiftWebBridge` (Lift fallback). Unhandled `/obp/v7.0.0/*` paths fall through silently to Lift — they do not 404. +Routes are tried in order: `corsHandler` (OPTIONS) → `StatusPage` → `Http4s500` → `Http4s700` → `Http4sBGv2` → `Http4s121` → `Http4sLiftWebBridge` (Lift fallback). Unhandled `/obp/v7.0.0/*` paths fall through silently to Lift — they do not 404. ``` HTTP Request @@ -38,10 +38,10 @@ HTTP Request Http4sServer (IOApp / Ember) │ ▼ -corsHandler → StatusPage → Http4s500 → Http4s700 → Http4sBGv2 → Http4sLiftWebBridge - │ - LiftRules.statelessDispatch - LiftRules.dispatch (REST API) +corsHandler → StatusPage → Http4s500 → Http4s700 → Http4sBGv2 → Http4s121 → Http4sLiftWebBridge + │ + LiftRules.statelessDispatch + LiftRules.dispatch (REST API) │ ▼ HTTP Response (with standard headers) @@ -101,9 +101,11 @@ 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 | Largest; everything inherits from it | +| 1 | `APIMethods121` | 70 | **Done** — `Http4s121.scala` serves all endpoints; 323 tests pass | | 2 | `APIMethods130` | 3 | Must follow #1 — `OBPAPI1_3_0` mixes in all of `APIMethods121` and registers those ~60 endpoints under the `/obp/v1.3.0/` prefix. Migrating v1.3.0 before v1.2.1 would require porting all inherited endpoints anyway. | | 3 | `APIMethods140` | 11 | | | 4 | `APIMethods200` | 40 | | @@ -229,7 +231,7 @@ Binds to `hostname` / `dev.port` from your props file (defaults: `127.0.0.1:8080 | File | Status | |---|---| -| `APIMethods121` | todo | +| `APIMethods121` | done — `Http4s121.scala` (all 323 API1_2_1Test scenarios pass) | | `APIMethods130` | todo | | `APIMethods140` | todo | | `APIMethods200` | todo | 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..ab1e0f6d30 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,7 @@ 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.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..7f32328910 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 @@ -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/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 +} From cc1c5d0e0ca680c8f0daf11d6bdf956b7322fb08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Thu, 7 May 2026 19:44:32 +0200 Subject: [PATCH 08/18] =?UTF-8?q?feat:=20migrate=20v1.3.0=20to=20http4s=20?= =?UTF-8?q?=E2=80=94=20Http4s130.scala?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3 own endpoints (root, getCards, getCardsForBank) serve natively; all inherited v1.2.1 endpoints are covered via a path-rewriting bridge that rewrites /obp/v1.3.0/… → /obp/v1.2.1/… and delegates to Http4s121.wrappedRoutesV121Services. PhysicalCardsTest: 2/2 pass. --- CLAUDE.md | 4 +- LIFT_HTTP4S_MIGRATION.md | 15 +- .../code/api/util/http4s/Http4sApp.scala | 1 + .../scala/code/api/v1_3_0/Http4s130.scala | 157 ++++++++++++++++++ 4 files changed, 169 insertions(+), 8 deletions(-) create mode 100644 obp-api/src/main/scala/code/api/v1_3_0/Http4s130.scala diff --git a/CLAUDE.md b/CLAUDE.md index 25510221a5..b58a12ed04 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,9 +12,9 @@ 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. -**Request priority chain** (Http4sServer): `corsHandler` (OPTIONS) → StatusPage → Http4s500 → Http4s700 → Http4sBGv2 → Http4s121 → Http4sLiftWebBridge (Lift fallback). Unhandled `/obp/v7.0.0/*` paths fall through silently to Lift — they do not 404. +**Request priority chain** (Http4sServer): `corsHandler` (OPTIONS) → StatusPage → Http4s500 → Http4s700 → Http4sBGv2 → Http4s130 → Http4s121 → Http4sLiftWebBridge (Lift fallback). Unhandled `/obp/v7.0.0/*` paths fall through silently to Lift — they do not 404. -**Key files**: `Http4s700.scala` (v7.0.0 endpoints), `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). +**Key files**: `Http4s700.scala` (v7.0.0 endpoints), `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). **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. diff --git a/LIFT_HTTP4S_MIGRATION.md b/LIFT_HTTP4S_MIGRATION.md index bbc437e7cd..7f23aa68bb 100644 --- a/LIFT_HTTP4S_MIGRATION.md +++ b/LIFT_HTTP4S_MIGRATION.md @@ -29,7 +29,7 @@ New API versions are implemented as native http4s routes and do not pass through ### Priority routing -Routes are tried in order: `corsHandler` (OPTIONS) → `StatusPage` → `Http4s500` → `Http4s700` → `Http4sBGv2` → `Http4s121` → `Http4sLiftWebBridge` (Lift fallback). Unhandled `/obp/v7.0.0/*` paths fall through silently to Lift — they do not 404. +Routes are tried in order: `corsHandler` (OPTIONS) → `StatusPage` → `Http4s500` → `Http4s700` → `Http4sBGv2` → `Http4s130` → `Http4s121` → `Http4sLiftWebBridge` (Lift fallback). Unhandled `/obp/v7.0.0/*` paths fall through silently to Lift — they do not 404. ``` HTTP Request @@ -38,7 +38,10 @@ HTTP Request Http4sServer (IOApp / Ember) │ ▼ -corsHandler → StatusPage → Http4s500 → Http4s700 → Http4sBGv2 → Http4s121 → Http4sLiftWebBridge +corsHandler → StatusPage → Http4s500 → Http4s700 → Http4sBGv2 → Http4s130 → Http4s121 → Http4sLiftWebBridge + │ │ + own routes v1.2.1 routes + (3 endpoints) (path-rewrite) │ LiftRules.statelessDispatch LiftRules.dispatch (REST API) @@ -106,7 +109,7 @@ Bottom-up — each version depends on the one below it being done. | # | File | Own endpoints | Notes | |---|---|---|---| | 1 | `APIMethods121` | 70 | **Done** — `Http4s121.scala` serves all endpoints; 323 tests pass | -| 2 | `APIMethods130` | 3 | Must follow #1 — `OBPAPI1_3_0` mixes in all of `APIMethods121` and registers those ~60 endpoints under the `/obp/v1.3.0/` prefix. Migrating v1.3.0 before v1.2.1 would require porting all inherited endpoints anyway. | +| 2 | `APIMethods130` | 3 | **Done** — `Http4s130.scala`: 3 own endpoints + path-rewriting bridge to `Http4s121`; 2 PhysicalCardsTest scenarios pass | | 3 | `APIMethods140` | 11 | | | 4 | `APIMethods200` | 40 | | | 5 | `APIMethods210` | 28 | | @@ -187,8 +190,8 @@ corsHandler → Http4s210 (/obp/v2.1.0/*) → Http4s200 (/obp/v2.0.0/*) → Http4s140 (/obp/v1.4.0/*) - → Http4s130 (/obp/v1.3.0/*) - → Http4s121 (/obp/v1.2.1/*) + → Http4s130 (/obp/v1.3.0/*) ← done + → Http4s121 (/obp/v1.2.1/*) ← done → Http4sBGv2 ← Lift bridge removed ``` @@ -232,7 +235,7 @@ Binds to `hostname` / `dev.port` from your props file (defaults: `127.0.0.1:8080 | File | Status | |---|---| | `APIMethods121` | done — `Http4s121.scala` (all 323 API1_2_1Test scenarios pass) | -| `APIMethods130` | todo | +| `APIMethods130` | done — `Http4s130.scala` (2 PhysicalCardsTest scenarios pass) | | `APIMethods140` | todo | | `APIMethods200` | todo | | `APIMethods210` | todo | diff --git a/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala b/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala index ab1e0f6d30..bae632730f 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,7 @@ 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.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/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)) + } +} From 14813eeb82b5192b84a516d012732e321f48ef5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Fri, 8 May 2026 07:53:12 +0200 Subject: [PATCH 09/18] =?UTF-8?q?feat:=20migrate=20v1.4.0=20to=20http4s=20?= =?UTF-8?q?=E2=80=94=20Http4s140.scala?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 11 own endpoints + path-rewriting bridge to Http4s130; fix addCustomer entitlement check to return 403 (not 400) when user has only one of the two required roles (canCreateCustomer + canCreateUserCustomerLink). --- CLAUDE.md | 4 +- LIFT_HTTP4S_MIGRATION.md | 6 +- .../code/api/util/http4s/Http4sApp.scala | 1 + .../scala/code/api/v1_4_0/Http4s140.scala | 513 ++++++++++++++++++ 4 files changed, 519 insertions(+), 5 deletions(-) create mode 100644 obp-api/src/main/scala/code/api/v1_4_0/Http4s140.scala diff --git a/CLAUDE.md b/CLAUDE.md index b58a12ed04..3ad6c8d480 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,9 +12,9 @@ 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. -**Request priority chain** (Http4sServer): `corsHandler` (OPTIONS) → StatusPage → Http4s500 → Http4s700 → Http4sBGv2 → Http4s130 → Http4s121 → Http4sLiftWebBridge (Lift fallback). Unhandled `/obp/v7.0.0/*` paths fall through silently to Lift — they do not 404. +**Request priority chain** (Http4sServer): `corsHandler` (OPTIONS) → StatusPage → Http4s500 → Http4s700 → Http4sBGv2 → Http4s140 → Http4s130 → Http4s121 → Http4sLiftWebBridge (Lift fallback). Unhandled `/obp/v7.0.0/*` paths fall through silently to Lift — they do not 404. -**Key files**: `Http4s700.scala` (v7.0.0 endpoints), `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). +**Key files**: `Http4s700.scala` (v7.0.0 endpoints), `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). **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. diff --git a/LIFT_HTTP4S_MIGRATION.md b/LIFT_HTTP4S_MIGRATION.md index 7f23aa68bb..bec83c39c0 100644 --- a/LIFT_HTTP4S_MIGRATION.md +++ b/LIFT_HTTP4S_MIGRATION.md @@ -110,7 +110,7 @@ Bottom-up — each version depends on the one below it being done. |---|---|---|---| | 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 | | +| 3 | `APIMethods140` | 11 | **Done** — `Http4s140.scala`: 11 own endpoints + path-rewriting bridge to `Http4s130` | | 4 | `APIMethods200` | 40 | | | 5 | `APIMethods210` | 28 | | | 6 | `APIMethods220` | 19 | | @@ -189,7 +189,7 @@ corsHandler → Http4s220 (/obp/v2.2.0/*) → Http4s210 (/obp/v2.1.0/*) → Http4s200 (/obp/v2.0.0/*) - → Http4s140 (/obp/v1.4.0/*) + → Http4s140 (/obp/v1.4.0/*) ← done → Http4s130 (/obp/v1.3.0/*) ← done → Http4s121 (/obp/v1.2.1/*) ← done → Http4sBGv2 @@ -236,7 +236,7 @@ Binds to `hostname` / `dev.port` from your props file (defaults: `127.0.0.1:8080 |---|---| | `APIMethods121` | done — `Http4s121.scala` (all 323 API1_2_1Test scenarios pass) | | `APIMethods130` | done — `Http4s130.scala` (2 PhysicalCardsTest scenarios pass) | -| `APIMethods140` | todo | +| `APIMethods140` | done — `Http4s140.scala` (all 11 own endpoints; path-rewriting bridge to Http4s130) | | `APIMethods200` | todo | | `APIMethods210` | todo | | `APIMethods220` | todo | diff --git a/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala b/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala index bae632730f..5423e34945 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,7 @@ 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.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/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)) + } +} From 521823ffbc97fc4bb692e928afb960358ac2f4c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Fri, 8 May 2026 11:14:30 +0200 Subject: [PATCH 10/18] =?UTF-8?q?feat:=20migrate=20v2.0.0=20to=20http4s=20?= =?UTF-8?q?=E2=80=94=20Http4s200.scala?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 37 own endpoints + path-rewriting bridge to Http4s140 so all inherited v1.4.0/v1.3.0/v1.2.1 paths are served under the v2.0.0 prefix. Two ResourceDoc fixes discovered while running tests: - createUser: removed AuthenticatedUserIsRequired from error list — the endpoint is public; having it caused needsAuthentication=true and the middleware returned 401 for unauthenticated POST /users. - addEntitlement: removed roles from ResourceDoc — bank_id comes from the request body, not the URL, so the middleware's BANK_ID-based role check always used bank="" and rejected canCreateEntitlementAtOneBank holders. Handler already does the correct inline check; switched its booleanToFuture to failCode=403 to match the expected response code. All passing: AccountTest (4), CreateUserTest (3), CustomerTest (1), EntitlementTests (5), API1_2_1Test (323), Http4s700RoutesTest (110). --- CLAUDE.md | 4 +- LIFT_HTTP4S_MIGRATION.md | 4 +- .../code/api/util/http4s/Http4sApp.scala | 1 + .../scala/code/api/v2_0_0/Http4s200.scala | 1392 +++++++++++++++++ 4 files changed, 1397 insertions(+), 4 deletions(-) create mode 100644 obp-api/src/main/scala/code/api/v2_0_0/Http4s200.scala diff --git a/CLAUDE.md b/CLAUDE.md index 3ad6c8d480..f5218667d8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,9 +12,9 @@ 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. -**Request priority chain** (Http4sServer): `corsHandler` (OPTIONS) → StatusPage → Http4s500 → Http4s700 → Http4sBGv2 → Http4s140 → Http4s130 → Http4s121 → Http4sLiftWebBridge (Lift fallback). Unhandled `/obp/v7.0.0/*` paths fall through silently to Lift — they do not 404. +**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. -**Key files**: `Http4s700.scala` (v7.0.0 endpoints), `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). +**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). **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. diff --git a/LIFT_HTTP4S_MIGRATION.md b/LIFT_HTTP4S_MIGRATION.md index bec83c39c0..d292e8b5cd 100644 --- a/LIFT_HTTP4S_MIGRATION.md +++ b/LIFT_HTTP4S_MIGRATION.md @@ -111,7 +111,7 @@ Bottom-up — each version depends on the one below it being done. | 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 | | +| 4 | `APIMethods200` | 40 | **Done** — `Http4s200.scala`: 37 own endpoints + path-rewriting bridge to `Http4s140` | | 5 | `APIMethods210` | 28 | | | 6 | `APIMethods220` | 19 | | | 7 | `APIMethods300` | 47 | | @@ -237,7 +237,7 @@ Binds to `hostname` / `dev.port` from your props file (defaults: `127.0.0.1:8080 | `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` | todo | +| `APIMethods200` | done — `Http4s200.scala` (37 own endpoints; path-rewriting bridge to Http4s140) | | `APIMethods210` | todo | | `APIMethods220` | todo | | `APIMethods300` | todo | diff --git a/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala b/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala index 5423e34945..fc245b66b0 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,7 @@ 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.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)) 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..8be5f89b0c --- /dev/null +++ b/obp-api/src/main/scala/code/api/v2_0_0/Http4s200.scala @@ -0,0 +1,1392 @@ +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) + val coreAccounts: List[CoreAccountJSON] = privateAccounts.map { account => + val viewsAvailable = privateViewsUserCanAccess + .filter(v => v.bankId == account.bankId && v.accountId == account.accountId && v.isPrivate) + .map(createBasicViewJSON) + .distinct + createCoreAccountJSON(account, net.liftweb.json.JObject(Nil)) + } + CoreAccountsJSON(coreAccounts) + } + } + } + + 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 { + val accounts = privateAccountsForOneBank.map(account => + createCoreAccountJSON(account, net.liftweb.json.JObject(Nil))) + CoreAccountsJSON(accounts) + } + } + 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 { + val accounts = privateAccountsForOneBank.map(account => + createCoreAccountJSON(account, net.liftweb.json.JObject(Nil))) + CoreAccountsJSON(accounts) + } + } + 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 { + val accounts = availablePrivateAccounts.map(account => + createCoreAccountJSON(account, net.liftweb.json.JObject(Nil))) + CoreAccountsJSON(accounts) + } + } + } + + 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)) + } +} From 106b0a00964a4efa032ee43e92f364a0b55847ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Fri, 8 May 2026 11:56:26 +0200 Subject: [PATCH 11/18] fix: two regressions in Http4s200 / ResourceDocMiddleware 1. corePrivateAccountsAllBanks and corePrivateAccountsAtOneBank returned CoreAccountsJSON(list) = {"accounts":[]} but the Lift path returned a plain JSON array []. DirectLoginTest expects the plain array. Fix: return List[CoreAccountJSON] directly. 2. authenticatedAccess returns Failure(UsernameHasBeenLocked) as a Box, not as an exception. The old authenticate step passed the Failure user through to the handler which returned 401. Old Lift code returned 400 for all unadorned Failure boxes. Fix: detect Failure in authenticate when needsAuth=true and return 400; Empty box keeps 401. --- .../util/http4s/ResourceDocMiddleware.scala | 17 ++++++++++- .../scala/code/api/v2_0_0/Http4s200.scala | 28 +++++-------------- 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala b/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala index 2bffe0e0e4..cc122e83fa 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` @@ -257,6 +257,21 @@ object ResourceDocMiddleware extends MdcLoggable { EitherT( io.attempt.flatMap { + // Auth succeeded and user is fully resolved — 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)))) + // Auth returned a Failure box (e.g. UsernameHasBeenLocked, UserIsDeleted). + // Old Lift code returned 400 for all unadorned Failure boxes — preserve that. + case Right((Failure(msg, _, _), optCC)) if needsAuth => + val cc2 = optCC.getOrElse(ctx.callContext) + ErrorResponseConverter.createErrorResponse(400, msg, cc2).map(Left(_)) + // Empty box — no valid credentials provided. + case Right((_, optCC)) if needsAuth => + val cc2 = optCC.getOrElse(ctx.callContext) + ErrorResponseConverter.createErrorResponse(401, $AuthenticatedUserIsRequired, cc2).map(Left(_)) + // Anonymous access (needsAuth=false) — pass any box user through unchanged. case Right((boxUser, Some(updatedCC))) => IO.pure(Right(ctx.copy(user = boxUser, callContext = updatedCC))) case Right((boxUser, None)) => diff --git a/obp-api/src/main/scala/code/api/v2_0_0/Http4s200.scala b/obp-api/src/main/scala/code/api/v2_0_0/Http4s200.scala index 8be5f89b0c..4ccfaee2de 100644 --- a/obp-api/src/main/scala/code/api/v2_0_0/Http4s200.scala +++ b/obp-api/src/main/scala/code/api/v2_0_0/Http4s200.scala @@ -114,14 +114,9 @@ object Http4s200 { Future { val (privateViewsUserCanAccess, privateAccountAccess) = Views.views.vend.privateViewsUserCanAccess(user) val privateAccounts = BankAccountX.privateAccounts(privateAccountAccess) - val coreAccounts: List[CoreAccountJSON] = privateAccounts.map { account => - val viewsAvailable = privateViewsUserCanAccess - .filter(v => v.bankId == account.bankId && v.accountId == account.accountId && v.isPrivate) - .map(createBasicViewJSON) - .distinct + privateAccounts.map { account => createCoreAccountJSON(account, net.liftweb.json.JObject(Nil)) } - CoreAccountsJSON(coreAccounts) } } } @@ -210,11 +205,8 @@ object Http4s200 { Views.views.vend.privateViewsUserCanAccessAtBank(user, bank.bankId) } (privateAccountsForOneBank, _) <- BankExtended(bank).privateAccountsFuture(privateAccountAccess, Some(cc)) - } yield { - val accounts = privateAccountsForOneBank.map(account => - createCoreAccountJSON(account, net.liftweb.json.JObject(Nil))) - CoreAccountsJSON(accounts) - } + } 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) => @@ -223,11 +215,8 @@ object Http4s200 { Views.views.vend.privateViewsUserCanAccessAtBank(user, bank.bankId) } (privateAccountsForOneBank, _) <- BankExtended(bank).privateAccountsFuture(privateAccountAccess, Some(cc)) - } yield { - val accounts = privateAccountsForOneBank.map(account => - createCoreAccountJSON(account, net.liftweb.json.JObject(Nil))) - CoreAccountsJSON(accounts) - } + } yield privateAccountsForOneBank.map(account => + createCoreAccountJSON(account, net.liftweb.json.JObject(Nil))) } case req @ GET -> `prefixPath` / "bank" / "accounts" => EndpointHelpers.withUser(req) { (user, cc) => @@ -237,11 +226,8 @@ object Http4s200 { Views.views.vend.privateViewsUserCanAccessAtBank(user, bank.bankId) } (availablePrivateAccounts, _) <- BankExtended(bank).privateAccountsFuture(privateAccountAccess, Some(cc)) - } yield { - val accounts = availablePrivateAccounts.map(account => - createCoreAccountJSON(account, net.liftweb.json.JObject(Nil))) - CoreAccountsJSON(accounts) - } + } yield availablePrivateAccounts.map(account => + createCoreAccountJSON(account, net.liftweb.json.JObject(Nil))) } } From 01efd7fc89532bf8beb1e0ce6e2dda3c19cf347d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Fri, 8 May 2026 12:21:23 +0200 Subject: [PATCH 12/18] fix: use anonymousAccess in ResourceDocMiddleware to preserve Failure status codes authenticatedAccess wraps the returned box with fullBoxOrException, which converts any non-Full result (including Failure(UsernameHasBeenLocked)) into a thrown plain Exception(json) carrying failCode=401. The authenticate handler's `case Left(e: APIFailureNewStyle)` never matched; the catch-all returned 401 for all auth failures including locked users. anonymousAccess already runs all post-auth checks (locked/deleted user, consumer-disabled, rate-limiting) and returns the Failure box as a successful Future result. Switching to anonymousAccess lets the pattern match in authenticate see the raw Failure(UsernameHasBeenLocked) box and return 400, matching the old Lift behaviour for DirectLoginV600Test and DirectLoginTest. --- .../util/http4s/ResourceDocMiddleware.scala | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala b/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala index cc122e83fa..532b72e521 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 @@ -251,27 +251,31 @@ 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))) + // Always call anonymousAccess to get the raw Box[User]. + // authenticatedAccess internally calls fullBoxOrException which converts any + // non-Full box into a thrown plain Exception(json) with failCode=401, losing + // the original error (e.g. UsernameHasBeenLocked should produce 400, not 401). + // anonymousAccess already runs all post-auth checks (locked/deleted user, + // consumer-disabled, rate-limiting) and returns the Failure box untouched. + val io = IO.fromFuture(IO(APIUtil.anonymousAccess(ctx.callContext))) EitherT( io.attempt.flatMap { - // Auth succeeded and user is fully resolved — happy path. + // 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)))) - // Auth returned a Failure box (e.g. UsernameHasBeenLocked, UserIsDeleted). - // Old Lift code returned 400 for all unadorned Failure boxes — preserve that. + // Auth returned a Failure box (e.g. UsernameHasBeenLocked, UserIsDeleted, + // ConsumerIsDisabled). Old Lift returned 400 for all unadorned Failure boxes. case Right((Failure(msg, _, _), optCC)) if needsAuth => val cc2 = optCC.getOrElse(ctx.callContext) ErrorResponseConverter.createErrorResponse(400, msg, cc2).map(Left(_)) - // Empty box — no valid credentials provided. + // 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 access (needsAuth=false) — pass any box user through unchanged. + // 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)) => From e1214aae4d552d5e1a2e6f14da61e0b603e54558 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Fri, 8 May 2026 13:03:23 +0200 Subject: [PATCH 13/18] fix: parse exception JSON in ResourceDocMiddleware to recover 400 for auth failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit anonymousAccess converts all Failure boxes to a thrown plain Exception(json) via fullBoxOrException, hardcoding failCode=401. The previous case Left(_) catch-all was returning 401 for everything, including UsernameHasBeenLocked and DAuthJwtTokenIsNotValid which Lift Old Style returned 400 for (via errorJsonResponse default). Parse the thrown exception's JSON payload to recover the original failMsg, then return 400 — matching the Lift Old Style implicit behavior. Also remove the dead-code case Right((Failure,...)) branch: anonymousAccess never returns a Failure box since it always converts them to thrown exceptions first. --- .../util/http4s/ResourceDocMiddleware.scala | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala b/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala index 532b72e521..85a16bb138 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 @@ -251,12 +251,12 @@ object ResourceDocMiddleware extends MdcLoggable { val needsAuth = ResourceDocMiddleware.needsAuthentication(resourceDoc) logger.debug(s"[ResourceDocMiddleware] needsAuthentication for ${resourceDoc.partialFunctionName}: $needsAuth") - // Always call anonymousAccess to get the raw Box[User]. - // authenticatedAccess internally calls fullBoxOrException which converts any - // non-Full box into a thrown plain Exception(json) with failCode=401, losing - // the original error (e.g. UsernameHasBeenLocked should produce 400, not 401). - // anonymousAccess already runs all post-auth checks (locked/deleted user, - // consumer-disabled, rate-limiting) and returns the Failure box untouched. + // 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( @@ -266,11 +266,6 @@ object ResourceDocMiddleware extends MdcLoggable { IO.pure(Right(ctx.copy(user = Full(user), callContext = updatedCC))) case Right((Full(user), None)) => IO.pure(Right(ctx.copy(user = Full(user)))) - // Auth returned a Failure box (e.g. UsernameHasBeenLocked, UserIsDeleted, - // ConsumerIsDisabled). Old Lift returned 400 for all unadorned Failure boxes. - case Right((Failure(msg, _, _), optCC)) if needsAuth => - val cc2 = optCC.getOrElse(ctx.callContext) - ErrorResponseConverter.createErrorResponse(400, msg, cc2).map(Left(_)) // Empty box — no valid credentials provided, and auth is required. case Right((_, optCC)) if needsAuth => val cc2 = optCC.getOrElse(ctx.callContext) @@ -282,8 +277,15 @@ object ResourceDocMiddleware extends MdcLoggable { 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) — the JSON + // preserves the original error message but hardcodes failCode=401. Parse it to recover + // the message and return 400, matching Lift Old Style error handling. + val failMsg = scala.util.Try { + implicit val formats = net.liftweb.json.DefaultFormats + net.liftweb.json.parse(e.getMessage).extract[APIFailureNewStyle].failMsg + }.getOrElse($AuthenticatedUserIsRequired) + ErrorResponseConverter.createErrorResponse(400, failMsg, ctx.callContext).map(Left(_)) } ) } From a8c9a19c453761b6a93ac3db80a28cd33d7db028 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Sat, 9 May 2026 10:34:53 +0200 Subject: [PATCH 14/18] =?UTF-8?q?feat:=20migrate=20v2.1.0=20to=20http4s=20?= =?UTF-8?q?=E2=80=94=20Http4s210.scala?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 25 own endpoints ported from APIMethods210 (root, sandboxDataImport, getTransactionRequestTypesSupportedByBank, createTransactionRequest ×4, answerTransactionRequestChallenge, getTransactionRequests, getRoles, getEntitlementsByBankAndUser, getConsumer, getConsumers, enableDisableConsumers, addCardForBank, getUsers, createTransactionType, getAtm, getBranch, getProduct, getProducts, createCustomer, getCustomersForUser, getCustomersForCurrentUserAtBank, updateBranch, createBranch, updateConsumerRedirectUrl, getMetrics) plus a path-rewriting bridge to Http4s200 for inherited v2.0.0/v1.4.0/v1.3.0/ v1.2.1 paths. Also fixes body-parse error responses in HttpsSupport helpers: replace plain BadRequest(msg) with ErrorResponseConverter.createErrorResponse so invalid-JSON responses are properly formatted JSON objects instead of raw strings. All 79 v2.1.0 tests pass. --- LIFT_HTTP4S_MIGRATION.md | 16 +- .../code/api/util/http4s/Http4sApp.scala | 1 + .../code/api/util/http4s/Http4sSupport.scala | 12 +- .../scala/code/api/v2_1_0/Http4s210.scala | 1205 +++++++++++++++++ 4 files changed, 1222 insertions(+), 12 deletions(-) create mode 100644 obp-api/src/main/scala/code/api/v2_1_0/Http4s210.scala diff --git a/LIFT_HTTP4S_MIGRATION.md b/LIFT_HTTP4S_MIGRATION.md index d292e8b5cd..dea783e580 100644 --- a/LIFT_HTTP4S_MIGRATION.md +++ b/LIFT_HTTP4S_MIGRATION.md @@ -29,7 +29,7 @@ New API versions are implemented as native http4s routes and do not pass through ### Priority routing -Routes are tried in order: `corsHandler` (OPTIONS) → `StatusPage` → `Http4s500` → `Http4s700` → `Http4sBGv2` → `Http4s130` → `Http4s121` → `Http4sLiftWebBridge` (Lift fallback). Unhandled `/obp/v7.0.0/*` paths fall through silently to Lift — they do not 404. +Routes are tried in order: `corsHandler` (OPTIONS) → `AppsPage` → `StatusPage` → `Http4s500` → `Http4s700` → `Http4sBGv2` → `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 @@ -38,10 +38,14 @@ HTTP Request Http4sServer (IOApp / Ember) │ ▼ -corsHandler → StatusPage → Http4s500 → Http4s700 → Http4sBGv2 → Http4s130 → Http4s121 → Http4sLiftWebBridge - │ │ - own routes v1.2.1 routes - (3 endpoints) (path-rewrite) +corsHandler → AppsPage → StatusPage → Http4s500 → Http4s700 → Http4sBGv2 + │ + Http4s210 → Http4s200 → Http4s140 → Http4s130 → Http4s121 → Http4sLiftWebBridge + │ │ │ │ │ + v2.1.0 v2.0.0 v1.4.0 v1.3.0 v1.2.1 routes + own routes own routes own routes own routes (all 323 scenarios) + + v2.0.0 + v1.4.0 + v1.3.0 + v1.2.1 + bridge bridge bridge bridge │ LiftRules.statelessDispatch LiftRules.dispatch (REST API) @@ -238,7 +242,7 @@ Binds to `hostname` / `dev.port` from your props file (defaults: `127.0.0.1:8080 | `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` | todo | +| `APIMethods210` | done — `Http4s210.scala` (25 own endpoints; path-rewriting bridge to Http4s200) | | `APIMethods220` | todo | | `APIMethods300` | todo | | `APIMethods310` | todo | diff --git a/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala b/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala index fc245b66b0..c764d5dc6d 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,7 @@ 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.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)) 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 7f32328910..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")) 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)) + } +} From 822462b2ab27f62fe7e181bfe228d1a209a31576 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Sat, 9 May 2026 11:46:10 +0200 Subject: [PATCH 15/18] =?UTF-8?q?feat:=20migrate=20v2.2.0=20to=20http4s=20?= =?UTF-8?q?=E2=80=94=20Http4s220.scala?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 18 own endpoints (root, getViewsForBankAccount, createViewForBankAccount, updateViewForBankAccount, getCurrentFxRate, getExplicitCounterpartiesForAccount, getExplicitCounterpartyById, getMessageDocs, createBank, createBranch, createAtm, createProduct, createFx, createAccount, config, getConnectorMetrics, createConsumer, createCounterparty) + path-rewriting bridge to Http4s210. All 27 v2.2.0 tests pass (AccountTest, API2_2_0Test, ExchangeRateTest, CreateCounterpartyTest). Notable patterns used: - createAccount: ResourceDoc uses NEW_ACCOUNT_ID (non-standard) to bypass middleware account-existence check; inline booleanToFuture(failCode=403) for cross-user role enforcement - createViewForBankAccount: ResourceDoc uses VIEW_ACCOUNT_ID to bypass middleware 404; inline Connector.checkBankAccountExists → unboxFullOrFail returns 400 for missing account (matching Lift behaviour) - updateViewForBankAccount: ResourceDoc uses UPD_VIEW_ID to bypass middleware VIEW_ID validation; handler's own startsWith("_") check returns 400 first - ResourceDocMiddleware: fix case-None branch to attach basic CC to request so endpoints at bare version paths (e.g. GET /obp/v2.2.0) can call req.callContext --- CLAUDE.md | 9 + LIFT_HTTP4S_MIGRATION.md | 20 +- .../code/api/util/http4s/Http4sApp.scala | 1 + .../util/http4s/ResourceDocMiddleware.scala | 7 +- .../scala/code/api/v2_2_0/Http4s220.scala | 855 ++++++++++++++++++ 5 files changed, 877 insertions(+), 15 deletions(-) create mode 100644 obp-api/src/main/scala/code/api/v2_2_0/Http4s220.scala diff --git a/CLAUDE.md b/CLAUDE.md index f5218667d8..dbacc9066f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,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(...)`. diff --git a/LIFT_HTTP4S_MIGRATION.md b/LIFT_HTTP4S_MIGRATION.md index dea783e580..9e168e5800 100644 --- a/LIFT_HTTP4S_MIGRATION.md +++ b/LIFT_HTTP4S_MIGRATION.md @@ -29,7 +29,7 @@ New API versions are implemented as native http4s routes and do not pass through ### Priority routing -Routes are tried in order: `corsHandler` (OPTIONS) → `AppsPage` → `StatusPage` → `Http4s500` → `Http4s700` → `Http4sBGv2` → `Http4s210` → `Http4s200` → `Http4s140` → `Http4s130` → `Http4s121` → `Http4sLiftWebBridge` (Lift fallback). Unhandled `/obp/v7.0.0/*` paths fall through silently to Lift — they do not 404. +Routes are tried in order: `corsHandler` (OPTIONS) → `AppsPage` → `StatusPage` → `Http4s500` → `Http4s700` → `Http4sBGv2` → `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 @@ -40,12 +40,12 @@ Http4sServer (IOApp / Ember) ▼ corsHandler → AppsPage → StatusPage → Http4s500 → Http4s700 → Http4sBGv2 │ - Http4s210 → Http4s200 → Http4s140 → Http4s130 → Http4s121 → Http4sLiftWebBridge - │ │ │ │ │ - v2.1.0 v2.0.0 v1.4.0 v1.3.0 v1.2.1 routes - own routes own routes own routes own routes (all 323 scenarios) - + v2.0.0 + v1.4.0 + v1.3.0 + v1.2.1 - bridge bridge bridge bridge + Http4s220 → Http4s210 → Http4s200 → Http4s140 → Http4s130 → Http4s121 → Http4sLiftWebBridge + │ │ │ │ │ │ + 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 (all 323 scenarios) + + 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) @@ -116,8 +116,8 @@ Bottom-up — each version depends on the one below it being done. | 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 | | -| 6 | `APIMethods220` | 19 | | +| 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 | | | 8 | `APIMethods310` | 102 | | | 9 | `APIMethods400` | ~258 total | Largest file; may need splitting into sub-traits | @@ -243,7 +243,7 @@ Binds to `hostname` / `dev.port` from your props file (defaults: `127.0.0.1:8080 | `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` | todo | +| `APIMethods220` | done — `Http4s220.scala` (18 own endpoints; path-rewriting bridge to Http4s210) | | `APIMethods300` | todo | | `APIMethods310` | todo | | `APIMethods400` | todo | diff --git a/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala b/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala index c764d5dc6d..5851c3e835 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,7 @@ 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.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)) 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 85a16bb138..811f38728b 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 @@ -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)) } } } 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)) + } +} From 7162d601655308c041ec8f2cc95cb122b7013347 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Sat, 9 May 2026 11:47:58 +0200 Subject: [PATCH 16/18] docs: add migration tips for IO-based 400 account lookup and middleware bypass variants --- CLAUDE.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index dbacc9066f..ddd82a8b04 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -133,7 +133,21 @@ _ <- if (userIdAccountOwner == loggedInUserId) Future.successful(Full(())) **`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`) 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. +**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. **`ResourceDoc` description and `needsAuthentication`**: The `ResourceDoc` constructor removes `AuthenticatedUserIsRequired` from `errorResponseBodies` when `description.contains(authenticationIsOptional) && rolesIsEmpty`. `needsAuthentication = errorResponseBodies.contains($AuthenticatedUserIsRequired) || roles.nonEmpty`. If the description embeds `${userAuthenticationMessage(false)}` (which includes `authenticationIsOptional`) and roles are empty, the error is silently removed → `needsAuthentication=false` → anonymous access → unauthenticated requests reach the handler. Fix: remove `${userAuthenticationMessage(false)}` from the description when `AuthenticatedUserIsRequired` must remain in the error list. From 84b5d35d7058c84f9f9e6240ae772eb871df2f9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Mon, 11 May 2026 09:39:12 +0200 Subject: [PATCH 17/18] =?UTF-8?q?feat:=20migrate=20v3.0.0=20to=20http4s=20?= =?UTF-8?q?=E2=80=94=20Http4s300.scala?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 47 own endpoints + path-rewriting bridge to Http4s220. All 86 v3.0.0 tests pass. Firehose endpoints use non-standard ResourceDoc URL template vars (FIREHOSE_BANK_ID / FIREHOSE_VIEW_ID) to bypass middleware bank/view resolution, ensuring prop check returns 400 and role check returns 403 in the correct order regardless of bank ID validity. --- LIFT_HTTP4S_MIGRATION.md | 16 +- .../code/api/util/http4s/Http4sApp.scala | 1 + .../scala/code/api/v3_0_0/Http4s300.scala | 1715 +++++++++++++++++ 3 files changed, 1724 insertions(+), 8 deletions(-) create mode 100644 obp-api/src/main/scala/code/api/v3_0_0/Http4s300.scala diff --git a/LIFT_HTTP4S_MIGRATION.md b/LIFT_HTTP4S_MIGRATION.md index 9e168e5800..6e5e6a3377 100644 --- a/LIFT_HTTP4S_MIGRATION.md +++ b/LIFT_HTTP4S_MIGRATION.md @@ -29,7 +29,7 @@ New API versions are implemented as native http4s routes and do not pass through ### Priority routing -Routes are tried in order: `corsHandler` (OPTIONS) → `AppsPage` → `StatusPage` → `Http4s500` → `Http4s700` → `Http4sBGv2` → `Http4s220` → `Http4s210` → `Http4s200` → `Http4s140` → `Http4s130` → `Http4s121` → `Http4sLiftWebBridge` (Lift fallback). Unhandled `/obp/v7.0.0/*` paths fall through silently to Lift — they do not 404. +Routes are tried in order: `corsHandler` (OPTIONS) → `AppsPage` → `StatusPage` → `Http4s500` → `Http4s700` → `Http4sBGv2` → `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 @@ -40,11 +40,11 @@ Http4sServer (IOApp / Ember) ▼ corsHandler → AppsPage → StatusPage → Http4s500 → Http4s700 → Http4sBGv2 │ - Http4s220 → Http4s210 → Http4s200 → Http4s140 → Http4s130 → Http4s121 → Http4sLiftWebBridge - │ │ │ │ │ │ - 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 (all 323 scenarios) - + v2.1.0 + v2.0.0 + v1.4.0 + v1.3.0 + v1.2.1 + 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 @@ -118,7 +118,7 @@ Bottom-up — each version depends on the one below it being done. | 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 | | +| 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 | | @@ -244,7 +244,7 @@ Binds to `hostname` / `dev.port` from your props file (defaults: `127.0.0.1:8080 | `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` | todo | +| `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 | 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 5851c3e835..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,7 @@ 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)) 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)) + } +} From 028250c5dd6257dc814619df4a5e23a45b9b34ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Mon, 11 May 2026 11:44:56 +0200 Subject: [PATCH 18/18] fix: return 401 for auth failures on New Style endpoints in ResourceDocMiddleware Old Style endpoints (v1.x, v2.0.0) keep 400 for auth failures (locked user, invalid DAuth JWT) matching Lift's errorJsonResponse default. New Style endpoints (v2.1.0+) now correctly return the parsed failCode from the exception (401), matching the Lift New Style contract. Fixes: DirectLoginTest locked-user scenario returning 400 at v3.0.0/users/current Fixes: dauthTest invalid DAuth JWT returning 400 at v3.0.0/users/current --- CLAUDE.md | 2 ++ .../util/http4s/ResourceDocMiddleware.scala | 21 ++++++++++++------- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ddd82a8b04..b8978a66fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -149,6 +149,8 @@ case req @ POST -> `prefixPath` / "banks" / _ / "accounts" / accountIdStr / "vie ``` `checkBankAccountExists` returns `OBPReturnType[Box[BankAccount]]` = `Future[(Box[BankAccount], Option[CC])]`. Extract the `Box` with `.map(_._1)`. `unboxFullOrFail` with default `emptyBoxErrorCode=400` throws a JSON-encoded 400 exception that `ErrorResponseConverter` parses correctly. +**Auth failure status code — Old Style vs New Style**: `ResourceDocMiddleware.authenticate` returns 400 for auth failures (locked user, invalid DAuth JWT) on Old Style endpoints (v1.2.1, v1.3.0, v1.4.0, v2.0.0) and 401 on New Style endpoints (v2.1.0+). The version check is in the `case Left(e)` branch: `oldStyleShortVersions.contains(resourceDoc.implementedInApiVersion.apiShortVersion)`. `anonymousAccess` always converts Failure boxes to `Exception(json_of_APIFailureNewStyle)` with `failCode=401` via `fullBoxOrException`. The Old Style 400 is a deliberate override for backward compatibility. + **`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`. 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 811f38728b..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 @@ -275,14 +275,21 @@ object ResourceDocMiddleware extends MdcLoggable { case Left(e: APIFailureNewStyle) => ErrorResponseConverter.createErrorResponse(e.failCode, e.failMsg, ctx.callContext).map(Left(_)) case Left(e) => - // anonymousAccess threw a plain Exception(json_of_APIFailureNewStyle) — the JSON - // preserves the original error message but hardcodes failCode=401. Parse it to recover - // the message and return 400, matching Lift Old Style error handling. - val failMsg = scala.util.Try { + // 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 - net.liftweb.json.parse(e.getMessage).extract[APIFailureNewStyle].failMsg - }.getOrElse($AuthenticatedUserIsRequired) - ErrorResponseConverter.createErrorResponse(400, failMsg, ctx.callContext).map(Left(_)) + 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(_)) } ) }