-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #1 from adamjosefus/development
Create init stuff
- Loading branch information
Showing
18 changed files
with
1,136 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
!.vscode | ||
.DS_Store |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
{ | ||
"deno.enable": true, | ||
"deno.lint": true, | ||
"deno.unstable": false | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1 @@ | ||
# deno-routing | ||
# Allo Routing for Deno 🦕 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
Oops, something went wrong.