diff --git a/README.md b/README.md index a0bb7d6..ef38980 100644 --- a/README.md +++ b/README.md @@ -1309,3 +1309,548 @@ class UserAdmin(BaseUserAdmin): - Página para crear usuarios ![img](./imgs_readme/django_admin_12.png) - Panel de usuarios del administrador ![img](./imgs_readme/django_admin_13.png) + +## Documentación de la API + +Es necesario tener acceso a una buena documentación para que los desarrolladores +puedan saber como usarla. Se documenta todo lo que sea necesario para usar la API + +- Endopoints disponibles (paths) +- Métodos soportados `GET`, `POST`, `PUT`, `PATCH`, `DELETE`... +- Formateo de payloads (inputs). Parametros, Post en formato **JSON** +- Formateo de respuestas (outputs). Respuesta en formato **JSON** +- Proceso Autenticación + +### Opiones de documentación + +- Manual + - Documento de texto + - Markdown +- Automatizada + - Usa la metadata del código (comments) + - Genera páginas de documentación + +## Autodocs de DRF + +- Documentación Autogenerada (3rd party library) + - `drf-spectacular` +- Genera el "schema" +- Interfaz web navegable + - Test requests + - Maneja la autenticación + +### Como funciona + +1. Creación del archivo `schema` +2. Pasa el schema al GUI + +### Open API Schema + +- Estandar para describir APIs +- Popular en la industria +- Soportada por la mayoría de herramientas de documentación de API + +### Ejemplo Schema + +fragmento + +```yml +/api/recipe/ingredients/: + get: + oprationId: recipe_ingredients_list + description: Manage ingredients in the database. + parameteres: + - in: query + name: assigned_only + schema: + type: integer + enum: + - 0 + - 1 + description: Filter by item assigned to recipies + tags: + - recipe + security: + - tokenAuth: [] + responses: + '200': + content: + application/json + schema: + type: array + items: + $ref: '#/components/schemas/Ingredient' + description: '' + ... +``` + +### Implementación DRF + +Se agrega dependencia en `drf-spectacular>=0.16` en `requirements.txt` + +Instalar app, en `settings.py` + +```py +INSTALLED_APPS = [ + ... + 'core', + 'rest_framework', + 'drf_spectacular', +] + +... +REST_FRAMEWORK = { + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', +} +``` + +### Activar las URLS + +```py +from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView +from django.contrib import admin +from django.urls import path + +urlpatterns = [ + path('admin/', admin.site.urls), + path('api/schema/', SpectacularAPIView.as_view(), name='api-schema'), + path( + 'api/docs', + SpectacularSwaggerView.as_view(url_name='api-schema'), + name='api-docs'), +] +``` + +- `docker compose run` +- `127.0.0.1:8000/api/docs` + +## User API + +### Diseño + +- Registro de usario +- Creación de token de autenticación +- Consultar y actualizar perfil + +### Endpoins + +| Endpoint | Method | Descripción | +| - | - | - | +| `user/create` | `POST` | Registrar nuevo usuario | +| `user/token` | `POST` | Crea un nuevo token | +| `user/me/` | `PUT/PATCH` | Actualizar perfíl | + +### Creación user app + +`docker compose run --rm app sh -c "python manage.py startapp user"` + +```sh +[+] Creating 1/0 + ✔ Container recipes_api_django-db-1 Created 0.0s +[+] Running 1/1 + ✔ Container recipes_api_django-db-1 Started 0.2s +``` + +Activar `user` app en `settings.py` + +### Test User API + +[`app/user/tests/test_user_api.py`](./app/user/tests/test_user_api.py) + +```py +CREATE_USER_URL = reverse('user:create') + +def create_user(**params): + """Create and return a new user.""" + return get_user_model().objects.crate_user(**params) + + +class PublicUserApiTest(TestCase): + """Test the public features of the user API.""" + + def setUp(self): + self.client = APIClient() + + def test_create_user_success(self): + """Tests creating a user is successful.""" + payload = { + 'email': 'test@example.com', + 'password':'testpass123', + 'name': 'TestName', + } + res = self.client.post(CREATE_USER_URL, payload) + + self.assertEqual(res.status_code, status.HTTP_201_CREATED) + user = get_user_model().objects.get(email=payload['email']) + self.assertTrue(user.check_password(payload['password'])) + self.assertNotIn('password', res.data) + + def test_user_with_email_exists_error(self): + """Test error returned if user with email exists.""" + payload = { + 'email': 'test@example.com', + 'password':'testpass123', + 'name': 'Test Name', + } + create_user(**payload) + res = self.client.post(CREATE_USER_URL, payload) + + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + + def test_password_too_short_error(self): + """Test an error is returned if password less than 5 chars.""" + payload = { + 'email': 'test@example.com', + 'password':'pw', + 'name': 'Test Name', + } + res = self.client.post(CREATE_USER_URL, payload) + + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + user_exists = get_user_model().objects.filter( + email=payload['email'] + ).exists() + self.assertFalse(user_exists) +``` + +### Creando funcionalidad de User API + +- [serializers.py](./app/user/serializers.py) + + ```py + from django.contrib.auth import get_user_model + from rest_framework import serializers + + class UserSerializer(serializers.ModelSerializer): + """Seralizer for the model object.""" + + class Meta: + model = get_user_model() + fields = ['email', 'password', 'name'] + extra_kwargs = {'password': {'write_only': True, 'min_length': 5}} + + def create(self, validated_data): + """Create and return a user with encrypted password""" + return get_user_model().objects.create_user(**validated_data) + ``` + +- [views.py](./app/user/views.py) + + ```py + from rest_framework import generics + from user.serializers import UserSerializer + + class CreateUserView(generics.CreateAPIView): + """Create a new user in the system.""" + serializer_class = UserSerializer + ``` + +- [urls.py](./app/user/urls.py) + + ```py + from django.urls import path + from user import views + + app_name='user' + urlpatterns = [ + path('create/', views.CreateUserView.as_view(), name='create') + ] + ``` + +- [app/urls.py](./app/app/urls.py) + + ```py + ... + from django.urls import include, path + + urlpatterns = [ + ... + path('api/user/', include('user.urls')), + ] + ``` + +### Autenticación + +| Tipo de autenticación | Descripción | +| - | - | +| **Básica** | Envía usuario y password en cada request | +| **Token** | Usa un token en el encabezado HTTP | +| **JSON Web Token (JWT)** | Usa un token de acceso | +| **Sesión** | Usa cookies | + +En esta app se utilza **Token** por: + +- Balance entre simplicidad y seguridad +- Soporte por defecto por **DRF** +- Bién soportada por la mayoria de clientes + + +```mermaid +%%{init: {'theme': 'dark','themeVariables': {'clusterBkg': '#2b2f38'}, 'flowchart': {'curve': 'basis'}}}%% +flowchart +subgraph " " + +CT["Create token +(Post username/password)"] +STOC["Store token on client"] +ITIH["Include token in HTTP headers"] +CT .-> STOC .-> ITIH +end +``` + +#### Pros del uso de Token + +- Soporte por defecto +- Simple de usar +- Soportada por todos los clientes +- Evita enviar datos de usuario/password en cada request + +#### Cons del uso de Token + +- El token debe ser seguro +- Requiere hacer peticiones a la base de datos + +### Login out + +- Sucede en el lado del cliente +- Borra el token + +### Test token API + +Agregar tests en +[`app/user/tests/test_user_api.py`](./app/user/tests/test_user_api.py) + +```py +... +TOKEN_URL = reverse('user:token') + +... + +class PublicUserApiTest(TestCase): + """Test the public features of the user API.""" + + ... + + def test_create_token_for_user(self): + """Test generate token for valid credentials.""" + user_details = { + 'name': 'Test Name', + 'email': 'test@example.com', + 'password':'test-user-password123', + } + create_user(**user_details) + + payload = { + 'email': user_details['email'], + 'password': user_details['password'], + } + res = self.client.post(TOKEN_URL, payload) + + self.assertIn('token', res.data) + self.assertEqual(res.status_code, status.HTTP_200_OK) + + def test_create_token_bad_credentials(self): + """Test returns error if credentials invalid.""" + create_user(email='test@example.com', password='goodpass') + + payload = {'email': 'test@example.com' ,'password': 'badpass'} + res = self.client.post(TOKEN_URL, payload) + + self.assertNotIn('token', res.data) + self.assertNotIn(res.status_code, status.HTTP_400_BAD_REQUEST) + + def test_create_token_blank_password(self): + """Test posting a blank password returns an error.""" + payload = {'email': 'test@example.com' , 'password': ''} + res = self.client.post(TOKEN_URL, payload) + + self.assertNotIn('token', res.data) + self.assertNotIn(res.status_code, status.HTTP_400_BAD_REQUEST) +``` + +### Implementar Token API + +- Añadir app `rest_framework.authtoken` en [settings.py](./app/app/settings.py) + + ```py + INSTALLED_APPS = [ + ... + 'rest_framework', + 'rest_framework.authtoken', # <--- + 'drf_spectacular', + 'user', + ] + ``` + +### Creación del serlizador para token api + +- [user/serializer.py](./app/user/serializers.py) + + ```py + ... + + class AuthTokenSerializer(serializers.Serializer): + """Serializer for the user auth token.""" + email = serializer.EmailField() + password = serializer.CharField( + style={'input_type': 'password'}, + trim_whitespace=False, + ) + + def validate(self, attrs): + """Validate and authenticate the user.""" + email = attrs.get('email') + password = attrs.get('password') + user = authenticate( + request=self.context.get('request'), + username=email, + password=password, + ) + if not user: + msg = _('Unable to authenticate with provided credentials.') + raise serializers.ValidationError(msg, code='authorization') + + attrs['user'] = user + return attrs + ``` + +- vista [user/views.py](./app/user/views.py) + + ```py + ... + class CreateTokenView(ObtainAuthToken): + """Create a new auth token for user.""" + serializer_class = AuthTokenSerializer + renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES + ``` + +- urls [user/urls.py](./app/user/urls.py) + + ```py + urlpatterns = [ + ... + path('token/', views.CreateTokenView.as_view(), name='token'), + ] + ``` + +### Test administrar usuario + +- [test_user_api.py](./app/user/tests/test_user_api.py) + + ```py + ... + ME_URL = reverse('user:me') + + ... + + class PrivateUserApiTests(TestCase): + """Test API requests that require authentication.""" + + def setUp(self): + self.user = create_user( + email='test@example.com', + password='testpass123', + name='Test Name', + ) + self.client = APIClient() + self.client.force_authenticate(user=self.user) + + def test_retrive_profile_success(self): + """Test retrieving profile for logged in user.""" + res = self.client.get(ME_URL) + + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertEqual( + res.data, { + 'name': self.user.name, + 'email': self.user.email, + }) + + def test_post_me_not_allowed(self): + """Test POST is not allowed for the 'me' endpoint.""" + res = self.client.post(ME_URL, {}) + + self.assertAlmostEqual( + res.status_code, + status.HTTP_405_METHOD_NOT_ALLOWED + ) + + def test_update_user_profile(self): + """Test updating the user profile for the autenticated user.""" + payload = { 'name': 'Updated Name', 'password': 'newpassword123' } + + res = self.client.patch(ME_URL, payload) + + self.user.refresh_from_db() + self.assertEqual(self.user.name, payload['name']) + self.assertTrue(self.user.check_password(payload['password'])) + self.assertEqual(res.status_code, status.HTTP_200_OK) + ``` + +### Implementación API actualizar usuario + +`me` endpoint + +- creación (sobrescritura) del método update +[serializer.py](./app/user/serializers.py) + + ```py + ... + + class UserSerializer(serializers.ModelSerializer): + + ... + + def create(self, validated_data): + """Create and return a user with encrypted password""" + return get_user_model().objects.create_user(**validated_data) + + def update(self, instance, validated_data): + """Update and return user.""" + password = validated_data.pop('password', None) + user = super().update(instance, validated_data) + + if password: + user.set_password(password) + user.save() + + return user + + ... + ``` + +- vistas [views.py](./app/user/views.py) + + ```py + from rest_framework import generics, authentication, permissions + ... + + class ManageUserView(generics.RetrieveUpdateAPIView): + """Manage the autenticated user.""" + serializer_class = UserSerializer + authentication_classes = [authentication.TokenAuthentication] + permission_classes = [permissions.IsAuthenticated] + + def get_object(self): + """Retrieve and return the authenticated user.""" + return self.request.user + ``` + +- urls [urls.py](./app/user/urls.py) + + ```py + urlpatterns = [ + ... + path('me/', views.ManageUserView.as_view(), name='me'), + ] + ``` + +### Pruebas en navegador + +Ruta `localhost:8000/api/docs` + +![img](./imgs_readme/api_swagger_00.png) + +---- + +Segunda parte -> [Recetas](./README2.md) diff --git a/app/app/settings.py b/app/app/settings.py index 11fd324..dd06dfd 100644 --- a/app/app/settings.py +++ b/app/app/settings.py @@ -38,6 +38,10 @@ INSTALLED_APPS = [ 'django.contrib.messages', 'django.contrib.staticfiles', 'core', + 'rest_framework', + 'rest_framework.authtoken', + 'drf_spectacular', + 'user', ] MIDDLEWARE = [ @@ -127,3 +131,7 @@ STATIC_URL = 'static/' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' AUTH_USER_MODEL = 'core.User' + +REST_FRAMEWORK = { + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', +} diff --git a/app/app/urls.py b/app/app/urls.py index 84b3189..5a8bedf 100644 --- a/app/app/urls.py +++ b/app/app/urls.py @@ -14,9 +14,16 @@ Including another URLconf 1. Import the include() function: from django.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ +from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView from django.contrib import admin -from django.urls import path +from django.urls import include, path urlpatterns = [ path('admin/', admin.site.urls), + path('api/schema/', SpectacularAPIView.as_view(), name='api-schema'), + path( + 'api/docs', + SpectacularSwaggerView.as_view(url_name='api-schema'), + name='api-docs'), + path('api/user/', include('user.urls')), ] diff --git a/app/core/tests/test_models.py b/app/core/tests/test_models.py index 656638a..acd5051 100644 --- a/app/core/tests/test_models.py +++ b/app/core/tests/test_models.py @@ -2,7 +2,7 @@ Test for models. """ from django.test import TestCase -from django.contrib.auth import get_user, get_user_model +from django.contrib.auth import get_user_model class ModelTests(TestCase): diff --git a/app/user/__init__.py b/app/user/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/user/apps.py b/app/user/apps.py new file mode 100644 index 0000000..36cce4c --- /dev/null +++ b/app/user/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class UserConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'user' diff --git a/app/user/serializers.py b/app/user/serializers.py new file mode 100644 index 0000000..4dc6652 --- /dev/null +++ b/app/user/serializers.py @@ -0,0 +1,56 @@ +""" +Seralizers for the user API View +""" +from django.contrib.auth import get_user_model, authenticate +from django.utils.translation import gettext as _, trim_whitespace + +from rest_framework import serializers + + +class UserSerializer(serializers.ModelSerializer): + """Seralizer for the model object.""" + + class Meta: + model = get_user_model() + fields = ['email', 'password', 'name'] + extra_kwargs = {'password': {'write_only': True, 'min_length': 5}} + + def create(self, validated_data): + """Create and return a user with encrypted password""" + return get_user_model().objects.create_user(**validated_data) + + def update(self, instance, validated_data): + """Update and return user.""" + password = validated_data.pop('password', None) + user = super().update(instance, validated_data) + + if password: + user.set_password(password) + user.save() + + return user + + +class AuthTokenSerializer(serializers.Serializer): + """Serializer for the user auth token.""" + email = serializers.EmailField() + password = serializers.CharField( + style={'input_type': 'password'}, + trim_whitespace=False, + ) + + def validate(self, attrs): + """Validate and authenticate the user.""" + email = attrs.get('email') + password = attrs.get('password') + user = authenticate( + request=self.context.get('request'), + username=email, + password=password, + ) + if not user: + msg = _('Unable to authenticate with provided credentials.') + raise serializers.ValidationError(msg, code='authorization') + + attrs['user'] = user + return attrs diff --git a/app/user/tests/__init__.py b/app/user/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/user/tests/test_user_api.py b/app/user/tests/test_user_api.py new file mode 100644 index 0000000..9df8ec1 --- /dev/null +++ b/app/user/tests/test_user_api.py @@ -0,0 +1,151 @@ +""" +Tests for the user API. +""" +from django.test import TestCase +from django.contrib.auth import get_user_model +from django.urls import reverse + +from rest_framework.test import APIClient +from rest_framework import status + +CREATE_USER_URL = reverse('user:create') +TOKEN_URL = reverse('user:token') +ME_URL = reverse('user:me') + +def create_user(**params): + """Create and return a new user.""" + return get_user_model().objects.create_user(**params) + + +class PublicUserApiTest(TestCase): + """Test the public features of the user API.""" + + def setUp(self): + self.client = APIClient() + + def test_create_user_success(self): + """Tests creating a user is successful.""" + payload = { + 'email': 'test@example.com', + 'password':'testpass123', + 'name': 'TestName', + } + res = self.client.post(CREATE_USER_URL, payload) + + self.assertEqual(res.status_code, status.HTTP_201_CREATED) + user = get_user_model().objects.get(email=payload['email']) + self.assertTrue(user.check_password(payload['password'])) + self.assertNotIn('password', res.data) + + def test_user_with_email_exists_error(self): + """Test error returned if user with email exists.""" + payload = { + 'email': 'test@example.com', + 'password':'testpass123', + 'name': 'Test Name', + } + create_user(**payload) + res = self.client.post(CREATE_USER_URL, payload) + + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + + def test_password_too_short_error(self): + """Test an error is returned if password less than 5 chars.""" + payload = { + 'email': 'test@example.com', + 'password':'pw', + 'name': 'Test Name', + } + res = self.client.post(CREATE_USER_URL, payload) + + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + user_exists = get_user_model().objects.filter( + email=payload['email'] + ).exists() + self.assertFalse(user_exists) + + def test_create_token_for_user(self): + """Test generate token for valid credentials.""" + user_details = { + 'name': 'Test Name', + 'email': 'test@example.com', + 'password':'test-user-password123', + } + create_user(**user_details) + + payload = { + 'email': user_details['email'], + 'password': user_details['password'], + } + res = self.client.post(TOKEN_URL, payload) + + self.assertIn('token', res.data) + self.assertEqual(res.status_code, status.HTTP_200_OK) + + def test_create_token_bad_credentials(self): + """Test returns error if credentials invalid.""" + create_user(email='test@example.com', password='goodpass') + + payload = {'email': 'test@example.com' ,'password': 'badpass'} + res = self.client.post(TOKEN_URL, payload) + + self.assertNotIn('token', res.data) + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + + def test_create_token_blank_password(self): + """Test posting a blank password returns an error.""" + payload = {'email': 'test@example.com' , 'password': ''} + res = self.client.post(TOKEN_URL, payload) + + self.assertNotIn('token', res.data) + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + + def test_retrive_user_unauthorized(self): + """Test authentication is required for users.""" + res = self.client.get(ME_URL) + + self.assertEqual(res.status_code, status.HTTP_401_UNAUTHORIZED) + + +class PrivateUserApiTests(TestCase): + """Test API requests that require authentication.""" + + def setUp(self): + self.user = create_user( + email='test@example.com', + password='testpass123', + name='Test Name', + ) + self.client = APIClient() + self.client.force_authenticate(user=self.user) + + def test_retrive_profile_success(self): + """Test retrieving profile for logged in user.""" + res = self.client.get(ME_URL) + + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertEqual( + res.data, { + 'name': self.user.name, + 'email': self.user.email, + }) + + def test_post_me_not_allowed(self): + """Test POST is not allowed for the 'me' endpoint.""" + res = self.client.post(ME_URL, {}) + + self.assertAlmostEqual( + res.status_code, + status.HTTP_405_METHOD_NOT_ALLOWED + ) + + def test_update_user_profile(self): + """Test updating the user profile for the autenticated user.""" + payload = { 'name': 'Updated Name', 'password': 'newpassword123' } + + res = self.client.patch(ME_URL, payload) + + self.user.refresh_from_db() + self.assertEqual(self.user.name, payload['name']) + self.assertTrue(self.user.check_password(payload['password'])) + self.assertEqual(res.status_code, status.HTTP_200_OK) diff --git a/app/user/urls.py b/app/user/urls.py new file mode 100644 index 0000000..494dd50 --- /dev/null +++ b/app/user/urls.py @@ -0,0 +1,15 @@ +""" +URL mappings for the user API +""" +from django.urls import path + +from user import views + + +app_name='user' + +urlpatterns = [ + path('create/', views.CreateUserView.as_view(), name='create'), + path('token/', views.CreateTokenView.as_view(), name='token'), + path('me/', views.ManageUserView.as_view(), name='me'), +] diff --git a/app/user/views.py b/app/user/views.py new file mode 100644 index 0000000..49c7a22 --- /dev/null +++ b/app/user/views.py @@ -0,0 +1,29 @@ +""" +Views for the user API. +""" +from rest_framework import generics, authentication, permissions +from rest_framework.authtoken.views import ObtainAuthToken +from rest_framework.settings import api_settings + +from user.serializers import UserSerializer, AuthTokenSerializer + + +class CreateUserView(generics.CreateAPIView): + """Create a new user in the system.""" + serializer_class = UserSerializer + + +class CreateTokenView(ObtainAuthToken): + """Create a new auth token for user.""" + serializer_class = AuthTokenSerializer + renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES + +class ManageUserView(generics.RetrieveUpdateAPIView): + """Manage the autenticated user.""" + serializer_class = UserSerializer + authentication_classes = [authentication.TokenAuthentication] + permission_classes = [permissions.IsAuthenticated] + + def get_object(self): + """Retrieve and return the authenticated user.""" + return self.request.user diff --git a/imgs_readme/api_swagger_00.png b/imgs_readme/api_swagger_00.png new file mode 100644 index 0000000..04e7687 Binary files /dev/null and b/imgs_readme/api_swagger_00.png differ diff --git a/requirements.txt b/requirements.txt index e40fb74..c3ba626 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ Django==4.2.5 djangorestframework==3.14.0 psycopg2>=2.9.9 +drf-spectacular>=0.16