export interface RouteDefaults { [index: string]: string | null; } export interface RouteRequirements { [index: string]: string; } export interface RouteParams { [index: string]: any; } export interface QueryParamAddFunction { (prefix: string, params: any): void; } export interface Route { tokens: (string|boolean)[][]; defaults: undefined[] | RouteDefaults; requirements: undefined[] | RouteRequirements; hosttokens: string[][]; schemes: string[]; methods: string[]; } export interface RoutesMap { [index: string]: Route; } export interface Context { base_url: string; prefix: string; host: string; port: string | null; scheme: string; locale: string | null; } export interface RoutingData { base_url: string; routes: RoutesMap; prefix?: string; host: string; port?: string | null; scheme?: string; locale?: string | null; } export class Router { private context_: Context; private routes_!: RoutesMap; static getInstance(): Router { return Routing; } static setData(data: RoutingData): void { const router = Router.getInstance(); router.setRoutingData(data); } constructor(context?: Context, routes?: RoutesMap) { this.context_ = context || { base_url: '', prefix: '', host: '', port: '', scheme: '', locale: '' }; this.setRoutes(routes || {}); } setRoutingData(data: RoutingData): void { this.setBaseUrl(data['base_url']); this.setRoutes(data['routes']); if (typeof data.prefix !== 'undefined') { this.setPrefix(data['prefix']); } if (typeof data.port !== 'undefined') { this.setPort(data['port']); } if (typeof data.locale !== 'undefined') { this.setLocale(data['locale']); } this.setHost(data['host']); if (typeof data.scheme !== 'undefined') { this.setScheme(data['scheme']); } } setRoutes(routes: RoutesMap): void { this.routes_ = Object.freeze(routes); } getRoutes(): RoutesMap { return this.routes_; } setBaseUrl(baseUrl: string): void { this.context_.base_url = baseUrl; } getBaseUrl(): string { return this.context_.base_url; } setPrefix(prefix: string): void { this.context_.prefix = prefix; } setScheme(scheme: string): void { this.context_.scheme = scheme; } getScheme(): string { return this.context_.scheme; } setHost(host: string): void { this.context_.host = host; } getHost(): string { return this.context_.host; } setPort(port: string | null) { this.context_.port = port; } getPort(): string | null { return this.context_.port; }; setLocale(locale: string | null) { this.context_.locale = locale; } getLocale(): string | null { return this.context_.locale; }; /** * Builds query string params added to a URL. * Port of jQuery's $.param() function, so credit is due there. */ buildQueryParams(prefix: string, params: any, add: QueryParamAddFunction): void { let name; let rbracket = new RegExp(/\[\]$/); if (params instanceof Array) { params.forEach((val, i) => { if (rbracket.test(prefix)) { add(prefix, val); } else { this.buildQueryParams(prefix + '[' + (typeof val === 'object' ? i : '') + ']', val, add); } }); } else if (typeof params === 'object') { for (name in params) { this.buildQueryParams(prefix + '[' + name + ']', params[name], add); } } else { add(prefix, params); } } /** * Returns a raw route object. */ getRoute(name: string): Route { let prefixedName = this.context_.prefix + name; let sf41i18nName = name + '.' + this.context_.locale; let prefixedSf41i18nName = this.context_.prefix + name + '.' + this.context_.locale; let variants = [prefixedName, sf41i18nName, prefixedSf41i18nName, name]; for (let i in variants) { if (variants[i] in this.routes_) { return this.routes_[variants[i]]; } } throw new Error('The route "' + name + '" does not exist.'); } /** * Generates the URL for a route. */ generate(name: string, opt_params?: RouteParams, absolute?: boolean): string { let route = (this.getRoute(name)); let params = opt_params || {}; let unusedParams = Object.assign({}, params); let url = ''; let optional = true; let host = ''; let port = (typeof this.getPort() == 'undefined' || this.getPort() === null) ? '' : this.getPort(); route.tokens.forEach((token) => { if ('text' === token[0] && typeof token[1] === 'string') { url = Router.encodePathComponent(token[1]) + url; optional = false; return; } if ('variable' === token[0]) { if (token.length === 6 && token[5] === true) { // Sixth part of the token array indicates if it should be included in case of defaults optional = false; } let hasDefault = route.defaults && !Array.isArray(route.defaults) && typeof token[3] === 'string' && (token[3] in route.defaults); if (false === optional || !hasDefault || ((typeof token[3] === 'string' && token[3] in params) && !Array.isArray(route.defaults) && params[token[3]] != route.defaults[token[3]])) { let value; if (typeof token[3] === 'string' && token[3] in params) { value = params[token[3]]; delete unusedParams[token[3]]; } else if (typeof token[3] === 'string' && hasDefault && !Array.isArray(route.defaults)) { value = route.defaults[token[3]]; } else if (optional) { return; } else { throw new Error('The route "' + name + '" requires the parameter "' + token[3] + '".'); } let empty = true === value || false === value || '' === value; if (!empty || !optional) { let encodedValue = Router.encodePathComponent(value); if ('null' === encodedValue && null === value) { encodedValue = ''; } url = token[1] + encodedValue + url; } optional = false; } else if (hasDefault && (typeof token[3] === 'string' && token[3] in unusedParams)) { delete unusedParams[token[3]]; } return; } throw new Error('The token type "' + token[0] + '" is not supported.'); }); if (url === '') { url = '/'; } route.hosttokens.forEach((token) => { let value; if ('text' === token[0]) { host = token[1] + host; return; } if ('variable' === token[0]) { if (token[3] in params) { value = params[token[3]]; delete unusedParams[token[3]]; } else if (route.defaults && !Array.isArray(route.defaults) && (token[3] in route.defaults)) { value = route.defaults[token[3]]; } host = token[1] + value + host; } }); url = this.context_.base_url + url; if (route.requirements && ('_scheme' in route.requirements) && this.getScheme() != route.requirements['_scheme']) { const currentHost = host || this.getHost(); url = route.requirements['_scheme'] + '://' + currentHost + (currentHost.indexOf(':' + port) > -1 || '' === port ? '' : ':' + port) + url; } else if ('undefined' !== typeof route.schemes && 'undefined' !== typeof route.schemes[0] && this.getScheme() !== route.schemes[0]) { const currentHost = host || this.getHost(); url = route.schemes[0] + '://' + currentHost + (currentHost.indexOf(':' + port) > -1 || '' === port ? '' : ':' + port) + url; } else if (host && this.getHost() !== host + (host.indexOf(':' + port) > -1 || '' === port ? '' : ':' + port)) { url = this.getScheme() + '://' + host + (host.indexOf(':' + port) > -1 || '' === port ? '' : ':' + port) + url; } else if (absolute === true) { url = this.getScheme() + '://' + this.getHost() + (this.getHost().indexOf(':' + port) > -1 || '' === port ? '' : ':' + port) + url; } if (Object.keys(unusedParams).length > 0) { let queryParams: string[] = []; let add = (key: string, value: string|(() => string)) => { // if value is a function then call it and assign it's return value as value value = (typeof value === 'function') ? value() : value; // change null to empty string value = (value === null) ? '' : value; queryParams.push(Router.encodeQueryComponent(key) + '=' + Router.encodeQueryComponent(value)); }; for (const prefix in unusedParams) { if(unusedParams.hasOwnProperty(prefix)) { this.buildQueryParams(prefix, unusedParams[prefix], add); } } url = url + '?' + queryParams.join('&'); } return url; } /** * Returns the given string encoded to mimic Symfony URL generator. */ static customEncodeURIComponent(value: string): string { return encodeURIComponent(value) .replace(/%2F/g, '/') .replace(/%40/g, '@') .replace(/%3A/g, ':') .replace(/%21/g, '!') .replace(/%3B/g, ';') .replace(/%2C/g, ',') .replace(/%2A/g, '*') .replace(/\(/g, '%28') .replace(/\)/g, '%29') .replace(/'/g, '%27') ; } /** * Returns the given path properly encoded to mimic Symfony URL generator. */ static encodePathComponent(value: string): string { return Router.customEncodeURIComponent(value) .replace(/%3D/g, '=') .replace(/%2B/g, '+') .replace(/%21/g, '!') .replace(/%7C/g, '|') ; } /** * Returns the given query parameter or value properly encoded to mimic Symfony URL generator. */ static encodeQueryComponent(value: string): string { return Router.customEncodeURIComponent(value) .replace(/%3F/g, '?') ; } } export const Routing = new Router(); export default Routing;