273 lines
8.6 KiB
TypeScript
273 lines
8.6 KiB
TypeScript
import { type Params } from "@lib/router.ts";
|
|
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, ok } from "@shared/utils/result.ts";
|
|
import {
|
|
InferSchemaType,
|
|
Schema,
|
|
SchemaValidationError,
|
|
} from "@shared/utils/validator.ts";
|
|
import { okAsync, ResultAsync } from "@shared/utils/resultasync.ts";
|
|
import log from "@shared/utils/logger.ts";
|
|
import {
|
|
FailedToParseRequestAsJSONError,
|
|
failedToParseRequestAsJSONError,
|
|
} from "@src/lib/errors.ts";
|
|
import { RequestValidationError } from "@src/lib/errors.ts";
|
|
import { requestValidationError } from "@src/lib/errors.ts";
|
|
|
|
// https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html
|
|
const SECURITY_HEADERS: Headers = new Headers({
|
|
"X-Frame-Option": "DENY",
|
|
"X-Content-Type-Options": "nosniff",
|
|
//"Referrer-Policy": "strict-origin-when-cross-origin",
|
|
//"Cross-Origin-Opener-Policy": "same-origin",
|
|
//"Cross-Origin-Resource-Policy": "same-site",
|
|
"Permissions-Policy": "geolocation=(), camera=(), microphone=()",
|
|
Server: "webserver",
|
|
//"Content-Security-Policy":
|
|
// "default-src 'self'; script-src 'self' 'unsafe-inline'",
|
|
});
|
|
|
|
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 merged = new Headers();
|
|
for (const hdr of headers) {
|
|
hdr.forEach((value, key) => merged.set(key, value));
|
|
}
|
|
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<
|
|
S extends string = string,
|
|
ReqSchema extends Schema<any> = Schema<unknown>,
|
|
ResSchema extends Schema<any> = Schema<unknown>,
|
|
> {
|
|
private _url?: URL;
|
|
private _hostname?: string;
|
|
private _port?: number;
|
|
private _cookies?: Record<string, string>;
|
|
|
|
public res = new Response();
|
|
|
|
constructor(
|
|
public readonly req: Request,
|
|
public readonly info: Deno.ServeHandlerInfo<Deno.Addr>,
|
|
public readonly params: Params<ExtractRouteParams<S>>,
|
|
) {}
|
|
|
|
public schema?: { req: ReqSchema; res: ResSchema };
|
|
|
|
public setSchema<
|
|
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 setParams(
|
|
params: Params<string>,
|
|
): Context<S, ReqSchema, ResSchema> {
|
|
const ctx = new Context<S, ReqSchema, ResSchema>(
|
|
this.req,
|
|
this.info,
|
|
params,
|
|
);
|
|
ctx._url = this._url;
|
|
ctx._hostname = this._hostname;
|
|
ctx._port = this._port;
|
|
ctx._cookies = this._cookies;
|
|
ctx.res = this.res;
|
|
ctx.schema = this.schema;
|
|
return ctx as Context<S, ReqSchema, ResSchema>;
|
|
}
|
|
|
|
public parseBody(): ResultAsync<
|
|
InferSchemaType<ReqSchema>,
|
|
RequestValidationError | FailedToParseRequestAsJSONError
|
|
> {
|
|
return ResultAsync
|
|
.fromPromise(
|
|
this.req.json(),
|
|
(e) => failedToParseRequestAsJSONError(getMessageFromError(e)),
|
|
)
|
|
.andThen((data: unknown) => {
|
|
if (!this.schema) {
|
|
return ok(data);
|
|
}
|
|
|
|
return this.schema?.req.parse(data).mapErr((e) =>
|
|
requestValidationError(e.info)
|
|
);
|
|
});
|
|
}
|
|
|
|
get url(): URL {
|
|
return this._url ?? (this._url = new URL(this.req.url));
|
|
}
|
|
|
|
get path(): ExtractPath<S> {
|
|
return this.url.pathname as ExtractPath<S>;
|
|
}
|
|
|
|
get preferredType(): Option<"json" | "html"> {
|
|
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;
|
|
}
|
|
|
|
matchPreferredType(
|
|
html: () => Response,
|
|
json: () => Response,
|
|
other: () => Response,
|
|
): Response {
|
|
switch (this.preferredType.unwrapOr("other")) {
|
|
case "json":
|
|
return json();
|
|
case "html":
|
|
return html();
|
|
case "other":
|
|
return other();
|
|
}
|
|
}
|
|
|
|
get hostname(): Option<string> {
|
|
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);
|
|
}
|
|
return none;
|
|
}
|
|
|
|
get port(): Option<number> {
|
|
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);
|
|
}
|
|
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<infer T> ? T : object | string,
|
|
init: ResponseInit = {},
|
|
): Response {
|
|
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) {
|
|
try {
|
|
responseBody = JSON.stringify(body);
|
|
} catch (error) {
|
|
console.error("Failed to serialize JSON body:", error);
|
|
responseBody = JSON.stringify({
|
|
err: "Internal Server Error",
|
|
});
|
|
status = 500;
|
|
}
|
|
}
|
|
|
|
this.res = new Response(responseBody, {
|
|
status,
|
|
headers,
|
|
});
|
|
return this.res;
|
|
}
|
|
|
|
public json400(
|
|
body?: ResSchema extends Schema<infer T> ? T : object | string,
|
|
init: ResponseInit = {},
|
|
): Response {
|
|
return this.json(body, { ...init, status: 400 });
|
|
}
|
|
|
|
public json500(
|
|
body?: ResSchema extends Schema<infer T> ? T : object | string,
|
|
init: ResponseInit = {},
|
|
) {
|
|
return this.json(body, { ...init, status: 500 });
|
|
}
|
|
|
|
public html(body?: BodyInit | null, init: ResponseInit = {}): Response {
|
|
const headers = this.buildHeaders(init.headers, HTML_CONTENT_TYPE);
|
|
const status = init.status ?? 200;
|
|
this.res = new Response(body ?? null, { status, headers });
|
|
return this.res;
|
|
}
|
|
|
|
public redirect(url: string, permanent = false): Response {
|
|
const headers = mergeHeaders(
|
|
this.res.headers,
|
|
new Headers({ location: url }),
|
|
);
|
|
this.res = new Response(null, {
|
|
status: permanent ? 301 : 302,
|
|
headers,
|
|
});
|
|
return this.res;
|
|
}
|
|
|
|
public get cookies() {
|
|
return {
|
|
get: (name: string): Option<string> => {
|
|
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),
|
|
};
|
|
}
|
|
}
|
|
|
|
type ExtractPath<S extends string> = S extends
|
|
`${infer _Start}:${infer Param}/${infer Rest}` ? string
|
|
: S extends `${infer _Start}/:${infer Param}` ? string
|
|
: S extends `${infer _Start}*` ? string
|
|
: S;
|