Zum Inhalt springen

Einen typsicheren API-Client-Generator aus OpenAPI bauen

Baue einen Codegenerator, der OpenAPI-Spezifikationen liest und typisierte TypeScript-Clients mit Validierung, Fehlerbehandlung und Typinferenz erzeugt.

5 Min. Lesezeit
Pipeline-Diagramm, das zeigt, wie eine OpenAPI-Spezifikation durch einen Codegenerator läuft und typisierte TypeScript-API-Client-Funktionen erzeugt

API-Client-Code von Hand zu schreiben ist mühsam und fehleranfällig. Der Pfad eines Endpunkts ändert sich, und eine Aufrufstelle wird übersehen. Ein Feld in der Antwort wird umbenannt, und die Typen stimmen nicht mehr überein. Eine OpenAPI-Spezifikation beschreibt bereits alles über deine API — Endpunkte, Request-Bodies, Antwortformen und Fehlercodes. Ein Codegenerator liest diese Spezifikation und erzeugt einen typisierten Client, der automatisch mit deiner API synchron bleibt.

Die OpenAPI-Spezifikation parsen

Eine OpenAPI-Spezifikation ist eine strukturierte Beschreibung jedes Endpunkts. Wir müssen die für die Client-Generierung relevanten Informationen extrahieren: Pfade, Methoden, Parameter, Request-Bodies und Antworttypen.

tstypescript
interface OpenAPISpec {
  paths: Record<string, PathItem>;
  components?: {
    schemas?: Record<string, SchemaObject>;
  };
}
 
interface PathItem {
  get?: Operation;
  post?: Operation;
  put?: Operation;
  patch?: Operation;
  delete?: Operation;
}
 
interface Operation {
  operationId?: string;
  summary?: string;
  parameters?: Parameter[];
  requestBody?: RequestBody;
  responses: Record<string, ResponseObject>;
  tags?: string[];
}
 
interface Parameter {
  name: string;
  in: "path" | "query" | "header";
  required: boolean;
  schema: SchemaObject;
}
 
interface RequestBody {
  required?: boolean;
  content: Record<string, { schema: SchemaObject }>;
}
 
interface ResponseObject {
  description: string;
  content?: Record<string, { schema: SchemaObject }>;
}
 
interface SchemaObject {
  type?: string;
  properties?: Record<string, SchemaObject>;
  required?: string[];
  items?: SchemaObject;
  $ref?: string;
  enum?: string[];
  format?: string;
}

Schema-Referenzen auflösen

OpenAPI-Spezifikationen verwenden $ref, um Duplizierung zu vermeiden. Bevor wir Typen generieren, müssen wir alle Referenzen zu ihren tatsächlichen Schemadefinitionen auflösen.

tstypescript
// ❌ Ignoring $ref — generates incomplete types
function badSchemaToType(schema: SchemaObject): string {
  if (schema.$ref) {
    return "any"; // Gives up on referenced types
  }
  return schema.type ?? "unknown";
}
tstypescript
// ✅ Proper $ref resolution
class SchemaResolver {
  private schemas: Record<string, SchemaObject>;
  private resolved: Map<string, string> = new Map();
 
  constructor(spec: OpenAPISpec) {
    this.schemas = spec.components?.schemas ?? {};
  }
 
  resolve(schema: SchemaObject): SchemaObject {
    if (schema.$ref) {
      const refPath = schema.$ref.replace("#/components/schemas/", "");
      const referenced = this.schemas[refPath];
      if (!referenced) {
        throw new Error(`Unresolved $ref: ${schema.$ref}`);
      }
      return this.resolve(referenced);
    }
    return schema;
  }
 
  schemaToTypeScript(
    schema: SchemaObject,
    indent: string = ""
  ): string {
    const resolved = this.resolve(schema);
 
    if (resolved.$ref) {
      return this.refToTypeName(resolved.$ref);
    }
 
    if (resolved.enum) {
      return resolved.enum.map(v => `"${v}"`).join(" | ");
    }
 
    switch (resolved.type) {
      case "string":
        return resolved.format === "date-time" ? "string" : "string";
      case "number":
      case "integer":
        return "number";
      case "boolean":
        return "boolean";
      case "array":
        if (resolved.items) {
          const itemType = this.schemaToTypeScript(resolved.items);
          return `${itemType}[]`;
        }
        return "unknown[]";
      case "object":
        return this.objectToTypeScript(resolved, indent);
      default:
        return "unknown";
    }
  }
 
  private objectToTypeScript(
    schema: SchemaObject,
    indent: string
  ): string {
    if (!schema.properties) return "Record<string, unknown>";
 
    const required = new Set(schema.required ?? []);
    const props = Object.entries(schema.properties).map(
      ([name, propSchema]) => {
        const optional = required.has(name) ? "" : "?";
        const type = this.schemaToTypeScript(propSchema, indent + "  ");
        return `${indent}  ${name}${optional}: ${type};`;
      }
    );
 
    return `{\n${props.join("\n")}\n${indent}}`;
  }
 
  private refToTypeName(ref: string): string {
    return ref.replace("#/components/schemas/", "");
  }
}

Typdefinitionen generieren

Da der Resolver nun vorhanden ist, generieren wir für jedes Schema in der Spezifikation ein TypeScript-Interface.

tstypescript
function generateTypeDefinitions(spec: OpenAPISpec): string {
  const resolver = new SchemaResolver(spec);
  const schemas = spec.components?.schemas ?? {};
  const lines: string[] = [];
 
  lines.push("// Auto-generated from OpenAPI spec");
  lines.push("// Do not edit manually\n");
 
  for (const [name, schema] of Object.entries(schemas)) {
    const typeBody = resolver.schemaToTypeScript(schema);
    lines.push(`export interface ${name} ${typeBody}\n`);
  }
 
  return lines.join("\n");
}
 
// Example output:
// export interface User {
//   id: number;
//   name: string;
//   email: string;
//   role: "admin" | "editor" | "viewer";
//   createdAt: string;
// }
//
// export interface CreateUserRequest {
//   name: string;
//   email: string;
//   role?: "admin" | "editor" | "viewer";
// }

Client-Funktionen generieren

Jeder Endpunkt wird zu einer typisierten Funktion. Pfadparameter werden aus der URL-Vorlage extrahiert, Query-Parameter werden zu optionalen Argumenten, und der Rückgabetyp entspricht dem Antwortschema.

tstypescript
interface EndpointInfo {
  method: string;
  path: string;
  operationId: string;
  pathParams: Parameter[];
  queryParams: Parameter[];
  requestBody?: SchemaObject;
  responseType: string;
  summary?: string;
}
 
function extractEndpoints(spec: OpenAPISpec): EndpointInfo[] {
  const resolver = new SchemaResolver(spec);
  const endpoints: EndpointInfo[] = [];
 
  for (const [path, pathItem] of Object.entries(spec.paths)) {
    const methods = ["get", "post", "put", "patch", "delete"] as const;
 
    for (const method of methods) {
      const operation = pathItem[method];
      if (!operation) continue;
 
      const operationId =
        operation.operationId ??
        `${method}${path.replace(/[^a-zA-Z]/g, "_")}`;
 
      const params = operation.parameters ?? [];
      const pathParams = params.filter(p => p.in === "path");
      const queryParams = params.filter(p => p.in === "query");
 
      let requestBody: SchemaObject | undefined;
      if (operation.requestBody?.content?.["application/json"]) {
        requestBody = resolver.resolve(
          operation.requestBody.content["application/json"].schema
        );
      }
 
      const successResponse =
        operation.responses["200"] ?? operation.responses["201"];
      let responseType = "void";
 
      if (successResponse?.content?.["application/json"]) {
        responseType = resolver.schemaToTypeScript(
          successResponse.content["application/json"].schema
        );
      }
 
      endpoints.push({
        method,
        path,
        operationId,
        pathParams,
        queryParams,
        requestBody,
        responseType,
        summary: operation.summary,
      });
    }
  }
 
  return endpoints;
}
 
function generateClientFunction(
  endpoint: EndpointInfo,
  resolver: SchemaResolver
): string {
  const lines: string[] = [];
 
  if (endpoint.summary) {
    lines.push(`/** ${endpoint.summary} */`);
  }
 
  // Build parameter list
  const params: string[] = [];
 
  for (const p of endpoint.pathParams) {
    const type = resolver.schemaToTypeScript(p.schema);
    params.push(`${p.name}: ${type}`);
  }
 
  if (endpoint.requestBody) {
    const type = resolver.schemaToTypeScript(endpoint.requestBody);
    params.push(`body: ${type}`);
  }
 
  if (endpoint.queryParams.length > 0) {
    const queryProps = endpoint.queryParams
      .map(p => {
        const optional = p.required ? "" : "?";
        const type = resolver.schemaToTypeScript(p.schema);
        return `${p.name}${optional}: ${type}`;
      })
      .join("; ");
    params.push(`query?: { ${queryProps} }`);
  }
 
  const paramStr = params.join(", ");
  const returnType = endpoint.responseType;
 
  lines.push(
    `export async function ${endpoint.operationId}(${paramStr}): Promise<${returnType}> {`
  );
 
  // Build URL with path params
  let urlExpr = `\`${endpoint.path.replace(
    /\{(\w+)\}/g,
    "${$1}"
  )}\``;
 
  // Add query string
  if (endpoint.queryParams.length > 0) {
    lines.push("  const searchParams = new URLSearchParams();");
    lines.push("  if (query) {");
    lines.push(
      "    for (const [key, value] of Object.entries(query)) {"
    );
    lines.push(
      "      if (value !== undefined) searchParams.set(key, String(value));"
    );
    lines.push("    }");
    lines.push("  }");
    lines.push(
      `  const queryString = searchParams.toString();`
    );
    lines.push(
      `  const url = queryString ? ${urlExpr} + "?" + queryString : ${urlExpr};`
    );
  } else {
    lines.push(`  const url = ${urlExpr};`);
  }
 
  // Build fetch options
  lines.push(`  const response = await fetch(baseUrl + url, {`);
  lines.push(`    method: "${endpoint.method.toUpperCase()}",`);
 
  if (endpoint.requestBody) {
    lines.push(`    headers: { "Content-Type": "application/json" },`);
    lines.push(`    body: JSON.stringify(body),`);
  }
 
  lines.push("  });");
  lines.push("");
  lines.push("  if (!response.ok) {");
  lines.push(
    "    throw new ApiError(response.status, await response.text());"
  );
  lines.push("  }");
  lines.push("");
 
  if (returnType === "void") {
    lines.push("  return;");
  } else {
    lines.push(`  return response.json() as Promise<${returnType}>;`);
  }
 
  lines.push("}");
 
  return lines.join("\n");
}

Der generierte Client

Der vollständige Generator kombiniert Typdefinitionen und Client-Funktionen zu einem vollständigen Modul.

tstypescript
function generateClient(spec: OpenAPISpec): string {
  const sections: string[] = [];
 
  sections.push("// Auto-generated API client");
  sections.push("// Do not edit manually\n");
  sections.push("const baseUrl = process.env.API_BASE_URL ?? '';\n");
  sections.push(
    "class ApiError extends Error {\n" +
    "  constructor(public status: number, public body: string) {\n" +
    '    super(`API error ${status}: ${body}`);\n' +
    "    this.name = 'ApiError';\n" +
    "  }\n" +
    "}\n"
  );
 
  // Type definitions
  sections.push(generateTypeDefinitions(spec));
  sections.push("");
 
  // Client functions
  const resolver = new SchemaResolver(spec);
  const endpoints = extractEndpoints(spec);
 
  for (const endpoint of endpoints) {
    sections.push(generateClientFunction(endpoint, resolver));
    sections.push("");
  }
 
  return sections.join("\n");
}
 
// Usage in build pipeline:
// const spec = JSON.parse(readFileSync("openapi.json", "utf-8"));
// const client = generateClient(spec);
// writeFileSync("src/api/client.generated.ts", client);

Wichtigste Erkenntnisse

Codegenerierung aus OpenAPI-Spezifikationen erspart den manuellen Aufwand, API-Clients bei Änderungen am Backend synchron zu halten. Der Generator liest die Endpunktdefinitionen, löst Schema-Referenzen auf, erzeugt TypeScript-Interfaces für jedes Modell und erstellt typisierte Funktionen für jeden Endpunkt. Pfadparameter werden zu Funktionsargumenten, Query-Parameter werden zu optionalen Objekten, und Antworttypen werden aus den Antwortschemata der Spezifikation abgeleitet. Führe den Generator als Teil deiner Build-Pipeline aus, damit der Client bei jeder API-Änderung aktuell bleibt. Die Investition in den Bau des Generators zahlt sich sofort aus: keine falsch zugeordneten Typen mehr, keine übersehenen Endpunktänderungen mehr, und jeder Entwickler bekommt Autovervollständigung und Typprüfung für jeden API-Aufruf.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX