working on api
This commit is contained in:
11
server/api.ts
Normal file
11
server/api.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { Api } from "@src/lib/apiValidator.ts";
|
||||||
|
import { z } from "@shared/utils/validator.ts";
|
||||||
|
|
||||||
|
const schema = {
|
||||||
|
req: z.obj({
|
||||||
|
password: z.string(),
|
||||||
|
}),
|
||||||
|
res: z.result(z.string(), z.any()),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loginApi = new Api("/login", "POST", schema);
|
||||||
@ -7,11 +7,12 @@ await esbuild.build({
|
|||||||
plugins: [
|
plugins: [
|
||||||
...denoPlugins(),
|
...denoPlugins(),
|
||||||
],
|
],
|
||||||
entryPoints: ["../shared/utils/index.ts"],
|
entryPoints: ["./src/js/shared.bundle.ts"],
|
||||||
outfile: "./public/js/shared.bundle.js",
|
outfile: "./public/js/shared.bundle.js",
|
||||||
bundle: true,
|
bundle: true,
|
||||||
minify: true,
|
minify: true,
|
||||||
format: "esm",
|
format: "esm",
|
||||||
|
treeShaking: true,
|
||||||
});
|
});
|
||||||
esbuild.stop();
|
esbuild.stop();
|
||||||
|
|
||||||
|
|||||||
101
server/main.ts
101
server/main.ts
@ -3,11 +3,19 @@ import { Eta } from "@eta-dev/eta";
|
|||||||
import { serveFile } from "jsr:@std/http/file-server";
|
import { serveFile } from "jsr:@std/http/file-server";
|
||||||
import rateLimitMiddleware from "@src/middleware/rateLimiter.ts";
|
import rateLimitMiddleware from "@src/middleware/rateLimiter.ts";
|
||||||
import authMiddleware from "@src/middleware/auth.ts";
|
import authMiddleware from "@src/middleware/auth.ts";
|
||||||
import { ok, ResultFromJSON } from "@shared/utils/result.ts";
|
|
||||||
import { ResultResponseFromJSON } from "@src/lib/context.ts";
|
|
||||||
import admin from "@src/lib/admin.ts";
|
|
||||||
import UsbipManager from "@shared/utils/usbip.ts";
|
|
||||||
import loggerMiddleware from "@src/middleware/logger.ts";
|
import loggerMiddleware from "@src/middleware/logger.ts";
|
||||||
|
import { SchemaValidationError, z } from "@shared/utils/validator.ts";
|
||||||
|
import { Api } from "@src/lib/apiValidator.ts";
|
||||||
|
import { loginApi } from "./api.ts";
|
||||||
|
import { err, getMessageFromError, ok } from "@shared/utils/result.ts";
|
||||||
|
import admin from "@src/lib/admin.ts";
|
||||||
|
import {
|
||||||
|
FailedToParseRequestAsJSON,
|
||||||
|
QueryExecutionError,
|
||||||
|
} from "@src/lib/errors.ts";
|
||||||
|
import { Context } from "@src/lib/context.ts";
|
||||||
|
|
||||||
|
const AUTH_COOKIE_NAME = "token";
|
||||||
|
|
||||||
const router = new HttpRouter();
|
const router = new HttpRouter();
|
||||||
|
|
||||||
@ -23,15 +31,15 @@ const cache: Map<string, Response> = new Map();
|
|||||||
router.get("/public/*", async (c) => {
|
router.get("/public/*", async (c) => {
|
||||||
const filePath = "." + c.path;
|
const filePath = "." + c.path;
|
||||||
|
|
||||||
const cached = cache.get(filePath);
|
//const cached = cache.get(filePath);
|
||||||
|
//
|
||||||
if (cached) {
|
//if (cached) {
|
||||||
return cached.clone();
|
// return cached.clone();
|
||||||
}
|
//}
|
||||||
|
|
||||||
const res = await serveFile(c.req, filePath);
|
const res = await serveFile(c.req, filePath);
|
||||||
|
|
||||||
cache.set(filePath, res.clone());
|
//cache.set(filePath, res.clone());
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
});
|
});
|
||||||
@ -42,26 +50,69 @@ router
|
|||||||
})
|
})
|
||||||
.get(["/login", "/login.html"], (c) => {
|
.get(["/login", "/login.html"], (c) => {
|
||||||
return c.html(eta.render("./login.html", {}));
|
return c.html(eta.render("./login.html", {}));
|
||||||
})
|
|
||||||
.post("/login", async (c) => {
|
|
||||||
const r = await ResultFromJSON<{ password: string }>(
|
|
||||||
await c.req.text(),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router
|
const schema = {
|
||||||
.get("/user/:id/:name/*", (c) => {
|
req: z.obj({
|
||||||
return c.html(
|
password: z.string().max(1024),
|
||||||
`id = ${c.params.id}, name = ${c.params.name}, rest = ${c.params.restOfThePath}`,
|
}),
|
||||||
);
|
res: z.result(z.void(), z.string()),
|
||||||
});
|
};
|
||||||
|
|
||||||
router
|
router.api(loginApi, async (c) => {
|
||||||
.get("/user/:idButDifferent", (c) => {
|
const r = await c
|
||||||
return c.html(
|
.parseBody()
|
||||||
`idButDifferent = ${c.params.idButDifferent}`,
|
.andThenAsync(
|
||||||
|
({ password }) => admin.verifyPassword(password),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (r.isErr()) {
|
||||||
|
if (r.error.type === "AdminPasswordNotSetError") {
|
||||||
|
return c.json(
|
||||||
|
err({
|
||||||
|
type: r.error.type,
|
||||||
|
msg: r.error.message,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return handleCommonErrors(c, r.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return admin.sessions.create()
|
||||||
|
.map(({ value, expires }) => {
|
||||||
|
c.cookies.set({
|
||||||
|
name: AUTH_COOKIE_NAME,
|
||||||
|
value,
|
||||||
|
expires,
|
||||||
});
|
});
|
||||||
|
return ok();
|
||||||
|
}).match(
|
||||||
|
() => c.json(ok()),
|
||||||
|
(e) => handleCommonErrors(c, e),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleCommonErrors(
|
||||||
|
c: Context<any, any, any>,
|
||||||
|
error:
|
||||||
|
| QueryExecutionError
|
||||||
|
| FailedToParseRequestAsJSON
|
||||||
|
| SchemaValidationError,
|
||||||
|
): Response {
|
||||||
|
switch (error.type) {
|
||||||
|
case "QueryExecutionError":
|
||||||
|
return c.json(
|
||||||
|
err(new QueryExecutionError("Server failed to execute query")),
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
case "FailedToParseRequestAsJSON":
|
||||||
|
case "SchemaValiationError":
|
||||||
|
return c.json(
|
||||||
|
err(error),
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
async fetch(req, connInfo) {
|
async fetch(req, connInfo) {
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
import{ok as n}from"./shared.bundle.js";const s=document.getElementById("loginForm"),a=document.getElementById("passwordInput");s.addEventListener("submit",async t=>{t.preventDefault();const o=a.value,e=JSON.stringify(n({password:o}).toJSON()),r=await(await fetch("/login",{method:"POST",headers:{accept:"application/json"},body:e})).json(),c=8});
|
import{loginApi as o}from"./shared.bundle.js";const s=document.getElementById("loginForm"),m=document.getElementById("passwordInput");s.addEventListener("submit",async e=>{e.preventDefault();const t=m.value,n=await o.makeRequest({password:t},{});console.log(n)});
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@ -1,6 +1,6 @@
|
|||||||
/// <reference lib="dom" />
|
/// <reference lib="dom" />
|
||||||
|
|
||||||
import { ok } from "./shared.bundle.ts";
|
import { loginApi } from "./shared.bundle.ts";
|
||||||
|
|
||||||
const form = document.getElementById("loginForm") as HTMLFormElement;
|
const form = document.getElementById("loginForm") as HTMLFormElement;
|
||||||
const passwordInput = document.getElementById(
|
const passwordInput = document.getElementById(
|
||||||
@ -12,19 +12,7 @@ form.addEventListener("submit", async (e) => {
|
|||||||
|
|
||||||
const password = passwordInput.value;
|
const password = passwordInput.value;
|
||||||
|
|
||||||
const bodyReq = JSON.stringify(
|
const res = await loginApi.makeRequest({ password }, {});
|
||||||
ok({
|
|
||||||
password: password,
|
|
||||||
}).toJSON(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const response = await fetch("/login", {
|
console.log(res);
|
||||||
method: "POST",
|
|
||||||
headers: { accept: "application/json" },
|
|
||||||
body: bodyReq,
|
|
||||||
});
|
|
||||||
|
|
||||||
const body = await response.json();
|
|
||||||
|
|
||||||
const a = 8;
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
../../../shared/utils/index.ts
|
|
||||||
5
server/src/js/shared.bundle.ts
Normal file
5
server/src/js/shared.bundle.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export * from "@shared/utils/option.ts";
|
||||||
|
export * from "@shared/utils/result.ts";
|
||||||
|
export * from "@shared/utils/resultasync.ts";
|
||||||
|
export * from "@shared/utils/validator.ts";
|
||||||
|
export * from "../../api.ts";
|
||||||
@ -131,13 +131,20 @@ class AdminSessions {
|
|||||||
}, EXPIRED_TOKENS_DELETION_INTERVAL);
|
}, EXPIRED_TOKENS_DELETION_INTERVAL);
|
||||||
}
|
}
|
||||||
|
|
||||||
public create(expiresAt?: Date): Result<string, QueryExecutionError> {
|
public create(
|
||||||
|
expiresAt?: Date,
|
||||||
|
): Result<{ value: string; expires: Date }, QueryExecutionError> {
|
||||||
const token = generateRandomString(TOKEN_LENGTH);
|
const token = generateRandomString(TOKEN_LENGTH);
|
||||||
|
|
||||||
if (expiresAt) {
|
if (expiresAt) {
|
||||||
return this.statements
|
return this.statements
|
||||||
.insertSessionTokenWithExpiry(token, expiresAt.toISOString())
|
.insertSessionTokenWithExpiry(token, expiresAt.toISOString())
|
||||||
.map(() => token);
|
.map(() => {
|
||||||
|
return {
|
||||||
|
value: token,
|
||||||
|
expires: expiresAt,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@ -148,7 +155,12 @@ class AdminSessions {
|
|||||||
|
|
||||||
return this.statements
|
return this.statements
|
||||||
.insertSessionTokenWithExpiry(token, expiresAtDefault.toISOString())
|
.insertSessionTokenWithExpiry(token, expiresAtDefault.toISOString())
|
||||||
.map(() => token);
|
.map(() => {
|
||||||
|
return {
|
||||||
|
value: token,
|
||||||
|
expires: expiresAtDefault,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public verifyToken(token: string): Result<boolean, QueryExecutionError> {
|
public verifyToken(token: string): Result<boolean, QueryExecutionError> {
|
||||||
|
|||||||
@ -1,10 +1,88 @@
|
|||||||
import { Result } from "@shared/utils/result.ts";
|
import { Result } from "@shared/utils/result.ts";
|
||||||
|
import {
|
||||||
|
InferSchemaType,
|
||||||
|
ResultSchema,
|
||||||
|
Schema,
|
||||||
|
z,
|
||||||
|
} from "@shared/utils/validator.ts";
|
||||||
|
import {
|
||||||
|
RequestValidationError,
|
||||||
|
ResponseValidationError,
|
||||||
|
} from "@src/lib/errors.ts";
|
||||||
|
import { errAsync, okAsync, ResultAsync } from "@shared/utils/resultasync.ts";
|
||||||
|
|
||||||
class Api<Req extends object, Res extends object> {
|
export type ExtractRouteParams<T extends string> = T extends string
|
||||||
client = {
|
? T extends `${infer _Start}:${infer Param}/${infer Rest}`
|
||||||
validate(res: Response): Result<Req, any>,
|
? Param | ExtractRouteParams<Rest>
|
||||||
};
|
: T extends `${infer _Start}:${infer Param}` ? Param
|
||||||
server = {
|
: never
|
||||||
validate(req: Request): Result<Res, any>,
|
: never;
|
||||||
};
|
|
||||||
|
type ApiError =
|
||||||
|
| RequestValidationError
|
||||||
|
| ResponseValidationError;
|
||||||
|
|
||||||
|
export class Api<
|
||||||
|
Path extends string,
|
||||||
|
ReqSchema extends Schema<any>,
|
||||||
|
ResSchema extends Schema<any>,
|
||||||
|
> {
|
||||||
|
private readonly pathSplitted: string[];
|
||||||
|
private readonly paramIndexes: Record<string, number>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public readonly path: Path,
|
||||||
|
public readonly method: string,
|
||||||
|
public readonly schema: {
|
||||||
|
req: ReqSchema;
|
||||||
|
res: ResSchema;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
this.pathSplitted = path.split("/");
|
||||||
|
this.paramIndexes = this.pathSplitted.reduce<Record<string, number>>(
|
||||||
|
(acc, segment, index) => {
|
||||||
|
if (segment.startsWith(":")) {
|
||||||
|
acc[segment.slice(1)] = index;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
makeRequest(
|
||||||
|
reqBody: InferSchemaType<ReqSchema>,
|
||||||
|
params: { [K in ExtractRouteParams<Path>]: string },
|
||||||
|
): ResultAsync<InferSchemaType<ResSchema>, ApiError> {
|
||||||
|
return this.schema.req
|
||||||
|
.parse(reqBody)
|
||||||
|
.toAsync()
|
||||||
|
.mapErr((e) => new RequestValidationError(e.input, e.detail))
|
||||||
|
.andThenAsync(async (data) => {
|
||||||
|
const pathSplitted = this.pathSplitted;
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
pathSplitted[this.paramIndexes[key]] = value as string;
|
||||||
|
}
|
||||||
|
const path = pathSplitted.join("/");
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
path,
|
||||||
|
{
|
||||||
|
method: this.method,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const resBody = await response.json();
|
||||||
|
|
||||||
|
return this.schema.res.parse(resBody).toAsync()
|
||||||
|
.map((v) => v as InferSchemaType<ResSchema>)
|
||||||
|
.mapErr((e) =>
|
||||||
|
new ResponseValidationError(e.input, e.detail)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,21 @@ 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 { Err, Ok, type Result, ResultFromJSON } from "@shared/utils/result.ts";
|
import {
|
||||||
|
Err,
|
||||||
|
getMessageFromError,
|
||||||
|
Ok,
|
||||||
|
type Result,
|
||||||
|
ResultFromJSON,
|
||||||
|
} from "@shared/utils/result.ts";
|
||||||
|
import {
|
||||||
|
InferSchemaType,
|
||||||
|
Schema,
|
||||||
|
SchemaValidationError,
|
||||||
|
} from "@shared/utils/validator.ts";
|
||||||
|
import { errAsync, ResultAsync } from "@shared/utils/resultasync.ts";
|
||||||
|
import log from "@shared/utils/logger.ts";
|
||||||
|
import { FailedToParseRequestAsJSON } from "@src/lib/errors.ts";
|
||||||
|
|
||||||
// https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html
|
// https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html
|
||||||
const SECURITY_HEADERS: Headers = new Headers({
|
const SECURITY_HEADERS: Headers = new Headers({
|
||||||
@ -36,7 +50,11 @@ function mergeHeaders(...headers: Headers[]): Headers {
|
|||||||
return mergedHeaders;
|
return mergedHeaders;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Context<S extends string = string> {
|
export class Context<
|
||||||
|
S extends string = string,
|
||||||
|
ReqSchema extends Schema<any> | never = never,
|
||||||
|
ResSchema extends Schema<any> | never = never,
|
||||||
|
> {
|
||||||
private _url?: URL;
|
private _url?: URL;
|
||||||
private _hostname?: string;
|
private _hostname?: string;
|
||||||
private _port?: number;
|
private _port?: number;
|
||||||
@ -48,8 +66,30 @@ export class Context<S extends string = string> {
|
|||||||
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 parseBody(): ResultAsync<
|
||||||
|
InferSchemaType<ReqSchema>,
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
get url(): URL {
|
get url(): URL {
|
||||||
return this._url ?? (this._url = new URL(this.req.url));
|
return this._url ?? (this._url = new URL(this.req.url));
|
||||||
}
|
}
|
||||||
@ -60,7 +100,6 @@ export class Context<S extends string = string> {
|
|||||||
|
|
||||||
get preferredType(): Option<"json" | "html"> {
|
get preferredType(): Option<"json" | "html"> {
|
||||||
const headers = new Headers(this.req.headers);
|
const headers = new Headers(this.req.headers);
|
||||||
|
|
||||||
return fromNullableVal(headers.get("accept")).andThen(
|
return fromNullableVal(headers.get("accept")).andThen(
|
||||||
(types_header) => {
|
(types_header) => {
|
||||||
const types = types_header.split(";")[0].trim().split(",");
|
const types = types_header.split(";")[0].trim().split(",");
|
||||||
@ -132,6 +171,16 @@ export class Context<S extends string = string> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public json400(body?: object | string, init: ResponseInit = {}) {
|
||||||
|
init.status = 400;
|
||||||
|
return this.json(body, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
public json500(body?: object | string, init: ResponseInit = {}) {
|
||||||
|
init.status = 400;
|
||||||
|
return this.json(body, init);
|
||||||
|
}
|
||||||
|
|
||||||
public html(body?: BodyInit | null, init: ResponseInit = {}): Response {
|
public html(body?: BodyInit | null, init: ResponseInit = {}): Response {
|
||||||
const headers = mergeHeaders(
|
const headers = mergeHeaders(
|
||||||
SECURITY_HEADERS,
|
SECURITY_HEADERS,
|
||||||
@ -192,6 +241,7 @@ export class Context<S extends string = string> {
|
|||||||
newCtx._port = ctx._port;
|
newCtx._port = ctx._port;
|
||||||
newCtx._cookies = ctx._cookies;
|
newCtx._cookies = ctx._cookies;
|
||||||
newCtx._responseHeaders = ctx._responseHeaders;
|
newCtx._responseHeaders = ctx._responseHeaders;
|
||||||
|
newCtx.schema = ctx.schema;
|
||||||
|
|
||||||
return newCtx;
|
return newCtx;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
import log from "@shared/utils/logger.ts";
|
import {
|
||||||
|
SchemaValidationError,
|
||||||
|
ValidationErrorDetail,
|
||||||
|
} from "@shared/utils/validator.ts";
|
||||||
|
|
||||||
export class ErrorBase extends Error {
|
export class ErrorBase extends Error {
|
||||||
constructor(message: string = "An unknown error has occurred") {
|
constructor(message: string = "An unknown error has occurred") {
|
||||||
@ -8,42 +11,69 @@ export class ErrorBase extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class QueryExecutionError extends ErrorBase {
|
export class QueryExecutionError extends ErrorBase {
|
||||||
public readonly code = "QueryExecutionError";
|
public readonly type = "QueryExecutionError";
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
super(message);
|
super(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class NoAdminEntryError extends ErrorBase {
|
export class NoAdminEntryError extends ErrorBase {
|
||||||
public readonly code = "NoAdminEntry";
|
public readonly type = "NoAdminEntry";
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
super(message);
|
super(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class FailedToReadFileError extends ErrorBase {
|
export class FailedToReadFileError extends ErrorBase {
|
||||||
public readonly code = "FailedToReadFileError";
|
public readonly type = "FailedToReadFileError";
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
super(message);
|
super(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class InvalidSyntaxError extends ErrorBase {
|
export class InvalidSyntaxError extends ErrorBase {
|
||||||
public readonly code = "InvalidSyntax";
|
public readonly type = "InvalidSyntax";
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
super(message);
|
super(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class InvalidPathError extends ErrorBase {
|
export class InvalidPathError extends ErrorBase {
|
||||||
public readonly code = "InvalidPath";
|
public readonly type = "InvalidPath";
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
super(message);
|
super(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AdminPasswordNotSetError extends ErrorBase {
|
export class AdminPasswordNotSetError extends ErrorBase {
|
||||||
public readonly code = "AdminPasswordNotSetError";
|
public readonly type = "AdminPasswordNotSetError";
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RequestValidationError extends SchemaValidationError {
|
||||||
|
public readonly type = "RequestValidationError";
|
||||||
|
constructor(
|
||||||
|
input: unknown,
|
||||||
|
detail: ValidationErrorDetail,
|
||||||
|
) {
|
||||||
|
super(input, detail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ResponseValidationError extends SchemaValidationError {
|
||||||
|
public readonly type = "ResponseValidationError";
|
||||||
|
constructor(
|
||||||
|
input: unknown,
|
||||||
|
detail: ValidationErrorDetail,
|
||||||
|
) {
|
||||||
|
super(input, detail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FailedToParseRequestAsJSON extends ErrorBase {
|
||||||
|
public readonly type = "FailedToParseRequestAsJSON";
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
super(message);
|
super(message);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,25 +1,44 @@
|
|||||||
import { RouterTree } from "@lib/routerTree.ts";
|
import { RouterTree } from "@lib/routerTree.ts";
|
||||||
import { none, Option, some } from "@shared/utils/option.ts";
|
import { none, Option, some } from "@shared/utils/option.ts";
|
||||||
import { Context } from "@lib/context.ts";
|
import { Context } from "@lib/context.ts";
|
||||||
|
import { Schema } from "@shared/utils/validator.ts";
|
||||||
|
import { Api } from "@src/lib/apiValidator.ts";
|
||||||
|
|
||||||
type RequestHandler<S extends string> = (
|
type RequestHandler<
|
||||||
c: Context<S>,
|
S extends string,
|
||||||
|
ReqSchema extends Schema<any> = never,
|
||||||
|
ResSchema extends Schema<any> = never,
|
||||||
|
> = (
|
||||||
|
c: Context<S, ReqSchema, ResSchema>,
|
||||||
) => Promise<Response> | Response;
|
) => Promise<Response> | Response;
|
||||||
|
type RequestHandlerWithSchema<S extends string> = {
|
||||||
|
handler: RequestHandler<S>;
|
||||||
|
schema?: {
|
||||||
|
res: Schema<any>;
|
||||||
|
req: Schema<any>;
|
||||||
|
};
|
||||||
|
};
|
||||||
export type Middleware = (
|
export type Middleware = (
|
||||||
c: Context<string>,
|
c: Context<string>,
|
||||||
next: () => Promise<void>,
|
next: () => Promise<void>,
|
||||||
) => Promise<Response | void> | Response | void;
|
) => Promise<Response | void> | Response | void;
|
||||||
|
|
||||||
type MethodHandlers<S extends string> = Partial<
|
type MethodHandlers<S extends string> = Partial<
|
||||||
Record<string, RequestHandler<S>>
|
Record<string, {
|
||||||
|
handler: RequestHandler<S>;
|
||||||
|
schema?: {
|
||||||
|
res: Schema<any>;
|
||||||
|
req: Schema<any>;
|
||||||
|
};
|
||||||
|
}>
|
||||||
>;
|
>;
|
||||||
|
|
||||||
const DEFAULT_NOT_FOUND_HANDLER = () => new Response("404 Not found");
|
const DEFAULT_NOT_FOUND_HANDLER = () => new Response("404 Not found");
|
||||||
|
|
||||||
class HttpRouter {
|
class HttpRouter {
|
||||||
routerTree = new RouterTree<MethodHandlers<any>>();
|
public readonly routerTree = new RouterTree<MethodHandlers<any>>();
|
||||||
pathPreprocessor?: (path: string) => string;
|
pathPreprocessor?: (path: string) => string;
|
||||||
middlewares: Middleware[] = [];
|
private middlewares: Middleware[] = [];
|
||||||
defaultNotFoundHandler: RequestHandler<string> = DEFAULT_NOT_FOUND_HANDLER;
|
defaultNotFoundHandler: RequestHandler<string> = DEFAULT_NOT_FOUND_HANDLER;
|
||||||
|
|
||||||
setPathProcessor(processor: (path: string) => string) {
|
setPathProcessor(processor: (path: string) => string) {
|
||||||
@ -35,27 +54,39 @@ class HttpRouter {
|
|||||||
path: S,
|
path: S,
|
||||||
method: string,
|
method: string,
|
||||||
handler: RequestHandler<S>,
|
handler: RequestHandler<S>,
|
||||||
|
schema?: {
|
||||||
|
res: Schema<any>;
|
||||||
|
req: Schema<any>;
|
||||||
|
},
|
||||||
): HttpRouter;
|
): HttpRouter;
|
||||||
add<S extends string>(
|
add<S extends string>(
|
||||||
path: S[],
|
path: S[],
|
||||||
method: string,
|
method: string,
|
||||||
handler: RequestHandler<string>,
|
handler: RequestHandler<string>,
|
||||||
|
schema?: {
|
||||||
|
res: Schema<any>;
|
||||||
|
req: Schema<any>;
|
||||||
|
},
|
||||||
): HttpRouter;
|
): HttpRouter;
|
||||||
add(
|
add(
|
||||||
path: string | string[],
|
path: string | string[],
|
||||||
method: string,
|
method: string,
|
||||||
handler: RequestHandler<string>,
|
handler: RequestHandler<string>,
|
||||||
|
schema?: {
|
||||||
|
res: Schema<any>;
|
||||||
|
req: Schema<any>;
|
||||||
|
},
|
||||||
): HttpRouter {
|
): HttpRouter {
|
||||||
const paths = Array.isArray(path) ? path : [path];
|
const paths = Array.isArray(path) ? path : [path];
|
||||||
|
|
||||||
for (const p of paths) {
|
for (const p of paths) {
|
||||||
this.routerTree.getHandler(p).match(
|
this.routerTree.getHandler(p).match(
|
||||||
(mth) => {
|
(mth) => {
|
||||||
mth[method] = handler;
|
mth[method] = { handler, schema };
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
const mth: MethodHandlers<string> = {};
|
const mth: MethodHandlers<string> = {};
|
||||||
mth[method] = handler;
|
mth[method] = { handler, schema };
|
||||||
this.routerTree.add(p, mth);
|
this.routerTree.add(p, mth);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -64,14 +95,11 @@ class HttpRouter {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Overload signatures for 'get'
|
|
||||||
get<S extends string>(path: S, handler: RequestHandler<S>): HttpRouter;
|
get<S extends string>(path: S, handler: RequestHandler<S>): HttpRouter;
|
||||||
get<S extends string>(
|
get<S extends string>(
|
||||||
path: S[],
|
path: S[],
|
||||||
handler: RequestHandler<string>,
|
handler: RequestHandler<string>,
|
||||||
): HttpRouter;
|
): HttpRouter;
|
||||||
|
|
||||||
// Non-generic implementation for 'get'
|
|
||||||
get(path: string | string[], handler: RequestHandler<string>): HttpRouter {
|
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);
|
||||||
@ -84,7 +112,6 @@ class HttpRouter {
|
|||||||
path: string[],
|
path: string[],
|
||||||
handler: RequestHandler<string>,
|
handler: RequestHandler<string>,
|
||||||
): HttpRouter;
|
): HttpRouter;
|
||||||
|
|
||||||
post(path: string | string[], handler: RequestHandler<string>): HttpRouter {
|
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);
|
||||||
@ -92,6 +119,17 @@ class HttpRouter {
|
|||||||
return this.add(path, "POST", handler);
|
return this.add(path, "POST", handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
api<
|
||||||
|
Path extends string,
|
||||||
|
ReqSchema extends Schema<any>,
|
||||||
|
ResSchema extends Schema<any>,
|
||||||
|
>(
|
||||||
|
api: Api<Path, ReqSchema, ResSchema>,
|
||||||
|
handler: RequestHandler<Path, ReqSchema, ResSchema>,
|
||||||
|
): HttpRouter {
|
||||||
|
return this.add(api.path, api.method, handler, api.schema);
|
||||||
|
}
|
||||||
|
|
||||||
async handleRequest(
|
async handleRequest(
|
||||||
req: Request,
|
req: Request,
|
||||||
connInfo: Deno.ServeHandlerInfo<Deno.Addr>,
|
connInfo: Deno.ServeHandlerInfo<Deno.Addr>,
|
||||||
@ -102,25 +140,37 @@ class HttpRouter {
|
|||||||
? this.pathPreprocessor(c.path)
|
? this.pathPreprocessor(c.path)
|
||||||
: c.path;
|
: c.path;
|
||||||
|
|
||||||
let params: string[] = [];
|
let params: Record<string, string> = {};
|
||||||
|
|
||||||
const handler = this.routerTree
|
const handler = this.routerTree
|
||||||
.find(path)
|
.find(path)
|
||||||
.andThen((routeMatch) => {
|
.andThen((routeMatch) => {
|
||||||
const { value: handlers, params: paramsMatched } = routeMatch;
|
const { value: methodToHandler, params: paramsMatched } =
|
||||||
|
routeMatch;
|
||||||
|
|
||||||
params = paramsMatched;
|
params = paramsMatched;
|
||||||
const handler = handlers[req.method];
|
|
||||||
return handler ? some(handler) : none;
|
const handlerAndSchema = methodToHandler[req.method];
|
||||||
|
|
||||||
|
if (!handlerAndSchema) {
|
||||||
|
return none;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handler = handlerAndSchema.handler;
|
||||||
|
|
||||||
|
c.schema = handlerAndSchema.schema;
|
||||||
|
|
||||||
|
return some(handler);
|
||||||
})
|
})
|
||||||
.unwrapOrElse(() => this.defaultNotFoundHandler);
|
.unwrapOrElse(() => this.defaultNotFoundHandler);
|
||||||
|
|
||||||
const cf = await this.executeMiddlewareChain(
|
const res = (await this.executeMiddlewareChain(
|
||||||
this.middlewares,
|
this.middlewares,
|
||||||
handler,
|
handler,
|
||||||
Context.setParams(c, params),
|
Context.setParams(c, params),
|
||||||
);
|
)).res;
|
||||||
|
|
||||||
return cf.res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async executeMiddlewareChain<S extends string>(
|
private async executeMiddlewareChain<S extends string>(
|
||||||
|
|||||||
@ -4,6 +4,7 @@ const loggerMiddleware: Middleware = async (c, next) => {
|
|||||||
console.log("", c.req.method, c.path);
|
console.log("", c.req.method, c.path);
|
||||||
await next();
|
await next();
|
||||||
console.log("", c.res.status, "\n");
|
console.log("", c.res.status, "\n");
|
||||||
|
console.log(await c.res.json());
|
||||||
};
|
};
|
||||||
|
|
||||||
export default loggerMiddleware;
|
export default loggerMiddleware;
|
||||||
|
|||||||
@ -1,5 +1,9 @@
|
|||||||
import { type Result } from "@shared/utils/result.ts";
|
import { type Result } from "@shared/utils/result.ts";
|
||||||
import { type InferSchema, Schema } from "@shared/utils/validator.ts";
|
import {
|
||||||
|
type InferSchema,
|
||||||
|
InferSchemaType,
|
||||||
|
Schema,
|
||||||
|
} from "@shared/utils/validator.ts";
|
||||||
|
|
||||||
export type ExtractRouteParams<T extends string> = T extends string
|
export type ExtractRouteParams<T extends string> = T extends string
|
||||||
? T extends `${infer _Start}:${infer Param}/${infer Rest}`
|
? T extends `${infer _Start}:${infer Param}/${infer Rest}`
|
||||||
@ -13,22 +17,35 @@ class ClientApi<
|
|||||||
ReqSchema extends Schema<any>,
|
ReqSchema extends Schema<any>,
|
||||||
ResSchema extends Schema<any>,
|
ResSchema extends Schema<any>,
|
||||||
> {
|
> {
|
||||||
|
private readonly path: string[];
|
||||||
|
private readonly paramsIndexes: Record<string, number>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public readonly path: Path,
|
path: Path,
|
||||||
public readonly reqSchema: ReqSchema,
|
public readonly reqSchema: ReqSchema,
|
||||||
public readonly resSchema: ResSchema,
|
public readonly resSchema: ResSchema,
|
||||||
) {
|
) {
|
||||||
|
this.path = path.split("/");
|
||||||
|
this.paramsIndexes = this.path.reduce<Record<number, string>>(
|
||||||
|
(acc, segment, index) => {
|
||||||
|
if (segment.startsWith(":")) {
|
||||||
|
acc[index] = segment.slice(1);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
makeRequest(
|
makeRequest(
|
||||||
reqBody: InferSchemaType<ReqSchema>,
|
reqBody: InferSchemaType<ReqSchema>,
|
||||||
params?: ExtractRouteParams<Path>,
|
params?: { [K in ExtractRouteParams<Path>]: string },
|
||||||
) {
|
) {
|
||||||
const pathWithParams = this.path.split("/").map((segment) => {
|
const path = this.path.slice().reduce<string>((acc, cur) => {});
|
||||||
if (segment.startsWith(":")) {
|
if (params) {
|
||||||
return params[segment.slice(1)];
|
for (const param of Object.keys(params)) {
|
||||||
|
pathSplitted[this.paramsIndexes[param]] = param;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return segment;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
export * from "@shared/utils/option.ts";
|
export * from "@shared/utils/option.ts";
|
||||||
export * from "@shared/utils/result.ts";
|
export * from "@shared/utils/result.ts";
|
||||||
export * from "@shared/utils/resultasync.ts";
|
export * from "@shared/utils/resultasync.ts";
|
||||||
//export * from "@shared/utils/validator.ts";
|
export * from "@shared/utils/validator.ts";
|
||||||
|
|||||||
@ -150,7 +150,7 @@ export class ResultAsync<T, E> implements PromiseLike<Result<T, E>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
andThenAsync<U, F>(
|
andThenAsync<U, F>(
|
||||||
fn: (value: T) => ResultAsync<U, F>,
|
fn: (value: T) => ResultAsync<U, E | F> | Promise<Result<U, E | F>>,
|
||||||
): ResultAsync<U, E | F> {
|
): ResultAsync<U, E | F> {
|
||||||
return new ResultAsync(
|
return new ResultAsync(
|
||||||
this._promise.then(
|
this._promise.then(
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { err, Result } from "@shared/utils/result.ts";
|
|||||||
import { ok } from "@shared/utils/index.ts";
|
import { ok } from "@shared/utils/index.ts";
|
||||||
import { None, none, Option, some } from "@shared/utils/option.ts";
|
import { None, none, Option, some } from "@shared/utils/option.ts";
|
||||||
// ── Error Types ─────────────────────────────────────────────────────
|
// ── Error Types ─────────────────────────────────────────────────────
|
||||||
type ValidationErrorDetail =
|
export type ValidationErrorDetail =
|
||||||
| {
|
| {
|
||||||
kind: "typeMismatch";
|
kind: "typeMismatch";
|
||||||
expected: string;
|
expected: string;
|
||||||
@ -33,14 +33,18 @@ type ValidationErrorDetail =
|
|||||||
}
|
}
|
||||||
| { kind: "general"; mark?: string; msg: string };
|
| { kind: "general"; mark?: string; msg: string };
|
||||||
|
|
||||||
class SchemaValidationError extends Error {
|
export class SchemaValidationError extends Error {
|
||||||
public readonly type = "SchemaValidationError";
|
public readonly type = "SchemaValiationError";
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public readonly input: unknown,
|
public readonly input: unknown,
|
||||||
public readonly detail: ValidationErrorDetail,
|
public readonly detail: ValidationErrorDetail,
|
||||||
) {
|
) {
|
||||||
super(detail.msg || "Schema validation error");
|
super(
|
||||||
|
SchemaValidationError.getBestMsg(detail) ||
|
||||||
|
"Schema validation error",
|
||||||
|
);
|
||||||
|
this.name = "SchemaValidationError";
|
||||||
}
|
}
|
||||||
|
|
||||||
public format(): Record<string, unknown> {
|
public format(): Record<string, unknown> {
|
||||||
@ -971,7 +975,7 @@ class NullishSchema<S extends Schema<any>>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ResultSchema<T extends Schema<any>, E extends Schema<any>>
|
export class ResultSchema<T extends Schema<any>, E extends Schema<any>>
|
||||||
extends BaseSchema<Result<InferSchemaType<T>, InferSchemaType<E>>> {
|
extends BaseSchema<Result<InferSchemaType<T>, InferSchemaType<E>>> {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly okSchema: T,
|
private readonly okSchema: T,
|
||||||
@ -1078,7 +1082,7 @@ class ResultSchema<T extends Schema<any>, E extends Schema<any>>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class OptionSchema<T extends Schema<any>>
|
export class OptionSchema<T extends Schema<any>>
|
||||||
extends BaseSchema<Option<InferSchemaType<T>>> {
|
extends BaseSchema<Option<InferSchemaType<T>>> {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly schema: T,
|
private readonly schema: T,
|
||||||
|
|||||||
Reference in New Issue
Block a user