Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
76 changes: 76 additions & 0 deletions src/api.client.ts
Original file line number Diff line number Diff line change
@@ -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 <T>(
method: Method,
url: URL | string,
data: unknown,
options: Opts = {},
): Promise<T | { error: string; status?: number; }> => {
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<R extends JsonRoute<any, 'GET' | 'DELETE' | 'HEAD' | 'OPTIONS'>>(
method: R['__method'], path: R['__path'], data?: R['__reqBody'], queryParams?: R['__queryParams'], opts?: Opts
): Promise<R['__resBody']>
export function fetchApi<R extends JsonRoute<any, 'PATCH' | 'POST' | 'PUT'>>(
method: R['__method'], path: R['__path'], data: R['__reqBody'], queryParams?: R['__queryParams'], opts?: Opts
): Promise<R['__resBody']>
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)
}
113 changes: 113 additions & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
@@ -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<B=any, M extends Method = any, P=any, Q=any, R=any, U extends string = any> =
((req: Request) => Response | Promise<Response>) & {
__method: M;
__params: P;
__path: U;
__queryParams: Q;
__reqBody: v.InferOutput<B>;
__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<JsonRoute>` 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<string, string | undefined>,
Q extends Record<string, string | undefined>,
R extends object,
U extends string,
B=undefined,
>(
opts: {
method: M;
params?: P;
path: U;
queryParams?: Q;
reqBodySchema?: v.InferOutput<B>;
},
handler: (
context: {
body: B;
queryParams: Q;
}
) => R | Promise<R>,
): JsonRoute<B, M, P, Q, R, U> => (
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<B, M, P, Q, R, U>

const jsonErr = (e: unknown, status: number) => {
const error = e instanceof Error ? e.message : e?.toString();
return jsonResponse({ error, status }, status);
}