Saltar a contenido

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.

API Handshake

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:

$ pip install django-ninja
$ uv add django-ninja

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

  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:

  • categories para almacenar las categorías.
  • posts para almacenar los posts.

Escribimos el fichero de modelos categories/models.py con el siguiente contenido:

categories/models.py
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:

$ uv run manage.py loaddata categories

Comprobamos que tenemos las categorías cargadas en la base de datos:

$ uv run manage.py shell -v0 -c 'for c in Category.objects.all(): print(c)'
Design
Learning
Configuration

posts/models.py
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

  1. 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:

$ uv run manage.py loaddata posts

Comprobamos que tenemos los posts cargados en la base de datos:

$ uv run manage.py shell -v0 -c 'for p in Post.objects.all(): print(p)'
Small Changes
Learning Takes Time
Thinking in Code
Useful Mistakes
Curiosity

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»:

main/api.py
from ninja import NinjaAPI

api = NinjaAPI()

api.add_router('/posts/', 'posts.api.router', tags=['posts'])#(1)!

  1. Añadimos el enrutador de la aplicación posts al enrutador principal de la API, indicando la ruta base /posts/ y una etiqueta tags para organizar la documentación.

main/urls.py
from django.contrib import admin
from django.urls import path

from .api import api#(1)!

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/', api.urls),
]

  1. Importamos el módulo API principal

posts/api.py
from ninja import Router

router = Router()


@router.get('/')#(1)!
def list_posts(request):
    pass#(2)!

  1. Petición GET a /api/posts/
  2. En principio no hacemos nada. A efectos explicativos se verá más tade.

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:

1⃣ Utiliza sustantivos, no verbos; con plural para colecciones:

Por ejemplo utiliza /posts/ en vez de /getPosts/.

2⃣ 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

3⃣ Identifica recursos con IDs en la ruta:

Por ejemplo utiliza /posts/17 en vez de /posts?id=17.

4⃣ 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.

5⃣ 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.

6⃣ Versiona tu API: Muy recomendable para evitar romper clientes existentes:

Por ejemplo /api/v1/posts/ o /api/v2/posts/

7⃣ URLs simples y predecibles:

  • Usar kebab-case es una buena práctica: ejemplo /api/posts/reset-category
  • Evita mayúsculas.
  • Evita caracteres especiales.
  • No incluyas formato (.json, .xml) en la URL.

8⃣ 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»:

posts/schemas.py
from ninja import Schema


class PostSchema(Schema):
    id: int#(1)!
    title: str
    slug: str
    content: str

  1. En peticiones API se suele utilizar id en vez de pk.
posts/schemas.py
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.
posts/schemas.py
from ninja import ModelSchema, Schema

from .models import Post


class PostSchema(ModelSchema):
    id: int

    class Meta:
        model = Post
        exclude = ['id']

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»:

posts/api.py
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)!

  1. Necesitamos el enrutador.
  2. Necesitamos el esquema.
    • @router.get Se trata de una petición GET.
    • '/' Acceso a la raíz del «sub-router» posts.
    • list[PostSchema] devolvemos una lista de PostSchema.
  3. El manejador recibe request por defecto pero ningún otro parámetro (en este caso).
  4. Si añadimos un docstring se verá reflejado en la documentación del punto de entrada.
  5. Django Ninja se encarga de convertir la «queryset» en una lista de PostSchema como 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:

Ninja API inicial

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:

Ninja API - Listado de posts

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.

Ninja API - Esquema Post

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:

  1. http://localhost:8000/api/docs mediante la documentación generada por Django Ninja.
  2. http://localhost:8000/api/posts/ en cualquier navegador.
  3. Cliente API en línea de comandos: $ curl -X GET http://localhost:8000/api/posts/
  4. 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:

> winget install -e --id cURL.cURL#(1)!

  1. Instalación mediante winstall.

Para que no tengas problemas (en Windows) a la hora de ejecutar es recomendable utilizar curl.exe:

> curl.exe -X GET http://localhost:8000/api/posts/
$ brew install curl
$ apt-get install curl

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»:

posts/api.py
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.get Se trata de una petición GET.
    • '/{post_id}' Identificador del «post» (clave primaria).
    • PostSchema devolvemos un PostSchema.
  1. Necesitamos definir el parámetro post_id en el manejador.
  2. 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:

posts/api.py
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)

  1. Definimos el parámetro category_id como un query parameter opcional (con valor por defecto None).
  2. Comprobamos si se ha proporcionado el parámetro category_id en la petición.
  3. 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:

posts/schemas.py
from ninja import ModelSchema

from .models import Post


class PostSchema(ModelSchema):
    class Meta:
        model = Post
        fields = '__all__'#(1)!

  1. Ahora incluimos todos los campos del modelo Post, incluyendo la clave ajena category. 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:

categories/schemas.py
from ninja import ModelSchema

from .models import Category


class CategorySchema(ModelSchema):
    class Meta:
        model = Category
        fields = '__all__'

posts/schemas.py
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)!

  1. Definimos el campo category como un CategorySchema opcional (con valor por defecto None). 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.
  2. Ahora solo incluimos los campos id, title, slug y content del modelo Post, ya que el campo category lo 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»:

  1. El «payload» de la respuesta JSON usando identificador de clave ajena ocupa 806 bytes.
  2. 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»:

posts/schemas.py
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

  1. Definimos el campo summary como un campo de tipo str.
  2. Incluimos los campos id, title, slug y content del modelo Post, pero no incluimos el campo summary porque lo vamos a calcular de forma dinámica.
    • Definimos el método resolve_summary que se encargará de calcular el valor del campo summary.
    • Recibe como parámetro el objeto («post») que el esquema está resolviendo.

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:

posts/schemas.py
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()

  1. Definimos el campo title como un campo de tipo str (sin valor por defecto, por lo que es obligatorio).
  2. Incluimos los campos id, slug y content del modelo Post, pero no incluimos el campo title porque lo vamos a calcular de forma dinámica.
    • Definimos el método resolve_title que se encargará de calcular el valor del campo title.
    • Recibe como parámetro el objeto («post») que el esquema está resolviendo.

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»:

posts/api.py
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:

posts/models.py
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)

  1. Sólo generamos el slug si no existe ya uno asignado, para evitar que se sobrescriba el slug cada 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».

posts/schemas.py
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 id y slug del esquema de entrada porque el id se genera automáticamente al crear el recurso y el slug se genera automáticamente a partir del title en el método save() del modelo.
    • Igualmente no se añade el campo category porque se verá en el próximo epígrafe claves ajenas.

El manejador («route handler») debe usar los esquemas de entrada y salida para gestionar la creación del nuevo recurso:

posts/api.py
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)!

  1. La respuesta del punto de entrada será un PostSchemaOut, que incluye el id y el slug generados automáticamente al crear el nuevo «post».
  2. El manejador recibe un objeto post de tipo PostSchemaIn, 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»:

Request body
{
  "title": "Focused Progress",
  "content": "Small consistent steps create real progress."
}

La respuesta esperada sería la siguiente:

Code 200
{
  "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:

posts/schemas.py
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']

  1. Añadimos el campo category para poder indicar el identificador de la categoría al crear un nuevo «post».

posts/api.py
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)!

  1. Extraemos el identificador de la categoría del cuerpo de la petición.
  2. Obtenemos el objeto Category correspondiente al identificador proporcionado (si se ha proporcionado alguno).
  3. 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:

Request body
{
  "title": "Embrace Iteration",
  "content": "Improve a little every day.",
  "category": 1  // Design
}

La respuesta esperada sería la siguiente:

Code 200
{
  "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:

posts/schemas.py
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__'

  1. Importamos el modelo Field desde Pydantic.
  2. Definimos el patrón de expresión regular para el nuevo campo vericode.

posts/api.py
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

  1. Establecemos el código de verificación que debe cumplirse.
  2. Extraemos el código de verificación del payload y comprobamos si es correcto.
  3. 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 a null o 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»:

posts/schemas.py
from ninja import ModelSchema

from .models import Post


class PostSchemaIn(ModelSchema):
    class Meta:
        model = Post
        exclude = ['title', 'content', 'category']


class PostSchemaOut(ModelSchema):
    class Meta:
        model = Post
        fields = ['id', 'title', 'slug', 'content', 'category']

posts/api.py
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)!

  1. Añadimos la categoría al «payload» que estamos manejando.
  2. Recorremos los elementos del «payload».
  3. Asignamos los nuevos valores a los atributos del objeto post_obj utilizando la función setattr().
  4. Guardamos los cambios en la base de datos.
  5. 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»):

Request 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:

Code 200
{
  "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»:

posts/schemas.py
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']

  1. Definimos un nuevo esquema PostSchemaPatch para la actualización parcial.
  2. Utilizamos fields_optional = '__all__' para indicar que todos los campos del esquema de entrada son opcionales, lo que permite realizar una actualización parcial.

posts/api.py
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

  1. Obtenemos los datos de entrada como diccionario. En este caso, utilizamos exclude_unset=True para excluir aquellos campos que no se han proporcionado en la petición, lo que permite realizar una actualización parcial.
  2. 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»):

Request body
{
  "content": "Small daily changes can lead to big results. Consistency is key. Embrace the journey."
}

La respuesta esperada sería la siguiente:

Code 200
{
  "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»:

posts/api.py
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)!

  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:

Code 200
{
  "detail": "Post deleted successfully"
}

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:

Response body (422)
{
  "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):

Response body (422)
{
  "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:

Request body
{
  "title": "Problem with request",
  "content": "Trailing comma at the end of json body",
  "category_id": 1,//(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:

Response body (400)
{
  "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:

Response body (405)
{
  "detail": "Method Not Allowed"
}

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:

posts/api.py
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:

Response body (404)
{
  "detail": "Not Found: No Post matches the given query."
}

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:

posts/api.py
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:

Response body (403)
{
  "detail": "Access to this post is restricted"
}

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
Authorization: Bearer <token>

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:

users/models.py
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=False hace que no se pueda editar desde la interfaz administrativa.
  1. Necesitamos vincularlo con la clase User que 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_at que 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:

$ uv run manage.py loaddata users

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:

main/urls.py
from ninja import NinjaAPI

api = NinjaAPI()

api.add_router('/posts/', 'posts.api.router', tags=['posts'])
api.add_router('/users/', 'users.api.router', tags=['users'])
users/schemas.py
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']
users/api.py
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:

Request body
{
  "username": "guido",
  "password": "pythoncreator"
}

La respuesta esperada sería la siguiente:

Code 200
{
  "user": "guido",
  "key": "40e5f786-1210-45f5-9e5d-f76925a9e98a"
}

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:

users/auth.py
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)!

  1. Esta clase hereda de HttpBearer y sobrescribe el método authenticate(), que se encarga de verificar el token incluido en las peticiones.
  2. El método recibe la petición HTTP request y el token extraído de la cabecera de autenticación («headers»).
  3. Intentamos obtener el objeto Token correspondiente al token proporcionado.
  4. Si el token no existe, devolvemos None, lo que indica que la autenticación ha fallado.
  5. 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:

posts/api.py
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

  1. Añadimos el parámetro auth=AuthBearer() al decorador del manejador para indicar que este punto de entrada requiere autenticación utilizando la clase AuthBearer que hemos definido previamente.
    • El parámetro request.auth dentro del manejador contendrá lo que devuelva el método AuthBearer.authenticate().
    • En este caso contiene el usuario autenticado (si la autenticación ha sido exitosa) o None (si la autenticación ha fallado).

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:

Response body (401)
{
  "detail": "Unauthorized"
}

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:

posts/models.py
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:

posts/schemas.py
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__'

  1. Dejamos fuera el campo cover del esquema de entrada porque lo gestionaremos de forma separada en los parámetros del manejador.

posts/api.py
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 formulario multipart/form-data
    • Utilizamos File[UploadedFile] para indicar que el campo cover se recibirá como un fichero subido.

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'

  1. OpenAPI es un estándar que describe cómo funciona una API (sus rutas, parámetros y respuestas) en un formato que pueden entender humanos y máquinas. 

  2. En terminología API «atacar» un punto de entrada significa acceder al recurso correspondiente a la URL introducida.