diff --git a/src/app/__init__.py b/src/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/__pycache__/__init__.cpython-313.pyc b/src/app/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..66526f9 Binary files /dev/null and b/src/app/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/app/__pycache__/main.cpython-313.pyc b/src/app/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..b145191 Binary files /dev/null and b/src/app/__pycache__/main.cpython-313.pyc differ diff --git a/src/app/core/__init__.py b/src/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/core/__pycache__/__init__.cpython-313.pyc b/src/app/core/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..654b9b6 Binary files /dev/null and b/src/app/core/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/app/core/__pycache__/config.cpython-313.pyc b/src/app/core/__pycache__/config.cpython-313.pyc new file mode 100644 index 0000000..2d545f3 Binary files /dev/null and b/src/app/core/__pycache__/config.cpython-313.pyc differ diff --git a/src/app/core/__pycache__/exceptions.cpython-313.pyc b/src/app/core/__pycache__/exceptions.cpython-313.pyc new file mode 100644 index 0000000..afadf31 Binary files /dev/null and b/src/app/core/__pycache__/exceptions.cpython-313.pyc differ diff --git a/src/app/core/config.py b/src/app/core/config.py new file mode 100644 index 0000000..146e283 --- /dev/null +++ b/src/app/core/config.py @@ -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() \ No newline at end of file diff --git a/src/app/core/exceptions.py b/src/app/core/exceptions.py new file mode 100644 index 0000000..0db2f2e --- /dev/null +++ b/src/app/core/exceptions.py @@ -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 \ No newline at end of file diff --git a/src/app/graphql/__init__.py b/src/app/graphql/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/graphql/__pycache__/__init__.cpython-313.pyc b/src/app/graphql/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..4cd0ec2 Binary files /dev/null and b/src/app/graphql/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/app/graphql/__pycache__/extensions.cpython-313.pyc b/src/app/graphql/__pycache__/extensions.cpython-313.pyc new file mode 100644 index 0000000..b7d2c9f Binary files /dev/null and b/src/app/graphql/__pycache__/extensions.cpython-313.pyc differ diff --git a/src/app/graphql/__pycache__/mutations.cpython-313.pyc b/src/app/graphql/__pycache__/mutations.cpython-313.pyc new file mode 100644 index 0000000..caae63f Binary files /dev/null and b/src/app/graphql/__pycache__/mutations.cpython-313.pyc differ diff --git a/src/app/graphql/__pycache__/queries.cpython-313.pyc b/src/app/graphql/__pycache__/queries.cpython-313.pyc new file mode 100644 index 0000000..b28fb48 Binary files /dev/null and b/src/app/graphql/__pycache__/queries.cpython-313.pyc differ diff --git a/src/app/graphql/extensions.py b/src/app/graphql/extensions.py new file mode 100644 index 0000000..648c282 --- /dev/null +++ b/src/app/graphql/extensions.py @@ -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__ \ No newline at end of file diff --git a/src/app/graphql/inputs/__init__.py b/src/app/graphql/inputs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/graphql/inputs/__pycache__/__init__.cpython-313.pyc b/src/app/graphql/inputs/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..5cd4ae9 Binary files /dev/null and b/src/app/graphql/inputs/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/app/graphql/inputs/__pycache__/ingredient_input.cpython-313.pyc b/src/app/graphql/inputs/__pycache__/ingredient_input.cpython-313.pyc new file mode 100644 index 0000000..a9eb485 Binary files /dev/null and b/src/app/graphql/inputs/__pycache__/ingredient_input.cpython-313.pyc differ diff --git a/src/app/graphql/inputs/__pycache__/recipe_input.cpython-313.pyc b/src/app/graphql/inputs/__pycache__/recipe_input.cpython-313.pyc new file mode 100644 index 0000000..cc5311e Binary files /dev/null and b/src/app/graphql/inputs/__pycache__/recipe_input.cpython-313.pyc differ diff --git a/src/app/graphql/inputs/ingredient_input.py b/src/app/graphql/inputs/ingredient_input.py new file mode 100644 index 0000000..a6a2b95 --- /dev/null +++ b/src/app/graphql/inputs/ingredient_input.py @@ -0,0 +1,6 @@ +import strawberry + +@strawberry.input +class IngredientInput: + name: str + quantity: str \ No newline at end of file diff --git a/src/app/graphql/inputs/recipe_input.py b/src/app/graphql/inputs/recipe_input.py new file mode 100644 index 0000000..75f4636 --- /dev/null +++ b/src/app/graphql/inputs/recipe_input.py @@ -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] \ No newline at end of file diff --git a/src/app/graphql/mutations.py b/src/app/graphql/mutations.py new file mode 100644 index 0000000..fdc5b4b --- /dev/null +++ b/src/app/graphql/mutations.py @@ -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." + ) \ No newline at end of file diff --git a/src/app/graphql/queries.py b/src/app/graphql/queries.py new file mode 100644 index 0000000..51b040c --- /dev/null +++ b/src/app/graphql/queries.py @@ -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." + ) \ No newline at end of file diff --git a/src/app/graphql/resolvers/__init__.py b/src/app/graphql/resolvers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/graphql/resolvers/__pycache__/__init__.cpython-313.pyc b/src/app/graphql/resolvers/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..711baaf Binary files /dev/null and b/src/app/graphql/resolvers/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/app/graphql/resolvers/__pycache__/recipe_resolvers.cpython-313.pyc b/src/app/graphql/resolvers/__pycache__/recipe_resolvers.cpython-313.pyc new file mode 100644 index 0000000..36da517 Binary files /dev/null and b/src/app/graphql/resolvers/__pycache__/recipe_resolvers.cpython-313.pyc differ diff --git a/src/app/graphql/resolvers/recipe_resolvers.py b/src/app/graphql/resolvers/recipe_resolvers.py new file mode 100644 index 0000000..8ba0bdd --- /dev/null +++ b/src/app/graphql/resolvers/recipe_resolvers.py @@ -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 + ) \ No newline at end of file diff --git a/src/app/graphql/types/__init__.py b/src/app/graphql/types/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/graphql/types/__pycache__/__init__.cpython-313.pyc b/src/app/graphql/types/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..b65fc73 Binary files /dev/null and b/src/app/graphql/types/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/app/graphql/types/__pycache__/ingredient.cpython-313.pyc b/src/app/graphql/types/__pycache__/ingredient.cpython-313.pyc new file mode 100644 index 0000000..f13a3f3 Binary files /dev/null and b/src/app/graphql/types/__pycache__/ingredient.cpython-313.pyc differ diff --git a/src/app/graphql/types/__pycache__/recipe.cpython-313.pyc b/src/app/graphql/types/__pycache__/recipe.cpython-313.pyc new file mode 100644 index 0000000..2a4f958 Binary files /dev/null and b/src/app/graphql/types/__pycache__/recipe.cpython-313.pyc differ diff --git a/src/app/graphql/types/ingredient.py b/src/app/graphql/types/ingredient.py new file mode 100644 index 0000000..793401e --- /dev/null +++ b/src/app/graphql/types/ingredient.py @@ -0,0 +1,6 @@ +import strawberry + +@strawberry.type +class Ingredient: + name: str + quantity: str \ No newline at end of file diff --git a/src/app/graphql/types/recipe.py b/src/app/graphql/types/recipe.py new file mode 100644 index 0000000..69c796e --- /dev/null +++ b/src/app/graphql/types/recipe.py @@ -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] \ No newline at end of file diff --git a/src/app/main.py b/src/app/main.py new file mode 100644 index 0000000..60e72f0 --- /dev/null +++ b/src/app/main.py @@ -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) \ No newline at end of file diff --git a/src/app/models/__init__.py b/src/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/models/__pycache__/__init__.cpython-313.pyc b/src/app/models/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..fa11c82 Binary files /dev/null and b/src/app/models/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/app/models/__pycache__/ingredient.cpython-313.pyc b/src/app/models/__pycache__/ingredient.cpython-313.pyc new file mode 100644 index 0000000..ae17e5f Binary files /dev/null and b/src/app/models/__pycache__/ingredient.cpython-313.pyc differ diff --git a/src/app/models/__pycache__/recipe.cpython-313.pyc b/src/app/models/__pycache__/recipe.cpython-313.pyc new file mode 100644 index 0000000..48ce25c Binary files /dev/null and b/src/app/models/__pycache__/recipe.cpython-313.pyc differ diff --git a/src/app/models/ingredient.py b/src/app/models/ingredient.py new file mode 100644 index 0000000..07ca891 --- /dev/null +++ b/src/app/models/ingredient.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel + +class Ingredient(BaseModel): + name: str + quantity: str \ No newline at end of file diff --git a/src/app/models/recipe.py b/src/app/models/recipe.py new file mode 100644 index 0000000..8648ab1 --- /dev/null +++ b/src/app/models/recipe.py @@ -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] \ No newline at end of file diff --git a/src/app/repositories/__init__.py b/src/app/repositories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/repositories/__pycache__/__init__.cpython-313.pyc b/src/app/repositories/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..0f8683e Binary files /dev/null and b/src/app/repositories/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/app/repositories/__pycache__/recipe_repository.cpython-313.pyc b/src/app/repositories/__pycache__/recipe_repository.cpython-313.pyc new file mode 100644 index 0000000..97ae8f0 Binary files /dev/null and b/src/app/repositories/__pycache__/recipe_repository.cpython-313.pyc differ diff --git a/src/app/repositories/recipe_repository.py b/src/app/repositories/recipe_repository.py new file mode 100644 index 0000000..b28e9f6 --- /dev/null +++ b/src/app/repositories/recipe_repository.py @@ -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() diff --git a/src/app/services/__init__.py b/src/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/services/__pycache__/__init__.cpython-313.pyc b/src/app/services/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..8ca81f8 Binary files /dev/null and b/src/app/services/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/app/services/__pycache__/recipe_service.cpython-313.pyc b/src/app/services/__pycache__/recipe_service.cpython-313.pyc new file mode 100644 index 0000000..83b7e29 Binary files /dev/null and b/src/app/services/__pycache__/recipe_service.cpython-313.pyc differ diff --git a/src/app/services/recipe_service.py b/src/app/services/recipe_service.py new file mode 100644 index 0000000..87036b2 --- /dev/null +++ b/src/app/services/recipe_service.py @@ -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) \ No newline at end of file