Principes de conception d'API
Maîtrisez les principes de conception d'API REST et GraphQL pour construire des APIs intuitives, scalables et maintenables qui enchantent les développeurs et qui résistent au temps.
Quand utiliser cette compétence
- Concevoir de nouvelles API REST ou GraphQL
- Refactoriser les API existantes pour une meilleure utilisabilité
- Établir des standards de conception d'API pour votre équipe
- Examiner les spécifications d'API avant la mise en œuvre
- Migrer entre des paradigmes d'API (REST vers GraphQL, etc.)
- Créer une documentation d'API conviviale pour les développeurs
- Optimiser les API pour des cas d'usage spécifiques (mobile, intégrations tierces)
Concepts fondamentaux
1. Principes de conception RESTful
Architecture orientée ressources
- Les ressources sont des noms (utilisateurs, commandes, produits), pas des verbes
- Utiliser les méthodes HTTP pour les actions (GET, POST, PUT, PATCH, DELETE)
- Les URLs représentent les hiérarchies de ressources
- Conventions de nommage cohérentes
Sémantique des méthodes HTTP :
GET: Récupérer les ressources (idempotent, sûr)POST: Créer de nouvelles ressourcesPUT: Remplacer l'intégralité de la ressource (idempotent)PATCH: Mises à jour partielles de ressourcesDELETE: Supprimer les ressources (idempotent)
2. Principes de conception GraphQL
Développement basé sur le schéma
- Les types définissent votre modèle de domaine
- Les requêtes pour lire les données
- Les mutations pour modifier les données
- Les abonnements pour les mises à jour en temps réel
Structure des requêtes :
- Les clients demandent exactement ce dont ils ont besoin
- Un seul endpoint, plusieurs opérations
- Schéma fortement typé
- Introspection intégrée
3. Stratégies de versioning d'API
Versioning par URL :
/api/v1/users
/api/v2/users
Versioning par en-tête :
Accept: application/vnd.api+json; version=1
Versioning par paramètre de requête :
/api/users?version=1
Modèles de conception d'API REST
Modèle 1 : Conception de collection de ressources
# Bon : Endpoints orientés ressources
GET /api/users # Lister les utilisateurs (avec pagination)
POST /api/users # Créer un utilisateur
GET /api/users/{id} # Obtenir un utilisateur spécifique
PUT /api/users/{id} # Remplacer l'utilisateur
PATCH /api/users/{id} # Mettre à jour les champs de l'utilisateur
DELETE /api/users/{id} # Supprimer l'utilisateur
# Ressources imbriquées
GET /api/users/{id}/orders # Obtenir les commandes de l'utilisateur
POST /api/users/{id}/orders # Créer une commande pour l'utilisateur
# Mauvais : Endpoints orientés actions (à éviter)
POST /api/createUser
POST /api/getUserById
POST /api/deleteUser
Modèle 2 : Pagination et filtrage
from typing import List, Optional
from pydantic import BaseModel, Field
class PaginationParams(BaseModel):
page: int = Field(1, ge=1, description="Page number")
page_size: int = Field(20, ge=1, le=100, description="Items per page")
class FilterParams(BaseModel):
status: Optional[str] = None
created_after: Optional[str] = None
search: Optional[str] = None
class PaginatedResponse(BaseModel):
items: List[dict]
total: int
page: int
page_size: int
pages: int
@property
def has_next(self) -> bool:
return self.page < self.pages
@property
def has_prev(self) -> bool:
return self.page > 1
# Exemple d'endpoint FastAPI
from fastapi import FastAPI, Query, Depends
app = FastAPI()
@app.get("/api/users", response_model=PaginatedResponse)
async def list_users(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
status: Optional[str] = Query(None),
search: Optional[str] = Query(None)
):
# Appliquer les filtres
query = build_query(status=status, search=search)
# Compter le total
total = await count_users(query)
# Récupérer la page
offset = (page - 1) * page_size
users = await fetch_users(query, limit=page_size, offset=offset)
return PaginatedResponse(
items=users,
total=total,
page=page,
page_size=page_size,
pages=(total + page_size - 1) // page_size
)
Modèle 3 : Gestion des erreurs et codes de statut
from fastapi import HTTPException, status
from pydantic import BaseModel
class ErrorResponse(BaseModel):
error: str
message: str
details: Optional[dict] = None
timestamp: str
path: str
class ValidationErrorDetail(BaseModel):
field: str
message: str
value: Any
# Réponses d'erreur cohérentes
STATUS_CODES = {
"success": 200,
"created": 201,
"no_content": 204,
"bad_request": 400,
"unauthorized": 401,
"forbidden": 403,
"not_found": 404,
"conflict": 409,
"unprocessable": 422,
"internal_error": 500
}
def raise_not_found(resource: str, id: str):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={
"error": "NotFound",
"message": f"{resource} not found",
"details": {"id": id}
}
)
def raise_validation_error(errors: List[ValidationErrorDetail]):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={
"error": "ValidationError",
"message": "Request validation failed",
"details": {"errors": [e.dict() for e in errors]}
}
)
# Exemple d'utilisation
@app.get("/api/users/{user_id}")
async def get_user(user_id: str):
user = await fetch_user(user_id)
if not user:
raise_not_found("User", user_id)
return user
Modèle 4 : HATEOAS (Hypermedia as the Engine of Application State)
class UserResponse(BaseModel):
id: str
name: str
email: str
_links: dict
@classmethod
def from_user(cls, user: User, base_url: str):
return cls(
id=user.id,
name=user.name,
email=user.email,
_links={
"self": {"href": f"{base_url}/api/users/{user.id}"},
"orders": {"href": f"{base_url}/api/users/{user.id}/orders"},
"update": {
"href": f"{base_url}/api/users/{user.id}",
"method": "PATCH"
},
"delete": {
"href": f"{base_url}/api/users/{user.id}",
"method": "DELETE"
}
}
)
Modèles de conception GraphQL
Modèle 1 : Conception du schéma
# schema.graphql
# Définitions de type claires
type User {
id: ID!
email: String!
name: String!
createdAt: DateTime!
# Relations
orders(first: Int = 20, after: String, status: OrderStatus): OrderConnection!
profile: UserProfile
}
type Order {
id: ID!
status: OrderStatus!
total: Money!
items: [OrderItem!]!
createdAt: DateTime!
# Référence inverse
user: User!
}
# Modèle de pagination (style Relay)
type OrderConnection {
edges: [OrderEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type OrderEdge {
node: Order!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
# Énumérations pour la sécurité des types
enum OrderStatus {
PENDING
CONFIRMED
SHIPPED
DELIVERED
CANCELLED
}
# Scalaires personnalisés
scalar DateTime
scalar Money
# Racine Query
type Query {
user(id: ID!): User
users(first: Int = 20, after: String, search: String): UserConnection!
order(id: ID!): Order
}
# Racine Mutation
type Mutation {
createUser(input: CreateUserInput!): CreateUserPayload!
updateUser(input: UpdateUserInput!): UpdateUserPayload!
deleteUser(id: ID!): DeleteUserPayload!
createOrder(input: CreateOrderInput!): CreateOrderPayload!
}
# Types d'entrée pour les mutations
input CreateUserInput {
email: String!
name: String!
password: String!
}
# Types de charge utile pour les mutations
type CreateUserPayload {
user: User
errors: [Error!]
}
type Error {
field: String
message: String!
}
Modèle 2 : Conception des résolveurs
from typing import Optional, List
from ariadne import QueryType, MutationType, ObjectType
from dataclasses import dataclass
query = QueryType()
mutation = MutationType()
user_type = ObjectType("User")
@query.field("user")
async def resolve_user(obj, info, id: str) -> Optional[dict]:
"""Résoudre un utilisateur unique par ID."""
return await fetch_user_by_id(id)
@query.field("users")
async def resolve_users(
obj,
info,
first: int = 20,
after: Optional[str] = None,
search: Optional[str] = None
) -> dict:
"""Résoudre la liste paginée des utilisateurs."""
# Décoder le curseur
offset = decode_cursor(after) if after else 0
# Récupérer les utilisateurs
users = await fetch_users(
limit=first + 1, # Récupérer un de plus pour vérifier hasNextPage
offset=offset,
search=search
)
# Pagination
has_next = len(users) > first
if has_next:
users = users[:first]
edges = [
{
"node": user,
"cursor": encode_cursor(offset + i)
}
for i, user in enumerate(users)
]
return {
"edges": edges,
"pageInfo": {
"hasNextPage": has_next,
"hasPreviousPage": offset > 0,
"startCursor": edges[0]["cursor"] if edges else None,
"endCursor": edges[-1]["cursor"] if edges else None
},
"totalCount": await count_users(search=search)
}
@user_type.field("orders")
async def resolve_user_orders(user: dict, info, first: int = 20) -> dict:
"""Résoudre les commandes de l'utilisateur (prévention N+1 avec DataLoader)."""
# Utiliser DataLoader pour regrouper les requêtes
loader = info.context["loaders"]["orders_by_user"]
orders = await loader.load(user["id"])
return paginate_orders(orders, first)
@mutation.field("createUser")
async def resolve_create_user(obj, info, input: dict) -> dict:
"""Créer un nouvel utilisateur."""
try:
# Valider l'entrée
validate_user_input(input)
# Créer l'utilisateur
user = await create_user(
email=input["email"],
name=input["name"],
password=hash_password(input["password"])
)
return {
"user": user,
"errors": []
}
except ValidationError as e:
return {
"user": None,
"errors": [{"field": e.field, "message": e.message}]
}
Modèle 3 : DataLoader (Prévention du problème N+1)
from aiodataloader import DataLoader
from typing import List, Optional
class UserLoader(DataLoader):
"""Charger les utilisateurs par lot selon l'ID."""
async def batch_load_fn(self, user_ids: List[str]) -> List[Optional[dict]]:
"""Charger plusieurs utilisateurs en une seule requête."""
users = await fetch_users_by_ids(user_ids)
# Mapper les résultats à l'ordre d'entrée
user_map = {user["id"]: user for user in users}
return [user_map.get(user_id) for user_id in user_ids]
class OrdersByUserLoader(DataLoader):
"""Charger les commandes par ID d'utilisateur par lot."""
async def batch_load_fn(self, user_ids: List[str]) -> List[List[dict]]:
"""Charger les commandes de plusieurs utilisateurs en une seule requête."""
orders = await fetch_orders_by_user_ids(user_ids)
# Grouper les commandes par user_id
orders_by_user = {}
for order in orders:
user_id = order["user_id"]
if user_id not in orders_by_user:
orders_by_user[user_id] = []
orders_by_user[user_id].append(order)
# Retourner à l'ordre d'entrée
return [orders_by_user.get(user_id, []) for user_id in user_ids]
# Configuration du contexte
def create_context():
return {
"loaders": {
"user": UserLoader(),
"orders_by_user": OrdersByUserLoader()
}
}
Bonnes pratiques
API REST
- Nommage cohérent : Utiliser les pluriels pour les collections (
/users, pas/user) - Sans état : Chaque requête contient toutes les informations nécessaires
- Utiliser correctement les codes de statut HTTP : 2xx succès, 4xx erreurs client, 5xx erreurs serveur
- Versionnez votre API : Planifiez les changements cassants dès le départ
- Pagination : Toujours paginer les grandes collections
- Limitation de débit : Protégez votre API avec des limites de débit
- Documentation : Utiliser OpenAPI/Swagger pour la documentation interactive
API GraphQL
- Schéma d'abord : Concevoir le schéma avant d'écrire les résolveurs
- Éviter N+1 : Utiliser les DataLoaders pour une récupération de données efficace
- Validation des entrées : Valider au niveau du schéma et des résolveurs
- Gestion des erreurs : Retourner les erreurs structurées dans les charges utiles de mutation
- Pagination : Utiliser la pagination basée sur les curseurs (spécification Relay)
- Dépréciation : Utiliser la directive
@deprecatedpour une migration progressive - Surveillance : Suivre la complexité des requêtes et le temps d'exécution
Pièges courants
- Sur-récupération/Sous-récupération (REST) : Corrigée dans GraphQL mais nécessite les DataLoaders
- Changements cassants : Versionnez les API ou utilisez des stratégies de dépréciation
- Formats d'erreur incohérents : Standardiser les réponses d'erreur
- Absence de limite de débit : Les API sans limites sont vulnérables aux abus
- Documentation insuffisante : Les API non documentées frustr les développeurs
- Ignorer la sémantique HTTP : Utiliser POST pour les opérations idempotentes viole les attentes
- Couplage serré : La structure de l'API ne devrait pas refléter le schéma de la base de données