test(docs-infra): update `firebase-test-utils` to support regex-based redirects (#41842)
This commit updates the utilities in `firebase-test-utils/` to also support testing Firebase redirects that are configured using regular expressions (via the `regex` property). See the [Firebase docs][1] for more details. [1]: https://firebase.google.com/docs/hosting/full-config#redirects PR Close #41842
This commit is contained in:
parent
ae1ce5f9c7
commit
d1f5a9e44b
|
@ -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('<common><NgClass>');
|
||||
const globRedirect = new FirebaseRedirect({source: '/api/:package/:api-*', destination: '<:package><:api>'});
|
||||
expect(globRedirect.replace('/api/common/NgClass-directive')).toBe('<common><NgClass>');
|
||||
|
||||
const regexRedirect = new FirebaseRedirect(
|
||||
{regex: '^/api/(?P<package>[^/]+)/(?P<api>[^/]+)-.*$', destination: '<:package><:api>'});
|
||||
expect(regexRedirect.replace('/api/common/NgClass-directive')).toBe('<common><NgClass>');
|
||||
});
|
||||
|
||||
it('should inject rest params into the destination', () => {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@ import { FirebaseRedirectSource } from './FirebaseRedirectSource';
|
|||
|
||||
describe('FirebaseRedirectSource', () => {
|
||||
describe('test', () => {
|
||||
describe('(using glob)', () => {
|
||||
it('should match * parts', () => {
|
||||
testGlob('asdf/*.jpg',
|
||||
['asdf/.jpg', 'asdf/asdf.jpg', 'asdf/asdf_asdf.jpg'],
|
||||
|
@ -50,12 +51,41 @@ describe('FirebaseRedirectSource', () => {
|
|||
.toThrowError('Error in FirebaseRedirectSource: "/&(a|b)/c" - unknown expansion type: "&" in "&(a|b)"');
|
||||
});
|
||||
|
||||
// Globs that contain params tested via the match tests below
|
||||
// Globs that contain params are tested via the match tests below
|
||||
});
|
||||
|
||||
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<name>[^/]*)\\.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('/(?<foo>.*)')).toThrowError(
|
||||
'Error in FirebaseRedirectSource: "/(?<foo>.*)" - The regular expression pattern ' +
|
||||
'contains a named capture group of the format `(?<name>...)`, which is not ' +
|
||||
'compatible with the RE2 library. Use `(?P<name>...)` instead.');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('match', () => {
|
||||
describe('(using glob)', () => {
|
||||
it('should match patterns with no parameters', () => {
|
||||
testMatch('/abc/def/*', {
|
||||
testGlobMatch('/abc/def/*', {
|
||||
}, {
|
||||
'/abc/def/': {},
|
||||
'/abc/def/ghi': {},
|
||||
|
@ -66,7 +96,7 @@ describe('FirebaseRedirectSource', () => {
|
|||
});
|
||||
|
||||
it('should capture a simple named param', () => {
|
||||
testMatch('/:abc', {
|
||||
testGlobMatch('/:abc', {
|
||||
named: ['abc']
|
||||
}, {
|
||||
'/a': {abc: 'a'},
|
||||
|
@ -77,7 +107,7 @@ describe('FirebaseRedirectSource', () => {
|
|||
'/a/a/b': undefined,
|
||||
'/a/a/b/': undefined,
|
||||
});
|
||||
testMatch('/a/:b', {
|
||||
testGlobMatch('/a/:b', {
|
||||
named: ['b']
|
||||
}, {
|
||||
'/a/b': {b: 'b'},
|
||||
|
@ -92,7 +122,7 @@ describe('FirebaseRedirectSource', () => {
|
|||
});
|
||||
|
||||
it('should capture a named param followed by non-word chars', () => {
|
||||
testMatch('/a/:x-', {
|
||||
testGlobMatch('/a/:x-', {
|
||||
named: ['x']
|
||||
}, {
|
||||
'/a/b-': {x: 'b'},
|
||||
|
@ -108,7 +138,7 @@ describe('FirebaseRedirectSource', () => {
|
|||
});
|
||||
|
||||
it('should capture multiple named params', () => {
|
||||
testMatch('/a/:b/:c', {
|
||||
testGlobMatch('/a/:b/:c', {
|
||||
named: ['b', 'c']
|
||||
}, {
|
||||
'/a/b/c': {b: 'b', c: 'c'},
|
||||
|
@ -118,7 +148,7 @@ describe('FirebaseRedirectSource', () => {
|
|||
'/a/b/': undefined,
|
||||
'/a/b/c/': undefined,
|
||||
});
|
||||
testMatch('/:a/b/:c', {
|
||||
testGlobMatch('/:a/b/:c', {
|
||||
named: ['a', 'c']
|
||||
}, {
|
||||
'/a/b/c': {a: 'a', c: 'c'},
|
||||
|
@ -131,7 +161,7 @@ describe('FirebaseRedirectSource', () => {
|
|||
});
|
||||
|
||||
it('should capture a simple rest param', () => {
|
||||
testMatch('/:abc*', {
|
||||
testGlobMatch('/:abc*', {
|
||||
rest: ['abc']
|
||||
}, {
|
||||
'/a': {abc: 'a'},
|
||||
|
@ -143,7 +173,7 @@ describe('FirebaseRedirectSource', () => {
|
|||
'/a/b/c': {abc: 'a/b/c'},
|
||||
'/a/b/c/': {abc: 'a/b/c/'},
|
||||
});
|
||||
testMatch('/a/:b*', {
|
||||
testGlobMatch('/a/:b*', {
|
||||
rest: ['b']
|
||||
}, {
|
||||
'/a/b': {b: 'b'},
|
||||
|
@ -158,7 +188,7 @@ describe('FirebaseRedirectSource', () => {
|
|||
});
|
||||
|
||||
it('should capture a rest param mixed with a named param', () => {
|
||||
testMatch('/:abc/:rest*', {
|
||||
testGlobMatch('/:abc/:rest*', {
|
||||
named: ['abc'],
|
||||
rest: ['rest']
|
||||
}, {
|
||||
|
@ -173,17 +203,119 @@ describe('FirebaseRedirectSource', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
it('should capture a simple named group', () => {
|
||||
testRegexMatch('^/(?P<abc>[^/]+)$', {
|
||||
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<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 group followed by non-word chars', () => {
|
||||
testRegexMatch('^/a/(?P<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 capture groups', () => {
|
||||
testRegexMatch('^/a/(?P<b>[^/]+)/(?P<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,
|
||||
});
|
||||
testRegexMatch('^/(?P<a>[^/]+)/b/(?P<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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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]));
|
||||
}
|
||||
|
|
|
@ -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 ' +
|
||||
'`(?<name>...)`, which is not compatible with the RE2 library. Use `(?P<name>...)` ' +
|
||||
'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);
|
||||
}
|
||||
|
|
|
@ -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<foo>[^/]+)/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');
|
||||
});
|
||||
|
|
|
@ -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[];
|
||||
|
|
Loading…
Reference in New Issue