Ya os he hablado otras veces de Django REST Framework, un framework que me parece imprescindible si quereis que vuestro proyecto Django tenga una API REST. Si no estáis familiarizados con él, aquí podéis ver una introducción a Django REST framework.
A raíz de una serie de dudas que me exponía un lector habitual del blog sobre este framework, hoy quiero profundizar en como aprovechar el potencial de los ModelViewSets y los Serializers, centrándome en los siguientes casos:
Serializers anidados: Tengo un objeto que hace referencia a otro, y quiero que la salida me devuelva el objeto entero, en lugar de la referencia.
Parámetros de los serializers: Voy a ver como su flexibilidad me permite cambiar el tipo de campos que quiero en función del tipo de acceso, cambiar el nombre de un campo, etc
Métodos personalidos en un ModelViewSet: Aprovechando el caso anidado, veremos como se pueden crear métodos personalizados, más allá de los GET/POST/PUT/etc que ofrece el ModelViewSet por defecto.
Utilizaré Django 1.7, me basaré en el típico ejemplo de un blog con comentarios, y lo detallaré paso a paso.
Si quieres saltarte la creación del proyecto y demás, y quieres ir directo al código, al final de este artículo encontrarás el repositorio de github para clonártelo.
Creando el proyecto
Uso VirtualEnvWrapper para gestionar el entorno. Si no lo conoces, visita obligada a creando un proyecto con Django y virtualenv y VirtualenvWrapper.
Creo el entorno, proyecto y applicación blog donde tendré el modelo de datos:
miusuario$ mkvirtualenv blogWithSerializers
(blogWithSerializers)$ pip install django
(blogWithSerializers)$ django-admin.py startproject blogWithSerializers
(blogWithSerializers)$ cd blogWithSerializers
(blogWithSerializers)$ chmod u+x manage.py
(blogWithSerializers)$ ./manage.py startapp blog
Instalo también Django REST framework, y creo la aplicación api, donde meteré todo lo relativo a la API:
(blogWithSerializers)$ pip install djangorestframework
(blogWithSerializers)$ ./manage.py startapp api
Esto me deja la siguiente estructura:
blogWithSerializers
/manage.py
/blogWithSerializers
/__init__.py
/urls.py
/wsgi.py
/settings.py
/blog
/__init__.py
/admin.py
/models.py
/tests.py
/views.py
/api
/__init__.py
/admin.py
/models.py
/tests.py
/views.py
Añadiendo las apps
Para que el proyecto detecte las 2 apps que he creado, junto con la app de REST framework debo añadirlas al fichero settings.py:
INSTALLED_APPS = (
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
#here I add the installed apps
'rest_framework',
'blog',
'api',
)
Modelo del blog
Mi blog tendrá 3 clases básicas:
- UserProfile: irá asociado a un User (modelo por defecto de Django), al que añadiré algún otro campo
- Post: será un post del blog, irá vinculado a su creador, tendrá título, texto y comentarios
- Comment: Tendrá una valoración y un texto, irá vinculado a un Post, y también será creado por un UserProfile
A continuación escribo el modelo en blog/models.py:
from django.contrib.auth.models import User
from django.db import models
class UserProfile(models.Model):
user = models.OneToOneField(User, primary_key=True)
karma = models.IntegerField(default=0, blank=True)
def __str__(self):
return self.user.username
class Post(models.Model):
owner = models.ForeignKey(UserProfile)
title = models.CharField(max_length=100)
body = models.TextField()
def __str__(self):
return self.title
class Comment(models.Model):
owner = models.ForeignKey(UserProfile)
post = models.ForeignKey(Post)
text = models.CharField(max_length=300)
def __str__(self):
return self.text
Creación de la base de datos
He creado un nuevo modelo, y tendré que decirle a Django que me prepare la base de datos para poderlo guardar. Recordemos que Django 1.7 incorpora por defecto la gestión de migraciones de BBDD, así que:
1) Preparo la migración
(blogWithSerializers)$ ./manage.py makemigrations
Si no he hecho nada mal, esto me da una salida del estilo:
Migrations for ‘blog’:
0001_initial.py:
– Create model Comment
– Create model Post
– Create model UserProfile
– Add field owner to post
– Add field owner to comment
– Add field post to comment
2) Acto seguido ejecuto la migración:
(blogWithSerializers)$ ./manage.py migrate
Y la consola me responde:
Operations to perform:
Synchronize unmigrated apps: rest_framework
Apply all migrations: admin, blog, contenttypes, auth, sessions
Synchronizing apps without migrations:
Creating tables…
Installing custom SQL…
Installing indexes…
Running migrations:
Applying contenttypes.0001_initial… OK
Applying auth.0001_initial… OK
Applying admin.0001_initial… OK
Applying blog.0001_initial… OK
Applying sessions.0001_initial… OK
Parece que voy por buen camino.
3) Sincronizo (y de paso, ya que no existía, creo) la base de datos
(blogWithSerializers)$ ./manage.py syncdb
El terminal empieza a gastar saliva diciendo lo que va a realizar, y de repente me comenta que si quiero crear un superusuario. Le digo que yes, medice nombre, email y password 2 veces, completo y me contesta que el Superusuario se ha creado correctamente.
De momento todo bien, lo estoy bordando: ya tengo la base de datos creada 😉
Creando un Panel de administración básico
Voy a centrar la API en lo que son los Posts, así que por algún lado voy a tener que crear usuarios, y eso será el panel de administración. Me pongo el mono de trabajo y abro blog/admin.py. Lo dejo como sigue:
from django.contrib import admin
from .models import UserProfile, Post, Comment
class UserProfileAdmin(admin.ModelAdmin):
pass
class CommentInline(admin.TabularInline):
model = Comment
extra = 3
class PostAdmin(admin.ModelAdmin):
inlines = [CommentInline, ]
admin.site.register(UserProfile, UserProfileAdmin)
admin.site.register(Post, PostAdmin)
Además, abro blogWithSerializers/urls.py y la dejo así:
from django.conf.urls import patterns, include, url
from django.contrib import admin
admin.autodiscover()
urlpatterns = patterns('',
url(r'^admin/', include(admin.site.urls)),
)
Probamos el panel de administración
Estoy ya impaciente por ejecutar el servidor y ver corretear a la criatura. De paso, crearé algunos usuarios para poder añadir Posts más adelante desde la API. Dale:
(blogWithSerializers)$ ./manage.py runserver
Aún con cosquilleo en el estómago, abro el navegador y me voy a esta url: http://127.0.0.1:8000/admin/
¡¡Parece que funciona!! Me pide el usuario y password que he creado antes con el syncdb. Lo meto y… voilà:
Ahora amigos, os dejo unos minutos para Crear Users, User Profiles, e incluso si queréis, algún Post con sus comentarios, para ver que todo funciona correctamente.
Creando una API REST con ModelViewSet
¿Ya estáis aquí? Yo también. La verdad, creía que sería más divertido, pero a meter datos a través del panel de administración se le acaba la gracia pronto. ¡Vamos a crear una API señores!
Cuando has trabajado un poco con API REST Framework, te das cuenta de que los que controlan el cotarro son los ModelViewSet, hacen muchas cosas por defecto para ahorrarte líneas de código, pero siempre los puedes personalizar por que todos somos algo “especialitos”, y queremos las cosas a nuestra manera. Vamos al grano.
Que debe hacer mi API REST
Lo que yo quiero es poder hacer las siguientes cosas a través de mi API:
- recuperar todo el listado de posts
- recuperar un post concreto, con sus comentarios
- crear un nuevo post
- crear un nuevo comentario en un post
URLs
Como vemos, todo lo que quiero hacer, está relacionado con el recurso Post, incluso el comentario (es un recurso anidado), así que siguiendo patrones REST, debería acceder de este modo:
http://127.0.0.1:8000/api/v1/posts/
para obtener el listado de posts o crear un posthttp://127.0.0.1:8000/api/v1/post/13444
donde 13444 será el número de Post al que quiero accederhttp://127.0.0.1:8000/api/v1/post/13444/comment
para añadir un comentario al post 13444
Dado que solo trabajo con el recurso Post, crearé un ModelViewSet llamado PostViewSet donde incluiré toda la magia, pero eso será más adelante. De momento, me pongo manos a la obra y creo el archivo api/urls.py:
from django.conf.urls import patterns, include, url
from rest_framework.urlpatterns import format_suffix_patterns
from . import views
post_list = views.PostViewSet.as_view({
'get': 'list',
'post': 'create'
})
post_detail = views.PostViewSet.as_view({
'get': 'retrieve',
'put': 'update',
'patch': 'partial_update',
'delete': 'destroy'
})
comment_creation = views.PostViewSet.as_view({
'post': 'set_comment'
})
urlpatterns = patterns('api.views',
url(r'^v1/posts/$', post_list, name='post_list'),
url(r'^v1/post/(?P<pk>[0-9]+)/$', post_detail, name='post_detail'),
url(r'^v1/post/(?P<pk>[0-9]+)/comment/$', comment_creation, name='comment_creation'),
)
urlpatterns = format_suffix_patterns(urlpatterns)
Podemos ver como, por un lado, genero vistas relativas a mi futuro PostViewSet, donde vinculo distintos tipos de acceso (get/put/post, etc) a distintos métodos (algunos predefinidos como list o create, pero también a un método personalizado como set_comment que tendré que crear).
Acto seguido, vinculo las URLs con las vistas anteriores.
Incluyendo las URLs de ‘api’ en las URLs generales
Para que Django sepa encontrar mis urls, le voy a tener que decir donde encontrarlas, así que recupero el archivo blogWithSerializers/urls.py y lo dejo así:
from django.conf.urls import patterns, include, url
from django.contrib import admin
admin.autodiscover()
urlpatterns = patterns('',
url(r'^api/', include('api.urls')),
url(r'^admin/', include(admin.site.urls)),
)
Serializers
Si quiero parsear datos a través de la API REST, necesitaré unos Serializers, es decir, objetos que determinan qué campos y de que manera se traducen de cada modelo a la API y viceversa.
Aquí empieza la gracia. Si quiero serializar el Post, necesitaré una clase, que llamaré PostSerializer, donde le digo que campos quiero usar. Son estos:
- id
- title
- body
- owner
- Al recibir Posts, quiero que me envíe el objeto entero, así que necesito un UserProfileSerializer
- Por otro lado, cuando creo uno nuevo NO quiero enviar el objeto entero, sino solo el id
- comments: Quiero que me envíe los comentarios con todo su contenido, por lo que necesitaré crear un CommentSerializer
Vamos a ver como queda esto en código blogWithSerializers/serializers.py:
from rest_framework import serializers
from blog.models import Post, Comment, UserProfile
class UserProfileSerializer(serializers.ModelSerializer):
class Meta:
model = UserProfile
fields = ('user', 'karma')
class CommentSerializer(serializers.ModelSerializer):
class Meta:
model = Comment
fields = ('text', 'owner')
class PostSerializer(serializers.ModelSerializer):
owner = UserProfileSerializer(read_only=True)
ownerId = serializers.PrimaryKeyRelatedField(write_only=True, queryset=UserProfile.objects.all(), source='owner')
comments = CommentSerializer(many=True, read_only=True, source='comment_set')
class Meta:
model = Post
fields = ('id', 'title', 'body', 'owner', 'ownerId', 'comments')
Fíjate, para referenciar un campo del modelo, uso el mismo nombre en fields.
Si quiero usar otro nombre, o quiero indicar un serializer concreto, lo hago fuera de la subclase Meta. PrimaryKeyRelatedField me devuelve el id, mientras que si quiero un objeto completo, lo indico con un serializer a medida para dicho objeto.
DETALLE 1: El problema de dualidad con el campo owner lo soluciono creando 2 fields distintos, uno será read_only, para la lectura, y el otro write_only, para la escritura.
DETALLE 2: El campo comments, hace referencia al array de comentarios ligados al Post, que puedo recuperar usando el nombre comments_set, PERO no quiero usar ese nombre así que le digo a quién hace referencia con el parámetro source. Atención también al parámetro many=True con el que le digo que es una colección.
Vistas
Ya tengo las clases que van a pasar mis modelos a JSON para sacarlo por la API, y tengo las URLs apuntando a ciertas vistas, pero voy a tener que crearlas para que la cosa funcione. Voy a api/views.py y lo edito:
from rest_framework import viewsets, status
from rest_framework.response import Response
from rest_framework.decorators import detail_route
from blog.models import Post, Comment, UserProfile
from .serializers import PostSerializer, CommentSerializer, UserProfileSerializer
class PostViewSet(viewsets.ModelViewSet):
queryset = Post.objects.all()
serializer_class = PostSerializer
@detail_route(methods=['post'])
def set_comment(self, request, pk=None):
#get post object
my_post = self.get_object()
serializer = CommentSerializer(data=request.data)
if serializer.is_valid():
serializer.save(post=my_post)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
Ojo al tema: Solo añadiendo el queryset y el serializer_class, ya tengo la api funcionando para crear, listar y recibir posts individuales.
Y entonces, ¿qué #&!#%!! es el resto?
Métodos personalizados
- @detail_route es el decorador que me permite añadir un método personalizado, ligado a un objeto concreto.
- @list_route sería el decorador que usaría, si quiero un método personalizado para trabajar con todo el listado de post.
En este caso, recupero el Post concreto al que hace referencia la url (recuerda: http://127.0.0.1:8000/api/v1/post/13444/comment), creo un serializer tipo comentario con los datos de la petición POST que he recibido, y en caso de que sea válido, lo asocio al objeto Post y devuelvo una respuesta conforme todo está bien.
Y ahora…dime…¿ganas de probarlo? ¡¡Vamos allá!!
Probando la API REST
Otras veces os he hablado del cliente para API REST Postman. Si me tengo que pelear con una API en serio, sin duda, es mi arma preferida, ¡apúntatelo!
Lo que no comento tan a menudo, es que el propio Django REST Framework incorpora un mecanismo de vistas asociadas a su API, así que, una vez has creado las vistas, puedes acceder a su pequeño cliente para probar la API.
Ejecutar el servidor
De nuevo, si no lo tenía ya en marcha, ejecuto por consola:
(blogWithSerializers)$ ./manage.py runserver
Probar listado de Posts
Ahora me voy raudo y veloz a http://127.0.0.1:8000/api/v1/posts/ y
…
…
¡¡sorpresa!!
¡Ahí están!
¡Los posts que he creado al principio desde el panel de administración!
Veamos:
Me encanta el detalle de que tanto en el array de comentarios, como en el owner, lo que tengo son objetos completos, y no solo su id. ¿Pero que pasará cuando quiera crear un post? ¿Tengo que meter todo el diccionario del usuario creador?
Crear un Post
Evidentemente NO. De eso va este artículo. Si te desplazas al final de la vista, verás el formulario para hacer POST a la API, y solo contiene los campos title, body y Ownerid (el cual me deja seleccionar entre los distintos usuarios que tengo creados). Este último caso, a efectos de la petición, es solo el número de identificador del usuario.
Lo gracioso de todo esto es que aún no he salido de la misma URL, sigo en http://127.0.0.1:8000/api/v1/posts/. Meto algunos datos en el formulario, le doy a POST y todo va fino como la seda. El resultado: Una respuesta de servidor 201, mi objeto ha sido creado.
Obtener un Post concreto
Esto no tiene más secreto. Me voy a la URL http://127.0.0.1:8000/api/v1/post/4/, y efectivamente, obtengo el Post concreto que acabo de crear.
Crear un comentario (objetos anidados)
Ahora es cuando me la juego. La historia que os he vendido es que si me voy a http://127.0.0.1:8000/api/v1/post/4/comment/ Tendría que poder crear un nuevo comentario, que se asociará justamente al Post número 4.
De entrada (imagino que es un bug de API Rest Framework), si voy a la URL que acabo de citar, me sale un mensaje diciendo que el método no es válido (esto es correcto, ya que por defecto está haciendo un GET esta URL, cosa que no permito), y aparece un formulario como el de crear un nuevo blog. WTF!!!
Sin rellenar nada (para que perder el tiempo), le doy a POST, y… ¡ahora sí! Me sale de nuevo un mensaje de error, pero el formulario ha cambiado con los campos que se deben entrar para crear un comentario. Los relleno > POST > y ahí esta mi comentario!!
Jugando con el código
Para acabar, te dejo un enlace al repo de github por si quieres bajarte el código y jugar un poco con él. Recuerda instalar las dependencias del proyecto mediante pip install -r requirements.txt.
Si te ha gustado el artículo, compártelo 😉
¡¡Saludos!!