diff --git a/aio/tools/firebase-test-utils/FirebaseRedirect.spec.ts b/aio/tools/firebase-test-utils/FirebaseRedirect.spec.ts index 257edbeb43..c8aecc376c 100644 --- a/aio/tools/firebase-test-utils/FirebaseRedirect.spec.ts +++ b/aio/tools/firebase-test-utils/FirebaseRedirect.spec.ts @@ -3,18 +3,28 @@ import { FirebaseRedirect } from './FirebaseRedirect'; describe('FirebaseRedirect', () => { describe('replace', () => { it('should return undefined if the redirect does not match the url', () => { - const redirect = new FirebaseRedirect({source: '/a/b/c', destination: '/x/y/z'}); - expect(redirect.replace('/1/2/3')).toBe(undefined); + const globRedirect = new FirebaseRedirect({source: '/a/b/c', destination: '/x/y/z'}); + expect(globRedirect.replace('/1/2/3')).toBe(undefined); + + const regexRedirect = new FirebaseRedirect({regex: '^/a/b/c$', destination: '/x/y/z'}); + expect(regexRedirect.replace('/1/2/3')).toBe(undefined); }); it('should return the destination if there is a match', () => { - const redirect = new FirebaseRedirect({source: '/a/b/c', destination: '/x/y/z'}); - expect(redirect.replace('/a/b/c')).toBe('/x/y/z'); + const globRedirect = new FirebaseRedirect({source: '/a/b/c', destination: '/x/y/z'}); + expect(globRedirect.replace('/a/b/c')).toBe('/x/y/z'); + + const regexRedirect = new FirebaseRedirect({regex: '^/a/b/c$', destination: '/x/y/z'}); + expect(regexRedirect.replace('/a/b/c')).toBe('/x/y/z'); }); it('should inject name params into the destination', () => { - const redirect = new FirebaseRedirect({source: '/api/:package/:api-*', destination: '<:package><:api>'}); - expect(redirect.replace('/api/common/NgClass-directive')).toEqual(''); + const globRedirect = new FirebaseRedirect({source: '/api/:package/:api-*', destination: '<:package><:api>'}); + expect(globRedirect.replace('/api/common/NgClass-directive')).toBe(''); + + const regexRedirect = new FirebaseRedirect( + {regex: '^/api/(?P[^/]+)/(?P[^/]+)-.*$', destination: '<:package><:api>'}); + expect(regexRedirect.replace('/api/common/NgClass-directive')).toBe(''); }); it('should inject rest params into the destination', () => { diff --git a/aio/tools/firebase-test-utils/FirebaseRedirect.ts b/aio/tools/firebase-test-utils/FirebaseRedirect.ts index 212ae7f327..3183dcd4b5 100644 --- a/aio/tools/firebase-test-utils/FirebaseRedirect.ts +++ b/aio/tools/firebase-test-utils/FirebaseRedirect.ts @@ -6,8 +6,10 @@ export class FirebaseRedirect { source: FirebaseRedirectSource; destination: string; - constructor({source, destination}: FirebaseRedirectConfig) { - this.source = FirebaseRedirectSource.fromGlobPattern(source); + constructor({source, regex, destination}: FirebaseRedirectConfig) { + this.source = (typeof source === 'string') ? + FirebaseRedirectSource.fromGlobPattern(source) : + FirebaseRedirectSource.fromRegexPattern(regex!); this.destination = destination; } diff --git a/aio/tools/firebase-test-utils/FirebaseRedirectSource.spec.ts b/aio/tools/firebase-test-utils/FirebaseRedirectSource.spec.ts index 4c2b95c2c2..1c0fe71ec6 100644 --- a/aio/tools/firebase-test-utils/FirebaseRedirectSource.spec.ts +++ b/aio/tools/firebase-test-utils/FirebaseRedirectSource.spec.ts @@ -2,188 +2,320 @@ import { FirebaseRedirectSource } from './FirebaseRedirectSource'; describe('FirebaseRedirectSource', () => { describe('test', () => { - it('should match * parts', () => { - testGlob('asdf/*.jpg', - ['asdf/.jpg', 'asdf/asdf.jpg', 'asdf/asdf_asdf.jpg'], - ['asdf/asdf/asdf.jpg', 'xxxasdf/asdf.jpgxxx']); + describe('(using glob)', () => { + it('should match * parts', () => { + testGlob('asdf/*.jpg', + ['asdf/.jpg', 'asdf/asdf.jpg', 'asdf/asdf_asdf.jpg'], + ['asdf/asdf/asdf.jpg', 'xxxasdf/asdf.jpgxxx']); + }); + + it('should match ** parts', () => { + testGlob('asdf/**.jpg', // treated like two consecutive single `*`s + ['asdf/.jpg', 'asdf/asdf.jpg', 'asdf/asdf_asdf.jpg'], + ['asdf/a/.jpg', 'asdf/a/b.jpg', '/asdf/asdf.jpg', 'asdff/asdf.jpg', 'xxxasdf/asdf.jpg', 'asdf/asdf.jpgxxx']); + }); + + it('should match **/ and /**/', () => { + testGlob('**/*.js', + ['asdf.js', 'asdf/asdf.js', 'asdf/asdf/asdfasdf_asdf.js', '/asdf/asdf.js', '/asdf/aasdf-asdf.2.1.4.js'], + ['asdf/asdf.jpg', '/asdf/asdf.jpg']); + testGlob('aaa/**/bbb', + ['aaa/xxx/bbb', 'aaa/xxx/yyy/bbb', 'aaa/bbb'], + ['/aaa/xxx/bbb', 'aaa/x/bbb/', 'aaa/bbb/ccc']); + }); + + it('should match choice groups', () => { + testGlob('aaa/*.@(bbb|ccc)', + ['aaa/aaa.bbb', 'aaa/aaa_aaa.ccc'], + ['/aaa/aaa.bbb', 'aaaf/aaa.bbb', 'aaa/aaa.ddd']); + + testGlob('aaa/*(bbb|ccc)', + ['aaa/', 'aaa/bbb', 'aaa/ccc', 'aaa/bbbbbb', 'aaa/bbbccc', 'aaa/cccbbb', 'aaa/bbbcccbbb'], + ['aaa/aaa', 'aaa/bbbb']); + + testGlob('aaa/+(bbb|ccc)', + ['aaa/bbb', 'aaa/ccc', 'aaa/bbbbbb', 'aaa/bbbccc', 'aaa/cccbbb', 'aaa/bbbcccbbb'], + ['aaa/', 'aaa/aaa', 'aaa/bbbb']); + + testGlob('aaa/?(bbb|ccc)', + ['aaa/', 'aaa/bbb', 'aaa/ccc'], + ['aaa/aaa', 'aaa/bbbb', 'aaa/bbbbbb', 'aaa/bbbccc', 'aaa/cccbbb', 'aaa/bbbcccbbb']); + }); + + it('should error on non-supported choice groups', () => { + expect(() => FirebaseRedirectSource.fromGlobPattern('/!(a|b)/c')) + .toThrowError('Error in FirebaseRedirectSource: "/!(a|b)/c" - "not" expansions are not supported: "!(a|b)"'); + expect(() => FirebaseRedirectSource.fromGlobPattern('/(a|b)/c')) + .toThrowError('Error in FirebaseRedirectSource: "/(a|b)/c" - unknown expansion type: "/" in "/(a|b)"'); + expect(() => FirebaseRedirectSource.fromGlobPattern('/&(a|b)/c')) + .toThrowError('Error in FirebaseRedirectSource: "/&(a|b)/c" - unknown expansion type: "&" in "&(a|b)"'); + }); + + // Globs that contain params are tested via the match tests below }); - it('should match ** parts', () => { - testGlob('asdf/**.jpg', // treated like two consecutive single `*`s - ['asdf/.jpg', 'asdf/asdf.jpg', 'asdf/asdf_asdf.jpg'], - ['asdf/a/.jpg', 'asdf/a/b.jpg', '/asdf/asdf.jpg', 'asdff/asdf.jpg', 'xxxasdf/asdf.jpg', 'asdf/asdf.jpgxxx']); + describe('(using regex)', () => { + it('should match simple regexes', () => { + testRegex('^asdf/[^/]*\\.jpg$', + ['asdf/.jpg', 'asdf/asdf.jpg', 'asdf/asdf_asdf.jpg'], + ['asdf/asdf/asdf.jpg', 'xxxasdf/asdf.jpgxxx', 'asdf/asdf_jpg']); + }); + + it('should match regexes with capture groups', () => { + testRegex('asdf/([^/]+)\\.jpg$', + ['asdf/asdf.jpg', 'asdf/asdf_asdf.jpg', 'asdf/asdf/asdf.jpg'], + ['asdf/.jpg', 'xxxasdf/asdf.jpgxxx', 'asdf/asdf_jpg']); + }); + + it('should match regexes with named capture groups', () => { + testRegex('^asdf/(?P[^/]*)\\.jpg$', + ['asdf/.jpg', 'asdf/asdf.jpg', 'asdf/asdf_asdf.jpg'], + ['asdf/asdf/asdf.jpg', 'xxxasdf/asdf.jpgxxx', 'asdf/asdf_jpg']); + }); + + it('should error on non-supported named capture group syntax', () => { + expect(() => FirebaseRedirectSource.fromRegexPattern('/(?.*)')).toThrowError( + 'Error in FirebaseRedirectSource: "/(?.*)" - The regular expression pattern ' + + 'contains a named capture group of the format `(?...)`, which is not ' + + 'compatible with the RE2 library. Use `(?P...)` instead.'); + }); }); - - it('should match **/ and /**/', () => { - testGlob('**/*.js', - ['asdf.js', 'asdf/asdf.js', 'asdf/asdf/asdfasdf_asdf.js', '/asdf/asdf.js', '/asdf/aasdf-asdf.2.1.4.js'], - ['asdf/asdf.jpg', '/asdf/asdf.jpg']); - testGlob('aaa/**/bbb', - ['aaa/xxx/bbb', 'aaa/xxx/yyy/bbb', 'aaa/bbb'], - ['/aaa/xxx/bbb', 'aaa/x/bbb/', 'aaa/bbb/ccc']); - }); - - it('should match choice groups', () => { - testGlob('aaa/*.@(bbb|ccc)', - ['aaa/aaa.bbb', 'aaa/aaa_aaa.ccc'], - ['/aaa/aaa.bbb', 'aaaf/aaa.bbb', 'aaa/aaa.ddd']); - - testGlob('aaa/*(bbb|ccc)', - ['aaa/', 'aaa/bbb', 'aaa/ccc', 'aaa/bbbbbb', 'aaa/bbbccc', 'aaa/cccbbb', 'aaa/bbbcccbbb'], - ['aaa/aaa', 'aaa/bbbb']); - - testGlob('aaa/+(bbb|ccc)', - ['aaa/bbb', 'aaa/ccc', 'aaa/bbbbbb', 'aaa/bbbccc', 'aaa/cccbbb', 'aaa/bbbcccbbb'], - ['aaa/', 'aaa/aaa', 'aaa/bbbb']); - - testGlob('aaa/?(bbb|ccc)', - ['aaa/', 'aaa/bbb', 'aaa/ccc'], - ['aaa/aaa', 'aaa/bbbb', 'aaa/bbbbbb', 'aaa/bbbccc', 'aaa/cccbbb', 'aaa/bbbcccbbb']); - }); - - it('should error on non-supported choice groups', () => { - expect(() => FirebaseRedirectSource.fromGlobPattern('/!(a|b)/c')) - .toThrowError('Error in FirebaseRedirectSource: "/!(a|b)/c" - "not" expansions are not supported: "!(a|b)"'); - expect(() => FirebaseRedirectSource.fromGlobPattern('/(a|b)/c')) - .toThrowError('Error in FirebaseRedirectSource: "/(a|b)/c" - unknown expansion type: "/" in "/(a|b)"'); - expect(() => FirebaseRedirectSource.fromGlobPattern('/&(a|b)/c')) - .toThrowError('Error in FirebaseRedirectSource: "/&(a|b)/c" - unknown expansion type: "&" in "&(a|b)"'); - }); - - // Globs that contain params tested via the match tests below }); describe('match', () => { - it('should match patterns with no parameters', () => { - testMatch('/abc/def/*', { - }, { - '/abc/def/': {}, - '/abc/def/ghi': {}, - '/': undefined, - '/abc': undefined, - '/abc/def/ghi/jk;': undefined, + describe('(using glob)', () => { + it('should match patterns with no parameters', () => { + testGlobMatch('/abc/def/*', { + }, { + '/abc/def/': {}, + '/abc/def/ghi': {}, + '/': undefined, + '/abc': undefined, + '/abc/def/ghi/jk;': undefined, + }); + }); + + it('should capture a simple named param', () => { + testGlobMatch('/:abc', { + named: ['abc'] + }, { + '/a': {abc: 'a'}, + '/abc': {abc: 'abc'}, + '/': undefined, + '/a/': undefined, + '/a/b/': undefined, + '/a/a/b': undefined, + '/a/a/b/': undefined, + }); + testGlobMatch('/a/:b', { + named: ['b'] + }, { + '/a/b': {b: 'b'}, + '/a/bcd': {b: 'bcd'}, + '/a/': undefined, + '/a/b/': undefined, + '/a': undefined, + '/a//': undefined, + '/a/a/b': undefined, + '/a/a/b/': undefined, + }); + }); + + it('should capture a named param followed by non-word chars', () => { + testGlobMatch('/a/:x-', { + named: ['x'] + }, { + '/a/b-': {x: 'b'}, + '/a/bcd-': {x: 'bcd'}, + '/a/--': {x: '-'}, + '/a': undefined, + '/a/-': undefined, + '/a/-/': undefined, + '/a/': undefined, + '/a/b/-': undefined, + '/a/b-c': undefined, + }); + }); + + it('should capture multiple named params', () => { + testGlobMatch('/a/:b/:c', { + named: ['b', 'c'] + }, { + '/a/b/c': {b: 'b', c: 'c'}, + '/a/bcd/efg': {b: 'bcd', c: 'efg'}, + '/a/b/c-': {b: 'b', c: 'c-'}, + '/a/': undefined, + '/a/b/': undefined, + '/a/b/c/': undefined, + }); + testGlobMatch('/:a/b/:c', { + named: ['a', 'c'] + }, { + '/a/b/c': {a: 'a', c: 'c'}, + '/abc/b/efg': {a: 'abc', c: 'efg'}, + '/a/b/c-': {a: 'a', c: 'c-'}, + '/a/': undefined, + '/a/b/': undefined, + '/a/b/c/': undefined, + }); + }); + + it('should capture a simple rest param', () => { + testGlobMatch('/:abc*', { + rest: ['abc'] + }, { + '/a': {abc: 'a'}, + '/a/b': {abc: 'a/b'}, + '/a/bcd': {abc: 'a/bcd'}, + '/a/': {abc: 'a/'}, + '/a/b/': {abc: 'a/b/'}, + '/a//': {abc: 'a//'}, + '/a/b/c': {abc: 'a/b/c'}, + '/a/b/c/': {abc: 'a/b/c/'}, + }); + testGlobMatch('/a/:b*', { + rest: ['b'] + }, { + '/a/b': {b: 'b'}, + '/a/bcd': {b: 'bcd'}, + '/a/': {b: ''}, + '/a/b/': {b: 'b/'}, + '/a': {b: undefined}, + '/a//': {b: '/'}, + '/a/a/b': {b: 'a/b'}, + '/a/a/b/': {b: 'a/b/'}, + }); + }); + + it('should capture a rest param mixed with a named param', () => { + testGlobMatch('/:abc/:rest*', { + named: ['abc'], + rest: ['rest'] + }, { + '/a': {abc: 'a', rest: undefined}, + '/a/b': {abc: 'a', rest: 'b'}, + '/a/bcd': {abc: 'a', rest: 'bcd'}, + '/a/': {abc: 'a', rest: ''}, + '/a/b/': {abc: 'a', rest: 'b/'}, + '/a//': {abc: 'a', rest: '/'}, + '/a/b/c': {abc: 'a', rest: 'b/c'}, + '/a/b/c/': {abc: 'a', rest: 'b/c/'}, + }); }); }); - it('should capture a simple named param', () => { - testMatch('/:abc', { - named: ['abc'] - }, { - '/a': {abc: 'a'}, - '/abc': {abc: 'abc'}, - '/': undefined, - '/a/': undefined, - '/a/b/': undefined, - '/a/a/b': undefined, - '/a/a/b/': undefined, + describe('(using regex)', () => { + it('should match patterns with no parameters', () => { + testRegexMatch('^/abc/def/[^/]*$', { + }, { + '/abc/def/': {}, + '/abc/def/ghi': {}, + '/': undefined, + '/abc': undefined, + '/abc/def/ghi/jk;': undefined, + }); }); - testMatch('/a/:b', { - named: ['b'] - }, { - '/a/b': {b: 'b'}, - '/a/bcd': {b: 'bcd'}, - '/a/': undefined, - '/a/b/': undefined, - '/a': undefined, - '/a//': undefined, - '/a/a/b': undefined, - '/a/a/b/': undefined, - }); - }); - it('should capture a named param followed by non-word chars', () => { - testMatch('/a/:x-', { - named: ['x'] - }, { - '/a/b-': {x: 'b'}, - '/a/bcd-': {x: 'bcd'}, - '/a/--': {x: '-'}, - '/a': undefined, - '/a/-': undefined, - '/a/-/': undefined, - '/a/': undefined, - '/a/b/-': undefined, - '/a/b-c': undefined, + it('should capture a simple named group', () => { + testRegexMatch('^/(?P[^/]+)$', { + named: ['abc'], + }, { + '/a': {abc: 'a'}, + '/abc': {abc: 'abc'}, + '/': undefined, + '/a/': undefined, + '/a/b/': undefined, + '/a/a/b': undefined, + '/a/a/b/': undefined, + }); + testRegexMatch('^/a/(?P[^/]+)$', { + named: ['b'], + }, { + '/a/b': {b: 'b'}, + '/a/bcd': {b: 'bcd'}, + '/a/': undefined, + '/a/b/': undefined, + '/a': undefined, + '/a//': undefined, + '/a/a/b': undefined, + '/a/a/b/': undefined, + }); }); - }); - it('should capture multiple named params', () => { - testMatch('/a/:b/:c', { - named: ['b', 'c'] - }, { - '/a/b/c': {b: 'b', c: 'c'}, - '/a/bcd/efg': {b: 'bcd', c: 'efg'}, - '/a/b/c-': {b: 'b', c: 'c-'}, - '/a/': undefined, - '/a/b/': undefined, - '/a/b/c/': undefined, + it('should capture a named group followed by non-word chars', () => { + testRegexMatch('^/a/(?P[^/]+)-$', { + named: ['x'] + }, { + '/a/b-': {x: 'b'}, + '/a/bcd-': {x: 'bcd'}, + '/a/--': {x: '-'}, + '/a': undefined, + '/a/-': undefined, + '/a/-/': undefined, + '/a/': undefined, + '/a/b/-': undefined, + '/a/b-c': undefined, + }); }); - testMatch('/:a/b/:c', { - named: ['a', 'c'] - }, { - '/a/b/c': {a: 'a', c: 'c'}, - '/abc/b/efg': {a: 'abc', c: 'efg'}, - '/a/b/c-': {a: 'a', c: 'c-'}, - '/a/': undefined, - '/a/b/': undefined, - '/a/b/c/': undefined, - }); - }); - it('should capture a simple rest param', () => { - testMatch('/:abc*', { - rest: ['abc'] - }, { - '/a': {abc: 'a'}, - '/a/b': {abc: 'a/b'}, - '/a/bcd': {abc: 'a/bcd'}, - '/a/': {abc: 'a/'}, - '/a/b/': {abc: 'a/b/'}, - '/a//': {abc: 'a//'}, - '/a/b/c': {abc: 'a/b/c'}, - '/a/b/c/': {abc: 'a/b/c/'}, - }); - testMatch('/a/:b*', { - rest: ['b'] - }, { - '/a/b': {b: 'b'}, - '/a/bcd': {b: 'bcd'}, - '/a/': {b: ''}, - '/a/b/': {b: 'b/'}, - '/a': {b: undefined}, - '/a//': {b: '/'}, - '/a/a/b': {b: 'a/b'}, - '/a/a/b/': {b: 'a/b/'}, - }); - }); - - it('should capture a rest param mixed with a named param', () => { - testMatch('/:abc/:rest*', { - named: ['abc'], - rest: ['rest'] - }, { - '/a': {abc: 'a', rest: undefined}, - '/a/b': {abc: 'a', rest: 'b'}, - '/a/bcd': {abc: 'a', rest: 'bcd'}, - '/a/': {abc: 'a', rest: ''}, - '/a/b/': {abc: 'a', rest: 'b/'}, - '/a//': {abc: 'a', rest: '/'}, - '/a/b/c': {abc: 'a', rest: 'b/c'}, - '/a/b/c/': {abc: 'a', rest: 'b/c/'}, + it('should capture multiple named capture groups', () => { + testRegexMatch('^/a/(?P[^/]+)/(?P[^/]+)$', { + named: ['b', 'c'] + }, { + '/a/b/c': {b: 'b', c: 'c'}, + '/a/bcd/efg': {b: 'bcd', c: 'efg'}, + '/a/b/c-': {b: 'b', c: 'c-'}, + '/a/': undefined, + '/a/b/': undefined, + '/a/b/c/': undefined, + }); + testRegexMatch('^/(?P[^/]+)/b/(?P[^/]+)$', { + named: ['a', 'c'] + }, { + '/a/b/c': {a: 'a', c: 'c'}, + '/abc/b/efg': {a: 'abc', c: 'efg'}, + '/a/b/c-': {a: 'a', c: 'c-'}, + '/a/': undefined, + '/a/b/': undefined, + '/a/b/c/': undefined, + }); }); }); }); }); function testGlob(pattern: string, matches: string[], nonMatches: string[]) { - const redirectSource = FirebaseRedirectSource.fromGlobPattern(pattern); - matches.forEach(url => expect(redirectSource.test(url)).toBe(true, url)); - nonMatches.forEach(url => expect(redirectSource.test(url)).toBe(false, url)); + return testSource(FirebaseRedirectSource.fromGlobPattern(pattern), matches, nonMatches); } -function testMatch(pattern: string, captures: { named?: string[], rest?: string[] }, matches: { [url: string]: object|undefined }) { - const redirectSource = FirebaseRedirectSource.fromGlobPattern(pattern); - expect(redirectSource.namedGroups).toEqual(captures.named || []); - expect(redirectSource.restNamedGroups).toEqual(captures.rest || []); - Object.keys(matches).forEach(url => expect(redirectSource.match(url)).toEqual(matches[url])); +function testRegex(pattern: string, matches: string[], nonMatches: string[]) { + return testSource(FirebaseRedirectSource.fromRegexPattern(pattern), matches, nonMatches); +} + +function testSource(source: FirebaseRedirectSource, matches: string[], nonMatches: string[]) { + matches.forEach(url => expect(source.test(url)).toBe(true, url)); + nonMatches.forEach(url => expect(source.test(url)).toBe(false, url)); +} + +function testGlobMatch( + pattern: string, + captures: { named?: string[], rest?: string[] }, + matches: { [url: string]: object|undefined }) { + return testSourceMatch(FirebaseRedirectSource.fromGlobPattern(pattern), captures, matches); +} + +function testRegexMatch( + pattern: string, + captures: { named?: string[], rest?: string[] }, + matches: { [url: string]: object|undefined }) { + return testSourceMatch(FirebaseRedirectSource.fromRegexPattern(pattern), captures, matches); +} + +function testSourceMatch( + source: FirebaseRedirectSource, + captures: { named?: string[], rest?: string[] }, + matches: { [url: string]: object|undefined }) { + expect(source.namedGroups).toEqual(captures.named || []); + expect(source.restNamedGroups).toEqual(captures.rest || []); + Object.keys(matches).forEach(url => expect(source.match(url)).toEqual(matches[url])); } diff --git a/aio/tools/firebase-test-utils/FirebaseRedirectSource.ts b/aio/tools/firebase-test-utils/FirebaseRedirectSource.ts index 51294427a4..2eff990929 100644 --- a/aio/tools/firebase-test-utils/FirebaseRedirectSource.ts +++ b/aio/tools/firebase-test-utils/FirebaseRedirectSource.ts @@ -53,6 +53,29 @@ export class FirebaseRedirectSource { } } + static fromRegexPattern(regex: string): FirebaseRedirectSource { + try { + // NOTE: + // Firebase redirect regexes use the [RE2 library](https://github.com/google/re2/wiki/Syntax), + // which requires named capture groups to begin with `?P`. See + // https://firebase.google.com/docs/hosting/full-config#capture-url-segments-for-redirects. + + if (/\(\?<[^>]+>/.test(regex)) { + // Throw if the regex contains a non-RE2 named capture group. + throw new Error( + 'The regular expression pattern contains a named capture group of the format ' + + '`(?...)`, which is not compatible with the RE2 library. Use `(?P...)` ' + + 'instead.'); + } + + // Replace `(?P<...>` with just `(?<...>` to convert to `XRegExp`-compatible syntax for named + // capture groups. + return new FirebaseRedirectSource(regex.replace(/(\(\?)P(<[^>]+>)/g, '$1$2')); + } catch (err) { + throw new Error(`Error in FirebaseRedirectSource: "${regex}" - ${err.message}`); + } + } + test(url: string): boolean { return XRegExp.test(url, this.regex); } diff --git a/aio/tools/firebase-test-utils/FirebaseRedirector.spec.ts b/aio/tools/firebase-test-utils/FirebaseRedirector.spec.ts index 1833e525f7..931f9b4933 100644 --- a/aio/tools/firebase-test-utils/FirebaseRedirector.spec.ts +++ b/aio/tools/firebase-test-utils/FirebaseRedirector.spec.ts @@ -4,7 +4,7 @@ describe('FirebaseRedirector', () => { it('should replace with the first matching redirect', () => { const redirector = new FirebaseRedirector([ { source: '/a/b/c', destination: '/X/Y/Z' }, - { source: '/a/:foo/c', destination: '/X/:foo/Z' }, + { regex: '^/a/(?P[^/]+)/c$', destination: '/X/:foo/Z' }, { source: '/**/:foo/c', destination: '/A/:foo/zzz' }, ]); expect(redirector.redirect('/a/b/c')).toEqual('/X/Y/Z'); @@ -15,9 +15,9 @@ describe('FirebaseRedirector', () => { it('should return the original url if no redirect matches', () => { const redirector = new FirebaseRedirector([ - { source: 'x', destination: 'X' }, + { regex: '^x$', destination: 'X' }, { source: 'y', destination: 'Y' }, - { source: 'z', destination: 'Z' }, + { regex: '^z$', destination: 'Z' }, ]); expect(redirector.redirect('a')).toEqual('a'); }); @@ -25,7 +25,7 @@ describe('FirebaseRedirector', () => { it('should recursively redirect', () => { const redirector = new FirebaseRedirector([ { source: 'a', destination: 'b' }, - { source: 'b', destination: 'c' }, + { regex: '^b$', destination: 'c' }, { source: 'c', destination: 'd' }, ]); expect(redirector.redirect('a')).toEqual('d'); @@ -33,9 +33,9 @@ describe('FirebaseRedirector', () => { it('should throw if stuck in an infinite loop', () => { const redirector = new FirebaseRedirector([ - { source: 'a', destination: 'b' }, + { regex: '^a$', destination: 'b' }, { source: 'b', destination: 'c' }, - { source: 'c', destination: 'a' }, + { regex: '^c$', destination: 'a' }, ]); expect(() => redirector.redirect('a')).toThrowError('infinite redirect loop'); }); diff --git a/aio/tools/firebase-test-utils/FirebaseRedirector.ts b/aio/tools/firebase-test-utils/FirebaseRedirector.ts index 19f6a73ebc..a525f789e9 100644 --- a/aio/tools/firebase-test-utils/FirebaseRedirector.ts +++ b/aio/tools/firebase-test-utils/FirebaseRedirector.ts @@ -1,9 +1,8 @@ import { FirebaseRedirect } from './FirebaseRedirect'; -export interface FirebaseRedirectConfig { - source: string; - destination: string; -} +export type FirebaseRedirectConfig = + { source: string, regex?: undefined, destination: string } | + { source?: undefined, regex: string, destination: string }; export class FirebaseRedirector { private redirects: FirebaseRedirect[];