Skip to content

fix(openapi): improve OpenAPI path matching with rou3 v1 - #2173

Merged
dinwwwh merged 5 commits into
mainfrom
claude/hopeful-darwin-k7ip6f
Oct 4, 2026
Merged

dinwwwh merged 5 commits into
mainfrom
claude/hopeful-darwin-k7ip6f

Conversation

@dinwwwh

@dinwwwh dinwwwh commented Oct 4, 2026

Copy link
Copy Markdown
Member

Upgrade to rou3 v1.0.0 and improve OpenAPI path matching to handle edge cases with catch-all parameters, non-identifier parameter names, special characters, and percent-encoding.

Summary

This PR upgrades the rou3 dependency to v1.0.0 and refactors the OpenAPI path matching logic to properly handle:

  • Catch-all parameters ({+path}) that may be empty or followed by additional segments
  • Parameter names that aren't valid JavaScript identifiers (e.g., {user-id}, {0})
  • Repeated parameter names in the same path
  • Special characters in literal path segments (:, *, ?, +, (), {}, ., etc.)
  • Non-ASCII characters in paths (matched against percent-encoded request paths)
  • Percent-encoding normalization for user requests

Key Changes

  • Route pattern generation: Replaced simple parameter substitution with a more robust toRou3Route() function that:

    • Escapes rou3 syntax characters in literal path segments
    • Maps parameter names to internal rou3 keys (p0, p1, etc.) to support non-identifier names
    • Detects and validates that paths have at most one catch-all parameter
    • Tracks catch-all parameter keys for later validation
  • Match resolution: Added findMatch() method that:

    • Filters out matches where catch-all parameters captured empty values (invalid)
    • Falls back to less specific routes when catch-all parameters are empty
    • Uses findAllRoutes() to find the most specific valid match
  • Parameter decoding: Updated decodeParams() to map from internal rou3 keys back to original parameter names and decode URI components

  • Prefix matching: Enhanced toRou3PrefixMatcher() to handle prefixes with catch-all parameters

  • Lazy router handling: Improved to resolve lazy routers with prefixes ending in / or containing catch-all parameters, and to properly handle errors during lazy loading

  • Test coverage: Added comprehensive tests for edge cases including special characters, non-ASCII paths, catch-all parameter validation, and lazy router resolution

Implementation Details

  • Parameter names are now stored as [rou3Key, originalName] tuples in tree entries to support non-identifier names
  • Literal path segments are escaped using a regex that covers rou3 syntax plus the first dot of . and .. segments
  • Empty catch-all matches are detected and skipped to find the next most specific route
  • Percent-encoding normalization is only retried for paths containing % to keep misses efficient

https://claude.ai/code/session_01YRJj66odKexHiFFZ1qyVcU

claude added 5 commits October 3, 2026 13:56
rou3 1.0 aligns its pattern syntax with URLPattern, which changes how the
patterns OpenAPIMatcher built were read. Adapt the matcher so routing keeps
working:

- Give params positional rou3 keys and map them back to the OpenAPI names:
  rou3 now ends a name at `-` (`{user-id}` stopped matching) and throws on
  names like `{0}` or a repeated name.
- Escape literal path text, so characters rou3 reads as syntax (`:`, `*`,
  `?`, `(`, `{`, dot segments, ...) match literally instead of throwing or
  creating hidden params.
- Map `{+name}` to `:name(.*)` so values with empty segments (an encoded
  URL's `//`, absolute paths) still match, and let a match that leaves the
  catch-all empty give way to the next most specific route, keeping the
  old "needs a value" behavior.
- Cut lazy-router prefix matchers at a catch-all (rou3 allows one per
  route) and drop a trailing prefix slash, which rou3 now reads as an
  empty segment.
- Retry with a normalized path whenever normalization could change it, so
  raw characters rou3 now stores percent-encoded (`café`, `^`) still match.
- Convert every route before changing the tree, so an invalid path never
  leaves a lazy router half indexed; a path with two `{+name}` params now
  throws a clear error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YRJj66odKexHiFFZ1qyVcU
- Store each route's `[rou3 key, OpenAPI name]` pairs on the tree entry, so
  matching no longer rebuilds `p${i}` keys and tuple arrays per request.
- Decode params with a plain loop, keeping a `__proto__` name an own
  property.
- Drop the `Rou3Route` interface that duplicated `TreeEntry` fields, and
  simplify the empty catch-all fallback to `reverse().find()`.
- Merge duplicated catch-all test setups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YRJj66odKexHiFFZ1qyVcU
- Go back to retrying a missed lookup only when the path holds a `%`.
  The wider check made every miss with characters like `:` or `@`
  normalize and look up twice (~300 ns -> ~2000 ns). Raw characters that
  rou3 stores percent-encoded (e.g. `/café`) are now a documented
  limitation: HTTP clients send them encoded.
- Escape `.` only in `.` / `..` segments, so paths such as
  `/v1.2/report.json` register without the slower escape handling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YRJj66odKexHiFFZ1qyVcU
Remove comments in OpenAPIMatcher whose behavior the tests already pin
down, and add tests for the two behaviors that were only documented in
comments:

- the empty catch-all fallback picks the most specific other route
  (`/{name}` over `/{+path}`), also on a repeated match;
- a single `.` segment matches literally, like `..`.

Keep two short comments that explain choices tests can't show: why raw
characters are not retried, and why only dot segments are escaped. Label
the param tuples `rou3Key` so their fields need no doc comment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YRJj66odKexHiFFZ1qyVcU
Use the shared `setOwn` helper instead of an inline `__proto__` branch;
it keeps a `__proto__` param an own property the same way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YRJj66odKexHiFFZ1qyVcU
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
orpc ef3096b Commit Preview URL

Branch Preview URL
Oct 04 2026, 04:09 AM

@pkg-pr-new

pkg-pr-new Bot commented Oct 4, 2026

Copy link
Copy Markdown
More templates

@orpc/ai-sdk

npm i https://pkg.pr.new/@orpc/ai-sdk@2173

@orpc/arktype

npm i https://pkg.pr.new/@orpc/arktype@2173

@orpc/bun

npm i https://pkg.pr.new/@orpc/bun@2173

@orpc/client

npm i https://pkg.pr.new/@orpc/client@2173

@orpc/cloudflare

npm i https://pkg.pr.new/@orpc/cloudflare@2173

@orpc/contract

npm i https://pkg.pr.new/@orpc/contract@2173

@orpc/experimental-effect

npm i https://pkg.pr.new/@orpc/experimental-effect@2173

@orpc/evlog

npm i https://pkg.pr.new/@orpc/evlog@2173

@orpc/hibernation

npm i https://pkg.pr.new/@orpc/hibernation@2173

@orpc/json-schema

npm i https://pkg.pr.new/@orpc/json-schema@2173

@orpc/experimental-lock

npm i https://pkg.pr.new/@orpc/experimental-lock@2173

@orpc/experimental-msw

npm i https://pkg.pr.new/@orpc/experimental-msw@2173

@orpc/nest

npm i https://pkg.pr.new/@orpc/nest@2173

@orpc/next

npm i https://pkg.pr.new/@orpc/next@2173

@orpc/node

npm i https://pkg.pr.new/@orpc/node@2173

@orpc/openapi

npm i https://pkg.pr.new/@orpc/openapi@2173

@orpc/opentelemetry

npm i https://pkg.pr.new/@orpc/opentelemetry@2173

@orpc/pinia-colada

npm i https://pkg.pr.new/@orpc/pinia-colada@2173

@orpc/pino

npm i https://pkg.pr.new/@orpc/pino@2173

@orpc/publisher

npm i https://pkg.pr.new/@orpc/publisher@2173

@orpc/ratelimit

npm i https://pkg.pr.new/@orpc/ratelimit@2173

@orpc/server

npm i https://pkg.pr.new/@orpc/server@2173

@orpc/shared

npm i https://pkg.pr.new/@orpc/shared@2173

@orpc/swr

npm i https://pkg.pr.new/@orpc/swr@2173

@orpc/tanstack-query

npm i https://pkg.pr.new/@orpc/tanstack-query@2173

@orpc/trpc

npm i https://pkg.pr.new/@orpc/trpc@2173

@orpc/valibot

npm i https://pkg.pr.new/@orpc/valibot@2173

@orpc/zod

npm i https://pkg.pr.new/@orpc/zod@2173

commit: ef3096b

@codecov

codecov Bot commented Oct 4, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@codspeed

codspeed Bot commented Oct 4, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 30 untouched benchmarks


Comparing claude/hopeful-darwin-k7ip6f (ef3096b) with main (cf36774)1

Open in CodSpeed

Footnotes

  1. No successful run was found on main (cb7715a) during the generation of this report, so cf36774 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report. ↩

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No critical issues — one informational note on the dependency bump.

Reviewed changes

  • rou3 0.9.1 → 1.0.0 — packages/openapi/package.json, the lockfile, and the pnpm-workspace.yaml release-age allowlist.
  • Route pattern generation (toRou3Route) — escapes rou3 syntax in literal segments, remaps non-identifier and repeated param names to internal p0… keys, rejects more than one catch-all, and records the catch-all key on the entry.
  • Match resolution (findMatch) — when the best match's catch-all captured an empty value, falls back to the most specific valid route via findAllRoutes; empty catch-alls are no longer valid matches.
  • Param decoding (decodeParams) — maps internal keys back to the original names and decodes values, using setOwn so a __proto__ param stays an own property.
  • Prefix and lazy indexing (toRou3PrefixMatcher, index) — prefix matchers now handle trailing slashes and catch-all prefixes, and routes are collected before being committed so a single invalid path in a lazy router indexes nothing.
  • Tests and docs — extensive edge-case coverage (non-identifier names, repeated names, special characters, non-ASCII, catch-all suffix and empty fallback, lazy resolution) plus a docs sentence about {+name}.

Behavior looks correct and thoroughly covered. I ran the full @orpc/openapi suite (481 passed), the package typecheck, and eslint on both changed files — all clean. I also confirmed against rou3 v1 that findAllRoutes orders least→most specific, so .reverse().find(...) genuinely yields the most specific valid route, and probed __proto__/special-character params.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

Comment thread packages/openapi/package.json
@dinwwwh
dinwwwh merged commit b0b8261 into main Oct 4, 2026
12 checks passed
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