# TP : Filmothèque (API REST) ## Informations générales **Cours** : Python Avancé > REST API > Fast API \ **Objectifs pédagogiques** : - Créer une application backend basée sur FastAPI. - Structurer une application en couches (API/Routeurs, BLL/Services, DAL/Repository). - Définir des schémas de données avec Pydantic pour la validation et la sérialisation. - Interagir avec une base de données en asynchrone grâce à SQLAlchemy. - Mettre en place un système d'injection de dépendances (pour la session BDD). - Gérer les erreurs de manière centralisée avec des gestionnaires d'exceptions personnalisés, en particulier pour DAL et BLL - Tests : pytest - Outils modernes et performants (poetry, PyCharm, FastAPI, SQLAlchemy 2.x) - Bonnes pratiques de l'entreprise --- ## Prérequis ### Connaissances préalables * Connaissances de base en programmation. * Application du modèle en couches (API, BLL, DAL). ### Architecture en couches La pile applicative que nous allons construire suit une séparation claire des responsabilités : ```mermaid graph TD A[API / Routeurs] -- "Appelle le" --> B[Services / BLL]; B -- "Appelle le" --> C[Repository / DAL]; C -- "Utilise l'" --> D[ORM / SQLAlchemy]; D -- "Dialogue avec la" --> E[Base de donnees]; ``` * **API / Routeurs** : la porte d'entrée HTTP. * **Services / BLL** : la logique métier (Business Logic Layer). * **Repository / DAL** : l'abstraction de la persistance (Data Access Layer). * **ORM / SQLAlchemy** : les modèles et la session qui parlent à la BDD. ### Installation et configuration de l’environnement - Installation des dépendances : faire `poetry install` (fichier lock déja présent) - À ce stade, si vos imports ne sont pas reconnus dans l'IDE PyCharm à l'ouverture d'un fichier (par exemple `app/main.py`), marquer le répertoire `src` comme `Sources Root` (Clic droit sur répertoire `src`, puis `Mark Directory as ... > Sources Root`) ou redémarrer l'IDE (File > Invalidate Caches... > Just restart à gauche). - Par défaut, vous travaillerez avec une base de données locale `sqlite`. C'est fortement recommandé pour gagner du temps ! ### Lancement du projet Une fois le projet cloné, se positionner dans le répertoire racine du projet (là où se trouve le fichier `pyproject.toml`). Faire clic droit puis `Settings` sur l'onglet du terminal local Pycharm, puis définir la variable d'environnement `BACKEND_CORS_ORIGINS`, en prévision de l'accès à ce micro service depuis un front Angular : - `BACKEND_CORS_ORIGINS=["http://localhost:4200"]`) Ouvrir un (nouvel onglet) terminal local Pycharm, puis lancer le projet via : - `cd src` - `uvicorn app.main:app --host 0.0.0.0 --port 8000` Les URLs suivantes devront alors être accessibles, selon vos avancées sur le projet : - http://127.0.0.1:8000/ -> retourne "welcome to this fantastic API" - http://127.0.0.1:8000/api/v1/movies/ -> retourne une liste de films, éventuellement vide Notez qu'un pré-remplissage de la base est possible via le fichier `app/db/seeding.py`. Si vous souhaitez réinitialiser la base, vous pouvez supprimer le fichier `local_dev.db` à la racine de votre projet, en prenant soin de décocher l'option `safe delete` (pas de refactor nécessaire à cet effet). --- ## Énoncé ### Modèle logique : schéma UML Vous trouverez ci-dessous pour information le schéma UML de la base de données. ![Modèle logique UML](./resources/tp_fastapi_uml.png) ### Structure du projet La structure du projet `tp_fastapi` est la suivante : ``` tp_fastapi/ src/ ├── app/ │ ├── api/ │ │ ├── __init__.py │ │ ├── deps.py │ │ ├── exception_handlers.py │ │ └── routers/ # la porte d'entrée HTTP │ │ ├── __init__.py │ │ ├── genres.py │ │ ├── movies.py │ │ ├── opinions.py │ │ └── participants.py │ ├── core/ │ │ ├── __init__.py │ │ ├── config.py │ │ └── exceptions.py │ ├── db/ │ │ ├── __init__.py │ │ └── session.py │ ├── models/ # les Business Objects (BO), qui sont également des entités pour l'ORM SQLAlchemy │ │ ├── __init__.py │ │ ├── base_class.py │ │ ├── genre.py │ │ ├── member.py │ │ ├── movie.py │ │ ├── movie_actors.py │ │ ├── opinion.py │ │ ├── participant.py │ │ └── person.py │ ├── repository/ # l'abstraction de la persistance (Data Access Layer) │ │ ├── __init__.py │ │ ├── genre.py │ │ ├── movie.py │ │ ├── opinion.py │ │ └── participant.py │ ├── schemas/ # objets manipulés lors des requêtes/réponses HTTP │ │ ├── __init__.py │ │ ├── genre.py │ │ ├── movie.py │ │ ├── opinion.py │ │ ├── participant.py │ │ └── person.py │ ├── services/ # la logique métier (Business Logic Layer) │ │ ├── __init__.py │ │ ├── genre.py │ │ ├── movie.py │ │ ├── opinion.py │ │ └── participant.py │ ├── __init__.py │ └── main.py ``` ## Étape 1 : les fondations - modèles de données ORM L'Object-Relational Mapping (ORM) est la couche qui traduit nos objets Python en tables de base de données. C'est une partie importante, mais complexe. Pour que vous puissiez vous concentrer sur l'architecture de l'API, **le code de ce répertoire `app/models/` vous est intégralement fourni**. Votre mission est de **créer les fichiers et d'y copier le code ci-dessous**. Prenez le temps de lire et de comprendre les relations définies : * `Person` est une classe de base utilisant l'**héritage** pour définir `Participant` (acteur/réalisateur) et `Member` (utilisateur). * `Movie` a une relation **One-to-Many** avec `Opinion` (un film peut avoir plusieurs avis). * `Movie` a une relation **Many-to-One** avec `Genre` et `Participant` (pour le réalisateur). * `Movie` et `Participant` (pour les acteurs) ont une relation **Many-to-Many** via une table d'association. #### Fichier : `app/models/base_class.py` ```python from sqlalchemy.orm import declarative_base Base = declarative_base() ``` #### Fichier : `app/models/person.py` ```python from sqlalchemy import String from sqlalchemy.orm import Mapped, mapped_column from .base_class import Base class Person(Base): __tablename__ = "persons" id: Mapped[int] = mapped_column(primary_key=True) last_name: Mapped[str] = mapped_column(String(255)) first_name: Mapped[str] = mapped_column(String(255)) # colonne discriminante pour la hiérarchie d'héritage type: Mapped[str] = mapped_column(String(50)) __mapper_args__ = { "polymorphic_identity": "person", "polymorphic_on": "type", } ``` #### Fichier : `app/models/participant.py` ```python from sqlalchemy import ForeignKey from sqlalchemy.orm import Mapped, mapped_column from .person import Person class Participant(Person): __tablename__ = "participants" id: Mapped[int] = mapped_column(ForeignKey("persons.id"), primary_key=True) __mapper_args__ = {"polymorphic_identity": "participant"} ``` #### Fichier : `app/models/genre.py` ```python from sqlalchemy import String from sqlalchemy.orm import Mapped, mapped_column from .base_class import Base class Genre(Base): __tablename__ = "genres" id: Mapped[int] = mapped_column(primary_key=True) label: Mapped[str] = mapped_column(String(255)) ``` #### Fichier : `app/models/movie_actors.py` ```python from sqlalchemy import Column, ForeignKey, Table from .base_class import Base movie_actors_association_table = Table( "movie_actors_association", Base.metadata, Column("movie_id", ForeignKey("movies.id"), primary_key=True), Column("participant_id", ForeignKey("participants.id"), primary_key=True), ) ``` #### Fichier : `app/models/movie.py` ```python from sqlalchemy import Integer, String, ForeignKey, Text from sqlalchemy.orm import Mapped, mapped_column, relationship from typing import List from .base_class import Base from .movie_actors import movie_actors_association_table class Movie(Base): __tablename__ = "movies" id: Mapped[int] = mapped_column(primary_key=True) title: Mapped[str] = mapped_column(String(250), nullable=False) year: Mapped[int] = mapped_column(Integer) duration: Mapped[int] = mapped_column(Integer) synopsis: Mapped[str] = mapped_column(Text) director_id: Mapped[int] = mapped_column(ForeignKey("participants.id")) genre_id: Mapped[int] = mapped_column(ForeignKey("genres.id")) director: Mapped["Participant"] = relationship(foreign_keys=[director_id]) genre: Mapped["Genre"] = relationship() actors: Mapped[List["Participant"]] = relationship(secondary=movie_actors_association_table) opinions: Mapped[List["Opinion"]] = relationship(back_populates="movie", cascade="all, delete-orphan") ``` #### Fichier : `app/models/opinion.py` ```python from sqlalchemy import ( Integer, ForeignKey, Text ) from sqlalchemy.orm import ( Mapped, mapped_column, relationship ) from .base_class import Base class Opinion(Base): __tablename__ = "opinions" id: Mapped[int] = mapped_column(primary_key=True) note: Mapped[int] = mapped_column(Integer) comment: Mapped[str] = mapped_column(Text) member_id: Mapped[int] = mapped_column(ForeignKey("members.id")) movie_id: Mapped[int] = mapped_column(ForeignKey("movies.id")) member: Mapped["Member"] = relationship(back_populates="opinions") movie: Mapped["Movie"] = relationship(back_populates="opinions") ``` #### Fichier : `app/models/member.py` ```python from sqlalchemy import ( String, Boolean, ForeignKey, ) from sqlalchemy.orm import ( Mapped, mapped_column, relationship ) from typing import List from .person import Person class Member(Person): __tablename__ = "members" # La clé primaire est aussi une clé étrangère vers la table parente id: Mapped[int] = mapped_column(ForeignKey("persons.id"), primary_key=True) # Champs spécifiques à Member login: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) password: Mapped[str] = mapped_column(String(255), nullable=False) is_admin: Mapped[bool] = mapped_column(Boolean, default=False) # La relation vers Opinion opinions: Mapped[List["Opinion"]] = relationship(back_populates="member") __mapper_args__ = { "polymorphic_identity": "member", } def __repr__(self) -> str: return f"Member(id={self.id}, login='{self.login}')" ``` #### Fichier : `app/models/__init__.py` Pensez également à **importer toutes les classes dans `app/models/__init__.py`** pour faciliter les imports ailleurs dans le projet : ```python # app/models/__init__.py # Eviter les erreurs liés à l'importation circulaire # Exemple : sqlalchemy.exc.InvalidRequestError: When initializing mapper Mapper[Movie(movies)], expression 'Genre' failed to locate a name ('Genre'). If this is a class name, consider adding this relationship() to the class after both dependent classes have been defined from .base_class import Base from .genre import Genre from .member import Member from .movie import Movie from .opinion import Opinion from .participant import Participant from .person import Person # L'import des associations (ex : personnefilm) n'est en général pas nécessaire ici car elles sont gérées dans les modèles eux-mêmes ``` **Astuce :** lorsque vos fichiers sont prêts, et que vous aurez lancé l'application une première fois, vous pourrez désactiver la drop/création automatique des tables dans `main.py` (mettre en commentaire `await conn.run_sync(Base.metadata.drop_all)`). --- ## Étape 2 : les schémas de données avec Pydantic Les schémas Pydantic définissent la "forme" des données que notre API attend en entrée et renvoie en sortie. Ils assurent une validation robuste et automatique. Votre mission : **Créez les fichiers dans `app/schemas/`** et écrivez les classes Pydantic. Une bonne pratique est de créer : * Une classe `Base` (champs communs). * Une classe `Create` (champs requis pour la création). * Une classe `Read` (pour la lecture, avec les `id` et les relations). **Exemple pour `genre.py` :** ```python from pydantic import BaseModel, ConfigDict class GenreBase(BaseModel): label: str class GenreCreate(GenreBase): pass class GenreRead(GenreBase): id: int model_config = ConfigDict(from_attributes=True) ``` Inspirez-vous de cet exemple et des modèles ORM pour créer les schémas pour `Person`, `Participant`, `Opinion` et `Movie`. **Astuce pour `Movie` :** Le schéma de création `MovieCreate` attendra des IDs (`director_id`, `actors_ids`), tandis que le schéma de lecture `MovieRead` renverra les objets imbriqués (`director: Person`, `actors: List[Person]`). Pour valider votre travail, vous pouvez lancer le test suivant, à la racine du projet : `poetry run pytest tests/enonce/etape2_test_schemas.py`. --- ## Étape 3 : la couche d'accès aux données (DAL / Repository) Dans la couche DAL (Data Access Layer), le Repository est responsable de toutes les requêtes à la base de données. C'est ici que vous écrirez vos requêtes SQLAlchemy. L'objectif est de cacher la complexité de l'ORM au reste de l'application. Votre mission : **Complétez les fonctions dans les fichiers du répertoire `app/repositories/`**. Toutes les fonctions recevront une session `db: AsyncSession` en paramètre. **Exemple pour `repositories\movie.py` :** * **`get_movie(db, movie_id)`** : Doit récupérer un film par son ID. Pensez à utiliser `selectinload` pour charger ses relations (genre, réalisateur, acteurs) de manière efficace et éviter le problème N+1. * **`get_movies(db, skip, limit)`** : Récupère une liste de films paginée. * **`create_movie(db, movie)`** : Crée une nouvelle instance `Movie`, l'ajoute à la session et la commit. **Important** : entourez vos requêtes d'un bloc `try...except SQLAlchemyError` et levez une `DALException` personnalisée en cas d'erreur. Pour valider votre travail, vous pouvez lancer le test suivant, à la racine du projet : `poetry run pytest tests/enonce/etape3_test_repositories.py`. --- ## Étape 4 : la logique métier (BLL / Services) Le Service est le cœur de votre logique. Il orchestre les appels au Repository et applique les règles métier. C'est lui qui décide si une action est valide ou non. Votre mission : **Implémentez la logique dans les fichiers du répertoire `app/services/`**. **Exemple pour `services\movie.py` :** * Dans `create_movie`, avant d'appeler le repository, **ajoutez des vérifications** : 1. Le titre du film ne doit pas être vide. 2. L'année de sortie doit être réaliste (ex: entre 1888 et aujourd'hui + 5 ans). 3. Si une de ces règles n'est pas respectée, levez une `ValidationBLLException`. * Dans `get_movie_by_id`, si le repository ne retourne aucun film, levez une `NotFoundBLLException`. **Principe clé** : Un service ne doit jamais manipuler directement des exceptions HTTP (`HTTPException`). Il utilise des exceptions métier personnalisées (`BLLException`, `NotFoundBLLException`, etc.) pour rester indépendant du framework web. Pour valider votre travail, vous pouvez lancer le test suivant, à la racine du projet : `poetry run pytest tests/enonce/etape4_test_services.py`. --- ## Étape 5 : la couche API (Routers) C'est ici que tout se connecte ! Les routeurs FastAPI définissent les endpoints de votre API, reçoivent les requêtes HTTP, appellent les services appropriés et retournent les réponses. Votre mission : **Créez les endpoints dans les fichiers du répertoire `app/api/routers/`**. * Utilisez les décorateurs de FastAPI (`@router.get`, `@router.post`, etc.). * Utilisez `response_model` pour spécifier le schéma Pydantic de la réponse. * Utilisez `status_code` pour définir le code de statut HTTP approprié (ex: `201 CREATED`). * Injectez la session de base de données en utilisant `db: AsyncSession = Depends(get_db)`. * Appelez la fonction de service correspondante et retournez son résultat. Pour valider votre travail, vous pouvez lancer le test suivant, à la racine du projet : `poetry run pytest tests/enonce/etape5_test_api.py`. --- ## Étape 6 : l'assemblage final et la gestion des erreurs La dernière étape consiste à configurer l'application FastAPI principale, à connecter les routeurs et à mettre en place la gestion centralisée des exceptions. Votre mission : **Complétez le fichier `app/main.py` et observez `app/api/exception_handlers.py`**. 1. Dans `main.py` : * Créez l'instance `FastAPI`, si ce n'est pas déja fait. * Utilisez `app.include_router()` pour ajouter chaque routeur que vous avez créé. * Utilisez `@app.add_exception_handler()` pour lier vos exceptions métier personnalisées (`NotFoundBLLException`, `ValidationBLLException`, `DALException`, `BLLException`) à des fonctions de gestion. 2. Dans `app/api/exception_handlers.py`, observez les points suivants : * Les fonctions `async def` (ex: `not_found_bll_exception_handler`) qui prennent `Request` et votre exception en paramètres. À quoi servent-elles ? * Ces fonctions doivent retourner une `JSONResponse` avec le code de statut HTTP adéquat (`404`, `400`, `500`) et un message d'erreur clair dans le contenu. Pourquoi c'est une bonne pratique avec REST de retourner un code status HTTP approprié ? Est-ce que le code en question sépare les erreurs techniques des erreurs fonctionnelles ? Le schéma ci-dessous illustre comment les exceptions personnalisées sont levées par les couches internes (DAL, BLL) et interceptées par les gestionnaires centraux (handlers) définis dans `main.py` pour produire une réponse HTTP propre. ```mermaid graph TD subgraph "Flux de requete" A[Requete HTTP] --> B[Endpoint API] B -- Appelle --> C(Service BLL) C -- Appelle --> D(Repository DAL) end subgraph "Flux d'exception" D -- Leve --> E{DALException} C -- Leve --> F{NotFoundBLLException} C -- Leve --> G{ValidationBLLException} H[main.py: @app.add_exception_handler] -- Enregistre --> I(handler_dal) H -- Enregistre --> J(handler_not_found) H -- Enregistre --> K(handler_validation) E -.-> I F -.-> J G -.-> K I -- Genere --> L[JSONResponse 500] J -- Genere --> M[JSONResponse 404] K -- Genere --> N[JSONResponse 400] end ``` Une fois terminé, lancez votre application avec `uvicorn app.main:app --reload` (depuis le dossier `src/`) et explorez la documentation interactive sur `http://127.0.0.1:8000/docs`. ----- ## Aller plus loin (bonus) * Vérifier que l'intégralité des tests passent. Se positionner à la racine du projet (avant le src), puis lancer les tests unitaires via : - `poetry run pytest` - `poetry run pytest -vv` (avec davantage d'éléments de debug) * Implémentez-les endpoints `UPDATE` et `DELETE` pour les participants et les films. * Ajoutez des filtres à la route `GET /movies/` (par année, par genre, etc.). * Lancer les tests unitaires suivants : `poetry run pytest tests/api/test_movies_api.py` et `poetry run pytest tests/services/test_movie_service.py` * Écrivez d'autres tests unitaires et d'intégration avec `pytest`. * Mettre en œuvre une véritable base de données MySQL : Si vous souhaitez établir une connexion avec une véritable base de données telle que MySQL, sachez que le package `asyncmy` sera requis (`poetry add asyncmy@^0.2.10`), ainsi qu'au préalable sous Windows, l'installation des redistributables Visual Studio 2022 (via l'outil vsBuildTools dont le binaire et la documentation sont transmis dans votre `00_Install_Pack.zip`). ``` La base doit être créée au préalable (exemple en root) : CREATE DATABASE filmotheque CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; Un utilisateur filmotheque (mdp : filmotheque) doit être créé avec les droits sur cette base. ``` Faire clic droit puis Settings sur l'onglet du terminal local Pycharm, puis définir la variable d'environnement DATABASE_URL, contenant l'URL de connexion MySQL adéquate : `DATABASE_URL=mysql+asyncmy://filmotheque:filmotheque@127.0.0.1:3306/filmotheque` Fermez l'onglet du terminal, puis en ouvrir un autre, afin que les modifications soient effectives