working on api

This commit is contained in:
2025-02-10 22:15:47 +03:00
parent cbb18d516d
commit 64519e11ff
17 changed files with 396 additions and 99 deletions

11
server/api.ts Normal file
View 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);

View File

@ -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();

View File

@ -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) {

View File

@ -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

View File

@ -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;
}); });

View File

@ -1 +0,0 @@
../../../shared/utils/index.ts

View 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";

View File

@ -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> {

View File

@ -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)
);
});
}
} }

View File

@ -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;
} }

View File

@ -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);
} }

View File

@ -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>(

View File

@ -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;

View File

@ -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; }
});
} }
} }

View File

@ -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";

View File

@ -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(

View File

@ -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,