Skip to content

Commit 145b5e9

Browse files
authored
Merge pull request #2784 from constantine2nd/develop
Migration to Http4s
2 parents b492e1f + 669171d commit 145b5e9

10 files changed

Lines changed: 15376 additions & 103 deletions

File tree

CLAUDE.md

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -342,23 +342,21 @@ Per-endpoint integration test cost stays roughly constant as endpoints move Lift
342342

343343
## TODO / Phase Progress
344344

345-
### Phase 1 — Simple GETs (98 remaining in v6.0.0)
346-
GET + no body. Purely mechanical — 1:1 copy of `NewStyle.function.*` calls, pick helper from Rule 4 matrix, 3 test scenarios per endpoint (401 / 403 / 200).
345+
### Per-version completeness (from `comm -23 lift http4s` on each version's `lazy val ... : OBPEndpoint` declarations)
347346

348-
| Batch | Endpoints | Status |
349-
|---|---|---|
350-
| Batches 1–3 | 9 endpoints | ✓ done |
351-
| Batch 4 | getCacheConfig, getCacheInfo, getDatabasePoolInfo, getStoredProcedureConnectorHealth, getMigrations, getCacheNamespaces | ✓ done |
352-
| Remaining | 98 GETs | todo |
347+
| Version | Genuine Lift handlers still on the bridge |
348+
|---|---|
349+
| v1.2.1, v1.3.0, v2.0.0, v2.1.0, v2.2.0, v3.0.0, v4.0.0, v5.0.0, v5.1.0, v6.0.0 | 0 — fully on http4s |
350+
| v1.4.0 | `testResourceDoc` (dev-mode-only stub; tracked in "Per-version Lift leftovers") |
351+
| v3.1.0 | `getMessageDocsSwagger`, `getObpConnectorLoopback` (both tracked as leftovers; retire via Resource-docs / bridge-removal workstreams) |
353352

354-
### Phase 2Account/View/Counterparty GETs (subset of the 98 above)
355-
`withBankAccount` / `withView` / `withCounterparty` helpers ready. Same mechanical pattern.
353+
### v6.0.0 migrationdone (243 / 243)
354+
Phase 1 (35 overrides) and Phase 2 (208 originals) both complete. All v6 routes live in `Http4s600.scala`, wired into `Http4sApp.baseServices` ahead of the Lift bridge.
356355

357-
### Phase 3 — POST / PUT / DELETE (57 + 33 + 26 = 116 endpoints in v6.0.0)
358-
Body helpers and DELETE 204 helpers ready. Velocity: 6–8 endpoints/day.
356+
Architectural note from the v6 migration: around the 140-endpoint mark `Implementations6_0_0`'s `<init>` hit the JVM 64KB bytecode-per-method limit. The fix that ships in `Http4s600.scala` — and that future per-version files should adopt — is two-part:
359357

360-
### Phase 4 — Complex endpoints (~50 endpoints)
361-
Dynamic entities, ABAC rules, mandate workflows, polymorphic bodies. ~45–60 min each.
358+
1. Declare endpoints as `lazy val xxx: HttpRoutes[IO] = HttpRoutes.of[IO] { ... }` instead of `val`. Lambda materialisation moves out of `<init>` into per-field `lzycompute` methods (each with its own 64KB budget).
359+
2. Group `resourceDocs += ResourceDoc(...)` calls into `private def initXxxResourceDocs(): Unit` blocks of ~10–15 endpoints, called once each from the object body. Each helper def gets its own 64KB.
362360

363361
### Other TODOs
364362
- **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`.

LIFT_HTTP4S_MIGRATION.md

Lines changed: 119 additions & 21 deletions
Large diffs are not rendered by default.

LIFT_HTTP4S_MIGRATION_V6_AUDIT.md

Lines changed: 650 additions & 0 deletions
Large diffs are not rendered by default.

obp-api/src/main/scala/code/api/util/ErrorMessages.scala

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1029,6 +1029,24 @@ object ErrorMessages {
10291029
*/
10301030
def getCode(errorMsg: String): Int = errorToCode.get(errorMsg).getOrElse(400)
10311031

1032+
/**
1033+
* Resolve HTTP status code for an OBP-prefixed error message that may have a
1034+
* runtime suffix appended (e.g. "OBP-20020: User does not have access to the view.
1035+
* Current ViewId is owner"). Extracts the OBP-XXXXX prefix and returns the
1036+
* canonical status code from errorToCode, but ONLY when the canonical code is
1037+
* one of the auth-overrides Lift's `errorJsonResponse` performs (401, 403, 408,
1038+
* 429). For other codes (e.g. 404 BankNotFound) the default failCode the caller
1039+
* supplied wins — callers that want 404 must request it explicitly via
1040+
* `unboxFullOrFail(..., emptyBoxErrorCode = 404)` or similar, matching Lift's
1041+
* intent that 400 is a deliberate "validation failure" code.
1042+
*/
1043+
def getCodeByOBPPrefix(errorMsg: String): Int = {
1044+
val prefixOpt = "OBP-\\d{5}".r.findFirstIn(errorMsg)
1045+
prefixOpt.flatMap { prefix =>
1046+
errorToCode.find { case (key, _) => key.startsWith(prefix + ":") }.map(_._2)
1047+
}.filter(Set(401, 403, 408, 429).contains).getOrElse(400)
1048+
}
1049+
10321050
/****** special error message, start with $, mark as do validation according ResourceDoc errorResponseBodies *****/
10331051
/**
10341052
* validate method: APIUtil.authorizedAccess

obp-api/src/main/scala/code/api/util/http4s/ErrorResponseConverter.scala

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ object ErrorResponseConverter {
3434
implicit val formats: Formats = CustomJsonFormats.formats
3535
private val jsonContentType: `Content-Type` = `Content-Type`(MediaType.application.json)
3636

37+
private val obpErrorCodePrefix = "^OBP-\\d{5}: ".r
38+
3739
private def tryExtractApiFailureFromExceptionMessage(error: Throwable): Option[APIFailureNewStyle] = {
3840
val msg = Option(error.getMessage).getOrElse("").trim
3941
if (msg.startsWith("{") && msg.contains("\"failCode\"") && msg.contains("\"failMsg\"")) {
@@ -45,6 +47,9 @@ object ErrorResponseConverter {
4547
} catch {
4648
case _: Throwable => None
4749
}
50+
} else if (obpErrorCodePrefix.findFirstIn(msg).isDefined) {
51+
// Plain Exception("OBP-XXXXX: ...") thrown by fullBoxOrException(Failure(msg)) — Lift treats these as 400.
52+
Some(APIFailureNewStyle(msg, 400))
4853
} else {
4954
None
5055
}
@@ -80,13 +85,31 @@ object ErrorResponseConverter {
8085
}
8186
}
8287

88+
/** Old-style versions keep raw 400 codes — they never promote to 403/401/etc.
89+
* Mirrors the same set used in ResourceDocMiddleware.authenticate.
90+
*/
91+
private val oldStyleShortVersions = Set("v1.2.1", "v1.3.0", "v1.4.0", "v2.0.0")
92+
93+
/**
94+
* Translate a 400 default with an OBP-prefixed message to the canonical status
95+
* Lift assigns (403 for role/view-access codes, 401 for auth codes, etc.) via
96+
* ErrorMessages.getCodeByOBPPrefix. Leaves non-400 failCodes (caller set
97+
* status explicitly) untouched. Old-style versions (v1.x, v2.0.0) keep the
98+
* 400 — they return 400 for every error per the long-standing OBP convention.
99+
*/
100+
private def resolveStatusCode(failCode: Int, failMsg: String, callContext: CallContext): Int =
101+
if (failCode == 400 && !oldStyleShortVersions.contains(callContext.implementedInVersion))
102+
code.api.util.ErrorMessages.getCodeByOBPPrefix(failMsg)
103+
else failCode
104+
83105
/**
84106
* Convert APIFailureNewStyle to http4s Response.
85107
* Uses failCode as HTTP status and failMsg as error message.
86108
*/
87109
def apiFailureToResponse(failure: APIFailureNewStyle, callContext: CallContext): IO[Response[IO]] = {
88-
val errorJson = OBPErrorResponse(failure.failCode, failure.failMsg)
89-
val status = org.http4s.Status.fromInt(failure.failCode).getOrElse(org.http4s.Status.BadRequest)
110+
val resolvedCode = resolveStatusCode(failure.failCode, failure.failMsg, callContext)
111+
val errorJson = OBPErrorResponse(resolvedCode, failure.failMsg)
112+
val status = org.http4s.Status.fromInt(resolvedCode).getOrElse(org.http4s.Status.BadRequest)
90113
IO.pure(
91114
Response[IO](status)
92115
.withEntity(toJsonString(errorJson))

obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ object Http4sApp {
7272
private val v400Routes: HttpRoutes[IO] = gate(ApiVersion.v4_0_0, code.api.v4_0_0.Http4s400.wrappedRoutesV400Services)
7373
private val v500Routes: HttpRoutes[IO] = gate(ApiVersion.v5_0_0, code.api.v5_0_0.Http4s500.wrappedRoutesV500Services)
7474
private val v510Routes: HttpRoutes[IO] = gate(ApiVersion.v5_1_0, code.api.v5_1_0.Http4s510.wrappedRoutesV510Services)
75+
private val v600Routes: HttpRoutes[IO] = gate(ApiVersion.v6_0_0, code.api.v6_0_0.Http4s600.wrappedRoutesV600Services)
7576
private val v700Routes: HttpRoutes[IO] = gate(ApiVersion.v7_0_0, code.api.v7_0_0.Http4s700.wrappedRoutesV700Services)
7677

7778
/**
@@ -106,6 +107,7 @@ object Http4sApp {
106107
.orElse(AppsPage.routes.run(req))
107108
.orElse(StatusPage.routes.run(req))
108109
.orElse(v510Routes.run(req))
110+
.orElse(v600Routes.run(req))
109111
.orElse(v500Routes.run(req))
110112
.orElse(v700Routes.run(req))
111113
.orElse(code.api.berlin.group.v2.Http4sBGv2.wrappedRoutes.run(req))

obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,7 +376,11 @@ object ResourceDocMiddleware extends MdcLoggable {
376376
case Full(user) =>
377377
val bankId = pathParams.getOrElse("BANK_ID", "")
378378
val consumerId = APIUtil.getConsumerPrimaryKey(Some(ctx.callContext))
379-
val ok = APIUtil.handleAccessControlRegardingEntitlementsAndScopes(bankId, user.userId, consumerId, roles)
379+
// Use handleAccessControlWithAuthMode so authMode = UserOrApplication
380+
// accepts consumer-scope-only requests (the auth-only handleAccess...
381+
// function checks user-entitlements + scopes ANDed and rejects pure
382+
// consumer-scope requests with 403 even under UserOrApplication).
383+
val ok = APIUtil.handleAccessControlWithAuthMode(bankId, user.userId, consumerId, roles, resourceDoc.authMode)
380384
if (ok) success(ctx)
381385
else EitherT[IO, Response[IO], ValidationContext](
382386
ErrorResponseConverter.createErrorResponse(403, UserHasMissingRoles + roles.mkString(" or "), ctx.callContext)

obp-api/src/main/scala/code/api/v3_1_0/Http4s310.scala

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1706,6 +1706,39 @@ object Http4s310 {
17061706
List(apiTagCustomer), Some(List(canCreateCustomerAddress)),
17071707
http4sPartialFunction = Some(createCustomerAddress))
17081708

1709+
// ─── updateCustomerAddress (PUT) ─────────────────────────────────────────
1710+
1711+
val updateCustomerAddress: HttpRoutes[IO] = HttpRoutes.of[IO] {
1712+
case req @ PUT -> `prefixPath` / "banks" / _ / "customers" / customerIdStr / "addresses" / customerAddressIdStr =>
1713+
EndpointHelpers.withUserAndBankAndBody[PostCustomerAddressJsonV310, Any](req) { (user, bank, postedData, cc) =>
1714+
for {
1715+
_ <- NewStyle.function.hasEntitlement(bank.bankId.value, user.userId, canCreateCustomer, Some(cc))
1716+
(_, _) <- NewStyle.function.getCustomerByCustomerId(customerIdStr, Some(cc))
1717+
(address, _) <- NewStyle.function.updateCustomerAddress(
1718+
customerAddressIdStr,
1719+
postedData.line_1, postedData.line_2, postedData.line_3,
1720+
postedData.city, postedData.county, postedData.state,
1721+
postedData.postcode, postedData.country_code,
1722+
postedData.state,
1723+
postedData.tags.mkString(","),
1724+
Some(cc))
1725+
} yield JSONFactory310.createAddress(address)
1726+
}
1727+
}
1728+
1729+
resourceDocs += ResourceDoc(
1730+
null, implementedInApiVersion, nameOf(updateCustomerAddress), "PUT",
1731+
"/banks/BANK_ID/customers/CUSTOMER_ID/addresses/CUSTOMER_ADDRESS_ID",
1732+
"Update the Address of a Customer",
1733+
s"""Update an Address of the Customer specified by CUSTOMER_ADDRESS_ID.
1734+
|
1735+
|${userAuthenticationMessage(true)}
1736+
|""",
1737+
postCustomerAddressJsonV310, customerAddressJsonV310,
1738+
List(AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError),
1739+
List(apiTagCustomer), Some(List(canCreateCustomer)),
1740+
http4sPartialFunction = Some(updateCustomerAddress))
1741+
17091742
// ─── createUserAuthContext (POST) ────────────────────────────────────────
17101743

17111744
val createUserAuthContext: HttpRoutes[IO] = HttpRoutes.of[IO] {
@@ -3581,6 +3614,7 @@ object Http4s310 {
35813614
.orElse(revokeConsent.run(req))
35823615
.orElse(createTaxResidence.run(req))
35833616
.orElse(createCustomerAddress.run(req))
3617+
.orElse(updateCustomerAddress.run(req))
35843618
.orElse(createUserAuthContext.run(req))
35853619
.orElse(createProductAttribute.run(req))
35863620
.orElse(createAccountWebhook.run(req))

0 commit comments

Comments
 (0)