Respuestas con estructura anidada#

Este es el decimocuarto artículo de la serie tutorial de Django Ninja.
En el desarrollo de API, con frecuencia nos encontramos con situaciones en las que los datos entre modelos relacionados deben ser retornados simultáneamente.
Especialmente al manejar relaciones «uno a uno» o «uno a muchos», la estructura multicapa suele ser la norma.
Deseamos retornar los datos en forma de estructura anidada (Nested Objects), lo que permite a los usuarios de la API obtener la información necesaria de una sola vez, sin requerir múltiples solicitudes.
Este artículo continuará utilizando y ampliando el ejemplo de la API «Información de publicación única», explicando cómo implementar respuestas con estructura anidada en Django Ninja para que las respuestas de nuestra API sean más ricas y estructuradas.
Todos los cambios de código de este artículo se pueden consultar en este PR.
Proyecto de ejemplo en GitHub#
1. Contexto del problema#
En los diseños de API anteriores, la respuesta de «obtener la información de una sola publicación» incluía la información de la publicación y el id del autor:
class PostResponse(Schema):
id: int
title: str
content: str
author_id: int
created_at: datetime
updated_at: datetime
Los desarrolladores con experiencia saben que, ya sea id o author_id, por lo general no están destinados a ser leídos por los usuarios del servicio, sino para que el personal de frontend los utilice con flexibilidad.
Por ejemplo, en la pantalla del sistema, la publicación podría incluir un enlace a la información personal del autor, donde al hacer clic se puede ver la información del autor. En ese caso, el frontend debe usar el id para llamar a otra API («Obtener información de usuario») y conseguir el contenido adicional.
Si la información adicional es abundante, este diseño de «desacoplamiento» es sumamente razonable. Pero si deseamos presentar conjuntamente la «información necesaria» del autor, el diseño de llamadas por separado resulta un tanto tedioso.
¡Por eso necesitamos una estructura anidada!
La API puede incrustar directamente la «información necesaria» del autor en la respuesta, de modo que el usuario no tenga que realizar múltiples solicitudes. Aquí tomaremos como ejemplo mostrar conjuntamente el «nombre de usuario» y el «email» del autor.
2. Mejora de la API: redefinir el Schema#
Solo se necesita hacer una cosa para que el contenido y la estructura de la respuesta cambien: redefinir PostResponse:
from ninja import Schema
from datetime import datetime
class _AuthorInfo(Schema):
id: int
username: str
email: str
class PostResponse(Schema):
id: int
title: str
content: str
author: _AuthorInfo # Estructura anidada que contiene información del autor
created_at: datetime
updated_at: datetime
_AuthorInfo contiene el id, username y email del autor, e incrusta esta estructura en el campo author de PostResponse (renombrado desde author_id, ya que el contenido informativo ha cambiado).
Te recuerdo que solo modificamos PostResponse, mientras que la función view sigue siendo idéntica a la anterior, sin ningún cambio:
@router.get(path='/posts/{int:post_id}/', response=PostResponse)
def get_post(request: HttpRequest, post_id: int) -> Post:
"""
Obtener una sola publicación
"""
post = Post.objects.get(id=post_id)
return post
De esta manera, podemos obtener al mismo tiempo la información necesaria tanto de la publicación como del autor.
Apunte al margen: pequeño consejo de nomenclatura#
Quizás hayas notado que utilicé la convención de comenzar con guion bajo en _AuthorInfo. En Python, esta es una convención que indica que este atributo, función o clase se utiliza principalmente de forma interna.
Lo «interno» puede tener muchas interpretaciones; aquí mi intención es: solo forma parte de uno o varios Schema y no está destinado a ser invocado directamente por las funciones view.
No subestimes este detalle de nomenclatura. A medida que aumenta el número de tus Schema, al desarrollar nuevas API siempre necesitarás examinar primero los Schema existentes para decidir si redefinir o reutilizar uno existente.
En ese momento, contar con esta distinción de nombres resulta sumamente «conveniente»: no tendrás que revolver entre decenas de Schema hasta que te duelan los ojos.
En la práctica, las ocasiones para escribir Schema anidados son frecuentes, por lo que considero que cultivar este buen hábito vale la pena.
Respuesta actualizada: Nested Response#
Veamos la respuesta de la API:
// http://127.0.0.1:8000/posts/2/
{
"id": 2,
"title": "Alice's Django Ninja Post 1",
"content": "Alice's Django Ninja Post 1 content",
"author": {
"id": 1,
"username": "Alice",
"email": "[email protected]"
},
"created_at": "2024-09-12T02:28:16.801Z",
"updated_at": "2024-09-12T02:28:16.801Z"
}
Mira el contenido del nuevo campo author: ¡una estructura anidada absolutamente perfecta!
Los usuarios pueden ver directamente el nombre y email del autor del artículo, y si desean ver más información del autor, aún pueden hacerlo a través del campo id dejando que el frontend llame a otra API.
Esta es una solución de compromiso ideal.
3. «Aplanar» la información anidada#
La «solución de compromiso» anterior es ciertamente bastante ideal. Sin embargo, a veces nuestras necesidades son más simples.
Por ejemplo, en la API «Obtener lista de publicaciones», también podríamos necesitar mostrar información del autor, pero en ese caso solo el nombre de usuario es suficiente.
No se requiere el id del autor, y mucho menos el email; solo basta con el nombre.
Ahora bien, ¿por qué llamarlo «aplanar la información anidada»? Porque el nombre del autor no es un atributo directo del modelo Post, sino que proviene del modelo relacionado: User.
Debemos simplificar la información anidada referente al autor.
Originalmente era así:
"author": {
"id": 1,
"username": "Alice",
"email": "[email protected]"
},
Ahora pasa a ser así:
Pasa de dos capas de vuelta a una capa (pero ya no es el id del autor, sino el nombre), por lo que se llama «aplanar» (flatten).
Desacoplamiento de Schema#
¿Recuerdas que el formato de respuesta de la API «Obtener lista de publicaciones» en realidad se compartía con «Obtener información de publicación única»?:
@router.get(path='/posts/', response=list[PostResponse])
def get_posts(...) -> QuerySet[Post]:
"""
Obtener lista de publicaciones
"""
...
Ambas utilizaban PostResponse.
La modificación realizada en la primera mitad de este artículo a la respuesta de «Obtener información de publicación única» también afectará a «Obtener lista de publicaciones», lo cual no suele ser el resultado deseado.
Por lo tanto, debemos crear un Schema de respuesta propio para la API «Obtener lista de publicaciones» y simplificar la información de acuerdo con las necesidades mencionadas anteriormente.
Mi plan es:
- Omitir los dos campos del contenido de la publicación (
content) y la fecha de actualización (updated_at), ya que no son necesarios en la lista. - Para la parte del autor, dejar únicamente el «nombre».
4. Implementación del aplanado de información anidada: usar @property#
Veamos primero cómo se define el nuevo Schema:
Te parecerá extraño: ¿de dónde sale el atributo author_name si el modelo Post no lo tiene?
¡Así es! Porque lo definimos nosotros mismos utilizando @property:
# post/models.py
class Post(models.Model):
...
@property
def author_name(self) -> str:
return self.author.username
De esta manera, tu objeto modelo Post tendrá el atributo author_name.
Sin embargo, ten en cuenta que invocar este atributo suele significar disparar una segunda consulta (ya que es un atributo en un modelo relacionado), por lo que en la función view debe combinarse con el método select_related de QuerySet en Django:
Este es el tema común de «N+1» en el ORM de Django, en el cual no profundizaremos por ahora.
Un enfoque mejor#
Podrías pensar que este método no parece muy elegante (¡al menos eso fue lo que pensé la primera vez que lo vi!), especialmente comparado con el enfoque de Django REST framework.
Django REST framework lo escribiría así en el serializador:
¿A que resulta mucho más conciso?
Pero esta era ciertamente la forma recomendada en las primeras etapas por el autor de Django Ninja.
No te preocupes: en la entrega 16 presentaremos una forma mejor y más moderna. Sin embargo, @property sigue siendo muy útil en ciertos casos.
Respuesta tras el aplanado#
Por último, veamos la nueva respuesta de la API «Obtener lista de publicaciones»:
// http://127.0.0.1:8000/posts/
[
{
"id": 1,
"title": "Alice's Django Ninja Post 1",
"created_at": "2024-09-12T02:28:16.801Z",
"author_name": "Alice" // Nombre del autor aplanado
},
{
"id": 2,
"title": "Alice's Django Ninja Post 2",
// ...(se omite a continuación)
},
// ...(se omite a continuación)
]
¡Excelente!
Resumen#
En este artículo mostramos cómo usar Schema en Django Ninja para lograr respuestas con estructura anidada.
Luego presentamos cómo «aplanar» esta estructura anidada, reemplazando el id de autor original por el campo del nombre.
Estos métodos aumentan enormemente la flexibilidad de las respuestas de la API.
En el próximo artículo discutiremos las diferentes filosofías de diseño de Django Ninja y Django REST framework en el manejo de serialización y estructuras de respuesta, comparando las ventajas y desventajas de ambos.