From 720416d2b37639decf7d8dff2b7f37a5e4c94360 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Tue, 4 Feb 2025 09:05:25 +0900 Subject: [PATCH 1/2] feat(router): support greedy matches with subsequent static components. --- src/router/common.case.test.ts | 35 ++++++++++++++++++++++++++++++ src/router/linear-router/router.ts | 4 +++- src/router/trie-router/node.ts | 27 +++++++++++++++++------ src/utils/url.ts | 12 +++++----- 4 files changed, 65 insertions(+), 13 deletions(-) diff --git a/src/router/common.case.test.ts b/src/router/common.case.test.ts index cac9c2ac7..0284b828b 100644 --- a/src/router/common.case.test.ts +++ b/src/router/common.case.test.ts @@ -497,6 +497,41 @@ export const runTest = ({ }) }) + describe('Capture 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 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 $') diff --git a/src/router/linear-router/router.ts b/src/router/linear-router/router.ts index 28796ec7b..388a4f79d 100644 --- a/src/router/linear-router/router.ts +++ b/src/router/linear-router/router.ts @@ -86,7 +86,9 @@ export class LinearRouter implements Router { 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) { diff --git a/src/router/trie-router/node.ts b/src/router/trie-router/node.ts index 04cdba0dd..264a187eb 100644 --- a/src/router/trie-router/node.ts +++ b/src/router/trie-router/node.ts @@ -45,10 +45,11 @@ export class Node { for (let i = 0, len = parts.length; i < len; i++) { const p: string = parts[i] + const nextP = parts[i + 1] if (Object.keys(curNode.#children).includes(p)) { curNode = curNode.#children[p] - const pattern = getPattern(p) + const pattern = getPattern(p, nextP) if (pattern) { possibleKeys.push(pattern[1]) } @@ -57,7 +58,7 @@ export class Node { curNode.#children[p] = new Node() - const pattern = getPattern(p) + const pattern = getPattern(p, nextP) if (pattern) { curNode.#patterns.push(pattern) possibleKeys.push(pattern[1]) @@ -115,6 +116,7 @@ export class Node { const curNode: Node = this let curNodes = [curNode] const parts = splitPath(path) + const curNodesQueue: Node[][] = [] for (let i = 0, len = parts.length; i < len; i++) { const part: string = parts[i] @@ -165,10 +167,21 @@ export class Node { // `/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)) { @@ -188,7 +201,7 @@ export class Node { } } - curNodes = tempNodes + curNodes = tempNodes.concat(curNodesQueue.shift() ?? []) } if (handlerSets.length > 1) { diff --git a/src/utils/url.ts b/src/utils/url.ts index 57adfeb7d..a48ab7ba0 100644 --- a/src/utils/url.ts +++ b/src/utils/url.ts @@ -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 => (.+) @@ -59,15 +59,17 @@ 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] + '$')] + const lookahead = next && next[0] !== ':' && next[0] !== '*' ? `(?=/${next})` : '$' + patternCache[cacheKey] = [label, match[1], new RegExp('^' + match[2] + lookahead)] } else { - patternCache[label] = [label, match[1], true] + patternCache[cacheKey] = [label, match[1], true] } } - return patternCache[label] + return patternCache[cacheKey] } return null From 2fa716ffc8cb6e4ced27305eda12b239f2484ac9 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Wed, 5 Feb 2025 05:44:20 +0900 Subject: [PATCH 2/2] feat(router): support more complex greedy matches patterns --- src/router/common.case.test.ts | 26 +++++++++++- src/router/reg-exp-router/router.test.ts | 2 + src/router/trie-router/node.ts | 11 ++--- src/utils/url.test.ts | 52 ++++++++++++++++++------ src/utils/url.ts | 8 ++-- 5 files changed, 78 insertions(+), 21 deletions(-) diff --git a/src/router/common.case.test.ts b/src/router/common.case.test.ts index 0284b828b..8e46af108 100644 --- a/src/router/common.case.test.ts +++ b/src/router/common.case.test.ts @@ -497,7 +497,7 @@ export const runTest = ({ }) }) - describe('Capture multiple directories', () => { + describe('Capture simple multiple directories', () => { beforeEach(() => { router.add('GET', '/:dirs{.+}/file.html', 'file.html') }) @@ -510,6 +510,30 @@ 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') diff --git a/src/router/reg-exp-router/router.test.ts b/src/router/reg-exp-router/router.test.ts index 19999af99..305cee272 100644 --- a/src/router/reg-exp-router/router.test.ts +++ b/src/router/reg-exp-router/router.test.ts @@ -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', ], }, { diff --git a/src/router/trie-router/node.ts b/src/router/trie-router/node.ts index 264a187eb..802466044 100644 --- a/src/router/trie-router/node.ts +++ b/src/router/trie-router/node.ts @@ -46,9 +46,11 @@ export class Node { 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] + if (Object.keys(curNode.#children).includes(key)) { + curNode = curNode.#children[key] const pattern = getPattern(p, nextP) if (pattern) { possibleKeys.push(pattern[1]) @@ -56,14 +58,13 @@ export class Node { continue } - curNode.#children[p] = new Node() + curNode.#children[key] = new Node() - const pattern = getPattern(p, nextP) if (pattern) { curNode.#patterns.push(pattern) possibleKeys.push(pattern[1]) } - curNode = curNode.#children[p] + curNode = curNode.#children[key] } const m: Record> = Object.create(null) diff --git a/src/utils/url.test.ts b/src/utils/url.test.ts index bae253b79..621c4d90e 100644 --- a/src/utils/url.test.ts +++ b/src/utils/url.test.ts @@ -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', () => { diff --git a/src/utils/url.ts b/src/utils/url.ts index a48ab7ba0..7c87846a2 100644 --- a/src/utils/url.ts +++ b/src/utils/url.ts @@ -59,11 +59,13 @@ export const getPattern = (label: string, next?: string): Pattern | null => { const match = label.match(/^\:([^\{\}]+)(?:\{(.+)\})?$/) if (match) { - const cacheKey = `${label}-${next}` + const cacheKey = `${label}#${next}` if (!patternCache[cacheKey]) { if (match[2]) { - const lookahead = next && next[0] !== ':' && next[0] !== '*' ? `(?=/${next})` : '$' - patternCache[cacheKey] = [label, match[1], new RegExp('^' + match[2] + lookahead)] + patternCache[cacheKey] = + next && next[0] !== ':' && next[0] !== '*' + ? [cacheKey, match[1], new RegExp(`^${match[2]}(?=/${next})`)] + : [label, match[1], new RegExp(`^${match[2]}$`)] } else { patternCache[cacheKey] = [label, match[1], true] }