diff --git a/packages/openapi/src/adapters/fetch/openapi-handler.test.ts b/packages/openapi/src/adapters/fetch/openapi-handler.test.ts index f6273780c..2d0fea0fb 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,34 @@ describe('openapiHandler', () => { expect(misMatchPrefixResult.response).toBeUndefined() }) + 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(() => 'version'), + } + + 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']) + }) + 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..da889a51c 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,24 @@ 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) + }) + 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..a254729d6 100644 --- a/packages/openapi/src/adapters/standard/openapi-matcher.ts +++ b/packages/openapi/src/adapters/standard/openapi-matcher.ts @@ -127,29 +127,19 @@ export class OpenAPIMatcher { } } + // 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 @@ -268,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('/') }