Skip to content

Commit 2f1bb9f

Browse files
committed
docs: Add extras wildcard matching design
1 parent 2884d2a commit 2f1bb9f

1 file changed

Lines changed: 126 additions & 0 deletions

File tree

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
# Extras Wildcard Matching — Design
2+
3+
Date: 2026-05-22
4+
Status: Approved
5+
6+
## Motivation
7+
8+
Extras (registered via `OpenApiServer.Builder.extraRoute(path, handler)`) currently
9+
require an exact path. Some real-world routes need pattern matching:
10+
11+
- `/static/*` to serve any single file under `/static/`
12+
- `/schemas/**/openapi.yaml` to expose the spec at any depth
13+
- `/files/**` to serve a tree of static assets
14+
15+
OpenAPI 3.1 has no wildcard syntax (only `{name}` templates), but extras are
16+
explicitly outside the spec, so we are free to define a small wildcard syntax
17+
just for them.
18+
19+
## Syntax
20+
21+
Two wildcards, usable anywhere in a path (not just trailing):
22+
23+
- `*` — matches exactly one path segment, i.e. one or more characters with no `/`.
24+
- `**` — matches zero or more characters, may cross `/` boundaries.
25+
26+
Patterns containing neither `*` nor `**` are treated as exact paths (current
27+
behaviour, no semantic change).
28+
29+
The matched portion is NOT exposed to the handler. The handler receives the
30+
existing `Request` shape; if it needs to inspect the URI it can already do so
31+
via `Request.rawQuery()` and the underlying exchange.
32+
33+
## Architecture
34+
35+
Replace the current per-route `HttpServer.createContext(extraPath, …)` wiring
36+
with a single shared `ExtrasRouter` registered at `/`.
37+
38+
Why one router instead of per-route contexts:
39+
40+
- JDK's `HttpContext` is literal-prefix only — it cannot host a wildcard.
41+
- A unified router avoids JDK context collisions when multiple wildcards share
42+
a static prefix (e.g. `/static/*` and `/static/legacy/**`).
43+
- It removes the special-case `NotFoundHandler` registered at `/`; the
44+
ExtrasRouter takes over the "no match → 404" role.
45+
46+
The basePath context (e.g. `/api/v1`) keeps its existing registration. JDK
47+
picks the longest-prefix context, so spec routes still win for basePath URIs.
48+
49+
### ExtrasRouter
50+
51+
New class `com.retailsvc.http.internal.ExtrasRouter` implementing
52+
`HttpHandler`. Holds a list of compiled extras, each a record of
53+
`(originalPath, Pattern compiled, RequestHandler handler)`. On each request:
54+
55+
1. Look up by exact path first (O(1) map of original-path → handler) for the
56+
no-wildcard case.
57+
2. If no exact hit, iterate the wildcard list in registration order; first
58+
regex match wins.
59+
3. If nothing matches, render a 404 via the existing exception path.
60+
61+
The existing `ExtraRouteAdapter` is reused per match — it already builds the
62+
`Request`, invokes the handler, and renders the response.
63+
64+
### Pattern compilation
65+
66+
`PathPattern.compile(String raw)` returns a `Pattern` and a flag `hasWildcard`.
67+
Compilation rules:
68+
69+
- Split on `/`. For each segment:
70+
- Literal segments → `Pattern.quote(segment)`
71+
- `*``[^/]+`
72+
- `**``.*` (across segments)
73+
- Mixed segments like `prefix-*.json` are NOT supported in this iteration —
74+
`*` and `**` must be a whole segment. Reject at compile time.
75+
- Rejoin with `/`, anchor with `^` and `$`.
76+
77+
Validation at boot:
78+
79+
- Pattern must start with `/`.
80+
- No empty segments (`//`).
81+
- `**` may not appear adjacent to another `**` (`/foo/**/**/bar`).
82+
- Compilation failures throw `IllegalStateException` from
83+
`OpenApiServer.Builder.build()`.
84+
85+
### Wiring changes in `OpenApiServer`
86+
87+
- Remove the per-extra `httpServer.createContext(path, …)` loop.
88+
- Build an `ExtrasRouter` from `handlerConfig.extras()`.
89+
- Register it once at `/` with the `ExceptionFilter`. Drop the existing
90+
`NotFoundHandler` registration — the router returns 404 itself when no
91+
extra matches.
92+
- The basePath context registration is unchanged.
93+
94+
## Error handling
95+
96+
Unchanged. Misses inside the router throw `NotFoundException`, which the
97+
`ExceptionFilter` (still in front of the router) renders as the standard
98+
problem+json 404.
99+
100+
## Testing
101+
102+
Tests added under `src/test/java/com/retailsvc/http/`:
103+
104+
- Unit tests for `PathPattern`:
105+
- exact path → no wildcard
106+
- `/files/*` matches `/files/a` but not `/files/a/b` and not `/files/`
107+
- `/files/**` matches `/files`, `/files/a`, `/files/a/b/c`
108+
- `/schemas/**/openapi.yaml` matches `/schemas/a/openapi.yaml` and
109+
`/schemas/a/b/openapi.yaml` but not `/schemas/openapi.yamlx`
110+
- mixed-segment patterns rejected at compile
111+
- empty segment rejected
112+
- adjacent `**/**` rejected
113+
114+
- Integration tests (`ExtrasWildcardIT`):
115+
- `/static/*` serves matching paths, rejects deeper paths
116+
- `/files/**` serves any depth
117+
- `/schemas/**/openapi.yaml` serves the spec at various depths
118+
- exact extras still work (regression for `ExactUrlMatchingIT` scenarios)
119+
- basePath spec routes still take precedence over extras
120+
121+
## Out of scope
122+
123+
- Exposing matched portions as path parameters.
124+
- Mid-segment wildcards (`prefix-*.json`).
125+
- Per-method extras (extras still accept any method).
126+
- Wildcards in spec paths (use OpenAPI `{name}` templates).

0 commit comments

Comments
 (0)