From ef6f22a4be83c4046938efaa12eca406f1bb8d32 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 05:59:04 +0000 Subject: [PATCH 1/2] fix(openapi): prefer static routes when matching paths that need normalization OpenAPIMatcher looked up the raw pathname first and only retried with the normalized pathname on a miss. Route literals are stored normalized, so a dynamic sibling could catch a raw path meant for a static route: `fetch` sends `/v1^beta/items` raw, which hit `/{version}/items` with `version: 'v1^beta'`, and `/users/m%65` hit `/users/{id}` instead of `/users/me`. Lazy routers behind such a prefix were never loaded. Normalize the pathname once, before resolving lazy routers and matching. Paths that need no normalization keep the fast path. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CLzZxXd7GQrxmCVXCabGwt --- .../adapters/fetch/openapi-handler.test.ts | 32 +++++++++ .../adapters/standard/openapi-matcher.test.ts | 68 ++++++++++++++++++- .../src/adapters/standard/openapi-matcher.ts | 26 +++---- 3 files changed, 107 insertions(+), 19 deletions(-) diff --git a/packages/openapi/src/adapters/fetch/openapi-handler.test.ts b/packages/openapi/src/adapters/fetch/openapi-handler.test.ts index f6273780c..ef0926f3e 100644 --- a/packages/openapi/src/adapters/fetch/openapi-handler.test.ts +++ b/packages/openapi/src/adapters/fetch/openapi-handler.test.ts @@ -1,7 +1,9 @@ import type { StandardHandlerPlugin } from '@orpc/server/standard' +import { createORPCClient } from '@orpc/client' import { os } from '@orpc/server' import { openapi } from '../../meta' import { OpenAPIHandler } from './openapi-handler' +import { OpenAPILink } from './openapi-link' describe('openapiHandler', () => { beforeEach(() => { @@ -48,6 +50,36 @@ describe('openapiHandler', () => { expect(misMatchPrefixResult.response).toBeUndefined() }) + it('routes an OpenAPILink call to its static route when fetch leaves a char raw that a dynamic sibling would catch', async () => { + const router = { + beta: os.meta(openapi({ method: 'GET', path: '/v1^beta/items' })).handler(() => 'beta'), + version: os.meta(openapi({ method: 'GET', path: '/{version}/items' })).handler(({ input }) => input), + } + + const handler = new OpenAPIHandler(router) + + const fetch = vi.fn(async (url: string, init: RequestInit) => { + const { matched, response } = await handler.handle(new Request(url, init), { prefix: '/api' }) + + if (!matched || !response) { + throw new Error('No procedure match') + } + + return response + }) + + const client = createORPCClient(new OpenAPILink(router, { + fetch, + origin: 'http://localhost:3000', + url: '/api', + })) + + await expect(client.beta()).resolves.toBe('beta') + expect(fetch).toHaveBeenCalledWith('http://localhost:3000/api/v1^beta/items', expect.anything(), expect.anything(), ['beta']) + + await expect(client.version({ version: 'v1' })).resolves.toEqual({ version: 'v1' }) + }) + it('supports standard handler plugins', async () => { const plugin: StandardHandlerPlugin = { name: 'test', diff --git a/packages/openapi/src/adapters/standard/openapi-matcher.test.ts b/packages/openapi/src/adapters/standard/openapi-matcher.test.ts index 8ea6e588a..227c8c7a6 100644 --- a/packages/openapi/src/adapters/standard/openapi-matcher.test.ts +++ b/packages/openapi/src/adapters/standard/openapi-matcher.test.ts @@ -270,6 +270,46 @@ describe('openAPIMatcher', () => { await expect(matcher.match('GET', '/v1beta/items:batchGet/a^b', undefined)).resolves.toBeUndefined() }) + it('prefers a static route over a dynamic sibling when the request path needs normalization', async () => { + const beta = os.meta(openapi({ method: 'GET', path: '/v1^beta/items' })).handler(() => 'beta') + const version = os.meta(openapi({ method: 'GET', path: '/{version}/items' })).handler(() => 'version') + const me = os.meta(openapi({ method: 'GET', path: '/users/me' })).handler(() => 'me') + const byId = os.meta(openapi({ method: 'GET', path: '/users/{id}' })).handler(() => 'byId') + + const matcher = new OpenAPIMatcher({ beta, version, me, byId }) + + // a raw char rou3 stores encoded, as `fetch` sends "^" + for (const pathname of ['/v1^beta/items', '/v1%5Ebeta/items', '/v1%5ebeta/items'] as const) { + await expect(matcher.match('GET', pathname, undefined)).resolves.toEqual({ + path: ['beta'], + procedure: beta, + params: undefined, + }) + } + + // a percent-encoded char rou3 stores raw + for (const pathname of ['/users/me', '/users/m%65', '/users/%6D%65'] as const) { + await expect(matcher.match('GET', pathname, undefined)).resolves.toEqual({ + path: ['me'], + procedure: me, + params: undefined, + }) + } + + // the dynamic siblings still match everything else, with decoded params + await expect(matcher.match('GET', '/v2^beta/items', undefined)).resolves.toEqual({ + path: ['version'], + procedure: version, + params: { version: 'v2^beta' }, + }) + + await expect(matcher.match('GET', '/users/m%2Fe', undefined)).resolves.toEqual({ + path: ['byId'], + procedure: byId, + params: { id: 'm/e' }, + }) + }) + it('matches path text however the route and the request encode it', async () => { const generated = os.handler(() => 'generated') const explicit = os.meta(openapi({ method: 'GET', path: '/x%3Ay/a%2Fb/{id}' })).handler(() => 'explicit') @@ -541,8 +581,7 @@ describe('openAPIMatcher', () => { user: os.meta(openapi({ prefix: '/users' })).lazy(loader), }) - // "%75" is "u": the prefix RegExp is tested against the raw pathname first and fails, - // so the retry has to re-run lazy resolution against the normalized pathname + // "%75" is "u": the prefix RegExp only matches the normalized pathname const result = await matcher.match('GET', '/%75sers/info', undefined) expect(result).toBeDefined() @@ -577,6 +616,31 @@ describe('openAPIMatcher', () => { expect(loader).toHaveBeenCalledTimes(1) }) + it('resolves a prefixed lazy router before a dynamic sibling can catch a path that needs normalization', async () => { + const info = os.meta(openapi({ method: 'GET', path: '/info' })).handler(() => 'info') + const version = os.meta(openapi({ method: 'GET', path: '/{version}/info' })).handler(() => 'version') + + const loader = vi.fn(async () => ({ default: { info } })) + + const matcher = new OpenAPIMatcher({ + version, + beta: os.meta(openapi({ prefix: '/v1^beta' })).lazy(loader), + }) + + await expect(matcher.match('GET', '/v1^beta/info', undefined)).resolves.toMatchObject({ + path: ['beta', 'info'], + params: undefined, + }) + expect(loader).toHaveBeenCalledTimes(1) + + await expect(matcher.match('GET', '/v2/info', undefined)).resolves.toEqual({ + path: ['version'], + procedure: version, + params: { version: 'v2' }, + }) + expect(loader).toHaveBeenCalledTimes(1) + }) + it('resolves nested lazy routers added during the same match', async () => { const summary = os .meta(openapi({ method: 'GET', path: '/summary' })) diff --git a/packages/openapi/src/adapters/standard/openapi-matcher.ts b/packages/openapi/src/adapters/standard/openapi-matcher.ts index 27eeb11ad..8daa43a46 100644 --- a/packages/openapi/src/adapters/standard/openapi-matcher.ts +++ b/packages/openapi/src/adapters/standard/openapi-matcher.ts @@ -127,29 +127,21 @@ export class OpenAPIMatcher { } } + if (NORMALIZABLE_PATHNAME_REGEX.test(pathname)) { + // Normalize before any lookup: users may percent-encode characters that we store + // unencoded ("a%62c" vs "abc"), or leave raw characters that we store encoded + // ("a^b" vs "a%5Eb"). Matching the raw path first would let a dynamic sibling + // ("/{version}/items") catch a path meant for a static route ("/v1^beta/items"). + pathname = normalizeRou3Path(pathname) as `/${string}` + } + // most requests `await undefined` so conditionally await it to save a microtask turn const loading = this.resolvePendingLazyRouters(pathname) if (loading !== undefined) { await loading } - let match = this.findMatch(method, pathname) - - if (match === undefined && NORMALIZABLE_PATHNAME_REGEX.test(pathname)) { - // Retry with a normalized path: users may percent-encode characters that we store - // unencoded ("a%62c" vs "abc"), or leave raw characters that we store encoded - // ("a^b" vs "a%5Eb"), so normalization handles both without duplicate entries. - - const normalizedPathname = normalizeRou3Path(pathname) as `/${string}` - - // most requests `await undefined` so conditionally await it to save a microtask turn - const normalizedLoading = this.resolvePendingLazyRouters(normalizedPathname) - if (normalizedLoading !== undefined) { - await normalizedLoading - } - - match = this.findMatch(method, normalizedPathname) - } + const match = this.findMatch(method, pathname) if (match === undefined) { return undefined From 251bae2249a5447fb1faa181440bc7858cd29a6f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 10:15:13 +0000 Subject: [PATCH 2/2] refactor(openapi): fold the normalization guard into normalizeRou3Path Both callers tested NORMALIZABLE_PATHNAME_REGEX before normalizing, so move the check into normalizeRou3Path and also skip segments that are already in rou3's form. Drop test assertions that passed with or without the fix. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CLzZxXd7GQrxmCVXCabGwt --- .../adapters/fetch/openapi-handler.test.ts | 6 ++--- .../adapters/standard/openapi-matcher.test.ts | 7 ----- .../src/adapters/standard/openapi-matcher.ts | 26 ++++++++++--------- 3 files changed, 16 insertions(+), 23 deletions(-) diff --git a/packages/openapi/src/adapters/fetch/openapi-handler.test.ts b/packages/openapi/src/adapters/fetch/openapi-handler.test.ts index ef0926f3e..2d0fea0fb 100644 --- a/packages/openapi/src/adapters/fetch/openapi-handler.test.ts +++ b/packages/openapi/src/adapters/fetch/openapi-handler.test.ts @@ -50,10 +50,10 @@ describe('openapiHandler', () => { expect(misMatchPrefixResult.response).toBeUndefined() }) - it('routes an OpenAPILink call to its static route when fetch leaves a char raw that a dynamic sibling would catch', async () => { + it('routes an OpenAPILink call with a raw char to its static route, not a dynamic sibling', async () => { const router = { beta: os.meta(openapi({ method: 'GET', path: '/v1^beta/items' })).handler(() => 'beta'), - version: os.meta(openapi({ method: 'GET', path: '/{version}/items' })).handler(({ input }) => input), + version: os.meta(openapi({ method: 'GET', path: '/{version}/items' })).handler(() => 'version'), } const handler = new OpenAPIHandler(router) @@ -76,8 +76,6 @@ describe('openapiHandler', () => { await expect(client.beta()).resolves.toBe('beta') expect(fetch).toHaveBeenCalledWith('http://localhost:3000/api/v1^beta/items', expect.anything(), expect.anything(), ['beta']) - - await expect(client.version({ version: 'v1' })).resolves.toEqual({ version: 'v1' }) }) it('supports standard handler plugins', async () => { diff --git a/packages/openapi/src/adapters/standard/openapi-matcher.test.ts b/packages/openapi/src/adapters/standard/openapi-matcher.test.ts index 227c8c7a6..da889a51c 100644 --- a/packages/openapi/src/adapters/standard/openapi-matcher.test.ts +++ b/packages/openapi/src/adapters/standard/openapi-matcher.test.ts @@ -632,13 +632,6 @@ describe('openAPIMatcher', () => { params: undefined, }) expect(loader).toHaveBeenCalledTimes(1) - - await expect(matcher.match('GET', '/v2/info', undefined)).resolves.toEqual({ - path: ['version'], - procedure: version, - params: { version: 'v2' }, - }) - expect(loader).toHaveBeenCalledTimes(1) }) it('resolves nested lazy routers added during the same match', async () => { diff --git a/packages/openapi/src/adapters/standard/openapi-matcher.ts b/packages/openapi/src/adapters/standard/openapi-matcher.ts index 8daa43a46..a254729d6 100644 --- a/packages/openapi/src/adapters/standard/openapi-matcher.ts +++ b/packages/openapi/src/adapters/standard/openapi-matcher.ts @@ -127,13 +127,11 @@ export class OpenAPIMatcher { } } - if (NORMALIZABLE_PATHNAME_REGEX.test(pathname)) { - // Normalize before any lookup: users may percent-encode characters that we store - // unencoded ("a%62c" vs "abc"), or leave raw characters that we store encoded - // ("a^b" vs "a%5Eb"). Matching the raw path first would let a dynamic sibling - // ("/{version}/items") catch a path meant for a static route ("/v1^beta/items"). - pathname = normalizeRou3Path(pathname) as `/${string}` - } + // Normalize before any lookup: users may percent-encode characters that we store + // unencoded ("a%62c" vs "abc"), or leave raw characters that we store encoded + // ("a^b" vs "a%5Eb"). Matching the raw path first would let a dynamic sibling + // ("/{version}/items") catch a path meant for a static route ("/v1^beta/items"). + pathname = normalizeRou3Path(pathname) as `/${string}` // most requests `await undefined` so conditionally await it to save a microtask turn const loading = this.resolvePendingLazyRouters(pathname) @@ -260,16 +258,20 @@ function toRou3Route(path: `/${string}`): { pattern: `/${string}` } & Pick safeDecodeURIComponent(segment).replace(ROU3_ENCODED_SEGMENT_TEXT_REGEX, safeEncodeURIComponent)) + .map(segment => NORMALIZABLE_PATHNAME_REGEX.test(segment) + ? safeDecodeURIComponent(segment).replace(ROU3_ENCODED_SEGMENT_TEXT_REGEX, safeEncodeURIComponent) + : segment) .join('/') }