UNPKG

@tdb/util

Version:
412 lines (373 loc) 10.5 kB
/** * See: * https://github.com/pillarjs/path-to-regexp */ import { parse as parseUrl } from 'url'; import { queryString } from '../queryString'; import { value as valueUtil } from '../value'; import { pathToRegex } from './libs'; import { IRoute, IRouteMeta, IRouteToken, RouteOptions, RouteParams, RouteQuery, } from './types'; export * from './types'; export type CreateRoute = <P extends RouteParams, Q extends RouteQuery = {}>( path: string, args?: RouteOptions, ) => Route<P, Q>; /** * Represents a URL route. */ export class Route<P extends RouteParams = {}, Q extends RouteQuery = {}> implements IRoute { public static create: CreateRoute = (path, args) => new Route(path, args); public static get: CreateRoute = (path, args) => Route.create(path, { ...args, method: 'GET' }); public static put: CreateRoute = (path, args) => Route.create(path, { ...args, method: 'PUT' }); public static post: CreateRoute = (path, args) => Route.create(path, { ...args, method: 'POST' }); public static delete: CreateRoute = (path, args) => Route.create(path, { ...args, method: 'DELETE' }); public static patch: CreateRoute = (path, args) => Route.create(path, { ...args, method: 'PATCH' }); public readonly path: IRoute['path']; private readonly _options: RouteOptions; private readonly _tokens: pathToRegex.Token[]; private readonly _regex: RegExp; private readonly _toPath: pathToRegex.PathFunction; private constructor(path: string, options: RouteOptions = {}) { path = (path || '').trim(); if (!path) { throw new Error(`A route pattern is required.`); } // Arguments from: // https://github.com/pillarjs/path-to-regexp#usage const sensitive = valueUtil.defaultValue(options.caseSensitive, true); const strict = valueUtil.defaultValue(options.strict, true); const end = valueUtil.defaultValue(options.end, true); const start = valueUtil.defaultValue(options.start, true); const delimiter = valueUtil.defaultValue(options.delimiter, '/'); const endsWith = valueUtil.defaultValue(options.endsWith, undefined); const delimiters = valueUtil.defaultValue(options.delimiters, ['./']); this.path = path; // this.method = method; this._options = options; this._regex = pathToRegex(path, [], { sensitive, strict, end, start, delimiter, endsWith, delimiters, }); this._tokens = pathToRegex.parse(path); this._toPath = pathToRegex.compile(path); } public toString() { return this.path; } /** * Retrieve the origin domain for the route. */ // public get origin() { // const factory = this._origin; // const args: IOriginArgs = { // env: IS_DEV ? 'DEV' : 'PROD', // isProd: !IS_DEV, // isDev: IS_DEV, // isBrowser: IS_BROWSER, // isServer: !IS_BROWSER, // }; // return factory ? factory(args) || '' : ''; // } /** * Names of the types used by the route for TS => JSON-Schema conversion. */ public get schema(): IRoute['schema'] { return this._options.schema || {}; } /** * The HTTP method of the route. */ public get method(): IRoute['method'] { return this._options.method || 'GET'; } /** * The title (used for documentation) */ public get title(): IRoute['title'] { return this._options.title; } /** * The summary description of the route. */ public get description(): IRoute['description'] { return this._options.description; } /** * URL to documentation for the route. */ public get docs(): IRoute['docs'] { return this._options.docs; } /** * URL to documentation for the route. */ public get meta(): IRouteMeta { return this._options.meta || {}; } /** * The set of variable-tokens within the route. */ public get tokens(): IRoute['tokens'] { const toObj = (token: pathToRegex.Key) => { const { name, prefix, delimiter, optional, repeat, partial } = token; const res: IRouteToken = { name, prefix, delimiter, optional, repeat, partial, }; return res; }; return (this._tokens || []).map(token => { return typeof token === 'string' ? token : toObj(token); }); } /** * Determines whether the given URL matches the route pattern. */ public isMatch(url?: string) { return Boolean(this._regex.exec(url || '')); } /** * Extracts the set of parameter values from the given URL. */ public params(url?: string): P { return Route.params(this, url); } public static params<P extends RouteParams = {}>( route: Route, url?: string, ): P { const params = {} as any; if (!url) { return params; } const tokens = route.tokens; const matches = route._regex.exec(url); if (!matches) { return params; } matches.forEach((value, i) => { value = value || ''; const token = tokens[i]; if (typeof token === 'object') { value = value.split('?')[0]; params[token.name] = valueUtil.toType(value); } }); return params; } /** * Extracts the query-string from the given url. * * Note: * Empty values are converted to flags. * * - "/foo?force" // <== true (implicit) * - "/foo?force=true" // <== true (explicit) * - "/foo?force=false" // <== false (explicit) * - "/foo" // <== false, query flag undefined. * * Example: * * const force = asValueFlag(req.query.force) * */ public query(url?: string): Q { return Route.query(url); } public static query<Q extends RouteQuery = {}>(url?: string): Q { const query: any = url ? parseUrl(url, true).query : {}; Object.keys(query).forEach(key => { let value = query[key]; value = value === '' ? queryString.valueAsFlag(value) : value; value = valueUtil.toType(value); query[key] = value; }); return query; } /** * Retrieves a parsed URL. */ public url(url: string = '', options: { origin?: string } = {}): RouteUrl { const { origin } = options; return new RouteUrl<P, Q>({ url, route: this, origin }); } /** * Converts the set of params to a URL. */ public toUrl(args: { params?: P; query?: Q; origin?: string }) { const { params = {}, query = {}, origin } = args; let url = this._toPath(params); const q = Route.toQueryString(query); if (q) { url = url.includes('?') ? `${url}&${q}` : `${url}?${q}`; } return this.url(url, { origin }); } /** * Converts an object to a formatted query-string. */ public static toQueryString(query?: RouteQuery) { const toQuery = ( key: string, value?: string | string[] | number | boolean, ) => { value = value ? encodeURI(value.toString()) : value; return value ? `${key}=${value}` : key; }; const res = query ? Object.keys(query || {}) .filter(key => Boolean(query[key])) .map(key => toQuery(key, query[key])) : []; return res.join('&'); } /** * Creates a clone of the route, overriding values. */ public clone(options: { path?: string } & RouteOptions = {}) { return Route.create(options.path || this.path, { ...this._options, ...options, }); } public toObject(): IRoute { return { path: this.path, method: this.method, title: this.title, description: this.description, docs: this.docs, schema: this.schema, tokens: this.tokens, }; } /** * Walks an object calling the given function for each Route * object that is found. * * NOTE: * This is use when building up index-objects of routes * that you wish to examine as a set. */ public static walk( tree: object | undefined, fn: (route: Route, args: { stop: () => void }) => void, ) { if (!tree) { return; } let stopped = false; for (const key of Object.keys(tree)) { const value = tree[key]; if (tree[key] instanceof Route) { fn(value, { stop: () => (stopped = true) }); } else { if (typeof value === 'object') { this.walk(value, fn); // <== RECURSION } } if (stopped) { return; } } } /** * Walks the tree looking for the first match. */ public static find( tree: object | undefined, match: (route: Route) => boolean, ) { if (!tree) { return; } let result: Route | undefined; Route.walk(tree, (route, e) => { if (match(route) === true) { result = route; e.stop(); } }); return result; } /** * Maps over an object containing an index of routes. */ public static map<T>( tree: object | undefined, fn: (route: Route, index: number) => T, ) { let result: T[] = []; Route.walk( tree, route => (result = [...result, fn(route, result.length - 1)]), ); return result; } public static toString(path: string, options: { origin?: string } = {}) { let res = path; if (options.origin) { res = `${options.origin.replace(/\/*$/, '')}/${res.replace(/^\//, '')}`; } return res; } } /** * Represents a specific URL. */ export class RouteUrl<P extends RouteParams = {}, Q extends RouteQuery = {}> { public readonly path: string; public readonly route: Route; public readonly params: P; public readonly query: Q; public readonly origin: string | undefined; constructor(args: { url: string; route: Route<P, Q>; origin?: string }) { const { url, route, origin = '' } = args; this.path = url; this.route = route; this.params = Route.params<P>(route, url); this.query = Route.query<Q>(url); this.origin = origin.trim() ? origin.replace(/\/*$/, '') : undefined; } public get s() { return this.toString(); } public toString(options: { origin?: string } = {}) { const origin = options.origin || this.origin; return Route.toString(this.path, { ...options, origin }); } /** * Checks a set of keys within the URL's query-string to see * if any of them are flags. * * For example: * * url.hasFlag(['f', 'force']); * */ public hasFlag(key?: string | string[]) { return queryString.isFlag(key, this.query); } }