Django REST Framework - Generic Views y ViewSets

DRF nos permite crear API REST muy poderosas de forma sencilla. Para ello, existen diferentes formas de crear endpoints entre ellos Generic Views y ViewSets. Este blog busca ayudar a mostrar sus implementaciones ante un mismo escenario.

Conceptos

Generic Views

Los Generic Views de Django REST Framework surgen con el mismo proposito de los Generic Views de Django. Ambos nos proveen una serie de vistas pre-construidas que proporcionan patrones de uso común y permiten crear rápidamente vistas de API.

"Las vistas genéricas de Django[...] se desarrollaron como un atajo para patrones de uso común[...] Toman ciertos modismos y patrones comunes que se encuentran en el desarrollo de vistas y los abstraen para que pueda escribir rápidamente vistas comunes de datos sin tener que repetirlo." Django. s.f., Built-in class-based views API - Base vs Generic views.

Sin embargo, si las vistas genéricas no se ajustan a las necesidades de su proyecto, puede utilizar la clase APIView. Otra alternativa sería reutilizar los mixins y las clases base utilizadas por los Generic Views.

ViewSets

La ventaja más significativa de ViewSets es que la construcción de las URL se maneja automáticamente (con una clase de enrutador). Ayudando con la consistencia de las convenciones de URL en su API y minimiza la cantidad de código que necesita escribir y garantizar el termino "Dont Repeat Yourserlf". En otros frameworks se pueden encontrar implementaciones conceptualmente similares llamados "Recursos" o "Controladores".

Puede definir estas acciones usted mismo utilizando la clase GenericViewSet junto con los mixin (CreateModelMixin, RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin o ListModelMixin). Igualmente puede utilizar ModelViewSet que implementa acciones comunes de lectura y escritura listas para usar. Como podrá visualizar en el siguiente enlace, internamente la clase ModelViewSet hereda de la superclase GenericViewSet e implementa los mixin anteriormente mencionados).

Implementación

Para este ejemplo vamos a considerar los siguientes modelos.

# books/models.py
class Author(models.Model):
    name = models.CharField('Name', max_length=120)

class Book(models.Model):
    title = models.CharField('Title', max_length=120)
    isbn = models.CharField('ISBN', max_length=13)
    publication_date = models.DateField('Publication date')

Tambien definimos los serializers correspondientes para nuestros modelos de Author y Book.

# books/api/serializers.py
class AuthorSerializer(serializers.ModelSerializer):
    class Meta:
        model = Author
        fields = '__all__'

class BookSerializer(serializers.ModelSerializer):
    class Meta:
        model = Book
        fields = '__all__'

Descripción del escenario #1

Para la elaboración de nuestra API nos solicitan que se pueda consultar la lista de libros y ver la información de un libro en especifico por su id.

Es decir, debemos poder realizar las siguientes consultas

# Consultar la lista de libros
curl --location --request GET '127.0.0.1:8000/api/books/'
# Consultar la información de un libro por su id
curl --location --request GET '127.0.0.1:8000/api/books/1/' 

Veamos cómo se realiza la implementación utilizando Generic Views y posteriormente cómo sería la implementación con ViewSets.

Implementando Generic View en escenario #1

Con base a los requerimientos anteriores, debemos de utilizar la clase generics.ListAPIView para mostrar la lista de libros que tenemos registrados.

"ListAPIView - Used for read-only endpoints to represent a collection of model instances."

# books/api/views.py
class BookList(generics.ListAPIView):
    queryset = Book.objects.all()
    serializer_class = BookSerializer

Posteriormente, para consultar la información de un libro por su id, debemos considerar en utilizar generics.RetrieveAPIView.

"RetrieveAPIView - Used for read-only endpoints to represent a single model instance."

# books/api/views.py
class BookDetail(generics.RetrieveAPIView):
    queryset = Book.objects.all()
    serializer_class = BookSerializer

Finalmente, registramos en la url dos endpoints utilizando las dos clases que creamos anteriormente.

# books/api/urls.py
urlpatterns = [
    path('books/', views.BookList.as_view(), name='book-list'),
    path('books/<pk>/', views.BookDetail.as_view(), name='book-detail'),
]

Implementando ViewSets en escenario #1

En la implementación con Generic Views vimos que debemos crear dos clases y dos urls para poder cumplir con el requerimiento #1, ahora veremos cómo el código disminuye si utilizamos ViewSets.

Inicialmente debemos definir nuestra view, esta única clase la crearemos heredando de la clase viewsets.ReadOnlyModelViewSet que implementa las acciones list y retrieve.

"ReadOnlyModelViewSet - [...] only provides the 'read-only' actions, .list() and .retrieve()."

# books/api/views.py
class BookViewSets(viewsets.ReadOnlyModelViewSet):
    queryset = Book.objects.all()
    serializer_class = BookSerializer

Finalmente, debemos definir el router que se encargará de construir las urls de forma automática.

# books/api/urls.py
router = routers.SimpleRouter()
router.register(r'books', views.BookViewSets)

urlpatterns = [] + router.urls

Descripción del escenario #2

Para el lanzamiento de una nueva versión de nuestra API nos solicitan que, aunado a las anteriores funcionalidades de listar y consultar libros, se puedan crear nuevos registros de libros, editar registros existentes y eliminar. Dicho de otra manera, nos solicitan tener un CRUD (Create, Read, Update, Delete) completo sobre el modelo de libros.

Es decir, debemos poder realizar estas nuevas consultas

# Registrar un nuevo libro
curl --location --request POST '127.0.0.1:8000/api/books/' \
--header 'Content-Type: application/json' \
--data-raw '{
    "title": "Madame Bovary",
    "isbn": "9780553211016",
    "publication_date": "1982-05-09",
    "authors": [1,2]
}'
# Editar la información de un libro en especifico por su id
curl --location --request PUT '127.0.0.1:8000/api/books/1/' \
--header 'Content-Type: application/json' \
--data-raw '{
    "title": "Madame Bovarya",
    "isbn": "9780553211016",
    "publication_date": "1982-05-09",
    "authors": [1,2]
}'
# Eliminar el registro de un libro en especifico por su id
curl --location --request DELETE '127.0.0.1:8000/api/books/1/'

Por fortuna, Django REST Framework nos permite realizar estas mejoras de manera muy facil y rápida únicamente cambiando las superclases de las que heredan nuestras clases actuales. Veamos qué cambios debemos realizar en nuestros Generic Views y , posteriormente, nuestros ViewSets.

Implementando Generic View en escenario #2

Como se mencionó anteriormente, debemos cambiar la herencia de nuestras clases. Por lo tanto, iniciaremos a realizar la modificación en nuestra clase BookList.

Ademas de listar los registros de nuestros libros, queremos que se puedan agregar nuevos registros. Para ello, debemos cambiar la herencia de generics.ListAPIView y extender de generics.ListCreateAPIView; de esta forma estamos permitiendo crear nuevos registros cambiando de forma sencilla y sin esfuezo extra el código que teniamos anteriormente.

"ListCreateAPIView - Used for read-write endpoints to represent a collection of model instances."

# Código anterior
# class BookList(generics.ListAPIView):
#     queryset = Book.objects.all()
#     serializer_class = BookSerializer

# Código actual
class BookList(generics.ListCreateAPIView):  # <-- Cambio de herencia
    queryset = Book.objects.all()
    serializer_class = BookSerializer

Por último, ademas de ver los detalles de un libro, queremos poder actualizar su información y eliminar el registro. Igual al caso anterior únicamente debemos cambiar la herencia de generics.RetrieveAPIView y extender de generics.RetrieveUpdateDestroyAPIView

"RetrieveUpdateDestroyAPIView - Used for read-write-delete endpoints to represent a single model instance."

# Código anterior
# class BookDetail(generics.RetrieveAPIView):
#     queryset = Book.objects.all()
#     serializer_class = BookSerializer

# Código actual
class BookDetail(generics.RetrieveUpdateDestroyAPIView):  # <-- Cambio de herencia
    queryset = Book.objects.all()
    serializer_class = BookSerializer

Implementando ViewSets en escenario #2

Similar a los GenericViews, debemos modificar la herencia de nuestro ViewSets para que pueda cumplir con los nuevos requerimientos.

Por lo tanto, a nuestra única clase debemos cambiarle la herencia de viewsets.ReadOnlyModelViewSet y extender de viewsets.ModelViewSet.

"ModelViewSet - The actions provided by the ModelViewSet class are .list(), .retrieve(), .create(), .update(), .partial_update(), and .destroy()."

# Código anterior
# class BookViewSets(viewsets.ReadOnlyModelViewSet):
#     queryset = Book.objects.all()
#     serializer_class = BookSerializer

# Código actual
class BookViewSets(viewsets.ModelViewSet):  # <-- Cambio de herencia
    queryset = Book.objects.all()
    serializer_class = BookSerializer

Descripción del escenario #3

Una última modificación a nuestra API, ahora queremos mostrar la información detallada de los autores consultando al siguiente endpoint [GET] /api/books/<pk>/authors/

Es decir, debemos poder realizar estas nuevas consultas

# Consultar la información de los autores de un libro en especifico
curl --location --request GET '127.0.0.1:8000/api/books/1/authors/'

Implementando Generic Views en escenario #3

Para conseguir cumplir con el requerimiento debemos crear un nueva clase la cual extienda de generics.RetrieveAPIView y sobreescribir unos métodos de clase.

  • "queryset" - Será sobre el modelo de Book debido a que estamos consultando un libro en especifico.
  • "serializerclass" - Se utilizará AuthorSerializer debido a que mostraremos la lista de autores de ese libro.
  • "get_serializer()" - Debemos sobreecribir el método get_serializer() para cambiar los kwargs al momento de crear la instancia del serializer para especificar que se trata de una lista de elementos many=True.
  • "get_object()" - Debemos sobreecribir el método get_object() para obtener el Queryset sobre el modelo Authors.
class BookAuthorDetail(generics.RetrieveAPIView):
    queryset = Book.objects.all()
    serializer_class = AuthorSerializer

    def get_serializer(self, *args, **kwargs):
        # Definimos que nuestro serializer va a regresar multiples elementos
        kwargs = {**kwargs, 'many': True}
        return super().get_serializer(*args, **kwargs)

    def get_object(self):
        # Nuestros objetos seran un Queryset de Authors
        obj = super().get_object()
        return obj.authors.all()

Para finalizar, debemos hacer uso de esta clase y definirlo en nuestras urls.

# books/api/urls.py
urlpatterns = [
    path('books/', views.BookList.as_view(), name='book-list'),
    path('books/<pk>/', views.BookDetail.as_view(), name='book-detail'),

    # Url solicitada en el nuevo requerimiento
    path('books/<pk>/authors/', views.BookAuthorDetail.as_view(), name='book-author-detail'),
]

Implementando ViewSets en escenario #3

Con los ViewSets resulta increiblemente simple, a diferencia de los Generic View, de llevar acabo estas modificaciones para cumplir con el requerimiento solicitado. Dentro de la misma clase BookViewSets debemos agregar una acción extra e incluir la lógica que deseamos realizar.

  • En nuestro ejemplo queremos obtener la lista de autores de un solo libro. Por lo tanto, en el decorador definimos con True el valor de detail @action(detail=True).
  • Posteriormente, debemos especificar un path de nuestra url para que se acceda con api/books/<pk>/authors/. Por lo tanto, en el decorador definimos igualmente la nuestro url_path @action(url_path='authors')
  • Y finalmente, definimos la lógica para obtener el la lista de autores del libro dentro de nuestro método de clase el cual definimos como list_authors(). No obstante, púede nombrar el método de la forma que más le sea conveniente.
class BookViewSets(viewsets.ModelViewSet):
    queryset = Book.objects.all()
    serializer_class = BookSerializer

    # Agregamos una nueva acción para poder consultar los detalles de los autores del libro
    @action(
        detail=True,  # Indica que se trabajara sobre un libro en especifico
        url_path='authors',  # Indica el path
        url_name='authors')
    def list_authors(self, request, pk=None):
        # De la instancia del libro se consulta la lista de los autores del libro
        queryset = self.get_object().authors.all()
        serializer = AuthorSerializer(queryset, many=True)
        return Response(serializer.data)

A diferencia del Generic View, no es necesario definir una url para nuestra nueva acción ya que lo definimos en el decorador @action() y nuestro router, que definimos desde un principio en la url, creará automáticamente el endpoint necesario para exponer este nuevo recurso.