diff --git a/src/router/common.case.test.ts b/src/router/common.case.test.ts index c708d9d7d..e6912ef8b 100644 --- a/src/router/common.case.test.ts +++ b/src/router/common.case.test.ts @@ -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') @@ -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 $') 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/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 977b4f195..fa565b9ee 100644 --- a/src/router/trie-router/node.ts +++ b/src/router/trie-router/node.ts @@ -45,24 +45,26 @@ 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] - 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> = Object.create(null) @@ -115,6 +117,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] @@ -166,10 +169,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)) { @@ -189,7 +203,7 @@ export class Node { } } - curNodes = tempNodes + curNodes = tempNodes.concat(curNodesQueue.shift() ?? []) } if (handlerSets.length > 1) { 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 57adfeb7d..7c87846a2 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,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