Initial commit
This commit is contained in:
0
src/app/__init__.py
Normal file
0
src/app/__init__.py
Normal file
BIN
src/app/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
src/app/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/app/__pycache__/main.cpython-313.pyc
Normal file
BIN
src/app/__pycache__/main.cpython-313.pyc
Normal file
Binary file not shown.
0
src/app/core/__init__.py
Normal file
0
src/app/core/__init__.py
Normal file
BIN
src/app/core/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
src/app/core/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/app/core/__pycache__/config.cpython-313.pyc
Normal file
BIN
src/app/core/__pycache__/config.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/app/core/__pycache__/exceptions.cpython-313.pyc
Normal file
BIN
src/app/core/__pycache__/exceptions.cpython-313.pyc
Normal file
Binary file not shown.
15
src/app/core/config.py
Normal file
15
src/app/core/config.py
Normal 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()
|
||||||
24
src/app/core/exceptions.py
Normal file
24
src/app/core/exceptions.py
Normal 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
|
||||||
0
src/app/graphql/__init__.py
Normal file
0
src/app/graphql/__init__.py
Normal file
BIN
src/app/graphql/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
src/app/graphql/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/app/graphql/__pycache__/extensions.cpython-313.pyc
Normal file
BIN
src/app/graphql/__pycache__/extensions.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/app/graphql/__pycache__/mutations.cpython-313.pyc
Normal file
BIN
src/app/graphql/__pycache__/mutations.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/app/graphql/__pycache__/queries.cpython-313.pyc
Normal file
BIN
src/app/graphql/__pycache__/queries.cpython-313.pyc
Normal file
Binary file not shown.
12
src/app/graphql/extensions.py
Normal file
12
src/app/graphql/extensions.py
Normal 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__
|
||||||
0
src/app/graphql/inputs/__init__.py
Normal file
0
src/app/graphql/inputs/__init__.py
Normal file
BIN
src/app/graphql/inputs/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
src/app/graphql/inputs/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/app/graphql/inputs/__pycache__/recipe_input.cpython-313.pyc
Normal file
BIN
src/app/graphql/inputs/__pycache__/recipe_input.cpython-313.pyc
Normal file
Binary file not shown.
6
src/app/graphql/inputs/ingredient_input.py
Normal file
6
src/app/graphql/inputs/ingredient_input.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import strawberry
|
||||||
|
|
||||||
|
@strawberry.input
|
||||||
|
class IngredientInput:
|
||||||
|
name: str
|
||||||
|
quantity: str
|
||||||
9
src/app/graphql/inputs/recipe_input.py
Normal file
9
src/app/graphql/inputs/recipe_input.py
Normal 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]
|
||||||
16
src/app/graphql/mutations.py
Normal file
16
src/app/graphql/mutations.py
Normal 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."
|
||||||
|
)
|
||||||
21
src/app/graphql/queries.py
Normal file
21
src/app/graphql/queries.py
Normal 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."
|
||||||
|
)
|
||||||
0
src/app/graphql/resolvers/__init__.py
Normal file
0
src/app/graphql/resolvers/__init__.py
Normal file
BIN
src/app/graphql/resolvers/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
src/app/graphql/resolvers/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
30
src/app/graphql/resolvers/recipe_resolvers.py
Normal file
30
src/app/graphql/resolvers/recipe_resolvers.py
Normal 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
|
||||||
|
)
|
||||||
0
src/app/graphql/types/__init__.py
Normal file
0
src/app/graphql/types/__init__.py
Normal file
BIN
src/app/graphql/types/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
src/app/graphql/types/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/app/graphql/types/__pycache__/ingredient.cpython-313.pyc
Normal file
BIN
src/app/graphql/types/__pycache__/ingredient.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/app/graphql/types/__pycache__/recipe.cpython-313.pyc
Normal file
BIN
src/app/graphql/types/__pycache__/recipe.cpython-313.pyc
Normal file
Binary file not shown.
6
src/app/graphql/types/ingredient.py
Normal file
6
src/app/graphql/types/ingredient.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import strawberry
|
||||||
|
|
||||||
|
@strawberry.type
|
||||||
|
class Ingredient:
|
||||||
|
name: str
|
||||||
|
quantity: str
|
||||||
10
src/app/graphql/types/recipe.py
Normal file
10
src/app/graphql/types/recipe.py
Normal 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
43
src/app/main.py
Normal 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)
|
||||||
0
src/app/models/__init__.py
Normal file
0
src/app/models/__init__.py
Normal file
BIN
src/app/models/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
src/app/models/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/app/models/__pycache__/ingredient.cpython-313.pyc
Normal file
BIN
src/app/models/__pycache__/ingredient.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/app/models/__pycache__/recipe.cpython-313.pyc
Normal file
BIN
src/app/models/__pycache__/recipe.cpython-313.pyc
Normal file
Binary file not shown.
5
src/app/models/ingredient.py
Normal file
5
src/app/models/ingredient.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
class Ingredient(BaseModel):
|
||||||
|
name: str
|
||||||
|
quantity: str
|
||||||
9
src/app/models/recipe.py
Normal file
9
src/app/models/recipe.py
Normal 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]
|
||||||
0
src/app/repositories/__init__.py
Normal file
0
src/app/repositories/__init__.py
Normal file
BIN
src/app/repositories/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
src/app/repositories/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
61
src/app/repositories/recipe_repository.py
Normal file
61
src/app/repositories/recipe_repository.py
Normal 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()
|
||||||
0
src/app/services/__init__.py
Normal file
0
src/app/services/__init__.py
Normal file
BIN
src/app/services/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
src/app/services/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/app/services/__pycache__/recipe_service.cpython-313.pyc
Normal file
BIN
src/app/services/__pycache__/recipe_service.cpython-313.pyc
Normal file
Binary file not shown.
40
src/app/services/recipe_service.py
Normal file
40
src/app/services/recipe_service.py
Normal 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)
|
||||||
Reference in New Issue
Block a user