From 7b9bc9093ad925d6ac939582694a39034347c88e Mon Sep 17 00:00:00 2001 From: mb21 Date: Thu, 11 Sep 2025 16:15:12 +0200 Subject: [PATCH] Started with mastro/api etc --- deno.json | 1 + src/api.client.ts | 76 +++++++++++++++++++++++++++++++ src/api.ts | 113 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 190 insertions(+) create mode 100644 src/api.client.ts create mode 100644 src/api.ts diff --git a/deno.json b/deno.json index 93686f4..9254d48 100644 --- a/deno.json +++ b/deno.json @@ -23,6 +23,7 @@ "@std/path": "jsr:@std/path@^1.1.1", "@std/http": "jsr:@std/http@^1.0.19", "@maverick-js/signals": "npm:@maverick-js/signals@6.0.0", + "@valibot/valibot": "jsr:@valibot/valibot@^1.1.0", "ts-blank-space": "npm:ts-blank-space@0.6.1" } } diff --git a/src/api.client.ts b/src/api.client.ts new file mode 100644 index 0000000..6ed8391 --- /dev/null +++ b/src/api.client.ts @@ -0,0 +1,76 @@ +// deno-lint-ignore-file no-explicit-any +import type { JsonRoute } from "./api.ts"; + +export type Method = 'DELETE' | 'GET' | 'HEAD' | 'OPTIONS' | 'PATCH' | 'POST' | 'PUT'; + +export type Opts = RequestInit & { timeout?: number } + +export const timeoutError = 'Request timed out' + +export const makeRequest = async ( + method: Method, + url: URL | string, + data: unknown, + options: Opts = {}, +): Promise => { + const { headers, timeout = 90000, ...restOptions } = options + const controller = new AbortController() + const abortTimeout = setTimeout(() => controller.abort(), timeout) + const body = data === undefined + ? undefined + : (data instanceof File ? data : JSON.stringify(data)) + + const fetchOpts: RequestInit = { + method, + headers: { 'Content-Type': data instanceof File ? data.type : 'application/json', ...(headers || {}) }, + body, + signal: controller.signal, + ...restOptions, + } + + let response + let json + let err + try { + response = await fetch(url.toString(), fetchOpts) + json = await response.json() + } catch (e: any) { + err = e + } finally { + clearTimeout(abortTimeout) + } + + if (!response || !response.ok || err) { + if (json === null || typeof json !== "object") { + json = {}; + } + + const status = response?.status; + const timedOut = err?.name === 'AbortError' + if (timedOut) { + json.error = timeoutError + } else if (status === 200) { + // response.json() may throw when a request is interrupted, + // but headers (including status=200) could already have been received. + json.error = err?.message || err?.toString() || 'Unknown fetch error'; + } else { + json.status = status; + if (typeof json?.error !== 'string' && status !== 204) { + json.error = `Failed to ${method} ${url}`; + } + } + } + + return json; +} + +export function fetchApi>( + method: R['__method'], path: R['__path'], data?: R['__reqBody'], queryParams?: R['__queryParams'], opts?: Opts +): Promise +export function fetchApi>( + method: R['__method'], path: R['__path'], data: R['__reqBody'], queryParams?: R['__queryParams'], opts?: Opts +): Promise +export function fetchApi (method: any, path: any, data: any, queryParams: any, opts: any) { + const url = queryParams ? `${path}?${new URLSearchParams(queryParams).toString()}` : path + return makeRequest(method, url, data, opts) +} diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..d5a7dee --- /dev/null +++ b/src/api.ts @@ -0,0 +1,113 @@ +import * as v from '@valibot/valibot'; + +import { getParams } from "./core/router.ts"; +import { jsonResponse } from "./core/responses.ts"; +import type { Method } from "./api.client.ts"; + +/** + * This is the standard `(req: Request) => Response type`, along with a few phantom types + * (aka branded types), which we use for type-checking in `fetchApi`. + */ +// deno-lint-ignore no-explicit-any +export type JsonRoute = + ((req: Request) => Response | Promise) & { + __method: M; + __params: P; + __path: U; + __queryParams: Q; + __reqBody: v.InferOutput; + __resBody: R; +} + +/** + * Constructs a JSON API route, handling request input validation and JSON serialization. + * The returned type contains also all route information for the `fetchApi` client function. + * + * Example usage: + * + * ``` + * export type ChatPost = typeof POST + * export const POST = jsonRoute( + * { + * method: 'POST', + * path: '' as `${string}.json`, + * params: { chatId: 'string' }, + * queryParams: { q: '' as string | undefined }, + * reqBodySchema: schema, + * }, + * async ({ body, params }) => { + * return { text: 'hello world' } + * } + * ) + * ``` + * + * TODO: we should use valibot to specify the params and queryParams + */ +export const jsonRoute = < + M extends Method, + P extends Record, + Q extends Record, + R extends object, + U extends string, + B=undefined, + >( + opts: { + method: M; + params?: P; + path: U; + queryParams?: Q; + reqBodySchema?: v.InferOutput; + }, + handler: ( + context: { + body: B; + queryParams: Q; + } + ) => R | Promise, + ): JsonRoute => ( + async (req) => { + const url = new URL(req.url) + const params = getParams(req.url) + + for (const key in opts.params) { + if (!params[key]) { + return jsonErr(`Param '${key}' missing`, 401); + } + } + + const queryParams = opts.queryParams ? Object.fromEntries(url.searchParams) : undefined + for (const key in opts.queryParams) { + if (opts.queryParams[key] === 'string' && !queryParams?.[key]) { + return jsonErr(`Mandatory QueryParam '${key}' missing`, 401); + } + } + + let body + if (opts.reqBodySchema) { + try { + const data = await req.json() + body = v.parse(opts.reqBodySchema, data); + } catch (e) { + const error = e instanceof v.ValiError + ? 'Zod schema validation failed: ' + JSON.stringify(e.issues) + : (e instanceof Error ? e.message : 'validate failed') + return jsonErr(error, 400); + } + } + + let res + try { + res = await handler({ req, body, queryParams }) + } catch (e) { + return jsonErr(e, 500); + } + + const status = 'status' in res ? res.status : undefined; + return jsonResponse(res, typeof status === "number" ? status : 200) + } + ) as JsonRoute + +const jsonErr = (e: unknown, status: number) => { + const error = e instanceof Error ? e.message : e?.toString(); + return jsonResponse({ error, status }, status); +}