From ad14560a2cded82699dc83ccab1d28906eaddc9d Mon Sep 17 00:00:00 2001 From: ton1c Date: Wed, 12 Feb 2025 14:15:41 +0300 Subject: [PATCH] refactored context and router --- server/src/lib/context.ts | 197 +++++++++++++++++------------------ server/src/lib/errors.ts | 2 + server/src/lib/router.ts | 81 +++++++------- server/src/lib/routerTree.ts | 125 +++++++++------------- server/test.db | Bin 53248 -> 53248 bytes test.py | 5 + 6 files changed, 198 insertions(+), 212 deletions(-) create mode 100644 test.py diff --git a/server/src/lib/context.ts b/server/src/lib/context.ts index 6240a62..ff3028a 100644 --- a/server/src/lib/context.ts +++ b/server/src/lib/context.ts @@ -3,13 +3,13 @@ import { type ExtractRouteParams } from "@lib/router.ts"; import { fromNullableVal, none, Option, some } from "@shared/utils/option.ts"; import { deleteCookie, getCookies, setCookie } from "@std/http/cookie"; import { type Cookie } from "@std/http/cookie"; -import { getMessageFromError } from "@shared/utils/result.ts"; +import { getMessageFromError, ok } from "@shared/utils/result.ts"; import { InferSchemaType, Schema, SchemaValidationError, } from "@shared/utils/validator.ts"; -import { ResultAsync } from "@shared/utils/resultasync.ts"; +import { okAsync, ResultAsync } from "@shared/utils/resultasync.ts"; import log from "@shared/utils/logger.ts"; import { FailedToParseRequestAsJSON } from "@src/lib/errors.ts"; @@ -25,63 +25,79 @@ const SECURITY_HEADERS: Headers = new Headers({ //"Content-Security-Policy": // "default-src 'self'; script-src 'self' 'unsafe-inline'", }); -const HTML_CONTENT_TYPE: [string, string] = [ - "Content-Type", - "text/html; charset=UTF-8", -]; -const JSON_CONTENT_TYPE: [string, string] = [ - "Content-Type", - "application/json; charset=utf-8", -]; + +const HTML_CONTENT_TYPE: string = "text/html; charset=UTF-8"; +const JSON_CONTENT_TYPE: string = "application/json; charset=utf-8"; function mergeHeaders(...headers: Headers[]): Headers { - const mergedHeaders = new Headers(); - for (const _headers of headers) { - for (const [key, value] of _headers.entries()) { - mergedHeaders.set(key, value); - } + const merged = new Headers(); + for (const hdr of headers) { + hdr.forEach((value, key) => merged.set(key, value)); } - return mergedHeaders; + return merged; } +export type ContextWithSchema< + C extends Context, + ReqSchema extends Schema, + ResSchema extends Schema, +> = C extends Context ? Context & { + schema: { req: ReqSchema; res: ResSchema }; + } + : never; + export class Context< S extends string = string, - ReqSchema extends Schema | never = never, - ResSchema extends Schema | never = never, + ReqSchema extends Schema = Schema, + ResSchema extends Schema = Schema, > { private _url?: URL; private _hostname?: string; private _port?: number; private _cookies?: Record; - private _responseHeaders: Headers = new Headers(); - public res: Response = new Response(); + + public res = new Response(); constructor( public readonly req: Request, public readonly info: Deno.ServeHandlerInfo, public readonly params: Params>, - public schema: [ReqSchema, ResSchema] extends [never, never] ? never - : { - req: ReqSchema; - res: ResSchema; - }, ) {} + public schema?: { req: ReqSchema; res: ResSchema }; + + public withSchema< + Req extends Schema, + Res extends Schema, + >( + schema: { req: Req; res: Res }, + ): Context & { schema: { req: Req; res: Res } } { + const ctx = new Context(this.req, this.info, this.params); + ctx._url = this._url; + ctx._hostname = this._hostname; + ctx._port = this._port; + ctx._cookies = this._cookies; + ctx.res = this.res; + ctx.schema = schema; + return ctx as Context & { schema: { req: Req; res: Res } }; + } + public parseBody(): ResultAsync< InferSchemaType, SchemaValidationError | FailedToParseRequestAsJSON > { - if (!this.schema || !this.schema.req) { - log.error("No schema provided!"); - Deno.exit(1); - } - return ResultAsync .fromPromise( this.req.json(), (e) => new FailedToParseRequestAsJSON(getMessageFromError(e)), ) - .andThen((data: unknown) => this.schema.req.parse(data)); + .andThen((data: unknown) => { + if (!this.schema) { + return ok(data); + } + + return this.schema?.req.parse(data); + }); } get url(): URL { @@ -93,29 +109,19 @@ export class Context< } get preferredType(): Option<"json" | "html"> { - const headers = new Headers(this.req.headers); - return fromNullableVal(headers.get("accept")).andThen( - (types_header) => { - const types = types_header.split(";")[0].trim().split(","); - - for (const type of types) { - if (type === "text/html") { - return some("html"); - } - if (type === "application/json") { - return some("json"); - } - } - return none; - }, - ); + const accept = this.req.headers.get("accept"); + if (!accept) return none; + const types = accept + .split(",") + .map((t) => t.split(";")[0].trim()); + if (types.includes("text/html")) return some("html"); + if (types.includes("application/json")) return some("json"); + return none; } get hostname(): Option { if (this._hostname) return some(this._hostname); - const remoteAddr = this.info.remoteAddr; - if (remoteAddr.transport === "tcp" || remoteAddr.transport === "udp") { this._hostname = remoteAddr.hostname; return some(remoteAddr.hostname); @@ -125,9 +131,7 @@ export class Context< get port(): Option { if (this._port) return some(this._port); - const remoteAddr = this.info.remoteAddr; - if (remoteAddr.transport === "tcp" || remoteAddr.transport === "udp") { this._port = remoteAddr.port; return some(remoteAddr.port); @@ -135,19 +139,27 @@ export class Context< return none; } + private buildHeaders( + initHeaders?: HeadersInit, + contentType?: string, + ): Headers { + const merged = mergeHeaders( + SECURITY_HEADERS, + this.res.headers, + new Headers(initHeaders), + ); + if (contentType) merged.set("Content-Type", contentType); + return merged; + } + public json( body?: ResSchema extends Schema ? T : object | string, init: ResponseInit = {}, ): Response { - const headers = mergeHeaders( - SECURITY_HEADERS, - this._responseHeaders, - new Headers(init.headers), - ); - headers.set(...JSON_CONTENT_TYPE); - let status = init.status || 200; - + const headers = this.buildHeaders(init.headers, JSON_CONTENT_TYPE); + let status = init.status ?? 200; let responseBody: BodyInit | null = null; + if (typeof body === "string") { responseBody = body; } else if (body !== undefined) { @@ -162,82 +174,67 @@ export class Context< } } - return new Response(responseBody, { + this.res = new Response(responseBody, { status, headers, }); + return this.res; } - public json400(body?: object | string, init: ResponseInit = {}) { - init.status = 400; - return this.json(body, init); + public json400( + body?: ResSchema extends Schema ? T : object | string, + init: ResponseInit = {}, + ): Response { + return this.json(body, { ...init, status: 400 }); } - public json500(body?: object | string, init: ResponseInit = {}) { - init.status = 400; - return this.json(body, init); + public json500( + body?: ResSchema extends Schema ? T : object | string, + init: ResponseInit = {}, + ) { + return this.json(body, { ...init, status: 500 }); } public html(body?: BodyInit | null, init: ResponseInit = {}): Response { - const headers = mergeHeaders( - SECURITY_HEADERS, - this._responseHeaders, - new Headers(init.headers), - ); - headers.set(...HTML_CONTENT_TYPE); + const headers = this.buildHeaders(init.headers, HTML_CONTENT_TYPE); const status = init.status ?? 200; - - return new Response(body ?? null, { - status, - headers, - }); + this.res = new Response(body ?? null, { status, headers }); + return this.res; } public redirect(url: string, permanent = false): Response { const headers = mergeHeaders( - this._responseHeaders, + this.res.headers, new Headers({ location: url }), ); - - return new Response(null, { + this.res = new Response(null, { status: permanent ? 301 : 302, headers, }); + return this.res; } - public cookies = (() => { - const self = this; - + public get cookies() { return { - get(name: string): Option { - if (!self._cookies) { - self._cookies = getCookies(self.req.headers); - } - - return fromNullableVal(self._cookies[name]); - }, - - set(cookie: Cookie) { - setCookie(self._responseHeaders, cookie); - }, - - delete(name: string) { - deleteCookie(self._responseHeaders, name); + get: (name: string): Option => { + this._cookies ??= getCookies(this.req.headers); + return fromNullableVal(this._cookies[name]); }, + set: (cookie: Cookie) => setCookie(this.res.headers, cookie), + delete: (name: string) => deleteCookie(this.res.headers, name), }; - })(); + } static setParams( ctx: Context, params: Params>, ): Context { - const newCtx = new Context(ctx.req, ctx.info, params); + const newCtx = new Context(ctx.req, ctx.info, params) as typeof ctx; newCtx._url = ctx._url; newCtx._hostname = ctx._hostname; newCtx._port = ctx._port; newCtx._cookies = ctx._cookies; - newCtx._responseHeaders = ctx._responseHeaders; newCtx.schema = ctx.schema; return newCtx; diff --git a/server/src/lib/errors.ts b/server/src/lib/errors.ts index 60021f2..233ef9d 100644 --- a/server/src/lib/errors.ts +++ b/server/src/lib/errors.ts @@ -1,6 +1,8 @@ import { + Schema, SchemaValidationError, ValidationErrorDetail, + z, } from "@shared/utils/validator.ts"; export class ErrorBase extends Error { diff --git a/server/src/lib/router.ts b/server/src/lib/router.ts index 5cc6d6d..4ab372f 100644 --- a/server/src/lib/router.ts +++ b/server/src/lib/router.ts @@ -8,9 +8,8 @@ type RequestHandler< S extends string, ReqSchema extends Schema = never, ResSchema extends Schema = never, -> = ( - c: Context, -) => Promise | Response; +> = (c: Context) => Promise | Response; + type RequestHandlerWithSchema = { handler: RequestHandler; schema?: { @@ -37,20 +36,22 @@ const DEFAULT_NOT_FOUND_HANDLER = () => new Response("404 Not found"); class HttpRouter { public readonly routerTree = new RouterTree>(); - pathPreprocessor?: (path: string) => string; + public pathPreprocessor?: (path: string) => string; private middlewares: Middleware[] = []; - defaultNotFoundHandler: RequestHandler = DEFAULT_NOT_FOUND_HANDLER; + public defaultNotFoundHandler: RequestHandler = + DEFAULT_NOT_FOUND_HANDLER; - setPathProcessor(processor: (path: string) => string) { + public setPathProcessor(processor: (path: string) => string) { this.pathPreprocessor = processor; + return this; } - use(mw: Middleware): HttpRouter { + public use(mw: Middleware): this { this.middlewares.push(mw); return this; } - add( + public add( path: S, method: string, handler: RequestHandler, @@ -59,7 +60,7 @@ class HttpRouter { req: Schema; }, ): HttpRouter; - add( + public add( path: S[], method: string, handler: RequestHandler, @@ -68,7 +69,7 @@ class HttpRouter { req: Schema; }, ): HttpRouter; - add( + public add( path: string | string[], method: string, handler: RequestHandler, @@ -95,31 +96,43 @@ class HttpRouter { return this; } - get(path: S, handler: RequestHandler): HttpRouter; - get( + public get( + path: S, + handler: RequestHandler, + ): HttpRouter; + public get( path: S[], handler: RequestHandler, ): HttpRouter; - get(path: string | string[], handler: RequestHandler): HttpRouter { + public get( + path: string | string[], + handler: RequestHandler, + ): HttpRouter { if (Array.isArray(path)) { return this.add(path, "GET", handler); } return this.add(path, "GET", handler); } - post(path: S, handler: RequestHandler): HttpRouter; - post( + public post( + path: S, + handler: RequestHandler, + ): HttpRouter; + public post( path: string[], handler: RequestHandler, ): HttpRouter; - post(path: string | string[], handler: RequestHandler): HttpRouter { + public post( + path: string | string[], + handler: RequestHandler, + ): HttpRouter { if (Array.isArray(path)) { return this.add(path, "POST", handler); } return this.add(path, "POST", handler); } - api< + public api< Path extends string, ReqSchema extends Schema, ResSchema extends Schema, @@ -134,31 +147,23 @@ class HttpRouter { req: Request, connInfo: Deno.ServeHandlerInfo, ): Promise { - const c = new Context(req, connInfo, {}); - + let ctx = new Context(req, connInfo, {}); + let routeParams: Record = {}; const path = this.pathPreprocessor - ? this.pathPreprocessor(c.path) - : c.path; - - let params: Record = {}; + ? this.pathPreprocessor(ctx.path) + : ctx.path; const handler = this.routerTree .find(path) - .andThen((routeMatch) => { - const { value: methodToHandler, params: paramsMatched } = - routeMatch; - - params = paramsMatched; - - const handlerAndSchema = methodToHandler[req.method]; - - if (!handlerAndSchema) { - return none; + .andThen((match) => { + const { value: methodHandler, params: params } = match; + routeParams = params; + const route = methodHandler[req.method]; + if (!route) return none; + if (route.schema) { + ctx = ctx.withSchema(route.schema); } - - const handler = handlerAndSchema.handler; - - c.schema = handlerAndSchema.schema; + const handler = route.handler; return some(handler); }) @@ -167,7 +172,7 @@ class HttpRouter { const res = (await this.executeMiddlewareChain( this.middlewares, handler, - Context.setParams(c, params), + Context.setParams(ctx, routeParams), )).res; return res; diff --git a/server/src/lib/routerTree.ts b/server/src/lib/routerTree.ts index 2e53668..1cb8a9a 100644 --- a/server/src/lib/routerTree.ts +++ b/server/src/lib/routerTree.ts @@ -148,83 +148,31 @@ export class RouterTree { public add(path: string, handler: T): void { const segments = this.splitPath(path); - const paramNames: string[] = this.extractParams(segments); - let current: Node = this.root; + const paramNames: string[] = this.extractParamNames(segments); + const node: Node = this.traverseOrCreate(segments); - for (const segment of segments) { - current = current - .getChild(segment) - .unwrapOrElse(() => - current.addChild( - segment, - this.wildcardSymbol, - this.paramPrefix, - ) - ); - - if (current.isWildcardNode()) { - current.paramNames = paramNames; - current.paramNames.push("restOfThePath"); - current.handler = some(handler); - return; - } - } - - current.paramNames = paramNames; - current.handler = some(handler); + node.paramNames = node.isWildcardNode() + ? [...paramNames, "restOfThePath"] + : paramNames; + node.handler = some(handler); } public find(path: string): Option> { - const segments = this.splitPath(path); - const paramValues: string[] = []; - let current: Node = this.root; - let i = 0; - - for (; i < segments.length; i++) { - const segment = segments[i]; - if (current.isWildcardNode()) break; - - const nextNode = current.getChild(segment).ifSome((child) => { - if (child.isDynamicNode()) { - paramValues.push(segment); - } - current = child; - }); - - if (nextNode.isNone()) return none; - } - - if (current.isWildcardNode()) { - const rest = segments.slice(i - 1); - if (rest.length > 0) { - paramValues.push(rest.join(this.pathSeparator)); + return this.traverse(path).andThen(({ node, paramValues }) => { + const params: Params = {}; + for ( + let i = 0; + i < Math.min(paramValues.length, node.paramNames.length); + i++ + ) { + params[node.paramNames[i]] = paramValues[i]; } - } - - const params: Params = {}; - - for (let i = 0; i < paramValues.length; i++) { - params[current.paramNames[i]] = paramValues[i]; - } - - return current.handler.map((value) => ({ value, params })); + return node.handler.map((handler) => ({ value: handler, params })); + }); } public getHandler(path: string): Option { - const segments = this.splitPath(path); - let current: Node = this.root; - - for (const segment of segments) { - if (current.isWildcardNode()) break; - - const child = current.getChild(segment).ifSome((child) => { - current = child; - }); - - if (child.isNone()) return none; - } - - return current.handler; + return this.traverse(path).andThen(({ node }) => node.handler); } private traverseOrCreate(segments: string[]): Node { @@ -235,20 +183,49 @@ export class RouterTree { node.addChild(segment, this.wildcardSymbol, this.paramPrefix) ); } + return node; + } + + private traverse( + path: string, + ): Option<{ node: Node; paramValues: string[] }> { + const segments = this.splitPath(path); + const paramValues: string[] = []; + let node: Node = this.root; + + for (let i = 0; i < segments.length; i++) { + if (node.isWildcardNode()) { + const remaining = segments.slice(i).join(this.pathSeparator); + if (remaining) paramValues.push(remaining); + return some({ node, paramValues }); + } + + const childOpt = node.getChild(segments[i]); + if (childOpt.isNone()) return none; + + node = childOpt.unwrap(); + if (node.isDynamicNode()) { + paramValues.push(segments[i]); + } + } + + return some({ node, paramValues }); } private splitPath(path: string): string[] { - const trimmed = path.trim().replace(/^\/+/, "").replace(/\/+$/, ""); - return trimmed ? trimmed.split(this.pathSeparator) : []; + return path + .trim() + .split(this.pathSeparator) + .filter((segment) => segment.length > 0); } - public extractParams(segments: string[]): string[] { + public extractParamNames(segments: string[]): string[] { return segments.filter((segment) => segment.startsWith(this.paramPrefix) - ).map((segment) => this.stripParamPrefix(segment)); + ).map((segment) => this.removeParamPrefix(segment)); } - public stripParamPrefix(segment: string): string { + public removeParamPrefix(segment: string): string { return segment.slice(this.paramPrefix.length); } } diff --git a/server/test.db b/server/test.db index 5bada1cbc2a7fd19349c301df0ba392ee54cb0fa..898143539fedba603d5140e72d839ff2bc79517f 100644 GIT binary patch delta 319 zcmZozz}&Ead4e?K#ECM_j1xB|)Y&ugZoY4SjNdfPrzpq7*f+P()F3R#tsp!&+sDF4 z7WFC{lJE3m>a+}*9X(84>=%(vXqE5IPd%%{YE@-2TkEh7UfV`D2*3tk2W241#1 z4E#QPGkBly8t~L`pWqhZO5j|>@q@#KeFEE^&4L2E*y%=uvRxv)Or70KlFM>T9X)b#JtDJ84P4CqB9j~o z3o1)JjVmo&U2>`l%u`)`bMkYt(hGf^gEP#%((??0b9~c00zeM0QEh28WI=Ivb468JR&Ym5 zYidz0cS&hUa%yQzZg5X&N-k%4Wlu|JRCQuyWkNPlY*}@a+D{%PF*70Gz;Xw(kXLYc$XzRQ diff --git a/test.py b/test.py new file mode 100644 index 0000000..b7d9807 --- /dev/null +++ b/test.py @@ -0,0 +1,5 @@ +import math + +a = max(4, 6) + +print(a)