Initial commit

This commit is contained in:
Johan
2025-12-17 09:40:11 +01:00
parent 86195cc72a
commit 8821782eec
48 changed files with 307 additions and 0 deletions

0
src/app/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

0
src/app/core/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

15
src/app/core/config.py Normal file
View File

@@ -0,0 +1,15 @@
from typing import List
from pydantic import AnyHttpUrl
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=True
)
PROJECT_NAME: str = "Recipe Book GraphQL API"
BACKEND_CORS_ORIGINS: List[AnyHttpUrl] = []
settings = Settings()

View File

@@ -0,0 +1,24 @@
class BaseAppException(Exception):
"""Exception de base pour l'application."""
pass
class DALException(BaseAppException):
"""Exception levée pour les erreurs de la couche d'accès aux données (DAL)."""
def __init__(self, message: str, original_exception: Exception = None):
self.message = message
self.original_exception = original_exception
super().__init__(self.message)
class BLLException(BaseAppException):
"""Exception de base pour les erreurs de la couche métier (BLL)."""
pass
class NotFoundBLLException(BLLException):
"""Levée lorsqu'une ressource n'est pas trouvée."""
def __init__(self, resource_name: str, resource_id: int | str):
message = f"{resource_name} avec l'ID '{resource_id}' non trouvé."
super().__init__(message)
class ValidationBLLException(BLLException):
"""Levée pour les erreurs de validation des règles métier."""
pass

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,12 @@
from strawberry.extensions import Extension
from app.core.exceptions import BLLException
class BusinessLogicErrorExtension(Extension):
def on_request_end(self):
for error in self.execution_context.errors:
original_error = error.original_error
if isinstance(original_error, BLLException):
error.message = f"[Business Error] {original_error}"
if not error.extensions:
error.extensions = {}
error.extensions['code'] = original_error.__class__.__name__

View File

View File

@@ -0,0 +1,6 @@
import strawberry
@strawberry.input
class IngredientInput:
name: str
quantity: str

View File

@@ -0,0 +1,9 @@
import strawberry
from typing import List
from .ingredient_input import IngredientInput
@strawberry.input
class AddRecipeInput:
title: str
description: str
ingredients: List[IngredientInput]

View File

@@ -0,0 +1,16 @@
import strawberry
from app.graphql.types.recipe import Recipe
from app.graphql.inputs.recipe_input import AddRecipeInput
from app.graphql.resolvers.recipe_resolvers import resolve_add_recipe
@strawberry.type
class Mutation:
"""
Point d'entrée pour toutes les requêtes d'écriture.
"""
addRecipe: Recipe = strawberry.field(
resolver=resolve_add_recipe,
description="Ajoute une nouvelle recette au livre de cuisine."
)

View File

@@ -0,0 +1,21 @@
import strawberry
from typing import List, Optional
from app.graphql.types.recipe import Recipe
from app.graphql.resolvers.recipe_resolvers import resolve_recipes, resolve_recipe_by_id
@strawberry.type
class Query:
"""
Point d'entrée pour toutes les requêtes de lecture.
"""
recipes: List[Recipe] = strawberry.field(
resolver=resolve_recipes,
description="Récupère la liste de toutes les recettes de cuisine."
)
recipe: Optional[Recipe] = strawberry.field(
resolver=resolve_recipe_by_id,
description="Récupère une recette par son ID."
)

View File

View File

@@ -0,0 +1,30 @@
import strawberry
from typing import List, Optional
from app.graphql.types.recipe import Recipe
from app.graphql.inputs.recipe_input import AddRecipeInput
import app.services.recipe_service as recipe_service
def resolve_recipes() -> List[Recipe]:
"""Resolver pour obtenir la liste de toutes les recettes."""
return recipe_service.get_all_recipes()
def resolve_recipe_by_id(id: strawberry.ID) -> Optional[Recipe]:
"""Resolver pour obtenir une recette par son ID."""
# Strawberry.ID est une chaîne, il faut la convertir en entier
return recipe_service.get_recipe_by_id(recipe_id=int(id))
def resolve_add_recipe(input: AddRecipeInput) -> Recipe:
"""Resolver pour ajouter une nouvelle recette."""
# Conversion des inputs strawberry en dictionnaires simples
ingredients_data = [
{"name": ing.name, "quantity": ing.quantity}
for ing in input.ingredients
]
return recipe_service.add_new_recipe(
title=input.title,
description=input.description,
ingredients=ingredients_data
)

View File

View File

@@ -0,0 +1,6 @@
import strawberry
@strawberry.type
class Ingredient:
name: str
quantity: str

View File

@@ -0,0 +1,10 @@
import strawberry
from typing import List
from .ingredient import Ingredient
@strawberry.type
class Recipe:
id: strawberry.ID
title: str
description: str
ingredients: List[Ingredient]

43
src/app/main.py Normal file
View File

@@ -0,0 +1,43 @@
from fastapi import FastAPI
from app.core.config import settings
from strawberry.fastapi import GraphQLRouter
import strawberry
import uvicorn
from fastapi.middleware.cors import CORSMiddleware
from app.graphql.extensions import BusinessLogicErrorExtension
from app.graphql.mutations import Mutation
from app.graphql.queries import Query
# Crée l'application FastAPI
app = FastAPI(
title="Recipe Book GraphQL API",
description="API GraphQL pour un livre de recettes de cuisine.",
version="1.0.0"
)
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=[str(origin).rstrip('/') for origin in settings.BACKEND_CORS_ORIGINS] if settings.BACKEND_CORS_ORIGINS else ["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Crée le schéma GraphQL
schema = strawberry.Schema(
query=Query,
mutation=Mutation,
extensions=[BusinessLogicErrorExtension]
)
# Crée le routeur GraphQL
graphql_app = GraphQLRouter(schema)
app.include_router(graphql_app, prefix="/graphql")
@app.get("/", tags=["Root"])
def read_root():
return {"message": "Bienvenue sur l'API du livre de recettes. Rendez-vous sur /graphql"}
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8004)

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,5 @@
from pydantic import BaseModel
class Ingredient(BaseModel):
name: str
quantity: str

9
src/app/models/recipe.py Normal file
View File

@@ -0,0 +1,9 @@
from pydantic import BaseModel
from typing import List
from .ingredient import Ingredient
class Recipe(BaseModel):
id: int
title: str
description: str
ingredients: List[Ingredient]

View File

View File

@@ -0,0 +1,61 @@
from typing import List, Optional, Dict, Any
from app.models.recipe import Recipe
# --- Base de données en mémoire ---
_RECIPES_DB = [
{
"id": 1,
"title": "Crêpes simples",
"description": "Une recette facile pour des crêpes délicieuses.",
"ingredients": [
{"name": "Farine", "quantity": "250g"},
{"name": "Oeufs", "quantity": "4"},
{"name": "Lait", "quantity": "500ml"},
{"name": "Sucre", "quantity": "2 cuillères à soupe"}
],
},
{
"id": 2,
"title": "Gâteau au chocolat",
"description": "Un classique qui plaît à tout le monde.",
"ingredients": [
{"name": "Chocolat noir", "quantity": "200g"},
{"name": "Beurre", "quantity": "150g"},
{"name": "Sucre", "quantity": "100g"},
{"name": "Oeufs", "quantity": "3"},
{"name": "Farine", "quantity": "50g"}
],
},
]
# Compteur pour simuler l'auto-incrémentation des IDs
_next_id = 3
# ---
class RecipeRepository:
"""
Simule l'accès à une base de données de recettes.
"""
def list(self) -> List[Recipe]:
"""Retourne toutes les recettes."""
return [Recipe.model_validate(r) for r in _RECIPES_DB]
def find_by_id(self, recipe_id: int) -> Optional[Recipe]:
"""Trouve une recette par son ID."""
for recipe_data in _RECIPES_DB:
if recipe_data["id"] == recipe_id:
return Recipe.model_validate(recipe_data)
return None
def create(self, recipe_data: Dict[str, Any]) -> Recipe:
"""Crée une nouvelle recette et l'ajoute à la DB."""
global _next_id
new_recipe = {
"id": _next_id,
**recipe_data
}
_RECIPES_DB.append(new_recipe)
_next_id += 1
return Recipe.model_validate(new_recipe)
# Instance unique du repository
recipe_repository = RecipeRepository()

View File

Binary file not shown.

View File

@@ -0,0 +1,40 @@
from typing import List, Dict, Any
from app.models.recipe import Recipe
from app.repositories.recipe_repository import recipe_repository
from app.core.exceptions import NotFoundBLLException, ValidationBLLException
def get_all_recipes() -> List[Recipe]:
"""Récupère toutes les recettes."""
return recipe_repository.list()
def get_recipe_by_id(recipe_id: int) -> Recipe:
"""
Récupère une recette par son ID.
Lève une exception si la recette n'est pas trouvée.
"""
recipe = recipe_repository.find_by_id(recipe_id)
if not recipe:
raise NotFoundBLLException(resource_name="Recipe", resource_id=recipe_id)
return recipe
def add_new_recipe(title: str, description: str, ingredients: List[Dict[str, Any]]) -> Recipe:
"""
Ajoute une nouvelle recette après validation.
"""
# Règle métier simple : le titre ne doit pas être vide
if not title or not title.strip():
raise ValidationBLLException("Le titre de la recette ne peut pas être vide.")
# Règle métier simple : il faut au moins un ingrédient
if not ingredients:
raise ValidationBLLException("Une recette doit contenir au moins un ingrédient.")
recipe_data = {
"title": title,
"description": description,
"ingredients": ingredients
}
return recipe_repository.create(recipe_data)