API¶
Django especializado
Una API (Interfaz de Programación de Aplicaciones) es un conjunto de reglas y definiciones que permite que diferentes aplicaciones o sistemas se comuniquen entre sí de manera estandarizada. Funciona como un intermediario que recibe solicitudes, las procesa según lo establecido y devuelve respuestas, facilitando el intercambio de datos o funcionalidades sin que los sistemas necesiten conocer cómo está construido el otro internamente. Gracias a las APIs, es posible integrar servicios externos, reutilizar funciones y desarrollar aplicaciones más rápidas, escalables y seguras.
En otras palabras, en una especie de contrato que se establece entre dos artefactos de software que quieren intercambiar información. Podemos hablar de un protocolo por el que se define la manera de solicitar y devolver datos.
También existe el concepto de API REST (Representational State Transfer) con las siguientes características:
- Todos los recursos se identifican mediante una URL.
- Se utilizan métodos HTTP para indicar la operación a realizar.
- Cada petición del cliente al servidor debe incluir toda la información necesaria para procesarla; el servidor no guarda sesiones previas («stateless» o sin estado).
- Formato de datos habitualmente en JSON.
Paquetes existentes¶
En el universo Python, existen varios paquetes de terceros muy relevantes dedicados a la implementación de APIs:
-
Integrados con Django
- Django Rest Framework - DRF: Se integra perfectamente con un proyecto Django y facilita enormemente la conexión de la API con el resto de componentes del «framework».
- Django Ninja: Buena integración en Django. Desarrollo rápido y sencillo de cara a la implementación de APIs. Su rendimiento es muy destacado.
-
Independientes de Django
- FastAPI: Framework para desarrollo web con alto rendimiento y fácil de aprender. Ha tomado mucha relevancia en los últimos años. Se acerca a 100K .
- Flask: Aunque no se trata de un framework específico para desarrollo de APIs, se ha popularizado como un paquete muy potente para desarrollo web en el que también se pueden implementar APIs.
En esta sección nos vamos a centrar en Django Ninja por ser una excelente solución a la hora de implementar APIs de forma rápida y simple, con una curva de aprendizaje baja y con un excelente rendimiento.
Django Ninja¶
Django Ninja es un framework para construir APIs con Django y anotaciones de tipo en Python.
Sus principales características son:
- Facilidad: Diseñado para que sea sencillo de usar e intuitivo.
- Rápida ejecución: Rendimiento muy alto gracias a Pydantic y soporte asíncrono.
- Desarrollo rápido: Basado en estándares abiertos para APIs: OpenAPI (previamente conocido como Swagger) y JSON Schema.
- Interconexión con Django: (Obviamente) tiene una buena integración con Django y su ORM.
- Preparado para producción: Utilizado en muchas empresas sobre proyectos vivos.
En el contexto de Django Ninja se pueden establecer las siguientes equivalencias:
| Django Ninja | Django |
|---|---|
| API | Proyecto |
| Entrypoint | URL |
| Router | Aplicación |
| Handler | Vista |
| Schema | Formulario |
| Recurso | Objeto |
Instalación¶
La instalación del paquete es muy sencilla:
Puesta en marcha¶
Vamos a empezar por crear un proyecto vacío en el que trataremos de implementar una API para un «blog».
$ mkdir blog-api
$ cd blog-api
$ uv init --bare --no-project
$ uv add django-ninja
$ uv run django-admin startproject main . #(1)!
- Creamos un proyecto «normal» de Django.
Django como dependencia
La instalación de django-ninja ya instala (como dependencia) el paquete django:
$ uv add django-ninja
Using CPython 3.14.3
Creating virtual environment at: .venv
Resolved 11 packages in 264ms
Installed 9 packages in 173ms
+ annotated-types==0.7.0
+ asgiref==3.11.1
+ django==6.0.3
+ django-ninja==1.6.2
+ pydantic==2.12.5
+ pydantic-core==2.41.5
+ sqlparse==0.5.5
+ typing-extensions==4.15.0
+ typing-inspection==0.4.2
Aplicaciones¶
El diseño de la base de datos muy sencillo:
erDiagram
Post }o--o| Category : has
Un «post» tiene 0 o 1 categoría y una categoría puede tener 0 o muchos «posts».
Por tanto crearemos dos aplicaciones:
categoriespara almacenar las categorías.postspara almacenar los posts.
Escribimos el fichero de modelos categories/models.py con el siguiente contenido:
from django.db import models
class Category(models.Model):
name = models.CharField(max_length=256)
slug = models.SlugField(max_length=256, unique=True)
class Meta:
verbose_name_plural = 'Categories'
def __str__(self):
return self.name
Una vez creadas y aplicadas las migraciones del modelo, vamos a cargar algunos datos de prueba. Para ello trabajaremos con «fixtures».
Copiamos el contenido del fichero categories.json y lo guardamos en la ruta categories/fixtures/categories.json (es posible que debas crear previamente la carpeta fixtures dentro de la aplicación categories). Luego lo cargamos con el siguiente comando:
Comprobamos que tenemos las categorías cargadas en la base de datos:
from django.db import models
class Post(models.Model):
title = models.CharField(max_length=256)
slug = models.SlugField(max_length=256, unique=True)
content = models.TextField()
category = models.ForeignKey(
'categories.Category',
on_delete=models.SET_NULL,#(1)!
related_name='posts',
null=True,
blank=True,
)
def __str__(self):
return self.title
- Al eliminar una categoría, «borramos» la asignación sobre el «post».
Una vez creadas y aplicadas las migraciones del modelo, vamos a cargar algunos datos de prueba. Para ello trabajaremos con «fixtures».
Copiamos el contenido del fichero posts.json y lo guardamos en la ruta posts/fixtures/posts.json (es posible que debas crear previamente la carpeta fixtures dentro de la aplicación posts). Luego lo cargamos con el siguiente comando:
Comprobamos que tenemos los posts cargados en la base de datos:
Puntos de entrada¶
El primer paso será definir las URLs que tendrá nuestro proyecto API. En este contexto, las URLs también se conocen como puntos de entrada o «entrypoints».
Enrutadores¶
Aunque Django Ninja permite definir URLs dentro de la propia «vista» (manejador), cuando tenemos proyectos de un tamaño mediano-grande se hace recomendable dividir la organización de las URLs tal y como hemos visto para un proyecto Django «clásico». En este sentido aparecen los llamados enrutadores («routers»).
Veamos un ejemplo de organización de las URLs para nuestro proyecto del «blog»:
from ninja import NinjaAPI
api = NinjaAPI()
api.add_router('/posts/', 'posts.api.router', tags=['posts'])#(1)!
- Añadimos el enrutador de la aplicación
postsal enrutador principal de la API, indicando la ruta base/posts/y una etiquetatagspara organizar la documentación.
Importar Ninja
Aunque el paquete se llama django-ninja lo importamos como import ninja dentro de un fichero Python.
Diseño¶
A la hora de diseñar los puntos de entrada de una API hay que tener en cuenta varias cuestiones relevantes:
Utiliza sustantivos, no verbos; con plural para colecciones:
Por ejemplo utiliza /posts/ en vez de /getPosts/.
Aprovecha los métodos HTTP:
| Método | Uso típico | Ejemplo | Explicación |
|---|---|---|---|
GET |
Obtener recursos | GET /api/posts/ |
Lista todos los «posts» |
POST |
Crear recursos | POST /api/posts/ |
Crea un nuevo «post» |
PUT |
Actualizar recursos (completo) | PUT /api/posts/17 |
Actualiza (por completo) el «post» con pk=17 |
PATCH |
Actualizar recursos (parcial) | PATCH /api/posts/17 |
Actualiza (parcialmente) el «post» con pk=17 |
DELETE |
Borrar recursos | DELETE /api/posts/17 |
Borra el «post» con pk=17 |
Identifica recursos con IDs en la ruta:
Por ejemplo utiliza /posts/17 en vez de /posts?id=17.
Utiliza «query parameters» para filtros y opciones: Los parámetros de consulta sirven para filtrar, ordenar o paginar, no para identificar el recurso principal.
Por ejemplo /posts?category=2 aplicaría un filtro a todos los «posts» para obtener únicamente aquellos cuya categoría tenga pk=2.
Representa relaciones de forma jerárquica: Cuando un recurso depende de otro.
Por ejemplo /category/2/posts representaría los «posts» de la categoría con pk=2.
Versiona tu API: Muy recomendable para evitar romper clientes existentes:
Por ejemplo /api/v1/posts/ o /api/v2/posts/
URLs simples y predecibles:
- Usar
kebab-casees una buena práctica: ejemplo/api/posts/reset-category - Evita mayúsculas.
- Evita caracteres especiales.
- No incluyas formato (
.json,.xml) en la URL.
Manejo de estados y errores:
- Usa códigos HTTP correctos (200, 401, 403, 404, 405, 409, 422, 500).
- Los errores deben devolverse en el cuerpo de la respuesta, no en la ruta.
Esquemas¶
Un esquema («schema») en el contexto de Django Ninja es una forma de indicar el formato de entrada y/o salida de los datos en la API.
Permite tanto validación de datos como generación de documentación:
- La validación de datos se realiza a través de Pydantic utilizando anotaciones de tipos.
- La generación de documentación se realiza automáticamente a partir de los esquemas definidos, siguiendo la especificación OpenAPI .
Un esquema no es más que una clase Python. Esencialmente hay dos tipos:
- Esquemas basados en campos: donde definimos «manualmente» los campos que tiene el esquema, heredando de
Schema. - Esquemas basados en modelo: donde indicamos un modelo del que se extraen los campos que tiene el esquema, heredando de
ModelSchema.
Vamos a implementar como ejemplo el esquema de un «post»:
from ninja import Schema
class PostSchema(Schema):
id: int#(1)!
title: str
slug: str
content: str
- En peticiones API se suele utilizar
iden vez depk.
from ninja import ModelSchema
from .models import Post
class PostSchema(ModelSchema):
class Meta:
model = Post
fields = ['id', 'title', 'slug', 'content']
Existen otras variantes para indicar los campos a incluir en el esquema:
fields = '__all__'para incluir todos los campos del modelo.exclude = ['field1', 'field2']para excluir campos del modelo desde un iterable.
En este caso nos quedaremos con el esquema basado en modelo.
Serialización
Los esquemas se encargan —entre otras muchas cosas— de serializar/deserializar los objetos en el protocolo de comunicación.
La serialización en APIs es el proceso de convertir objetos complejos en memoria (estructuras de datos) a un formato estándar y transportable como JSON, XML o binario. Esto permite enviar datos entre cliente y servidor, asegurando la compatibilidad entre diferentes lenguajes y plataformas. La deserialización realiza el paso inverso: reconstruir el objeto a partir del formato recibido.
CRUD¶
En desarrollo de software se utiliza el acrónimo CRUD para referirse a las operaciones básicas de Crear, Leer, Actualizar y Borrar recursos. Estas operaciones se corresponden con los métodos HTTP POST, GET, PUT/PATCH y DELETE respectivamente.
Obtener recursos¶
Para obtener recursos mediante nuestra API necesitaremos implementar los manejadores correspondientes en el módulo de aplicación.
Listado de recursos¶
Vamos a empezar por un ejemplo en el que obtenemos todos los «posts» de nuestro «blog»:
from ninja import Router#(1)!
from .models import Post
from .schemas import PostSchema#(2)!
router = Router()
@router.get('/', response=list[PostSchema])#(3)!
def list_posts(request):#(4)!
"""Get a list of all posts.""" #(5)!
return Post.objects.all()#(6)!
- Necesitamos el enrutador.
- Necesitamos el esquema.
-
@router.getSe trata de una peticiónGET.'/'Acceso a la raíz del «sub-router»posts.list[PostSchema]devolvemos una lista dePostSchema.
- El manejador recibe
requestpor defecto pero ningún otro parámetro (en este caso). - Si añadimos un docstring se verá reflejado en la documentación del punto de entrada.
- Django Ninja se encarga de convertir la «queryset» en una lista de
PostSchemacomo respuesta, tal y como se indicó en el decorador.
Una vez hecho esto, podemos levantar el servidor de desarrollo y visitar http://localhost:8000/api/docs. Deberíamos ver una pantalla similar a la siguiente:

La «magia» de Django Ninja hace que tengamos documentación generada automáticamente de nuestra API siguiendo la especificación OpenAPI1. Nos aparecen todos nuestros puntos de entrada y todos nuestros esquemas.
Aquí podemos definir los parámetros (en este caso no lleva ninguno) y también podemos comprobar el esquema esperado:

Pruébalo tú mismo
Al pulsar sobre Try it out podremos probar el punto de entrada y visualizar los resultados directamente en la misma página web.
Aquí podemos comprobar los distintos campos establecidos para el esquema.

Campos obligatorios
Aquellos campos seguidos de un asterisco indica que son campos obligatorios.
Por tanto, para obtener los resultados de nuestro punto de entrada /api/posts/ —que devuelve todos los «posts» en la base de datos— tenemos varias opciones:
- http://localhost:8000/api/docs mediante la documentación generada por Django Ninja.
- http://localhost:8000/api/posts/ en cualquier navegador.
- Cliente API en línea de comandos:
$ curl -X GET http://localhost:8000/api/posts/ - Cliente API con interfaz gráfica: Por ejemplo Thunder Client.
cURL
curl es una aplicación en línea de comandos que permite realizar peticiones HTTP hacia/desde un servidor. A continuación se muestra la manera de instalarlo para distintos sistemas operativos:
- Instalación mediante
winstall.
Para que no tengas problemas (en Windows) a la hora de ejecutar es recomendable utilizar curl.exe:
En cualquiera de los casos, la salida esperada debería ser:
[
{
"id": 1,
"title": "Small Changes",
"slug": "small-changes",
"content": "Small daily changes can lead to big results."
},
{
"id": 2,
"title": "Learning Takes Time",
"slug": "learning-takes-time",
"content": "Technology moves fast, but real learning takes time."
},
{
"id": 3,
"title": "Thinking in Code",
"slug": "thinking-in-code",
"content": "Writing code is also a way of thinking."
},
{
"id": 4,
"title": "Useful Mistakes",
"slug": "useful-mistakes",
"content": "Not every error is a failure."
},
{
"id": 5,
"title": "Curiosity",
"slug": "curiosity",
"content": "Great ideas are born from curiosity."
}
]
JSON
En la mayoría de los casos las API REST manejan contenido en formato JSON, pero este comportamiento se puede modificar en Django Ninja.
Detalle de recurso¶
Otro ejemplo que podemos abordar es el de obtener un único «post» del «blog»:
from ninja import Router
from .models import Post
from .schemas import PostSchema
router = Router()
@router.get('/', response=list[PostSchema])
def list_posts(request):
return Post.objects.all()
@router.get('/{post_id}', response=PostSchema)#(1)!
def get_post(request, post_id: int):#(2)!
return Post.objects.get(pk=post_id)#(3)!
-
@router.getSe trata de una peticiónGET.'/{post_id}'Identificador del «post» (clave primaria).PostSchemadevolvemos unPostSchema.
- Necesitamos definir el parámetro
post_iden el manejador. - Consulta del «post» en la base de datos.
Si ahora «atacamos»2 este nuevo punto de entrada en http://localhost:8000/api/posts/1 deberíamos obtener el siguiente resultado:
{
"id": 1,
"title": "Small Changes",
"slug": "small-changes",
"content": "Small daily changes can lead to big results."
}
Identificador de recurso
Aunque podría ser factible utilizar el slug del «post» para identificarlo en la petición a la API, por regla general se prefiere utilizar el identificador «numérico» (clave primaria o candidata).
Filtrado de recursos¶
Otra técnica muy utilizada en el acceso a los recursos API es poder filtrarlos por una serie de parámetros. Estos parámetros habitualmente se envían mediante un query string y Django Ninja nos permite gestionarlos muy fácilmente.
Veamos un ejemplo en el que filtramos los «posts» por su categoría:
from ninja import Router
from .models import Post
from .schemas import PostSchema
router = Router()
@router.get('/', response=list[PostSchema])
def list_posts(request, category_id: int = None):#(1)!
posts = Post.objects.all()
if category_id:#(2)!
posts = posts.filter(category__pk=category_id)#(3)!
return posts
@router.get('/{post_id}', response=PostSchema)
def get_post(request, post_id: int):
return Post.objects.get(pk=post_id)
- Definimos el parámetro
category_idcomo un query parameter opcional (con valor por defectoNone). - Comprobamos si se ha proporcionado el parámetro
category_iden la petición. - Si se ha proporcionado el parámetro, filtramos los «posts» por la categoría correspondiente.
Si ahora «atacamos»2 este nuevo punto de entrada en http://localhost:8000/api/posts/?category_id=1 deberíamos obtener el siguiente resultado:
[
{
"id": 1,
"title": "Small Changes",
"slug": "small-changes",
"content": "Small daily changes can lead to big results."
},
{
"id": 3,
"title": "Thinking in Code",
"slug": "thinking-in-code",
"content": "Writing code is also a way of thinking."
}
]
Como se puede observar, el resultado se ha filtrado para mostrar únicamente los «posts» que pertenecen a la categoría con pk=1 (Diseño).
Parametros
Cualquier parámetro que se añada al manejador y que no forme parte de la ruta se considera un query parameter y se puede gestionar de esta forma.
Claves ajenas¶
En el ejemplo anterior, el esquema PostSchema no incluye información de la categoría a la que pertenece cada «post». Sin embargo, podemos modificar el esquema para incluir esta información:
from ninja import ModelSchema
from .models import Post
class PostSchema(ModelSchema):
class Meta:
model = Post
fields = '__all__'#(1)!
- Ahora incluimos todos los campos del modelo
Post, incluyendo la clave ajenacategory. Esto hará que en la respuesta de la API se incluya el identificador de la categoría a la que pertenece cada «post».
Veamos la respuesta obtenida al acceder a http://localhost:8000/api/posts/ con esta nueva configuración:
[
{
"id": 1,
"title": "Small Changes",
"slug": "small-changes",
"content": "Small daily changes can lead to big results.",
"category": 1
},
{
"id": 2,
"title": "Learning Takes Time",
"slug": "learning-takes-time",
"content": "Technology moves fast, but real learning takes time.",
"category": 2
},
{
"id": 3,
"title": "Thinking in Code",
"slug": "thinking-in-code",
"content": "Writing code is also a way of thinking.",
"category": 1
},
{
"id": 4,
"title": "Useful Mistakes",
"slug": "useful-mistakes",
"content": "Not every error is a failure.",
"category": 2
},
{
"id": 5,
"title": "Curiosity",
"slug": "curiosity",
"content": "Great ideas are born from curiosity.",
"category": 2
}
]
Esquemas anidados¶
Por defecto, el campo category ahora muestra el identificador de la categoría a la que pertenece cada «post». Si queremos mostrar información más detallada de la categoría, podríamos crear un nuevo esquema para la categoría y utilizarlo dentro del esquema del «post». Es lo que se conoce como esquemas anidados:
from ninja import ModelSchema
from categories.schemas import CategorySchema
from .models import Post
class PostSchema(ModelSchema):
category: CategorySchema = None#(1)!
class Meta:
model = Post
fields = ['id', 'title', 'slug', 'content']#(2)!
- Definimos el campo
categorycomo unCategorySchemaopcional (con valor por defectoNone). Esto hará que en la respuesta de la API se incluya toda la información de la categoría a la que pertenece cada «post», en lugar de solo su identificador. - Ahora solo incluimos los campos
id,title,slugycontentdel modeloPost, ya que el campocategorylo hemos definido de forma explícita.
Con esta configuración, la respuesta de la API al acceder a http://localhost:8000/api/posts/ sería la siguiente:
[
{
"category": {
"id": 1,
"name": "Design",
"slug": "design"
},
"id": 1,
"title": "Small Changes",
"slug": "small-changes",
"content": "Small daily changes can lead to big results."
},
{
"category": {
"id": 2,
"name": "Learning",
"slug": "learning"
},
"id": 2,
"title": "Learning Takes Time",
"slug": "learning-takes-time",
"content": "Technology moves fast, but real learning takes time."
},
{
"category": {
"id": 1,
"name": "Design",
"slug": "design"
},
"id": 3,
"title": "Thinking in Code",
"slug": "thinking-in-code",
"content": "Writing code is also a way of thinking."
},
{
"category": {
"id": 2,
"name": "Learning",
"slug": "learning"
},
"id": 4,
"title": "Useful Mistakes",
"slug": "useful-mistakes",
"content": "Not every error is a failure."
},
{
"category": {
"id": 2,
"name": "Learning",
"slug": "learning"
},
"id": 5,
"title": "Curiosity",
"slug": "curiosity",
"content": "Great ideas are born from curiosity."
}
]
Buenas prácticas
Por lo general, es más habitual mostrar solo el identificador de la categoría en el esquema del «post» para evitar respuestas demasiado pesadas, especialmente cuando se trata de relaciones de muchos a muchos o cuando la información relacionada es muy extensa. Sin embargo, esto depende del caso de uso específico y de las necesidades de la API.
Si analizamos el ejemplo del listado de «posts», únicamente a nivel de «tamaño de respuesta»:
- El «payload» de la respuesta JSON usando identificador de clave ajena ocupa 806 bytes.
- El «payload» de la respuesta JSON usando esquemas anidados ajena ocupa 1158 bytes. Esto supone un 70% más que en el primer caso.
Campos calculados¶
En ocasiones, es posible que queramos incluir en la respuesta de la API campos que no existen en el modelo pero que se calculan a partir de otros campos. O incluso que existiendo, lleven una lógica adicional.
Para ello debemos utilizar los llamados «resolvers». Si queremos devolver un campo field debemos implementar el método estático resolve_field() en el esquema correspondiente.
Supongamos un ejemplo en el que queremos incluir un campo summary en el esquema del «post» que contenga un resumen del contenido del «post»:
from ninja import ModelSchema, Schema
from .models import Post
class PostSchema(ModelSchema):
summary: str#(1)!
class Meta:
model = Post
fields = ['id', 'title', 'slug', 'content']#(2)!
@staticmethod
def resolve_summary(post: Post) -> str:#(3)!
MAX_SUMMARY_LENGTH = 10
if len(content := str(post.content)) > MAX_SUMMARY_LENGTH:
return content[:MAX_SUMMARY_LENGTH] + '...'
return content
- Definimos el campo
summarycomo un campo de tipostr. - Incluimos los campos
id,title,slugycontentdel modeloPost, pero no incluimos el camposummaryporque lo vamos a calcular de forma dinámica. -
- Definimos el método
resolve_summaryque se encargará de calcular el valor del camposummary. - Recibe como parámetro el objeto («post») que el esquema está resolviendo.
- Definimos el método
Si ahora comprobamos la respuesta de la API al acceder a http://localhost:8000/api/posts/ sería algo similar a lo siguiente:
[
{
"summary": "Small dail...",
"id": 1,
"title": "Small Changes",
"slug": "small-changes",
"content": "Small daily changes can lead to big results."
},
{
"summary": "Technology...",
"id": 2,
"title": "Learning Takes Time",
"slug": "learning-takes-time",
"content": "Technology moves fast, but real learning takes time."
},
{
"summary": "Writing co...",
"id": 3,
"title": "Thinking in Code",
"slug": "thinking-in-code",
"content": "Writing code is also a way of thinking."
},
{
"summary": "Not every ...",
"id": 4,
"title": "Useful Mistakes",
"slug": "useful-mistakes",
"content": "Not every error is a failure."
},
{
"summary": "Great idea...",
"id": 5,
"title": "Curiosity",
"slug": "curiosity",
"content": "Great ideas are born from curiosity."
}
]
Supongamos un ejemplo en el que queremos que el campo title del esquema del «post» devuelva el título en mayúsculas:
from ninja import ModelSchema, Schema
from .models import Post
class PostSchema(ModelSchema):
title: str#(1)!
class Meta:
model = Post
fields = ['id', 'slug', 'content']#(2)!
@staticmethod
def resolve_title(post: Post) -> str:#(3)!
return post.title.upper()
- Definimos el campo
titlecomo un campo de tipostr(sin valor por defecto, por lo que es obligatorio). - Incluimos los campos
id,slugycontentdel modeloPost, pero no incluimos el campotitleporque lo vamos a calcular de forma dinámica. -
- Definimos el método
resolve_titleque se encargará de calcular el valor del campotitle. - Recibe como parámetro el objeto («post») que el esquema está resolviendo.
- Definimos el método
Si ahora comprobamos la respuesta de la API al acceder a http://localhost:8000/api/posts/ sería algo similar a lo siguiente:
[
{
"title": "SMALL CHANGES",
"id": 1,
"slug": "small-changes",
"content": "Small daily changes can lead to big results."
},
{
"title": "LEARNING TAKES TIME",
"id": 2,
"slug": "learning-takes-time",
"content": "Technology moves fast, but real learning takes time."
},
{
"title": "THINKING IN CODE",
"id": 3,
"slug": "thinking-in-code",
"content": "Writing code is also a way of thinking."
},
{
"title": "USEFUL MISTAKES",
"id": 4,
"slug": "useful-mistakes",
"content": "Not every error is a failure."
},
{
"title": "CURIOSITY",
"id": 5,
"slug": "curiosity",
"content": "Great ideas are born from curiosity."
}
]
Paginación¶
Cuando el número de recursos a devolver es muy grande, es recomendable implementar algún mecanismo de paginación para evitar respuestas demasiado pesadas. Django Ninja ofrece soporte para paginación de forma nativa, lo que facilita su implementación.
Supongamos por ejemplo que queremos implementar una paginación simple en el punto de entrada que devuelve el listado de «posts»:
from ninja import Router
from ninja.pagination import paginate
from .models import Post
from .schemas import PostSchema
router = Router()
@router.get('/', response=list[PostSchema])
@paginate
def list_posts(request, category_id: str = None):
posts = Post.objects.all()
if category_id:
posts = posts.filter(category__pk=category_id)
return posts
@router.get('/{post_id}', response=PostSchema)
def get_post(request, post_id: int):
return Post.objects.get(pk=post_id)
Aparecerán dos nuevos parámetros de consulta en el punto de entrada /api/posts/ para controlar la paginación:
-
limit: número máximo de recursos a devolver en la respuesta. -
offset: número de recursos a saltar antes de empezar a devolver resultados.
Esquema paginado
Si visitamos la documentación del proyecto en http://localhost:8000/api/docs veremos que aparece un «nuevo» esquema PagedPostSchema. Se genera de manera automática al añadir paginación sobre el modelo PostSchema.
Así las cosas, si hacemos por ejemplo una petición GET a http://localhost:8000/api/posts?limit=2&offset=0 obtendríamos la siguiente respuesta:
{
"items": [
{
"id": 1,
"title": "Small Changes",
"slug": "small-changes",
"content": "Small daily changes can lead to big results.",
"category": 1
},
{
"id": 2,
"title": "Learning Takes Time",
"slug": "learning-takes-time",
"content": "Technology moves fast, but real learning takes time.",
"category": 2
}
],
"count": 5
}
Respuesta paginada
Nótese la diferencia en la estructura de la respuesta al utilizar paginación. En este caso, la respuesta es un objeto JSON con dos campos:
items: una lista de los recursos devueltos en la página actual.count: el número total de recursos disponibles (sin paginar).
Crear recursos¶
Para crear recursos mediante nuestra API necesitaremos implementar los manejadores correspondientes en el módulo de aplicación, utilizando el método POST y definiendo un esquema de entrada que indique los datos necesarios para crear el recurso.
Veamos un ejemplo en el que creamos un nuevo «post» en nuestro «blog»:
Vamos a añadir un método save() al modelo Post para que se genere automáticamente el slug correspondiente al título del «post» al guardarlo en la base de datos:
from django.db import models
from django.utils.text import slugify
class Post(models.Model):
title = models.CharField(max_length=256)
slug = models.SlugField(max_length=256, unique=True)
content = models.TextField()
category = models.ForeignKey(
'categories.Category',
on_delete=models.CASCADE,
related_name='posts',
null=True,
blank=True,
)
def __str__(self):
return self.title
def save(self, *args, **kwargs):
if not self.slug:#(1)!
self.slug = slugify(self.title)
super().save(*args, **kwargs)
- Sólo generamos el
slugsi no existe ya uno asignado, para evitar que se sobrescriba elslugcada vez que se guarde el «post» (por ejemplo, al actualizarlo).
Se hace necesario definir un esquema de entrada y un esquema de salida para el recurso «post». El esquema de entrada indicará los datos necesarios para crear un nuevo «post», mientras que el esquema de salida indicará los datos que se devolverán una vez creado el «post».
from ninja import ModelSchema
from .models import Post
class PostSchemaIn(ModelSchema):
class Meta:
model = Post
fields = ['title', 'content']#(1)!
class PostSchemaOut(ModelSchema):
class Meta:
model = Post
fields = ['id', 'title', 'slug', 'content', 'category']
-
- No se incluyen los campos
idyslugdel esquema de entrada porque elidse genera automáticamente al crear el recurso y elslugse genera automáticamente a partir deltitleen el métodosave()del modelo. - Igualmente no se añade el campo
categoryporque se verá en el próximo epígrafe claves ajenas.
- No se incluyen los campos
El manejador («route handler») debe usar los esquemas de entrada y salida para gestionar la creación del nuevo recurso:
from ninja import Router
from .models import Post
from .schemas import PostSchemaIn, PostSchemaOut
router = Router()
@router.post('/', response=PostSchemaOut)#(1)!
def create_post(request, post: PostSchemaIn):#(2)!
return Post.objects.create(**post.dict())#(3)!
- La respuesta del punto de entrada será un
PostSchemaOut, que incluye elidy elsluggenerados automáticamente al crear el nuevo «post». - El manejador recibe un objeto
postde tipoPostSchemaIn, que contiene los datos necesarios para crear el nuevo «post». -
- Creamos el nuevo «post» en la base de datos utilizando los datos proporcionados en el esquema de entrada desplegando el diccionario de datos.
- Por ejemplo si «post» tiene título Django handlers y contenido Handlers can manage entrypoints,
**post.dict()title='Django handlers', content='Handlers can manage entrypoints
Ahora podemos hacer una petición POST a http://localhost:8000/api/posts/ con el siguiente cuerpo («json body») para crear un nuevo «post»:
{
"title": "Focused Progress",
"content": "Small consistent steps create real progress."
}
La respuesta esperada sería la siguiente:
{
"id": 6,
"title": "Focused Progress",
"slug": "focused-progress",
"content": "Small consistent steps create real progress.",
"category": null
}
Claves ajenas¶
Si queremos asignar una categoría (clave ajena) al nuevo «post» que estamos creando, debemos modificar ligeramente el manejador del punto de entrada:
from ninja import ModelSchema
from .models import Post
class PostSchemaIn(ModelSchema):
class Meta:
model = Post
fields = ['title', 'content', 'category']#(1)!
class PostSchemaOut(ModelSchema):
class Meta:
model = Post
fields = ['id', 'title', 'slug', 'content', 'category']
- Añadimos el campo
categorypara poder indicar el identificador de la categoría al crear un nuevo «post».
from ninja import Router
from categories.models import Category
from .models import Post
from .schemas import PostSchemaIn, PostSchemaOut
router = Router()
@router.post('/', response=PostSchemaOut)
def create_post(request, post: PostSchemaIn):
payload = post.dict()
category_id = payload.pop('category', None)#(1)!
category = Category.objects.get(pk=category_id) if category_id else None#(2)!
return Post.objects.create(category=category, **payload)#(3)!
- Extraemos el identificador de la categoría del cuerpo de la petición.
- Obtenemos el objeto
Categorycorrespondiente al identificador proporcionado (si se ha proporcionado alguno). - Creamos el nuevo «post» con la categoría asignada y el resto de campos.
Ahora podemos hacer una petición POST a http://localhost:8000/api/posts/ con el siguiente cuerpo («json body») para crear un nuevo «post» con categoría asignada:
{
"title": "Embrace Iteration",
"content": "Improve a little every day.",
"category": 1 // Design
}
La respuesta esperada sería la siguiente:
{
"id": 7,
"title": "Embrace Iteration",
"slug": "embrace-iteration",
"content": "Improve a little every day.",
"category": 1
}
Otras validaciones¶
Supongamos por ejemplo que a la hora de crear un «post» necesitamos disponer de un código de verificación de seguridad antes de almacenar el «post» en la base de datos. Este código tiene formato DDD-DD-DDDD.
Haciendo uso de los recursos que proporciona Pydantic para configuración de modelos podemos añadir esta validación (regex) en el propio esquema:
from ninja import ModelSchema
from pydantic import Field#(1)!
from .models import Post
class PostSchemaIn(ModelSchema):
vericode: str = Field(pattern=r'^\d{3}-\d{2}-\d{4}$')#(2)!
class Meta:
model = Post
fields = ['title', 'content', 'category']
class PostSchemaOut(ModelSchema):
class Meta:
model = Post
fields = '__all__'
- Importamos el modelo
Fielddesde Pydantic. - Definimos el patrón de expresión regular para el nuevo campo
vericode.
from ninja import Router
from categories.models import Category
from .models import Post
from .schemas import PostSchemaIn, PostSchemaOut
router = Router()
@router.post('/', response=PostSchemaOut)
def create_post(request, post: PostSchemaIn):
VERIFICATION_CODE = '123-45-6789'#(1)!
payload = post.dict()
if payload.pop('vericode') != VERIFICATION_CODE:#(2)!
raise ValueError('Invalid verification code')#(3)!
category_id = payload.pop('category', None)
category = Category.objects.get(pk=category_id) if category_id else None
post = Post.objects.create(category=category, **payload)
return post
- Establecemos el código de verificación que debe cumplirse.
- Extraemos el código de verificación del payload y comprobamos si es correcto.
- En caso que sea incorrecto, elevamos una excepción.
Esta aproximación tiene la ventaja de que el valor de entrada de vericode es validado de forma automática por Ninja Pydantic. Si no cumple con la expresión regular indicada, se notificará un error en la respuesta HTTP correspondiente.
Actualizar recursos¶
A la hora de actualizar recursos mediante nuestra API, tenemos dos opciones:
- Actualización completa: utilizando el método
PUT, donde se actualizan todos los campos del recurso, incluso aquellos que no se proporcionan en la petición (en cuyo caso se establecerían anullo a su valor por defecto). - Actualización parcial: utilizando el método
PATCH, donde se actualizan únicamente los campos que se proporcionan en la petición, manteniendo el resto de campos sin cambios.
Actualización completa¶
Para actualizar recursos mediante nuestra API necesitaremos implementar los manejadores correspondientes en el módulo de aplicación, utilizando el método PUT y definiendo un esquema de entrada que indique los datos necesarios.
Veamos un ejemplo en el que actualizamos un «post» de nuestro «blog»:
from ninja import Router
from .models import Post
from .schemas import PostSchemaIn, PostSchemaOut
router = Router()
@router.put('/{post_id}', response=PostSchemaOut)
def update_post(request, post_id: int, post: PostSchemaIn):
payload = post.dict()
category_id = payload.pop('category', None)
category = Category.objects.get(pk=category_id) if category_id else None
payload['category'] = category#(1)!
post_obj = Post.objects.get(pk=post_id)
for attr, value in payload.items():#(2)!
setattr(post_obj, attr, value)#(3)!
post_obj.save()#(4)!
return post_obj#(5)!
- Añadimos la categoría al «payload» que estamos manejando.
- Recorremos los elementos del «payload».
- Asignamos los nuevos valores a los atributos del objeto
post_objutilizando la funciónsetattr(). - Guardamos los cambios en la base de datos.
- Devolvemos el objeto actualizado como respuesta. Al existir un esquema de salida definido, Django Ninja se encargará de convertir el objeto en el formato adecuado para la respuesta.
Supongamos que queremos actualizar el «post» con id=7 para cambiar su título y su contenido. Para ello, haríamos una petición PUT a http://localhost:8000/api/posts/7 con el siguiente cuerpo («json body»):
{
"title": "Small Changes, Big Results",
"content": "Small daily changes can lead to big results. Consistency is key."
}
La respuesta esperada sería la siguiente:
{
"id": 7,
"title": "Small Changes, Big Results",
"slug": "embrace-iteration",
"content": "Small daily changes can lead to big results. Consistency is key.",
"category": 1
}
Slug
La decisión de actualizar o no el slug al cambiar el title depende del caso de uso específico. En algunos casos puede ser deseable mantener el mismo slug para evitar romper enlaces existentes, mientras que en otros casos puede ser preferible actualizar el slug para que refleje el nuevo título. En nuestro ejemplo, hemos decidido no actualizar el slug para mantener la consistencia de los enlaces.
Actualización parcial¶
Para actualizar recursos de forma parcial mediante nuestra API, el proceso es similar al de la actualización completa, pero utilizando el método PATCH y permitiendo que el esquema de entrada tenga campos opcionales.
Veamos un ejemplo en el que actualizamos parcialmente un «post» de nuestro «blog»:
from ninja import ModelSchema
from .models import Post
class PostSchemaIn(ModelSchema):
class Meta:
model = Post
fields = ['title', 'content', 'category']
class PostSchemaPatch(ModelSchema):#(1)!
class Meta:
model = Post
fields = ['title', 'content', 'category']
fields_optional = '__all__'#(2)!
class PostSchemaOut(ModelSchema):
class Meta:
model = Post
fields = ['id', 'title', 'slug', 'content', 'category']
- Definimos un nuevo esquema
PostSchemaPatchpara la actualización parcial. - Utilizamos
fields_optional = '__all__'para indicar que todos los campos del esquema de entrada son opcionales, lo que permite realizar una actualización parcial.
from ninja import Router
from .models import Post
from .schemas import PostSchemaOut, PostSchemaPatch
router = Router()
@router.patch('/{post_id}', response=PostSchemaOut)
def partial_update_post(request, post_id: int, post: PostSchemaPatch):
payload = post.dict(exclude_unset=True)#(1)!
if 'category' in payload:#(2)!
category_id = payload.pop('category', None)
category = Category.objects.get(id=category_id) if category_id else None
payload['category'] = category
post_obj = Post.objects.get(id=post_id)
for attr, value in payload.items():
setattr(post_obj, attr, value)
post_obj.save()
return post_obj
- Obtenemos los datos de entrada como diccionario. En este caso, utilizamos
exclude_unset=Truepara excluir aquellos campos que no se han proporcionado en la petición, lo que permite realizar una actualización parcial. - Solo gestionamos el caso de la categoría si ha sido incluida en la actualización (payload).
Supongamos que queremos actualizar parcialmente el «post» con id=7 para cambiar únicamente su contenido. Para ello, haríamos una petición PATCH a http://localhost:8000/api/posts/7 con el siguiente cuerpo («json body»):
{
"content": "Small daily changes can lead to big results. Consistency is key. Embrace the journey."
}
La respuesta esperada sería la siguiente:
{
"id": 7,
"title": "Small Changes, Big Results",
"slug": "embrace-iteration",
"content": "Small daily changes can lead to big results. Consistency is key. Embrace the journey.",
"category": 1
}
Borrar recursos¶
Para borrar recursos mediante nuestra API necesitaremos implementar los manejadores correspondientes en el módulo de aplicación, utilizando el método DELETE.
Veamos un ejemplo en el que borramos un «post» de nuestro «blog»:
from ninja import Router
from .models import Post
router = Router()
@router.delete('/{post_id}')
def delete_post(request, post_id: int):
post = Post.objects.get(pk=post_id)
post.delete()
return {'detail': 'Post deleted successfully'}#(1)!
- Es perfectamente válido devolver un diccionario, ya que Django Ninja se encargará de serializarlo automáticamente a JSON para la respuesta.
Esquemas
Nótese que en este caso no es necesario definir un esquema de entrada ni un esquema de salida, ya que el manejador no recibe ningún dato adicional para identificar el recurso a borrar (más allá del post_id en la ruta) y la respuesta es simplemente un mensaje de éxito (diccionario) que Django Ninja serializa automáticamente.
Supongamos que queremos borrar el «post» con id=7. Para ello, haríamos una petición DELETE a http://localhost:8000/api/posts/7 sin necesidad de incluir un cuerpo en la petición. La respuesta esperada sería la siguiente:
Gestión de errores¶
En el desarrollo de una API es fundamental gestionar adecuadamente los errores que puedan ocurrir durante el procesamiento de las peticiones. Django Ninja proporciona varias herramientas para manejar errores de forma eficiente y devolver respuestas adecuadas a los clientes de la API.
A la hora de devolver un error desde un manejador, es importante utilizar el código de estado HTTP correcto para indicar el tipo de error que ha ocurrido e incluir un «response body» en formato JSON con un mensaje de error claro y detallado.
Según el RFC 9457 (Problem Details for HTTP APIs) es una buena práctica incluir un campo detail en el cuerpo de la respuesta de error, que contenga información adicional sobre el error ocurrido.
Validación de datos¶
Cuando se reciben datos en una petición, Django Ninja realiza automáticamente la validación de los datos según los esquemas definidos. Si los datos no cumplen con las validaciones establecidas en el esquema, se devuelve una respuesta con un código de estado HTTP 422 (Unprocessable Content) y un mensaje de error detallado.
Por ejemplo si intentamos crear un nuevo «post» sin proporcionar el campo title, que es obligatorio según nuestro esquema de entrada, obtendremos la siguiente respuesta:
{
"detail": [
{
"type": "missing",
"loc": [
"body",
"post",
"title"
],
"msg": "Field required"
}
]
}
Otro ejemplo sería intentar crear un nuevo «post» con un tipo de dato incorrecto para el campo category (por ejemplo, una cadena en lugar de un número):
{
"detail": [
{
"type": "int_parsing",
"loc": [
"body",
"post",
"category_id"
],
"msg": "Input should be a valid integer, unable to parse string as an integer"
}
]
}
Petición mal formada¶
Si un cliente hace una petición con un formato incorrecto (por ejemplo, un cuerpo de petición que no es un JSON válido), Django Ninja devolverá automáticamente una respuesta con un código de estado HTTP 400 (Bad Request) y un mensaje de error indicando que la petición está mal formada.
Por ejemplo si intentamos hacer una petición POST a http://localhost:8000/api/posts/ con el siguiente cuerpo mal formado:
{
"title": "Problem with request",
"content": "Trailing comma at the end of json body",
"category_id": 1,//(1)!
}
- El cuerpo de la petición no es un JSON válido debido a la coma al final del campo
title, lo que hará que Django Ninja devuelva un error de petición mal formada.
Obtendríamos la siguiente respuesta:
{
"detail": "Cannot parse request body (Illegal trailing comma before end of object: line 3 column 19 (char 20))"
}
Método no permitido¶
Si un cliente intenta acceder a un punto de entrada utilizando un método HTTP que no está permitido (por ejemplo, haciendo una petición POST a un punto de entrada que solo permite GET), Django Ninja devolverá automáticamente una respuesta con un código de estado HTTP 405 (Method Not Allowed) y un mensaje de error indicando que el método no está permitido.
Por ejemplo si intentamos hacer una petición PUT a http://localhost:8000/api/posts/ (que solo permite GET o POST), obtendremos la siguiente respuesta:
Recurso no encontrado¶
Una forma bastante sencilla de gestionar el error de recurso no encontrado es utilizar el método get_object_or_404() de Django, que devuelve una respuesta con un código de estado HTTP 404 (Not Found) si el recurso no existe.
Por ejemplo en el manejador de obtención de detalle de un «post», podríamos modificar la consulta para utilizar get_object_or_404() de la siguiente manera:
from django.shortcuts import get_object_or_404
from ninja import Router
from .models import Post
from .schemas import PostSchemaOut
router = Router()
@router.get('/{post_id}', response=PostSchemaOut)
def get_post(request, post_id: int):
return get_object_or_404(Post, pk=post_id)
Si ahora intentamos acceder a un «post» que no existe (por ejemplo, con id=999) http://localhost:8000/api/posts/999 obtendremos la siguiente respuesta:
Devolviendo errores¶
En algunos casos, es posible que queramos devolver un error personalizado con un mensaje específico y un código de estado HTTP determinado. Para ello, Django Ninja proporciona la clase HttpError que nos permite crear respuestas de error personalizadas.
Supongamos por ejemplo que hay una serie de «posts» restringidos en nuestro «blog». Por lo tanto, queremos devolver un error de acceso denegado HTTP 403 (Forbidden) si el usuario intenta acceder a uno de estos «posts» restringidos. Podríamos modificar el manejador de obtención de detalle del «post» de la siguiente manera:
from django.shortcuts import get_object_or_404
from ninja import Router
from ninja.errors import HttpError
from .models import Post
from .schemas import PostSchemaOut
router = Router()
@router.get('/{post_id}', response=PostSchemaOut)
def get_post(request, post_id: int):
RESTRICTED_POSTS_IDS = [1, 2, 3]
if post_id in RESTRICTED_POSTS_IDS:
raise HttpError(403, 'Access to this post is restricted')
return get_object_or_404(Post, pk=post_id)
Si ahora intentamos acceder a uno de los «posts» restringidos (por ejemplo, con id=1) http://localhost:8000/api/posts/1 obtendremos la siguiente respuesta:
Elevar excepción
Al estar gestionando errores, no se trata de devolver la excepción sino de lanzarla, utilizando para ello raise HttpError().
Autenticación¶
En el desarrollo de una API, es fundamental implementar mecanismos de autenticación para proteger los recursos y garantizar que solo los usuarios autorizados puedan acceder a ellos. Django Ninja proporciona varias opciones de autenticación que se pueden configurar fácilmente.
Django Ninja ofrece los siguientes métodos de autenticación:
- Token Authentication: Utiliza un token único para cada usuario que se incluye en las peticiones para autenticar al usuario. Es sencillo de implementar y adecuado para aplicaciones móviles o clientes que no pueden manejar cookies.
- Session Authentication: Utiliza las sesiones de Django para autenticar a los usuarios. Es adecuado para aplicaciones web tradicionales donde el cliente puede manejar cookies.
- Bearer Authentication: Utiliza tokens de portador (Bearer tokens) que se incluyen en las cabeceras de la petición para autenticar al usuario. Es comúnmente utilizado en APIs RESTful y es compatible con OAuth2.
En esta sección nos centraremos en el método de autenticación HTTP Bearer por ser el más comúnmente utilizado en APIs RESTful, aunque los conceptos y técnicas que veremos también pueden aplicarse a otros métodos de autenticación.
HTTP Bearer¶
El método de autenticación HTTP Bearer es una forma común de autenticar a los usuarios en una API RESTful. Consiste en incluir un token de portador («bearer token») en la cabecera («headers») de las peticiones HTTP para autenticar al usuario.
| Headers | |
|---|---|
Definiendo el modelo¶
Lo primero que necesitamos es definir un modelo que nos permita almacenar el token de autenticación de cada usuario/a.
Para ello vamos a empezar creando una aplicación llamada users que gestione todo lo relacionado con los usuarios y la autenticación. Luego, añadimos el siguiente modelo en users/models.py para almacenar los tokens de autenticación:
import uuid
from django.conf import settings
from django.db import models
class Token(models.Model):
key = models.UUIDField(unique=True, default=uuid.uuid4, editable=False)#(1)!
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)#(2)!
created_at = models.DateTimeField(auto_now_add=True)#(3)!
def __str__(self):
return str(self.key)
-
- Este campo almacenará el valor de la clave (token) de tipo UUID.
- Le damos un valor por defecto que en este caso será un «callable» (función)
uuid.uuid4() - Indicar
editable=Falsehace que no se pueda editar desde la interfaz administrativa.
- Necesitamos vincularlo con la clase
Userque nos proporciona Django. -
- Añadimos un atributo para tener el momento en el que se creó el token.
- También se podría haber añadido un atributo
expires_atque indica cuándo expira.
Una vez creadas y aplicadas las migraciones del modelo, vamos a cargar algunos datos de prueba. Para ello trabajaremos con «fixtures».
Copiamos el contenido del fichero users.json y lo guardamos en la ruta users/fixtures/users.json (es posible que debas crear previamente la carpeta fixtures dentro de la aplicación users). Luego lo cargamos con el siguiente comando:
Comprobamos que tenemos datos de autenticación cargados en la base de datos:
$ uv run manage.py shell -v0 -c 'for t in Token.objects.all(): print(t.user, t.key)'
guido 40e5f786-1210-45f5-9e5d-f76925a9e98a
Contraseña
La contraseña creada para el usuario guido es pythoncreator
Obteniendo el token¶
El protocolo HTTP Bearer se basa en el siguiente flujo de autenticación:
sequenceDiagram
participant c as Client
participant s as Server
c->>s: ¡Hola! Quiero autenticarme
s-->>c: Necesito nombre de usuario y contraseña
c->>s: guido | 1234
s-->>c: Correcto. Tu token es A65FF32B8
Por lo tanto, vamos a implementar un punto de entrada en nuestra API que permita a los usuarios obtener su token de autenticación proporcionando nombre de usuario y contraseña:
from django.contrib.auth import get_user_model
from ninja import ModelSchema
from .models import Token
User = get_user_model()
class AuthSchemaIn(ModelSchema):
class Meta:
model = User
fields = ['username', 'password']
class TokenSchemaOut(ModelSchema):
class Meta:
model = Token
fields = ['user', 'key']
from django.contrib.auth import authenticate
from django.shortcuts import get_object_or_404
from ninja import Router
from ninja.errors import HttpError
from .models import Token
from .schemas import AuthSchemaIn, TokenSchemaOut
router = Router()
@router.post('/auth/', response=TokenSchemaOut)
def auth(request, auth: AuthSchemaIn):
if not (user := authenticate(request, username=auth.username, password=auth.password)):
raise HttpError(401, 'Invalid credentials')
return get_object_or_404(Token, user=user)
Ahora podemos hacer una petición POST a http://localhost:8000/api/users/auth/ con el siguiente cuerpo («json body») para obtener el token de autenticación:
La respuesta esperada sería la siguiente:
Protegiendo recursos¶
Una vez que los usuarios pueden obtener su token de autenticación, el siguiente paso es proteger los recursos de nuestra API para que solo los usuarios autenticados puedan acceder a ellos.
Lo primero que debemos hacer es definir una clase de autenticación personalizada que verifique el token incluido en las peticiones. Para ello, creamos un nuevo archivo users/auth.py con el siguiente contenido:
from ninja.security import HttpBearer
from .models import Token
class AuthBearer(HttpBearer):#(1)!
def authenticate(self, request, token):#(2)!
try:
token_obj = Token.objects.get(key=token)#(3)!
except Token.DoesNotExist:
return None#(4)!
return token_obj.user#(5)!
- Esta clase hereda de
HttpBearery sobrescribe el métodoauthenticate(), que se encarga de verificar el token incluido en las peticiones. - El método recibe la petición HTTP
requesty el token extraído de la cabecera de autenticación («headers»). - Intentamos obtener el objeto
Tokencorrespondiente al token proporcionado. - Si el token no existe, devolvemos
None, lo que indica que la autenticación ha fallado. - Si el token es válido, devolvemos el usuario asociado a ese token, lo que indica que la autenticación ha sido exitosa.
En el ejemplo mostrado a continuación protegemos el punto de entrada de creación de un nuevo «post» para que solo los usuarios autenticados puedan acceder a él:
from ninja import Router
from categories.models import Category
from users.auth import AuthBearer
from .models import Post
from .schemas import PostSchemaIn, PostSchemaOut
router = Router()
@router.post('/', response=PostSchemaOut, auth=AuthBearer())#(1)!
def create_post(request, post: PostSchemaIn):#(2)!
payload = post.dict()
category_id = payload.pop('category', None)
category = Category.objects.get(pk=category_id) if category_id else None
post = Post.objects.create(category=category, **payload)
return post
- Añadimos el parámetro
auth=AuthBearer()al decorador del manejador para indicar que este punto de entrada requiere autenticación utilizando la claseAuthBearerque hemos definido previamente. -
- El parámetro
request.authdentro del manejador contendrá lo que devuelva el métodoAuthBearer.authenticate(). - En este caso contiene el usuario autenticado (si la autenticación ha sido exitosa) o
None(si la autenticación ha fallado).
- El parámetro
Por lo tanto, si intentamos crear un nuevo «post» sin incluir el token de autenticación en la cabecera de la petición, obtendremos la siguiente respuesta:
Autenticación
En la interfaz gráfica de la documentación de la API, se indicará que el punto de entrada requiere autenticación y se mostrará un candado para introducir el token de autenticación. Una vez introducido el token, podremos acceder al punto de entrada y crear nuevos «posts» normalmente.
Subida de ficheros¶
Si queremos permitir la subida de ficheros a través de nuestra API, Django Ninja nos ofrece soporte nativo para gestionar este tipo de escenarios, teniendo en cuenta que el formato de la petición debe ser multipart/form-data.
En el siguiente ejemplo vamos a modificar el modelo Post para añadir una portada que se pueda subir a través de la API:
from django.db import models
from django.utils.text import slugify
class Post(models.Model):
title = models.CharField(max_length=256)
slug = models.SlugField(max_length=256, unique=True)
content = models.TextField()
category = models.ForeignKey(
'categories.Category',
on_delete=models.CASCADE,
related_name='posts',
null=True,
blank=True,
)
cover = models.ImageField(
upload_to='post/covers/',
null=True,
blank=True,
)
def __str__(self):
return self.title
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.title)
super().save(*args, **kwargs)
Una vez creadas y aplicadas las migraciones del modelo, vamos a establecer los esquemas de entrada y salida, así como el manejador del punto de entrada para gestionar la creación de un nuevo «post» con portada:
from ninja import ModelSchema
from .models import Post
class PostSchemaIn(ModelSchema):
class Meta:
model = Post
fields = ['title', 'content', 'category']#(1)!
class PostSchemaOut(ModelSchema):
class Meta:
model = Post
fields = '__all__'
- Dejamos fuera el campo
coverdel esquema de entrada porque lo gestionaremos de forma separada en los parámetros del manejador.
from ninja import File, Form, Router, UploadedFile
from categories.models import Category
from .models import Post
from .schemas import PostSchemaIn, PostSchemaOut
router = Router()
@router.post('/', response=PostSchemaOut)
def create_post(request, post: Form[PostSchemaIn], cover: File[UploadedFile] = None):#(1)!
payload = post.dict()
category_id = payload.pop('category', None)
category = Category.objects.get(pk=category_id) if category_id else None
post = Post.objects.create(category=category, cover=cover, **payload)
return post
-
- Utilizamos
Form[PostSchemaIn]para indicar que los datos del esquema de entrada se recibirán como parte de un formulariomultipart/form-data - Utilizamos
File[UploadedFile]para indicar que el campocoverse recibirá como un fichero subido.
- Utilizamos
Ahora podremos crear un nuevo «post» con portada a través de la interfaz gráfica de la documentación de la API, que nos permitirá subir un fichero de imagen para la portada y el nuevo «post» se creará correctamente con la portada asociada.
Como ejemplo puedes probar con estos datos en el formulario de la documentación de la API:
| Campo | Valor |
|---|---|
title |
Designing APIs |
content |
Good APIs are designed, not just implemented. |
category |
1 |
cover |
test_api_image.jpg |
La petición curl asociada a esta acción sería la siguiente:
curl -X 'POST' \
'http://localhost:8000/api/posts/' \
-H 'accept: application/json' \
-H 'Content-Type: multipart/form-data' \
-F 'title=Designing APIs' \
-F 'content=Good APIs are designed, not just implemented.' \
-F 'category_id=1' \
-F 'cover=@test_api_image.jpg;type=image/jpeg'