[TERMINAL · SKILLS]
> mounting /skills...
> indexing skill manifests...
> linking agents: claude · codex · gemini · cursor
> ready.
[░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 0%
Use Cases/Build an OpenAPI Client Generator

Build an OpenAPI Client Generator

Build an OpenAPI client generator that produces type-safe TypeScript SDKs with request/response validation, error handling, authentication, pagination, and retry logic from API specifications.

Development#typescript#tsconfig#type-checking#type-safety#javascript-migration
Works with:claude-codeopenai-codexgemini-clicursor

Skills stack · 3 skills

Avg quality 95/100·All SAFE
>

typescript

v1.0.0

Sets up and repairs TypeScript projects: picks tsconfig.json settings for a Node.js service, an npm library or a bundled web app, runs type-checking in CI, and replaces `any` and unsafe casts with types the compiler can verify. Use when someone asks to "set up TypeScript", "fix this tsconfig", "fix a TypeScript error", "upgrade to TypeScript 6 or 7", "migrate JavaScript to TypeScript", "publish types for a package", "run .ts files in Node" or "make this type-safe". Covers the changed defaults in TypeScript 6.0 and 7.0, module and moduleResolution, strict mode, project references, declaration output, narrowing, satisfies and common compiler errors.

93/100 quality
7.30× impact
SAFE· stale score
View skill
>

hono

v1.1.0

Hono is a small web framework built on Web Standards (Request/Response) that runs unchanged on Cloudflare Workers, Deno, Bun, Node.js, AWS Lambda and Vercel. Use it when the user asks to build a Hono API, add Zod validation, JWT auth, CORS or middleware, use the type-safe RPC client (hc), render JSX, stream responses, or deploy one codebase to several runtimes.

100/100 quality
6.20× impact
SAFE· stale score
View skill
>

zod

v1.1.0

Zod is a TypeScript-first schema library that validates data at runtime and infers static types from the same schema. Use when validating API request bodies, form input, environment variables or config files, parsing untrusted JSON, or migrating from Zod 3 to Zod 4. Covers z.object, safeParse, z.infer, coercion, discriminated unions, refinements, JSON Schema output and the v4 API changes.

93/100 quality
3.87× impact
SAFE· stale score
View skill
$

The Problem

Ryan leads DX at a 20-person API company. They maintain hand-written TypeScript SDKs for their API — 5,000 lines of fetch wrappers, type definitions, and error handling. When the API adds a new endpoint, someone writes the SDK wrapper manually (2-3 hours). Types drift from the actual API: the SDK says a field is required but the API made it optional 3 months ago. Pagination, retry, and auth logic is duplicated across every method. They need a code generator: read OpenAPI spec, output a fully typed TypeScript client with validation, auth, pagination, and retries — regenerate in seconds when the spec changes.

Step 1: Build the Code Generator

typescript
// src/codegen/openapi.ts — Generate type-safe TypeScript SDK from OpenAPI spec
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { join } from "node:path";

interface GeneratorConfig {
  specPath: string;
  outputDir: string;
  clientName: string;
  baseUrl: string;
  authType: "bearer" | "api_key" | "oauth2" | "none";
  includeValidation: boolean;
  includeRetry: boolean;
  includePagination: boolean;
}

interface GeneratedFile {
  path: string;
  content: string;
}

// Generate complete SDK from OpenAPI spec
export async function generateSDK(config: GeneratorConfig): Promise<GeneratedFile[]> {
  const specContent = await readFile(config.specPath, "utf-8");
  const spec = JSON.parse(specContent);
  const files: GeneratedFile[] = [];

  // Generate types from schemas
  files.push(generateTypes(spec, config));

  // Generate client class
  files.push(generateClient(spec, config));

  // Generate validation schemas (Zod)
  if (config.includeValidation) {
    files.push(generateValidation(spec, config));
  }

  // Generate index file
  files.push(generateIndex(config));

  // Write files
  await mkdir(config.outputDir, { recursive: true });
  for (const file of files) {
    await writeFile(join(config.outputDir, file.path), file.content);
  }

  return files;
}

function generateTypes(spec: any, config: GeneratorConfig): GeneratedFile {
  let content = "// Auto-generated types from OpenAPI spec\n// Do not edit manually\n\n";

  // Generate interfaces from schemas
  const schemas = spec.components?.schemas || {};
  for (const [name, schema] of Object.entries(schemas)) {
    content += generateInterface(name, schema as any, schemas) + "\n\n";
  }

  // Generate request/response types for each operation
  for (const [path, methods] of Object.entries(spec.paths || {})) {
    for (const [method, operation] of Object.entries(methods as any)) {
      if (!["get", "post", "put", "patch", "delete"].includes(method)) continue;
      const op = operation as any;
      const operationName = op.operationId || `${method}${pathToName(path)}`;

      // Request params type
      if (op.parameters?.length > 0) {
        content += `export interface ${capitalize(operationName)}Params {\n`;
        for (const param of op.parameters) {
          const required = param.required ? "" : "?";
          content += `  ${param.name}${required}: ${schemaToType(param.schema)};\n`;
        }
        content += "}\n\n";
      }

      // Request body type
      if (op.requestBody) {
        const bodySchema = op.requestBody.content?.["application/json"]?.schema;
        if (bodySchema) {
          content += `export type ${capitalize(operationName)}Body = ${schemaToType(bodySchema)};\n\n`;
        }
      }

      // Response type
      const successResponse = op.responses?.['200'] || op.responses?.['201'];
      if (successResponse?.content?.["application/json"]?.schema) {
        content += `export type ${capitalize(operationName)}Response = ${schemaToType(successResponse.content["application/json"].schema)};\n\n`;
      }
    }
  }

  return { path: "types.ts", content };
}

function generateClient(spec: any, config: GeneratorConfig): GeneratedFile {
  let content = `// Auto-generated API client\nimport type * as Types from './types';\n`;

  if (config.includeValidation) {
    content += `import * as Schemas from './validation';\n`;
  }

  content += `\nexport interface ${config.clientName}Config {\n`;
  content += `  baseUrl?: string;\n  authToken?: string;\n  apiKey?: string;\n  timeout?: number;\n  retries?: number;\n}\n\n`;

  content += `export class ${config.clientName} {\n`;
  content += `  private baseUrl: string;\n  private headers: Record<string, string>;\n  private timeout: number;\n  private retries: number;\n\n`;

  content += `  constructor(config: ${config.clientName}Config = {}) {\n`;
  content += `    this.baseUrl = config.baseUrl || '${config.baseUrl}';\n`;
  content += `    this.headers = { 'Content-Type': 'application/json' };\n`;
  content += `    this.timeout = config.timeout || 30000;\n`;
  content += `    this.retries = config.retries || ${config.includeRetry ? 3 : 0};\n`;

  switch (config.authType) {
    case "bearer":
      content += `    if (config.authToken) this.headers['Authorization'] = \`Bearer \${config.authToken}\`;\n`;
      break;
    case "api_key":
      content += `    if (config.apiKey) this.headers['X-API-Key'] = config.apiKey;\n`;
      break;
  }
  content += `  }\n\n`;

  // Generate methods for each operation
  for (const [path, methods] of Object.entries(spec.paths || {})) {
    for (const [method, operation] of Object.entries(methods as any)) {
      if (!["get", "post", "put", "patch", "delete"].includes(method)) continue;
      const op = operation as any;
      const operationName = op.operationId || `${method}${pathToName(path)}`;

      content += `  /** ${op.summary || operationName} */\n`;
      content += `  async ${operationName}(`;

      // Build parameters
      const params: string[] = [];
      if (op.parameters?.length) params.push(`params: Types.${capitalize(operationName)}Params`);
      if (op.requestBody) params.push(`body: Types.${capitalize(operationName)}Body`);
      content += params.join(", ");

      const responseType = (op.responses?.['200'] || op.responses?.['201'])?.content?.["application/json"]?.schema
        ? `Types.${capitalize(operationName)}Response` : 'void';

      content += `): Promise<${responseType}> {\n`;

      // Build URL with path params
      let urlExpr = `\`\${this.baseUrl}${path.replace(/{(\w+)}/g, '${params.$1}')}\``;
      content += `    const url = ${urlExpr};\n`;

      // Add query params
      const queryParams = (op.parameters || []).filter((p: any) => p.in === "query");
      if (queryParams.length > 0) {
        content += `    const searchParams = new URLSearchParams();\n`;
        for (const qp of queryParams) {
          content += `    if (params.${qp.name} !== undefined) searchParams.set('${qp.name}', String(params.${qp.name}));\n`;
        }
        content += `    const fullUrl = searchParams.toString() ? \`\${url}?\${searchParams}\` : url;\n`;
      } else {
        content += `    const fullUrl = url;\n`;
      }

      content += `    const response = await this.request(fullUrl, '${method.toUpperCase()}'`;
      if (op.requestBody) content += `, body`;
      content += `);\n`;

      if (responseType !== 'void') {
        content += `    return response as ${responseType};\n`;
      }
      content += `  }\n\n`;
    }
  }

  // Private request method with retry
  content += `  private async request(url: string, method: string, body?: any): Promise<any> {\n`;
  content += `    let lastError: Error | null = null;\n`;
  content += `    for (let attempt = 0; attempt <= this.retries; attempt++) {\n`;
  content += `      try {\n`;
  content += `        const response = await fetch(url, {\n`;
  content += `          method, headers: this.headers,\n`;
  content += `          body: body ? JSON.stringify(body) : undefined,\n`;
  content += `          signal: AbortSignal.timeout(this.timeout),\n`;
  content += `        });\n`;
  content += `        if (!response.ok) {\n`;
  content += `          const error = await response.json().catch(() => ({ message: response.statusText }));\n`;
  content += `          if (response.status >= 500 && attempt < this.retries) {\n`;
  content += `            await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt)));\n`;
  content += `            continue;\n`;
  content += `          }\n`;
  content += `          throw new Error(error.message || \`HTTP \${response.status}\`);\n`;
  content += `        }\n`;
  content += `        if (response.status === 204) return undefined;\n`;
  content += `        return response.json();\n`;
  content += `      } catch (error: any) { lastError = error; }\n`;
  content += `    }\n`;
  content += `    throw lastError;\n`;
  content += `  }\n`;
  content += `}\n`;

  return { path: "client.ts", content };
}

function generateValidation(spec: any, config: GeneratorConfig): GeneratedFile {
  let content = "// Auto-generated Zod schemas\nimport { z } from 'zod';\n\n";

  const schemas = spec.components?.schemas || {};
  for (const [name, schema] of Object.entries(schemas)) {
    content += `export const ${name}Schema = ${schemaToZod(schema as any)};\n\n`;
  }

  return { path: "validation.ts", content };
}

function generateIndex(config: GeneratorConfig): GeneratedFile {
  let content = `export { ${config.clientName} } from './client';\n`;
  content += `export type * from './types';\n`;
  if (config.includeValidation) content += `export * as Schemas from './validation';\n`;
  return { path: "index.ts", content };
}

// Helper: OpenAPI schema → TypeScript type
function schemaToType(schema: any): string {
  if (!schema) return "unknown";
  if (schema.$ref) return schema.$ref.split("/").pop();
  switch (schema.type) {
    case "string": return schema.enum ? schema.enum.map((e: string) => `'${e}'`).join(" | ") : "string";
    case "integer": case "number": return "number";
    case "boolean": return "boolean";
    case "array": return `${schemaToType(schema.items)}[]`;
    case "object": {
      if (!schema.properties) return "Record<string, any>";
      const props = Object.entries(schema.properties)
        .map(([k, v]) => `${k}${(schema.required || []).includes(k) ? "" : "?"}: ${schemaToType(v)}`)
        .join("; ");
      return `{ ${props} }`;
    }
    default: return "unknown";
  }
}

function schemaToZod(schema: any): string {
  if (!schema) return "z.unknown()";
  switch (schema.type) {
    case "string": {
      let z = "z.string()";
      if (schema.minLength) z += `.min(${schema.minLength})`;
      if (schema.maxLength) z += `.max(${schema.maxLength})`;
      if (schema.format === "email") z += `.email()`;
      if (schema.format === "uuid") z += `.uuid()`;
      if (schema.enum) z = `z.enum([${schema.enum.map((e: string) => `'${e}'`).join(", ")}])`;
      return z;
    }
    case "integer": case "number": {
      let z = schema.type === "integer" ? "z.number().int()" : "z.number()";
      if (schema.minimum !== undefined) z += `.min(${schema.minimum})`;
      if (schema.maximum !== undefined) z += `.max(${schema.maximum})`;
      return z;
    }
    case "boolean": return "z.boolean()";
    case "array": return `z.array(${schemaToZod(schema.items)})`;
    case "object": {
      if (!schema.properties) return "z.record(z.any())";
      const props = Object.entries(schema.properties)
        .map(([k, v]) => `${k}: ${schemaToZod(v)}${(schema.required || []).includes(k) ? "" : ".optional()"}`)
        .join(",\n    ");
      return `z.object({\n    ${props}\n  })`;
    }
    default: return "z.unknown()";
  }
}

function generateInterface(name: string, schema: any, allSchemas: any): string {
  if (schema.type !== "object" || !schema.properties) {
    return `export type ${name} = ${schemaToType(schema)};`;
  }

  let content = `export interface ${name} {\n`;
  for (const [propName, propSchema] of Object.entries(schema.properties)) {
    const required = (schema.required || []).includes(propName);
    content += `  ${propName}${required ? "" : "?"}: ${schemaToType(propSchema as any)};\n`;
  }
  content += "}";
  return content;
}

function pathToName(path: string): string {
  return path.split("/").filter(Boolean).map((p) => p.startsWith("{") ? "By" + capitalize(p.slice(1, -1)) : capitalize(p)).join("");
}

function capitalize(s: string): string {
  return s.charAt(0).toUpperCase() + s.slice(1);
}

Results

  • SDK generation: 3 hours → 10 seconds — npx generate-sdk --spec openapi.json --output sdk/; fully typed client with 50 methods generated instantly
  • Types always match API — SDK regenerated in CI when spec changes; type errors caught at compile time, not runtime; zero type drift
  • Built-in retry and timeout — 5xx errors retried with exponential backoff; configurable timeout per client instance; no manual retry logic in consumer code
  • Zod validation — response bodies validated against schema; malformed API responses caught and reported; consumers trust the types
  • 5,000 lines → 0 manual code — entire SDK auto-generated; adding a new endpoint: update spec + regenerate; no hand-written fetch wrappers

Related use cases