diff --git a/src/client-http.test.ts b/src/client-http.test.ts new file mode 100644 index 0000000..14638ff --- /dev/null +++ b/src/client-http.test.ts @@ -0,0 +1,201 @@ +import { afterAll, beforeAll, describe, expect, expectTypeOf, it } from "vitest"; +import { createClient } from "../src/client-http"; +import { z } from "zod"; +import { createEndpoint } from "./endpoint"; +import { createRouter, type Router } from "./router"; +import { createMiddleware } from "./middleware"; + +import { toNodeHandler } from "./adapters/node"; +import { createServer } from "http"; + +let port = 3000; +async function listen(router: Router) { + let usePort = port++; + const server = createServer(toNodeHandler(router.handler)); + await server.listen(usePort); + return { + baseURL: `http://localhost:${usePort}`, + close: () => server.close() + }; +} + +describe("client-http", () => { + const getEndpoint = createEndpoint( + "/test2", + { + method: "GET", + query: z.object({ + hello: z.string(), + }), + }, + async (ctx) => { + return { + hello: "world", + }; + }, + ); + const endpoint = createEndpoint( + "/test", + { + method: "POST", + body: z.object({ + hello: z.string(), + }), + }, + async (ctx) => { + return { + hello: "world", + }; + }, + ); + + const endpoint2 = createEndpoint( + "/test3", + { + method: "GET", + query: z.object({ + hello: z.string().optional(), + }), + }, + async (ctx) => { + return { + hello: "world", + }; + }, + ); + + let router = createRouter({ endpoint, endpoint2, getEndpoint, });; + let close: any; + let baseURL: string; + + beforeAll(async () => { + const { close: _close, baseURL: _baseURL } = await listen(router);; + close = _close; + baseURL = _baseURL; + }) + afterAll(async () => await close()); + + it("should send request and get response", async () => { + const client = createClient({ baseURL }); + + expectTypeOf[0]>().toExtend<"/test">(); + expectTypeOf[0]>().toExtend<"/test2" | "/test3">(); + + const response = await client.post("/test", { + body: { + hello: "world", + }, + }); + + if (response.ok) { + // response.data.hello + + // TODO: these should all be available + console.log("HEADERS:", response.headers); + expect(response.data).toMatchObject({ hello: "world" }); + } else { + console.log("ERROR!?", response.error); + console.log("ERROR .status", response.status); + console.log("ERROR .statusText", response.statusText); + expect(response.data).toBeNull(); + } + }); + + it("should infer types", async () => { + const client = createClient({ baseURL }); + + const res = await client.post("/test", { + body: { + hello: "world", + }, + }); + + expectTypeOf[0]>().toExtend<"/test">(); + expectTypeOf[0]>().toExtend<"/test2" | "/test3">(); + + client.post("/test", { + body: { + //@ts-expect-error + hello: 1, + }, + }); + + client.get("/test2", { + query: { + //@ts-expect-error + hello: 2, + }, + }); + client.get("/test3", { + query: {}, + }); + }); + + it("should call endpoint n", async () => { + const endpoint = createEndpoint( + "/test", + { + method: "POST", + body: z.object({ + hello: z.string(), + }), + }, + async (ctx) => { + return { hello: "world", }; + }, + ); + const endpoint2 = createEndpoint( + "/test2", + { + method: "GET", + }, + async (ctx) => { + return { hello: "world", }; + }, + ); + + const client = createClient({ baseURL }); + const result = await client.post("/test", { + body: { + hello: "world", + }, + }); + + if (result.ok) { + result.data.hello + } + + await client.get("/test2", { + query: { hello: "world" } + }); + }); + + it("should infer from custom creator", () => { + const cr2 = createEndpoint.create({ + use: [ + createMiddleware(async (ctx) => { + return { + something: "", + }; + }), + ], + }); + + const endpoint = cr2( + "/test", + { + method: "POST", + }, + async (ctx) => { + return { hello: "world", }; + }, + ); + + const endpoints = { endpoint, }; + + const client = createClient({ + baseURL: "http://localhost:3000", + }); + expectTypeOf[0]>().toExtend<"/test">(); + }); +}); diff --git a/src/client-http.ts b/src/client-http.ts new file mode 100644 index 0000000..2231922 --- /dev/null +++ b/src/client-http.ts @@ -0,0 +1,361 @@ +import type { Router } from "./router"; +import type { HasRequiredKeys, Prettify, UnionToIntersection } from "./helper"; +import type { Endpoint } from "./endpoint"; +import type { HTTPMethod } from "./context"; + +type HasRequired< + T extends { + body?: any; + query?: any; + params?: any; + }, +> = HasRequiredKeys extends true + ? true + : HasRequiredKeys extends true + ? true + : HasRequiredKeys extends true + ? true + : false; + +type InferContext = T extends (ctx: infer Ctx) => any + ? Ctx extends object + ? Ctx + : never + : never; + +type WithRequired = T & { + [P in K extends string ? K : never]-?: T[P extends keyof T ? P : never]; +}; + +type WithoutServerOnly> = { + [K in keyof T]: T[K] extends Endpoint + ? O extends { metadata: { SERVER_ONLY: true } } + ? never + : T[K] + : T[K]; +}; + +// Method-specific options type +type MethodOptions = API extends { [key: string]: infer T; } + ? T extends Endpoint + ? O["method"] extends M + ? { [key in T["path"]]: T; } + : O["method"] extends M[] + ? M extends O["method"][number] + ? { [key in T["path"]]: T; } + : {} + : O["method"] extends "*" + ? { [key in T["path"]]: T; } + : {} + : {} + : {}; + +export type RequiredOptionKeys< + C extends { + body?: any; + query?: any; + params?: any; + }, +> = (undefined extends C["body"] + ? {} + : { + body: true; + }) & + (undefined extends C["query"] + ? {} + : { + query: true; + }) & + (undefined extends C["params"] + ? {} + : { + params: true; + }); + + +export interface ClientOptions extends FetchRequestOptions { + baseURL: string; +} + +type CommonHeaders = { + accept: "application/json" | "text/plain" | "application/octet-stream"; + "content-type": "application/json" | "text/plain" | "application/x-www-form-urlencoded" | "multipart/form-data" | "application/octet-stream"; + authorization: "Bearer" | "Basic"; +}; + +type FetchRequestOptions< + Body = any, + Query extends Record = any, + Params extends Record | Array | undefined = any, Res = any, + ExtraOptions extends Record = {} +> = Prettify & { + baseURL?: string; + + /** + * Headers + */ + headers?: CommonHeaders | Headers | HeadersInit; + + /** + * Body + */ + body?: Body; + + /** + * Query parameters (key-value pairs) + */ + query?: Query; + + /** + * Dynamic parameters. + * + * If url is defined as /path/:id, params will be { id: string } + */ + params?: Params; +}> + +type ResponseData = { + ok: true; + data: T; + error: null, + response: Response; + headers: Headers; + status: number; + statusText: string; +}; + +type ResponseError = { + ok: false, + data: null, + error: Prettify<(E extends Record ? E : { + message?: string; + }) & { + code?: string; + }>; + response: Response; + headers: Headers; + status: number; + statusText: string; +}; + +type FetchResponse | unknown = unknown, Throw extends boolean = false> = + Throw extends true + ? T + : ResponseData | ResponseError; + +// type dd = BetterFetchOption; + +export function isJSONSerializable(value: any) { + if (value === undefined) { + return false; + } + const t = typeof value; + if (t === "string" || t === "number" || t === "boolean" || t === null) { + return true; + } + if (t !== "object") { + return false; + } + if (Array.isArray(value)) { + return true; + } + if (value.buffer) { + return false; + } + return ( + (value.constructor && value.constructor.name === "Object") || + typeof value.toJSON === "function" + ); +} + +function getBody(body: any, options?: FetchRequestOptions) { + if (!body) { return null; } + + const headers = new Headers(options?.headers); + if (isJSONSerializable(body) && !headers.has("content-type")) { + options?.headers + return JSON.stringify(body); + } + + return body; +} + +const JSON_RE = /^application\/(?:[\w!#$%&*.^`~-]*\+)?json(;.+)?$/i; + +export type ResponseType = "json" | "text" | "blob"; +export function detectResponseType(request: Response): ResponseType { + const _contentType = request.headers.get("content-type"); + const textTypes = new Set([ + "image/svg", + "application/xml", + "application/xhtml", + "application/html", + ]); + if (!_contentType) { + return "json"; + } + const contentType = _contentType.split(";").shift() || ""; + if (JSON_RE.test(contentType)) { + return "json"; + } + if (textTypes.has(contentType) || contentType.startsWith("text/")) { + return "text"; + } + return "blob"; +} + +function getURL(url: string, option?: FetchRequestOptions) { + let { baseURL, params, query } = option || { + query: {}, + params: {}, + baseURL: "", + }; + let basePath = url.startsWith("http") + ? url.split("/").slice(0, 3).join("/") + : baseURL || ""; + + if (!basePath.endsWith("/")) basePath += "/"; + let [path, urlQuery] = url.replace(basePath, "").split("?"); + const queryParams = new URLSearchParams(urlQuery); + for (const [key, value] of Object.entries(query || {})) { + if (value == null) continue; + queryParams.set(key, String(value)); + } + if (params) { + if (Array.isArray(params)) { + const paramPaths = path.split("/").filter((p) => p.startsWith(":")); + for (const [index, key] of paramPaths.entries()) { + const value = params[index]; + path = path.replace(key, value); + } + } else { + for (const [key, value] of Object.entries(params)) { + path = path.replace(`:${key}`, String(value)); + } + } + } + + path = path.split("/").map(encodeURIComponent).join("/"); + if (path.startsWith("/")) path = path.slice(1); + let queryParamString = queryParams.toString(); + queryParamString = + queryParamString.length > 0 ? `?${queryParamString}`.replace(/\+/g, "%20") : ""; + if (!basePath.startsWith("http")) { + return `${basePath}${path}${queryParamString}`; + } + return new URL(`${path}${queryParamString}`, basePath); +} + +export const createClient = (baseOptions: ClientOptions) => { + type API = WithoutServerOnly< + R extends { endpoints: Record } + ? R["endpoints"] + : R + >; + + function createVerbMethod(method: M) { + type O = Prettify>>; + + return async >( + path: K, + ...options: HasRequired extends true + ? [ + WithRequired< + FetchRequestOptions, + keyof RequiredOptionKeys + >, + ] + : [FetchRequestOptions?] + ): Promise< + FetchResponse>> + > => { + // + // FIXME: if FormData is provided, merging "baseOptions.body" with + // "options.body" will not work as intended + // + let body = (baseOptions.body) + ? { ...baseOptions.body, ...(options[0]?.body || {}) } + : options[0]?.body; + + const query = (baseOptions.query) + ? { ...baseOptions.query, ...(options[0]?.query || {}) } + : options[0]?.query; + + const params = (baseOptions.params) + ? { ...baseOptions.params, ...(options[0]?.params || {}) } + : options[0]?.params; + + const headers = new Headers( + (baseOptions.headers) + ? { ...baseOptions.headers, ...(options[0]?.headers || {}) } + : options[0]?.headers + ); + + if (isJSONSerializable(body) && !headers.has("content-type")) { + headers.set("content-type", "application/json"); + for (const [key, value] of Object.entries(body)) { + if (value instanceof Date) { + body[key] = value.toISOString(); + } + } + body = JSON.stringify(body); + } + + const mergedOptions = { + credentials: options[0]?.credentials || "include", + ...baseOptions, + ...options[0], + query, + params, + headers, + body, + method, + }; + + mergedOptions.body = getBody(body, mergedOptions); + + const url = getURL(path.toString(), mergedOptions); + + const response = await fetch(url, mergedOptions); + const contentType = response.headers.get("content-type"); + + let data: any; + let error = null; + + // TODO: improve content-type detection here! + if (contentType?.indexOf("json")) { + data = await response.json(); + + } else if (contentType?.indexOf("text")) { + data = await response.text(); + + } else { + data = await response.blob(); + } + + if (!response.ok) { + // TODO: throw error here?! + error = data; + data = null; + } + + return { + ok: response.ok, + headers: response.headers, + data, + error, + status: response.status, + statusText: response.statusText, + response, + } as any; + }; + }; + + return { + get: createVerbMethod("GET"), + post: createVerbMethod("POST"), + delete: createVerbMethod("DELETE"), + patch: createVerbMethod("PATCH"), + put: createVerbMethod("PUT"), + }; +};