# Recipe API #### Caracteristicas - Crear - Listar - Ver detalles - Actualizar - Borrar #### Endpoints - `/recipes/` - `GET` Listar todas las recetas - `POST` Crea recetas - `/recipes/`/ - `GET` Ver detalles de receta - `PUT/PATCH` Actualizar receta - `DELETE` Borrar receta ## APIView vs Viewsets Una vista maneja un request a una URL, DRF usa clases con lógica reutilizable. DRF además soporta decoradores. `APIView` y `Viewsets` son clases base falicitadas por DRF. ### APIView - Concentradas alrededor de los metodos HTTP - Métodos de clase para los métodos HTTP `GET`, `POST`, `PUT`, `PATCH`, `DELETE` - Ofrece flexibilidad sobre toda la estuctura de las URL y la lógica usada para procesar estas peticiones - Util para APIs sin CRUD. Lógica a la medida, ej. auth, jobs, apis externas ### Viewsets - Concentradas alrededor de aciones - Retrive, list, update, partial update, destroy - Mapea los modelos de Django - Usa rutas para generar URLs - Genial para operaciones CRUD en los modelos ## Test Create Recipe [core/tests/test_models.py](../app/core/tests/test_models.py) ```py from decimal import Decimal ... from core import models class ModelTests(TestCase): ... def test_create_recipe(self): """Test creating a recipe is successful.""" user = get_user_model().objects.create_user( 'test@example.com', 'test123', ) recipe = models.Recipe.objects.create( user=user, title='Nombre receta ejemplo', time_minutes=5, price=Decimal('5.50'), decription='Descripción de la receta de ejemplo' ) self.assertEqual(str(recipe), recipe.title) ``` ## Creación del modelo [`core/models.py`](../app/core/models.py) ```py ... class Recipe(models.Model): """Recipe object.""" user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, ) title = models.CharField(max_length=255) description = models.TextField(blank=True) time_minutes = models.IntegerField() price = models.DecimalField(max_digits=5,decimal_places=2, blank=True) link = models.CharField(max_length=255, blank=True) def __str__(self): return self.title ``` ### Agregar al panel de administración [`core/admin.py`](../app/core/admin.py) ```py ... admin.site.register(models.Recipe) ``` ### Crear migraciones `docker compose run --rm app sh -c "python manage.py makemigrations"` ```sh [+] Creating 1/0 ✔ Container recipes_api_django-db-1 Running 0.0s Migrations for 'core': core/migrations/0002_recipe.py - Create model Recipe ``` ## Creación de app recipe `docker compose run --rm app sh -c "python manage.py startapp recipe"` ```sh [+] Creating 1/0 ✔ Container recipes_api_django-db-1 Running 0.0s ``` - Se eliminan, directorio `migrations`, `test.py`, `admin.py` `models.py` - Se crean directorio `reicpes/tests/` y su respecto `__init__.py` - Añadir app `recipe` en [settings.py](../app/app/settings.py) ```py INSTALLED_APPS = [ ... 'recipe', ] ``` ### Tests recipe API [`recipe/tests/test_recipe_api.py`](../app/recipe/tests/test_recipe_api.py) ```py ... RECIPES_URL = reverse('recipe:recipe-list') def create_recipe(user, **params): """Create and return a sample recipe.""" defaults = { 'title': 'Titulo receta de ejemplo', 'time_minutes': 31, 'price': Decimal('5.25'), 'description': 'Descripción de ejemplo', 'link': 'https://defzn.kickto.net/blog', } defaults.update(params) recipe = Recipe.objects.create(user=user, **defaults) return recipe class PublicRecipeApiTests(TestCase): """Test unauthenticated API requests.""" def setUp(self): self.client = APIClient() def test_auth_required(self): """Test auth is required to call API.""" res = self.client.get(RECIPES_URL) self.assertEqual(res.status_code, status.HTTP_401_UNAUTHORIZED) class PrivateRecipeApiTests(TestCase): """Test authenticated API requests.""" def setUp(self): self.client = APIClient() self.user = get_user_model().objects.create_user( 'user@example.com', 'testpass123', ) self.client.force_authenticate(self.user) def test_retrive_recipes(self): """Test retrieving a list of recipes.""" create_recipe(user=self.user) create_recipe(user=self.user) res = self.client.get(RECIPES_URL) recipes = Recipe.objects.all().order_by('-id') serializer = RecipeSerializer(recipes, many=True) self.assertEqual(res.status_code, status.HTTP_200_OK) self.assertEqual(res.data, serializer.data) def test_recipe_list_limited_to_user(self): """Test list of recipes is limited to authenticated user.""" other_user = get_user_model().objects.create_user( 'other@example.com', 'password123', ) create_recipe(user=other_user) create_recipe(user=self.user) res = self.client.get(RECIPES_URL) recipes = Recipe.objects.filter(user=self.user) serializer = RecipeSerializer(recipes, many=True) self.assertEqual(res.status_code, status.HTTP_200_OK) self.assertEqual(res.data, serializer.data) ``` ### Serializador para Recetas [`recipe/serializer.py`](../app/recipe/serializers.py) ```py from rest_framework import serializers from core.models import Recipe class RecipeSerializer(serializers.ModelSerializer): """Serializer for recipes.""" class Meta: model = Recipe fileds = ['id', 'title', 'time_minutes', 'price', 'link'] read_only_fields = ['id'] ``` ### Vista Recetas [`recipe/views.py`](../app/recipe/views.py) ```py from rest_framework import viewsets from rest_framework.authentication import TokenAuthentication from rest_framework.permissions import IsAuthenticated from core.models import Recipe from recipe import serializers class RecipeViewSet(viewsets.ModelViewSet): """View for manage recipe APIs.""" serializer_class = serializers.RecipeSerializer queryset = Recipe.objects.all() authentication_classes = [TokenAuthentication] permission_classes = [IsAuthenticated] def get_queryset(self): """Retrieve recipes for authenticated user.""" return self.queryset.filter(user=self.request.user).order_by('-id') ``` ### URLs Recetas [`recipe/urls.py`](../app/recipe/urls.py) ```py from django.urls import path, include from rest_framework.routers import DefaultRouter from recipe import views router = DefaultRouter() router.register('recipes', views.RecipeViewSet) app_name = 'recipe' urlpatterns = [ path('', include(router.urls)), ] ``` [`app/urls.py`](../app/app/urls.py) ```py ... urlpatterns = [ ... path('api/recipe', include('recipe.urls')), ] ``` ### Test detalles receta API [`recipe/tests/test_recipe_api`](../app/recipe/tests/test_recipe_api.py) ```py ... from recipe.serializers import ( RecipeSerializer, RecipeDetailSerializer, ) ... def detail_url(recipe_id): """Create and return a recipe detail URL.""" return reverse('recipe:recipe-deatil', args=[recipe_id]) ... class PrivateRecipeApiTests(TestCase): ... def test_get_recipe_detail(self): """Test get recipe detail.""" recipe = create_recipe(user=self.user) url = detail_url(recipe.id) res = self.client.get(url) serializer = RecipeDetailSerializer(recipe) self.assertEqual(res.data, serializer.data) ``` ## Implementación Api detalles receta ### Serializador para APIs detalles receta ['recipe/serializer.py'](../app/recipe/serializers.py) ```py ... class RecipeDetailSerializer(RecipeSerializer): """Serializer for recipe detail view.""" class Meta(RecipeSerializer.Meta): fields = RecipeSerializer.Meta.fields + ['description'] ``` ### Views para APIs detalles receta Sobrescribiendo [get_serializer_classself](https://www.django-rest-framework.org/api-guide/generic-views/#get_serializer_classself) para usar `RecipeDetailSerializer`. Se añade la lógica para que al listar se utilice `RecipeSerializer` [`recipe/views.py`](../app/recipe/views.py) ```py ... class RecipeViewSet(viewsets.ModelViewSet): """View for manage recipe APIs.""" serializer_class = serializers.RecipeDetailSerializer ... def get_serializer_class(self): """Return the serializer class for request.""" if self.action == 'list': return serializers.RecipeSerializer return self.serializer_class ``` ### Test creación de receta a travez de la API [`test_recipe_api.py`](../app/recipe/tests/test_recipe_api.py) ```py class PrivateRecipeApiTests(TestCase): ... def test_create_recipe(self): """Test creating a recipe.""" payload = { 'title': 'Titulo receta de ejemplo', 'time_minutes': 16, 'price': Decimal('5.99'), } res = self.client.post(RECIPES_URL, payload) self.assertEqual(res.status_code, status.HTTP_201_CREATED) recipe = Recipe.objects.get(id=res.data['id']) for k, v in payload.items(): self.assertEqual(getattr(recipe, k), v) self.assertEqual(recipe.user, self.user) ``` Se hace la Comparación utilizando el método [getattr](https://docs.python.org/3/library/functions.html#getattr) de python ### Implementación de vista para creación de receta [`recipe/views.py`](../app/recipe/views.py) ```py ... def perform_create(self, serializer): """Create a new recipe.""" serializer.save(user=self.request.user) ``` ## Tests adicionales ### Refactorizando test_recipe_api [`test_recipe_api.py`](../app/recipe/tests/test_recipe_api.py) ```diff ... +def create_user(**params): + """Create and create a new user.""" + return get_user_model().objects.create_user(**params) ... class PrivateRecipeApiTests(TestCase): """Test authenticated API requests.""" def setUp(self): self.client = APIClient() - self.user = get_user_model().objects.create_user( - 'user@example.com', - 'testpass123', - ) + self.user = create_user(email='user@example.com', password='test123') self.client.force_authenticate(self.user) ... def test_recipe_list_limited_to_user(self): """Test list of recipes is limited to authenticated user.""" - other_user = get_user_model().objects.create_user( - 'other@example.com', - 'password123', - ) + other_user = create_user(email='other@example.com', password='test123') create_recipe(user=other_user) create_recipe(user=self.user) ... ``` ### Test actualización parcial de receta ```py ... def test_partial_update(self): """Test partial update of a recipe.""" original_link = 'https://devfzn.kicnto.net/blog' recipe = create_recipe( user=self.user, title='Titulo de la Receta de ejemplo', link=original_link, ) payload = {'title': 'Nuevo titulo de la receta de ejemplo'} url = detail_url(recipe.id) res = self.client.patch(url, payload) self.assertEqual(res.status_code, status.HTTP_200_OK) ``` ### Test actualización completa de receta ```py def test_full_update(self): """Test full update of recipe.""" recipe = create_recipe( user=self.user, title='Titulo receta de ejemplo', link='https://devfzn.kickto.net/blog', description='Descripción receta de ejemplo', ) payload = { 'title': 'Titulo receta de ejemplo', 'link': 'https://defzn.kickto.net/blog', 'description': 'Descripción de ejemplo', 'time_minutes': 10, 'price': Decimal('3.65'), } url = detail_url(recipe.id) res = self.client.put(url, payload) self.assertEqual(res.status_code, status.HTTP_200_OK) recipe.refresh_from_db() for k, v in payload.items(): self.assertEqual(getattr(recipe, k), v) self.assertEqual(recipe.user, self.user) ``` ### Test cambio de usuario dueño de receta ```py def test_update_user_returns_error(self): """Test changing the recipe user results in an error.""" new_user = create_user(email='user2@example.com', password='testpass123') recipe = create_recipe(user=self.user) payload = {'user': new_user.id} url = detail_url(recipe.id) self.client.patch(url, payload) recipe.refresh_from_db() self.assertEqual(recipe.user, self.user) ``` ### Test eliminar receta ```py def test_delete_recipe(self): """Test deleting a recipe sucessful.""" recipe = create_recipe(user=self.user) url = detail_url(recipe.id) res = self.client.delete(url) self.assertEqual(res.status_code, status.HTTP_204_NO_CONTENT) self.assertFalse(Recipe.objects.filter(id=recipe.id).exists()) ``` ### Test eliminar receta de otro usuario ```py def test_recipe_other_users_recipe_error(self): """Test trying to delete another users recipe gives error.""" new_user = create_user(email='user2@example.com', password='testpass123') recipe = create_recipe(user=new_user) url = detail_url(recipe.id) res = self.client.delete(url) self.assertEqual(res.status_code, status.HTTP_404_NOT_FOUND) self.assertTrue(Recipe.objects.filter(id=recipe.id).exists()) ``` ## Probando API en el navegador URL `localhost:8000/api/docs/` ![img](./imgs_readme/api_swagger_01.png) ---- - [Inicio](../README.md) - [User API](./01_user_api.md) - [**Recipe API**](./02_recipe_api.md) - [Tag API](./03_tag_api.md) - [Ingredient API](./04_ingredient_api.md) - [Image API](./05_image_api.md) - [Filters](./06_filters.md)