
committed by
Miško Hevery

parent
ac815f7281
commit
2b67400652
189
aio/tools/firebase-test-utils/FirebaseGlob.spec.ts
Normal file
189
aio/tools/firebase-test-utils/FirebaseGlob.spec.ts
Normal file
@ -0,0 +1,189 @@
|
||||
import { FirebaseGlob } from './FirebaseGlob';
|
||||
describe('FirebaseGlob', () => {
|
||||
|
||||
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']);
|
||||
});
|
||||
|
||||
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(() => 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)"');
|
||||
});
|
||||
|
||||
// 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,
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
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 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,
|
||||
});
|
||||
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/'},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
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]));
|
||||
}
|
74
aio/tools/firebase-test-utils/FirebaseGlob.ts
Normal file
74
aio/tools/firebase-test-utils/FirebaseGlob.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import * as XRegExp from 'xregexp';
|
||||
|
||||
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 pattern = glob
|
||||
.replace(dot, '\\.')
|
||||
.replace(modifiedPatterns, replaceModifiedPattern)
|
||||
.replace(restParam, (_, param) => {
|
||||
// capture the rest of the string
|
||||
this.restParams[param] = true;
|
||||
return `(?:/(?<${param}>.🐷))?`;
|
||||
})
|
||||
.replace(namedParam, (_, param) => {
|
||||
// capture the named parameter
|
||||
this.namedParams[param] = true;
|
||||
return `/(?<${param}>[^/]+)`;
|
||||
})
|
||||
.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);
|
||||
} catch (e) {
|
||||
throw new Error(`Error in FirebaseGlob: "${glob}" - ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
test(url: string) {
|
||||
return XRegExp.test(url, this.regex);
|
||||
}
|
||||
|
||||
match(url: string) {
|
||||
const match = XRegExp.exec(url, this.regex);
|
||||
if (match) {
|
||||
const result = {};
|
||||
const names = (this.regex as any).xregexp.captureNames || [];
|
||||
names.forEach(name => result[name] = match[name]);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function replaceModifiedPattern(_, modifier, pattern) {
|
||||
switch (modifier) {
|
||||
case '!':
|
||||
throw new Error(`"not" expansions are not supported: "${_}"`);
|
||||
case '?':
|
||||
case '+':
|
||||
return `(${pattern})${modifier}`;
|
||||
case '*':
|
||||
return `(${pattern})🐷`; // it will become a star
|
||||
case '@':
|
||||
return `(${pattern})`;
|
||||
default:
|
||||
throw new Error(`unknown expansion type: "${modifier}" in "${_}"`);
|
||||
}
|
||||
}
|
30
aio/tools/firebase-test-utils/FirebaseRedirect.spec.ts
Normal file
30
aio/tools/firebase-test-utils/FirebaseRedirect.spec.ts
Normal file
@ -0,0 +1,30 @@
|
||||
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');
|
||||
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');
|
||||
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>');
|
||||
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');
|
||||
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');
|
||||
expect(redirect.replace('/a/b/c')).toEqual('/x/a/y/b/c/z');
|
||||
});
|
||||
});
|
||||
});
|
16
aio/tools/firebase-test-utils/FirebaseRedirect.ts
Normal file
16
aio/tools/firebase-test-utils/FirebaseRedirect.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import * as XRegExp from 'xregexp';
|
||||
import { FirebaseGlob } from './FirebaseGlob';
|
||||
|
||||
export class FirebaseRedirect {
|
||||
glob = new FirebaseGlob(this.source);
|
||||
constructor(public source: string, public destination: string) {}
|
||||
|
||||
replace(url: string) {
|
||||
const match = this.glob.match(url);
|
||||
if (match) {
|
||||
const paramReplacers = Object.keys(this.glob.namedParams).map(name => [ XRegExp(`:${name}`, 'g'), match[name] ]);
|
||||
const restReplacers = Object.keys(this.glob.restParams).map(name => [ XRegExp(`:${name}\\*`, 'g'), match[name] ]);
|
||||
return XRegExp.replaceEach(this.destination, [...paramReplacers, ...restReplacers]);
|
||||
}
|
||||
}
|
||||
}
|
42
aio/tools/firebase-test-utils/FirebaseRedirector.spec.ts
Normal file
42
aio/tools/firebase-test-utils/FirebaseRedirector.spec.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { FirebaseRedirector } from './FirebaseRedirector';
|
||||
|
||||
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' },
|
||||
{ source: '/**/:foo/c', destination: '/A/:foo/zzz' },
|
||||
]);
|
||||
expect(redirector.redirect('/a/b/c')).toEqual('/X/Y/Z');
|
||||
expect(redirector.redirect('/a/moo/c')).toEqual('/X/moo/Z');
|
||||
expect(redirector.redirect('/x/y/a/b/c')).toEqual('/A/b/zzz');
|
||||
expect(redirector.redirect('/x/y/c')).toEqual('/A/y/zzz');
|
||||
});
|
||||
|
||||
it('should return the original url if no redirect matches', () => {
|
||||
const redirector = new FirebaseRedirector([
|
||||
{ source: 'x', destination: 'X' },
|
||||
{ source: 'y', destination: 'Y' },
|
||||
{ source: 'z', destination: 'Z' },
|
||||
]);
|
||||
expect(redirector.redirect('a')).toEqual('a');
|
||||
});
|
||||
|
||||
it('should recursively redirect', () => {
|
||||
const redirector = new FirebaseRedirector([
|
||||
{ source: 'a', destination: 'b' },
|
||||
{ source: 'b', destination: 'c' },
|
||||
{ source: 'c', destination: 'd' },
|
||||
]);
|
||||
expect(redirector.redirect('a')).toEqual('d');
|
||||
});
|
||||
|
||||
it('should throw if stuck in an infinite loop', () => {
|
||||
const redirector = new FirebaseRedirector([
|
||||
{ source: 'a', destination: 'b' },
|
||||
{ source: 'b', destination: 'c' },
|
||||
{ source: 'c', destination: 'a' },
|
||||
]);
|
||||
expect(() => redirector.redirect('a')).toThrowError('infinite redirect loop');
|
||||
});
|
||||
});
|
36
aio/tools/firebase-test-utils/FirebaseRedirector.ts
Normal file
36
aio/tools/firebase-test-utils/FirebaseRedirector.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { FirebaseRedirect } from './FirebaseRedirect';
|
||||
|
||||
export interface FirebaseRedirectConfig {
|
||||
source: string;
|
||||
destination: string;
|
||||
}
|
||||
|
||||
export class FirebaseRedirector {
|
||||
private redirects: FirebaseRedirect[];
|
||||
constructor(redirects: FirebaseRedirectConfig[]) {
|
||||
this.redirects = redirects.map(redirect => new FirebaseRedirect(redirect.source, redirect.destination));
|
||||
}
|
||||
|
||||
redirect(url: string) {
|
||||
let ttl = 50;
|
||||
while (ttl > 0) {
|
||||
const newUrl = this.doRedirect(url);
|
||||
if (newUrl === url) {
|
||||
return url;
|
||||
} else {
|
||||
url = newUrl;
|
||||
ttl--;
|
||||
}
|
||||
}
|
||||
throw new Error('infinite redirect loop');
|
||||
}
|
||||
private doRedirect(url: string) {
|
||||
for (let i = 0; i < this.redirects.length; i++) {
|
||||
const newUrl = this.redirects[i].replace(url);
|
||||
if (newUrl !== undefined) {
|
||||
return newUrl;
|
||||
}
|
||||
}
|
||||
return url;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user