Aller au contenu principal

API de base

remarque

Ce chapitre est le premier gros tutoriel pratique du wiki. Il montre comment construire une API FastAPI de base en reprenant la même logique d'architecture que le repo de référence task-organizer-API, tout en ajoutant le guidage qui manque à un débutant.

Introduction

Ce chapitre sert de socle pour tout le reste du wiki. Au lieu d'empiler des snippets isolés, on construit dès le départ une petite API de tâches avec une structure proche d'un vrai projet FastAPI :

  • main.py pour assembler l'application,
  • config.py pour la configuration,
  • database/ pour la persistance,
  • services/ pour la logique métier,
  • api/ pour l'interface HTTP.

L'objectif n'est donc pas seulement d'obtenir quatre endpoints CRUD. L'objectif est de comprendre pourquoi chaque morceau du code vit à cet endroit, afin que les chapitres suivants puissent réutiliser ce même socle sans repartir de zéro.

Ce que tu vas construire

À la fin de ce chapitre, tu auras un projet capable de :

  • démarrer avec uvicorn,
  • exposer une documentation automatique,
  • créer une tâche,
  • lire une tâche,
  • mettre à jour son statut,
  • supprimer une tâche,
  • gérer proprement les cas simples de tâche introuvable.

Prérequis minimum

Ce chapitre convient si tu maîtrises déjà :

  • les bases de Python,
  • quelques commandes terminal simples,
  • l'idée générale d'une requête HTTP et d'une réponse JSON,
  • les notions les plus simples d'une base de données relationnelle.

Si certains mots sont encore flous, retiens simplement ceci avant de coder :

  • endpoint : une route API appelée par un client.
  • schema : la forme des données d'entrée ou de sortie.
  • model : la structure persistée en base.
  • session : l'objet qui porte les échanges avec la base.
  • dependency injection : la manière dont FastAPI fournit automatiquement certains objets.
  • service layer : l'endroit où l'on met la logique métier plutôt que dans les routes.

Vision d'ensemble

Pendant tout le chapitre, garde ce flux en tête :

  1. le client appelle une route HTTP ;
  2. la route valide l'entrée et récupère ses dépendances ;
  3. le service applique la logique métier ;
  4. la base lit ou écrit les données ;
  5. FastAPI renvoie une réponse JSON conforme au schéma attendu.

Ce fil conducteur est plus important que chaque détail de syntaxe : si tu comprends ce trajet, le reste du wiki sera beaucoup plus facile à suivre.

Architecture type retenue

Nous allons viser cette structure :

fastapi-base-api/
├── main.py
├── config.py
├── requirements.txt
├── .gitignore
├── .env
├── .env.example
├── api/
│ ├── __init__.py
│ ├── router.py
│ ├── dependencies.py
│ └── schemas/
│ ├── __init__.py
│ └── task.py
├── database/
│ ├── __init__.py
│ ├── models.py
│ └── session.py
└── services/
├── __init__.py
└── task.py

Repères utiles avant de lire le code

Avant de coder, garde ces repères simples en tête :

  • main.py assemble l'application, mais ne doit pas contenir toute la logique métier.
  • config.py lit la configuration, surtout la connexion base de données.
  • database/models.py décrit la forme des données en base.
  • api/schemas/task.py décrit la forme des données échangées par l'API.
  • database/session.py prépare le moteur et les sessions de travail.
  • services/task.py contient ce que l'application fait vraiment avec une tâche.
  • api/dependencies.py demande à FastAPI de fournir les bons objets automatiquement.
  • api/router.py reçoit les appels HTTP et les envoie au service.

Si tu comprends déjà ce découpage, le code sera beaucoup plus facile à lire.

Étape 1 — créer le projet et installer les dépendances

Commence par créer le dossier de travail, l'environnement virtuel et les dépendances.

mkdir fastapi-base-api
cd fastapi-base-api
python -m venv .venv
source .venv/bin/activate

Crée ensuite un fichier requirements.txt :

fastapi
uvicorn[standard]
sqlmodel
sqlalchemy
asyncpg
pydantic-settings
scalar-fastapi

Puis installe les dépendances :

pip install -r requirements.txt

Étape 2 — préparer les fichiers de base du projet

Crée maintenant les dossiers et fichiers clés.

mkdir -p api/schemas database services
touch main.py config.py .env .env.example .gitignore
touch api/__init__.py api/router.py api/dependencies.py
touch api/schemas/__init__.py api/schemas/task.py
touch database/__init__.py database/models.py database/session.py
touch services/__init__.py services/task.py

À ce stade, l'idée n'est pas encore de tout comprendre dans le détail. L'idée est de voir où le code va vivre.

Ajouter .gitignore

Même dans un mini-projet pédagogique, il faut éviter de versionner les secrets et les fichiers temporaires.

__pycache__/
*.py[cod]
.venv/
.env
.DS_Store

Ajouter .env.example

Ce fichier sert de modèle pour montrer quelles variables doivent exister sans exposer de vraies valeurs sensibles.

POSTGRES_SERVER=localhost
POSTGRES_PORT=5432
POSTGRES_DB=taskapi_db
POSTGRES_USER=postgres
POSTGRES_PASSWORD=change_me

Étape 3 — préparer PostgreSQL

Le repo de référence repose sur PostgreSQL. Pour rester fidèle à sa logique d'architecture, on garde cette base.

Option simple si PostgreSQL tourne déjà chez toi

Si tu as déjà un utilisateur postgres local :

createdb taskapi_db

Option plus explicite si tu veux tout créer proprement

createuser postgres --pwprompt
createdb taskapi_db -O postgres

Ensuite, remplis .env avec de vraies valeurs locales :

POSTGRES_SERVER=localhost
POSTGRES_PORT=5432
POSTGRES_DB=taskapi_db
POSTGRES_USER=postgres
POSTGRES_PASSWORD=your_real_password
attention

Si PostgreSQL n'est pas lancé, ou si les identifiants ne correspondent pas à ton installation locale, l'API ne démarrera pas correctement.

Étape 4 — écrire config.py

Le rôle de ce fichier est de centraliser la configuration et de fabriquer l'URL de connexion à la base.

from pydantic_settings import BaseSettings, SettingsConfigDict


class DatabaseSettings(BaseSettings):
POSTGRES_SERVER: str
POSTGRES_PORT: int
POSTGRES_USER: str
POSTGRES_PASSWORD: str
POSTGRES_DB: str

model_config = SettingsConfigDict(
env_file=".env",
env_ignore_empty=True,
extra="ignore",
)

@property
def POSTGRES_URL(self) -> str:
return (
f"postgresql+asyncpg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
f"@{self.POSTGRES_SERVER}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
)


settings = DatabaseSettings()

Pourquoi ce fichier existe ?

Parce que tu ne veux pas disperser les informations sensibles dans tout le projet. Tu veux un seul endroit qui lit l'environnement et fabrique proprement la connexion.

astuce

Le repo d'origine construisait déjà l'URL de connexion dans config.py. Ici, on garde cette idée, mais on ajoute explicitement le mot de passe pour rendre le setup plus complet et plus clair.

Étape 5 — définir le modèle de données dans database/models.py

Ici, on définit ce qu'est une tâche en base de données.

from enum import Enum

from sqlalchemy import Enum as SAEnum
from sqlmodel import Field, SQLModel


class Status_Task(str, Enum):
to_do = "to_do"
in_progress = "in_progress"
completed = "completed"


class Task(SQLModel, table=True):
__tablename__ = "task"

id: int | None = Field(default=None, primary_key=True)
title: str
description: str
assignee: str
status: Status_Task = Field(
sa_type=SAEnum(Status_Task, native_enum=False),
default=Status_Task.to_do,
)

Cette étape sert à définir le moule de tes données.

Ce qu'il faut remarquer

  • Task représente la table SQL.
  • id est la clé primaire.
  • status est limité à trois valeurs connues.
  • native_enum=False évite certaines surprises selon les configurations SQL.

Étape 6 — définir les schémas d'entrée et de sortie dans api/schemas/task.py

Ici, on définit la forme des données API.

from pydantic import BaseModel, Field

from database.models import Status_Task


class BaseTask(BaseModel):
title: str
description: str
assignee: str


class TaskRead(BaseTask):
id: int
status: Status_Task


class TaskCreate(BaseTask):
pass


class TaskUpdate(BaseModel):
status: Status_Task | None = Field(default=None)

Pourquoi ne pas utiliser directement Task partout ?

Parce qu'une API n'a pas toujours la même forme en entrée qu'en sortie. Les schémas permettent de clarifier cela proprement.

  • TaskCreate sert à recevoir les données de création.
  • TaskRead sert à renvoyer une tâche complète au client.
  • TaskUpdate sert à mettre à jour seulement ce qui change.

Étape 7 — créer la couche de session base de données dans database/session.py

Ici, on prépare le moteur async, la session et la création des tables.

from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from sqlmodel import SQLModel

from config import settings


engine = create_async_engine(
url=settings.POSTGRES_URL,
echo=True,
)

async_session = sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)


async def create_db_tables():
async with engine.begin() as connection:
await connection.run_sync(SQLModel.metadata.create_all)


async def get_session():
async with async_session() as session:
yield session

Ce que fait ce fichier

  • engine ouvre la grande connexion technique vers PostgreSQL.
  • async_session fabrique des sessions de travail.
  • create_db_tables() crée les tables au démarrage si elles n'existent pas.
  • get_session() fournit une session temporaire à chaque requête.

Pourquoi expire_on_commit=False ?

Parce qu'après un commit(), on veut encore pouvoir manipuler proprement l'objet chargé sans déclencher des comportements de rechargement peu intuitifs pour un débutant.

Étape 8 — créer le service métier dans services/task.py

Dans cette architecture, le service porte la logique CRUD de la ressource Task.

from sqlalchemy.ext.asyncio import AsyncSession

from api.schemas.task import TaskCreate
from database.models import Task


class TaskService:
def __init__(self, session: AsyncSession):
self.session = session

async def get_task(self, id: int) -> Task | None:
return await self.session.get(Task, id)

async def add_task(self, task_create: TaskCreate) -> Task:
new_task = Task(**task_create.model_dump())
self.session.add(new_task)
await self.session.commit()
await self.session.refresh(new_task)
return new_task

async def update_task(self, id: int, task_update: dict) -> Task | None:
task = await self.get_task(id)
if task is None:
return None

task.sqlmodel_update(task_update)
self.session.add(task)
await self.session.commit()
await self.session.refresh(task)
return task

async def delete_task(self, id: int) -> bool:
task = await self.get_task(id)
if task is None:
return False

await self.session.delete(task)
await self.session.commit()
return True

Pourquoi cette couche est importante

On évite de mettre toute la logique directement dans les routes.

Les routes doivent rester simples :

  • elles reçoivent la requête,
  • elles délèguent au service,
  • elles renvoient la réponse.

Le service, lui, fait le vrai travail métier.

Ce que cette version améliore déjà

Par rapport au repo de référence, on gère ici plus proprement deux cas :

  • la mise à jour d'une tâche absente
  • la suppression d'une tâche absente

Au lieu de laisser planter le code plus loin, le service renvoie None ou False, puis la route traduit cela en 404.

Étape 9 — préparer l'injection de dépendances dans api/dependencies.py

Ici, on demande à FastAPI de fournir automatiquement :

  • une session de base de données,
  • un TaskService déjà prêt.
from typing import Annotated

from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession

from database.session import get_session
from services.task import TaskService


SessionDep = Annotated[AsyncSession, Depends(get_session)]


def get_task_service(session: SessionDep) -> TaskService:
return TaskService(session)


ServiceDep = Annotated[TaskService, Depends(get_task_service)]

Pourquoi cette étape est précieuse

C'est ici que FastAPI devient très agréable : tu n'as pas besoin d'aller chercher la session toi-même dans chaque route.

Tu déclares ce dont tu as besoin, et FastAPI te le fournit.

Étape 10 — écrire les routes CRUD dans api/router.py

Ici, on expose la ressource Task à travers HTTP.

from fastapi import APIRouter, HTTPException, status

from .dependencies import ServiceDep
from .schemas.task import TaskCreate, TaskRead, TaskUpdate


router = APIRouter(prefix="/task", tags=["Task"])


@router.get("/", response_model=TaskRead)
async def get(id: int, service: ServiceDep):
task = await service.get_task(id)
if task is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Task not found",
)
return task


@router.post("/", response_model=TaskRead, status_code=status.HTTP_201_CREATED)
async def create(task_create: TaskCreate, service: ServiceDep):
return await service.add_task(task_create)


@router.patch("/", response_model=TaskRead)
async def update(id: int, task_update: TaskUpdate, service: ServiceDep):
update_data = task_update.model_dump(exclude_unset=True)
task = await service.update_task(id, update_data)

if task is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Task not found",
)

return task


@router.delete("/")
async def delete(id: int, service: ServiceDep):
deleted = await service.delete_task(id)
if not deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Task not found",
)

return {"message": f"Task with ID {id} has been deleted successfully"}

Ce qu'il faut remarquer ici

  • les routes sont fines,
  • elles ne contiennent pas toute la logique CRUD,
  • elles traduisent les cas d'erreur en réponses HTTP,
  • elles annoncent explicitement les schémas de réponse.
remarque

Pour rester proche du repo de référence, on garde ici un style de routes avec id en query parameter. Plus tard, tu pourras faire évoluer cette base vers une forme plus REST comme /task/{id}.

Étape 11 — assembler l'application dans main.py

Il ne reste plus qu'à relier les morceaux entre eux.

from contextlib import asynccontextmanager

from fastapi import FastAPI
from scalar_fastapi import get_scalar_api_reference

from api.router import router
from database.session import create_db_tables


@asynccontextmanager
async def lifespan(app: FastAPI):
await create_db_tables()
yield


app = FastAPI(
title="Task Organizer API",
description="Une API de base construite avec FastAPI",
lifespan=lifespan,
)

app.include_router(router)


@app.get("/scalar", include_in_schema=False)
def get_scalar_docs():
return get_scalar_api_reference(
openapi_url=app.openapi_url,
title="Scalar API",
)

main.py joue le rôle de chef d'orchestre :

  • il démarre l'application,
  • il crée les tables au lancement,
  • il branche les routes,
  • il expose une documentation alternative via Scalar.

Étape 12 — lancer l'application

Depuis la racine du projet :

uvicorn main:app --reload

Si tout va bien, tu devrais pouvoir ouvrir :

  • http://127.0.0.1:8000/docs
  • http://127.0.0.1:8000/scalar

Vérification guidée

Test 1 — créer une tâche

curl -X POST http://127.0.0.1:8000/task/ -H "Content-Type: application/json" -d '{
"title": "Préparer le wiki FastAPI",
"description": "Construire la section API de base",
"assignee": "Enes"
}'

Réponse attendue :

{
"title": "Préparer le wiki FastAPI",
"description": "Construire la section API de base",
"assignee": "Enes",
"id": 1,
"status": "to_do"
}

Test 2 — lire la tâche créée

curl "http://127.0.0.1:8000/task/?id=1"

Test 3 — mettre à jour le statut

curl -X PATCH "http://127.0.0.1:8000/task/?id=1" -H "Content-Type: application/json" -d '{"status": "completed"}'

Réponse attendue :

{
"title": "Préparer le wiki FastAPI",
"description": "Construire la section API de base",
"assignee": "Enes",
"id": 1,
"status": "completed"
}

Test 4 — supprimer la tâche

curl -X DELETE "http://127.0.0.1:8000/task/?id=1"

Réponse attendue :

{
"message": "Task with ID 1 has been deleted successfully"
}

Test 5 — vérifier le 404

curl "http://127.0.0.1:8000/task/?id=999"

Réponse attendue :

{
"detail": "Task not found"
}

Test 6 — vérifier directement en base

Si tu veux vérifier que les données existent vraiment en base :

psql taskapi_db -c "SELECT id, title, status, assignee FROM task;"
astuce

Même si FastAPI te montre déjà la réponse JSON, vérifier aussi la base aide beaucoup à comprendre que ton API n'est pas juste un affichage : elle écrit réellement dans PostgreSQL.

Ce que ce chapitre améliore par rapport au repo de référence

Ce tutoriel reste fidèle à task-organizer-API, mais il le rend plus praticable pour un débutant.

Voici ce qu'il apporte en plus :

  • un requirements.txt
  • un .gitignore
  • un .env.example
  • une configuration PostgreSQL plus explicite
  • une meilleure gestion des cas 404 sur PATCH et DELETE
  • une vérification guidée plus complète
  • plus d'explications sur le rôle exact de chaque fichier

Correspondance avec le repo de référence

Le lien avec task-organizer-API est volontairement visible :

  • main.py joue le même rôle d'entrée principale
  • config.py centralise la configuration DB
  • api/router.py regroupe les endpoints
  • api/dependencies.py prépare session et service
  • api/schemas/task.py porte les schémas d'entrée/sortie
  • services/task.py porte la logique CRUD
  • database/models.py décrit la structure Task
  • database/session.py gère moteur, session et tables

Donc le tutoriel reste fidèle à la même architecture, même si la version pédagogique est plus guidée et un peu plus robuste.

Pièges et erreurs fréquentes

  • oublier d'activer le virtualenv avant de lancer uvicorn
  • oublier de créer la base PostgreSQL avant de lancer l'API
  • avoir un .env incorrect
  • confondre Task (modèle DB) et TaskCreate / TaskRead (schémas API)
  • mettre toute la logique directement dans les routes
  • oublier commit() ou refresh() après la création / mise à jour
  • oublier que TaskUpdate ne contient ici que le statut
  • penser que main.py doit contenir tout le projet

Bonnes pratiques à retenir

  • garder les routes fines
  • garder la logique métier dans TaskService
  • nommer les schémas selon leur rôle (Create, Read, Update)
  • expliquer chaque couche quand tu apprends une nouvelle architecture
  • faire apparaître le flow complet requête → service → base → réponse
  • préparer un .env.example dès qu'il y a de la configuration
  • versionner seulement ce qui doit l'être

Variantes et évolutions possibles

Cette API de base peut ensuite grandir vers :

  • une authentification utilisateur,
  • un système de login/logout,
  • des relations SQL,
  • une gestion d'erreurs plus riche,
  • des middlewares,
  • des tests automatisés,
  • un packaging Docker.

C'est exactement ce qui ouvrira naturellement le second grand bloc du wiki.

Checklist de validation

Avant de considérer ce socle comme compris, vérifie que tu peux :

  • expliquer où vit la logique métier
  • montrer où FastAPI injecte la session et le service
  • expliquer pourquoi on sépare modèle SQL et schéma API
  • montrer où se fait la connexion PostgreSQL
  • créer, lire, mettre à jour et supprimer une tâche
  • relancer le projet et vérifier les endpoints principaux sans te perdre dans l’arborescence

Résumé final

Dans ce chapitre, tu as construit une API FastAPI de base avec une architecture claire inspirée du repo task-organizer-API.

Tu as vu :

  • comment organiser le projet,
  • comment brancher PostgreSQL,
  • comment séparer schémas, modèles, services et routes,
  • comment démarrer l'application,
  • comment vérifier le fonctionnement avec des appels HTTP réels.

Si tu retiens une seule chose, retiens celle-ci :

une bonne API de base ne commence pas par une montagne de fonctionnalités ; elle commence par une structure lisible, un flow clair et du code bien réparti.

Pour aller plus loin