40 APIs in React – OpenAPI, Contracts und Code-Generierung

React hat keine Datenbank. Kein Backend. Keine Business-Logic. Eine React-App ist Client-Code im Browser – isoliert, stateless nach jedem Reload, komplett abhängig von externen Datenquellen. Diese Datenquellen sind APIs: REST-Endpoints, GraphQL-Server, gRPC-Services. Der Standard-Fall: HTTP-basierte REST-APIs mit JSON.

Das Problem: Manuell API-Calls zu schreiben ist fehleranfällig. Endpoints ändern sich, Response-Strukturen mutieren, neue Felder kommen, alte verschwinden. TypeScript-Types müssen händisch synchronisiert werden. Ein Tippfehler im Endpoint-String? Runtime-Error. Fehlende Properties im Response? Runtime-Error. Das skaliert nicht.

Die Lösung: Contract-Driven Development mit OpenAPI. Der API-Contract ist die Single Source of Truth. Aus diesem Contract generiert man Client-Code – typsicher, automatisch, konsistent.

40.1 OpenAPI: Der API-Contract als Code

OpenAPI (früher Swagger) ist eine Spezifikation zur Beschreibung von REST-APIs. Eine YAML- oder JSON-Datei definiert: - Endpoints (/users, /products/{id}) - HTTP-Methoden (GET, POST, PUT, DELETE) - Request-Parameter (Query, Path, Body) - Response-Schemas (Success, Error) - Datentypen und Validierung

Beispiel: Einfache User-API

# openapi.yaml
openapi: 3.0.0
info:
  title: User Management API
  version: 1.0.0

paths:
  /users:
    get:
      summary: List all users
      operationId: listUsers
      parameters:
        - name: page
          in: query
          schema:
            type: integer
            default: 1
        - name: limit
          in: query
          schema:
            type: integer
            default: 10
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                type: object
                properties:
                  users:
                    type: array
                    items:
                      $ref: '#/components/schemas/User'
                  total:
                    type: integer
                  page:
                    type: integer
    
    post:
      summary: Create a new user
      operationId: createUser
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateUserRequest'
      responses:
        '201':
          description: User created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /users/{userId}:
    get:
      summary: Get user by ID
      operationId: getUser
      parameters:
        - name: userId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: User found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
        '404':
          description: User not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

components:
  schemas:
    User:
      type: object
      required:
        - id
        - email
        - name
      properties:
        id:
          type: string
          format: uuid
        email:
          type: string
          format: email
        name:
          type: string
        role:
          type: string
          enum: [admin, user, guest]
        createdAt:
          type: string
          format: date-time
    
    CreateUserRequest:
      type: object
      required:
        - email
        - name
      properties:
        email:
          type: string
          format: email
        name:
          type: string
        role:
          type: string
          enum: [admin, user, guest]
          default: user
    
    Error:
      type: object
      properties:
        message:
          type: string
        code:
          type: string

Dieser Contract ist: - Maschinenlesbar: Tools können daraus Code generieren - Versioniert: In Git neben Code - Single Source of Truth: Definiert exakt, was API kann - Technologieneutral: React, Angular, Vue können alle daraus generieren

40.2 Code-Generierung: Von OpenAPI zu TypeScript

Statt händisch Fetch-Calls zu schreiben, generiert man TypeScript-Code aus der OpenAPI-Spec.

40.2.1 Option 1: openapi-typescript-codegen

npm install --save-dev openapi-typescript-codegen
// package.json
{
  "scripts": {
    "generate:api": "openapi --input ./openapi.yaml --output ./src/api --client fetch"
  }
}
npm run generate:api

Generierter Output:

src/api/
  ├── index.ts
  ├── models/
  │   ├── User.ts
  │   ├── CreateUserRequest.ts
  │   └── Error.ts
  ├── services/
  │   └── DefaultService.ts
  └── core/
      ├── ApiError.ts
      ├── ApiRequestOptions.ts
      ├── ApiResult.ts
      └── request.ts

Generierte TypeScript-Typen:

// src/api/models/User.ts
export type User = {
  id: string;
  email: string;
  name: string;
  role?: 'admin' | 'user' | 'guest';
  createdAt?: string;
};

// src/api/models/CreateUserRequest.ts
export type CreateUserRequest = {
  email: string;
  name: string;
  role?: 'admin' | 'user' | 'guest';
};

// src/api/models/Error.ts
export type Error = {
  message?: string;
  code?: string;
};

Generierter Service:

// src/api/services/DefaultService.ts
import type { User } from '../models/User';
import type { CreateUserRequest } from '../models/CreateUserRequest';

export class DefaultService {
  /**
   * List all users
   * @param page 
   * @param limit 
   * @returns any Successful response
   * @throws ApiError
   */
  public static listUsers(
    page: number = 1,
    limit: number = 10,
  ): CancelablePromise<{
    users?: Array<User>;
    total?: number;
    page?: number;
  }> {
    return __request(OpenAPI, {
      method: 'GET',
      url: '/users',
      query: {
        'page': page,
        'limit': limit,
      },
    });
  }

  /**
   * Create a new user
   * @param requestBody 
   * @returns User User created
   * @throws ApiError
   */
  public static createUser(
    requestBody: CreateUserRequest,
  ): CancelablePromise<User> {
    return __request(OpenAPI, {
      method: 'POST',
      url: '/users',
      body: requestBody,
      mediaType: 'application/json',
      errors: {
        400: `Validation error`,
      },
    });
  }

  /**
   * Get user by ID
   * @param userId 
   * @returns User User found
   * @throws ApiError
   */
  public static getUser(
    userId: string,
  ): CancelablePromise<User> {
    return __request(OpenAPI, {
      method: 'GET',
      url: '/users/{userId}',
      path: {
        'userId': userId,
      },
      errors: {
        404: `User not found`,
      },
    });
  }
}

40.2.2 Option 2: orval (React Query Integration)

Orval generiert nicht nur Types, sondern auch React Query Hooks direkt.

npm install --save-dev orval
// orval.config.ts
import { defineConfig } from 'orval';

export default defineConfig({
  api: {
    input: './openapi.yaml',
    output: {
      mode: 'tags-split',
      target: './src/api/generated',
      client: 'react-query',
      mock: true,
      override: {
        mutator: {
          path: './src/api/client.ts',
          name: 'customClient'
        }
      }
    }
  }
});
// src/api/client.ts - Custom Fetch-Wrapper
export const customClient = async <T>(
  config: RequestConfig
): Promise<T> => {
  const response = await fetch(config.url, {
    method: config.method,
    headers: {
      'Content-Type': 'application/json',
      ...config.headers
    },
    body: config.data ? JSON.stringify(config.data) : undefined
  });

  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.message || 'API Error');
  }

  return response.json();
};
npx orval

Generierte React Query Hooks:

// src/api/generated/users.ts
import { useQuery, useMutation, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query';
import type { User, CreateUserRequest } from './models';

export const useListUsers = <TData = { users?: User[]; total?: number; page?: number }>(
  params?: { page?: number; limit?: number },
  options?: UseQueryOptions<TData>
) => {
  return useQuery<TData>({
    queryKey: ['users', params],
    queryFn: () => customClient<TData>({
      url: '/users',
      method: 'GET',
      params
    }),
    ...options
  });
};

export const useGetUser = <TData = User>(
  userId: string,
  options?: UseQueryOptions<TData>
) => {
  return useQuery<TData>({
    queryKey: ['users', userId],
    queryFn: () => customClient<TData>({
      url: `/users/${userId}`,
      method: 'GET'
    }),
    ...options
  });
};

export const useCreateUser = <TData = User>(
  options?: UseMutationOptions<TData, Error, CreateUserRequest>
) => {
  return useMutation<TData, Error, CreateUserRequest>({
    mutationFn: (data) => customClient<TData>({
      url: '/users',
      method: 'POST',
      data
    }),
    ...options
  });
};

40.3 React-Integration: Generated Code verwenden

Mit openapi-typescript-codegen (Plain Fetch):

// hooks/useUsers.ts
import { useState, useEffect } from 'react';
import { DefaultService } from '../api/services/DefaultService';
import type { User } from '../api/models/User';

export function useUsers(page: number = 1, limit: number = 10) {
  const [users, setUsers] = useState<User[]>([]);
  const [total, setTotal] = useState(0);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    const fetchUsers = async () => {
      try {
        setLoading(true);
        const response = await DefaultService.listUsers(page, limit);
        setUsers(response.users || []);
        setTotal(response.total || 0);
      } catch (err) {
        setError(err instanceof Error ? err : new Error('Failed to fetch users'));
      } finally {
        setLoading(false);
      }
    };

    fetchUsers();
  }, [page, limit]);

  return { users, total, loading, error };
}
// components/UserList.tsx
import { useUsers } from '../hooks/useUsers';

export function UserList() {
  const { users, total, loading, error } = useUsers(1, 20);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <div>
      <h2>Users ({total})</h2>
      <ul>
        {users.map(user => (
          <li key={user.id}>
            {user.name} ({user.email}) - {user.role}
          </li>
        ))}
      </ul>
    </div>
  );
}

Mit orval (React Query Hooks):

// components/UserList.tsx
import { useListUsers, useCreateUser } from '../api/generated/users';
import { useState } from 'react';

export function UserList() {
  const [page, setPage] = useState(1);
  
  // Auto-generated Hook mit Caching, Refetching, etc.
  const { data, isLoading, error } = useListUsers({ page, limit: 20 });
  
  const createMutation = useCreateUser({
    onSuccess: () => {
      // Invalidate & refetch
      queryClient.invalidateQueries(['users']);
    }
  });

  const handleCreateUser = async () => {
    await createMutation.mutateAsync({
      name: 'New User',
      email: 'user@example.com',
      role: 'user'
    });
  };

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <div>
      <h2>Users ({data?.total})</h2>
      <button onClick={handleCreateUser}>Create User</button>
      
      <ul>
        {data?.users?.map(user => (
          <li key={user.id}>
            {user.name} ({user.email})
          </li>
        ))}
      </ul>
      
      <button onClick={() => setPage(p => p - 1)} disabled={page === 1}>
        Previous
      </button>
      <button onClick={() => setPage(p => p + 1)}>
        Next
      </button>
    </div>
  );
}

40.4 Vite Integration: Auto-Generation beim Build

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { exec } from 'child_process';
import { promisify } from 'util';

const execAsync = promisify(exec);

const generateApiPlugin = () => ({
  name: 'generate-api',
  async buildStart() {
    console.log('Generating API client from OpenAPI spec...');
    try {
      await execAsync('npm run generate:api');
      console.log('API client generated successfully');
    } catch (error) {
      console.error('Failed to generate API client:', error);
      throw error;
    }
  }
});

export default defineConfig({
  plugins: [
    generateApiPlugin(), // Generiert vor jedem Build
    react()
  ]
});

Oder als Pre-Build Script:

// package.json
{
  "scripts": {
    "generate:api": "orval",
    "prebuild": "npm run generate:api",
    "build": "vite build",
    "dev": "vite"
  }
}

40.5 DTOs vs. DAOs: Begriffe klären

DTO (Data Transfer Object): - Repräsentiert Daten wie sie über Netzwerk übertragen werden - JSON-Schema aus API - Im Frontend: Generated Types aus OpenAPI - Kein Verhalten, nur Daten

DAO (Data Access Object): - Backend-Pattern für Datenbankzugriff - Kapselt SQL/ORM-Queries - Nicht relevant für Frontend - React kennt nur DTOs

// DTO - Generated aus OpenAPI
export type User = {
  id: string;
  email: string;
  name: string;
  role?: 'admin' | 'user' | 'guest';
};

// Nicht DTO: UI-spezifisches Model
interface UserFormData {
  email: string;
  name: string;
  role: 'admin' | 'user' | 'guest';
  agreeToTerms: boolean; // Nur UI-Feld
}

// Mapper: DTO ↔ UI Model
function mapUserToFormData(user: User): UserFormData {
  return {
    email: user.email,
    name: user.name,
    role: user.role || 'user',
    agreeToTerms: false
  };
}

function mapFormDataToDTO(formData: UserFormData): CreateUserRequest {
  return {
    email: formData.email,
    name: formData.name,
    role: formData.role
  };
}

40.6 Authentifizierung: Bearer Tokens in Generated Client

// src/api/client.ts
import { OpenAPI } from './generated/core/OpenAPI';

// Base URL konfigurieren
OpenAPI.BASE = import.meta.env.VITE_API_URL || 'https://api.example.com';

// Auth Token setzen
export function setAuthToken(token: string | null) {
  if (token) {
    OpenAPI.TOKEN = token;
  } else {
    OpenAPI.TOKEN = undefined;
  }
}

// Oder mit Custom Fetch
export const authenticatedClient = async <T>(
  config: RequestConfig
): Promise<T> => {
  const token = localStorage.getItem('authToken');
  
  const response = await fetch(config.url, {
    method: config.method,
    headers: {
      'Content-Type': 'application/json',
      ...(token && { Authorization: `Bearer ${token}` }),
      ...config.headers
    },
    body: config.data ? JSON.stringify(config.data) : undefined
  });

  if (response.status === 401) {
    // Token expired, redirect to login
    window.location.href = '/login';
    throw new Error('Unauthorized');
  }

  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.message || 'API Error');
  }

  return response.json();
};
// App.tsx
import { useEffect } from 'react';
import { setAuthToken } from './api/client';
import { useAuth } from './contexts/AuthContext';

function App() {
  const { token } = useAuth();
  
  useEffect(() => {
    setAuthToken(token);
  }, [token]);
  
  return <div>{/* App */}</div>;
}

40.7 Error Handling: Typed API Errors

OpenAPI definiert Error-Responses. Generated Code wirft typisierte Errors.

// OpenAPI Error-Definition
components:
  schemas:
    ValidationError:
      type: object
      properties:
        message:
          type: string
        errors:
          type: array
          items:
            type: object
            properties:
              field:
                type: string
              message:
                type: string
// Generated Type
export type ValidationError = {
  message?: string;
  errors?: Array<{
    field?: string;
    message?: string;
  }>;
};
// React Component mit Error Handling
import { useCreateUser } from '../api/generated/users';
import type { ValidationError } from '../api/models/ValidationError';

function UserForm() {
  const [errors, setErrors] = useState<ValidationError | null>(null);
  
  const createMutation = useCreateUser({
    onError: (error) => {
      // Generated Error Type
      if (error.status === 400) {
        setErrors(error.body as ValidationError);
      }
    }
  });

  const handleSubmit = async (data: FormData) => {
    setErrors(null);
    
    try {
      await createMutation.mutateAsync({
        email: data.email,
        name: data.name
      });
    } catch (error) {
      // Handled by onError
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      {errors?.errors?.map(err => (
        <div key={err.field} className="error">
          {err.field}: {err.message}
        </div>
      ))}
      
      {/* Form fields */}
    </form>
  );
}

40.8 Mock Server: Development ohne Backend

Generated Code kann Mock-Server generieren.

npx orval --mode mocks
// src/api/mocks/users.mock.ts - Auto-generated
import { faker } from '@faker-js/faker';
import type { User } from '../models/User';

export const getUsersMock = (): { users: User[]; total: number } => ({
  users: Array.from({ length: 10 }, () => ({
    id: faker.string.uuid(),
    email: faker.internet.email(),
    name: faker.person.fullName(),
    role: faker.helpers.arrayElement(['admin', 'user', 'guest']),
    createdAt: faker.date.past().toISOString()
  })),
  total: 100
});
// src/api/client.ts - MSW Integration
import { setupWorker } from 'msw';
import { handlers } from './mocks/handlers';

export const worker = setupWorker(...handlers);

// In Development: Mock Server starten
if (import.meta.env.DEV) {
  worker.start();
}

40.9 Vergleich: Code-Generatoren

Tool Client React Query Mocks Bundle Size Customization
openapi-typescript-codegen Fetch, Axios ❌ Nein ✅ Ja Klein Mittel
orval Fetch, Axios ✅ Ja ✅ Ja Mittel Hoch
swagger-typescript-api Fetch, Axios ❌ Nein ❌ Nein Klein Niedrig
openapi-generator-cli Fetch, Axios ❌ Nein ❌ Nein Groß Sehr hoch

Empfehlung: - Einfach, ohne React Query: openapi-typescript-codegen - Mit React Query: orval - Maximum Control: openapi-generator-cli - Schnellstart: swagger-typescript-api

40.10 Watch-Mode: Auto-Regeneration bei API-Änderungen

// package.json
{
  "scripts": {
    "generate:api": "orval",
    "generate:api:watch": "orval --watch"
  }
}

Parallel zu npm run dev:

# Terminal 1
npm run dev

# Terminal 2
npm run generate:api:watch

Bei Änderung in openapi.yaml → Auto-Regeneration → Hot-Reload in Browser.

40.11 Häufige Fehler

Fehler 1: Generated Code im Git

# ❌ Falsch: Generated Code committen
git add src/api/generated

# ✓ Richtig: .gitignore
# .gitignore
src/api/generated/

Regeneriere bei jedem Build. Oder: Committe Generated Code wenn Team OpenAPI-Spec nicht hat.

Fehler 2: Manuelle Änderungen in Generated Files

// ❌ Falsch: Generated File editieren
// src/api/generated/users.ts
export const useListUsers = () => {
  // Custom logic hier  ← Wird beim nächsten Generate überschrieben!
};

// ✓ Richtig: Wrapper schreiben
// src/hooks/useUsers.ts
import { useListUsers as useListUsersGenerated } from '../api/generated/users';

export function useListUsers() {
  const query = useListUsersGenerated();
  
  // Custom logic hier
  
  return query;
}

Fehler 3: Falsche Base URL

// ❌ Falsch: Hardcoded Production URL
OpenAPI.BASE = 'https://api.production.com';

// ✓ Richtig: Environment-Variable
OpenAPI.BASE = import.meta.env.VITE_API_URL;
# .env.development
VITE_API_URL=http://localhost:3000

# .env.production
VITE_API_URL=https://api.production.com

Fehler 4: Types und Runtime nicht synchron

// OpenAPI sagt: email ist required
// Backend ändert: email ist jetzt optional
// Generated Code ist veraltet!

// ✓ Lösung: Regeneriere regelmäßig
npm run generate:api

Fehler 5: Keine Validation der OpenAPI-Spec

# ✓ Validate vor Generate
npx swagger-cli validate openapi.yaml
npm run generate:api

40.12 CI/CD Integration

# .github/workflows/build.yml
name: Build

on: [push]

jobs:
  build:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v3
      
      - uses: actions/setup-node@v3
        with:
          node-version: 18
      
      # OpenAPI Spec validieren
      - name: Validate OpenAPI Spec
        run: npx swagger-cli validate openapi.yaml
      
      # API Client generieren
      - name: Generate API Client
        run: npm run generate:api
      
      # TypeScript Check
      - name: TypeScript Check
        run: npx tsc --noEmit
      
      # Build
      - name: Build
        run: npm run build

Contract-Driven Development mit OpenAPI ist kein Nice-to-Have für professionelle React-Apps – es ist Standard. Die API ist der Contract. Der Contract ist Typisierung. Typisierung ist Sicherheit. Code-Generierung eliminiert Boilerplate und hält Frontend/Backend synchron. React profitiert massiv: Autocomplete, Compile-Zeit-Validierung, konsistente Error-Handling – alles automatisch aus der API-Spec generiert.