Skip to content

Commit

Permalink
Merge pull request #1 from adamjosefus/development
Browse files Browse the repository at this point in the history
Create init stuff
  • Loading branch information
adamjosefus authored Feb 10, 2022
2 parents ca0c946 + 52be0d8 commit f7628f6
Show file tree
Hide file tree
Showing 18 changed files with 1,136 additions and 1 deletion.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
!.vscode
.DS_Store
5 changes: 5 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"deno.enable": true,
"deno.lint": true,
"deno.unstable": false
}
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1 @@
# deno-routing
# Allo Routing for Deno 🦕
43 changes: 43 additions & 0 deletions helpers/RouterOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* @copyright Copyright (c) 2022 Adam Josefus
*/


export type RouterOptions = {
/**
* Transform `pathname` to new shape.
* May be usfull if you have shifted server root.
*
* ```ts
* const options: RouterOptions = {
* // URL: "http://localhost/my-root/product/detail"
* // Transform "my-root/product/detail" => "product/detail"
* tranformPathname: (pathname) => {
* const prefix = "my-root";
*
* if (pathname.startsWith(prefix))
* return pathname.substring(prefix.length);
*
* return pathname;
* }
* }
* ```
*/
tranformPathname?: (pathname: string) => string;
}


/**
* Create `RouterOptions` with all requesred properties.
* Missin properties will be set to default values.
*/
export function createRequiredOptions(options?: RouterOptions): Required<RouterOptions> {
const fallback: Required<RouterOptions> = {
tranformPathname: (pathname: string) => pathname,
}

return {
tranformPathname: options?.tranformPathname ?? fallback.tranformPathname,
}
}
10 changes: 10 additions & 0 deletions mod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export * from './helpers/RouterOptions.ts';

export * from './routers/Router.ts';
export * from './routers/MaskRouter.ts';
export * from './routers/PatternRouter.ts';
export * from './routers/RegExpRouter.ts';
export * from './routers/RouterMalformedException.ts';

export * from './types/ServeResponseType.ts';
export * from './types/IRouter.ts';
328 changes: 328 additions & 0 deletions routers/MaskRouter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,328 @@
/**
* @copyright Copyright (c) 2022 Adam Josefus
*/


import { Cache } from "https://deno.land/x/allo_caching@v1.0.0/mod.ts";
import { type IRouter } from "../types/IRouter.ts";
import { Router } from "./Router.ts";
import { type ServeResponseType } from "../types/ServeResponseType.ts";
import { type RouterOptions, createRequiredOptions } from "../helpers/RouterOptions.ts";
import { type ParamDeclarationsType } from "../types/ParamDeclarationsType.ts";
import { type ParamValuesType } from "../types/ParamValuesType.ts";
import { RouterMalformedException } from "./RouterMalformedException.ts";


/**
* Class `MaskRouter` is a router that mathing url by mask.
*
* Some part of the mask can be optional. This part is closed to `[` and `]`.
*
* Mask may contains parameters.
* Parameter is defined by `<` and `>` symbols.
* In the parameter you can specific the default value. `<param=default>`
* Also you can define regular expression for value validation.`<param=default regexp>`
*
* Examples:
* - `product[/detail]`
* - `[product[/detail]]`
* - `product/<id>`
* - `page/<id=123 \\d+>`
*
*/
export class MaskRouter extends Router implements IRouter {

readonly #masks: readonly string[];
readonly #serveResponse: ServeResponseType;
readonly #options: Required<RouterOptions>;

readonly #maskParser = /\<(?<name>[a-z][A-z0-9]*)(=(?<defaultValue>.+?))?\s*(\s+(?<expression>.+?))?\>/g;

readonly #matchCache = new Cache<boolean>();
readonly #varialibityCache = new Cache<string[]>();
readonly #paramParserCache = new Cache<RegExp>();
readonly #paramDeclarationCache = new Cache<ParamDeclarationsType>();
readonly #paramValuesCache = new Cache<ParamValuesType | null>();


constructor(mask: string, serveResponse: ServeResponseType, options?: RouterOptions) {
super();

this.#masks = this.#parseVarialibity(MaskRouter.cleanPathname(mask));
this.#serveResponse = serveResponse;
this.#options = createRequiredOptions(options);
}


match(req: Request): boolean {
const pathname = this.#computePathname(req);

return this.#match(pathname)
}


async serveResponse(req: Request): Promise<Response> {
const pathname = this.#computePathname(req);
const mask = this.#getMatchedMask(pathname);

if (mask === null) throw new Error("No mask matched");

const params: Record<string, string> = {};
const paramValues = this.#parseParamValues(mask, pathname);

if (paramValues === null) {
throw new Error("No param values parsed");
}

for (const [name, { value }] of paramValues) {
if (value === null) continue;

params[name] = value;
}

return await this.#serveResponse(req, params);
}


#match(pathname: string): boolean {
const result = this.#masks.some(mask => {
return this.#matchMask(mask, pathname);
});

return result;
}


#getMatchedMask(pathname: string): string | null {
const result = this.#masks.find(mask => {
return this.#matchMask(mask, pathname);
}) ?? null;

return result;
}


#matchMask(mask: string, pathname: string): boolean {
const compute = (mask: string, pathname: string) => {
const paramParser = this.#createParamParser(mask);

paramParser.lastIndex = 0;
if (!paramParser.test(pathname)) return false;

const paramDeclarations = this.#parseParamDeclarations(mask);
if (paramDeclarations === null) return true;

const paramValues = this.#parseParamValues(mask, pathname);
if (paramValues === null) return false;

return Array.from(paramValues.values()).every(({ valid }) => valid);
}

return this.#matchCache.load(`${mask}|${pathname}`, () => compute(mask, pathname));
}


#parseParamDeclarations(mask: string): ParamDeclarationsType | null {
const parse = (mask: string) => {
const declarations: ParamDeclarationsType = new Map();

let order = 1;
let matches: RegExpExecArray | null = null;

this.#maskParser.lastIndex = 0;
while ((matches = this.#maskParser.exec(mask)) !== null) {
const groups = matches.groups! as {
name: string,
defaultValue: string | null,
expression: string | null,
};

const name = groups.name;
const defaultValue = groups.defaultValue ?? null;
const expression = (v => {
if (v !== null) {
try {
return new RegExp(v);
} catch (_err) {
throw new RouterMalformedException(`Invalid expression for argument "${name}"`);
}
} return null;
})(groups.expression ?? null);

declarations.set(name, {
order,
defaultValue,
expression,
})

order++;
}

return declarations;
}

return this.#paramDeclarationCache.load(mask, () => parse(mask));
}


#parseParamValues(mask: string, pathname: string): ParamValuesType | null {
const parse = (mask: string, pathname: string) => {
const paramParser = this.#createParamParser(mask);
const paramValues: ParamValuesType = new Map();

paramParser.lastIndex = 0;
if (!paramParser.test(pathname)) return null;

const paramDeclarations = this.#parseParamDeclarations(mask);
if (paramDeclarations === null) return paramValues;

paramParser.lastIndex = 0;
const exec = paramParser.exec(pathname);

const parsedValues = exec?.groups ?? {};

function isValid(value: string | null, expression: RegExp | null): boolean {
if (expression === null) return true;
if (value === null) return false;

expression.lastIndex = 0;
return expression.test(value)
}


let order = 1;
for (const [name, declaration] of paramDeclarations) {
const parsedValue = parsedValues[name] as string | undefined;
const value = parsedValue ?? declaration.defaultValue ?? null;
const valid = isValid(value, declaration.expression);

paramValues.set(name, {
order,
value,
valid,
});

order++;
}

return paramValues;
}

return this.#paramValuesCache.load(`${mask}|${pathname}`, () => parse(mask, pathname));
}


#createParamParser(mask: string): RegExp {
const parse = (mask: string): RegExp => {
this.#maskParser.lastIndex = 0;
const regexp = mask.replace(this.#maskParser, (_substring, matchedName) => {
return `(?<${matchedName}>[a-zA-Z0-9_~:.=+\-]+)`
});

return new RegExp(`^${regexp}$`);
};

return this.#paramParserCache.load(mask, () => parse(mask));
}


#parseVarialibity(mask: string): string[] {
const parse = (mask: string): string[] => {
const openChar = '[';
const closeChar = ']';

type RangeType = {
open: number,
close: number,
};

const ranges: RangeType[] = []

let openPos: number | null = null;
let closePos: number | null = null;

let skipClosing = 0;

for (let i = 0; i < mask.length; i++) {
const char = mask.at(i);

if (char === openChar) {
if (openPos === null) openPos = i;
else skipClosing++;

} else if (char === closeChar) {
if (skipClosing === 0) closePos = i;
else skipClosing--;
}

if (openPos !== null && closePos !== null) {
ranges.push({
open: openPos,
close: closePos + 1,
});

openPos = null;
closePos = null;
}
}

if ((openPos === null && closePos !== null) || (openPos !== null && closePos === null)) throw new RouterMalformedException(`Malformed Mask "${mask}"`);

if (ranges.length === 0) return [mask];

const baseParts: string[] = ranges.reduce((acc: number[], v, i, arr) => {
if (i === 0) acc.push(0);
acc.push(v.open, v.close);
if (i === arr.length - 1) acc.push(mask.length);

return acc;
}, []).reduce((acc: RangeType[], _n, i, arr) => {
if (i % 2 !== 0) return acc;

acc.push({
open: arr[i + 0],
close: arr[i + 1],
});

return acc;
}, []).map(range => mask.substring(range.open, range.close));

const optionalParts: string[] = ranges.map(range => mask.substring(range.open + openChar.length, range.close - closeChar.length));

const variations: string[] = []

for (let i = 0; i <= optionalParts.length; i++) {
for (let j = i; j <= optionalParts.length; j++) {
const optionals = [...Array(i).map(_ => ''), ...optionalParts.slice(i, j)];

const variation = baseParts.reduce((acc: string[], v, n) => {
acc.push(v);
acc.push(optionals.at(n) ?? '');

return acc;
}, []).join('');

variations.push(variation);
}
}

const result = variations.reduce((acc: string[], variation) => {
acc.push(...this.#parseVarialibity(variation));

return acc;
}, []).filter((v, i, arr) => arr.indexOf(v) === i);

return result;
}

return this.#varialibityCache.load(mask, () => parse(mask));
}


#computePathname(req: Request): string {
const url = new URL(req.url);
const pathname = this.#options.tranformPathname(url.pathname);

return MaskRouter.cleanPathname(pathname);
}
}
Loading

0 comments on commit f7628f6

Please sign in to comment.