skillby RomualdP

API Contracts Generator

Génère des contrats API cohérents entre Frontend (Next.js) et Backend (NestJS) avec types synchronisés, validation standardisée et error handling uniforme. À utiliser lors de la création d'APIs, DTOs, types frontend/backend, ou quand l'utilisateur mentionne "API", "DTO", "types", "contract", "validation", "frontend-backend", "synchronisation".

Installs: 0
Used in: 1 repos
Updated: 2d ago
$npx ai-builder add skill RomualdP/api-contracts

Installs to .claude/skills/api-contracts/

# API Contracts Generator

## 🎯 Mission

Garantir une **communication parfaite** entre Frontend (Next.js) et Backend (NestJS) via des contrats API cohérents, types synchronisés et validation standardisée.

## 🏗️ Philosophie des API Contracts

### Le Problème

Dans un projet full-stack, les erreurs de communication Frontend ↔ Backend sont fréquentes :
- ❌ Types incohérents (backend attend `clubId`, frontend envoie `id`)
- ❌ Validations divergentes (backend accepte 100 chars, frontend 50)
- ❌ Erreurs non standardisées (format différent selon l'endpoint)
- ❌ Documentation obsolète (Swagger non à jour)

### La Solution : API Contracts

Un **API Contract** définit le contrat entre frontend et backend :
- ✅ **DTOs Backend** : Structure des requêtes/réponses avec validation
- ✅ **Types Frontend** : TypeScript synchronisés avec le backend
- ✅ **Validation cohérente** : Mêmes règles backend et frontend
- ✅ **Error format standard** : Format uniforme pour toutes les erreurs
- ✅ **Documentation auto** : Swagger généré depuis le code

### Architecture de Communication

```
Frontend (Next.js)
  ↓ Server Action (avec types)
  ↓ Validation Zod
  ↓ fetch/axios
Backend (NestJS)
  ↓ Controller (avec DTOs)
  ↓ Validation class-validator
  ↓ Handler (CQRS)
  ↓ Response DTO
  ↑ JSON Response
Frontend (Next.js)
  ↑ Typed Response
  ↑ UI Update
```

## 📦 1. Backend DTOs (NestJS)

### Request DTOs (Input)

Les **Request DTOs** définissent la structure des données **envoyées par le frontend**.

#### Template Request DTO

```typescript
// volley-app-backend/src/club-management/presentation/dtos/create-club.dto.ts

import { IsString, IsNotEmpty, IsOptional, MaxLength, MinLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';

export class CreateClubDto {
  @ApiProperty({
    description: 'Club name',
    example: 'Volley Club Paris',
    minLength: 3,
    maxLength: 100,
  })
  @IsString()
  @IsNotEmpty()
  @MinLength(3)
  @MaxLength(100)
  readonly name: string;

  @ApiPropertyOptional({
    description: 'Club description',
    example: 'Best volleyball club in Paris',
    maxLength: 500,
  })
  @IsString()
  @IsOptional()
  @MaxLength(500)
  readonly description?: string;
}
```

**Règles pour Request DTOs** :
- ✅ Validation avec `class-validator` (IsString, IsNotEmpty, etc.)
- ✅ Swagger decorators `@ApiProperty` pour documentation
- ✅ `readonly` pour immutabilité
- ✅ Types primitifs (string, number, boolean, Date)
- ✅ Exemples dans Swagger (`example`)
- ❌ **JAMAIS** de logique métier (seulement validation)

### Response DTOs (Output)

Les **Response DTOs** définissent la structure des données **retournées par le backend**.

#### Template Response DTO

```typescript
// volley-app-backend/src/club-management/presentation/dtos/club-detail.dto.ts

import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';

export class OwnerDto {
  @ApiProperty({ example: 'user-123' })
  id: string;

  @ApiProperty({ example: 'John Doe' })
  name: string;

  @ApiProperty({ example: 'john@example.com' })
  email: string;
}

export class SubscriptionDto {
  @ApiProperty({ example: 'FREE', enum: ['FREE', 'PRO', 'UNLIMITED'] })
  plan: string;

  @ApiProperty({ example: 'ACTIVE', enum: ['ACTIVE', 'INACTIVE', 'EXPIRED'] })
  status: string;

  @ApiProperty({ example: 1 })
  maxTeams: number;

  @ApiProperty({ example: 0 })
  currentTeamsCount: number;
}

export class ClubDetailDto {
  @ApiProperty({ example: 'club-123' })
  id: string;

  @ApiProperty({ example: 'Volley Club Paris' })
  name: string;

  @ApiPropertyOptional({ example: 'Best club in Paris' })
  description?: string;

  @ApiProperty({ type: OwnerDto })
  owner: OwnerDto;

  @ApiProperty({ type: SubscriptionDto })
  subscription: SubscriptionDto;

  @ApiProperty({ example: 15 })
  membersCount: number;

  @ApiProperty({ example: '2024-01-01T00:00:00.000Z' })
  createdAt: Date;
}
```

**Règles pour Response DTOs** :
- ✅ Swagger decorators pour documentation complète
- ✅ Nested DTOs pour relations (OwnerDto, SubscriptionDto)
- ✅ Exemples réalistes
- ✅ Enum values documentés
- ✅ Types primitifs + nested objects
- ❌ **JAMAIS** d'entités domain brutes (utiliser des mappers)

### Pagination DTO (Standard)

```typescript
// volley-app-backend/src/shared/dtos/pagination.dto.ts

import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsOptional, Max, Min } from 'class-validator';

export class PaginationQueryDto {
  @ApiPropertyOptional({ default: 1, minimum: 1 })
  @IsOptional()
  @Type(() => Number)
  @IsInt()
  @Min(1)
  page?: number = 1;

  @ApiPropertyOptional({ default: 10, minimum: 1, maximum: 100 })
  @IsOptional()
  @Type(() => Number)
  @IsInt()
  @Min(1)
  @Max(100)
  limit?: number = 10;
}

export class PaginationMetaDto {
  @ApiProperty({ example: 1 })
  page: number;

  @ApiProperty({ example: 10 })
  limit: number;

  @ApiProperty({ example: 50 })
  total: number;

  @ApiProperty({ example: 5 })
  totalPages: number;
}

export class PaginatedResponseDto<T> {
  @ApiProperty({ isArray: true })
  data: T[];

  @ApiProperty({ type: PaginationMetaDto })
  meta: PaginationMetaDto;
}
```

### Controller Integration

```typescript
// volley-app-backend/src/club-management/presentation/controllers/clubs.controller.ts

import { Controller, Post, Get, Body, Param, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
import { CreateClubDto } from '../dtos/create-club.dto';
import { ClubDetailDto } from '../dtos/club-detail.dto';
import { ClubListDto } from '../dtos/club-list.dto';
import { PaginationQueryDto, PaginatedResponseDto } from '../../shared/dtos/pagination.dto';
import { CreateClubHandler } from '../../application/commands/create-club/create-club.handler';
import { GetClubHandler } from '../../application/queries/get-club/get-club.handler';
import { ListClubsHandler } from '../../application/queries/list-clubs/list-clubs.handler';

@ApiTags('Clubs')
@ApiBearerAuth()
@Controller('clubs')
@UseGuards(JwtAuthGuard)
export class ClubsController {
  constructor(
    private readonly createClubHandler: CreateClubHandler,
    private readonly getClubHandler: GetClubHandler,
    private readonly listClubsHandler: ListClubsHandler,
  ) {}

  @Post()
  @ApiOperation({ summary: 'Create a new club' })
  @ApiResponse({ status: 201, description: 'Club created', type: String })
  @ApiResponse({ status: 400, description: 'Validation error' })
  async create(@Body() dto: CreateClubDto): Promise<{ id: string }> {
    const command = new CreateClubCommand(dto.name, dto.description, 'current-user-id');
    const id = await this.createClubHandler.execute(command);
    return { id };
  }

  @Get(':id')
  @ApiOperation({ summary: 'Get club details' })
  @ApiResponse({ status: 200, description: 'Club found', type: ClubDetailDto })
  @ApiResponse({ status: 404, description: 'Club not found' })
  async findOne(@Param('id') id: string): Promise<ClubDetailDto> {
    const query = new GetClubQuery(id);
    return this.getClubHandler.execute(query);
  }

  @Get()
  @ApiOperation({ summary: 'List clubs with pagination' })
  @ApiResponse({ status: 200, description: 'Clubs list', type: PaginatedResponseDto })
  async findAll(@Query() pagination: PaginationQueryDto): Promise<PaginatedResponseDto<ClubListDto>> {
    const query = new ListClubsQuery(pagination.page, pagination.limit);
    return this.listClubsHandler.execute(query);
  }
}
```

## 🎨 2. Frontend Types (Next.js)

### Stratégie de Synchronisation

**Option 1 : Générer les types depuis Swagger** (Recommandé)
```bash
# Install openapi-typescript
npm install --save-dev openapi-typescript

# Generate types from backend Swagger
npx openapi-typescript http://localhost:3000/api-json -o src/types/api.ts
```

**Option 2 : Partager les types (Monorepo)**
```typescript
// shared/types/club.types.ts (partagé entre frontend et backend)
export interface CreateClubInput {
  name: string;
  description?: string;
}

export interface ClubDetail {
  id: string;
  name: string;
  description?: string;
  owner: {
    id: string;
    name: string;
    email: string;
  };
  subscription: {
    plan: string;
    status: string;
    maxTeams: number;
    currentTeamsCount: number;
  };
  membersCount: number;
  createdAt: Date;
}
```

**Option 3 : Dupliquer les types manuellement** (Moins recommandé)
```typescript
// volley-app-frontend/src/features/club-management/types/club.types.ts

// Dupliqué depuis backend CreateClubDto
export interface CreateClubInput {
  name: string;
  description?: string;
}

// Dupliqué depuis backend ClubDetailDto
export interface ClubDetail {
  id: string;
  name: string;
  description?: string;
  owner: {
    id: string;
    name: string;
    email: string;
  };
  subscription: {
    plan: string;
    status: string;
    maxTeams: number;
    currentTeamsCount: number;
  };
  membersCount: number;
  createdAt: Date;
}
```

### Validation Frontend avec Zod

```typescript
// volley-app-frontend/src/features/club-management/schemas/club.schema.ts

import { z } from 'zod';

// Schema SYNCHRONISÉ avec backend CreateClubDto
export const createClubSchema = z.object({
  name: z
    .string()
    .min(3, 'Le nom doit contenir au moins 3 caractères')
    .max(100, 'Le nom ne peut pas dépasser 100 caractères'),
  description: z
    .string()
    .max(500, 'La description ne peut pas dépasser 500 caractères')
    .optional(),
});

export type CreateClubInput = z.infer<typeof createClubSchema>;
```

**CRITIQUE** : Les règles de validation Zod doivent **EXACTEMENT** correspondre aux règles backend (class-validator).

## 🔗 3. Server Actions (Frontend → Backend)

### Template Server Action

```typescript
// volley-app-frontend/src/features/club-management/actions/create-club.action.ts
'use server';

import { revalidatePath } from 'next/cache';
import { createClubSchema, CreateClubInput } from '../schemas/club.schema';
import { clubsApi } from '../api/clubs.api';

export async function createClubAction(input: CreateClubInput) {
  try {
    // 1. Validate input (frontend validation)
    const validated = createClubSchema.parse(input);

    // 2. Call backend API
    const response = await clubsApi.create(validated);

    // 3. Revalidate cache
    revalidatePath('/dashboard/coach');

    // 4. Return success
    return {
      success: true as const,
      data: response,
    };
  } catch (error) {
    // 5. Handle errors
    if (error instanceof z.ZodError) {
      return {
        success: false as const,
        error: {
          code: 'VALIDATION_ERROR',
          message: 'Données invalides',
          details: error.errors,
        },
      };
    }

    return {
      success: false as const,
      error: {
        code: 'UNKNOWN_ERROR',
        message: error.message || 'Une erreur est survenue',
      },
    };
  }
}

// Type du retour
export type CreateClubResult =
  | { success: true; data: { id: string } }
  | { success: false; error: { code: string; message: string; details?: any } };
```

### API Client

```typescript
// volley-app-frontend/src/features/club-management/api/clubs.api.ts

import { CreateClubInput, ClubDetail, ClubList } from '../types/club.types';
import { PaginatedResponse } from '@/types/api.types';

const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';

export const clubsApi = {
  async create(input: CreateClubInput): Promise<{ id: string }> {
    const response = await fetch(`${API_BASE_URL}/clubs`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${getToken()}`, // Helper to get JWT
      },
      body: JSON.stringify(input),
    });

    if (!response.ok) {
      throw await handleApiError(response);
    }

    return response.json();
  },

  async getById(id: string): Promise<ClubDetail> {
    const response = await fetch(`${API_BASE_URL}/clubs/${id}`, {
      headers: {
        Authorization: `Bearer ${getToken()}`,
      },
    });

    if (!response.ok) {
      throw await handleApiError(response);
    }

    return response.json();
  },

  async list(page: number = 1, limit: number = 10): Promise<PaginatedResponse<ClubList>> {
    const response = await fetch(
      `${API_BASE_URL}/clubs?page=${page}&limit=${limit}`,
      {
        headers: {
          Authorization: `Bearer ${getToken()}`,
        },
      },
    );

    if (!response.ok) {
      throw await handleApiError(response);
    }

    return response.json();
  },
};

// Helper functions
function getToken(): string {
  // Get JWT from cookies or localStorage
  return '';
}

async function handleApiError(response: Response): Promise<Error> {
  const error = await response.json();
  return new ApiError(error.code, error.message, error.details);
}

class ApiError extends Error {
  constructor(
    public code: string,
    message: string,
    public details?: any,
  ) {
    super(message);
    this.name = 'ApiError';
  }
}
```

## ⚠️ 4. Error Handling Standard

### Backend Error Format

```typescript
// volley-app-backend/src/shared/filters/http-exception.filter.ts

import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common';
import { Response } from 'express';

export interface ErrorResponse {
  code: string;
  message: string;
  details?: any;
  timestamp: string;
  path: string;
}

@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest();

    let status = HttpStatus.INTERNAL_SERVER_ERROR;
    let errorResponse: ErrorResponse = {
      code: 'INTERNAL_SERVER_ERROR',
      message: 'Une erreur interne est survenue',
      timestamp: new Date().toISOString(),
      path: request.url,
    };

    if (exception instanceof HttpException) {
      status = exception.getStatus();
      const exceptionResponse = exception.getResponse();

      if (typeof exceptionResponse === 'object') {
        errorResponse = {
          ...errorResponse,
          ...(exceptionResponse as any),
        };
      } else {
        errorResponse.message = exceptionResponse as string;
      }
    }

    response.status(status).json(errorResponse);
  }
}
```

### Frontend Error Handling

```typescript
// volley-app-frontend/src/lib/api-error.ts

export class ApiError extends Error {
  constructor(
    public code: string,
    message: string,
    public details?: any,
    public status?: number,
  ) {
    super(message);
    this.name = 'ApiError';
  }

  static fromResponse(response: any): ApiError {
    return new ApiError(
      response.code || 'UNKNOWN_ERROR',
      response.message || 'Une erreur est survenue',
      response.details,
      response.status,
    );
  }

  // User-friendly messages
  getUserMessage(): string {
    const messages: Record<string, string> = {
      VALIDATION_ERROR: 'Les données fournies sont invalides',
      NOT_FOUND: 'La ressource demandée n\'existe pas',
      UNAUTHORIZED: 'Vous devez être connecté pour effectuer cette action',
      FORBIDDEN: 'Vous n\'avez pas les permissions nécessaires',
      INTERNAL_SERVER_ERROR: 'Une erreur interne est survenue. Veuillez réessayer.',
    };

    return messages[this.code] || this.message;
  }
}
```

## ✅ 5. Checklist API Contract

### Backend (NestJS)
- [ ] Request DTOs avec validation class-validator
- [ ] Response DTOs avec Swagger decorators
- [ ] Exemples réalistes dans Swagger
- [ ] Error handling standardisé
- [ ] Pagination DTO pour listes
- [ ] Swagger activé et accessible (`/api`)

### Frontend (Next.js)
- [ ] Types synchronisés avec backend (OpenAPI ou partagés)
- [ ] Validation Zod cohérente avec backend
- [ ] Server Actions avec types
- [ ] API client avec types
- [ ] Error handling standardisé
- [ ] Messages d'erreur traduits pour UI

### Synchronisation
- [ ] Script de génération des types (si OpenAPI)
- [ ] CI/CD vérifie la synchronisation
- [ ] Documentation Swagger à jour
- [ ] Types partagés si monorepo

## 🎓 Exemple Complet : CreateClub Flow

### 1. Backend DTO

```typescript
// backend/src/club-management/presentation/dtos/create-club.dto.ts
export class CreateClubDto {
  @IsString()
  @MinLength(3)
  @MaxLength(100)
  readonly name: string;

  @IsString()
  @IsOptional()
  @MaxLength(500)
  readonly description?: string;
}
```

### 2. Frontend Schema (Zod)

```typescript
// frontend/src/features/club-management/schemas/club.schema.ts
export const createClubSchema = z.object({
  name: z.string().min(3).max(100),
  description: z.string().max(500).optional(),
});
```

### 3. Server Action

```typescript
// frontend/src/features/club-management/actions/create-club.action.ts
export async function createClubAction(input: CreateClubInput) {
  const validated = createClubSchema.parse(input); // Frontend validation
  const response = await clubsApi.create(validated); // Backend call
  revalidatePath('/dashboard/coach');
  return { success: true, data: response };
}
```

### 4. Component Usage

```typescript
// frontend/src/features/club-management/components/ClubCreationForm.tsx
'use client';

import { useTransition } from 'react';
import { createClubAction } from '../actions/create-club.action';

export function ClubCreationForm() {
  const [isPending, startTransition] = useTransition();

  const handleSubmit = async (formData: FormData) => {
    startTransition(async () => {
      const result = await createClubAction({
        name: formData.get('name') as string,
        description: formData.get('description') as string,
      });

      if (result.success) {
        router.push(`/clubs/${result.data.id}`);
      } else {
        setError(result.error.message);
      }
    });
  };

  return <form action={handleSubmit}>...</form>;
}
```

## 🚨 Erreurs Courantes à Éviter

1. ❌ **Types incohérents**
   - ✅ FAIRE : Générer types frontend depuis Swagger ou partager
   - ❌ NE PAS FAIRE : Dupliquer manuellement sans synchronisation

2. ❌ **Validations divergentes**
   - ✅ FAIRE : Même règles backend (class-validator) et frontend (Zod)
   - ❌ NE PAS FAIRE : Backend max=100, Frontend max=50

3. ❌ **Erreurs non standardisées**
   - ✅ FAIRE : Format uniforme `{ code, message, details }`
   - ❌ NE PAS FAIRE : Formats différents selon l'endpoint

4. ❌ **Swagger obsolète**
   - ✅ FAIRE : Swagger généré automatiquement depuis les DTOs
   - ❌ NE PAS FAIRE : Documentation manuelle non synchronisée

5. ❌ **Server Actions avec logique métier**
   - ✅ FAIRE : Server Actions = orchestration mince (appel API + cache)
   - ❌ NE PAS FAIRE : Logique métier dans Server Actions

## 📚 Skills Complémentaires

Pour aller plus loin :
- **server-actions** : Patterns Server Actions Next.js détaillés
- **ddd-bounded-context** : Architecture backend DDD
- **cqrs-command-query** : Commands/Queries pour APIs

---

**Rappel** : La **synchronisation parfaite** Frontend ↔ Backend garantit une communication sans bugs et une expérience développeur optimale.

Quick Install

$npx ai-builder add skill RomualdP/api-contracts

Details

Type
skill
Author
RomualdP
Slug
RomualdP/api-contracts
Created
6d ago