/** * Mandu Contract Definition - Contract-First 개발 * * @example * ```ts * import { defineContract } from '@mandujs/core'; * * export const userContract = defineContract({ * getUser: { * method: 'GET', * path: '/api/users/:id', * input: z.object({ id: z.string() }), * output: z.object({ name: z.string(), email: z.string() }), * }, * createUser: { * method: 'POST', * path: '/api/users', * input: z.object({ name: z.string(), email: z.string() }), * output: z.object({ id: z.string() }), * }, * }); * * // 자동 생성됨: * // - API 핸들러 타입 * // - 클라이언트 훅 * // - OpenAPI 스펙 * ``` */ import type { ZodType } from 'zod'; import { getZodTypeName, getZodInnerType, getZodArrayElementType, getZodObjectShape, getZodEnumValues, isZodRequired, } from './zod-utils'; // ============================================================================ // Types // ============================================================================ export type ContractMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; export interface EndpointDefinition< TInput extends ZodType = ZodType, TOutput extends ZodType = ZodType, TParams extends ZodType = ZodType, > { /** HTTP 메서드 */ method: ContractMethod; /** API 경로 */ path: string; /** URL 파라미터 스키마 */ params?: TParams; /** 요청 바디/쿼리 스키마 */ input?: TInput; /** 응답 스키마 */ output: TOutput; /** 가능한 에러 코드 */ errors?: readonly string[]; /** 설명 */ description?: string; /** 태그 (OpenAPI grouping) */ tags?: string[]; } export type ContractDefinition = Record; // ============================================================================ // Contract Metadata // ============================================================================ export interface ContractMeta { __contract: true; __name: string; __endpoints: T; __version: string; } export type Contract = T & ContractMeta; // ============================================================================ // defineContract() - Contract 정의 // ============================================================================ let contractCounter = 0; /** * Contract 정의 * * Contract는 API의 명세(specification)입니다. * - 타입 안전한 API 호출 * - 자동 코드 생성의 기반 * - OpenAPI 문서 자동 생성 */ export function defineContract( endpoints: T, options?: { name?: string; version?: string; } ): Contract { const name = options?.name || `contract_${++contractCounter}`; const version = options?.version || '1.0.0'; const contract = endpoints as Contract; contract.__contract = true; contract.__name = name; contract.__endpoints = endpoints; contract.__version = version; return contract; } // ============================================================================ // isContract() - Contract 체크 // ============================================================================ export function isContract( value: unknown ): value is Contract { return ( typeof value === 'object' && value !== null && (value as ContractMeta).__contract === true ); } // ============================================================================ // Type Inference Utilities // ============================================================================ /** Contract에서 Input 타입 추출 */ export type ContractInput< C extends ContractMeta, K extends keyof C['__endpoints'] > = C['__endpoints'][K]['input'] extends ZodType ? T : never; /** Contract에서 Output 타입 추출 */ export type ContractOutput< C extends ContractMeta, K extends keyof C['__endpoints'] > = C['__endpoints'][K]['output'] extends ZodType ? T : never; /** Contract에서 Params 타입 추출 */ export type ContractParams< C extends ContractMeta, K extends keyof C['__endpoints'] > = C['__endpoints'][K]['params'] extends ZodType ? T : never; // ============================================================================ // Code Generation Templates // ============================================================================ /** * Contract에서 API 핸들러 코드 생성 */ export function generateApiHandler( contract: Contract, endpointName: keyof T ): string { const endpoint = contract.__endpoints[endpointName as string]; if (!endpoint) { throw new Error(`Endpoint "${String(endpointName)}" not found in contract`); } const { method, path, input, description } = endpoint; return ` import { Mandu } from '@mandujs/core'; import { z } from 'zod'; /** * ${description || endpointName as string} * ${method} ${path} */ export default Mandu.filling() ${input ? `.onParse(async (ctx) => { // Input validation is automatic via ctx.body() })` : ''} .${method.toLowerCase()}(async (ctx) => { ${input ? `const body = await ctx.body(/* your input schema */); if (!body.success) { return ctx.error('Validation failed', body.error); } const data = body.data;` : ''} // TODO: Implement your logic here return ctx.ok({ // TODO: Return your response }); }); `.trim(); } /** * Contract에서 React Query 훅 생성 */ export function generateClientHook( contract: Contract, endpointName: keyof T ): string { const endpoint = contract.__endpoints[endpointName as string]; if (!endpoint) { throw new Error(`Endpoint "${String(endpointName)}" not found in contract`); } const { method, path, description } = endpoint; const hookName = `use${String(endpointName).charAt(0).toUpperCase() + String(endpointName).slice(1)}`; const isQuery = method === 'GET'; if (isQuery) { return ` import { useQuery, type UseQueryOptions } from '@tanstack/react-query'; import type { ContractInput, ContractOutput } from '@mandujs/core'; import { contract } from '../contracts'; type Input = ContractInput; type Output = ContractOutput; /** * ${description || endpointName as string} */ export function ${hookName}( params: Input, options?: Omit, 'queryKey' | 'queryFn'> ) { return useQuery({ queryKey: ['${String(endpointName)}', params], queryFn: async () => { const res = await fetch(\`${path.replace(/:(\w+)/g, '${params.$1}')}\`); if (!res.ok) throw new Error('API Error'); return res.json() as Promise; }, ...options, }); } `.trim(); } else { return ` import { useMutation, type UseMutationOptions } from '@tanstack/react-query'; import type { ContractInput, ContractOutput } from '@mandujs/core'; import { contract } from '../contracts'; type Input = ContractInput; type Output = ContractOutput; /** * ${description || endpointName as string} */ export function ${hookName}( options?: Omit, 'mutationFn'> ) { return useMutation({ mutationFn: async (input: Input) => { const res = await fetch('${path}', { method: '${method}', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), }); if (!res.ok) throw new Error('API Error'); return res.json() as Promise; }, ...options, }); } `.trim(); } } /** * Contract에서 전체 코드 생성 */ export function generateAllFromContract( contract: Contract ): { handlers: Record; hooks: Record; types: string; } { const handlers: Record = {}; const hooks: Record = {}; for (const name of Object.keys(contract.__endpoints)) { handlers[name] = generateApiHandler(contract, name); hooks[name] = generateClientHook(contract, name); } const types = generateTypeDefinitions(contract); return { handlers, hooks, types }; } /** * Contract에서 타입 정의 생성 */ export function generateTypeDefinitions( contract: Contract ): string { const lines: string[] = [ `// Auto-generated types for ${contract.__name}`, `// Version: ${contract.__version}`, '', 'import { z } from "zod";', '', ]; for (const [name, endpoint] of Object.entries(contract.__endpoints)) { const typeName = name.charAt(0).toUpperCase() + name.slice(1); if (endpoint.input) { lines.push(`export type ${typeName}Input = z.infer;`); } if (endpoint.output) { lines.push(`export type ${typeName}Output = z.infer;`); } if (endpoint.params) { lines.push(`export type ${typeName}Params = z.infer;`); } lines.push(''); } return lines.join('\n'); } // ============================================================================ // OpenAPI Generation // ============================================================================ /** * Contract에서 OpenAPI 스펙 생성 */ export function generateOpenAPISpec( contract: Contract, options?: { title?: string; version?: string; servers?: Array<{ url: string; description?: string }>; } ): Record { const paths: Record> = {}; for (const [name, endpoint] of Object.entries(contract.__endpoints)) { const { method, path, input, description, tags, errors } = endpoint; if (!paths[path]) { paths[path] = {}; } paths[path][method.toLowerCase()] = { operationId: name, summary: description || name, tags: tags || [], ...(input && ['POST', 'PUT', 'PATCH'].includes(method) ? { requestBody: { required: true, content: { 'application/json': { schema: { $ref: `#/components/schemas/${name}Input` }, }, }, }, } : {}), responses: { '200': { description: 'Success', content: { 'application/json': { schema: { $ref: `#/components/schemas/${name}Output` }, }, }, }, ...(errors?.reduce( (acc, error) => ({ ...acc, [getStatusCode(error)]: { description: error, }, }), {} ) || {}), }, }; } return { openapi: '3.0.3', info: { title: options?.title || contract.__name, version: options?.version || contract.__version, }, servers: options?.servers || [{ url: '/' }], paths, components: { schemas: generateSchemas(contract), }, }; } function generateSchemas( contract: Contract ): Record { const schemas: Record = {}; for (const [name, endpoint] of Object.entries(contract.__endpoints)) { if (endpoint.input) { schemas[`${name}Input`] = zodToOpenAPI(endpoint.input); } if (endpoint.output) { schemas[`${name}Output`] = zodToOpenAPI(endpoint.output); } } return schemas; } function zodToOpenAPI(schema: ZodType): Record { const typeName = getZodTypeName(schema); switch (typeName) { case 'ZodString': return { type: 'string' }; case 'ZodNumber': return { type: 'number' }; case 'ZodBoolean': return { type: 'boolean' }; case 'ZodArray': { const elementType = getZodArrayElementType(schema); return { type: 'array', items: elementType ? zodToOpenAPI(elementType) : {} }; } case 'ZodObject': { const properties: Record = {}; const required: string[] = []; const shape = getZodObjectShape(schema) ?? {}; for (const [key, value] of Object.entries(shape)) { properties[key] = zodToOpenAPI(value); if (isZodRequired(value)) { required.push(key); } } return { type: 'object', properties, required }; } case 'ZodOptional': { const inner = getZodInnerType(schema); return inner ? zodToOpenAPI(inner) : {}; } case 'ZodNullable': { const inner = getZodInnerType(schema); return { ...(inner ? zodToOpenAPI(inner) : {}), nullable: true }; } case 'ZodEnum': return { type: 'string', enum: getZodEnumValues(schema) }; default: return { type: 'object' }; } } function getStatusCode(error: string): string { const map: Record = { NOT_FOUND: '404', UNAUTHORIZED: '401', FORBIDDEN: '403', RATE_LIMITED: '429', BAD_REQUEST: '400', INTERNAL_ERROR: '500', }; return map[error] || '400'; }