@tdb/util
Version:
Shared helpers and utilities.
412 lines (373 loc) • 10.5 kB
text/typescript
/**
* 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);
}
}