FastAPI + Vue 3 : Comment Construire une API REST Moderne avec Python et la Consommer depuis le Frontend
Tutoriel complet de bout en bout : construisez une API REST avec FastAPI et Python, puis consommez-la depuis Vue 3 avec Composition API et TypeScript. Authentification JWT, CORS, déploiement Docker inclus.

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 :
📝 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é dansget_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
| Couche | Technologie | Version |
|---|---|---|
| Backend | Python + FastAPI | 3.12 / 0.111+ |
| ORM | SQLAlchemy + Alembic | 2.x |
| Validation | Pydantic v2 | 2.x |
| Authentification | python-jose + passlib | JWT |
| Base de données | PostgreSQL | 16 |
| Frontend | Vue 3 + TypeScript | 3.4+ |
| État global | Pinia | 2.x |
| Client HTTP | Axios | 1.x |
| Outil de build | Vite | 5.x |
| Conteneurs | Docker + Compose | 26+ |
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 dansget_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 :
passlib avec bcrypt est le standardcreate_all() directementslowapistatus.HTTP_ au lieu de nombres littéraux** — status.HTTP_404_NOT_FOUND est lisible, recherchable et refactorisable ; 404 est un nombre magiqueget_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 transactionDans le frontend :
localStorage suffisent pour de nombreux cas, mais envisage les httpOnly cookies pour plus de sécurité dans les applications critiquesdefineStore avec la syntaxe setup de Pinia — c'est plus flexible et TypeScript-friendlyPour la production :
SECRET_KEY par une valeur générée avec openssl rand -hex 32Conclusion
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
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.