Skip to content

Refactor/route bound resource doc - #2937

Open
hongwei1 wants to merge 32 commits into
OpenBankProject:developfrom
hongwei1:refactor/route-bound-resource-doc
Open

hongwei1 wants to merge 32 commits into
OpenBankProject:developfrom
hongwei1:refactor/route-bound-resource-doc

Conversation

@hongwei1

@hongwei1 hongwei1 commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

No description provided.

simonredfern and others added 12 commits October 6, 2026 17:35
Requests are upper-cased in ResourceDocMiddleware, lookups and
comparisons ignore case, and the upperCaseStoredCurrencyCodes migration
rewrites stored codes in upper case, logging each column it changed.
 transaction-request type and SCA method as a literal URL segment. The
 hand-kept literal list had fallen behind, so the v7 MOBILE_WALLET
 template claimed UTILITY, BULK and OPEN_CORRIDOR_PROMISE requests and
 gave HOLD, CARDANO and Ethereum on v7 an empty 404 instead of falling
 through to v6; v6 HOLD claimed CARDANO and Ethereum, the v4
 TRANSACTION_REQUEST_TYPE doc claimed every v4 type, and Berlin Group
 getPaymentInformation claimed the signing-basket status
 and authorisation requests. ResourceDocSelfResolveTest checks that
 every registered doc's own URL resolves to that doc, and
 TransactionRequestTypeRoutingTest checks the v7-to-v6 fall-through and
 that disabling one type leaves the others on.
allows with 400 (OBP-10068) instead of cutting it off when stored.
ResourceDocMiddleware checks amounts paired with a currency in the
request body and query string; crypto assets are not checked until their
real precision is recorded.
upperCaseStoredCurrencyCodes migration, so it also works on H2, which
stores names in upper case and is what CI runs on.
createTransactionRequestFreeForm, which now behaves as its ResourceDoc
says. Its odd answer came from the matcher treating FREE_FORM as a
placeholder, not from a design choice, and went away when FREE_FORM
became a literal segment.
request's challenge could credit wrong sandbox account, because the
payee was looked up again by routing as the payer wrote it and the empty
secondary-routing fallback matched anyone's counterparty. SIMPLE and
OPEN_CORRIDOR_PROMISE requests now record their counterparty id at
creation and the challenge pays that counterparty.
…ameter is empty

GET /banks/BANK_ID/api-products/API_PRODUCT_CODE/subscriptions requires
canGetApiProductSubscriptionAtOneBank, which only ResourceDocMiddleware
enforces. With an empty API_PRODUCT_CODE segment the http4s route still
matches but the middleware finds no ResourceDoc, so the handler runs
unvalidated and answers 500 (Bank not found in CallContext) instead of
403 for a caller without the role.

The empty-segment scenario fails until the middleware selects the
ResourceDoc from the route that serves the request.
…ct the doc by route

ResourceDocMiddleware finds the ResourceDoc of a request by matching the URL
against the doc templates, separately from the http4s route that runs the
handler. The two can disagree, and then the request is validated under one
endpoint's roles, enable/disable switch and operationId while another
endpoint's handler runs.

Add Http4sRoute, which keeps the pattern match of an endpoint so that it can
be asked whether it serves a request, and ResourceDocMatcher.selectByRoute,
which returns the doc of the first route that does. When several docs share
one route, their templates tell them apart. ResourceDocMiddleware tries the
route-bound docs first and runs only the selected route, wrapped in the new
`wrap` argument; docs without a route are still found by template, so
versions that have not been converted behave as before. An implicit
conversion from HttpRoutes[IO] keeps the existing
`http4sPartialFunction = Some(routes)` sites compiling.
Declare each v7.0.0 endpoint as an Http4sRoute instead of HttpRoutes.of, so
the middleware can ask it whether it serves a request. The same ordered doc
list now drives both selecting the doc and running the routes, and
ResourceDocMiddleware runs only the selected route, wrapped in the
idempotency middleware. The v7.0.0 to v6.0.0 bridge asks the routes whether
any of them serves a path instead of matching the URL against templates.

A request whose path parameter is empty (/banks/B/api-products//subscriptions)
is now served by the route that matches it and validated under that route's
doc, so its roles are enforced instead of the handler running unvalidated.

ResourceDocRouteBindingTest checks that every v7.0.0 doc is bound to a route
that serves its own URL and that selecting by route returns that doc.
Explain how an endpoint is declared as an Http4sRoute so the middleware
selects its ResourceDoc by route, and how that differs from the template
matching that versions not yet converted still use.
GET /obp/v7.0.0/management/traffic/top-callers lists only the 50 busiest
caller and endpoint pairs of the window, and the window is the current
minute of the JVM. Each pair the test looks for has a single request, so
when other suites had made more than 50 pairs in the same minute the pair
was cut off and the test failed. It passed alone and failed after
AuthSweepTest in one JVM.

Clear the record at the start of the scenario so it only sees its own
requests.
@hongwei1
hongwei1 force-pushed the refactor/route-bound-resource-doc branch from 4e5c610 to 25f2e72 Compare October 7, 2026 11:09
… route-selected doc

A route can serve a path with an empty segment, such as
/banks/B/api-products//subscriptions, and the doc selected for it is the
route's. extractPathParams dropped empty segments before comparing the path
with the template, so the lengths differed and it returned no parameters.
BANK_ID, ACCOUNT_ID and VIEW_ID were then never validated, and the role check
asked for the role at bank "": a caller holding the role at bank B was refused
with 403, and a handler that needs the bank or account answered 500.

Read the parameters by position with the empty segment kept when that matches
the template, and fall back to the previous reading otherwise, so docs that
are still matched by template behave as before.
ResourceDocRouteBindingTest sent each doc's URL through selectByRoute with the
docs in registration order, but the v7.0.0 middleware selects from the docs
sorted by segment count, the order its routes are tried in. A doc shadowed
by another one in that order would have passed the test and been validated
and run as the wrong endpoint in production. Use the ordered list.
…oute serves

selectByRoute asked every route-bound doc on every request. The v7.0.0
middleware sits ahead of the older versions in the chain, so a v4 or v3
request paid for about 120 pattern matches in v7.0.0 first, and the docs that
share a route were looked up again with a pass over all docs each time.

Build an index of the route-bound docs by verb and API version when the
middleware is built, with the docs that share a route worked out once, and ask
only the routes of the request's verb and version. The v7.0.0 to v6.0.0 bridge
uses the same index.

When every doc of a group carries its route, a request no route serves is
passed on at once, before the CallContext is built: nothing else in the group
could serve it, so resolving the caller and running all the routes again
could only find nothing.

Also report a route that is null (an endpoint val declared after the
resourceDocs += line that uses it) by the endpoint's name when the middleware
is built, and say why a doc that shares a route with others falls back to the
first of them when no template matches.
@hongwei1
hongwei1 force-pushed the refactor/route-bound-resource-doc branch from 2a22501 to 9d18edd Compare October 7, 2026 14:47
Add Http4sRoute.chain, which tries handlers in the order given, and
ResourceDocMatcher.orderByRoutes, which puts a version's docs in the order its
routes are tried. Docs that share a route stay together and an alias of a route
counts as the same route. A doc whose route is missing from the chain is an
error naming the doc, unless the route is listed as deliberately run outside
the middleware; a doc with no route documents an endpoint served elsewhere and
is left out. The route index is bucketed by apiShortVersion so Berlin Group
and UK Open Banking versions are found as well.
Declare each endpoint as an Http4sRoute and derive the route chain from one
list, so the middleware selects the doc of the route that serves a request and
runs only that route. The path-rewriting bridge asks the routes whether the
version serves a path instead of matching the URL against doc templates.
Declare each endpoint as an Http4sRoute and derive the route chain from one
list, so the middleware selects the doc of the route that serves a request and
runs only that route. The path-rewriting bridge asks the routes whether the
version serves a path instead of matching the URL against doc templates.
Declare each endpoint as an Http4sRoute and derive the route chain from one
list, so the middleware selects the doc of the route that serves a request and
runs only that route. The path-rewriting bridge asks the routes whether the
version serves a path instead of matching the URL against doc templates.
Declare each endpoint as an Http4sRoute and derive the route chain from one
list, so the middleware selects the doc of the route that serves a request and
runs only that route. The path-rewriting bridge asks the routes whether the
version serves a path instead of matching the URL against doc templates.

The createTransactionRequestAccount doc now documents the ACCOUNT URL, as the Lift
baseline does; a request for a type that has no doc of its own is validated under
it, the first doc of the shared route.
Declare each endpoint as an Http4sRoute and derive the route chain from one
list, so the middleware selects the doc of the route that serves a request and
runs only that route. The path-rewriting bridge asks the routes whether the
version serves a path instead of matching the URL against doc templates.

createConsent is shared by the EMAIL, SMS and IMPLICIT docs. The message-docs
Swagger doc has no route here (Http4sResourceDocs serves it) and stays out of the
middleware's docs.
Declare each endpoint as an Http4sRoute and derive the route chain from one
list, so the middleware selects the doc of the route that serves a request and
runs only that route. The path-rewriting bridge asks the routes whether the
version serves a path instead of matching the URL against doc templates.
Declare each endpoint of v1.2.1, v1.3.0, v1.4.0, v2.0.0, v2.1.0 and v2.2.0 as an
Http4sRoute and derive the route chain from one list. bankById keeps running
before the middleware so an unknown bank answers 400, not 464, and is listed as
outside the chain.
Each group lists its routes in order and the aggregator concatenates them, so
the middleware selects the doc of the route that serves a request. A signing
basket's status and authorisations no longer fall to getPaymentInformation,
whose template is made only of placeholders. The v2 routes are declared as
lazy vals because their docs are registered before them.
…ceDoc to its route

Each group lists its routes in order and the aggregator concatenates them, so
the middleware selects the doc of the route that serves a request.
…ect real URLs

ResourceDocRouteBindingTest covers all 18 catalogs (v1.2.1 to v7.0.0, Berlin
Group v1.3 and v2, UK Open Banking v2.0.0, v3.1.0, v4.0.1): each doc's own route
must serve its own URL and selection must return that doc. ResourceDocRouteOrderTest
covers orderByRoutes and Http4sRoute.chain. ResourceDocRealCatalogSelectionTest
selects real URLs that used to be given another endpoint's doc: the v4.0.0 and
v6.0.0 transaction-request types, the types v7.0.0 has no route for, and the
Berlin Group signing-basket status and authorisations.
…he parity allowlist

The doc now documents the ACCOUNT URL of the Lift baseline; only the VIEW_ID to
GRANT_VIEW_ID difference remains, and its digest is recomputed with
allowlist_helper.py.
Every doc of every catalog now carries its route, so the middleware selects the
doc by asking the routes and passes on a request no route serves. Delete the
URL-template index and lookup (buildIndex, findResourceDoc), the guess that a
capitalised segment is a placeholder (isTemplateVariable, literalAllCapsSegments),
the no-doc branch of the middleware and the caller resolution it needed.

ResourceDocMiddleware.apply now returns the routes itself: it runs the route of
the selected doc, so it no longer takes the chain as a second argument. A doc
with no route, or a null one, fails when the middleware is built and names the doc.

Docs that share one route are told apart by the segments the request has in
common with each template, with the first doc as the fallback, instead of by a
list of enum values. The path parameters read from a template are the four the
middleware validates: BANK_ID, ACCOUNT_ID, VIEW_ID and COUNTERPARTY_ID.

ResourceDocSelfResolveTest is replaced by ResourceDocRouteBindingTest, which
checks the same thing through the routes. ResourceDocMatcherTest keeps the path
parameter and CallContext scenarios.
Rewrite the guidance on declaring an endpoint, the route chain, docs that share a
route and the empty path segment gotcha, now that no version matches docs by URL
template.
elasticSearchWarehouse and elasticSearchMetrics documented /search/warehouse and
/search/metrics, but their routes serve /search/warehouse/{query} and
/search/metrics/{query}: the query is a path segment, so the documented URL could
not be called. Matching docs by template hid this, because the template validated
a URL no route served. The docs now name the segment, and the parity allowlist
records the difference from the Lift baseline.
Authenticating a consent request writes: it creates the consent's user and copies
the consent's Roles onto it. Http4sDynamicEntity authenticated inside the write
transaction, so those writes were uncommitted when the handler's own checks read
them on other connections: a consent that carried the Role was refused with 403
(OBP-20006), and a personal row was owned by the consent user instead of the
human who granted the consent, so the consent could not read back what it wrote.

The first link of the version chain used to authenticate the request outside any
transaction and committed those writes as a side effect; every version now serves
only the requests its routes serve, so nothing did. Resolve the caller before the
transaction opens, as ResourceDocMiddleware does for every other endpoint.

DynamicEntityConsentUserTest covers it: two scenarios failed on the shard that
runs v6.0.0.
The holder shared a caller resolved by the first version hop that had no ResourceDoc
for a request. Every version group now passes on a request none of its routes serves
without resolving anything, so nothing fills or reads it. Update the doc comment of
resolveCallerWithoutRateLimiting, whose one user is now the dynamic entity service.
@sonarqubecloud

sonarqubecloud Bot commented Oct 7, 2026

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants