/** * Mandu Contract Client * Contract 기반 타입 안전 클라이언트 * * tRPC/Elysia Eden 패턴 채택: * - Contract에서 클라이언트 타입 자동 추론 * - 타입 안전 fetch 호출 */ import type { z } from "zod"; import type { ContractSchema, ContractMethod, MethodRequestSchema, } from "./schema"; import type { InferResponseSchema } from "./types"; import { TIMEOUTS } from "../constants"; /** * Client options for making requests */ export interface ClientOptions { /** Base URL for API requests */ baseUrl: string; /** Default headers for all requests */ headers?: Record; /** Custom fetch function (for SSR or testing) */ fetch?: typeof fetch; /** Request timeout in milliseconds */ timeout?: number; } /** * Request options for a specific call */ export interface RequestOptions< TQuery = unknown, TBody = unknown, TParams = unknown, THeaders = Record, > { query?: TQuery; body?: TBody; params?: TParams; headers?: THeaders; signal?: AbortSignal; } /** * Client response wrapper */ export interface ClientResponse { data: T; status: number; headers: Headers; ok: boolean; } /** * Infer request options from method schema */ type InferRequestOptions = T extends MethodRequestSchema ? RequestOptions< T["query"] extends z.ZodTypeAny ? z.input : undefined, T["body"] extends z.ZodTypeAny ? z.input : undefined, T["params"] extends z.ZodTypeAny ? z.input : undefined, T["headers"] extends z.ZodTypeAny ? z.input : Record > : RequestOptions>; /** * Infer success response from contract */ type InferSuccessResponse = InferResponseSchema extends never ? InferResponseSchema extends never ? unknown : InferResponseSchema : InferResponseSchema; /** * Contract client method */ export type ContractClientMethod< T extends MethodRequestSchema | undefined, TResponse extends ContractSchema["response"], > = ( options?: InferRequestOptions ) => Promise>>; /** * Contract client interface */ export type ContractClient = { [M in ContractMethod as M extends keyof T["request"] ? M : never]: ContractClientMethod< T["request"][M] extends MethodRequestSchema ? T["request"][M] : undefined, T["response"] >; }; /** * Build query string from object */ function buildQueryString(query: Record | undefined): string { if (!query) return ""; const params = new URLSearchParams(); for (const [key, value] of Object.entries(query)) { if (value !== undefined && value !== null) { if (Array.isArray(value)) { for (const v of value) { params.append(key, String(v)); } } else { params.append(key, String(value)); } } } const str = params.toString(); return str ? `?${str}` : ""; } /** * Replace path parameters in URL */ function replacePathParams( path: string, params: Record | undefined ): string { if (!params) return path; let result = path; for (const [key, value] of Object.entries(params)) { result = result.replace(`:${key}`, encodeURIComponent(String(value))); result = result.replace(`[${key}]`, encodeURIComponent(String(value))); } return result; } /** * Create a type-safe client from a contract * * @example * ```typescript * const userContract = Mandu.contract({ * request: { * GET: { query: z.object({ page: z.number() }) }, * POST: { body: z.object({ name: z.string() }) }, * }, * response: { * 200: z.object({ users: z.array(UserSchema) }), * 201: z.object({ user: UserSchema }), * }, * }); * * const client = createClient(userContract, { * baseUrl: "http://localhost:3000/api/users", * }); * * // Type-safe calls * const users = await client.GET({ query: { page: 1 } }); * // users.data is typed as { users: User[] } * * const newUser = await client.POST({ body: { name: "Alice" } }); * // newUser.data is typed as { user: User } * ``` */ export function createClient( _contract: T, options: ClientOptions ): ContractClient { const { baseUrl, headers: defaultHeaders = {}, fetch: customFetch = fetch, timeout = TIMEOUTS.CLIENT_DEFAULT, } = options; const methods: ContractMethod[] = ["GET", "POST", "PUT", "PATCH", "DELETE"]; const client = {} as ContractClient; for (const method of methods) { // @ts-expect-error - Dynamic method assignment client[method] = async ( requestOptions: RequestOptions = {} ): Promise> => { const { query, body, params, headers = {}, signal } = requestOptions; // Build URL let url = replacePathParams(baseUrl, params as Record); url += buildQueryString(query as Record); // Build request options const fetchOptions: RequestInit = { method, headers: { ...defaultHeaders, ...headers, }, signal, }; // Add body for non-GET methods if (body && method !== "GET") { fetchOptions.headers = { ...fetchOptions.headers, "Content-Type": "application/json", }; fetchOptions.body = JSON.stringify(body); } // Add timeout const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); if (!signal) { fetchOptions.signal = controller.signal; } try { const response = await customFetch(url, fetchOptions); clearTimeout(timeoutId); let data: unknown; const contentType = response.headers.get("content-type") || ""; if (contentType.includes("application/json")) { data = await response.json(); } else { data = await response.text(); } return { data, status: response.status, headers: response.headers, ok: response.ok, }; } catch (error) { clearTimeout(timeoutId); throw error; } }; } return client; } /** * Type-safe fetch wrapper for a single endpoint * * @example * ```typescript * const result = await contractFetch(userContract, "GET", "/api/users", { * query: { page: 1, limit: 10 }, * }); * // result.data is typed based on contract response * ``` */ export async function contractFetch< T extends ContractSchema, M extends ContractMethod & keyof T["request"], >( _contract: T, method: M, url: string, options: InferRequestOptions< T["request"][M] extends MethodRequestSchema ? T["request"][M] : undefined > = {} as InferRequestOptions< T["request"][M] extends MethodRequestSchema ? T["request"][M] : undefined >, clientOptions: Partial = {} ): Promise>> { const { query, body, params, headers = {}, signal, } = options as RequestOptions; const { headers: defaultHeaders = {}, fetch: customFetch = fetch, timeout = TIMEOUTS.CLIENT_DEFAULT, } = clientOptions; // Build URL let finalUrl = replacePathParams(url, params as Record); finalUrl += buildQueryString(query as Record); // Build request options const fetchOptions: RequestInit = { method, headers: { ...defaultHeaders, ...(headers as Record), }, signal, }; // Add body for non-GET methods if (body && method !== "GET") { fetchOptions.headers = { ...fetchOptions.headers, "Content-Type": "application/json", }; fetchOptions.body = JSON.stringify(body); } // Add timeout const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); if (!signal) { fetchOptions.signal = controller.signal; } try { const response = await customFetch(finalUrl, fetchOptions); clearTimeout(timeoutId); let data: unknown; const contentType = response.headers.get("content-type") || ""; if (contentType.includes("application/json")) { data = await response.json(); } else { data = await response.text(); } return { data: data as InferSuccessResponse, status: response.status, headers: response.headers, ok: response.ok, }; } catch (error) { clearTimeout(timeoutId); throw error; } }