Skip to content

Commit

Permalink
feat(router): support greedy matches with subsequent static component…
Browse files Browse the repository at this point in the history
…s. (#3888)

* feat(router): support greedy matches with subsequent static components.

* feat(router): support more complex greedy matches patterns
  • Loading branch information
usualoma authored Feb 6, 2025
1 parent 438983b commit 7ce0ddd
Show file tree
Hide file tree
Showing 6 changed files with 138 additions and 29 deletions.
59 changes: 59 additions & 0 deletions src/router/common.case.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,19 @@ export const runTest = ({
})
})

describe('Capture simple multiple directories', () => {
beforeEach(() => {
router.add('GET', '/:dirs{.+}/file.html', 'file.html')
})

it('GET /foo/bar/file.html', () => {
const res = match('GET', '/foo/bar/file.html')
expect(res.length).toBe(1)
expect(res[0].handler).toEqual('file.html')
expect(res[0].params['dirs']).toEqual('foo/bar')
})
})

describe('Capture regex pattern has trailing wildcard', () => {
beforeEach(() => {
router.add('GET', '/:dir{[a-z]+}/*/file.html', 'file.html')
Expand All @@ -510,6 +523,52 @@ export const runTest = ({
})
})

describe('Capture complex multiple directories', () => {
beforeEach(() => {
router.add('GET', '/:first{.+}/middle-a/:reference?', '1')
router.add('GET', '/:first{.+}/middle-b/end-c/:uuid', '2')
router.add('GET', '/:first{.+}/middle-b/:digest', '3')
})

it('GET /part1/middle-b/latest', () => {
const res = match('GET', '/part1/middle-b/latest')
expect(res.length).toBe(1)
expect(res[0].handler).toEqual('3')
expect(res[0].params['first']).toEqual('part1')
expect(res[0].params['digest']).toEqual('latest')
})

it('GET /part1/middle-b/end-c/latest', () => {
const res = match('GET', '/part1/middle-b/end-c/latest')
expect(res.length).toBe(1)
expect(res[0].handler).toEqual('2')
expect(res[0].params['first']).toEqual('part1')
expect(res[0].params['uuid']).toEqual('latest')
})
})

describe('Capture multiple directories and optional', () => {
beforeEach(() => {
router.add('GET', '/:prefix{.+}/contents/:id?', 'contents')
})

it('GET /foo/bar/contents', () => {
const res = match('GET', '/foo/bar/contents')
expect(res.length).toBe(1)
expect(res[0].handler).toEqual('contents')
expect(res[0].params['prefix']).toEqual('foo/bar')
expect(res[0].params['id']).toEqual(undefined)
})

it('GET /foo/bar/contents/123', () => {
const res = match('GET', '/foo/bar/contents/123')
expect(res.length).toBe(1)
expect(res[0].handler).toEqual('contents')
expect(res[0].params['prefix']).toEqual('foo/bar')
expect(res[0].params['id']).toEqual('123')
})
})

describe('non ascii characters', () => {
beforeEach(() => {
router.add('ALL', '/$/*', 'middleware $')
Expand Down
4 changes: 3 additions & 1 deletion src/router/linear-router/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,9 @@ export class LinearRouter<T> implements Router<T> {
if (name.charCodeAt(name.length - 1) === 125) {
// :label{pattern}
const openBracePos = name.indexOf('{')
const pattern = name.slice(openBracePos + 1, -1)
const next = parts[j + 1]
const lookahead = next && next[1] !== ':' && next[1] !== '*' ? `(?=${next})` : ''
const pattern = name.slice(openBracePos + 1, -1) + lookahead
const restPath = path.slice(pos + 1)
const match = new RegExp(pattern, 'd').exec(restPath) as RegExpMatchArrayWithIndices
if (!match || match.indices[0][0] !== 0 || match.indices[0][1] === 0) {
Expand Down
2 changes: 2 additions & 0 deletions src/router/reg-exp-router/router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ describe('RegExpRouter', () => {
'Duplicate param name > parent',
'Duplicate param name > child',
'Capture Group > Complex capturing group > GET request',
'Capture complex multiple directories > GET /part1/middle-b/latest',
'Capture complex multiple directories > GET /part1/middle-b/end-c/latest',
],
},
{
Expand Down
36 changes: 25 additions & 11 deletions src/router/trie-router/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,24 +45,26 @@ export class Node<T> {

for (let i = 0, len = parts.length; i < len; i++) {
const p: string = parts[i]
const nextP = parts[i + 1]
const pattern = getPattern(p, nextP)
const key = Array.isArray(pattern) ? pattern[0] : p

if (Object.keys(curNode.#children).includes(p)) {
curNode = curNode.#children[p]
const pattern = getPattern(p)
if (Object.keys(curNode.#children).includes(key)) {
curNode = curNode.#children[key]
const pattern = getPattern(p, nextP)
if (pattern) {
possibleKeys.push(pattern[1])
}
continue
}

curNode.#children[p] = new Node()
curNode.#children[key] = new Node()

const pattern = getPattern(p)
if (pattern) {
curNode.#patterns.push(pattern)
possibleKeys.push(pattern[1])
}
curNode = curNode.#children[p]
curNode = curNode.#children[key]
}

const m: Record<string, HandlerSet<T>> = Object.create(null)
Expand Down Expand Up @@ -115,6 +117,7 @@ export class Node<T> {
const curNode: Node<T> = this
let curNodes = [curNode]
const parts = splitPath(path)
const curNodesQueue: Node<T>[][] = []

for (let i = 0, len = parts.length; i < len; i++) {
const part: string = parts[i]
Expand Down Expand Up @@ -166,10 +169,21 @@ export class Node<T> {

// `/js/:filename{[a-z]+.js}` => match /js/chunk/123.js
const restPathString = parts.slice(i).join('/')
if (matcher instanceof RegExp && matcher.test(restPathString)) {
params[name] = restPathString
handlerSets.push(...this.#getHandlerSets(child, method, node.#params, params))
continue
if (matcher instanceof RegExp) {
const m = matcher.exec(restPathString)
if (m) {
params[name] = m[0]
handlerSets.push(...this.#getHandlerSets(child, method, node.#params, params))

if (Object.keys(child.#children).length) {
child.#params = params
const componentCount = m[0].match(/\//)?.length ?? 0
const targetCurNodes = (curNodesQueue[componentCount] ||= [])
targetCurNodes.push(child)
}

continue
}
}

if (matcher === true || matcher.test(part)) {
Expand All @@ -189,7 +203,7 @@ export class Node<T> {
}
}

curNodes = tempNodes
curNodes = tempNodes.concat(curNodesQueue.shift() ?? [])
}

if (handlerSets.length > 1) {
Expand Down
52 changes: 40 additions & 12 deletions src/utils/url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,18 +49,46 @@ describe('url', () => {
expect(ps).toStrictEqual(['users', ':dept{\\d+}', ':@name{[0-9a-zA-Z_-]{3,10}}'])
})

it('getPattern', () => {
let res = getPattern(':id')
expect(res).not.toBeNull()
expect(res?.[0]).toBe(':id')
expect(res?.[1]).toBe('id')
expect(res?.[2]).toBe(true)
res = getPattern(':id{[0-9]+}')
expect(res?.[0]).toBe(':id{[0-9]+}')
expect(res?.[1]).toBe('id')
expect(res?.[2]).toEqual(/^[0-9]+$/)
res = getPattern('*')
expect(res).toBe('*')
describe('getPattern', () => {
it('no pattern', () => {
const res = getPattern('id')
expect(res).toBeNull()
})

it('no pattern with next', () => {
const res = getPattern('id', 'next')
expect(res).toBeNull()
})

it('default pattern', () => {
const res = getPattern(':id')
expect(res).toEqual([':id', 'id', true])
})

it('default pattern with next', () => {
const res = getPattern(':id', 'next')
expect(res).toEqual([':id', 'id', true])
})

it('regex pattern', () => {
const res = getPattern(':id{[0-9]+}')
expect(res).toEqual([':id{[0-9]+}', 'id', /^[0-9]+$/])
})

it('regex pattern with next', () => {
const res = getPattern(':id{[0-9]+}', 'next')
expect(res).toEqual([':id{[0-9]+}#next', 'id', /^[0-9]+(?=\/next)/])
})

it('wildcard', () => {
const res = getPattern('*')
expect(res).toBe('*')
})

it('wildcard with next', () => {
const res = getPattern('*', 'next')
expect(res).toBe('*')
})
})

describe('getPath', () => {
Expand Down
14 changes: 9 additions & 5 deletions src/utils/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ const replaceGroupMarks = (paths: string[], groups: [string, string][]): string[
}

const patternCache: { [key: string]: Pattern } = {}
export const getPattern = (label: string): Pattern | null => {
export const getPattern = (label: string, next?: string): Pattern | null => {
// * => wildcard
// :id{[0-9]+} => ([0-9]+)
// :id => (.+)
Expand All @@ -59,15 +59,19 @@ export const getPattern = (label: string): Pattern | null => {

const match = label.match(/^\:([^\{\}]+)(?:\{(.+)\})?$/)
if (match) {
if (!patternCache[label]) {
const cacheKey = `${label}#${next}`
if (!patternCache[cacheKey]) {
if (match[2]) {
patternCache[label] = [label, match[1], new RegExp('^' + match[2] + '$')]
patternCache[cacheKey] =
next && next[0] !== ':' && next[0] !== '*'
? [cacheKey, match[1], new RegExp(`^${match[2]}(?=/${next})`)]
: [label, match[1], new RegExp(`^${match[2]}$`)]
} else {
patternCache[label] = [label, match[1], true]
patternCache[cacheKey] = [label, match[1], true]
}
}

return patternCache[label]
return patternCache[cacheKey]
}

return null
Expand Down

0 comments on commit 7ce0ddd

Please sign in to comment.