From 64519e11ffac5559391a8f879e9b3c7f6400af62 Mon Sep 17 00:00:00 2001 From: ton1c Date: Mon, 10 Feb 2025 22:15:47 +0300 Subject: [PATCH] working on api --- server/api.ts | 11 ++++ server/autoBundler.ts | 3 +- server/main.ts | 103 ++++++++++++++++++++++-------- server/public/js/login.js | 2 +- server/public/js/shared.bundle.js | 2 +- server/src/js/login.ts | 18 +----- server/src/js/shared.bundle.ts | 6 +- server/src/lib/admin.ts | 18 +++++- server/src/lib/apiValidator.ts | 92 ++++++++++++++++++++++++-- server/src/lib/context.ts | 56 +++++++++++++++- server/src/lib/errors.ts | 44 +++++++++++-- server/src/lib/router.ts | 86 +++++++++++++++++++------ server/src/middleware/logger.ts | 1 + shared/utils/api.ts | 33 +++++++--- shared/utils/index.ts | 2 +- shared/utils/resultasync.ts | 2 +- shared/utils/validator.ts | 16 +++-- 17 files changed, 396 insertions(+), 99 deletions(-) create mode 100644 server/api.ts mode change 120000 => 100644 server/src/js/shared.bundle.ts diff --git a/server/api.ts b/server/api.ts new file mode 100644 index 0000000..85e9c95 --- /dev/null +++ b/server/api.ts @@ -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); diff --git a/server/autoBundler.ts b/server/autoBundler.ts index fe7feae..044e8a4 100644 --- a/server/autoBundler.ts +++ b/server/autoBundler.ts @@ -7,11 +7,12 @@ await esbuild.build({ plugins: [ ...denoPlugins(), ], - entryPoints: ["../shared/utils/index.ts"], + entryPoints: ["./src/js/shared.bundle.ts"], outfile: "./public/js/shared.bundle.js", bundle: true, minify: true, format: "esm", + treeShaking: true, }); esbuild.stop(); diff --git a/server/main.ts b/server/main.ts index 47bdfea..f7cf8df 100644 --- a/server/main.ts +++ b/server/main.ts @@ -3,11 +3,19 @@ import { Eta } from "@eta-dev/eta"; import { serveFile } from "jsr:@std/http/file-server"; import rateLimitMiddleware from "@src/middleware/rateLimiter.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 { 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(); @@ -23,15 +31,15 @@ const cache: Map = new Map(); router.get("/public/*", async (c) => { const filePath = "." + c.path; - const cached = cache.get(filePath); - - if (cached) { - return cached.clone(); - } + //const cached = cache.get(filePath); + // + //if (cached) { + // return cached.clone(); + //} const res = await serveFile(c.req, filePath); - cache.set(filePath, res.clone()); + //cache.set(filePath, res.clone()); return res; }); @@ -42,26 +50,69 @@ router }) .get(["/login", "/login.html"], (c) => { return c.html(eta.render("./login.html", {})); - }) - .post("/login", async (c) => { - const r = await ResultFromJSON<{ password: string }>( - await c.req.text(), - ); }); -router - .get("/user/:id/:name/*", (c) => { - return c.html( - `id = ${c.params.id}, name = ${c.params.name}, rest = ${c.params.restOfThePath}`, - ); - }); +const schema = { + req: z.obj({ + password: z.string().max(1024), + }), + res: z.result(z.void(), z.string()), +}; -router - .get("/user/:idButDifferent", (c) => { - return c.html( - `idButDifferent = ${c.params.idButDifferent}`, +router.api(loginApi, async (c) => { + const r = await c + .parseBody() + .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, + 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 { async fetch(req, connInfo) { diff --git a/server/public/js/login.js b/server/public/js/login.js index 81624a0..ff1f886 100644 --- a/server/public/js/login.js +++ b/server/public/js/login.js @@ -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)}); diff --git a/server/public/js/shared.bundle.js b/server/public/js/shared.bundle.js index 8db5dd0..d399f74 100644 --- a/server/public/js/shared.bundle.js +++ b/server/public/js/shared.bundle.js @@ -1 +1 @@ -var T=class r{constructor(e){this._promise=e;this._promise=e}static fromPromise(e,t){let n=e.then(E=>new s(E)).catch(E=>new a(t(E)));return new r(n)}static fromSafePromise(e){let t=e.then(n=>new s(n));return new r(t)}static fromThrowable(e,t){return(...n)=>{try{return f(e(n))}catch(E){return p(t?t(E):E)}}}async unwrap(){let e=await this._promise;if(e.isErr())throw e.error;return e.value}async match(e,t){let n=await this._promise;return n.isErr()?t(n.error):e(n.value)}map(e){return new r(this._promise.then(t=>t.isErr()?new a(t.error):new s(e(t.value))))}mapAsync(e){return new r(this._promise.then(async t=>t.isErr()?p(t.error):new s(await e(t.value))))}mapErr(e){return new r(this._promise.then(t=>t.isErr()?new a(e(t.error)):new s(t.value)))}mapErrAsync(e){return new r(this._promise.then(async t=>t.isErr()?p(await e(t.error)):new s(t.value)))}andThen(e){return new r(this._promise.then(t=>t.isErr()?p(t.error):e(t.value)))}nullableToOption(){return this.map(e=>e?U(e):v)}flatten(){return new r(this._promise.then(e=>e.flatten()))}flattenOption(e){return new r(this._promise.then(t=>t.flattenOption(e)))}flattenOptionOrDefault(e){return new r(this._promise.then(t=>t.flattenOptionOrDefault(e)))}matchOption(e,t){return new r(this._promise.then(n=>n.matchOption(e,t)))}matchOptionAndFlatten(e,t){return new r(this._promise.then(n=>n.matchOptionAndFlatten(e,t)))}then(e,t){return this._promise.then(e,t)}};function f(r){return new T(Promise.resolve(new s(r)))}function p(r){return new T(Promise.resolve(new a(r)))}var s=class r{constructor(e){this.value=e;this.value=e,Object.defineProperties(this,{tag:{writable:!1,enumerable:!1}})}tag="Ok";isErr(){return!1}ifErr(e){return this}isErrOrNone(){return this.value instanceof i}isOk(){return!0}ifOk(e){return e(this.value),this}unwrap(){return this.value}unwrapOr(e){return this.value}unwrapOrElse(e){return this.value}match(e,t){return e(this.value)}map(e){let t=e(this.value);return new r(t)}mapAsync(e){return T.fromSafePromise(e(this.value))}mapOption(e){return this.value instanceof i||this.value instanceof l?o(this.value.map(e)):o(U(e(this.value)))}andThen(e){return e(this.value)}andThenAsync(e){return e(this.value)}mapErr(e){return new r(this.value)}mapErrAsync(e){return f(this.value)}flatten(){return h(this)}flattenOption(e){return this.value instanceof i||this.value instanceof l?this.value.okOrElse(e):new r(this.value)}flattenOptionOr(e){return this.value instanceof i||this.value instanceof l?this.value.unwrapOr(e):new r(this.value)}matchOption(e,t){return this.value instanceof i||this.value instanceof l?o(this.value.match(e,t)):o(e(this.value))}toNullable(){return this.value}toAsync(){return f(this.value)}void(){return o()}toJSON(){return{tag:"ok",value:this.value}}},a=class r{constructor(e){this.error=e;this.error=e,Object.defineProperties(this,{tag:{writable:!1,configurable:!1,enumerable:!1}})}tag="Err";isErr(){return!0}ifErr(e){return e(this.error),this}isOk(){return!1}ifOk(e){return this}isErrOrNone(){return!0}unwrap(){let e=`Tried to unwrap error: ${O(this.error)}`;throw new Error(e)}unwrapOr(e){return e}unwrapOrElse(e){return e()}match(e,t){return t(this.error)}map(e){return new r(this.error)}mapAsync(e){return p(this.error)}mapErr(e){let t=e(this.error);return new r(t)}mapErrAsync(e){return T.fromPromise(new Promise(()=>{throw""}),()=>e(this.error))}mapOption(e){return u(this.error)}andThen(e){return new r(this.error)}flatten(){return h(this)}flattenOption(e){return new r(this.error)}flattenOptionOr(e){return new r(this.error)}matchOption(e,t){return u(this.error)}toNullable(){return null}toAsync(){return p(this.error)}void(){return u(this.error)}toJSON(){return{tag:"err",error:this.error}}};function o(r){return new s(r)}function u(r){return new a(r)}function b(r,e){return(...t)=>{try{let n=r(...t);return o(n)}catch(n){return u(e?e(n):n)}}}function O(r){if(r instanceof Error)return r.message?r.message:"code"in r&&typeof r.code=="string"?r.code:"An unknown error occurred";if(typeof r=="string")return r;if(typeof r=="object"&&r!==null&&"message"in r){let e=r;return typeof e.message=="string"?e.message:String(e.message)}return"An unknown error occurred"}function h(r){let e=r;for(;e instanceof s;)e=e.value;return e}var c=class extends Error{constructor(e){super(`Failed to parse ${e} as result`)}};function B(r){let e;if(typeof r=="string")try{e=JSON.parse(r)}catch(n){return u(new c(O(n)))}else e=r;if(typeof e!="object"||e===null)return u(new c("Expected an object but received type ${typeof data}."));let t=e;if("tag"in t)switch(t.tag){case"ok":{if("value"in t)return o(t.value);break}case"err":{if("error"in t)return u(t.error);break}}return u(new c("Object does not contain 'tag' and 'value' or 'error' property"))}var l=class r{constructor(e){this.value=e;Object.defineProperties(this,{tag:{writable:!1,enumerable:!1}})}tag="Some";isSome(){return!0}ifSome(e){return e(this.value),this}isNone(){return!1}ifNone(e){return this}map(e){return new r(e(this.value))}flatMap(e){return e(this.value)}andThen(e){return e(this.value)}unwrap(){return this.value}unwrapOr(e){return this.value}unwrapOrElse(e){return this.value}or(e){return this}orElse(e){return this}match(e,t){return e(this.value)}toJSON(){return{value:this.value}}toString(){return`Some(${this.value})`}toNullable(){return this.value}toBoolean(){return!0}okOrElse(e){return o(this.value)}},i=class r{tag="None";constructor(){Object.defineProperties(this,{tag:{writable:!1,enumerable:!1}})}isSome(){return!1}ifSome(e){return this}isNone(){return!0}ifNone(e){return e(),this}map(e){return new r}andThen(e){return v}flatMap(e){return new r}unwrap(){throw new Error("Tried to unwrap a non-existent value")}unwrapOr(e){return e}unwrapOrElse(e){return e()}or(e){return e}orElse(e){return e()}match(e,t){return t()}toJSON(){return{_tag:this._tag}}toString(){return"None"}toNullable(){return null}toBoolean(){return!1}okOrElse(e){return u(e())}};function U(r){return new l(r)}var v=new i;function S(r){return r?U(r):v}export{a as Err,i as None,s as Ok,T as ResultAsync,B as ResultFromJSON,l as Some,u as err,p as errAsync,h as flattenResult,S as fromNullableVal,b as fromThrowable,O as getMessageFromError,v as none,o as ok,f as okAsync,U as some}; +var v=class t{constructor(e){this._promise=e;this._promise=e}static fromPromise(e,r){let n=e.then(s=>new c(s)).catch(s=>new f(r(s)));return new t(n)}static fromSafePromise(e){let r=e.then(n=>new c(n));return new t(r)}static fromThrowable(e,r){return(...n)=>t.fromPromise(e(n),s=>r?r(s):s)}async unwrap(){let e=await this._promise;if(e.isErr())throw e.error;return e.value}async match(e,r){let n=await this._promise;return n.isErr()?r(n.error):e(n.value)}map(e){return new t(this._promise.then(r=>r.isErr()?new f(r.error):new c(e(r.value))))}mapAsync(e){return new t(this._promise.then(async r=>r.isErr()?g(r.error):new c(await e(r.value))))}mapErr(e){return new t(this._promise.then(r=>r.isErr()?new f(e(r.error)):new c(r.value)))}mapErrAsync(e){return new t(this._promise.then(async r=>r.isErr()?g(await e(r.error)):new c(r.value)))}andThen(e){return new t(this._promise.then(r=>r.isErr()?g(r.error):e(r.value).toAsync()))}andThenAsync(e){return new t(this._promise.then(r=>r.isErr()?g(r.error):e(r.value)))}nullableToOption(){return this.map(e=>e?h(e):y)}flatten(){return new t(this._promise.then(e=>e.flatten()))}flattenOption(e){return new t(this._promise.then(r=>r.flattenOption(e)))}flattenOptionOrDefault(e){return new t(this._promise.then(r=>r.flattenOptionOrDefault(e)))}matchOption(e,r){return new t(this._promise.then(n=>n.matchOption(e,r)))}matchOptionAndFlatten(e,r){return new t(this._promise.then(n=>n.matchOptionAndFlatten(e,r)))}then(e,r){return this._promise.then(e,r)}};function O(t){return new v(Promise.resolve(new c(t)))}function g(t){return new v(Promise.resolve(new f(t)))}var c=class t{constructor(e){this.value=e;this.value=e,Object.defineProperties(this,{tag:{writable:!1,enumerable:!1}})}tag="ok";isErr(){return!1}ifErr(e){return this}isErrOrNone(){return this.value instanceof T}isOk(){return!0}ifOk(e){return e(this.value),this}unwrap(){return this.value}unwrapOr(e){return this.value}unwrapOrElse(e){return this.value}unwrapErr(){return y}match(e,r){return e(this.value)}map(e){let r=e(this.value);return new t(r)}mapAsync(e){return v.fromSafePromise(e(this.value))}mapOption(e){return this.value instanceof T||this.value instanceof E?u(this.value.map(e)):u(h(e(this.value)))}andThen(e){return e(this.value)}andThenAsync(e){return e(this.value)}mapErr(e){return u(this.value)}mapErrAsync(e){return O(this.value)}flatten(){return Y(this)}flattenOption(e){return this.value instanceof T||this.value instanceof E?this.value.okOrElse(e):new t(this.value)}flattenOptionOr(e){return this.value instanceof T||this.value instanceof E?this.value.unwrapOr(e):new t(this.value)}matchOption(e,r){return this.value instanceof T||this.value instanceof E?u(this.value.match(e,r)):u(e(this.value))}toNullable(){return this.value}toAsync(){return O(this.value)}void(){return u()}toJSON(){return{tag:"ok",value:this.value}}},f=class t{constructor(e){this.error=e;this.error=e,Object.defineProperties(this,{tag:{writable:!1,configurable:!1,enumerable:!1}})}tag="err";isErr(){return!0}ifErr(e){return e(this.error),this}isOk(){return!1}ifOk(e){return this}isErrOrNone(){return!0}unwrap(){let e=`Tried to unwrap error: ${X(this.error)}`;throw new Error(e)}unwrapOr(e){return e}unwrapOrElse(e){return e()}unwrapErr(){return h(this.error)}match(e,r){return r(this.error)}map(e){return new t(this.error)}mapAsync(e){return g(this.error)}mapErr(e){return new t(e(this.error))}mapErrAsync(e){return v.fromPromise(new Promise(()=>{throw""}),()=>e(this.error))}mapOption(e){return i(this.error)}andThen(e){return new t(this.error)}andThenAsync(e){return new t(this.error).toAsync()}flatten(){return Y(this)}flattenOption(e){return new t(this.error)}flattenOptionOr(e){return new t(this.error)}matchOption(e,r){return i(this.error)}toNullable(){return null}toAsync(){return g(this.error)}void(){return i(this.error)}toJSON(){return{tag:"err",error:this.error}}};function u(t){return new c(t)}function i(t){return new f(t)}function ce(t,e){return(...r)=>{try{let n=t(...r);return u(n)}catch(n){return i(e?e(n):n)}}}function X(t){if(t instanceof Error)return t.message?t.message:"code"in t&&typeof t.code=="string"?t.code:"An unknown error occurred";if(typeof t=="string")return t;if(typeof t=="object"&&t!==null&&"message"in t){let e=t;return typeof e.message=="string"?e.message:String(e.message)}return"An unknown error occurred"}function Y(t){let e=t;for(;e instanceof c;)e=e.value;return e}var k=class extends Error{constructor(e){super(`Failed to parse ${e} as result`)}};function pe(t){let e;if(typeof t=="string")try{e=JSON.parse(t)}catch(n){return i(new k(X(n)))}else e=t;if(typeof e!="object"||e===null)return i(new k("Expected an object but received type ${typeof data}."));let r=e;if("tag"in r)switch(r.tag){case"ok":{if("value"in r)return u(r.value);break}case"err":{if("error"in r)return i(r.error);break}}return i(new k("Object does not contain 'tag' and 'value' or 'error' property"))}var E=class t{constructor(e){this.value=e;Object.defineProperties(this,{tag:{writable:!1,enumerable:!1}})}tag="some";isSome(){return!0}ifSome(e){return e(this.value),this}isNone(){return!1}ifNone(e){return this}map(e){return new t(e(this.value))}flatMap(e){return e(this.value)}andThen(e){return e(this.value)}unwrap(){return this.value}unwrapOr(e){return this.value}unwrapOrElse(e){return this.value}or(e){return this}orElse(e){return this}match(e,r){return e(this.value)}toString(){return`Some(${this.value})`}toJSON(){return{tag:"some",value:this.value}}toNullable(){return this.value}toBoolean(){return!0}okOrElse(e){return u(this.value)}},T=class t{tag="none";constructor(){Object.defineProperties(this,{tag:{writable:!1,enumerable:!1}})}isSome(){return!1}ifSome(e){return this}isNone(){return!0}ifNone(e){return e(),this}map(e){return new t}andThen(e){return y}flatMap(e){return new t}unwrap(){throw new Error("Tried to unwrap a non-existent value")}unwrapOr(e){return e}unwrapOrElse(e){return e()}or(e){return e}orElse(e){return e()}match(e,r){return r()}toString(){return"None"}toJSON(){return{tag:"none"}}toNullable(){return null}toBoolean(){return!1}okOrElse(e){return i(e())}};function h(t){return new E(t)}var y=new T;function Te(t){return t?h(t):y}var U=class t extends Error{constructor(r,n){super(t.getBestMsg(n)||"Schema validation error");this.input=r;this.detail=n;this.name="SchemaValidationError"}type="SchemaValiationError";format(){return{input:this.input,error:t.formatDetail(this.detail)}}get msg(){return t.getBestMsg(this.detail)}static getBestMsg(r){switch(r.kind){case"typeMismatch":case"unexpectedProperties":case"missingProperties":case"general":case"unionValidation":return t.formatMsg(r);case"propertyValidation":case"arrayElement":return r.msg||t.getBestMsg(r.detail);default:return"Unknown error"}}static formatDetail(r){switch(r.kind){case"general":case"typeMismatch":return t.formatMsg(r);case"propertyValidation":return{[r.property]:r.msg||this.formatDetail(r.detail)};case"unexpectedProperties":case"missingProperties":{let n=r.msg||(r.kind==="unexpectedProperties"?"Property is not allowed in a strict schema object":"Property is required, but missing");return r.keys.reduce((s,l)=>(s[l]=n,s),{})}case"arrayElement":{let n={};return r.msg&&(n.msg=r.msg),n[`index_${r.index}`]=this.formatDetail(r.detail),n}case"unionValidation":{let n=r.details?.map(s=>this.formatDetail(s));return r.msg&&n.unshift("Msg: "+r.msg),n}default:return"Unknown error type"}}static formatMsg(r){if(r.msg||r.kind==="general")return r.msg||"Unknown error";switch(r.kind){case"typeMismatch":return`Expected ${r.expected}, but received ${r.received}`;case"unexpectedProperties":return`Properties not allowed: ${r.keys.join(", ")}`;case"missingProperties":return`Missing required properties: ${r.keys.join(", ")}`;case"unionValidation":return"Input did not match any union member";default:return"Unknown error"}}};function o(t,e){return new U(t,e)}var a=class{constructor(e){this.msg=e}checks=[];parse(e){return this.validateInput(e).andThen(r=>this.applyValidationChecks(r))}static validatePrimitive(e,r,n){let s=typeof e;return s===r?u(e):i(o(e,{kind:"typeMismatch",expected:r,received:s,msg:n}))}addCheck(e){return this.checks.push(e),this}applyValidationChecks(e){for(let r of this.checks){let n=r(e);if(n)return i(n)}return u(e)}static isNullishSchema(e){return e.parse(null).isOk()||e.parse(void 0).isOk()}},A=class t extends a{static emailRegex=/^(([^<>()[\]\.,;:\s@\"]+(\.[^<>()[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i;static ipRegex=/^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;validateInput(e){return a.validatePrimitive(e,"string",this.msg)}max(e,r){return this.addCheck(n=>{if(n.length>e)return o(n,{kind:"general",msg:r||`String must be at most ${e} characters long`})})}min(e,r){return this.addCheck(n=>{if(n.length{if(!e.test(n))return o(n,{kind:"general",msg:r||`String must match pattern ${String(e)}`})})}email(e){return this.regex(t.emailRegex,e||"String must be a valid email address")}ip(e){return this.regex(t.ipRegex,e||"String must be a valid ip address")}},P=class extends a{constructor(r,n){super(n);this.literal=r}validateInput(r){return a.validatePrimitive(r,"string",this.msg).andThen(n=>n===this.literal?u(n):i(o(r,{kind:"typeMismatch",expected:this.literal,received:n,msg:this.msg})))}},V=class extends a{validateInput(e){return a.validatePrimitive(e,"number",this.msg)}gt(e,r){return this.addCheck(n=>{if(n<=e)return o(n,{kind:"general",msg:r||`Number must be greater than ${e}`})})}gte(e,r){return this.addCheck(n=>{if(n{if(n>=e)return o(n,{kind:"general",msg:r||`Number must be less than ${e}`})})}lte(e,r){return this.addCheck(n=>{if(n>e)return o(n,{kind:"general",msg:r||`Number must be less than or equal to ${e}`})})}int(e){return this.addCheck(r=>{if(!Number.isInteger(r))return o(r,{kind:"general",msg:e||"Number must be an integer"})})}positive(e){return this.gt(0,e||"Number must be positive")}nonnegative(e){return this.gte(0,e||"Number must be nonnegative")}negative(e){return this.lt(0,e||"Number must be negative")}nonpositive(e){return this.lte(0,e||"Number must be nonpositive")}finite(e){return this.addCheck(r=>{if(!Number.isFinite(r))return o(r,{kind:"general",msg:e||"Number must be an integer"})})}safe(e){return this.addCheck(r=>{if(!Number.isSafeInteger(r))return o(r,{kind:"general",msg:e||"Number must be an integer"})})}multipleOf(e,r){return this.addCheck(n=>{if(n%e!==0)return o(n,{kind:"general",msg:r||`Number must be a multiple of ${e}`})})}},I=class extends a{validateInput(e){return a.validatePrimitive(e,"bigint",this.msg)}},F=class extends a{validateInput(e){return a.validatePrimitive(e,"boolean",this.msg)}},N=class extends a{validateInput(e){return a.validatePrimitive(e,"object",this.msg).andThen(r=>{if(r instanceof Date)return u(r);let n=r?.constructor?.name??"unknown";return i(o(e,{kind:"typeMismatch",expected:"Date instance",received:n,msg:this.msg}))})}},M=class extends a{validateInput(e){return a.validatePrimitive(e,"symbol",this.msg)}},B=class extends a{validateInput(e){return a.validatePrimitive(e,"undefined",this.msg)}},j=class extends a{validateInput(e){if(e===null)return u(e);let r=typeof e=="object"?e?.constructor?.name??"unknown":typeof e;return i(o(e,{kind:"typeMismatch",expected:"null",received:r,msg:this.msg}))}},D=class extends a{validateInput(e){if(e==null)return u();let r=typeof e=="object"?e?.constructor?.name??"unknown":typeof e;return i(o(e,{kind:"typeMismatch",expected:"void (undefined/null)",received:r,msg:this.msg}))}},$=class extends a{validateInput(e){return u(e)}},C=class extends a{validateInput(e){return u(e)}},J=class extends a{validateInput(e){return i(o(e,{kind:"typeMismatch",expected:"never",received:typeof e,msg:this.msg}))}},q=class extends a{constructor(r,n){let s,l;typeof n=="string"?s=n:typeof n=="object"&&(l=n);super(s);this.shape=r;this.objectMsg=l}strictMode=!1;objectMsg;validateInput(r){return a.validatePrimitive(r,"object",this.msg||this.objectMsg?.mismatch).andThen(n=>{if(n===null)return i(o(r,{kind:"typeMismatch",expected:"Non-null object",received:"null",msg:this.msg||this.objectMsg?.nullObject}));let s={},l=new Set(Object.keys(this.shape));for(let m of Object.keys(n)){let d=this.shape[m];if(d===void 0){if(this.strictMode){let Z=new Set(Object.keys(n)).difference(new Set(Object.keys(this.shape))).keys().toArray();return i(o(r,{kind:"unexpectedProperties",keys:Z,msg:this.msg||this.objectMsg?.unexpectedProperty}))}continue}let w=d.parse(n[m]);if(w.isErr())return i(o(r,{kind:"propertyValidation",property:m,detail:w.error.detail,msg:this.msg||this.objectMsg?.propertyValidation}));l.delete(m),s[m]=w.value}let p=l.keys().filter(m=>!a.isNullishSchema(this.shape[m])).toArray();return p.length>0?i(o(r,{kind:"missingProperties",keys:p,msg:this.msg||this.objectMsg?.missingProperty})):u(s)})}strict(){return this.strictMode=!0,this}},L=class t extends a{constructor(r,n){let s,l;typeof n=="string"?s=n:typeof n=="object"&&(l=n);super(s);this.schemas=r;this.unionMsg=l}unionMsg;static getTypeFromSchemaName(r){switch(r){case"StringSchema":case"LiteralSchema":return"string";case"NumberSchema":return"number";case"BigintSchema":return"bigint";case"BooleanSchema":return"boolean";case"UndefinedSchema":return"undefined";case"SymbolSchema":return"symbol";default:return"object"}}validateInput(r){let n=[],s=!0;for(let l of this.schemas){let p=l.parse(r);if(p.isOk())return u(p.value);s=p.error.detail?.kind==="typeMismatch"&&s,n.push(p.error.detail)}return s?i(o(r,{kind:"typeMismatch",expected:this.schemas.map(l=>t.getTypeFromSchemaName(l.constructor.name)).join(" | "),received:typeof r,msg:this.msg||this.unionMsg?.mismatch})):i(o(r,{kind:"unionValidation",msg:this.msg||this.unionMsg?.unionValidation||"Input did not match any union member",details:n}))}},K=class extends a{constructor(r,n){let s,l;typeof n=="string"?s=n:typeof n=="object"&&(l=n);super(s);this.schema=r;this.arrayMsg=l}arrayMsg;validateInput(r){if(!Array.isArray(r))return i(o(r,{kind:"typeMismatch",expected:"Array",received:"Non-array",msg:this.msg||this.arrayMsg?.mismatch}));for(let n=0;n{if("tag"in n)switch(n.tag){case"ok":return"value"in n?this.okSchema.parse(n.value).match(s=>u(u(s)),s=>i(o(r,{kind:"propertyValidation",property:"value",detail:s.detail}))):a.isNullishSchema(this.okSchema)?u(u()):i(o(r,{kind:"missingProperties",keys:["value"],msg:"If tag is set to 'ok', than result must contain a 'value' property"}));case"err":return"error"in n?this.errSchema.parse(n.error).match(s=>u(i(s)),s=>i(o(r,{kind:"propertyValidation",property:"error",detail:s.detail}))):a.isNullishSchema(this.errSchema)?u(i()):i(o(r,{kind:"missingProperties",keys:["error"],msg:"If tag is set to 'err', than result must contain a 'error' property"}));default:return i(o(r,{kind:"propertyValidation",property:"tag",detail:{kind:"typeMismatch",expected:"'ok' or 'err'",received:`'${n.tag}'`}}))}else return i(o(r,{kind:"missingProperties",keys:["tag"],msg:"Result must contain a tag property"}))})}},Q=class extends a{constructor(r){super();this.schema=r}validateInput(r){return a.validatePrimitive(r,"object").andThen(n=>{if("tag"in n)switch(n.tag){case"some":return"value"in n?this.schema.parse(n.value).match(s=>u(h(s)),s=>i(o(r,{kind:"propertyValidation",property:"value",detail:s.detail}))):a.isNullishSchema(this.schema)?u(h()):i(o(r,{kind:"missingProperties",keys:["value"],msg:"If tag is set to 'some', than option must contain a 'value' property"}));case"none":return u(y);default:return i(o(r,{kind:"propertyValidation",property:"tag",detail:{kind:"typeMismatch",expected:"'some' or 'none'",received:`'${n.tag}'`}}))}else return i(o(r,{kind:"missingProperties",keys:["tag"],msg:"Option must contain a tag property"}))})}},R={string:t=>new A(t),literal:(t,e)=>new P(t,e),number:t=>new V(t),bigint:t=>new I(t),boolean:t=>new F(t),date:t=>new N(t),symbol:t=>new M(t),undefined:t=>new B(t),null:t=>new j(t),void:t=>new D(t),any:t=>new $(t),unknown:t=>new C(t),never:t=>new J(t),obj:(t,e)=>new q(t,e),union:(t,e)=>new L(t,e),array:(t,e)=>new K(t,e),optional:(t,e)=>new z(t,e),nullable:(t,e)=>new W(t,e),nullish:(t,e)=>new G(t,e),result:(t,e)=>new H(t,e),option:t=>new Q(t)};var x=class extends U{type="RequestValidationError";constructor(e,r){super(e,r)}},S=class extends U{type="ResponseValidationError";constructor(e,r){super(e,r)}};var b=class{constructor(e,r,n){this.path=e;this.method=r;this.schema=n;this.pathSplitted=e.split("/"),this.paramIndexes=this.pathSplitted.reduce((s,l,p)=>(l.startsWith(":")&&(s[l.slice(1)]=p),s),{})}pathSplitted;paramIndexes;makeRequest(e,r){return this.schema.req.parse(e).toAsync().mapErr(n=>new x(n.input,n.detail)).andThenAsync(async n=>{let s=this.pathSplitted;for(let[d,w]of Object.entries(r))s[this.paramIndexes[d]]=w;let l=s.join("/"),m=await(await fetch(l,{method:this.method,headers:{"Content-Type":"application/json"},body:JSON.stringify(n)})).json();return this.schema.res.parse(m).toAsync().map(d=>d).mapErr(d=>new S(d.input,d.detail))})}};var _={req:R.obj({password:R.string()}),res:R.result(R.string(),R.any())},Be=new b("/login","POST",_);export{a as BaseSchema,f as Err,T as None,c as Ok,Q as OptionSchema,v as ResultAsync,pe as ResultFromJSON,H as ResultSchema,U as SchemaValidationError,E as Some,i as err,g as errAsync,Y as flattenResult,Te as fromNullableVal,ce as fromThrowable,X as getMessageFromError,Be as loginApi,y as none,u as ok,O as okAsync,h as some,R as z}; diff --git a/server/src/js/login.ts b/server/src/js/login.ts index 4ddf6c3..f1deee1 100644 --- a/server/src/js/login.ts +++ b/server/src/js/login.ts @@ -1,6 +1,6 @@ /// -import { ok } from "./shared.bundle.ts"; +import { loginApi } from "./shared.bundle.ts"; const form = document.getElementById("loginForm") as HTMLFormElement; const passwordInput = document.getElementById( @@ -12,19 +12,7 @@ form.addEventListener("submit", async (e) => { const password = passwordInput.value; - const bodyReq = JSON.stringify( - ok({ - password: password, - }).toJSON(), - ); + const res = await loginApi.makeRequest({ password }, {}); - const response = await fetch("/login", { - method: "POST", - headers: { accept: "application/json" }, - body: bodyReq, - }); - - const body = await response.json(); - - const a = 8; + console.log(res); }); diff --git a/server/src/js/shared.bundle.ts b/server/src/js/shared.bundle.ts deleted file mode 120000 index e4719d5..0000000 --- a/server/src/js/shared.bundle.ts +++ /dev/null @@ -1 +0,0 @@ -../../../shared/utils/index.ts \ No newline at end of file diff --git a/server/src/js/shared.bundle.ts b/server/src/js/shared.bundle.ts new file mode 100644 index 0000000..8156480 --- /dev/null +++ b/server/src/js/shared.bundle.ts @@ -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"; diff --git a/server/src/lib/admin.ts b/server/src/lib/admin.ts index 2980399..39ac111 100644 --- a/server/src/lib/admin.ts +++ b/server/src/lib/admin.ts @@ -131,13 +131,20 @@ class AdminSessions { }, EXPIRED_TOKENS_DELETION_INTERVAL); } - public create(expiresAt?: Date): Result { + public create( + expiresAt?: Date, + ): Result<{ value: string; expires: Date }, QueryExecutionError> { const token = generateRandomString(TOKEN_LENGTH); if (expiresAt) { return this.statements .insertSessionTokenWithExpiry(token, expiresAt.toISOString()) - .map(() => token); + .map(() => { + return { + value: token, + expires: expiresAt, + }; + }); } const now = new Date(); @@ -148,7 +155,12 @@ class AdminSessions { return this.statements .insertSessionTokenWithExpiry(token, expiresAtDefault.toISOString()) - .map(() => token); + .map(() => { + return { + value: token, + expires: expiresAtDefault, + }; + }); } public verifyToken(token: string): Result { diff --git a/server/src/lib/apiValidator.ts b/server/src/lib/apiValidator.ts index e353eb4..c43a1b3 100644 --- a/server/src/lib/apiValidator.ts +++ b/server/src/lib/apiValidator.ts @@ -1,10 +1,88 @@ 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 { - client = { - validate(res: Response): Result, - }; - server = { - validate(req: Request): Result, - }; +export type ExtractRouteParams = T extends string + ? T extends `${infer _Start}:${infer Param}/${infer Rest}` + ? Param | ExtractRouteParams + : T extends `${infer _Start}:${infer Param}` ? Param + : never + : never; + +type ApiError = + | RequestValidationError + | ResponseValidationError; + +export class Api< + Path extends string, + ReqSchema extends Schema, + ResSchema extends Schema, +> { + private readonly pathSplitted: string[]; + private readonly paramIndexes: Record; + + 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>( + (acc, segment, index) => { + if (segment.startsWith(":")) { + acc[segment.slice(1)] = index; + } + return acc; + }, + {}, + ); + } + + makeRequest( + reqBody: InferSchemaType, + params: { [K in ExtractRouteParams]: string }, + ): ResultAsync, 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) + .mapErr((e) => + new ResponseValidationError(e.input, e.detail) + ); + }); + } } diff --git a/server/src/lib/context.ts b/server/src/lib/context.ts index 6b22f3e..18f9ce0 100644 --- a/server/src/lib/context.ts +++ b/server/src/lib/context.ts @@ -3,7 +3,21 @@ 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 { 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 const SECURITY_HEADERS: Headers = new Headers({ @@ -36,7 +50,11 @@ function mergeHeaders(...headers: Headers[]): Headers { return mergedHeaders; } -export class Context { +export class Context< + S extends string = string, + ReqSchema extends Schema | never = never, + ResSchema extends Schema | never = never, +> { private _url?: URL; private _hostname?: string; private _port?: number; @@ -48,8 +66,30 @@ export class Context { public readonly req: Request, public readonly info: Deno.ServeHandlerInfo, public readonly params: Params>, + public schema: [ReqSchema, ResSchema] extends [never, never] ? never + : { + req: ReqSchema; + res: ResSchema; + }, ) {} + public parseBody(): ResultAsync< + InferSchemaType, + SchemaValidationError | FailedToParseRequestAsJSON + > { + if (!this.schema || !this.schema.req) { + log.error("No schema provided!"); + Deno.exit(1); + } + + return ResultAsync + .fromPromise( + this.req.json(), + (e) => new FailedToParseRequestAsJSON(getMessageFromError(e)), + ) + .andThen((data: unknown) => this.schema.req.parse(data)); + } + get url(): URL { return this._url ?? (this._url = new URL(this.req.url)); } @@ -60,7 +100,6 @@ export class Context { get preferredType(): Option<"json" | "html"> { const headers = new Headers(this.req.headers); - return fromNullableVal(headers.get("accept")).andThen( (types_header) => { const types = types_header.split(";")[0].trim().split(","); @@ -132,6 +171,16 @@ export class Context { }); } + 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 { const headers = mergeHeaders( SECURITY_HEADERS, @@ -192,6 +241,7 @@ export class Context { newCtx._port = ctx._port; newCtx._cookies = ctx._cookies; newCtx._responseHeaders = ctx._responseHeaders; + newCtx.schema = ctx.schema; return newCtx; } diff --git a/server/src/lib/errors.ts b/server/src/lib/errors.ts index e30c723..60021f2 100644 --- a/server/src/lib/errors.ts +++ b/server/src/lib/errors.ts @@ -1,4 +1,7 @@ -import log from "@shared/utils/logger.ts"; +import { + SchemaValidationError, + ValidationErrorDetail, +} from "@shared/utils/validator.ts"; export class ErrorBase extends Error { constructor(message: string = "An unknown error has occurred") { @@ -8,42 +11,69 @@ export class ErrorBase extends Error { } export class QueryExecutionError extends ErrorBase { - public readonly code = "QueryExecutionError"; + public readonly type = "QueryExecutionError"; constructor(message: string) { super(message); } } export class NoAdminEntryError extends ErrorBase { - public readonly code = "NoAdminEntry"; + public readonly type = "NoAdminEntry"; constructor(message: string) { super(message); } } export class FailedToReadFileError extends ErrorBase { - public readonly code = "FailedToReadFileError"; + public readonly type = "FailedToReadFileError"; constructor(message: string) { super(message); } } export class InvalidSyntaxError extends ErrorBase { - public readonly code = "InvalidSyntax"; + public readonly type = "InvalidSyntax"; constructor(message: string) { super(message); } } export class InvalidPathError extends ErrorBase { - public readonly code = "InvalidPath"; + public readonly type = "InvalidPath"; constructor(message: string) { super(message); } } 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) { super(message); } diff --git a/server/src/lib/router.ts b/server/src/lib/router.ts index 4af49f4..5cc6d6d 100644 --- a/server/src/lib/router.ts +++ b/server/src/lib/router.ts @@ -1,25 +1,44 @@ import { RouterTree } from "@lib/routerTree.ts"; import { none, Option, some } from "@shared/utils/option.ts"; import { Context } from "@lib/context.ts"; +import { Schema } from "@shared/utils/validator.ts"; +import { Api } from "@src/lib/apiValidator.ts"; -type RequestHandler = ( - c: Context, +type RequestHandler< + S extends string, + ReqSchema extends Schema = never, + ResSchema extends Schema = never, +> = ( + c: Context, ) => Promise | Response; +type RequestHandlerWithSchema = { + handler: RequestHandler; + schema?: { + res: Schema; + req: Schema; + }; +}; export type Middleware = ( c: Context, next: () => Promise, ) => Promise | Response | void; type MethodHandlers = Partial< - Record> + Record; + schema?: { + res: Schema; + req: Schema; + }; + }> >; const DEFAULT_NOT_FOUND_HANDLER = () => new Response("404 Not found"); class HttpRouter { - routerTree = new RouterTree>(); + public readonly routerTree = new RouterTree>(); pathPreprocessor?: (path: string) => string; - middlewares: Middleware[] = []; + private middlewares: Middleware[] = []; defaultNotFoundHandler: RequestHandler = DEFAULT_NOT_FOUND_HANDLER; setPathProcessor(processor: (path: string) => string) { @@ -35,27 +54,39 @@ class HttpRouter { path: S, method: string, handler: RequestHandler, + schema?: { + res: Schema; + req: Schema; + }, ): HttpRouter; add( path: S[], method: string, handler: RequestHandler, + schema?: { + res: Schema; + req: Schema; + }, ): HttpRouter; add( path: string | string[], method: string, handler: RequestHandler, + schema?: { + res: Schema; + req: Schema; + }, ): HttpRouter { const paths = Array.isArray(path) ? path : [path]; for (const p of paths) { this.routerTree.getHandler(p).match( (mth) => { - mth[method] = handler; + mth[method] = { handler, schema }; }, () => { const mth: MethodHandlers = {}; - mth[method] = handler; + mth[method] = { handler, schema }; this.routerTree.add(p, mth); }, ); @@ -64,14 +95,11 @@ class HttpRouter { return this; } - // Overload signatures for 'get' get(path: S, handler: RequestHandler): HttpRouter; get( path: S[], handler: RequestHandler, ): HttpRouter; - - // Non-generic implementation for 'get' get(path: string | string[], handler: RequestHandler): HttpRouter { if (Array.isArray(path)) { return this.add(path, "GET", handler); @@ -84,7 +112,6 @@ class HttpRouter { path: string[], handler: RequestHandler, ): HttpRouter; - post(path: string | string[], handler: RequestHandler): HttpRouter { if (Array.isArray(path)) { return this.add(path, "POST", handler); @@ -92,6 +119,17 @@ class HttpRouter { return this.add(path, "POST", handler); } + api< + Path extends string, + ReqSchema extends Schema, + ResSchema extends Schema, + >( + api: Api, + handler: RequestHandler, + ): HttpRouter { + return this.add(api.path, api.method, handler, api.schema); + } + async handleRequest( req: Request, connInfo: Deno.ServeHandlerInfo, @@ -102,25 +140,37 @@ class HttpRouter { ? this.pathPreprocessor(c.path) : c.path; - let params: string[] = []; + let params: Record = {}; const handler = this.routerTree .find(path) .andThen((routeMatch) => { - const { value: handlers, params: paramsMatched } = routeMatch; + const { value: methodToHandler, params: paramsMatched } = + routeMatch; + 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); - const cf = await this.executeMiddlewareChain( + const res = (await this.executeMiddlewareChain( this.middlewares, handler, Context.setParams(c, params), - ); + )).res; - return cf.res; + return res; } private async executeMiddlewareChain( diff --git a/server/src/middleware/logger.ts b/server/src/middleware/logger.ts index 20ede98..0f5f821 100644 --- a/server/src/middleware/logger.ts +++ b/server/src/middleware/logger.ts @@ -4,6 +4,7 @@ const loggerMiddleware: Middleware = async (c, next) => { console.log("", c.req.method, c.path); await next(); console.log("", c.res.status, "\n"); + console.log(await c.res.json()); }; export default loggerMiddleware; diff --git a/shared/utils/api.ts b/shared/utils/api.ts index 26f3669..0d52a65 100644 --- a/shared/utils/api.ts +++ b/shared/utils/api.ts @@ -1,5 +1,9 @@ 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 `${infer _Start}:${infer Param}/${infer Rest}` @@ -13,22 +17,35 @@ class ClientApi< ReqSchema extends Schema, ResSchema extends Schema, > { + private readonly path: string[]; + private readonly paramsIndexes: Record; + constructor( - public readonly path: Path, + path: Path, public readonly reqSchema: ReqSchema, public readonly resSchema: ResSchema, ) { + this.path = path.split("/"); + this.paramsIndexes = this.path.reduce>( + (acc, segment, index) => { + if (segment.startsWith(":")) { + acc[index] = segment.slice(1); + } + return acc; + }, + {}, + ); } makeRequest( reqBody: InferSchemaType, - params?: ExtractRouteParams, + params?: { [K in ExtractRouteParams]: string }, ) { - const pathWithParams = this.path.split("/").map((segment) => { - if (segment.startsWith(":")) { - return params[segment.slice(1)]; + const path = this.path.slice().reduce((acc, cur) => {}); + if (params) { + for (const param of Object.keys(params)) { + pathSplitted[this.paramsIndexes[param]] = param; } - return segment; - }); + } } } diff --git a/shared/utils/index.ts b/shared/utils/index.ts index 94da3cc..8fa3dfe 100644 --- a/shared/utils/index.ts +++ b/shared/utils/index.ts @@ -1,4 +1,4 @@ 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 "@shared/utils/validator.ts"; diff --git a/shared/utils/resultasync.ts b/shared/utils/resultasync.ts index 7530fe6..4819ca1 100644 --- a/shared/utils/resultasync.ts +++ b/shared/utils/resultasync.ts @@ -150,7 +150,7 @@ export class ResultAsync implements PromiseLike> { } andThenAsync( - fn: (value: T) => ResultAsync, + fn: (value: T) => ResultAsync | Promise>, ): ResultAsync { return new ResultAsync( this._promise.then( diff --git a/shared/utils/validator.ts b/shared/utils/validator.ts index 594f8ed..1f914f4 100644 --- a/shared/utils/validator.ts +++ b/shared/utils/validator.ts @@ -2,7 +2,7 @@ import { err, Result } from "@shared/utils/result.ts"; import { ok } from "@shared/utils/index.ts"; import { None, none, Option, some } from "@shared/utils/option.ts"; // ── Error Types ───────────────────────────────────────────────────── -type ValidationErrorDetail = +export type ValidationErrorDetail = | { kind: "typeMismatch"; expected: string; @@ -33,14 +33,18 @@ type ValidationErrorDetail = } | { kind: "general"; mark?: string; msg: string }; -class SchemaValidationError extends Error { - public readonly type = "SchemaValidationError"; +export class SchemaValidationError extends Error { + public readonly type = "SchemaValiationError"; constructor( public readonly input: unknown, public readonly detail: ValidationErrorDetail, ) { - super(detail.msg || "Schema validation error"); + super( + SchemaValidationError.getBestMsg(detail) || + "Schema validation error", + ); + this.name = "SchemaValidationError"; } public format(): Record { @@ -971,7 +975,7 @@ class NullishSchema> } } -class ResultSchema, E extends Schema> +export class ResultSchema, E extends Schema> extends BaseSchema, InferSchemaType>> { constructor( private readonly okSchema: T, @@ -1078,7 +1082,7 @@ class ResultSchema, E extends Schema> } } -class OptionSchema> +export class OptionSchema> extends BaseSchema>> { constructor( private readonly schema: T,