Skip to content

Instantly share code, notes, and snippets.

@MichaelFedora
Last active June 13, 2023 21:30
Show Gist options
  • Save MichaelFedora/d737352711dbbb82c7a4d046270e4eec to your computer and use it in GitHub Desktop.
Save MichaelFedora/d737352711dbbb82c7a4d046270e4eec to your computer and use it in GitHub Desktop.
Parse router layers into something usable
/**
* Parse Express Router Layers into something usable, with
* formatted paths, parameters lists, and ancestry tracking.
*/
/**
* A "reduced layer", the output of `reduceStack`. Has
* simplified information in it.
*/
export interface ReducedLayer {
/** All of the handles for the layers above this one */
ancestry: ((..._) => unknown)[];
/** The handle (logic) */
handle: (..._) => unknown;
/** The method (GET, POST, etc) */
method: string;
/** The formatted path */
path: string;
/** The route parameters */
params: { name: string | number; optional?: boolean }[];
}
/**
* A very hacky interface for the Express Router Layer type.
*/
interface RouteLayer {
handle?: ((..._) => void) & {
params?: unknown;
_params?: unknown[];
caseSensitive?: boolean;
mergeParams?: boolean;
strict?: boolean;
stack?: RouteLayer[];
};
name?: string;
params: unknown;
path: string;
keys: { name: string; optional: boolean; offset: number }[];
regexp: RegExp;
method: string;
route?: {
path: string | RegExp;
stack: RouteLayer[];
methods: Record<string, boolean>;
};
}
/**
* Reduce a route layer stack from a route into something more
* consumable, with formatted paths and parameter lists.
*
* @param stack The layer stack from a router
* @param base Base information for recursive logic
* @returns The reduced layers
*/
export function reduceStack(
stack: RouteLayer[],
base?: Partial<Pick<ReducedLayer, 'ancestry' | 'path' | 'params'>>
): ReducedLayer[] {
const filledBase: Pick<ReducedLayer, 'ancestry' | 'path' | 'params'> = Object.assign(
{ path: '', params: [], ancestry: [] },
base
);
const basePath = filledBase.path.endsWith('/') ? filledBase.path : (filledBase.path + '/');
const baseParams: Readonly<ReducedLayer['params']> = filledBase.params;
const ancestry: ReducedLayer['ancestry'] = filledBase.ancestry.slice();
const ret: ReducedLayer[] = [];
for(const layer of stack) {
let path = layer.path && typeof layer.path === 'string'
? layer.path
: regexpToPath(layer.regexp, layer.keys);
const params = baseParams.slice();
if(!path)
path = basePath.slice(0, -1); // remove trailing /
else
path = basePath + path.replace(/^\/+|\/+$/g, ''); // replace leading & trailing /'s
if(layer.handle && layer.keys?.length) {
for(const key of layer.keys) {
path = path.replace(':' + key.name, `{${key.name}}`);
params.push({ name: key.name, optional: key.optional });
}
}
if(layer.method)
ret.push({ ancestry: ancestry.slice(), handle: layer.handle, method: layer.method, path, params });
// throw this layer onto the ancestry, for children
// but also for other layers on this level
if(layer.handle)
ancestry.push(layer.handle);
if(layer.name === 'router' && layer.handle?.stack)
for(const subLayer of reduceStack(layer.handle.stack, { path, params, ancestry }))
ret.push(subLayer);
if(layer.route?.stack)
for(const subLayer of reduceStack(layer.route.stack, { path, params, ancestry }))
ret.push(subLayer);
}
return ret;
}
/**
* A heavily modified function to compute a path from a regexp.
*
* - originally from https://github.com/expressjs/express/issues/3308#issuecomment-300957572
* - also from https://github.com/wesleytodd/express-openapi/blob/main/lib/generate-doc.js
*
* @param regexp The regexp to consume
* @param keys The parameter keys to replace
* @returns The formatted path
*/
export function regexpToPath(regexp: RegExp & { fast_slash?: boolean }, keys: { name: string }[]): string {
if(regexp.fast_slash)
return '';
let str = regexp.toString();
if(keys?.length) {
for (const key of keys) {
const closestGeneric = str.indexOf('/(?:([^\\/]+?))');
const closestOverall = str.indexOf('/(');
if(closestOverall === closestGeneric) { // much simpler to do this
str = str.replace('(?:([^\\/]+?))', ':' + key.name);
} else { // if it's a more complicated match..
// replace all the [...] and all loose parentheses with underscores
// so we don't have to worry about them when taking our measurements
const simplerStr = str.replace(
/[^\\]\[(.+?[^\\])\]/,
ss => ss[0] + new Array(ss.length - 1).fill('_').join('')
).replace(/\\[)(]/g, '_');
const first = simplerStr.indexOf('/(') + 1; // find the start point
if(first === 0)
continue; // it broke
let len = 1;
let depth = 1;
// find the end point via checking when our depth of groups reaches 0
for(; depth > 0; len++) {
const char = simplerStr.charAt(first + len);
if(!char)
break;
else if(char === '(')
depth++;
else if(char === ')')
depth--;
}
let pre = simplerStr.slice(0, first - 1);
if(pre.endsWith('/'))
pre = pre.replace(/\/+$/, '');
let post = simplerStr.slice(first + len);
if(post.startsWith('/'))
post = post.replace(/^\/+/, '');
// this would be the way to get the regex-like matcher, but for now
// it is discarded
// matcher = path.slice(first, first + len);
// format it nicely
str = `${pre}/{${key.name}}/${post}`;
}
}
}
return str
.replace('(?=\\/|$)', '$') // weird matchers begone
.replace(/^\/\^\\?|(?:\/\?)?\$\/i$/g, '') // get rid of the prefix/suffix matchers
.replace('\\/?', '') // no weird artifacts
.replace(/\\/gi, ''); // no double slashes
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment