From fd37ea7423bc2fd9e5323c830a7ffff5738e7194 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Fri, 22 May 2026 12:33:29 +0200 Subject: [PATCH 1/5] fix(v1.2.1): typo in counterparty More Info descriptions ("perpestive" / "counter party") MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Simon's review comment on the new PR: "counter party from the perpestive" → "counterparty from the perspective" Two occurrences in Http4s121.scala: - addCounterpartyMoreInfo (POST .../metadata/more_info) - updateCounterpartyMoreInfo (PUT .../metadata/more_info) Both were restored verbatim from APIMethods121.scala in 813f7a493, which also has the typos. Per the source-of-truth rule, the Lift file is not modified — the typo fix lives on the http4s side with a comment marking the intentional drift. --- obp-api/src/main/scala/code/api/v1_2_1/Http4s121.scala | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) 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 index bde8257772..3e7f990db1 100644 --- 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 @@ -1315,7 +1315,10 @@ object Http4s121 { 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 perpestive of the account e.g. My dentist", + "Add Counterparty More Info", + // Intentional drift from Lift's APIMethods121.scala source-of-truth: + // typo fixes "counter party" → "counterparty" and "perpestive" → "perspective". + "Add a description of the counterparty from the perspective of the account e.g. My dentist", moreInfoJSON, successMessage, List( AuthenticatedUserIsRequired, @@ -1350,7 +1353,10 @@ object Http4s121 { 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 from the perpestive of the account e.g. My dentist", + "Update Counterparty More Info", + // Intentional drift from Lift's APIMethods121.scala source-of-truth: + // typo fixes "counter party" → "counterparty" and "perpestive" → "perspective". + "Update the more info description of the counterparty from the perspective of the account e.g. My dentist", 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), From 28dafdc375538181fe7a895ad53299eadb34e290 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Mon, 25 May 2026 15:31:11 +0200 Subject: [PATCH 2/5] =?UTF-8?q?feat(bridge):=20audit=20endpoint=20for=20Li?= =?UTF-8?q?ft-bridge=20traffic=20=E2=80=94=20Http4sLiftBridgeTraffic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Data-driven prioritisation for the "remove Lift Web" milestone. Every request that reaches `Http4sLiftWebBridge.dispatch` is now tallied in an in-memory map of (method × path-bucket) → hit count. - First hit of a new bucket is logged at INFO: [BRIDGE-AUDIT] first hit: METHOD /path/bucket (original path: ...) Subsequent hits only increment the AtomicLong (no per-request log spam). - New admin route `GET /admin/lift-bridge-traffic` returns the full snapshot as JSON, sorted by hit count desc. Wired into Http4sApp.baseServices ahead of the per-version routes so the admin path can't be shadowed by a Lift fallback. - `POST /admin/lift-bridge-traffic/reset` clears the tally (handy before a baseline run). Path-bucket normalisation collapses opaque IDs so the map stays small: UUIDs → {uuid}, all-digits → {n}, any segment with a `.` (and not an API-version string) → {id}, ≥12-char mixed alphanumeric → {id}. API-version strings (v6.0.0, v1_2_1) are kept verbatim. Unit-tested in Http4sLiftBridgeTrafficTest (8 cases). The migration doc gains a "Bridge-traffic audit" subsection under "Migration leftovers" with an operator playbook for confirming bridge-readiness for retirement. Operator playbook: 1. POST /admin/lift-bridge-traffic/reset on a representative instance. 2. Run a normal traffic window (e.g. 24h + daily/weekly jobs). 3. GET /admin/lift-bridge-traffic — every bucket left is either a documented leftover (OIDC callback, etc.) or a new migration target. When the snapshot is empty for a full traffic window the bridge can be retired. --- LIFT_HTTP4S_MIGRATION.md | 25 ++++ .../code/api/util/http4s/Http4sApp.scala | 6 + .../api/util/http4s/Http4sLiftWebBridge.scala | 115 ++++++++++++++++++ .../http4s/Http4sLiftBridgeTrafficTest.scala | 59 +++++++++ 4 files changed, 205 insertions(+) create mode 100644 obp-api/src/test/scala/code/api/util/http4s/Http4sLiftBridgeTrafficTest.scala diff --git a/LIFT_HTTP4S_MIGRATION.md b/LIFT_HTTP4S_MIGRATION.md index d7d16dcccb..11d8fb637b 100644 --- a/LIFT_HTTP4S_MIGRATION.md +++ b/LIFT_HTTP4S_MIGRATION.md @@ -344,6 +344,31 @@ Track new leftovers here when later version files are migrated — the bridge-re Things still on Lift that block the `Http4sLiftWebBridge` from being removed. Use this section as the master TODO for the "remove Lift Web" milestone. +### Bridge-traffic audit (data-driven prioritisation) + +Every request that reaches `Http4sLiftWebBridge.dispatch` is tallied in-memory by `Http4sLiftBridgeTraffic` so we can see exactly what still needs migrating before the bridge can be retired. + +- **First hit of any (method, path-bucket)** is logged at INFO: `[BRIDGE-AUDIT] first hit: METHOD /path/bucket (original path: /actual/path)`. Subsequent hits only increment an `AtomicLong`. +- **Snapshot endpoint** — `GET /admin/lift-bridge-traffic` returns the full tally as JSON, sorted by hit count desc: + ```json + { + "unique_buckets": 3, + "total_hits": 142, + "entries": [ + {"bucket": "GET /auth/openid-connect/callback", "count": 99}, + {"bucket": "POST /obp/v6.0.0/banks/{id}/x", "count": 41}, + {"bucket": "GET /favicon.ico", "count": 2} + ] + } + ``` +- **Reset** — `POST /admin/lift-bridge-traffic/reset` clears the tally (handy for taking a baseline before a load test). +- **Path normalisation** collapses opaque IDs so the map doesn't fill up: UUIDs → `{uuid}`, all-digits → `{n}`, anything with a dot or 12+-char alnum mixed → `{id}`. API-version strings (`v6.0.0`, `v1_2_1`) are kept verbatim. Unit-tested in `Http4sLiftBridgeTrafficTest`. + +Operator playbook for "is the bridge ready to retire?": +1. Reset the tally on a representative instance. +2. Let it run through a normal traffic window (e.g. 24h + the daily/weekly jobs). +3. Query `/admin/lift-bridge-traffic` — every bucket left is either a known leftover (see tables below) or a new migration target. + ### Auth stack — every handler is its own `RestHelper` | Handler | File | Routes | Status | 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 24bddefe92..14092720a8 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 @@ -106,6 +106,12 @@ object Http4sApp { corsHandler.run(req) .orElse(AppsPage.routes.run(req)) .orElse(StatusPage.routes.run(req)) + // Bridge-retirement audit endpoint — exposes the in-memory tally of + // requests that have fallen through to Http4sLiftWebBridge so we can + // see exactly what still needs migrating before the bridge can be + // removed. Placed before the per-version routes so the admin path + // can't be shadowed by a version-prefixed handler. + .orElse(Http4sLiftBridgeTraffic.routes.run(req)) .orElse(Http4sResourceDocs.routes.run(req)) .orElse(v510Routes.run(req)) .orElse(v600Routes.run(req)) diff --git a/obp-api/src/main/scala/code/api/util/http4s/Http4sLiftWebBridge.scala b/obp-api/src/main/scala/code/api/util/http4s/Http4sLiftWebBridge.scala index 989ff3b57b..ad0cdd122f 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/Http4sLiftWebBridge.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/Http4sLiftWebBridge.scala @@ -11,15 +11,126 @@ import net.liftweb.common._ import net.liftweb.http._ import net.liftweb.http.provider._ import org.http4s._ +import org.http4s.dsl.io._ +import org.http4s.headers.`Content-Type` import org.typelevel.ci.CIString import java.io.{ByteArrayInputStream, ByteArrayOutputStream, InputStream} import java.time.format.DateTimeFormatter import java.time.{ZoneOffset, ZonedDateTime} import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicLong import java.util.{Locale, UUID} import scala.collection.JavaConverters._ +/** + * In-memory tally of which requests reach the Lift fallback bridge. + * + * Goal: data-driven prioritisation of remaining migration work. Every request + * that lands here means no http4s handler claimed it. Once an audit run shows + * which (method, path-bucket) pairs still hit the bridge, those buckets become + * the next migration targets. When the map is empty for a representative + * traffic window, the bridge can be retired. + * + * Path-bucket normalisation collapses common ID segments (long opaque tokens, + * UUIDs, numbers, version segments) so we don't fill the map with one entry + * per real-world ID value. The exact form of each bucket is logged the first + * time it is observed. + */ +object Http4sLiftBridgeTraffic extends MdcLoggable { + private val counts: ConcurrentHashMap[String, AtomicLong] = new ConcurrentHashMap() + private val UUID_RE = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$".r + private val VERSION_RE = "^v\\d+(_\\d+){2}$|^v\\d+(\\.\\d+){2}$".r + private val NUMERIC_RE = "^\\d+$".r + + /** Buckets that look like opaque IDs: + * 1. Plain UUID → {uuid} + * 2. All-digits → {n} + * 3. Contains a `.` (e.g. `gh.29.uk`, `some.bank.io`, `127.0.0.1`) → {id} + * (API-version strings like `v6.0.0` are caught earlier and kept.) + * 4. Long-ish (≥ 12 chars) AND contains both letters & digits → {id} + * + * Keeps short path keywords like `openid-connect`, `callback-1`, + * `BANK_ID`, `accounts`, `views`. + */ + private def bucketSegment(seg: String): String = { + if (seg.isEmpty) return seg + if (VERSION_RE.findFirstIn(seg).isDefined) return seg + if (UUID_RE.findFirstIn(seg).isDefined) return "{uuid}" + if (NUMERIC_RE.findFirstIn(seg).isDefined) return "{n}" + if (seg.contains('.')) return "{id}" + val hasDigit = seg.exists(_.isDigit) + val hasLetter = seg.exists(_.isLetter) + if (seg.length >= 12 && hasDigit && hasLetter) return "{id}" + seg + } + + def bucket(path: String): String = + "/" + path.split('/').filter(_.nonEmpty).map(bucketSegment).mkString("/") + + def observe(method: String, path: String): Unit = { + val key = s"$method ${bucket(path)}" + val prev = counts.putIfAbsent(key, new AtomicLong(1L)) + if (prev == null) { + logger.info(s"[BRIDGE-AUDIT] first hit: $key (original path: $path)") + } else { + prev.incrementAndGet() + } + } + + /** Snapshot of (bucket → hit-count). For use by an admin endpoint or tests. */ + def snapshot(): Map[String, Long] = + counts.asScala.toMap.map { case (k, v) => k -> v.get() } + + /** Wipe the in-memory tally. Mostly for tests / a manual reset after a baseline. */ + def reset(): Unit = counts.clear() + + /** Admin route: `GET /admin/lift-bridge-traffic` returns the snapshot as JSON, + * sorted by hit count desc. Intended to be wired into Http4sApp's + * baseServices ahead of the Lift fallback so it can be queried at runtime. + */ + val routes: HttpRoutes[IO] = HttpRoutes.of[IO] { + case GET -> Root / "admin" / "lift-bridge-traffic" => + val snap = snapshot().toList.sortBy(-_._2) + val totalUnique = snap.length + val totalHits = snap.map(_._2).sum + val entriesJson = snap.map { case (key, n) => + s""" {"bucket": ${jsonString(key)}, "count": $n}""" + }.mkString(",\n") + val body = + s"""{ + | "unique_buckets": $totalUnique, + | "total_hits": $totalHits, + | "entries": [ + |$entriesJson + | ] + |} + |""".stripMargin + IO.pure(Response[IO]() + .withEntity(body.getBytes("UTF-8")) + .withHeaders(Headers(`Content-Type`(MediaType.application.json, Charset.`UTF-8`)))) + + case POST -> Root / "admin" / "lift-bridge-traffic" / "reset" => + reset() + IO.pure(Response[IO]() + .withEntity("""{"status":"reset"}""".getBytes("UTF-8")) + .withHeaders(Headers(`Content-Type`(MediaType.application.json, Charset.`UTF-8`)))) + } + + private def jsonString(s: String): String = { + val esc = s.flatMap { + case '"' => "\\\"" + case '\\' => "\\\\" + case '\n' => "\\n" + case '\r' => "\\r" + case '\t' => "\\t" + case c if c < 0x20 => f"\\u${c.toInt}%04x" + case c => c.toString + } + s""""$esc"""" + } +} + object Http4sLiftWebBridge extends MdcLoggable { type HttpF[A] = OptionT[IO, A] @@ -67,6 +178,10 @@ object Http4sLiftWebBridge extends MdcLoggable { val (effectiveReq, servedVersion) = rewriteIfV700(req) val uri = req.uri.renderString val method = req.method.name + // Audit: tally every request that reaches the Lift fallback. The first time + // a (method, path-bucket) is observed an INFO log is emitted; subsequent + // hits only increment the in-memory counter. See Http4sLiftBridgeTraffic. + Http4sLiftBridgeTraffic.observe(method, req.uri.path.renderString) logger.debug(s"Http4sLiftBridge dispatching: $method $uri, S.inStatefulScope_? = ${S.inStatefulScope_?}") val result = for { bodyBytes <- effectiveReq.body.compile.to(Array) diff --git a/obp-api/src/test/scala/code/api/util/http4s/Http4sLiftBridgeTrafficTest.scala b/obp-api/src/test/scala/code/api/util/http4s/Http4sLiftBridgeTrafficTest.scala new file mode 100644 index 0000000000..1d14eb0629 --- /dev/null +++ b/obp-api/src/test/scala/code/api/util/http4s/Http4sLiftBridgeTrafficTest.scala @@ -0,0 +1,59 @@ +package code.api.util.http4s + +import org.scalatest.{FlatSpec, Matchers} + +class Http4sLiftBridgeTrafficTest extends FlatSpec with Matchers { + + "bucket" should "keep API version segments verbatim" in { + Http4sLiftBridgeTraffic.bucket("/obp/v6.0.0/banks") shouldBe "/obp/v6.0.0/banks" + Http4sLiftBridgeTraffic.bucket("/obp/v1_2_1/banks") shouldBe "/obp/v1_2_1/banks" + } + + it should "collapse UUIDs" in { + Http4sLiftBridgeTraffic.bucket("/obp/v6.0.0/consents/9ca8a7e4-6d02-40e3-a129-0b2bf89de9b1") shouldBe + "/obp/v6.0.0/consents/{uuid}" + } + + it should "collapse purely numeric segments" in { + Http4sLiftBridgeTraffic.bucket("/obp/v6.0.0/transactions/12345") shouldBe + "/obp/v6.0.0/transactions/{n}" + } + + it should "collapse long opaque token-like segments (with a digit/dot/dash/underscore)" in { + Http4sLiftBridgeTraffic.bucket("/obp/v6.0.0/banks/gh.29.uk/accounts") shouldBe + "/obp/v6.0.0/banks/{id}/accounts" + Http4sLiftBridgeTraffic.bucket("/obp/v6.0.0/accounts/8ca8a7e4_6d02_40e3_a129_0b2bf89de9f0/x") shouldBe + "/obp/v6.0.0/accounts/{id}/x" + } + + it should "leave short literal segments alone" in { + Http4sLiftBridgeTraffic.bucket("/obp/v6.0.0/banks/BANK_ID/accounts/views") shouldBe + "/obp/v6.0.0/banks/BANK_ID/accounts/views" + } + + it should "normalise the OIDC callback path the way operators expect" in { + Http4sLiftBridgeTraffic.bucket("/auth/openid-connect/callback") shouldBe + "/auth/openid-connect/callback" + Http4sLiftBridgeTraffic.bucket("/auth/openid-connect/callback-1") shouldBe + "/auth/openid-connect/callback-1" + } + + it should "handle the root path" in { + Http4sLiftBridgeTraffic.bucket("/") shouldBe "/" + Http4sLiftBridgeTraffic.bucket("") shouldBe "/" + } + + "observe" should "increment on repeated hits and snapshot the totals" in { + Http4sLiftBridgeTraffic.reset() + Http4sLiftBridgeTraffic.observe("GET", "/obp/v6.0.0/banks/gh.29.uk") + Http4sLiftBridgeTraffic.observe("GET", "/obp/v6.0.0/banks/gh.29.uk") + Http4sLiftBridgeTraffic.observe("GET", "/obp/v6.0.0/banks/some.other.bank") + Http4sLiftBridgeTraffic.observe("POST", "/auth/openid-connect/callback") + val snap = Http4sLiftBridgeTraffic.snapshot() + snap("GET /obp/v6.0.0/banks/{id}") shouldBe 3L + snap("POST /auth/openid-connect/callback") shouldBe 1L + snap.size shouldBe 2 + Http4sLiftBridgeTraffic.reset() + Http4sLiftBridgeTraffic.snapshot() shouldBe empty + } +} From d918ae3c8f6dce4b678226359ca3552f4d14a97f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Mon, 25 May 2026 16:24:13 +0200 Subject: [PATCH 3/5] =?UTF-8?q?feat(bridge):=20record=20response=20status?= =?UTF-8?q?=20in=20Http4sLiftBridgeTraffic=20=E2=80=94=20split=20real=20wo?= =?UTF-8?q?rk=20from=20404=20probes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First-pass audit (commit 28dafdc37) couldn't tell apart "Lift actually handled this request" from "Lift returned 404 because nothing matched" — both bumped the same counter. Shard 1's first real CI snapshot caught a v4.0.0 `DELETE /banks/{id}/accounts` bucket that turned out to be `DeleteBankCascadeTest` deliberately probing for 404 after a cascade delete; not a missing migration. Extend the audit to key on `(method, bucket, status)`: - `observe(method, path, status)` instead of `observe(method, path)`. Called from `dispatch` AFTER the response is built (or 500 in the error path) so the actual outcome is captured. - First-hit INFO log now includes the status: [BRIDGE-AUDIT] first hit: METHOD /path/bucket STATUS (original path: ...) - `GET /admin/lift-bridge-traffic` now returns two groups: "real_work" — non-404 entries. Each one is a (method, URL pattern, status) that's actually doing something on Lift. These are the migration targets. "not_found" — 404 entries. Test probes, stale callers, dead links. Informational, not blocking bridge removal. Each group is sorted by hit count desc. Summary block carries the unique-bucket and total-hit counts per group. - Unit tests gain a case that the same (method, bucket) keys separately when the status differs — 200 and 404 don't collide. Migration doc updated with the new JSON shape, an explicit "empty real_work == ready to retire" rule, and the first concrete audit findings from shard 1 (20 dynamic-entity/endpoint buckets = a real workstream; 2 v4.0.0 buckets = test-probe 404 noise). --- LIFT_HTTP4S_MIGRATION.md | 35 ++++++--- .../api/util/http4s/Http4sLiftWebBridge.scala | 78 ++++++++++++++----- .../http4s/Http4sLiftBridgeTrafficTest.scala | 25 ++++-- 3 files changed, 104 insertions(+), 34 deletions(-) diff --git a/LIFT_HTTP4S_MIGRATION.md b/LIFT_HTTP4S_MIGRATION.md index 11d8fb637b..c080e38008 100644 --- a/LIFT_HTTP4S_MIGRATION.md +++ b/LIFT_HTTP4S_MIGRATION.md @@ -348,26 +348,41 @@ Things still on Lift that block the `Http4sLiftWebBridge` from being removed. Us Every request that reaches `Http4sLiftWebBridge.dispatch` is tallied in-memory by `Http4sLiftBridgeTraffic` so we can see exactly what still needs migrating before the bridge can be retired. -- **First hit of any (method, path-bucket)** is logged at INFO: `[BRIDGE-AUDIT] first hit: METHOD /path/bucket (original path: /actual/path)`. Subsequent hits only increment an `AtomicLong`. -- **Snapshot endpoint** — `GET /admin/lift-bridge-traffic` returns the full tally as JSON, sorted by hit count desc: +- **First hit of any (method, path-bucket, status)** triple is logged at INFO: `[BRIDGE-AUDIT] first hit: METHOD /path/bucket STATUS (original path: /actual/path)`. Subsequent hits only increment an `AtomicLong`. +- **Snapshot endpoint** — `GET /admin/lift-bridge-traffic` returns the tally grouped into `real_work` (non-404) and `not_found` (404). 404 entries are typically test-probe traffic / stale URLs / dead links and are **not** migration work. Each group is sorted by hit count desc: ```json { - "unique_buckets": 3, - "total_hits": 142, - "entries": [ - {"bucket": "GET /auth/openid-connect/callback", "count": 99}, - {"bucket": "POST /obp/v6.0.0/banks/{id}/x", "count": 41}, - {"bucket": "GET /favicon.ico", "count": 2} + "unique_buckets": 5, + "total_hits": 248, + "summary": { + "real_work": {"unique_buckets": 3, "total_hits": 230}, + "not_found": {"unique_buckets": 2, "total_hits": 18} + }, + "real_work": [ + {"method": "GET", "bucket": "/auth/openid-connect/callback", "status": 200, "count": 99}, + {"method": "POST", "bucket": "/obp/dynamic-entity/FooBar", "status": 201, "count": 88}, + {"method": "GET", "bucket": "/obp/dynamic-entity/FooBar", "status": 200, "count": 43} + ], + "not_found": [ + {"method": "DELETE", "bucket": "/obp/v4.0.0/banks/{id}/accounts", "status": 404, "count": 16}, + {"method": "GET", "bucket": "/favicon.ico", "status": 404, "count": 2} ] } ``` - **Reset** — `POST /admin/lift-bridge-traffic/reset` clears the tally (handy for taking a baseline before a load test). -- **Path normalisation** collapses opaque IDs so the map doesn't fill up: UUIDs → `{uuid}`, all-digits → `{n}`, anything with a dot or 12+-char alnum mixed → `{id}`. API-version strings (`v6.0.0`, `v1_2_1`) are kept verbatim. Unit-tested in `Http4sLiftBridgeTrafficTest`. +- **Path normalisation** collapses opaque IDs so the map doesn't fill up: UUIDs → `{uuid}`, all-digits → `{n}`, anything with a dot or 12+-char alnum mixed → `{id}`. API-version strings (`v6.0.0`, `v1_2_1`) are kept verbatim. Unit-tested in `Http4sLiftBridgeTrafficTest` (9 cases). Operator playbook for "is the bridge ready to retire?": 1. Reset the tally on a representative instance. 2. Let it run through a normal traffic window (e.g. 24h + the daily/weekly jobs). -3. Query `/admin/lift-bridge-traffic` — every bucket left is either a known leftover (see tables below) or a new migration target. +3. Query `/admin/lift-bridge-traffic`: + - **`real_work[]` empty** → bridge can be retired (modulo any documented leftovers). + - **`real_work[]` non-empty** → those buckets are concrete migration targets. Each entry is a (method, URL pattern) that some live caller still needs Lift to serve. + - **`not_found[]`** is informational — useful for spotting stale callers or unused URL patterns, but not blocking bridge removal. + +First real audit data (shard 1 CI run on 2026-05-25, 515 tests): +- 20 `real_work` entries — all the `/obp/dynamic-entity/...` and `/obp/dynamic-endpoint/...` URLs. These are runtime-generated by Lift's dynamic dispatch when an admin creates a dynamic entity / endpoint at runtime; porting them is a workstream of its own (not endpoint-by-endpoint). +- 2 `not_found` entries — `DELETE /obp/v4.0.0/banks/{id}/accounts`, asserted as 404 by `DeleteBankCascadeTest` to verify the cascade actually wiped the bank. ### Auth stack — every handler is its own `RestHelper` diff --git a/obp-api/src/main/scala/code/api/util/http4s/Http4sLiftWebBridge.scala b/obp-api/src/main/scala/code/api/util/http4s/Http4sLiftWebBridge.scala index ad0cdd122f..2abdcbaf08 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/Http4sLiftWebBridge.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/Http4sLiftWebBridge.scala @@ -68,8 +68,14 @@ object Http4sLiftBridgeTraffic extends MdcLoggable { def bucket(path: String): String = "/" + path.split('/').filter(_.nonEmpty).map(bucketSegment).mkString("/") - def observe(method: String, path: String): Unit = { - val key = s"$method ${bucket(path)}" + /** Record one bridge dispatch with its outcome. Key is `METHOD BUCKET STATUS` + * so the snapshot can distinguish: + * - real Lift handler work (2xx/3xx/5xx) — actual migration targets + * - 404 fall-throughs — test code probing for non-existent endpoints, or + * stale callers; not migration work + */ + def observe(method: String, path: String, status: Int): Unit = { + val key = s"$method ${bucket(path)} $status" val prev = counts.putIfAbsent(key, new AtomicLong(1L)) if (prev == null) { logger.info(s"[BRIDGE-AUDIT] first hit: $key (original path: $path)") @@ -78,31 +84,65 @@ object Http4sLiftBridgeTraffic extends MdcLoggable { } } - /** Snapshot of (bucket → hit-count). For use by an admin endpoint or tests. */ + /** Snapshot of (`METHOD BUCKET STATUS` → hit-count). */ def snapshot(): Map[String, Long] = counts.asScala.toMap.map { case (k, v) => k -> v.get() } /** Wipe the in-memory tally. Mostly for tests / a manual reset after a baseline. */ def reset(): Unit = counts.clear() + /** Split the key string `METHOD BUCKET STATUS` back into its parts. */ + private def splitKey(key: String): (String, String, Int) = { + // method is everything before the first space; status is the trailing int; + // bucket is the middle. + val firstSp = key.indexOf(' ') + val lastSp = key.lastIndexOf(' ') + if (firstSp <= 0 || lastSp <= firstSp) ("?", key, 0) + else { + val method = key.substring(0, firstSp) + val statusStr = key.substring(lastSp + 1) + val status = try statusStr.toInt catch { case _: Throwable => 0 } + val bucketStr = key.substring(firstSp + 1, lastSp) + (method, bucketStr, status) + } + } + /** Admin route: `GET /admin/lift-bridge-traffic` returns the snapshot as JSON, - * sorted by hit count desc. Intended to be wired into Http4sApp's - * baseServices ahead of the Lift fallback so it can be queried at runtime. + * grouped by `real_work` (non-404) vs `not_found` (404s — test probes / + * stale URLs / dead links). Entries inside each group are sorted by hit + * count desc. + * + * Wired into Http4sApp's baseServices ahead of the per-version routes so + * the admin path can't be shadowed. */ val routes: HttpRoutes[IO] = HttpRoutes.of[IO] { case GET -> Root / "admin" / "lift-bridge-traffic" => - val snap = snapshot().toList.sortBy(-_._2) - val totalUnique = snap.length - val totalHits = snap.map(_._2).sum - val entriesJson = snap.map { case (key, n) => - s""" {"bucket": ${jsonString(key)}, "count": $n}""" - }.mkString(",\n") + val rows = snapshot().toList.map { case (k, n) => + val (m, b, s) = splitKey(k) + (m, b, s, n) + } + val (notFound, realWork) = rows.partition { case (_, _, s, _) => s == 404 } + def renderGroup(group: List[(String, String, Int, Long)]): String = + group.sortBy { case (_, _, _, n) => -n }.map { case (m, b, s, n) => + s""" {"method": ${jsonString(m)}, "bucket": ${jsonString(b)}, "status": $s, "count": $n}""" + }.mkString(",\n") + val totalUnique = rows.length + val totalHits = rows.map(_._4).sum + val realHits = realWork.map(_._4).sum + val notFoundHits = notFound.map(_._4).sum val body = s"""{ | "unique_buckets": $totalUnique, | "total_hits": $totalHits, - | "entries": [ - |$entriesJson + | "summary": { + | "real_work": {"unique_buckets": ${realWork.length}, "total_hits": $realHits}, + | "not_found": {"unique_buckets": ${notFound.length}, "total_hits": $notFoundHits} + | }, + | "real_work": [ + |${renderGroup(realWork)} + | ], + | "not_found": [ + |${renderGroup(notFound)} | ] |} |""".stripMargin @@ -178,10 +218,7 @@ object Http4sLiftWebBridge extends MdcLoggable { val (effectiveReq, servedVersion) = rewriteIfV700(req) val uri = req.uri.renderString val method = req.method.name - // Audit: tally every request that reaches the Lift fallback. The first time - // a (method, path-bucket) is observed an INFO log is emitted; subsequent - // hits only increment the in-memory counter. See Http4sLiftBridgeTraffic. - Http4sLiftBridgeTraffic.observe(method, req.uri.path.renderString) + val originalPath = req.uri.path.renderString logger.debug(s"Http4sLiftBridge dispatching: $method $uri, S.inStatefulScope_? = ${S.inStatefulScope_?}") val result = for { bodyBytes <- effectiveReq.body.compile.to(Array) @@ -214,11 +251,16 @@ object Http4sLiftWebBridge extends MdcLoggable { logger.debug(s"[BRIDGE] Http4sLiftBridge completed: $method $uri -> ${http4sResponse.status.code}") logger.debug(s"Http4sLiftBridge completed: $method $uri -> ${http4sResponse.status.code}") val baseResp = ensureStandardHeaders(req, http4sResponse) - servedVersion.fold(baseResp)(v => baseResp.putHeaders(Header.Raw(CIString("X-OBP-Version-Served"), v))) + val finalResp = servedVersion.fold(baseResp)(v => baseResp.putHeaders(Header.Raw(CIString("X-OBP-Version-Served"), v))) + // Tally with the response status now known. 404s tell us "test probes / + // stale URLs"; non-404s are real Lift work still owned by the bridge. + Http4sLiftBridgeTraffic.observe(method, originalPath, finalResp.status.code) + finalResp } result.handleErrorWith { e => logger.error(s"[BRIDGE] Uncaught exception in dispatch: $method $uri - ${e.getMessage}", e) val errorBody = s"""{"error":"Internal Server Error","message":"${e.getMessage}"}""" + Http4sLiftBridgeTraffic.observe(method, originalPath, 500) IO.pure(ensureStandardHeaders(req, Response[IO]( status = org.http4s.Status.InternalServerError ).withEntity(errorBody.getBytes("UTF-8")) diff --git a/obp-api/src/test/scala/code/api/util/http4s/Http4sLiftBridgeTrafficTest.scala b/obp-api/src/test/scala/code/api/util/http4s/Http4sLiftBridgeTrafficTest.scala index 1d14eb0629..c88583c493 100644 --- a/obp-api/src/test/scala/code/api/util/http4s/Http4sLiftBridgeTrafficTest.scala +++ b/obp-api/src/test/scala/code/api/util/http4s/Http4sLiftBridgeTrafficTest.scala @@ -45,15 +45,28 @@ class Http4sLiftBridgeTrafficTest extends FlatSpec with Matchers { "observe" should "increment on repeated hits and snapshot the totals" in { Http4sLiftBridgeTraffic.reset() - Http4sLiftBridgeTraffic.observe("GET", "/obp/v6.0.0/banks/gh.29.uk") - Http4sLiftBridgeTraffic.observe("GET", "/obp/v6.0.0/banks/gh.29.uk") - Http4sLiftBridgeTraffic.observe("GET", "/obp/v6.0.0/banks/some.other.bank") - Http4sLiftBridgeTraffic.observe("POST", "/auth/openid-connect/callback") + Http4sLiftBridgeTraffic.observe("GET", "/obp/v6.0.0/banks/gh.29.uk", 200) + Http4sLiftBridgeTraffic.observe("GET", "/obp/v6.0.0/banks/gh.29.uk", 200) + Http4sLiftBridgeTraffic.observe("GET", "/obp/v6.0.0/banks/some.other.bank", 200) + Http4sLiftBridgeTraffic.observe("POST", "/auth/openid-connect/callback", 200) val snap = Http4sLiftBridgeTraffic.snapshot() - snap("GET /obp/v6.0.0/banks/{id}") shouldBe 3L - snap("POST /auth/openid-connect/callback") shouldBe 1L + snap("GET /obp/v6.0.0/banks/{id} 200") shouldBe 3L + snap("POST /auth/openid-connect/callback 200") shouldBe 1L snap.size shouldBe 2 Http4sLiftBridgeTraffic.reset() Http4sLiftBridgeTraffic.snapshot() shouldBe empty } + + it should "key separately on status — 200 and 404 don't collide" in { + Http4sLiftBridgeTraffic.reset() + // Same method + bucket but different statuses → two separate entries. + // This is what surfaces test-probe 404s vs real Lift work in the audit. + Http4sLiftBridgeTraffic.observe("DELETE", "/obp/v4.0.0/banks/gh.29.uk/accounts", 200) + Http4sLiftBridgeTraffic.observe("DELETE", "/obp/v4.0.0/banks/gh.29.uk/accounts", 404) + Http4sLiftBridgeTraffic.observe("DELETE", "/obp/v4.0.0/banks/de.bank.io/accounts", 404) + val snap = Http4sLiftBridgeTraffic.snapshot() + snap("DELETE /obp/v4.0.0/banks/{id}/accounts 200") shouldBe 1L + snap("DELETE /obp/v4.0.0/banks/{id}/accounts 404") shouldBe 2L + snap.size shouldBe 2 + } } From 12b305a9b9ff8e46cd9bb2471889357ba0e3daff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Mon, 25 May 2026 18:25:50 +0200 Subject: [PATCH 4/5] feat(resource-docs): serve /openapi and /openapi.yaml for every URL prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the `prefix == "v6.0.0"` guard that Http4sResourceDocs inherited from the original Lift setup (only ResourceDocs600 registered the OpenAPI 3.1 routes). The handlers depend solely on the requested-API- version path segment (`requestedApiVersionString`), not on the URL prefix — `implForPrefix(prefix)` already falls back to `ImplDefault` for non-v6 prefixes, and `isVersion4OrHigher` is hardcoded `true` inside the handlers because the OpenAPI converter always consumes the v4-shape input. The prefix guard added no value, just surprised callers hitting `/obp/v5.1.0/resource-docs/v5.1.0/openapi` with a Lift 404 fall-through. After this change: GET /obp/{ANY_PREFIX}/resource-docs/{API_VERSION}/openapi → 200 JSON GET /obp/{ANY_PREFIX}/resource-docs/{API_VERSION}/openapi.yaml → 200 YAML Tests: SwaggerDocsTest gains two scenarios that hit /openapi and /openapi.yaml under the v5.1.0 URL prefix and assert 200. The existing v6.0.0-prefix scenarios continue to pass (12 total now). Step #1 from the as-is overview's to-do list. Closes a small but real bridge fall-through that the audit endpoint had surfaced as a candidate; reduces the surface area Lift still has to handle. --- .../api/util/http4s/Http4sResourceDocs.scala | 18 ++++++++++++----- .../ResourceDocs1_4_0/SwaggerDocsTest.scala | 20 +++++++++++++++++++ 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/obp-api/src/main/scala/code/api/util/http4s/Http4sResourceDocs.scala b/obp-api/src/main/scala/code/api/util/http4s/Http4sResourceDocs.scala index 8bde7e3dfd..1372b03613 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/Http4sResourceDocs.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/Http4sResourceDocs.scala @@ -690,13 +690,21 @@ object Http4sResourceDocs extends MdcLoggable { case req @ GET -> Root / "obp" / prefix / "resource-docs" / requestedApiVersionString / "swagger" => handleGetResourceDocsSwagger(req, prefix, requestedApiVersionString) - // OpenAPI 3.1 JSON was only registered by ResourceDocs600 (v6.0.0 prefix); guard the - // route so requests under other prefixes still 404-fall-through, matching old behaviour. - case req @ GET -> Root / "obp" / prefix / "resource-docs" / requestedApiVersionString / "openapi" if prefix == "v6.0.0" => + // OpenAPI 3.1 JSON and YAML — served for every URL prefix. + // + // Historically these routes were only registered by ResourceDocs600 (v6.0.0 + // prefix). With the centralised service, generating the OpenAPI spec only + // depends on the requested-API-version path segment (`requestedApiVersionString`), + // not on the URL prefix the client used to reach the service — so guarding + // on a single prefix added no value and surprised callers that hit e.g. + // `/obp/v5.1.0/resource-docs/v5.1.0/openapi` and got a Lift 404 fall-through. + // The handlers use `implForPrefix(prefix)` which falls back to `ImplDefault` + // for non-v6 prefixes; `isVersion4OrHigher` is hardcoded `true` inside the + // handlers because the OpenAPI converter always consumes the v4-shape input. + case req @ GET -> Root / "obp" / prefix / "resource-docs" / requestedApiVersionString / "openapi" => handleGetResourceDocsOpenAPI31(req, prefix, requestedApiVersionString) - // openapi.yaml was likewise only on v6.0.0. - case req @ GET -> Root / "obp" / prefix / "resource-docs" / requestedApiVersionString / "openapi.yaml" if prefix == "v6.0.0" => + case req @ GET -> Root / "obp" / prefix / "resource-docs" / requestedApiVersionString / "openapi.yaml" => handleGetResourceDocsOpenAPI31Yaml(req, prefix, requestedApiVersionString) case req @ GET -> Root / "obp" / prefix / "banks" / bankIdStr / "resource-docs" / requestedApiVersionString / "obp" => diff --git a/obp-api/src/test/scala/code/api/ResourceDocs1_4_0/SwaggerDocsTest.scala b/obp-api/src/test/scala/code/api/ResourceDocs1_4_0/SwaggerDocsTest.scala index 1412bf74c3..d015851682 100644 --- a/obp-api/src/test/scala/code/api/ResourceDocs1_4_0/SwaggerDocsTest.scala +++ b/obp-api/src/test/scala/code/api/ResourceDocs1_4_0/SwaggerDocsTest.scala @@ -230,6 +230,26 @@ class SwaggerDocsTest extends ResourceDocsV140ServerSetup with PropsReset with D responseGetOpenAPIYAML.body.toString.trim.nonEmpty should be (true) } + // The OpenAPI routes used to be gated on `prefix == "v6.0.0"` by the + // centralised Http4sResourceDocs service — replicating the historical Lift + // setup where only ResourceDocs600 registered them. The gate was removed + // because the spec content only depends on the API-version path segment, + // not on the URL prefix. Verify a non-v6 prefix is now served. + scenario("OpenAPI JSON - served for non-v6.0.0 URL prefix (v5.1.0)", ApiEndpoint1, VersionOfApi) { + setPropsValues("resource_docs_requires_role" -> "false") + val req = (ResourceDocsV5_1Request / "resource-docs" / "v5.1.0" / "openapi").GET < "false") + val req = (ResourceDocsV5_1Request / "resource-docs" / "v5.1.0" / "openapi.yaml").GET < Date: Mon, 25 May 2026 19:45:36 +0200 Subject: [PATCH 5/5] docs(migration): mark v3.1.0 leftovers as off-bridge in per-version leftovers table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both `getMessageDocsSwagger` and `getObpConnectorLoopback` are listed in the per-version-leftovers table as if they still hit the Lift bridge, but they don't. Audit confirms zero bridge hits for either. - `getObpConnectorLoopback` — native http4s route in `Http4s310.scala` (~line 4875) that returns 400 NotImplemented, mirroring Lift's deprecated-stub behaviour. No bridge dispatch. - `getMessageDocsSwagger` — real handler is `Http4sResourceDocs.handleGetMessageDocsSwagger`, matched by the wildcard `/obp/*/message-docs/{CONNECTOR}/swagger2.0` route before any per-version service. `Http4s310.scala` keeps a one-line `HttpRoutes.empty` stub + a `ResourceDoc` entry so `nameOf(getMessageDocsSwagger)` compiles in test files and the `FrozenClassTest` STABLE-API-surface guard keeps passing. The doc table previously said "kept until the bridge-removal PR retires it" / "Both retire together in the bridge-removal PR" — which was the original plan, but the work has actually happened in the meantime. Update the rows to reflect reality + cross out the matching item in the Resource-docs workstream steps and the bridge-leftovers summary. No code change. Both stubs remain deletable in the future bridge-removal PR alongside the frozen-snapshot refresh. --- LIFT_HTTP4S_MIGRATION.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/LIFT_HTTP4S_MIGRATION.md b/LIFT_HTTP4S_MIGRATION.md index c080e38008..1e21552827 100644 --- a/LIFT_HTTP4S_MIGRATION.md +++ b/LIFT_HTTP4S_MIGRATION.md @@ -118,7 +118,7 @@ Bottom-up — each version depends on the one below it being done. | 5 | `APIMethods210` | 28 | **Done** — `Http4s210.scala`: 25 own endpoints + path-rewriting bridge to `Http4s200`; all 79 v2.1.0 tests pass | | 6 | `APIMethods220` | 19 | **Done** — `Http4s220.scala`: 18 own endpoints + path-rewriting bridge to `Http4s210`; all 27 v2.2.0 tests pass | | 7 | `APIMethods300` | 47 | **Done** — `Http4s300.scala`: 47 own endpoints + path-rewriting bridge to `Http4s220`; all 86 v3.0.0 tests pass | -| 8 | `APIMethods310` | 102 | **Done** — `Http4s310.scala` has all 100 functional endpoints (42 GET, 10 DELETE, 19 POST, 25 PUT, 1 GET-shaped revoke, 3 SCA aliases) + path-rewriting bridge to `Http4s300`; 181 v3.1.0 tests pass. `getMessageDocsSwagger` is shadowed in production by `Http4sResourceDocs.routes` but the Lift `lazy val` is intentionally kept — its deletion is caught by `FrozenClassTest` as a STABLE-API surface reduction. Both `getMessageDocsSwagger` and `getObpConnectorLoopback` retire together in the bridge-removal PR. | +| 8 | `APIMethods310` | 102 | **Done** — `Http4s310.scala` has all 100 functional endpoints (42 GET, 10 DELETE, 19 POST, 25 PUT, 1 GET-shaped revoke, 3 SCA aliases) + path-rewriting bridge to `Http4s300`; 181 v3.1.0 tests pass. The Lift `APIMethods310` trait is now a stub (live code commented out). `getObpConnectorLoopback` has a real native http4s route (always returns 400 NotImplemented). `getMessageDocsSwagger` has a `HttpRoutes.empty` stub in `Http4s310` — actual routing is owned by `Http4sResourceDocs`, the stub exists only so `nameOf(getMessageDocsSwagger)` compiles for `FrozenClassTest`. Both stubs deletable in the bridge-removal PR alongside a frozen-snapshot refresh. | | 9 | `APIMethods400` | 258 | **Done — 258 / 258 (100%)**. `Http4s400.scala` covers all 253 unique handlers (`lazy val NAME: HttpRoutes[IO]`) plus 8 ResourceDoc aliases for the transaction-request-type variants (ACCOUNT, ACCOUNT_OTP, SEPA, COUNTERPARTY, REFUND, FREE_FORM, SIMPLE, AGENT_CASH_WITHDRAWAL — handled by the shared `createTransactionRequest` wildcard handler; the `literalAllCapsSegments` set in `Http4sSupport.scala` dispatches the matcher to the per-type doc for swagger purposes). Adopts the **lazy val + helper-def init pattern** (Batches 1–19) introduced in v6 to dodge the JVM 64KB `` method-size limit. **Bridge-cascade hijack** historically threatened v4's overrides; resolved by migrating all 35/35 v4-over-older URL+verb overrides. | | 10 | `APIMethods500` | 10 | **Done** — `Http4s500.scala` (all v5.0.0 originals migrated) | | 11 | `APIMethods510` | 111 | **Done** — `Http4s510.scala`. v5.1.0's `createConsent` Lift handler is exposed in Http4s510 under the alias name `createConsentImplicit` (a single handler with `if scaMethod == "EMAIL" \|\| scaMethod == "SMS" \|\| scaMethod == "IMPLICIT"` guard covers all three SCA-method URLs). | @@ -159,7 +159,7 @@ Currently served via a raw Lift `serve { case Req(..., "openapi.yaml", ...) }` b 1. Fix aggregation bug in `getResourceDocsObpV700` → make `V7ResourceDocsAggregationTest` pass. 2. Extract shared handler logic into `Http4sResourceDocs` service; wire into `Http4sApp`. 3. Add `openapi.yaml` route to the same service. -4. Port `getMessageDocsSwagger` from `APIMethods310` into the same service (currently still served by the Lift bridge — see "Per-version Lift leftovers" below). +4. ~~Port `getMessageDocsSwagger` from `APIMethods310` into the same service~~ — **Done.** Now served by `Http4sResourceDocs.handleGetMessageDocsSwagger` via the wildcard `/obp/*/message-docs/{CONNECTOR}/swagger2.0` route matched before any per-version service. The `val getMessageDocsSwagger: HttpRoutes[IO] = HttpRoutes.empty` stub in `Http4s310.scala` exists only to satisfy the `FrozenClassTest` API-surface check. 5. Remove resource-docs from the per-version Lift objects (`ResourceDocs140`–`ResourceDocs600`) once the centralized service covers them. --- @@ -332,8 +332,8 @@ An `APIMethods{version}` file is marked **done** in the progress table when ever | Endpoint | Origin | Why on Lift | Retired by | |---|---|---|---| -| `getMessageDocsSwagger` (`GET /message-docs/CONNECTOR/swagger2.0`) | `APIMethods310` | The URL is already served by `Http4sResourceDocs.routes` (`handleGetMessageDocsSwagger`), so the Lift `lazy val` is shadowed dead code. **But** deleting it reduces v3.1.0's STABLE API surface, which `FrozenClassTest` correctly rejects (the frozen-API guard sees a `lazy val ... : OBPEndpoint` go missing from `Implementations3_1_0`). Refreshing the frozen snapshot via `FrozenClassUtil.main` is the documented way out, but doing so also requires touching `GetMessageDocsSwaggerTest`, which is below v7.0.0. Kept as-is until the bridge-removal PR retires it. | The bridge-removal PR. | -| `getObpConnectorLoopback` (`GET /connector/loopback`) | `APIMethods310` | Deprecated stub that unconditionally throws `IllegalStateException(NotImplemented)`; no functional behaviour | Either a 3-line native http4s route that throws the same exception or outright deletion, decided when the Lift bridge is removed | +| `getMessageDocsSwagger` (`GET /message-docs/CONNECTOR/swagger2.0`) | `Http4s310` (stub) + `Http4sResourceDocs` (real handler) | **Effectively done.** The real handler lives in `Http4sResourceDocs.handleGetMessageDocsSwagger`, matched by the wildcard `/obp/*/message-docs/{CONNECTOR}/swagger2.0` before any per-version service. `Http4s310.scala` keeps a stub `val getMessageDocsSwagger: HttpRoutes[IO] = HttpRoutes.empty` plus a `ResourceDoc` entry so the ResourceDoc surface stays consistent and the `FrozenClassTest` surface check keeps passing (`nameOf(getMessageDocsSwagger)` compiles). No bridge dispatch is involved. | The stub can be deleted as part of the bridge-removal PR alongside the frozen-snapshot refresh; until then the wiring above is correct in production. | +| `getObpConnectorLoopback` (`GET /connector/loopback`) | `Http4s310` | **Done.** Native http4s route in `Http4s310.scala` (~line 4875) — `booleanToFuture(NotImplemented, failCode = 400) { false }`, i.e. the route always returns 400 NotImplemented, mirroring Lift's original deprecated-stub behaviour. No bridge dispatch. | Deletable in the bridge-removal PR (or kept indefinitely as a documented deprecation stub). | | ~~`testResourceDoc`~~ | ~~`APIMethods140`~~ | Dev-mode-only `/dummy` stub deleted — returned a dummy `APIInfoJSON`, no production behaviour. Removed from `OBPAPI1_4_0.routes` and `Implementations1_4_0`. `FrozenClassTest` did not flag it because v1.4.0's `testResourceDoc` ResourceDoc was registered behind `if (Props.devMode)` — the frozen snapshot (captured in test mode) never contained it. | **Done.** | Track new leftovers here when later version files are migrated — the bridge-removal milestone in "Done Criteria" only requires the per-version files to be **done** in this table's sense (functional endpoints migrated, tests green). Leftovers folded into the Resource-docs or Auth-stack workstreams retire via those workstreams. @@ -404,7 +404,7 @@ Already partly described in the next major section, but counted here for complet - `ResourceDocs140` … `ResourceDocs600` — six separate Lift files, each registered via `LiftRules.statelessDispatch.append` in `Boot.scala`. - `getResourceDocsObpV700` aggregation bug fix — landed (`V7ResourceDocsAggregationTest` passes). - `openapi.yaml` route — raw `Lift serve { ... }` block, no native http4s handler. -- `getMessageDocsSwagger` (v3.1.0) — folds into the centralised `Http4sResourceDocs` service when it ships. +- ~~`getMessageDocsSwagger` (v3.1.0) — folds into the centralised `Http4sResourceDocs` service when it ships.~~ **Done** — served by `Http4sResourceDocs.handleGetMessageDocsSwagger` via the wildcard `/obp/*/message-docs/{CONNECTOR}/swagger2.0` route. - One-PR opportunity: build `Http4sResourceDocs` above the Lift bridge in `Http4sApp`, intercept all `/obp/*/resource-docs/*` traffic, retire six Lift dispatch entries in a single change. ### Small singleton Lift endpoints @@ -580,7 +580,7 @@ Binds to `hostname` / `dev.port` from your props file (defaults: `127.0.0.1:8080 | `APIMethods210` | done — `Http4s210.scala` (25 own endpoints; path-rewriting bridge to Http4s200) | | `APIMethods220` | done — `Http4s220.scala` (18 own endpoints; path-rewriting bridge to Http4s210) | | `APIMethods300` | done — `Http4s300.scala` (47 own endpoints; path-rewriting bridge to Http4s220; all 86 v3.0.0 tests pass) | -| `APIMethods310` | done — `Http4s310.scala` (100 own endpoints + `updateCustomerAddress`; path-rewriting bridge to Http4s300; 2 endpoints still on Lift: `getMessageDocsSwagger` (shadowed by `Http4sResourceDocs.routes`, kept to satisfy `FrozenClassTest`) and `getObpConnectorLoopback`) | +| `APIMethods310` | done — `Http4s310.scala` (100 own endpoints + `updateCustomerAddress`; path-rewriting bridge to Http4s300). Two former Lift leftovers now both off-bridge: `getMessageDocsSwagger` served by `Http4sResourceDocs` (in-file stub kept only for `FrozenClassTest`), `getObpConnectorLoopback` served by a native http4s route that returns 400 NotImplemented. | | `APIMethods400` | **done — 258 / 258 (100%)**. `Http4s400.scala` covers all 253 unique handlers + 8 ResourceDoc aliases for transaction-request-type variants (served by the shared wildcard handler). | | `APIMethods500` | done — `Http4s500.scala` (all 10 v5.0.0 originals on http4s) | | `APIMethods510` | done — `Http4s510.scala` (all 111 v5.1.0 originals on http4s; `createConsent` exposed as `createConsentImplicit` with a guard covering EMAIL/SMS/IMPLICIT SCA methods) |