OpenAPI 3.1#

Une API sans spécification formelle repose sur la documentation humaine : des fichiers Markdown, des wikis, des Postman collections. Ces artefacts se désynchronisent du code, contiennent des erreurs et ne peuvent pas être traités par des outils. OpenAPI 3.1 est la solution : un contrat machine-lisible qui décrit l’API de manière exhaustive et serve à la fois de documentation, de source de vérité pour la validation, et de base pour la génération de clients.

Pourquoi OpenAPI#

Contrat machine-lisible#

OpenAPI décrit une API REST en YAML ou JSON selon une structure standardisée. Ce document peut être :

  • rendu en documentation interactive (Swagger UI, Redoc, Scalar)

  • utilisé pour valider les requêtes et réponses à l’exécution

  • transformé en clients dans n’importe quel langage (openapi-generator)

  • analysé statiquement pour détecter les breaking changes (oasdiff, Optic)

  • utilisé pour générer des mocks (Prism, WireMock)

Design-first vs code-first#

Il existe deux approches :

  • Code-first : le code produit le document OpenAPI (FastAPI génère openapi.json automatiquement depuis les types Pydantic). Simple à démarrer, mais le document peut devenir difficile à lire si les annotations sont incomplètes.

  • Design-first : le document OpenAPI est rédigé avant le code. Le contrat devient la source de vérité, le code est généré ou validé contre lui. Plus de discipline, meilleure gouvernance.

OpenAPI 3.1 et JSON Schema

OpenAPI 3.1 aligne son modèle de schéma sur JSON Schema 2020-12, ce qui résout les incompatibilités historiques entre les deux spécifications. Les propriétés nullable, exclusiveMinimum et exclusiveMaximum ont changé de sémantique par rapport à OpenAPI 3.0.

Structure d’un document OpenAPI 3.1#

Un document OpenAPI 3.1 est un objet JSON ou YAML avec les sections principales suivantes.

# Squelette d'un document OpenAPI 3.1
openapi: "3.1.0"

info:
  title: "API E-commerce"
  version: "2.0.0"
  description: "API de gestion des commandes et produits."
  contact:
    name: "Équipe API"
    email: "api@example.com"
  license:
    name: "MIT"

servers:
  - url: "https://api.example.com/v2"
    description: "Production"
  - url: "https://staging-api.example.com/v2"
    description: "Staging"

tags:
  - name: "orders"
    description: "Gestion des commandes"
  - name: "products"
    description: "Catalogue produits"

paths:
  /orders:
    get:
      summary: "Lister les commandes"
      operationId: "listOrders"
      tags: ["orders"]
      ...
  /orders/{orderId}:
    get:
      ...

components:
  schemas:
    Order: ...
    Product: ...
  securitySchemes:
    BearerAuth: ...

security:
  - BearerAuth: []

Section info#

Contient les métadonnées de l’API : titre, version, description, contact, licence. La version suit SemVer.

Section servers#

Liste les URLs de base. Chaque serveur peut avoir des variables de template.

servers:
  - url: "https://{environment}.api.example.com/v2"
    variables:
      environment:
        default: "api"
        enum: ["api", "staging-api", "sandbox-api"]
        description: "Environnement cible"

Section paths#

Le cœur du document. Chaque chemin est un objet avec les méthodes HTTP supportées.

Section components#

Stocke les objets réutilisables : schémas, paramètres, réponses, exemples, headers, security schemes.

Section webhooks (OpenAPI 3.1)#

Nouveauté d’OpenAPI 3.1 : décrit les webhooks sortants de l’API.

Schémas JSON Schema#

Types et formats#

components:
  schemas:
    Order:
      type: object
      required: [id, status, total, currency]
      properties:
        id:
          type: integer
          format: int64
          readOnly: true
          example: 17
        status:
          type: string
          enum: [pending, confirmed, shipped, delivered, cancelled]
        total:
          type: number
          format: double
          minimum: 0
          example: 89.90
        currency:
          type: string
          pattern: "^[A-Z]{3}$"
          example: "EUR"
        created_at:
          type: string
          format: date-time
          readOnly: true
          example: "2024-03-15T14:30:00Z"
        customer_id:
          type: integer
          format: int64
          writeOnly: true

Références et composition#

# $ref pour réutiliser un schéma
properties:
  customer:
    $ref: "#/components/schemas/Customer"

# allOf — héritage / intersection
OrderWithCustomer:
  allOf:
    - $ref: "#/components/schemas/Order"
    - type: object
      properties:
        customer:
          $ref: "#/components/schemas/Customer"

# oneOf avec discriminator — polymorphisme
PaymentMethod:
  oneOf:
    - $ref: "#/components/schemas/CardPayment"
    - $ref: "#/components/schemas/BankTransferPayment"
  discriminator:
    propertyName: type
    mapping:
      card: "#/components/schemas/CardPayment"
      bank_transfer: "#/components/schemas/BankTransferPayment"

Nullable en OpenAPI 3.1#

OpenAPI 3.1 utilise type: ["string", "null"] à la place du nullable: true d’OpenAPI 3.0.

# OpenAPI 3.1
shipped_at:
  type: ["string", "null"]
  format: date-time

# OpenAPI 3.0 (déprécié)
shipped_at:
  type: string
  format: date-time
  nullable: true

Paramètres et corps de requête#

Paramètres#

paths:
  /orders:
    get:
      parameters:
        - name: status
          in: query
          schema:
            type: string
            enum: [pending, confirmed, shipped]
          description: "Filtrer par statut"
        - name: limit
          in: query
          schema:
            type: integer
            default: 20
            minimum: 1
            maximum: 100
        - name: X-Request-ID
          in: header
          schema:
            type: string
            format: uuid
          required: false
  /orders/{orderId}:
    get:
      parameters:
        - name: orderId
          in: path
          required: true
          schema:
            type: integer
            format: int64

Corps de requête#

paths:
  /orders:
    post:
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/OrderCreate"
            example:
              customer_id: 5
              items:
                - product_id: 101
                  quantity: 2
          application/x-www-form-urlencoded:
            schema:
              $ref: "#/components/schemas/OrderCreate"

Réponses#

Codes de statut et headers#

paths:
  /orders:
    post:
      responses:
        "201":
          description: "Commande créée"
          headers:
            Location:
              schema:
                type: string
                format: uri
              description: "URL de la ressource créée"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Order"
        "400":
          description: "Données invalides"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"

Liens HATEOAS#

OpenAPI 3.1 supporte les links pour décrire les transitions HATEOAS.

responses:
  "201":
    description: "Commande créée"
    content:
      application/json:
        schema:
          $ref: "#/components/schemas/Order"
    links:
      GetOrderById:
        operationId: getOrder
        parameters:
          orderId: "$response.body#/id"
        description: "Récupérer la commande créée"
      CancelOrder:
        operationId: cancelOrder
        parameters:
          orderId: "$response.body#/id"

Sécurité#

Security schemes#

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT

    ApiKeyHeader:
      type: apiKey
      in: header
      name: X-API-Key

    OAuth2:
      type: oauth2
      flows:
        authorizationCode:
          authorizationUrl: "https://auth.example.com/oauth/authorize"
          tokenUrl: "https://auth.example.com/oauth/token"
          scopes:
            "orders:read":  "Lire les commandes"
            "orders:write": "Créer et modifier les commandes"
            "admin":        "Accès administrateur complet"

    OpenIDConnect:
      type: openIdConnect
      openIdConnectUrl: "https://auth.example.com/.well-known/openid-configuration"

Sécurité globale et par opération#

# Sécurité globale (s'applique à toutes les opérations)
security:
  - BearerAuth: []

# Surcharge par opération
paths:
  /public/products:
    get:
      security: []   # endpoint public, pas d'auth

  /admin/users:
    get:
      security:
        - OAuth2: ["admin"]

Webhooks#

La section webhooks d’OpenAPI 3.1 décrit les callbacks que l’API peut envoyer aux abonnés.

webhooks:
  orderStatusChanged:
    post:
      summary: "Changement de statut d'une commande"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [event, order_id, new_status, timestamp]
              properties:
                event:
                  type: string
                  enum: [order.status_changed]
                order_id:
                  type: integer
                  format: int64
                new_status:
                  type: string
                  enum: [confirmed, shipped, delivered, cancelled]
                timestamp:
                  type: string
                  format: date-time
      responses:
        "200":
          description: "Webhook reçu et traité"
        "202":
          description: "Webhook reçu, traitement asynchrone"

Outils#

Génération automatique avec FastAPI#

FastAPI génère le document OpenAPI depuis les types Pydantic et les décorateurs de route.

from fastapi import FastAPI
from pydantic import BaseModel, Field
from typing import Annotated

app = FastAPI(
    title="API E-commerce",
    version="2.0.0",
    description="API de gestion des commandes et produits.",
    contact={"name": "Équipe API", "email": "api@example.com"},
)

class OrderCreate(BaseModel):
    customer_id: Annotated[int, Field(description="ID du client", gt=0)]
    items: list["OrderItemCreate"]

    model_config = {"json_schema_extra": {"example": {"customer_id": 5, "items": []}}}


@app.post(
    "/orders",
    response_model=Order,
    status_code=201,
    summary="Créer une commande",
    responses={
        422: {"model": ValidationError, "description": "Données invalides"},
    },
    tags=["orders"],
)
async def create_order(order_data: OrderCreate) -> Order:
    """
    Crée une nouvelle commande pour un client.

    La commande est créée au statut `pending`. Le paiement doit être
    initié séparément via `POST /orders/{id}/payment`.
    """
    ...

Le document est accessible à /openapi.json et l’interface Swagger UI à /docs.

Validation avec Spectral#

Spectral est un linter de documents OpenAPI. Il vérifie la conformité à la spec et peut appliquer des règles personnalisées.

# .spectral.yaml
extends: ["spectral:oas"]
rules:
  operation-operationId: error
  operation-tags: error
  operation-summary: warn
  info-contact: warn
  no-$ref-siblings: error

Génération de clients#

# TypeScript (axios)
openapi-generator generate -i openapi.yaml -g typescript-axios -o ./client/

# Python
openapi-generator generate -i openapi.yaml -g python -o ./client/

# Java
openapi-generator generate -i openapi.yaml -g java --library resttemplate -o ./client/
import json
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import seaborn as sns

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)

# Parse et validation d'un document OpenAPI minimal
MINIMAL_OPENAPI = {
    "openapi": "3.1.0",
    "info": {
        "title": "API Exemple",
        "version": "1.0.0"
    },
    "paths": {
        "/users": {
            "get": {
                "operationId": "listUsers",
                "summary": "Lister les utilisateurs",
                "tags": ["users"],
                "responses": {
                    "200": {
                        "description": "Liste des utilisateurs",
                        "content": {
                            "application/json": {
                                "schema": {
                                    "type": "array",
                                    "items": {"$ref": "#/components/schemas/User"}
                                }
                            }
                        }
                    }
                }
            }
        }
    },
    "components": {
        "schemas": {
            "User": {
                "type": "object",
                "required": ["id", "name", "email"],
                "properties": {
                    "id":    {"type": "integer"},
                    "name":  {"type": "string"},
                    "email": {"type": "string", "format": "email"}
                }
            }
        }
    }
}

def validate_openapi_minimal(doc: dict) -> list[str]:
    """Validation manuelle d'un document OpenAPI minimal."""
    errors = []
    warnings = []

    # Version
    version = doc.get("openapi", "")
    if not version.startswith("3."):
        errors.append(f"Version OpenAPI invalide : {version!r}")

    # Info
    info = doc.get("info", {})
    if not info.get("title"):
        errors.append("info.title est requis")
    if not info.get("version"):
        errors.append("info.version est requis")

    # Paths
    paths = doc.get("paths", {})
    if not paths:
        warnings.append("Aucun path défini")

    for path, path_item in paths.items():
        if not path.startswith("/"):
            errors.append(f"Le chemin '{path}' doit commencer par /")
        for method, operation in path_item.items():
            if method not in ("get", "post", "put", "patch", "delete", "head", "options"):
                continue
            if not operation.get("responses"):
                errors.append(f"{method.upper()} {path} : 'responses' est requis")
            if not operation.get("operationId"):
                warnings.append(f"{method.upper()} {path} : 'operationId' recommandé")
            if not operation.get("summary"):
                warnings.append(f"{method.upper()} {path} : 'summary' recommandé")

    return errors, warnings

errors, warnings = validate_openapi_minimal(MINIMAL_OPENAPI)

print("=== Validation du document OpenAPI ===")
print(f"\nVersion : {MINIMAL_OPENAPI['openapi']}")
print(f"Titre   : {MINIMAL_OPENAPI['info']['title']}")
print(f"Paths   : {len(MINIMAL_OPENAPI['paths'])}")
print()

if errors:
    print(f"Erreurs ({len(errors)}) :")
    for e in errors:
        print(f"  ✗ {e}")
else:
    print("Aucune erreur.")

if warnings:
    print(f"\nAvertissements ({len(warnings)}) :")
    for w in warnings:
        print(f"  ⚠ {w}")
else:
    print("Aucun avertissement.")

print(f"\nDocument valide : {len(errors) == 0}")
=== Validation du document OpenAPI ===

Version : 3.1.0
Titre   : API Exemple
Paths   : 1

Aucune erreur.
Aucun avertissement.

Document valide : True
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import seaborn as sns

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)

# Visualisation de la structure hiérarchique d'un document OpenAPI
fig, ax = plt.subplots(figsize=(12, 8))
ax.set_xlim(0, 12)
ax.set_ylim(0, 9)
ax.axis("off")
ax.set_title("Structure d'un document OpenAPI 3.1", fontsize=13, pad=14)

def draw_box(ax, x, y, w, h, label, color, fontsize=9, alpha=0.85):
    rect = mpatches.FancyBboxPatch(
        (x, y), w, h,
        boxstyle="round,pad=0.08",
        facecolor=color, edgecolor="white",
        linewidth=1.2, alpha=alpha
    )
    ax.add_patch(rect)
    ax.text(x + w / 2, y + h / 2, label, ha="center", va="center",
            fontsize=fontsize, fontweight="bold", color="white")

def draw_arrow(ax, x1, y1, x2, y2):
    ax.annotate("", xy=(x2, y2), xytext=(x1, y1),
                arrowprops=dict(arrowstyle="-|>", color="#999999", lw=1.2))

# Racine
draw_box(ax, 4.5, 8.0, 3, 0.7, "Document OpenAPI 3.1", "#2c3e50", fontsize=10)

sections = [
    (0.2,  6.5, 1.6, 0.7, "openapi\ninfo",    "#4c72b0"),
    (2.1,  6.5, 1.6, 0.7, "servers",           "#dd8452"),
    (3.9,  6.5, 1.6, 0.7, "paths",             "#55a868"),
    (5.7,  6.5, 1.6, 0.7, "components",        "#c44e52"),
    (7.5,  6.5, 1.6, 0.7, "security\ntags",   "#8172b2"),
    (9.3,  6.5, 1.6, 0.7, "webhooks",          "#937860"),
]

for x, y, w, h, label, color in sections:
    draw_box(ax, x, y, w, h, label, color, fontsize=8.5)
    draw_arrow(ax, 6.0, 8.0, x + w / 2, y + h)

# Détails paths
paths_details = [
    (2.8, 5.0, 1.8, 0.6, "/resource\n/{id}", "#55a868", 0.7),
]
for x, y, w, h, label, color, alpha in paths_details:
    draw_box(ax, x, y, w, h, label, color, alpha=alpha, fontsize=8)
    draw_arrow(ax, 4.7, 6.5, x + w / 2, y + h)

methods = [
    (1.5, 3.5, 0.9, 0.5, "GET",    "#5bc0de"),
    (2.6, 3.5, 0.9, 0.5, "POST",   "#5cb85c"),
    (3.7, 3.5, 0.9, 0.5, "PUT",    "#f0ad4e"),
    (4.8, 3.5, 0.9, 0.5, "DELETE", "#d9534f"),
]
for x, y, w, h, label, color in methods:
    draw_box(ax, x, y, w, h, label, color, fontsize=8)
    draw_arrow(ax, 3.7, 5.0, x + w / 2, y + h)

# Détails components
comp_details = [
    (6.3, 5.0, 1.1, 0.5, "schemas",        "#c44e52", 0.7),
    (7.6, 5.0, 1.1, 0.5, "securitySchemes","#c44e52", 0.7),
    (8.9, 5.0, 1.1, 0.5, "responses\nparams", "#c44e52", 0.6),
]
for x, y, w, h, label, color, alpha in comp_details:
    draw_box(ax, x, y, w, h, label, color, alpha=alpha, fontsize=7.5)
    draw_arrow(ax, 6.5, 6.5, x + w / 2, y + h)

operation_parts = [
    (1.5, 2.1, 1.2, 0.5, "summary\noperationId", "#4c72b0", 0.6),
    (2.9, 2.1, 1.2, 0.5, "parameters\nrequestBody", "#4c72b0", 0.6),
    (4.3, 2.1, 1.2, 0.5, "responses\nsecurity", "#4c72b0", 0.6),
]
for x, y, w, h, label, color, alpha in operation_parts:
    draw_box(ax, x, y, w, h, label, color, alpha=alpha, fontsize=7.5)
    for mx, my, mw, mh, ml, mc in methods:
        draw_arrow(ax, mx + mw / 2, my, x + w / 2, y + h)
        break

plt.show()
_images/1af0a3b800be23b6b594dba6068b8b1866885333bfa1b0718ce00ecba927ad3f.png
import json
import seaborn as sns
import matplotlib.pyplot as plt

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)

# Génération d'un squelette OpenAPI depuis des types simulés
def python_type_to_json_schema(py_type: str) -> dict:
    mapping = {
        "int":      {"type": "integer"},
        "float":    {"type": "number", "format": "double"},
        "str":      {"type": "string"},
        "bool":     {"type": "boolean"},
        "datetime": {"type": "string", "format": "date-time"},
        "date":     {"type": "string", "format": "date"},
    }
    return mapping.get(py_type, {"type": "string"})


def generate_openapi_schema(model_name: str, fields: list[tuple[str, str, bool]]) -> dict:
    """
    Génère un schéma OpenAPI depuis une liste de (nom, type_python, required).
    """
    properties = {}
    required = []
    for name, py_type, is_required in fields:
        properties[name] = python_type_to_json_schema(py_type)
        if is_required:
            required.append(name)
    schema = {"type": "object", "properties": properties}
    if required:
        schema["required"] = required
    return schema


def generate_openapi_stub(
    title: str,
    version: str,
    models: dict[str, list[tuple[str, str, bool]]],
    endpoints: list[tuple[str, str, str, str]],
) -> dict:
    """
    Génère un document OpenAPI 3.1 minimal.
    endpoints: liste de (method, path, operationId, response_model)
    """
    doc = {
        "openapi": "3.1.0",
        "info": {"title": title, "version": version},
        "paths": {},
        "components": {"schemas": {}},
    }

    for model_name, fields in models.items():
        doc["components"]["schemas"][model_name] = generate_openapi_schema(model_name, fields)

    for method, path, operation_id, resp_model in endpoints:
        if path not in doc["paths"]:
            doc["paths"][path] = {}
        operation = {
            "operationId": operation_id,
            "summary": operation_id.replace("_", " ").title(),
            "responses": {
                "200": {
                    "description": "Succès",
                    "content": {
                        "application/json": {
                            "schema": {"$ref": f"#/components/schemas/{resp_model}"}
                            if resp_model in models
                            else {"type": "object"}
                        }
                    }
                }
            }
        }
        doc["paths"][path][method] = operation

    return doc


# Modèles simulés
models = {
    "User": [
        ("id",         "int",      True),
        ("name",       "str",      True),
        ("email",      "str",      True),
        ("created_at", "datetime", False),
    ],
    "Order": [
        ("id",          "int",   True),
        ("status",      "str",   True),
        ("total",       "float", True),
        ("customer_id", "int",   True),
        ("created_at",  "datetime", False),
    ],
}

endpoints = [
    ("get",  "/users",       "list_users",  "User"),
    ("post", "/users",       "create_user", "User"),
    ("get",  "/users/{id}",  "get_user",    "User"),
    ("get",  "/orders",      "list_orders", "Order"),
    ("post", "/orders",      "create_order","Order"),
]

stub = generate_openapi_stub("API Générée", "1.0.0", models, endpoints)

print("=== Document OpenAPI généré ===")
print(f"Paths  : {len(stub['paths'])}")
print(f"Schemas: {len(stub['components']['schemas'])}")
print()
print(json.dumps(stub["components"]["schemas"]["User"], indent=2))
=== Document OpenAPI généré ===
Paths  : 3
Schemas: 2

{
  "type": "object",
  "properties": {
    "id": {
      "type": "integer"
    },
    "name": {
      "type": "string"
    },
    "email": {
      "type": "string"
    },
    "created_at": {
      "type": "string",
      "format": "date-time"
    }
  },
  "required": [
    "id",
    "name",
    "email"
  ]
}
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import seaborn as sns
import numpy as np

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)

tools = ["Swagger UI", "Redoc", "Scalar", "Stoplight\nElements"]
criteria = ["Interactivité", "Lisibilité", "Personnali-\nsation", "Performance\ngrande API", "Intégration\nfacile"]

scores = {
    "Swagger UI":         [3, 2, 2, 2, 3],
    "Redoc":              [1, 3, 2, 3, 3],
    "Scalar":             [3, 3, 3, 3, 2],
    "Stoplight\nElements":[2, 3, 3, 2, 2],
}

colors = ["#4c72b0", "#dd8452", "#55a868", "#c44e52"]
x = np.arange(len(criteria))
width = 0.2

fig, ax = plt.subplots(figsize=(11, 5))

for i, (tool, color) in enumerate(zip(tools, colors)):
    offset = (i - 1.5) * width
    vals = scores[tool]
    bars = ax.bar(x + offset, vals, width, label=tool, color=color, alpha=0.85)
    for bar, v in zip(bars, vals):
        labels = {1: "bas", 2: "moy", 3: "haut"}
        ax.text(
            bar.get_x() + bar.get_width() / 2,
            bar.get_height() + 0.05,
            labels[v],
            ha="center", va="bottom", fontsize=6.5, color="#333"
        )

ax.set_xticks(x)
ax.set_xticklabels(criteria, fontsize=9)
ax.set_ylim(0, 4)
ax.set_yticks([])
ax.set_title("Comparaison des outils de documentation API", fontsize=13, pad=14)
ax.legend(loc="upper right", fontsize=9)
plt.show()
_images/37463c8013f9592d5e922a5f5f1b82614518bac8c04306324c05537a78231f47.png

Résumé#

Ce chapitre a couvert OpenAPI 3.1 comme format de contrat d’API.

La structure d’un document OpenAPI articule les sections info, servers, paths, components, security, tags et webhooks. Les components centralisent les schémas et security schemes réutilisables ; les paths décrivent les opérations avec leurs paramètres, corps de requête, réponses et liens HATEOAS.

Les schémas JSON Schema permettent de décrire les structures de données avec types, formats, validations, $ref pour la réutilisation, et les compositions allOf/oneOf/anyOf pour le polymorphisme. OpenAPI 3.1 aligne enfin sa définition des schémas sur JSON Schema 2020-12.

La sécurité est déclarée dans securitySchemes avec quatre types : http (Bearer JWT), apiKey, oauth2 (avec les flows Authorization Code, Client Credentials, Implicit) et openIdConnect. La sécurité peut être définie globalement et surchargée par opération.

Les outils forment un écosystème riche : FastAPI génère le document automatiquement en code-first, Spectral valide la qualité du document, openapi-generator produit des clients dans des dizaines de langages, et Swagger UI / Redoc / Scalar rendent la documentation interactive.