| | | 1 | | import type { paths } from "~/api/types"; |
| | | 2 | | import { apiFetch } from "~/api/http"; |
| | | 3 | | import type { ProblemBody } from "~/api/problem"; |
| | | 4 | | |
| | | 5 | | type HttpMethod = "get" | "post" | "put" | "delete"; |
| | | 6 | | |
| | | 7 | | type PathsWithMethod<M extends HttpMethod> = { |
| | | 8 | | [P in keyof paths]: paths[P] extends Record<M, object> ? P : never; |
| | | 9 | | }[keyof paths]; |
| | | 10 | | |
| | | 11 | | type Op<P extends keyof paths, M extends HttpMethod> = P extends keyof paths |
| | | 12 | | ? M extends keyof paths[P] |
| | | 13 | | ? paths[P][M] |
| | | 14 | | : never |
| | | 15 | | : never; |
| | | 16 | | |
| | | 17 | | type RequestBody<O> = O extends { |
| | | 18 | | requestBody: { content: { "application/json": infer B } }; |
| | | 19 | | } |
| | | 20 | | ? B |
| | | 21 | | : never; |
| | | 22 | | |
| | | 23 | | type SuccessBody<O> = O extends { responses: infer R } |
| | | 24 | | ? R extends { 200: { content: { "application/json": infer S } } } |
| | | 25 | | ? S |
| | | 26 | | : R extends { 201: { content: { "application/json": infer S } } } |
| | | 27 | | ? S |
| | | 28 | | : R extends { 204: unknown } |
| | | 29 | | ? void |
| | | 30 | | : unknown |
| | | 31 | | : unknown; |
| | | 32 | | |
| | | 33 | | type PathParams<O> = O extends { parameters: { path: infer P } } |
| | | 34 | | ? P extends Record<string, unknown> |
| | | 35 | | ? P |
| | | 36 | | : never |
| | | 37 | | : never; |
| | | 38 | | |
| | | 39 | | type HasPathParams<O> = O extends { parameters: { path: Record<string, unknown> } } |
| | | 40 | | ? true |
| | | 41 | | : false; |
| | | 42 | | |
| | | 43 | | type HasBody<O> = O extends { requestBody: { content: unknown } } ? true : false; |
| | | 44 | | |
| | | 45 | | export interface CallOptions { |
| | | 46 | | ssrRequest?: Request; |
| | | 47 | | signal?: AbortSignal; |
| | | 48 | | } |
| | | 49 | | |
| | | 50 | | type WithParams<P extends keyof paths, M extends HttpMethod> = |
| | | 51 | | HasPathParams<Op<P, M>> extends true |
| | | 52 | | ? { params: PathParams<Op<P, M>> } |
| | | 53 | | : { params?: never }; |
| | | 54 | | |
| | | 55 | | type WithBody<P extends keyof paths, M extends HttpMethod> = |
| | | 56 | | HasBody<Op<P, M>> extends true |
| | | 57 | | ? { body: RequestBody<Op<P, M>> } |
| | | 58 | | : { body?: never }; |
| | | 59 | | |
| | | 60 | | export type Options<P extends keyof paths, M extends HttpMethod> = |
| | | 61 | | CallOptions & WithParams<P, M> & WithBody<P, M>; |
| | | 62 | | |
| | 10 | 63 | | function substitutePath(path: string, params?: Record<string, unknown>): string { |
| | 10 | 64 | | if (!params) return path; |
| | 6 | 65 | | return path.replace(/\{(\w+)\}/g, (_, key) => { |
| | 6 | 66 | | const v = params[key]; |
| | 6 | 67 | | if (v === undefined || v === null) { |
| | 0 | 68 | | throw new Error(`Missing path parameter "${key}" for ${path}`); |
| | | 69 | | } |
| | 6 | 70 | | return encodeURIComponent(String(v)); |
| | | 71 | | }); |
| | | 72 | | } |
| | | 73 | | |
| | 3 | 74 | | async function parseProblem(response: Response): Promise<ProblemBody | null> { |
| | 3 | 75 | | const ct = response.headers.get("content-type") ?? ""; |
| | 3 | 76 | | if (!ct.includes("problem+json") && !ct.includes("application/json")) return null; |
| | 2 | 77 | | try { |
| | 2 | 78 | | return (await response.json()) as ProblemBody; |
| | | 79 | | } catch { |
| | 0 | 80 | | return null; |
| | | 81 | | } |
| | | 82 | | } |
| | | 83 | | |
| | 10 | 84 | | async function call<P extends keyof paths, M extends HttpMethod>( |
| | | 85 | | method: M, |
| | | 86 | | path: P, |
| | | 87 | | opts?: Options<P, M>, |
| | | 88 | | ): Promise<SuccessBody<Op<P, M>>> { |
| | 10 | 89 | | const url = substitutePath( |
| | | 90 | | path as string, |
| | | 91 | | opts?.params as Record<string, unknown> | undefined, |
| | | 92 | | ); |
| | | 93 | | |
| | 10 | 94 | | const init: RequestInit = { method: method.toUpperCase(), signal: opts?.signal }; |
| | 10 | 95 | | if (opts && "body" in opts && opts.body !== undefined) { |
| | 3 | 96 | | init.headers = { "Content-Type": "application/json" }; |
| | 3 | 97 | | init.body = JSON.stringify(opts.body); |
| | | 98 | | } |
| | | 99 | | |
| | 10 | 100 | | const response = await apiFetch(url, init, opts?.ssrRequest); |
| | | 101 | | |
| | 10 | 102 | | if (!response.ok) { |
| | 3 | 103 | | const body = await parseProblem(response); |
| | 3 | 104 | | const fallback: ProblemBody = body ?? { title: response.statusText, status: response.status }; |
| | | 105 | | // Throw a `Response` (not a custom Error) so RR7 serializes the body to the |
| | | 106 | | // client across SSR — `useRouteError` exposes the parsed body via `error.data` |
| | | 107 | | // and `isRouteErrorResponse(error)` returns true. Custom Error subclass |
| | | 108 | | // fields would be stripped by `serializeError`. |
| | 3 | 109 | | throw new Response(JSON.stringify(fallback), { |
| | | 110 | | status: response.status, |
| | | 111 | | statusText: typeof fallback.title === "string" |
| | | 112 | | ? fallback.title.slice(0, 200) |
| | | 113 | | : response.statusText, |
| | | 114 | | headers: { "Content-Type": "application/problem+json" }, |
| | | 115 | | }); |
| | | 116 | | } |
| | | 117 | | |
| | 7 | 118 | | if (response.status === 204) return undefined as SuccessBody<Op<P, M>>; |
| | 6 | 119 | | return (await response.json()) as SuccessBody<Op<P, M>>; |
| | | 120 | | } |
| | | 121 | | |
| | 6 | 122 | | export function get<P extends PathsWithMethod<"get">>( |
| | | 123 | | path: P, |
| | | 124 | | opts?: Options<P, "get">, |
| | | 125 | | ): Promise<SuccessBody<Op<P, "get">>> { |
| | 6 | 126 | | return call("get", path, opts); |
| | | 127 | | } |
| | | 128 | | |
| | 2 | 129 | | export function post<P extends PathsWithMethod<"post">>( |
| | | 130 | | path: P, |
| | | 131 | | opts?: Options<P, "post">, |
| | | 132 | | ): Promise<SuccessBody<Op<P, "post">>> { |
| | 2 | 133 | | return call("post", path, opts); |
| | | 134 | | } |
| | | 135 | | |
| | 1 | 136 | | export function put<P extends PathsWithMethod<"put">>( |
| | | 137 | | path: P, |
| | | 138 | | opts?: Options<P, "put">, |
| | | 139 | | ): Promise<SuccessBody<Op<P, "put">>> { |
| | 1 | 140 | | return call("put", path, opts); |
| | | 141 | | } |
| | | 142 | | |
| | 1 | 143 | | export function del<P extends PathsWithMethod<"delete">>( |
| | | 144 | | path: P, |
| | | 145 | | opts?: Options<P, "delete">, |
| | | 146 | | ): Promise<SuccessBody<Op<P, "delete">>> { |
| | 1 | 147 | | return call("delete", path, opts); |
| | | 148 | | } |