---
jupytext:
  text_representation:
    extension: .md
    format_name: myst
    format_version: 0.13
    jupytext_version: 1.16.0
kernelspec:
  name: python3
  display_name: Python 3
---

# 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.

```{admonition} OpenAPI 3.1 et JSON Schema
:class: note
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.

```python
# 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.

```python
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

```python
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

```python
# $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.

```python
# 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

```python
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

```python
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

```python
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.

```python
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

```python
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

```python
# 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.

```python
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.

```python
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.

```python
# .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/
```

```{code-cell} python3
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}")
```

```{code-cell} python3
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()
```

```{code-cell} python3
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))
```

```{code-cell} python3
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()
```

## 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.
