diseño, creación e implementación de user API

serializadores, administración, autenticación y tests
This commit is contained in:
devfzn 2023-10-08 20:04:34 -03:00
parent d035ec4cca
commit f8ac495c2d
Signed by: devfzn
GPG Key ID: E070ECF4A754FDB1
13 changed files with 820 additions and 2 deletions

545
README.md
View File

@ -1309,3 +1309,548 @@ class UserAdmin(BaseUserAdmin):
- Página para crear usuarios ![img](./imgs_readme/django_admin_12.png) - Página para crear usuarios ![img](./imgs_readme/django_admin_12.png)
- Panel de usuarios del administrador ![img](./imgs_readme/django_admin_13.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["<b>Create token</b>
(Post username/password)"]
STOC["<b>Store token on client</b>"]
ITIH["<b>Include token in HTTP headers</b>"]
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)

View File

@ -38,6 +38,10 @@ INSTALLED_APPS = [
'django.contrib.messages', 'django.contrib.messages',
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'core', 'core',
'rest_framework',
'rest_framework.authtoken',
'drf_spectacular',
'user',
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@ -127,3 +131,7 @@ STATIC_URL = 'static/'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
AUTH_USER_MODEL = 'core.User' AUTH_USER_MODEL = 'core.User'
REST_FRAMEWORK = {
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
}

View File

@ -14,9 +14,16 @@ Including another URLconf
1. Import the include() function: from django.urls import include, path 1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 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.contrib import admin
from django.urls import path from django.urls import include, path
urlpatterns = [ urlpatterns = [
path('admin/', admin.site.urls), 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')),
] ]

View File

@ -2,7 +2,7 @@
Test for models. Test for models.
""" """
from django.test import TestCase 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): class ModelTests(TestCase):

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

6
app/user/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class UserConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'user'

56
app/user/serializers.py Normal file
View File

@ -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

View File

View File

@ -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)

15
app/user/urls.py Normal file
View File

@ -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'),
]

29
app/user/views.py Normal file
View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@ -1,3 +1,4 @@
Django==4.2.5 Django==4.2.5
djangorestframework==3.14.0 djangorestframework==3.14.0
psycopg2>=2.9.9 psycopg2>=2.9.9
drf-spectacular>=0.16