Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions packages/openapi/src/adapters/fetch/openapi-handler.test.ts
Original file line number Diff line number Diff line change
@@ -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(() => {
Expand Down Expand Up @@ -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<any>(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<any> = {
name: 'test',
Expand Down
61 changes: 59 additions & 2 deletions packages/openapi/src/adapters/standard/openapi-matcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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' }))
Expand Down
38 changes: 16 additions & 22 deletions packages/openapi/src/adapters/standard/openapi-matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -268,16 +258,20 @@ function toRou3Route(path: `/${string}`): { pattern: `/${string}` } & Pick<TreeE
const ROU3_SYNTAX_REGEX = /[\\:*?+(){}]|(?<![^/])\.(?=\.?(?:\/|$))/g

function toRou3Literal(text: string): string {
const normalized = NORMALIZABLE_PATHNAME_REGEX.test(text) ? normalizeRou3Path(text) : text

return normalized.replace(ROU3_SYNTAX_REGEX, '\\$&')
return normalizeRou3Path(text).replace(ROU3_SYNTAX_REGEX, '\\$&')
}

// decodes each segment, then re-encodes it the way rou3 stores literal text
// decodes each segment, then re-encodes it the way rou3 stores literal text; text already in that form is kept as is
function normalizeRou3Path(path: string): string {
if (!NORMALIZABLE_PATHNAME_REGEX.test(path)) {
return path
}

return path
.split('/')
.map(segment => 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('/')
}

Expand Down
Loading