api-design-principles

Par wshobson · agents

Maîtrisez les principes de conception d'API REST et GraphQL pour créer des API intuitives, évolutives et maintenables qui satisfont les développeurs. À utiliser lors de la conception de nouvelles API, de la revue de spécifications d'API ou de l'établissement de standards de conception d'API.

npx skills add https://github.com/wshobson/agents --skill api-design-principles

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 ressources
  • PUT : Remplacer l'intégralité de la ressource (idempotent)
  • PATCH : Mises à jour partielles de ressources
  • DELETE : 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

  1. Nommage cohérent : Utiliser les pluriels pour les collections (/users, pas /user)
  2. Sans état : Chaque requête contient toutes les informations nécessaires
  3. Utiliser correctement les codes de statut HTTP : 2xx succès, 4xx erreurs client, 5xx erreurs serveur
  4. Versionnez votre API : Planifiez les changements cassants dès le départ
  5. Pagination : Toujours paginer les grandes collections
  6. Limitation de débit : Protégez votre API avec des limites de débit
  7. Documentation : Utiliser OpenAPI/Swagger pour la documentation interactive

API GraphQL

  1. Schéma d'abord : Concevoir le schéma avant d'écrire les résolveurs
  2. Éviter N+1 : Utiliser les DataLoaders pour une récupération de données efficace
  3. Validation des entrées : Valider au niveau du schéma et des résolveurs
  4. Gestion des erreurs : Retourner les erreurs structurées dans les charges utiles de mutation
  5. Pagination : Utiliser la pagination basée sur les curseurs (spécification Relay)
  6. Dépréciation : Utiliser la directive @deprecated pour une migration progressive
  7. 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

Skills similaires