FastAPI + Vue 3 : Comment Construire une API REST Moderne avec Python et la Consommer depuis le Frontend

Si vous cherchez le stack idéal pour construire une application web moderne avec Python en backend et un frontend réactif, FastAPI + Vue 3 est l'une des combinaisons les plus solides et productives disponibles aujourd'hui. FastAPI est le framework Python qui a le plus progressé ces trois dernières années, et Vue 3 avec la Composition API a définitivement changé la façon dont nous écrivons des interfaces réactives.

Dans ce tutoriel, nous construisons ensemble une application réelle de bout en bout : une API de gestion de tâches avec authentification JWT, et un frontend en Vue 3 qui la consomme. Vous obtiendrez un stack fonctionnel, avec des bonnes pratiques, prêt à passer à l'échelle.

Ce que vous allez apprendre :

  • Créer une API REST complète avec FastAPI, Pydantic v2 et SQLAlchemy
  • Implémenter l'authentification JWT avec refresh tokens
  • Configurer CORS correctement pour que Vue puisse consommer l'API
  • Construire le frontend avec Vue 3, Composition API, TypeScript et Pinia
  • Gérer l'authentification et les routes protégées côté client
  • Dockeriser les deux services avec docker-compose
  • 📝 Mise à jour — Mai 2026 : Suite aux retours de la communauté sur Reddit, deux problèmes ont été corrigés dans la version originale : (1) utilisation incohérente des status codes littéraux vs status.HTTP_*, et (2) commits manuels dans les routers au lieu d'un context manager centralisé dans get_db(). Les deux corrections sont appliquées dans le code actuel. Merci à ceux qui ont signalé les erreurs.

    Référentiels :

    > - Template officiel du créateur de FastAPI (avec React, mais l'architecture backend est identique) : github.com/fastapi/full-stack-fastapi-template

    > - FastAPI + Vue3 + Naive UI (2,1k ⭐ — stack très similaire à celui de cet article) : github.com/topics/fastapi?l=vue

    > - Vue3-FastAPI-WebApp-template (inclut Pinia + Axios + WebSockets) : github.com/Tomansion/Vue3-FastAPI-WebApp-template


    Stack technique

    CoucheTechnologieVersion
    BackendPython + FastAPI3.12 / 0.111+
    ORMSQLAlchemy + Alembic2.x
    ValidationPydantic v22.x
    Authentificationpython-jose + passlibJWT
    Base de donnéesPostgreSQL16
    FrontendVue 3 + TypeScript3.4+
    État globalPinia2.x
    Client HTTPAxios1.x
    Outil de buildVite5.x
    ConteneursDocker + Compose26+

    Partie 1 : Le Backend avec FastAPI

    Structure du projet

    Avant d'écrire une ligne de code, la structure compte. Un projet FastAPI bien organisé ressemble à ceci :

    backend/
    ├── app/
    │   ├── __init__.py
    │   ├── main.py              # Point d'entrée
    │   ├── config.py            # Settings avec Pydantic
    │   ├── database.py          # Connexion à PostgreSQL
    │   ├── models/              # Modèles SQLAlchemy
    │   │   ├── __init__.py
    │   │   ├── user.py
    │   │   └── task.py
    │   ├── schemas/             # Schemas Pydantic (requête/réponse)
    │   │   ├── __init__.py
    │   │   ├── user.py
    │   │   └── task.py
    │   ├── routers/             # Endpoints organisés par domaine
    │   │   ├── __init__.py
    │   │   ├── auth.py
    │   │   └── tasks.py
    │   ├── services/            # Logique métier
    │   │   ├── auth.py
    │   │   └── tasks.py
    │   └── dependencies.py      # Dépendances réutilisables
    ├── alembic/                 # Migrations de base de données
    ├── tests/
    ├── .env
    ├── requirements.txt
    └── Dockerfile

    Cette séparation en couches — modèles, schemas, routers, services — maintient le code maintenable quand le projet grandit. Les routers connaissent HTTP ; les services connaissent la logique métier ; les modèles connaissent la base de données. Chaque couche a une seule responsabilité.

    Installation des dépendances

    # Créer un environnement virtuel
    python -m venv venv
    source venv/bin/activate  # Windows : venv\Scripts\activate
    
    # Installer les dépendances
    pip install fastapi uvicorn[standard] sqlalchemy alembic \
      psycopg2-binary pydantic-settings python-jose[cryptography] \
      passlib[bcrypt] python-multipart

    requirements.txt :

    fastapi==0.111.0
    uvicorn[standard]==0.29.0
    sqlalchemy==2.0.30
    alembic==1.13.1
    psycopg2-binary==2.9.9
    pydantic-settings==2.2.1
    python-jose[cryptography]==3.3.0
    passlib[bcrypt]==1.7.4
    python-multipart==0.0.9

    Configuration avec Pydantic Settings

    Une pratique essentielle : centraliser la configuration en un seul endroit, en lisant depuis les variables d'environnement.

    # app/config.py
    from pydantic_settings import BaseSettings
    from functools import lru_cache
    
    
    class Settings(BaseSettings):
        # Base
        APP_NAME: str = "FastAPI + Vue3 App"
        DEBUG: bool = False
        API_V1_PREFIX: str = "/api/v1"
    
        # Base de données
        DATABASE_URL: str
    
        # JWT
        SECRET_KEY: str
        ALGORITHM: str = "HS256"
        ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
        REFRESH_TOKEN_EXPIRE_DAYS: int = 7
    
        # CORS — liste des origines autorisées
        ALLOWED_ORIGINS: list[str] = ["http://localhost:5173"]
    
        class Config:
            env_file = ".env"
    
    
    @lru_cache()
    def get_settings() -> Settings:
        return Settings()
    
    
    settings = get_settings()

    Le décorateur @lru_cache() garantit que l'objet Settings est créé une seule fois pendant le cycle de vie de l'application — pas à chaque requête.

    .env :

    DATABASE_URL=postgresql://postgres:secret@localhost:5432/fastapi_vue3
    SECRET_KEY=ta-clé-secrète-très-longue-et-aléatoire-ici
    DEBUG=True
    ALLOWED_ORIGINS=["http://localhost:5173","http://localhost:3000"]

    Connexion à la base de données

    # app/database.py
    from sqlalchemy import create_engine
    from sqlalchemy.ext.declarative import declarative_base
    from sqlalchemy.orm import sessionmaker
    from app.config import settings
    
    engine = create_engine(
        settings.DATABASE_URL,
        pool_pre_ping=True,      # Vérifie la connexion avant de l'utiliser
        pool_size=10,            # Connexions dans le pool
        max_overflow=20,
    )
    
    SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
    Base = declarative_base()
    
    
    def get_db():
        """
        Dependency qui fournit une session de base de données par requête.
        Effectue un commit automatique si le bloc se termine sans exception ;
        rollback automatique si quelque chose échoue. Les routers ne touchent
        jamais à commit() ni rollback() directement.
        """
        db = SessionLocal()
        try:
            yield db
            db.commit()
        except Exception:
            db.rollback()
            raise
        finally:
            db.close()

    Pourquoi un context manager et pas des commits manuels ?

    Quand le router appelle db.commit() manuellement, toute exception qui survient après une écriture mais avant le commit laisse la base de données dans un état incohérent sans effectuer de rollback. Le context manager dans get_db() centralise cette responsabilité : si la requête se termine bien, il commit ; si une exception est levée, il rollback automatiquement. Les routers restent propres — ils travaillent uniquement avec des objets, jamais avec la gestion de la transaction.

    Modèles SQLAlchemy

    # app/models/user.py
    from sqlalchemy import Column, Integer, String, Boolean, DateTime
    from sqlalchemy.sql import func
    from sqlalchemy.orm import relationship
    from app.database import Base
    
    
    class User(Base):
        __tablename__ = "users"
    
        id = Column(Integer, primary_key=True, index=True)
        email = Column(String, unique=True, index=True, nullable=False)
        username = Column(String, unique=True, index=True, nullable=False)
        hashed_password = Column(String, nullable=False)
        is_active = Column(Boolean, default=True)
        created_at = Column(DateTime(timezone=True), server_default=func.now())
        updated_at = Column(DateTime(timezone=True), onupdate=func.now())
    
        tasks = relationship("Task", back_populates="owner", cascade="all, delete-orphan")
    # app/models/task.py
    from sqlalchemy import Column, Integer, String, Boolean, ForeignKey, DateTime, Enum
    from sqlalchemy.sql import func
    from sqlalchemy.orm import relationship
    from app.database import Base
    import enum
    
    
    class TaskStatus(str, enum.Enum):
        PENDING = "pending"
        IN_PROGRESS = "in_progress"
        COMPLETED = "completed"
    
    
    class Task(Base):
        __tablename__ = "tasks"
    
        id = Column(Integer, primary_key=True, index=True)
        title = Column(String(255), nullable=False)
        description = Column(String, nullable=True)
        status = Column(Enum(TaskStatus), default=TaskStatus.PENDING)
        is_completed = Column(Boolean, default=False)
        owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
        created_at = Column(DateTime(timezone=True), server_default=func.now())
        updated_at = Column(DateTime(timezone=True), onupdate=func.now())
    
        owner = relationship("User", back_populates="tasks")

    Schemas Pydantic v2

    Les schemas sont le contrat entre le client et l'API. Ils définissent ce qui est reçu et ce qui est renvoyé — séparés des modèles de base de données.

    # app/schemas/user.py
    from pydantic import BaseModel, EmailStr, Field
    from datetime import datetime
    
    
    class UserCreate(BaseModel):
        email: EmailStr
        username: str = Field(min_length=3, max_length=50)
        password: str = Field(min_length=8)
    
    
    class UserResponse(BaseModel):
        id: int
        email: EmailStr
        username: str
        is_active: bool
        created_at: datetime
    
        model_config = {"from_attributes": True}  # Équivalent à orm_mode en v1
    
    
    class Token(BaseModel):
        access_token: str
        refresh_token: str
        token_type: str = "bearer"
    
    
    class TokenData(BaseModel):
        user_id: int | None = None
    # app/schemas/task.py
    from pydantic import BaseModel, Field
    from datetime import datetime
    from app.models.task import TaskStatus
    
    
    class TaskCreate(BaseModel):
        title: str = Field(min_length=1, max_length=255)
        description: str | None = None
        status: TaskStatus = TaskStatus.PENDING
    
    
    class TaskUpdate(BaseModel):
        title: str | None = Field(default=None, min_length=1, max_length=255)
        description: str | None = None
        status: TaskStatus | None = None
        is_completed: bool | None = None
    
    
    class TaskResponse(BaseModel):
        id: int
        title: str
        description: str | None
        status: TaskStatus
        is_completed: bool
        owner_id: int
        created_at: datetime
        updated_at: datetime | None
    
        model_config = {"from_attributes": True}

    Service d'authentification JWT

    # app/services/auth.py
    from datetime import datetime, timedelta, timezone
    from jose import JWTError, jwt
    from passlib.context import CryptContext
    from sqlalchemy.orm import Session
    from app.config import settings
    from app.models.user import User
    from app.schemas.user import UserCreate, TokenData
    
    pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
    
    
    def hash_password(password: str) -> str:
        return pwd_context.hash(password)
    
    
    def verify_password(plain_password: str, hashed_password: str) -> bool:
        return pwd_context.verify(plain_password, hashed_password)
    
    
    def create_access_token(data: dict) -> str:
        to_encode = data.copy()
        expire = datetime.now(timezone.utc) + timedelta(
            minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
        )
        to_encode.update({"exp": expire, "type": "access"})
        return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
    
    
    def create_refresh_token(data: dict) -> str:
        to_encode = data.copy()
        expire = datetime.now(timezone.utc) + timedelta(
            days=settings.REFRESH_TOKEN_EXPIRE_DAYS
        )
        to_encode.update({"exp": expire, "type": "refresh"})
        return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
    
    
    def decode_token(token: str) -> TokenData:
        try:
            payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
            user_id: int = payload.get("sub")
            if user_id is None:
                raise ValueError("Token invalide")
            return TokenData(user_id=int(user_id))
        except JWTError:
            raise ValueError("Token invalide ou expiré")
    
    
    def get_user_by_email(db: Session, email: str) -> User | None:
        return db.query(User).filter(User.email == email).first()
    
    
    def create_user(db: Session, user_data: UserCreate) -> User:
        db_user = User(
            email=user_data.email,
            username=user_data.username,
            hashed_password=hash_password(user_data.password),
        )
        db.add(db_user)
        db.flush()    # Persiste dans la transaction active ; le commit est géré par get_db()
        db.refresh(db_user)
        return db_user

    Dépendances réutilisables

    # app/dependencies.py
    from fastapi import Depends, HTTPException, status
    from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
    from sqlalchemy.orm import Session
    from app.database import get_db
    from app.services.auth import decode_token
    from app.models.user import User
    
    security = HTTPBearer()
    
    
    def get_current_user(
        credentials: HTTPAuthorizationCredentials = Depends(security),
        db: Session = Depends(get_db),
    ) -> User:
        token = credentials.credentials
        try:
            token_data = decode_token(token)
        except ValueError:
            raise HTTPException(
                status_code=status.HTTP_401_UNAUTHORIZED,
                detail="Token invalide ou expiré",
                headers={"WWW-Authenticate": "Bearer"},
            )
        user = db.query(User).filter(User.id == token_data.user_id).first()
        if not user or not user.is_active:
            raise HTTPException(
                status_code=status.HTTP_404_NOT_FOUND,
                detail="Utilisateur non trouvé",
            )
        return user

    Routers

    # app/routers/auth.py
    from fastapi import APIRouter, Depends, HTTPException, status
    from fastapi.security import OAuth2PasswordRequestForm
    from sqlalchemy.orm import Session
    from app.database import get_db
    from app.schemas.user import UserCreate, UserResponse, Token
    from app.services.auth import (
        get_user_by_email, create_user, verify_password,
        create_access_token, create_refresh_token
    )
    
    router = APIRouter(prefix="/auth", tags=["Authentification"])
    
    
    @router.post("/register", response_model=UserResponse, status_code=201)
    def register(user_data: UserCreate, db: Session = Depends(get_db)):
        if get_user_by_email(db, user_data.email):
            raise HTTPException(
                status_code=status.HTTP_400_BAD_REQUEST,
                detail="Cet email est déjà enregistré",
            )
        return create_user(db, user_data)
    
    
    @router.post("/login", response_model=Token)
    def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
        user = get_user_by_email(db, form_data.username)
        if not user or not verify_password(form_data.password, user.hashed_password):
            raise HTTPException(
                status_code=status.HTTP_401_UNAUTHORIZED,
                detail="Identifiants incorrects",
            )
        return Token(
            access_token=create_access_token({"sub": str(user.id)}),
            refresh_token=create_refresh_token({"sub": str(user.id)}),
        )
    # app/routers/tasks.py
    from fastapi import APIRouter, Depends, HTTPException, status
    from sqlalchemy.orm import Session
    from app.database import get_db
    from app.dependencies import get_current_user
    from app.models.user import User
    from app.models.task import Task
    from app.schemas.task import TaskCreate, TaskUpdate, TaskResponse
    
    router = APIRouter(prefix="/tasks", tags=["Tâches"])
    
    
    @router.get("/", response_model=list[TaskResponse])
    def get_tasks(
        skip: int = 0,
        limit: int = 20,
        db: Session = Depends(get_db),
        current_user: User = Depends(get_current_user),
    ):
        return (
            db.query(Task)
            .filter(Task.owner_id == current_user.id)
            .offset(skip)
            .limit(limit)
            .all()
        )
    
    
    @router.post("/", response_model=TaskResponse, status_code=status.HTTP_201_CREATED)
    def create_task(
        task_data: TaskCreate,
        db: Session = Depends(get_db),
        current_user: User = Depends(get_current_user),
    ):
        task = Task(**task_data.model_dump(), owner_id=current_user.id)
        db.add(task)
        db.flush()   # Persiste dans la transaction sans commit — get_db() commit à la fermeture
        db.refresh(task)
        return task
    
    
    @router.put("/{task_id}", response_model=TaskResponse)
    def update_task(
        task_id: int,
        task_data: TaskUpdate,
        db: Session = Depends(get_db),
        current_user: User = Depends(get_current_user),
    ):
        task = db.query(Task).filter(Task.id == task_id, Task.owner_id == current_user.id).first()
        if not task:
            raise HTTPException(
                status_code=status.HTTP_404_NOT_FOUND,
                detail="Tâche non trouvée",
            )
        for field, value in task_data.model_dump(exclude_unset=True).items():
            setattr(task, field, value)
        db.flush()
        db.refresh(task)
        return task
    
    
    @router.delete("/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
    def delete_task(
        task_id: int,
        db: Session = Depends(get_db),
        current_user: User = Depends(get_current_user),
    ):
        task = db.query(Task).filter(Task.id == task_id, Task.owner_id == current_user.id).first()
        if not task:
            raise HTTPException(
                status_code=status.HTTP_404_NOT_FOUND,
                detail="Tâche non trouvée",
            )
        db.delete(task)

    Point d'entrée — main.py

    # app/main.py
    from fastapi import FastAPI
    from fastapi.middleware.cors import CORSMiddleware
    from app.config import settings
    from app.routers import auth, tasks
    from app.database import Base, engine
    
    # Créer les tables (en production, utilise Alembic)
    Base.metadata.create_all(bind=engine)
    
    app = FastAPI(
        title=settings.APP_NAME,
        docs_url="/api/docs",
        redoc_url="/api/redoc",
        openapi_url="/api/openapi.json",
    )
    
    # CORS — critique pour que Vue puisse communiquer
    app.add_middleware(
        CORSMiddleware,
        allow_origins=settings.ALLOWED_ORIGINS,
        allow_credentials=True,
        allow_methods=["*"],
        allow_headers=["*"],
    )
    
    # Enregistrer les routers
    app.include_router(auth.router, prefix=settings.API_V1_PREFIX)
    app.include_router(tasks.router, prefix=settings.API_V1_PREFIX)
    
    
    @app.get("/health")
    def health_check():
        return {"status": "ok", "app": settings.APP_NAME}

    Le backend est maintenant complet. Lance-le avec :

    uvicorn app.main:app --reload --port 8000

    La documentation interactive sera disponible à http://localhost:8000/api/docs — l'un des avantages les plus productifs de FastAPI.


    Partie 2 : Le Frontend avec Vue 3

    Créer le projet avec Vite

    npm create vue@latest frontend
    
    # Sélectionner :
    # ✅ TypeScript
    # ✅ Vue Router
    # ✅ Pinia
    # ✅ ESLint + Prettier
    
    cd frontend
    npm install
    npm install axios

    Structure du frontend

    frontend/
    ├── src/
    │   ├── api/                 # Couche de communication avec l'API
    │   │   ├── client.ts        # Instance Axios configurée
    │   │   ├── auth.ts
    │   │   └── tasks.ts
    │   ├── stores/              # État global avec Pinia
    │   │   ├── auth.store.ts
    │   │   └── tasks.store.ts
    │   ├── composables/         # Logique réutilisable
    │   │   └── useNotification.ts
    │   ├── views/               # Pages
    │   │   ├── LoginView.vue
    │   │   ├── RegisterView.vue
    │   │   └── TasksView.vue
    │   ├── components/
    │   │   ├── TaskCard.vue
    │   │   └── TaskForm.vue
    │   ├── router/
    │   │   └── index.ts
    │   ├── types/               # Interfaces TypeScript
    │   │   └── index.ts
    │   └── App.vue

    Types TypeScript

    // src/types/index.ts
    export interface User {
      id: number
      email: string
      username: string
      is_active: boolean
      created_at: string
    }
    
    export interface Token {
      access_token: string
      refresh_token: string
      token_type: string
    }
    
    export type TaskStatus = 'pending' | 'in_progress' | 'completed'
    
    export interface Task {
      id: number
      title: string
      description: string | null
      status: TaskStatus
      is_completed: boolean
      owner_id: number
      created_at: string
      updated_at: string | null
    }
    
    export interface TaskCreate {
      title: string
      description?: string
      status?: TaskStatus
    }
    
    export interface TaskUpdate {
      title?: string
      description?: string
      status?: TaskStatus
      is_completed?: boolean
    }

    Client Axios avec intercepteurs

    C'est la pièce la plus importante du frontend — un client Axios qui attache automatiquement le token à chaque requête et gère le renouvellement lorsqu'il expire.

    // src/api/client.ts
    import axios from 'axios'
    
    const apiClient = axios.create({
      baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api/v1',
      headers: {
        'Content-Type': 'application/json',
      },
    })
    
    // Intercepteur de requête — attache le token automatiquement
    apiClient.interceptors.request.use(
      (config) => {
        const token = localStorage.getItem('access_token')
        if (token) {
          config.headers.Authorization = `Bearer ${token}`
        }
        return config
      },
      (error) => Promise.reject(error)
    )
    
    // Intercepteur de réponse — gère le token expiré (401)
    apiClient.interceptors.response.use(
      (response) => response,
      async (error) => {
        const originalRequest = error.config
    
        if (error.response?.status === 401 && !originalRequest._retry) {
          originalRequest._retry = true
    
          try {
            const refreshToken = localStorage.getItem('refresh_token')
            const response = await axios.post(
              `${import.meta.env.VITE_API_BASE_URL}/auth/refresh`,
              { refresh_token: refreshToken }
            )
            const { access_token } = response.data
            localStorage.setItem('access_token', access_token)
            originalRequest.headers.Authorization = `Bearer ${access_token}`
            return apiClient(originalRequest)
          } catch {
            // Refresh échoué — nettoyer la session
            localStorage.removeItem('access_token')
            localStorage.removeItem('refresh_token')
            window.location.href = '/login'
          }
        }
    
        return Promise.reject(error)
      }
    )
    
    export default apiClient

    Couche API

    // src/api/auth.ts
    import apiClient from './client'
    import type { Token, User } from '@/types'
    
    export const authApi = {
      async register(email: string, username: string, password: string): Promise<User> {
        const { data } = await apiClient.post<User>('/auth/register', {
          email,
          username,
          password,
        })
        return data
      },
    
      async login(email: string, password: string): Promise<Token> {
        // FastAPI attend du form-data pour OAuth2
        const formData = new URLSearchParams()
        formData.append('username', email)
        formData.append('password', password)
    
        const { data } = await apiClient.post<Token>('/auth/login', formData, {
          headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
        })
        return data
      },
    }
    // src/api/tasks.ts
    import apiClient from './client'
    import type { Task, TaskCreate, TaskUpdate } from '@/types'
    
    export const tasksApi = {
      async getAll(skip = 0, limit = 20): Promise<Task[]> {
        const { data } = await apiClient.get<Task[]>('/tasks', {
          params: { skip, limit },
        })
        return data
      },
    
      async create(task: TaskCreate): Promise<Task> {
        const { data } = await apiClient.post<Task>('/tasks', task)
        return data
      },
    
      async update(id: number, task: TaskUpdate): Promise<Task> {
        const { data } = await apiClient.put<Task>(`/tasks/${id}`, task)
        return data
      },
    
      async remove(id: number): Promise<void> {
        await apiClient.delete(`/tasks/${id}`)
      },
    }

    Store d'authentification avec Pinia

    // src/stores/auth.store.ts
    import { defineStore } from 'pinia'
    import { ref, computed } from 'vue'
    import { authApi } from '@/api/auth'
    import type { User } from '@/types'
    
    export const useAuthStore = defineStore('auth', () => {
      const user = ref<User | null>(null)
      const isLoading = ref(false)
      const error = ref<string | null>(null)
    
      const isAuthenticated = computed(() => {
        return !!localStorage.getItem('access_token')
      })
    
      async function login(email: string, password: string) {
        isLoading.value = true
        error.value = null
        try {
          const tokens = await authApi.login(email, password)
          localStorage.setItem('access_token', tokens.access_token)
          localStorage.setItem('refresh_token', tokens.refresh_token)
        } catch (err: any) {
          error.value = err.response?.data?.detail || 'Erreur de connexion'
          throw err
        } finally {
          isLoading.value = false
        }
      }
    
      async function register(email: string, username: string, password: string) {
        isLoading.value = true
        error.value = null
        try {
          user.value = await authApi.register(email, username, password)
        } catch (err: any) {
          error.value = err.response?.data?.detail || "Erreur d'inscription"
          throw err
        } finally {
          isLoading.value = false
        }
      }
    
      function logout() {
        localStorage.removeItem('access_token')
        localStorage.removeItem('refresh_token')
        user.value = null
      }
    
      return { user, isLoading, error, isAuthenticated, login, register, logout }
    })

    Store des tâches

    // src/stores/tasks.store.ts
    import { defineStore } from 'pinia'
    import { ref } from 'vue'
    import { tasksApi } from '@/api/tasks'
    import type { Task, TaskCreate, TaskUpdate } from '@/types'
    
    export const useTasksStore = defineStore('tasks', () => {
      const tasks = ref<Task[]>([])
      const isLoading = ref(false)
      const error = ref<string | null>(null)
    
      async function fetchTasks() {
        isLoading.value = true
        try {
          tasks.value = await tasksApi.getAll()
        } catch (err: any) {
          error.value = 'Impossible de charger les tâches'
        } finally {
          isLoading.value = false
        }
      }
    
      async function addTask(data: TaskCreate) {
        const task = await tasksApi.create(data)
        tasks.value.unshift(task)
        return task
      }
    
      async function updateTask(id: number, data: TaskUpdate) {
        const updated = await tasksApi.update(id, data)
        const index = tasks.value.findIndex((t) => t.id === id)
        if (index !== -1) tasks.value[index] = updated
        return updated
      }
    
      async function removeTask(id: number) {
        await tasksApi.remove(id)
        tasks.value = tasks.value.filter((t) => t.id !== id)
      }
    
      return { tasks, isLoading, error, fetchTasks, addTask, updateTask, removeTask }
    })

    Vue principale des tâches

    <!-- src/views/TasksView.vue -->
    <script setup lang="ts">
    import { onMounted, ref } from 'vue'
    import { useTasksStore } from '@/stores/tasks.store'
    import { useAuthStore } from '@/stores/auth.store'
    import { useRouter } from 'vue-router'
    import TaskCard from '@/components/TaskCard.vue'
    import TaskForm from '@/components/TaskForm.vue'
    import type { TaskCreate } from '@/types'
    
    const tasksStore = useTasksStore()
    const authStore = useAuthStore()
    const router = useRouter()
    const showForm = ref(false)
    
    onMounted(() => tasksStore.fetchTasks())
    
    async function handleCreate(data: TaskCreate) {
      await tasksStore.addTask(data)
      showForm.value = false
    }
    
    function handleLogout() {
      authStore.logout()
      router.push('/login')
    }
    </script>
    
    <template>
      <div class="tasks-container">
        <header class="tasks-header">
          <h1>Mes Tâches</h1>
          <div class="header-actions">
            <button class="btn-primary" @click="showForm = !showForm">
              {{ showForm ? 'Annuler' : '+ Nouvelle tâche' }}
            </button>
            <button class="btn-ghost" @click="handleLogout">Déconnexion</button>
          </div>
        </header>
    
        <TaskForm v-if="showForm" @submit="handleCreate" />
    
        <div v-if="tasksStore.isLoading" class="loading">Chargement des tâches...</div>
    
        <div v-else-if="tasksStore.tasks.length === 0" class="empty-state">
          <p>Vous n'avez pas encore de tâches. Créez la première !</p>
        </div>
    
        <div v-else class="tasks-grid">
          <TaskCard
            v-for="task in tasksStore.tasks"
            :key="task.id"
            :task="task"
            @update="tasksStore.updateTask(task.id, $event)"
            @delete="tasksStore.removeTask(task.id)"
          />
        </div>
      </div>
    </template>

    Router avec guards de navigation

    // src/router/index.ts
    import { createRouter, createWebHistory } from 'vue-router'
    
    const router = createRouter({
      history: createWebHistory(),
      routes: [
        {
          path: '/login',
          name: 'login',
          component: () => import('@/views/LoginView.vue'),
          meta: { requiresGuest: true },
        },
        {
          path: '/register',
          name: 'register',
          component: () => import('@/views/RegisterView.vue'),
          meta: { requiresGuest: true },
        },
        {
          path: '/tasks',
          name: 'tasks',
          component: () => import('@/views/TasksView.vue'),
          meta: { requiresAuth: true },
        },
        {
          path: '/',
          redirect: '/tasks',
        },
      ],
    })
    
    // Guard de navigation global
    router.beforeEach((to) => {
      const isAuthenticated = !!localStorage.getItem('access_token')
    
      if (to.meta.requiresAuth && !isAuthenticated) {
        return { name: 'login' }
      }
    
      if (to.meta.requiresGuest && isAuthenticated) {
        return { name: 'tasks' }
      }
    })
    
    export default router

    Partie 3 : Docker Compose — Tout assembler

    # docker-compose.yml
    version: '3.8'
    
    services:
      db:
        image: postgres:16-alpine
        environment:
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: secret
          POSTGRES_DB: fastapi_vue3
        volumes:
          - postgres_data:/var/lib/postgresql/data
        healthcheck:
          test: ["CMD-SHELL", "pg_isready -U postgres"]
          interval: 5s
          timeout: 5s
          retries: 5
    
      backend:
        build: ./backend
        ports:
          - "8000:8000"
        environment:
          DATABASE_URL: postgresql://postgres:secret@db:5432/fastapi_vue3
          SECRET_KEY: change-cette-clé-en-production
          DEBUG: "false"
          ALLOWED_ORIGINS: '["http://localhost:5173"]'
        depends_on:
          db:
            condition: service_healthy
        command: uvicorn app.main:app --host 0.0.0.0 --port 8000
    
      frontend:
        build: ./frontend
        ports:
          - "5173:80"
        environment:
          VITE_API_BASE_URL: http://localhost:8000/api/v1
        depends_on:
          - backend
    
    volumes:
      postgres_data:
    # backend/Dockerfile
    FROM python:3.12-slim
    
    WORKDIR /app
    
    # Installer les dépendances système
    RUN apt-get update && apt-get install -y \
        libpq-dev gcc \
        && rm -rf /var/lib/apt/lists/*
    
    COPY requirements.txt .
    RUN pip install --no-cache-dir -r requirements.txt
    
    COPY . .
    
    EXPOSE 8000
    CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
    # frontend/Dockerfile
    FROM node:20-alpine AS builder
    WORKDIR /app
    COPY package*.json .
    RUN npm ci
    COPY . .
    RUN npm run build
    
    FROM nginx:alpine
    COPY --from=builder /app/dist /usr/share/nginx/html
    COPY nginx.conf /etc/nginx/conf.d/default.conf
    EXPOSE 80

    Tout lancer avec une seule commande :

    docker-compose up --build

    Bonnes pratiques et considérations finales

    Dans le backend :

  • Ne stocke jamais les mots de passe en texte clair — passlib avec bcrypt est le standard
  • Valide toujours que la ressource appartient à l'utilisateur authentifié avant de la modifier
  • Utilise Alembic pour les migrations plutôt que create_all() directement
  • Configure du rate limiting en production avec slowapi
  • *Utilise toujours status.HTTP_ au lieu de nombres littéraux** — status.HTTP_404_NOT_FOUND est lisible, recherchable et refactorisable ; 404 est un nombre magique
  • Centralise commit/rollback dans get_db() — les routers ne doivent jamais appeler db.commit() directement. Utilise db.flush() quand tu as besoin de l'ID avant la fermeture de la transaction
  • Dans le frontend :

  • Les tokens dans localStorage suffisent pour de nombreux cas, mais envisage les httpOnly cookies pour plus de sécurité dans les applications critiques
  • Centralise la gestion des erreurs dans les intercepteurs Axios — ne duplique pas la logique d'erreur dans chaque composable
  • Utilise defineStore avec la syntaxe setup de Pinia — c'est plus flexible et TypeScript-friendly
  • Pour la production :

  • Remplace SECRET_KEY par une valeur générée avec openssl rand -hex 32
  • Configure HTTPS — sans TLS, les JWT en transit n'ont aucune valeur
  • Envisage Redis pour la mise sur liste noire des tokens lors de la déconnexion

  • Conclusion

    FastAPI et Vue 3 forment un stack qui équilibre productivité et performance d'une manière difficile à surpasser avec Python. FastAPI te donne une validation automatique, une documentation générée et des performances proches de Node.js. Vue 3 avec la Composition API et Pinia t'offre une réactivité granulaire et un code hautement maintenable.

    Le modèle que nous avons vu ici — séparation en couches, contrats avec TypeScript, intercepteurs pour l'auth, stores Pinia — passe à l'échelle sans problème d'un projet personnel à une application SaaS avec des milliers d'utilisateurs.

    Si tu veux voir ce même modèle appliqué à un contexte de sécurité et de détection de vulnérabilités, tu peux consulter DevGuardian AI, où j'utilise exactement ce stack en production.


    Ressources supplémentaires

  • FastAPI docs : fastapi.tiangolo.com
  • Vue 3 docs : vuejs.org
  • Pinia docs : pinia.vuejs.org
  • Pydantic v2 : docs.pydantic.dev
  • SQLAlchemy 2.0 : docs.sqlalchemy.org

  • Tu as des questions sur ce stack ou tu construis quelque chose de similaire ? Tu peux me trouver sur GitHub ou m'écrire depuis mon portfolio.