refactored context and router

This commit is contained in:
2025-02-12 14:15:41 +03:00
parent f0ec7a1f00
commit ad14560a2c
6 changed files with 198 additions and 212 deletions

View File

@ -3,13 +3,13 @@ import { type ExtractRouteParams } from "@lib/router.ts";
import { fromNullableVal, none, Option, some } from "@shared/utils/option.ts"; import { fromNullableVal, none, Option, some } from "@shared/utils/option.ts";
import { deleteCookie, getCookies, setCookie } from "@std/http/cookie"; import { deleteCookie, getCookies, setCookie } from "@std/http/cookie";
import { type Cookie } 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 { import {
InferSchemaType, InferSchemaType,
Schema, Schema,
SchemaValidationError, SchemaValidationError,
} from "@shared/utils/validator.ts"; } 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 log from "@shared/utils/logger.ts";
import { FailedToParseRequestAsJSON } from "@src/lib/errors.ts"; import { FailedToParseRequestAsJSON } from "@src/lib/errors.ts";
@ -25,63 +25,79 @@ const SECURITY_HEADERS: Headers = new Headers({
//"Content-Security-Policy": //"Content-Security-Policy":
// "default-src 'self'; script-src 'self' 'unsafe-inline'", // "default-src 'self'; script-src 'self' 'unsafe-inline'",
}); });
const HTML_CONTENT_TYPE: [string, string] = [
"Content-Type", const HTML_CONTENT_TYPE: string = "text/html; charset=UTF-8";
"text/html; charset=UTF-8", const JSON_CONTENT_TYPE: string = "application/json; charset=utf-8";
];
const JSON_CONTENT_TYPE: [string, string] = [
"Content-Type",
"application/json; charset=utf-8",
];
function mergeHeaders(...headers: Headers[]): Headers { function mergeHeaders(...headers: Headers[]): Headers {
const mergedHeaders = new Headers(); const merged = new Headers();
for (const _headers of headers) { for (const hdr of headers) {
for (const [key, value] of _headers.entries()) { hdr.forEach((value, key) => merged.set(key, value));
mergedHeaders.set(key, value);
}
} }
return mergedHeaders; return merged;
} }
export type ContextWithSchema<
C extends Context<string, any, any>,
ReqSchema extends Schema<any>,
ResSchema extends Schema<any>,
> = C extends Context<infer S, any, any> ? Context<S, ReqSchema, ResSchema> & {
schema: { req: ReqSchema; res: ResSchema };
}
: never;
export class Context< export class Context<
S extends string = string, S extends string = string,
ReqSchema extends Schema<any> | never = never, ReqSchema extends Schema<any> = Schema<unknown>,
ResSchema extends Schema<any> | never = never, ResSchema extends Schema<any> = Schema<unknown>,
> { > {
private _url?: URL; private _url?: URL;
private _hostname?: string; private _hostname?: string;
private _port?: number; private _port?: number;
private _cookies?: Record<string, string>; private _cookies?: Record<string, string>;
private _responseHeaders: Headers = new Headers();
public res: Response = new Response(); public res = new Response();
constructor( constructor(
public readonly req: Request, public readonly req: Request,
public readonly info: Deno.ServeHandlerInfo<Deno.Addr>, public readonly info: Deno.ServeHandlerInfo<Deno.Addr>,
public readonly params: Params<ExtractRouteParams<S>>, public readonly params: Params<ExtractRouteParams<S>>,
public schema: [ReqSchema, ResSchema] extends [never, never] ? never
: {
req: ReqSchema;
res: ResSchema;
},
) {} ) {}
public schema?: { req: ReqSchema; res: ResSchema };
public withSchema<
Req extends Schema<any>,
Res extends Schema<any>,
>(
schema: { req: Req; res: Res },
): Context<S, Req, Res> & { schema: { req: Req; res: Res } } {
const ctx = new Context<S, Req, Res>(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<S, Req, Res> & { schema: { req: Req; res: Res } };
}
public parseBody(): ResultAsync< public parseBody(): ResultAsync<
InferSchemaType<ReqSchema>, InferSchemaType<ReqSchema>,
SchemaValidationError | FailedToParseRequestAsJSON SchemaValidationError | FailedToParseRequestAsJSON
> { > {
if (!this.schema || !this.schema.req) {
log.error("No schema provided!");
Deno.exit(1);
}
return ResultAsync return ResultAsync
.fromPromise( .fromPromise(
this.req.json(), this.req.json(),
(e) => new FailedToParseRequestAsJSON(getMessageFromError(e)), (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 { get url(): URL {
@ -93,29 +109,19 @@ export class Context<
} }
get preferredType(): Option<"json" | "html"> { get preferredType(): Option<"json" | "html"> {
const headers = new Headers(this.req.headers); const accept = this.req.headers.get("accept");
return fromNullableVal(headers.get("accept")).andThen( if (!accept) return none;
(types_header) => { const types = accept
const types = types_header.split(";")[0].trim().split(","); .split(",")
.map((t) => t.split(";")[0].trim());
for (const type of types) { if (types.includes("text/html")) return some("html");
if (type === "text/html") { if (types.includes("application/json")) return some("json");
return some("html"); return none;
}
if (type === "application/json") {
return some("json");
}
}
return none;
},
);
} }
get hostname(): Option<string> { get hostname(): Option<string> {
if (this._hostname) return some(this._hostname); if (this._hostname) return some(this._hostname);
const remoteAddr = this.info.remoteAddr; const remoteAddr = this.info.remoteAddr;
if (remoteAddr.transport === "tcp" || remoteAddr.transport === "udp") { if (remoteAddr.transport === "tcp" || remoteAddr.transport === "udp") {
this._hostname = remoteAddr.hostname; this._hostname = remoteAddr.hostname;
return some(remoteAddr.hostname); return some(remoteAddr.hostname);
@ -125,9 +131,7 @@ export class Context<
get port(): Option<number> { get port(): Option<number> {
if (this._port) return some(this._port); if (this._port) return some(this._port);
const remoteAddr = this.info.remoteAddr; const remoteAddr = this.info.remoteAddr;
if (remoteAddr.transport === "tcp" || remoteAddr.transport === "udp") { if (remoteAddr.transport === "tcp" || remoteAddr.transport === "udp") {
this._port = remoteAddr.port; this._port = remoteAddr.port;
return some(remoteAddr.port); return some(remoteAddr.port);
@ -135,19 +139,27 @@ export class Context<
return none; 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( public json(
body?: ResSchema extends Schema<infer T> ? T : object | string, body?: ResSchema extends Schema<infer T> ? T : object | string,
init: ResponseInit = {}, init: ResponseInit = {},
): Response { ): Response {
const headers = mergeHeaders( const headers = this.buildHeaders(init.headers, JSON_CONTENT_TYPE);
SECURITY_HEADERS, let status = init.status ?? 200;
this._responseHeaders,
new Headers(init.headers),
);
headers.set(...JSON_CONTENT_TYPE);
let status = init.status || 200;
let responseBody: BodyInit | null = null; let responseBody: BodyInit | null = null;
if (typeof body === "string") { if (typeof body === "string") {
responseBody = body; responseBody = body;
} else if (body !== undefined) { } else if (body !== undefined) {
@ -162,82 +174,67 @@ export class Context<
} }
} }
return new Response(responseBody, { this.res = new Response(responseBody, {
status, status,
headers, headers,
}); });
return this.res;
} }
public json400(body?: object | string, init: ResponseInit = {}) { public json400(
init.status = 400; body?: ResSchema extends Schema<infer T> ? T : object | string,
return this.json(body, init); init: ResponseInit = {},
): Response {
return this.json(body, { ...init, status: 400 });
} }
public json500(body?: object | string, init: ResponseInit = {}) { public json500(
init.status = 400; body?: ResSchema extends Schema<infer T> ? T : object | string,
return this.json(body, init); init: ResponseInit = {},
) {
return this.json(body, { ...init, status: 500 });
} }
public html(body?: BodyInit | null, init: ResponseInit = {}): Response { public html(body?: BodyInit | null, init: ResponseInit = {}): Response {
const headers = mergeHeaders( const headers = this.buildHeaders(init.headers, HTML_CONTENT_TYPE);
SECURITY_HEADERS,
this._responseHeaders,
new Headers(init.headers),
);
headers.set(...HTML_CONTENT_TYPE);
const status = init.status ?? 200; const status = init.status ?? 200;
this.res = new Response(body ?? null, { status, headers });
return new Response(body ?? null, { return this.res;
status,
headers,
});
} }
public redirect(url: string, permanent = false): Response { public redirect(url: string, permanent = false): Response {
const headers = mergeHeaders( const headers = mergeHeaders(
this._responseHeaders, this.res.headers,
new Headers({ location: url }), new Headers({ location: url }),
); );
this.res = new Response(null, {
return new Response(null, {
status: permanent ? 301 : 302, status: permanent ? 301 : 302,
headers, headers,
}); });
return this.res;
} }
public cookies = (() => { public get cookies() {
const self = this;
return { return {
get(name: string): Option<string> { get: (name: string): Option<string> => {
if (!self._cookies) { this._cookies ??= getCookies(this.req.headers);
self._cookies = getCookies(self.req.headers); return fromNullableVal(this._cookies[name]);
}
return fromNullableVal(self._cookies[name]);
},
set(cookie: Cookie) {
setCookie(self._responseHeaders, cookie);
},
delete(name: string) {
deleteCookie(self._responseHeaders, name);
}, },
set: (cookie: Cookie) => setCookie(this.res.headers, cookie),
delete: (name: string) => deleteCookie(this.res.headers, name),
}; };
})(); }
static setParams<S extends string>( static setParams<S extends string>(
ctx: Context<string>, ctx: Context<string>,
params: Params<ExtractRouteParams<S>>, params: Params<ExtractRouteParams<S>>,
): Context<S> { ): Context<S> {
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._url = ctx._url;
newCtx._hostname = ctx._hostname; newCtx._hostname = ctx._hostname;
newCtx._port = ctx._port; newCtx._port = ctx._port;
newCtx._cookies = ctx._cookies; newCtx._cookies = ctx._cookies;
newCtx._responseHeaders = ctx._responseHeaders;
newCtx.schema = ctx.schema; newCtx.schema = ctx.schema;
return newCtx; return newCtx;

View File

@ -1,6 +1,8 @@
import { import {
Schema,
SchemaValidationError, SchemaValidationError,
ValidationErrorDetail, ValidationErrorDetail,
z,
} from "@shared/utils/validator.ts"; } from "@shared/utils/validator.ts";
export class ErrorBase extends Error { export class ErrorBase extends Error {

View File

@ -8,9 +8,8 @@ type RequestHandler<
S extends string, S extends string,
ReqSchema extends Schema<any> = never, ReqSchema extends Schema<any> = never,
ResSchema extends Schema<any> = never, ResSchema extends Schema<any> = never,
> = ( > = (c: Context<S, ReqSchema, ResSchema>) => Promise<Response> | Response;
c: Context<S, ReqSchema, ResSchema>,
) => Promise<Response> | Response;
type RequestHandlerWithSchema<S extends string> = { type RequestHandlerWithSchema<S extends string> = {
handler: RequestHandler<S>; handler: RequestHandler<S>;
schema?: { schema?: {
@ -37,20 +36,22 @@ const DEFAULT_NOT_FOUND_HANDLER = () => new Response("404 Not found");
class HttpRouter { class HttpRouter {
public readonly routerTree = new RouterTree<MethodHandlers<any>>(); public readonly routerTree = new RouterTree<MethodHandlers<any>>();
pathPreprocessor?: (path: string) => string; public pathPreprocessor?: (path: string) => string;
private middlewares: Middleware[] = []; private middlewares: Middleware[] = [];
defaultNotFoundHandler: RequestHandler<string> = DEFAULT_NOT_FOUND_HANDLER; public defaultNotFoundHandler: RequestHandler<string> =
DEFAULT_NOT_FOUND_HANDLER;
setPathProcessor(processor: (path: string) => string) { public setPathProcessor(processor: (path: string) => string) {
this.pathPreprocessor = processor; this.pathPreprocessor = processor;
return this;
} }
use(mw: Middleware): HttpRouter { public use(mw: Middleware): this {
this.middlewares.push(mw); this.middlewares.push(mw);
return this; return this;
} }
add<S extends string>( public add<S extends string>(
path: S, path: S,
method: string, method: string,
handler: RequestHandler<S>, handler: RequestHandler<S>,
@ -59,7 +60,7 @@ class HttpRouter {
req: Schema<any>; req: Schema<any>;
}, },
): HttpRouter; ): HttpRouter;
add<S extends string>( public add<S extends string>(
path: S[], path: S[],
method: string, method: string,
handler: RequestHandler<string>, handler: RequestHandler<string>,
@ -68,7 +69,7 @@ class HttpRouter {
req: Schema<any>; req: Schema<any>;
}, },
): HttpRouter; ): HttpRouter;
add( public add(
path: string | string[], path: string | string[],
method: string, method: string,
handler: RequestHandler<string>, handler: RequestHandler<string>,
@ -95,31 +96,43 @@ class HttpRouter {
return this; return this;
} }
get<S extends string>(path: S, handler: RequestHandler<S>): HttpRouter; public get<S extends string>(
get<S extends string>( path: S,
handler: RequestHandler<S>,
): HttpRouter;
public get<S extends string>(
path: S[], path: S[],
handler: RequestHandler<string>, handler: RequestHandler<string>,
): HttpRouter; ): HttpRouter;
get(path: string | string[], handler: RequestHandler<string>): HttpRouter { public get(
path: string | string[],
handler: RequestHandler<string>,
): HttpRouter {
if (Array.isArray(path)) { if (Array.isArray(path)) {
return this.add(path, "GET", handler); return this.add(path, "GET", handler);
} }
return this.add(path, "GET", handler); return this.add(path, "GET", handler);
} }
post<S extends string>(path: S, handler: RequestHandler<S>): HttpRouter; public post<S extends string>(
post<S extends string>( path: S,
handler: RequestHandler<S>,
): HttpRouter;
public post<S extends string>(
path: string[], path: string[],
handler: RequestHandler<string>, handler: RequestHandler<string>,
): HttpRouter; ): HttpRouter;
post(path: string | string[], handler: RequestHandler<string>): HttpRouter { public post(
path: string | string[],
handler: RequestHandler<string>,
): HttpRouter {
if (Array.isArray(path)) { if (Array.isArray(path)) {
return this.add(path, "POST", handler); return this.add(path, "POST", handler);
} }
return this.add(path, "POST", handler); return this.add(path, "POST", handler);
} }
api< public api<
Path extends string, Path extends string,
ReqSchema extends Schema<any>, ReqSchema extends Schema<any>,
ResSchema extends Schema<any>, ResSchema extends Schema<any>,
@ -134,31 +147,23 @@ class HttpRouter {
req: Request, req: Request,
connInfo: Deno.ServeHandlerInfo<Deno.Addr>, connInfo: Deno.ServeHandlerInfo<Deno.Addr>,
): Promise<Response> { ): Promise<Response> {
const c = new Context(req, connInfo, {}); let ctx = new Context(req, connInfo, {});
let routeParams: Record<string, string> = {};
const path = this.pathPreprocessor const path = this.pathPreprocessor
? this.pathPreprocessor(c.path) ? this.pathPreprocessor(ctx.path)
: c.path; : ctx.path;
let params: Record<string, string> = {};
const handler = this.routerTree const handler = this.routerTree
.find(path) .find(path)
.andThen((routeMatch) => { .andThen((match) => {
const { value: methodToHandler, params: paramsMatched } = const { value: methodHandler, params: params } = match;
routeMatch; routeParams = params;
const route = methodHandler[req.method];
params = paramsMatched; if (!route) return none;
if (route.schema) {
const handlerAndSchema = methodToHandler[req.method]; ctx = ctx.withSchema(route.schema);
if (!handlerAndSchema) {
return none;
} }
const handler = route.handler;
const handler = handlerAndSchema.handler;
c.schema = handlerAndSchema.schema;
return some(handler); return some(handler);
}) })
@ -167,7 +172,7 @@ class HttpRouter {
const res = (await this.executeMiddlewareChain( const res = (await this.executeMiddlewareChain(
this.middlewares, this.middlewares,
handler, handler,
Context.setParams(c, params), Context.setParams(ctx, routeParams),
)).res; )).res;
return res; return res;

View File

@ -148,83 +148,31 @@ export class RouterTree<T> {
public add(path: string, handler: T): void { public add(path: string, handler: T): void {
const segments = this.splitPath(path); const segments = this.splitPath(path);
const paramNames: string[] = this.extractParams(segments); const paramNames: string[] = this.extractParamNames(segments);
let current: Node<T> = this.root; const node: Node<T> = this.traverseOrCreate(segments);
for (const segment of segments) { node.paramNames = node.isWildcardNode()
current = current ? [...paramNames, "restOfThePath"]
.getChild(segment) : paramNames;
.unwrapOrElse(() => node.handler = some(handler);
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);
} }
public find(path: string): Option<RouteMatch<T>> { public find(path: string): Option<RouteMatch<T>> {
const segments = this.splitPath(path); return this.traverse(path).andThen(({ node, paramValues }) => {
const paramValues: string[] = []; const params: Params = {};
let current: Node<T> = this.root; for (
let i = 0; let i = 0;
i < Math.min(paramValues.length, node.paramNames.length);
for (; i < segments.length; i++) { i++
const segment = segments[i]; ) {
if (current.isWildcardNode()) break; params[node.paramNames[i]] = paramValues[i];
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 node.handler.map((handler) => ({ value: handler, params }));
});
const params: Params = {};
for (let i = 0; i < paramValues.length; i++) {
params[current.paramNames[i]] = paramValues[i];
}
return current.handler.map((value) => ({ value, params }));
} }
public getHandler(path: string): Option<T> { public getHandler(path: string): Option<T> {
const segments = this.splitPath(path); return this.traverse(path).andThen(({ node }) => node.handler);
let current: Node<T> = 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;
} }
private traverseOrCreate(segments: string[]): Node<T> { private traverseOrCreate(segments: string[]): Node<T> {
@ -235,20 +183,49 @@ export class RouterTree<T> {
node.addChild(segment, this.wildcardSymbol, this.paramPrefix) node.addChild(segment, this.wildcardSymbol, this.paramPrefix)
); );
} }
return node;
}
private traverse(
path: string,
): Option<{ node: Node<T>; paramValues: string[] }> {
const segments = this.splitPath(path);
const paramValues: string[] = [];
let node: Node<T> = 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[] { private splitPath(path: string): string[] {
const trimmed = path.trim().replace(/^\/+/, "").replace(/\/+$/, ""); return path
return trimmed ? trimmed.split(this.pathSeparator) : []; .trim()
.split(this.pathSeparator)
.filter((segment) => segment.length > 0);
} }
public extractParams(segments: string[]): string[] { public extractParamNames(segments: string[]): string[] {
return segments.filter((segment) => return segments.filter((segment) =>
segment.startsWith(this.paramPrefix) 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); return segment.slice(this.paramPrefix.length);
} }
} }

Binary file not shown.

5
test.py Normal file
View File

@ -0,0 +1,5 @@
import math
a = max(4, 6)
print(a)