refactor(docs-infra): prepare `firebase-test-utils` for accepting regex-based redirects (#41842)
Currently, the utilities for testing Firebase redirects assume that the redirects are configured using the glob-based `source` property. However, Firebase also supports configuring redirects using regular expressions (via the `regex` property). See the [Firebase docs][1] for more details. This commit refactors the utilities in `firebase-test-utils/` to make it easy to add support for such regex-based redirect configurations. [1]: https://firebase.google.com/docs/hosting/full-config#redirects PR Close #41842
This commit is contained in:
parent
36e10e7932
commit
ae1ce5f9c7
|
@ -3,27 +3,27 @@ import { FirebaseRedirect } from './FirebaseRedirect';
|
|||
describe('FirebaseRedirect', () => {
|
||||
describe('replace', () => {
|
||||
it('should return undefined if the redirect does not match the url', () => {
|
||||
const redirect = new FirebaseRedirect('/a/b/c', '/x/y/z');
|
||||
const redirect = new FirebaseRedirect({source: '/a/b/c', destination: '/x/y/z'});
|
||||
expect(redirect.replace('/1/2/3')).toBe(undefined);
|
||||
});
|
||||
|
||||
it('should return the destination if there is a match', () => {
|
||||
const redirect = new FirebaseRedirect('/a/b/c', '/x/y/z');
|
||||
const redirect = new FirebaseRedirect({source: '/a/b/c', destination: '/x/y/z'});
|
||||
expect(redirect.replace('/a/b/c')).toBe('/x/y/z');
|
||||
});
|
||||
|
||||
it('should inject name params into the destination', () => {
|
||||
const redirect = new FirebaseRedirect('/api/:package/:api-*', '<:package><:api>');
|
||||
const redirect = new FirebaseRedirect({source: '/api/:package/:api-*', destination: '<:package><:api>'});
|
||||
expect(redirect.replace('/api/common/NgClass-directive')).toEqual('<common><NgClass>');
|
||||
});
|
||||
|
||||
it('should inject rest params into the destination', () => {
|
||||
const redirect = new FirebaseRedirect('/a/:rest*', '/x/:rest*/y');
|
||||
const redirect = new FirebaseRedirect({source: '/a/:rest*', destination: '/x/:rest*/y'});
|
||||
expect(redirect.replace('/a/b/c')).toEqual('/x/b/c/y');
|
||||
});
|
||||
|
||||
it('should inject both named and rest parameters into the destination', () => {
|
||||
const redirect = new FirebaseRedirect('/:a/:rest*', '/x/:a/y/:rest*/z');
|
||||
const redirect = new FirebaseRedirect({source: '/:a/:rest*', destination: '/x/:a/y/:rest*/z'});
|
||||
expect(redirect.replace('/a/b/c')).toEqual('/x/a/y/b/c/z');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,19 +1,25 @@
|
|||
import * as XRegExp from 'xregexp';
|
||||
import { FirebaseGlob } from './FirebaseGlob';
|
||||
import { FirebaseRedirectConfig } from './FirebaseRedirector';
|
||||
import { FirebaseRedirectSource } from './FirebaseRedirectSource';
|
||||
|
||||
export class FirebaseRedirect {
|
||||
glob = new FirebaseGlob(this.source);
|
||||
constructor(public source: string, public destination: string) {}
|
||||
source: FirebaseRedirectSource;
|
||||
destination: string;
|
||||
|
||||
constructor({source, destination}: FirebaseRedirectConfig) {
|
||||
this.source = FirebaseRedirectSource.fromGlobPattern(source);
|
||||
this.destination = destination;
|
||||
}
|
||||
|
||||
replace(url: string): string | undefined {
|
||||
const match = this.glob.match(url);
|
||||
const match = this.source.match(url);
|
||||
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const paramReplacers = Object.keys(this.glob.namedParams).map<[RegExp, string]>(name => [ XRegExp(`:${name}`, 'g'), match[name] ]);
|
||||
const restReplacers = Object.keys(this.glob.restParams).map<[RegExp, string]>(name => [ XRegExp(`:${name}\\*`, 'g'), match[name] ]);
|
||||
return XRegExp.replaceEach(this.destination, [...paramReplacers, ...restReplacers]);
|
||||
const namedReplacers = this.source.namedGroups.map<[RegExp, string]>(name => [ XRegExp(`:${name}`, 'g'), match[name] ]);
|
||||
const restReplacers = this.source.restNamedGroups.map<[RegExp, string]>(name => [ XRegExp(`:${name}\\*`, 'g'), match[name] ]);
|
||||
return XRegExp.replaceEach(this.destination, [...namedReplacers, ...restReplacers]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { FirebaseGlob } from './FirebaseGlob';
|
||||
describe('FirebaseGlob', () => {
|
||||
import { FirebaseRedirectSource } from './FirebaseRedirectSource';
|
||||
|
||||
describe('FirebaseRedirectSource', () => {
|
||||
describe('test', () => {
|
||||
it('should match * parts', () => {
|
||||
testGlob('asdf/*.jpg',
|
||||
|
@ -42,12 +42,12 @@ describe('FirebaseGlob', () => {
|
|||
});
|
||||
|
||||
it('should error on non-supported choice groups', () => {
|
||||
expect(() => new FirebaseGlob('/!(a|b)/c'))
|
||||
.toThrowError('Error in FirebaseGlob: "/!(a|b)/c" - "not" expansions are not supported: "!(a|b)"');
|
||||
expect(() => new FirebaseGlob('/(a|b)/c'))
|
||||
.toThrowError('Error in FirebaseGlob: "/(a|b)/c" - unknown expansion type: "/" in "/(a|b)"');
|
||||
expect(() => new FirebaseGlob('/&(a|b)/c'))
|
||||
.toThrowError('Error in FirebaseGlob: "/&(a|b)/c" - unknown expansion type: "&" in "&(a|b)"');
|
||||
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
|
||||
|
@ -176,14 +176,14 @@ describe('FirebaseGlob', () => {
|
|||
});
|
||||
|
||||
function testGlob(pattern: string, matches: string[], nonMatches: string[]) {
|
||||
const glob = new FirebaseGlob(pattern);
|
||||
matches.forEach(url => expect(glob.test(url)).toBe(true, url));
|
||||
nonMatches.forEach(url => expect(glob.test(url)).toBe(false, url));
|
||||
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));
|
||||
}
|
||||
|
||||
function testMatch(pattern: string, captures: { named?: string[], rest?: string[] }, matches: { [url: string]: object|undefined }) {
|
||||
const glob = new FirebaseGlob(pattern);
|
||||
expect(Object.keys(glob.namedParams)).toEqual(captures.named || []);
|
||||
expect(Object.keys(glob.restParams)).toEqual(captures.rest || []);
|
||||
Object.keys(matches).forEach(url => expect(glob.match(url)).toEqual(matches[url]));
|
||||
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]));
|
||||
}
|
|
@ -5,45 +5,51 @@ interface XRegExp extends RegExp {
|
|||
xregexp: { captureNames?: string[] };
|
||||
}
|
||||
|
||||
const dot = /\./g;
|
||||
const star = /\*/g;
|
||||
const doubleStar = /(^|\/)\*\*($|\/)/g; // e.g. a/**/b or **/b or a/** but not a**b
|
||||
const modifiedPatterns = /(.)\(([^)]+)\)/g; // e.g. `@(a|b)
|
||||
const restParam = /\/:([A-Za-z]+)\*/g; // e.g. `:rest*`
|
||||
const namedParam = /\/:([A-Za-z]+)/g; // e.g. `:api`
|
||||
const possiblyEmptyInitialSegments = /^\.🐷\//g; // e.g. `**/a` can also match `a`
|
||||
const possiblyEmptySegments = /\/\.🐷\//g; // e.g. `a/**/b` can also match `a/b`
|
||||
const willBeStar = /🐷/g; // e.g. `a**b` not matched by previous rule
|
||||
export class FirebaseRedirectSource {
|
||||
regex = XRegExp(this.pattern) as XRegExp;
|
||||
namedGroups: string[] = [];
|
||||
|
||||
private constructor(public pattern: string, public restNamedGroups: string[] = []) {
|
||||
const restNamedGroupsSet = new Set(restNamedGroups);
|
||||
pattern.replace(/\(\?<([^>]+)>/g, (_, name) => {
|
||||
if (!restNamedGroupsSet.has(name)) {
|
||||
this.namedGroups.push(name);
|
||||
}
|
||||
return '';
|
||||
});
|
||||
}
|
||||
|
||||
static fromGlobPattern(glob: string): FirebaseRedirectSource {
|
||||
const dot = /\./g;
|
||||
const star = /\*/g;
|
||||
const doubleStar = /(^|\/)\*\*($|\/)/g; // e.g. a/**/b or **/b or a/** but not a**b
|
||||
const modifiedPatterns = /(.)\(([^)]+)\)/g; // e.g. `@(a|b)
|
||||
const restParam = /\/:([A-Za-z]+)\*/g; // e.g. `:rest*`
|
||||
const namedParam = /\/:([A-Za-z]+)/g; // e.g. `:api`
|
||||
const possiblyEmptyInitialSegments = /^\.🐷\//g; // e.g. `**/a` can also match `a`
|
||||
const possiblyEmptySegments = /\/\.🐷\//g; // e.g. `a/**/b` can also match `a/b`
|
||||
const willBeStar = /🐷/g; // e.g. `a**b` not matched by previous rule
|
||||
|
||||
export class FirebaseGlob {
|
||||
pattern: string;
|
||||
regex: XRegExp;
|
||||
namedParams: { [key: string]: boolean } = {};
|
||||
restParams: { [key: string]: boolean } = {};
|
||||
constructor(glob: string) {
|
||||
try {
|
||||
const restNamedGroups: string[] = [];
|
||||
const pattern = glob
|
||||
.replace(dot, '\\.')
|
||||
.replace(modifiedPatterns, replaceModifiedPattern)
|
||||
.replace(restParam, (_, param) => {
|
||||
// capture the rest of the string
|
||||
this.restParams[param] = true;
|
||||
restNamedGroups.push(param);
|
||||
return `(?:/(?<${param}>.🐷))?`;
|
||||
})
|
||||
.replace(namedParam, (_, param) => {
|
||||
// capture the named parameter
|
||||
this.namedParams[param] = true;
|
||||
return `/(?<${param}>[^/]+)`;
|
||||
})
|
||||
.replace(namedParam, `/(?<$1>[^/]+)`)
|
||||
.replace(doubleStar, '$1.🐷$2') // use the pig to avoid replacing ** in next rule
|
||||
.replace(star, '[^/]*') // match a single segment
|
||||
.replace(possiblyEmptyInitialSegments, '(?:.*)')// deal with **/ special cases
|
||||
.replace(possiblyEmptySegments, '(?:/|/.*/)') // deal with /**/ special cases
|
||||
.replace(willBeStar, '*'); // other ** matches
|
||||
this.pattern = `^${pattern}$`;
|
||||
this.regex = XRegExp(this.pattern) as XRegExp;
|
||||
} catch (e) {
|
||||
throw new Error(`Error in FirebaseGlob: "${glob}" - ${e.message}`);
|
||||
|
||||
return new FirebaseRedirectSource(`^${pattern}$`, restNamedGroups);
|
||||
} catch (err) {
|
||||
throw new Error(`Error in FirebaseRedirectSource: "${glob}" - ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -52,7 +58,7 @@ export class FirebaseGlob {
|
|||
}
|
||||
|
||||
match(url: string): { [key: string]: string } | undefined {
|
||||
const match = XRegExp.exec(url, this.regex) as ReturnType<typeof XRegExp.exec> & { [captured: string]: string };
|
||||
const match = XRegExp.exec(url, this.regex) as ReturnType<typeof XRegExp.exec>;
|
||||
|
||||
if (!match) {
|
||||
return undefined;
|
|
@ -8,7 +8,7 @@ export interface FirebaseRedirectConfig {
|
|||
export class FirebaseRedirector {
|
||||
private redirects: FirebaseRedirect[];
|
||||
constructor(redirects: FirebaseRedirectConfig[]) {
|
||||
this.redirects = redirects.map(redirect => new FirebaseRedirect(redirect.source, redirect.destination));
|
||||
this.redirects = redirects.map(redirect => new FirebaseRedirect(redirect));
|
||||
}
|
||||
|
||||
redirect(url: string): string {
|
||||
|
|
Loading…
Reference in New Issue