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.jsonautomatiquement 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()
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()
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.