Antes

Prefacio

"API on Rails 6" está basado en "APIs on Rails: Building REST APIs with Rails". Fue publicado inicialmente en 2014 por Abraham Kuri bajo la licencia MIT y Beerware.

La primera versión no es mantenida y fue planeada para Ruby on Rails 4 la cual no recibe más actualizaciones de seguridad. He buscado actualizar este excelente libro, adaptándolo a nuevas versiones de Ruby on Rails. Este libro está por lo tanto disponible para Ruby on Rails en sus versiones 5.2 y 6.0 (el cual te encuentras leyendo).

Note
Este libro también está disponible en el lenguaje Molière (Esto significa francés).

Acerca del autor

Mi nombre es Alexandre Rousseau y soy un desarrollador en Rails con más de 4 años de experiencia (al momento de escribirlo). Actualmente soy socio en una compañía (iSignif) donde construyo y mantengo un producto SAAS usando Rails. También contribuyo a la comunidad Ruby produciendo y manteniendo algunas gemas que puedes consular en my Rubygems.org profile. La mayoría de mis proyectos están en GitHub así que no dudes en seguirme.

Todo el código fuente de este libro está en formato Asciidoctor disponible en GitHub. Por lo tanto, siéntete libre de hacer un fork al proyecto si quieres mejorarlo o corregir errores que no noté.

Derechos de autor y licencia

Este libro está bajo la licencia MIT. Todo el código fuente del libro está en el formato Markdown disponible en GitHub

Licencia MIT

Copyright 2019 Alexandre Rousseau

Por la presente se concede permiso, libre de cargos, a cualquier persona que obtenga una copia de este software y de los archivos de documentación asociados (el "Software"), a utilizar el Software sin restricción, incluyendo sin limitación los derechos a usar, copiar, modificar, fusionar, publicar, distribuir, sublicenciar, y/o vender copias del Software, y a permitir a las personas a las que se les proporcione el Software a hacer lo mismo, sujeto a las siguientes condiciones:

El aviso de copyright anterior y este aviso de permiso se incluirán en todas las copias o partes sustanciales del Software. EL SOFTWARE SE PROPORCIONA "COMO ESTÁ", SIN GARANTÍA DE NINGÚN TIPO, EXPRESA O IMPLÍCITA, INCLUYENDO, PERO NO LIMITADO A GARANTÍAS DE COMERCIALIZACIÓN, IDONEIDAD PARA UN PROPÓSITO PARTICULAR E INCUMPLIMIENTO. EN NINGÚN CASO LOS AUTORES O PROPIETARIOS DE LOS DERECHOS DE AUTOR SERÁN RESPONSABLES DE NINGUNA RECLAMACIÓN, DAÑOS U OTRAS RESPONSABILIDADES, YA SEA EN UNA ACCIÓN DE CONTRATO, AGRAVIO O CUALQUIER OTRO MOTIVO, DERIVADAS DE, FUERA DE O EN CONEXIÓN CON EL SOFTWARE O SU USO U OTRO TIPO DE ACCIONES EN EL SOFTWARE.

La portada de este libro usa una hermosa foto tomada por Yoann Siloine quien publicó en Unsplash.

Agradecimientos

Un gran "gracias" a todos los contribuidores de GitHub quienes mantienen este libro vivo. En orden alfabético:

Introducción

Bienvenido a API on Rails 6, un tutorial con esteroides para enseñarte el mejor camino para construir tú siguiente API con Rails. El propósito de este libro es proveer una metodología comprensiva para desarrollar una API RESTful siguiendo las mejores prácticas.

Al finalizar este libro, tu podrás crear tu propia API e integrarla con cualquier cliente como un navegador web o aplicación móvil. El código generado esta codeado con Ruby on Rails 6.0 que es la versión actual.

El propósito de este libro no es solamente enseñarte como construir un API con Rails sino mucho mejor enseñarte como construir una API evolutiva y mantenible con Rails. Esto es, mejorar tu conocimiento actual con Rails. En esta sección, aprenderás a:

  • Usar Git para control de versiones

  • Construir respuestas JSON

  • Probar tus end-points con pruebas unitarias y funcionales

  • Configurar autenticación con JSON Web Tokens (JWT)

  • Usar la especificación JSON:API

  • Optimizar y hacer cache de la API

Recomiendo enérgicamente que sigas todos los pasos en este libro. Intenta no saltarte capítulos porque doy algunos tips y trucos para improvisar tus habilidades a través del libro. Puedes considerarte a ti mismo el personaje principal de un videojuego que gana un nivel en cada capítulo.

En este primer capítulo explicaré como configurar tu entorno de desarrollo (en caso que aún no lo sepas). Luego vamos a crear una aplicación llamada market_place_api. Me aseguraré que te enseño las mejores practicas que he aprendido durante mi experiencia. Esto significa que vamos a iniciar usando Git justo después de inicializar el proyecto.

Vamos a crear la aplicación siguiendo un método simple de trabajo que usé a diario en los siguientes capítulos. Vamos a desarrollar una aplicación completa usando Test Driven Development(TDD). También explicaré el interés de usar una API para tu siguiente proyecto y eligiendo un adecuado formato de respuesta como JSON o XML. Mas allá, vamos a tener nuestras manos sobre el código y completar lo básico de la aplicación construyendo todos los caminos necesarios. También vamos a implementar acceso seguro a la API implementando autenticación por intercambio de cabeceras HTTP. Finalmente, en el último capítulo, vamos a añadir técnicas de optimización para mejorar la estructura y tiempos de respuesta del servidor.

La aplicación final rozará la superficie de iniciar una tienda donde los usuario pueden realizar ordenes, subir productos y más. Hay muchas opciones allá afuera para echar a andar una tienda en linea, como Shopify, Spree o Magento.

Convenciones en este libro

Las convenciones en este libro están basadas en este Tutorial de Ruby on Rails. En esta sección vamos a mencionar algunas que tal vez no son muy claras.

Utilizaré muchos ejemplos usando la línea de comandos. No intentare con windows cmd (lo siento chic@s), así que basare todos los ejemplos usando el estilo Unix, como a continuación se observa:

$ echo "A command-line command"
A command-line command

Estaré usando algunas pautas relacionadas al lenguaje, y me refiero a lo siguiente:

  • Evitar significa que no debes hacerlo

  • Preferir indica que las 2 opciones, la primera es mejor

  • Usar significa que eres bueno para usar el recurso

Si por alguna razón encuentras errores cuando ejecutas un comando, en lugar de tratar de explicar cada resultado posible, te recomiendo 'googlearlo', lo cual no lo considero una mala práctica. Pero si te gusta tomar una cerveza o tienes problemas con el tutorial siempre puedes escribirme.

Entornos de desarrollo

Una de las partes más dolorosas para casi todo desarrollador es configurar el entorno de desarrollo, pero mientras lo hagas, los siguientes pasos pueden ser una pieza del pastel y una buena recompensa. Así que voy a guiarte para que te sientas motivado.

Editores de texto y terminal

Hay muchos casos en que los entornos de desarrollo pueden diferir de computadora a computadora. Este no es el caso con los editores de texto o IDE’s. Pienso que para el desarrollo en Rails un IDE es demasiado, pero alguien podría encontrarlo como la mejor forma de hacerlo, así que si es tú caso te recomiendo que lo hagas con RadRails o RubyMine, ambos están bien soportados y vienen con muchas integraciones 'out of the box'.

Editor de texto: En lo personal uso vim como mi editor por defecto con janus el cual puede añadir y manejar muchos de los plugins que probablemente vas a utilizar. En caso que no sea un fan de vim como yo, hay muchas otras soluciones como Sublime Text que es multi plataforma, fácil de aprender y personalizable (este es probablemente tú mejor opción), esta altamente inspirado por TextMate (solo disponible para Mac OS). Una tercera opción es usando un muy reciente editor de texto de los chicos de GitHub llamado Atom, es un prometedor editor de texto echo con JavaScript, es fácil de extender y personalizar para satisfacer tus necesidades, dale una oportunidad. Cualquiera de los editores que te presento harán del trabajo, así que te dejo elegir cual se ajusta a tu ojo.

Terminal: Si decides seguir con kaishi para configurar el entorno, notarás que pone pro defecto el shell con zsh, lo cual recomiendo bastante. Para la terminal, no soy fan de aplicaciones de Terminal que traen mejoras si estas en Mac OS, así que mira iTerm2, Que es un remplazo de la terminal para Mac OS. Si estas en Linux probablemente ya tienes una linda terminal, pero la que viene por defecto puede funcionar bien.

Navegadores

Cuando se trata de navegadores diría Firefox inmediatamente, pero algunos otros desarrolladores pueden decir Chrome o incluso Safari. Cualquiera de ellos ayudara a construir la aplicación que buscas, ellos vienen con un buen inspector no justamente para el DOM pero para el análisis de red y muchas otras características que ya conoces.

Manejador de paquetes

  • Mac OS: Hay muchas opciones para gestionar o instalar tus paquetes en tu Mac, como el Mac Ports ó Homebrew, ambos son buenas opciones pero yo elegiría la última, he encontrado menos problemas cuando instalo software y lo administro. Para instalar brew solo ejecuta en la consola lo siguiente:

$ /usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"
  • Linux: Estas listo!, realmente no es mucho problema si tu estas usando apt, pacman, yum siempre que te sientas cómodo con ello sepas como instalar paquetes para poder seguir avanzando.

Git

Usaremos Git bastante, y puedes usarlo no solo para el propósito de este tutorial sino para cada proyecto independiente.

  • en Mac OS: $ brew install git

  • en Linux: $ sudo apt-get install git

Ruby

Son muchos los caminos en que puedes instalar y gestionar ruby, y ahora tú puedes tener probablemente alguna versión instalada si estas en Mac OS, para ver la versión que tienes, solo ejecuta:

$ ruby -v

Rails 6.0 requiere la instalación de la versión 2.5 o mayor.

Yo recomiendo usar Ruby Version Manager (RVM) ó rbenv para instalarlo. Vamos a usar RVM en este tutorial, pero no hay problema con cuál de las 2 utilices.

El principio de esta herramienta es permitirte instalar varias versiones de Ruby en el mismo equipo, en un entorno hermético con una posible versión instalada en tu sistema operativo y luego tener la habilidad de cambiar de una a otra versión fácilmente.

Para instalar RVM, ve a https://rvm.io/ e instala la huella de la llave GPG: [La huella de la llave GPG te permite verificar la identidad del autor o del origen de la descarga.]. Para realizarlo ejecutamos:

$ gpg --keyserver hkp://keys.gnupg.net --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB
$ \curl -sSL https://get.rvm.io | bash

Ahora instalaremos ruby:

$ rvm install 2.6

Ahora es momento de instalar el resto de dependencias que vamos a usar.

Gemas, Rails y Librerías faltantes

Primero actualizamos las gemas en el sistema:

$ gem update --system

En algunos casos si estas en Mac OS, necesitarás instalar algunas librerías extras:

$ brew install libtool libxslt libksba openssl

Luego instalamos las gemas necesarias e ignoramos la documentación para cada una:

$ gem install bundler
$ gem install rails -v 6.0.0

Revisamos que todo funciona correctamente:

$ rails -v
Rails 6.0.0

Base de datos

Recomiendo mucho que instales Postgresql para gestionar tus bases de datos. Pero aquí usaremos SQlite por simplicidad. Si estas usando Mac OS estas listo para continuar, en caso que uses Linux, no te preocupes solo nos faltan unos pasos más:

$ sudo apt-get install libxslt-dev libxml2-dev libsqlite3-dev

ó

$ sudo yum install libxslt-devel libxml2-devel libsqlite3-devel

Inicializando el proyecto

Inicializar una aplicación Rails puede ser muy sencillo para ti. Si no es el caso aquí tienes un tutorial super rápido.

Estos son los comandos:

$ mkdir ~/workspace
$ cd ~/workspace
$ rails new market_place_api --api
Note
La opción --api apareció en la versión 5 de Rails. Ésta te permite limitar las librerías y Middleware incluido en la aplicación. Esto también evita generar vistas HTML cuando se usan los generadores de Rails.

Como puedes adivinar, los anteriores comandos generaran los huesos desnudos de tu aplicación Rails.

Versionado

Recuerda que Git te ayuda a dar seguimiento y mantener el historial de tu código. Ten en mente que el codigo fuente de la aplicación es publicado en GitHub. Puedes seguir el proyecto en GitHub.

Ruby on Rails inicializa el directorio Git por tí cuando usas el comando rails new. Esto significa que no necesitas ejecutar el comando git init.

Sin embargo es necesario configurar la información del autor de los commits. Si aún no lo has echo, ve al directorio de proyecto y corre los siguientes comandos:

$ git config --global user.name "Aquí pon tu nombre"
$ git config --global user.email "Aquí pon tu email"

Rails también provee un archivo .gitignore para ignorar algunos archivos a los que no queramos dar seguimiento. El archivo .gitignore por defecto puede lucir como se ve a continuación:

.gitignore
# Ignore bundler config.
/.bundle

# Ignore the default SQLite database.
/db/*.sqlite3
/db/*.sqlite3-journal

# Ignore all logfiles and tempfiles.
/log/*
/tmp/*
!/log/.keep
!/tmp/.keep

# Ignore uploaded files in development.
/storage/*
!/storage/.keep
.byebug_history

# Ignore master key for decrypting credentials and more.
/config/master.key

Después de modificar el archivo .gitignore únicamente necesitamos añadir los archivos y hacer commit de los cambios, para ello usamos los siguientes comandos:

$ git add .
$ git commit -m "Commit Inicial"
Tip
He encontrado que el mensaje del commit debería iniciar con un verbo en tiempo presente, describiendo lo que el commit hace y no lo que hizo, ayuda cuando estás explorando el historial del proyecto. Encontré esto más natural para leer y entender. Seguiremos esta práctica hasta el final del tutorial.

Por ultimo y como un paso opcional configuramos el proyecto en GitHub y hacemos push de nuestro código al servidor remoto: Pero primero añadimos el remoto:

$ git remote add origin git@github.com:madeindjs/market_place_api_6.git

Entonces hacemos push(empujamos) el código:

$ git push -u origin master

A medida que avanzamos con el tútorial, usaré las practicas que uso a diario, esto incluye trabajar con branches(ramas), rebasing, squash y algo mas. Por ahora no debes preocuparte si algunos términos no te suenan familiares, te guiaré en ello con el tiempo.

Conclusión

Ha sido un largo camino a través de este capítulo, si has llegado hasta aquí déjame felicitarte y asegurarte que a partir de este punto las cosas mejorarán. Asi que vamos a ensuciarnos las manos y comenzar a escribir algo de código!

La API

En esta sección resumiré la aplicación. Hasta aquí ya debiste leer el capítulo anterior. Si no lo has leído te recomiendo que lo hagas.

Puedes clonar el proyecto hasta este punto con:

$ git checkout tags/checkpoint_chapter02

Resumiendo, simplemente generamos nuestra aplicación Rails e hicimos el primer commit.

Planificando la aplicación

Como queremos que la aplicación sea sencilla, esta consistirá de 5 modelos. No te preocupes si no entiendes completamente que estamos haciendo. Vamos a revisar y a construir cada uno de los recursos a medida que avancemos con el tutorial.

Esquema conexiones entre los modelos

Resumiendo, el user(usuario) podrá realizar muchas orders(ordenes/pedidos), subir múltiples products(productos) los cuales pueden tener muchas images(imágenes) ó comments(comentarios) de otros usuarios de la aplicación.

No construiremos vistas para mostrar o interactuar con la API, así que no hagas de esto un gran tutorial. Para ello hay muchas opciones allá afuera como los frameworks de javascript (Angular, Vue.js, React.js).

Hasta este punto deberías preguntarte:

¿Esta bien, pero, yo necesito explorar o visualizar cómo va la construcción del API?

Y eso es justo. Probablemente si googleas algo relacionado con explorar un api, aparecerá una aplicación llamada Postman. Este es un gran software pero no lo utilizaremos porque usaremos cURL que permite a cualquiera reproducir peticiones en cualquier computadora.

Configurar la API

Una API es definida por wikipedia como _La interfaz de programación de aplicaciones (API), es un conjunto de subrutinas, funciones y procedimientos que ofrece cierta biblioteca para ser utilizado por otro software como una capa de abstracción. _ En otras palabras la forma en que el sistema interactúa entre sí mediante una interfaz común, en nuestro caso un servicio web construido con JSON. Hay otros protocolos de comunicación como SOAP, pero no lo cubriremos aquí.

JSON, como tipo estándar en Internet, es ampliamente aceptado, legible, extensible y fácil de implementar. Muchos de los frameworks actuales consumen APIs JSON por defecto (Angular ó Vue.js por ejemplo). También hay grandes bibliotecas para Objetive-C como AFNetworking ó RESTKit. Probablemente hay buenas soluciones para Android, pero por mi falta de experiencia en esa plataforma, podría no ser la persona adecuada para recomendarte alguna.

Muy bien. Así que vamos a construir nuestra API con JSON. Hay muchos caminos para logarlo. Lo primero que me viene a la mente es justamente iniciar añadiendo rutas definiendo los end points. Pero puede ser mala idea porque no hay un patrón URI suficientemente claro para saber que recurso está expuesto. El protocolo o estructura del que estoy hablando es REST que significa Transferencia de Estado Representacional(Representational state transfer) según la definición de Wikipedia.

aService.getUser("1")

Y en REST puedes llamar una URL con una petición HTTP específica, en este caso con una petición GET: http://domain.com/resources_name/uri_pattern

La APIs RESTful debe seguir al menos tres simples pautas:

  • Una base URI, como es http://example.com/resources/.

  • Un tipo multimedia de Internet para representar los datos, es comúnmente JSON y es comúnmente definido mediante el intercambio de cabeceras.

  • Sigue el estándar Metodos HTTP como son GET, POST, PUT, DELETE.

    • GET: Lee el recurso o recursos definidos por el patrón URI

    • POST: Crea una nueva entrada en la colección de recursos

    • PUT: Actualiza una colección o un miembro de los recursos

    • DELETE: Destruye una colección o miembro de los recursos

Esto podría no ser suficientemente claro o podría parecer mucha información para digerir, pero como vamos avanzando en el tutorial, con suerte conseguirás entender con mayor facilidad.

Restricciones de Rutas y Espacios de Nombres

Antes de comenzar a escribir código, preparamos el código con git. Vamos a estar usando una rama por capítulo, la subiremos a GitHub y entonces la fusionaremos con la rama master. Así que vamos a a iniciar abriendo la terminal, cd hacia el directorio market_place_api y tecleamos lo siguiente:

$ git checkout -b chapter02
Switched to a new branch 'chapter02'

Únicamente vamos a estar trabajando en config/routes.rb, ya que solo vamos a establecer las restricciones y el formato de respuesta predeterminado para cada respuesta.

config/routes.rb
Rails.application.routes.draw do
  # ...
end

Primero que todo borra todo el código comentado que viene en el archivo, no lo vamos a necesitar. Entonces haz un commit, solo como un calentamiento:

$ git add config/routes.rb
$ git commit -m "Removes comments from the routes file"

Vamos a aislar los controladores del API bajo un espacio de nombres. Con Rails esto es bastante simple: solo tienes que crear un folder en app/controllers llamado api. El nombre es importante porque es el espacio de nombres que usaremos para gestionar los controladores para los endpoints del api.

$ mkdir app/controllers/api

Entonces agregamos el nombre de espacio dentro de nuestro archivo routes.rb:

config/routes.rb
Rails.application.routes.draw do
  # Api definition
  namespace :api do
    # We are going to list our resources here
  end
end

Por definición un espacio de nombres en el archivo routes.rb. Rails automáticamente mapeara que espacio de nombres corresponde al folder de los controlladores, en nuestro caso el directorio api/`.

Archivos multimedia soportados por Rails

Rails soporta 35 tipos diferentes de archivos multimedia, puedes listarlos accediendo a la clase SET del módulo Mime:

$ rails c
2.6.3 :001 > Mime::SET.collect(&:to_s)
 => ["text/html", "text/plain", "text/javascript", "text/css", "text/calendar", "text/csv", "text/vcard", "text/vtt", "image/png", "image/jpeg", "image/gif", "image/bmp", "image/tiff", "image/svg+xml", "video/mpeg", "audio/mpeg", "audio/ogg", "audio/aac", "video/webm", "video/mp4", "font/otf", "font/ttf", "font/woff", "font/woff2", "application/xml", "application/rss+xml", "application/atom+xml", "application/x-yaml", "multipart/form-data", "application/x-www-form-urlencoded", "application/json", "application/pdf", "application/zip", "application/gzip"]

Esto es importante porque vamos a trabajar con JSON, uno de los tipos MIME aceptados por Rails, solo necesitamos especificar que este es el formato por defecto:

config/routes.rb
Rails.application.routes.draw do
  # Api definition
  namespace :api, defaults: { format: :json }  do
    # We are going to list our resources here
  end
end

Hasta este punto no hemos hecho nada loco. Ahora lo que queremos es una base_uri que incluye la versión de la API. Pero hagamos commit antes de ir a la siguiente sección:

$ git add config/routes.rb
$ git commit -m "Set the routes constraints for the api"

Versionado Api

Hasta este punto deberíamos tener un buen mapeado de rutas usando espacio de nombres. Tu archivo routes.rb debería lucir como esto:

config/routes.rb
Rails.application.routes.draw do
  # Api definition
  namespace :api, defaults: { format: :json }  do
    # We are going to list our resources here
  end
end

Ahora es tiempo de configurar algunas otras restricciones para propósitos de versionado. Deberías preocuparte por versionar tú aplicación desde el inicio pues le dará una mejor estructura a tu api, y cuando hagas cambios, puedes dar a los desarrolladores que están consumiendo tu api la oportunidad de adaptar las nuevas características mientras las viejas quedan obsoletas. Este es un excelente railscast explicando esto.

Para establecer la versión del API, primero necesitamos agregar otro directorio en el de api que antes creamos:

$ mkdir app/controllers/api/v1

De esta forma podemos definir espacio de nombres a nuestra api con diferentes versiones fácilmente, ahora solo necesitamos añadir el código necesario al archivo routes.rb:

config/routes.rb
Rails.application.routes.draw do
  # Api definition
  namespace :api, defaults: { format: :json }  do
    namespace :v1 do
      # We are going to list our resources here
    end
  end
end

Hasta este punto, el API puede ser alcanzada a través de la URL. Por ejemplo con esta configuración un end-point para recuperar un producto podría ser algo como: http://localhost:3000/v1/products/1 .

Patrones Comunes del API

Puedes encontrar muchas formas de configurar un base_uri cuando construimos un api siguiendo diferentes patrones, asumiendo que estamos versionando nuestra api:

  • api.example.com/: En mi opinión este es el camino a seguir, te da una mejor interfaz y aislamiento, y a largo plazo puede ayudarte a escalar rápidamente

  • example.com/api/: Este patrón es muy común, y es actualmente un buen camino a seguir cuando no quieres poner bajo espacio de nombres tu api en un subdominio

  • example.com/api/v1: parece buena idea, poniendo la versión del api mediante la URL, parece como un patrón descriptivo, pero esta forma te forza a incluir la URL en cada petición, así que si en algún momento decides cambiar este patrón, se convierte en un problema de mantenimiento a largo plazo.

Estas son algunas prácticas en la construcción de una API que recomiendan no versionar el API a través de la URL. Es verdad. El desarrollador no debería conocer la versión que está usando. En términos de simplicidad, he decidido dejar esta convención, que podremos aplicar en una segunda fase.

Es tiempo de hacer commit:

$ git commit -am "Set the versioning namespaces for API"

Estamos en lo último del capítulo. Por lo tanto, es tiempo de aplicar nuestras modificaciones a la rama master haciendo un merge. Para hacerlo, nos cambiamos a la rama master y hacemos merge de chapter02:

$ git checkout master
$ git merge chapter02

Conclusión

Ha sido un largo camino, lo sé, pero lo hiciste, no te rindas esto solo es un pequeño escalón para cualquier cosa grande, así que sigue. Mientras tanto y si te sientes curioso hay algunas gemas que pueden manejar este tipo de configuración:

No cubriré eso en este libro, ya que estamos intentando aprender a implementar este tipo de funcionalidades, pero es bueno saberlo. Por cierto, el código hasta este punto está aquí.

Presentando a los usuarios

En el último capítulo configuramos el esqueleto para la configuración de los enpoints en nuestra aplicación.

En un próximo capítulo manejaremos autenticación de usuarios mediante autenticación con tokens configurando permisos para poner límites de acceso preguntando que usuario esta autenticado. En capítulos venideros vamos a relacionar products (productos) a usuarios y dar la habilidad de generar órdenes.

Puedes clonar el proyecto hasta este punto con:

$ git checkout tags/checkpoint_chapter03

Como ya estarás imaginando hay muchas soluciones de autenticación para Rails, AuthLogic, Clearance y Devise.

Estas librerías son soluciones como llave en mano, por ejemplo ellas te permiten gestionar un montón de cosas como autenticación, olvido de contraseña, validación, etc.. Sin embargo, vamos a usar la gema bcrypt para generar un hash para la contraseña del usuario.

Este capítulo estará completo. Puede ser largo pero intentare cubrir el mayor número de temas posibles. Siéntete libre de tomar un café y vamos. Al final de este capítulo tendrás construida la lógica del usuario así como la validación y manejo de errores.

Es un buen momento para crear una nueva rama:

$ git checkout -b chapter03
Note
Asegúrate que estas en la rama master antes de hacer checkout.

Modelo usuario

Generación del modelo User

Comenzaremos por generar nuestro modelo User. Este modelo será realmente básico y tendrá solo dos campos:

  • email el cual será único y permitirá conectar con la aplicación

  • password_digest el cual contiene la versión hasheada de la contraseña (los discutiremos mas tarde en este capítulo)

Generamos nuestro modelo User usando el comando generate model provisto por Ruby on Rails. Es muy fácil de usar:

$ rails generate model User email:string password_digest:string
invoke  active_record
      create    db/migrate/20190603195146_create_users.rb
      create    app/models/user.rb
      invoke    test_unit
      create      test/models/user_test.rb
      create      test/fixtures/users.yml
Note
El modelo es el elemento que contiene la información o datos así como la lógica relacionada a esa información: validación, lectura y guardado.

¡Este comando genera un montón de archivos! No te preocupes revisaremos uno por uno.

El archivo de migración contenido en el forder db/migrate contiene la migración que describe los cambios que realizará en la base de datos. Este archivo puede lucir así:

db/migrate/20190603195146_create_users.rb
class CreateUsers < ActiveRecord::Migration[6.0]
  def change
    create_table :users do |t|
      t.string :email
      t.string :password_digest

      t.timestamps
    end
  end
end
Note
La fecha insertada al inicio del nombre del archivo de migración debiera ser diferente para ti ya que corresponde a la fecha de creación de la migración.

Haremos un pequeño cambio a la migración a fin de añadir algunas validaciones a la base de datos. Con rails es una práctica común hacer validaciones directamente en el modelo Ruby. Es buena práctica hacer algo en el esquema de la base de datos.

Por lo tanto haremos dos restricciones adicionales:

  • email es forzoso: usaremos la propiedad null: false.

  • email debe ser único: añadiremos un índice para la columna email con la propiedad unique: true.

  • password es forzoso: usamos la propiedad null: false.

La migración quedaría así:

db/migrate/20190603195146_create_users.rb
# ...
create_table :users do |t|
  t.string :email, null: false
  t.index :email, unique: true
  t.string :password_digest, null: false
  # ...
end

Una vez completa la migración, podemos correr los cambios con el siguiente comando:

db/migrate/20190603195146_create_users.rb
$ rake db:migrate
== 20190603195146 CreateUsers: migrating ======================================
-- create_table(:users)
   -> 0.0027s
== 20190603195146 CreateUsers: migrated (0.0028s) =============================
Note
Este comando convertirá nuestra migración en una consulta SQL que actualizara la base de datos SQLite3 almacenada en el folder db.

Modelo

Así definimos nuestro esquema de la base de datos. El siguiente paso es actualizar nuestro modelo para definir reglas de validación. Estas reglas están definidas en el modelo localizado en el folder`app/models`.

Ruby on Rails provee un mecanismo completo que puedes encontrar en su documentación oficial. En nuestro caso buscamos validar solo 3 cosas:

  1. que el email tenga un formato válido

  2. que el email sea único

  3. que la contraseña siempre contenga algo

Estas tres reglas son definidas por el siguiente código:

app/models/user.rb
class User < ApplicationRecord
  validates :email, uniqueness: true
  validates_format_of :email, with: /@/
  validates :password_digest, presence: true
end

Ahí tienes. Rails una sintaxis simple y el código es muy legible.

Validación del Email

Habrás notado que la validación del email es muy simplista solo validando la presencia de una @.

Es normal.

Hay infinidad de excepciones en la dirección de un correo electrónico que incluso Mira todos estos espacios!@example.com es una dirección de correo valida. Por lo tanto, es mejor para favorecer un enfoque sencillo y confirmar la dirección de correo enviando un email.

Pruebas unitarias

Finalizamos con las pruebas unitarias. Aquí usaremos Minitest un framework de pruebas que es proporcionado por defecto con Rails.

Minitest está basado en Fixtures que te permiten llenar tu base de datos con datos predefinidos*. Los Fixtures están definidos en un archivo YAML en el directorio tests/fixtures. Hay un archivo por plantilla.

Debemos por lo tanto iniciar actualizando nuestros tests/fixtures.

Note
fixtures no están diseñados para crear todas los datos que tus pruebas necesitan. Solo te permiten definir los datos básicos que tu aplicación necesita.

Así que comenzamos por crear un fixture definiendo un usuario:

test/fixtures/users.yml
one:
  email: one@one.org
  password_digest: hashed_password

Ahora podemos crear tres pruebas:

  • 1. Verifica que un usuario con datos correctos es válido:

test/models/user_test.rb
# ...
test 'user with a valid email should be valid' do
  user = User.new(email: 'test@test.org', password_digest: 'test')
  assert user.valid?
end
  • 2. Verifica que un usuario con un email erróneo no es válido:

test/models/user_test.rb
# ...
test 'user with invalid email should be invalid' do
  user = User.new(email: 'test', password_digest: 'test')
  assert_not user.valid?
end
  • 3. Verifica que un nuevo usuario con email no es válido. Así que usamos el mismo email que creamos en el fixture.

test/models/user_test.rb
# ...
test 'user with taken email should be invalid' do
  other_user = users(:one)
  user = User.new(email: other_user.email, password_digest: 'test')
  assert_not user.valid?
end

Ahí lo tienes. Podemos validar que nuestra implementación es correcta simplemente corriendo las pruebas unitarias que creamos:

$ rake test
...
3 runs, 3 assertions, 0 failures, 0 errors, 0 skips

I think it’s time to do a little commit to validate our progress:

$ git add . && git commit -m "Create user model"

Hash de la contraseña

Previamente implementamos el almacenamiento de los datos del usuario. Pero seguimos teniendo un problema por resolver: el almacenamiento de la contraseña está en texto plano.

Si almacenas la contraseña de los usuarios en texto plano, entonces un atacante que roba una copia de tu base de datos tiene una lista gigante de emails y contraseñas. Alguno de tus usuarios podría tener únicamente una contraseña — para su cuenta de email, para sus cuentas de banco, para su aplicación. Un simple hackeo puede escalar en un robo masivo de identidad. - fuente - Porque deberías usar bcrypt(en inglés)

Así que vamos a usar la gema bcrypt para hashear la contraseña.

Note
Hashear es el proceso de transformar un arreglo de caracteres en un Hash. Este Hash no te permite encontrar el arreglo de caracteres original. Pero como sea, podemos fácilmente usarlo para encontrar si un arreglo de caracteres dado coincide con el hash que almacenamos.

Primero debemos agregar la gema Bcrypt al Gemfile. Podemos usar el comando bundle add. Que hará:

  1. añadir la gema al Gemfile recuperando la versión más reciente

  2. ejecutar el comando bundle install el cual instalará la gema y actualizará el archivo Gemfile.lock "bloqueando" la versión actual de la gema

Por lo tanto, ejecutamos el siguiente comando:

$ bundle add bcrypt

Una vez que el comando es ejecutado, la siguiente línea es añadida al final del Gemfile:

Gemfile
gem "bcrypt", "~> 3.1"
Note
La versión 3.1 de bcrypt es la versión actual al momento de escribir. Esto podría por lo tanto variar en tú caso.

Active Record nos ofrece un método ActiveModel::SecurePassword::has_secure_password que hará interfaz con Bcrypt y nos ayudará con la contraseña lo que lo hace más fácil.

app/models/user.rb
class User < ApplicationRecord
  # ...
  has_secure_password
end

has_secure_password agrega las siguientes validaciones:

  • La contraseña debe estar presente en la creación.

  • La longitud de la contraseña debe ser menor o igual a 72 bytes.

  • La confirmación de la contraseña usa el atributo password_confirmation (si es enviado)

En adición, este método añadirá un atributo User#password que será automáticamente hasheado y guardado en el atributo User#password_digest.

Vamos a intentarlo ahora mismo en la consola de Rails. Abre una consola con rails console:

2.6.3 :001 > User.create! email: 'toto@toto.org', password: '123456'
 =>#<User id: 1, email: "toto@toto.org", password_digest: [FILTERED], created_at: "2019-06-04 10:51:44", updated_at: "2019-06-04 10:51:44">

Puedes ver que cuando llamas al método User#create! , el atributo password es hasheado y guardado en password_digest. Vamos a enviar también un atributo password_confirmation que ActiveRecord comparará con password:

2.6.3 :002 > User.create! email: 'tata@tata.org', password: '123456', password_confirmation: 'azerty'
ActiveRecord::RecordInvalid (Validation failed: Password confirmation doesn t match Password)

¡Todo está trabajando como lo planeamos! Vamos a hacer un commit para mantener la historia concisa:

$ git commit -am "Setup Bcrypt"

Creando usuarios

Es tiempo de hacer nuestro primer "entry point". Iniciaremos por construir la acción show que responderá con información de un usuario único en formato JSON. Los pasos son:

  1. generar el controlador users_controller.

  2. añadir las pruebas correspondientes

  3. construir el código real.

Vamos a enfocarnos primero en generar el controlador y las pruebas funcionales.

En orden para respetar la vista de nuestra API, vamos a cortar nuestra aplicación usando modules (módulos). La sintaxis por lo tanto es la siguiente:

$ rails generate controller api::v1::users

Este comando creará el archivo users_controller_test.rb. Antes de ir más lejos hay dos cosas que queremos probar en nuestra API:

  • La estructura JSON que devuelve el servidor

  • El código de la respuesta HTTP que devuelve el servidor

Códigos HTTP más comunes

El primer dígito de el código de estado especifica una de las 5 clases de respuesta. El mínimo indispensable para un cliente HTTP es que este una de estas 5 clases. Esta es una lista de los códigos HTTP comúnmente usados:

  • 200: Respuesta estándar para una solicitud HTTP exitosa. Usualmente en solicitudes GET

  • 201: La petición fue recibida y resulta en la creación del nuevo recurso. Después de una solicitud POST

  • 204: El servidor tiene una petición procesada con éxito, pero no se regresó ningún contenido. Esto es usual en una solicitud DELETE exitosa.

  • 400: La petición no se puede ejecutar debido a una sintaxis incorrecta. Puede suceder para cualquier tipo de solicitud.

  • 401: Similar al 403, pero especialmente usada al solicitar autenticación y ha fallado o aún no se ha proporcionado. Puede suceder en cualquier tipo de solicitud.

  • 404: El recurso solicitado no fue encontrado, pero podría estar disponible en el futuro. Usualmente concierne a la petición GET.

  • 500: Un mensaje de error genérico, dado cuando una condición inesperada ha sido encontrada y ningún otro mensaje especifico es apropiado.

Para una lista completa de códigos HTTP, mira este articulo de Wikipedia (en inglés).

Por lo tanto, vamos a implementar la prueba funcional que verifica el acceso al método Users#show.

test/controllers/api/v1/users_controller_test.rb
# ...
class Api::V1::UsersControllerTest < ActionDispatch::IntegrationTest
  setup do
    @user = users(:one)
  end

  test "should show user" do
    get api_v1_user_url(@user), as: :json
    assert_response :success
    # Test to ensure response contains the correct email
    json_response = JSON.parse(self.response.body)
    assert_equal @user.email, json_response['email']
  end
end

Entonces simplemente agrega la acción a tu controlador. Es extremadamente simple:

app/controllers/api/v1/users_controller.rb
class  Api::V1::UsersController < ApplicationController
  # GET /users/1
  def show
    render json: User.find(params[:id])
  end
end

Si corres la prueba con rails test obtienes el siguiente error:

$ rails test

...E

Error:
UsersControllerTest#test_should_show_user:
DRb::DRbRemoteError: undefined method \`api_v1_user_url' for #<UsersControllerTest:0x000055ce32f00bd0> (NoMethodError)
    test/controllers/users_controller_test.rb:9:in `block in <class:UsersControllerTest>'

¡Este tipo de error es muy común cuando generaste tus recursos manualmente! En efecto, nos hemos olvidado por completo de la ruta. Así que vamos a añadirla:

config/routes.rb
Rails.application.routes.draw do
  namespace :api, defaults: { format: :json } do
    namespace :v1 do
      resources :users, only: [:show]
    end
  end
end

Las pruebas ahora deberían pasar:

$ rails test
....
4 runs, 5 assertions, 0 failures, 0 errors, 0 skips

Como siempre, después de añadir una característica que nos satisface, vamos a hacer un commit:

$ git add . && git commit -m "Adds show action to the users controller"

Prueba tu recurso con cURL

Así que finalmente tenemos un recurso para probar. Tenemos muchas soluciones para probarlo. La primera que se me viene a la mente es hacer uso de cURL, el cual está integrado en la mayoría de distribuciones Linux. Así que vamos a probarlo:

Primero inicializamos el servidor de Rails en una nueva terminal.

$ rails s

Entonces cambia de nuevo a tu otra terminal y corre:

$ curl http://localhost:3000/api/v1/users/1
{"id":1,"email":"toto@toto.org", ...

Encontramos el usuario que creamos con la consola de Rails en la sección previa. Ahora tienes una entrada en el API para registro de usuarios.

Crear usuarios

Ahora que tenemos mejor entendimiento de como construir "entry points" (puntos de entrada), es tiempo de extender nuestra API. Una de las características más importantes es darles a los usuarios que puedan crear un perfil en nuestra aplicación. Como siempre, vamos a escribir nuestras pruebas antes de implementar nuestro código para extender nuestro banco de pruebas.

Asegura que tu directorio de Git está limpio y que no tienes algún archivo en staging. Si es así hazles commit que vamos a empezar de nuevo.

Así que vamos a iniciar por escribir nuestra prueba añadiendo una entrada para crear un usuario en el archivo users_controller_test.rb:

test/controllers/users_controller_test.rb
# ...
class Api::V1::UsersControllerTest < ActionDispatch::IntegrationTest
  # ...
  test "should create user" do
    assert_difference('User.count') do
      post api_v1_users_url, params: { user: { email: 'test@test.org', password: '123456' } }, as: :json
    end
    assert_response :created
  end

  test "should not create user with taken email" do
    assert_no_difference('User.count') do
      post api_v1_users_url, params: { user: { email: @user.email, password: '123456' } }, as: :json
    end
    assert_response :unprocessable_entity
  end
end

Es un montón de código. No te preocupes explicare todo:

  • En el primer test revisamos la creación de un usuario enviando una petición POST valida. Entonces, revisamos que un usuario adicional ahora existe en la base de datos y que el código HTTP de respuesta es created (código de estado 201)

  • En el segundo test revisamos que el usuario no es creado usando una dirección de correo que ya está en uso. Entonces, revisamos que el código HTTP de respuesta es unprocessable_entity (código de estado 422)

Hasta este punto, la prueba debería de fallar (como esperábamos):

$ rails test
...E

Asi que es tiempo de implementar el código para que nuestra prueba sea exitosa:

app/controllers/api/v1/users_controller.rb
class Api::V1::UsersController < ApplicationController
  # ...

  # POST /users
  def create
    @user = User.new(user_params)

    if @user.save
      render json: @user, status: :created
    else
      render json: @user.errors, status: :unprocessable_entity
    end
  end

  private

  # Only allow a trusted parameter "white list" through.
  def user_params
    params.require(:user).permit(:email, :password)
  end
end

Recuerda que cada vez que agregamos una entrada en nuestra API debemos agregar esta acción en nuestro archivo routes.rb.

config/routes.rb
Rails.application.routes.draw do
  namespace :api, defaults: { format: :json } do
    namespace :v1 do
      resources :users, only: %i[show create]
    end
  end
end

Como puedes ver, la implementación es bastante simple. También hemos añadido el método privado user_params para proteger de la asignación masiva de atributos. Ahora nuestra prueba debería de pasar:

$ rails test
......
6 runs, 9 assertions, 0 failures, 0 errors, 0 skips

Yeah! Hagamos commit de los cambios y a continuar construyendo nuestra aplicación:

$ git commit -am "Adds the user create endpoint"

Actualizar usuarios

El esquema para actualizar usuarios es muy similar a la de creación. Si eres un desarrollador Rails experimentado, ya sabes las diferencias entre estas dos acciones:

  • La accion update (actualizar) responde a una petición PUT/PATCH.

  • Únicamente un usuario conectado debería ser capaz de actualizar su información. Esto significa que tendremos que forzar a un usuario a autenticarse. Discutiremos esto en el capítulo 5.

Como siempre, empezamos escribiendo nuestra prueba:

test/controllers/users_controller_test.rb
# ...
class Api::V1::UsersControllerTest < ActionDispatch::IntegrationTest
  # ...
  test "should update user" do
    patch api_v1_user_url(@user), params: { user: { email: @user.email, password: '123456' } }, as: :json
    assert_response :success
  end

  test "should not update user when invalid params are sent" do
    patch api_v1_user_url(@user), params: { user: { email: 'bad_email', password: '123456' } }, as: :json
    assert_response :unprocessable_entity
  end
end

Para que la prueba se exitosa, debemos construir la acción update en el archivo users_controller.rb y agregar la ruta al archivo routes.rb. Como puedes ver, tenemos mucho código duplicado, vamos a rediseñar nuestra prueba en el capítulo 4. Primero añadimos la acción al archivo routes.rb:

config/routes.rb
Rails.application.routes.draw do
  # ...
  resources :users, only: %i[show create update]
  # ...
end

Entonces implementamos la acción update en el controlador del usuario y corremos las pruebas:

app/controllers/api/v1/users_controller.rb
class Api::V1::UsersController < ApplicationController
  before_action :set_user, only: %i[show update]

  # GET /users/1
  def show
    render json: @user
  end

  # ...

  # PATCH/PUT /users/1
  def update
    if @user.update(user_params)
      render json: @user, status: :ok
    else
      render json: @user.errors, status: :unprocessable_entity
    end
  end

  private
  # ...

  def set_user
    @user = User.find(params[:id])
  end
end

Todas nuestras pruebas deberían pasar:

$ rails test
........
8 runs, 11 assertions, 0 failures, 0 errors, 0 skips

Hacemos un commit ya que todo funciona:

$ git commit -am "Adds update action the users controller"

Eliminar al usuario

Hasta aquí, hemos hecho un montón de acciones en el controlador del usuario con sus propias pruebas, pero no hemos terminado. Solo necesitamos una cosa más, que es la acción de destruir. Así que vamos a crear la prueba:

test/controllers/users_controller_test.rb
# ...
class Api::V1::UsersControllerTest < ActionDispatch::IntegrationTest
  # ...

  test "should destroy user" do
    assert_difference('User.count', -1) do
      delete api_v1_user_url(@user), as: :json
    end
    assert_response :no_content
  end
end

Como puedes ver, la prueba es muy simple. Únicamente respondemos con estado 204 que significa No Content (Sin contenido). También podríamos devolver un código de estado 200, pero encuentro más natural la respuesta No Content (Sin contenido) en este caso porque eliminamos un recurso y una respuesta exitosa podría ser bastante.

La implementación de la acción de destrucción es muy simple:

app/controllers/api/v1/users_controller.rb
class Api::V1::UsersController < ApplicationController
  before_action :set_user, only: %i[show update destroy]
  # ...

  # DELETE /users/1
  def destroy
    @user.destroy
    head 204
  end

  # ...
end

No olvides añadir la acción destroy en el archivo routes.rb:

config/routes.rb
Rails.application.routes.draw do
  # ...
  resources :users, only: %i[show create update destroy]
  # ...
end

Las pruebas deberían de pasar si todo es correcto:

$ rails test
.........
9 runs, 13 assertions, 0 failures, 0 errors, 0 skips

Recuerda que después de hacer algunos cambios en nuestro código, es buena práctica hacerles commit así podremos tener un historial segmentado correctamente.

$ git commit -am "Adds destroy action to the users controller"

Y a medida que llegamos al final de nuestro capítulo, es tiempo de aplicar nuestra modificaciones a la rama master haciendo un merge:

$ git checkout master
$ git merge chapter03

Conclusión

¡Oh, ahí tienes!, ¡Bien echo! ¡Se que probablemente fue un largo tiempo, pero no te rindas! Asegúrate de entender cada pieza del código, las cosas mejorarán, en el siguiente capítulo, vamos a rediseñar nuestras pruebas para hace nuestro código más legible y mantenible. ¡Entonces quédate conmigo!

Autenticando al usuario

Ha sido un largo tiempo desde que iniciamos. Espero que te guste este viaje tanto como a mí.

En el capítulo anterior configuramos las entradas de recursos para los usuarios. Si te saltaste este capítulo o si no entendiste todo, te recomiendo encarecidamente que lo mires. Éste cubre las primeras bases de las pruebas y es una introducción a respuestas JSON.

Puedes clonar el proyecto hasta este punto:

$ git checkout tags/checkpoint_chapter04

En este capítulo las cosas se pondrán muy interesantes porque vamos a configurar el mecanismo de autenticación. En mi opinión es uno de los capítulos más interesantes. Introduciremos un montón de términos nuevos y terminarás con un simple pero poderoso sistema de autenticación. No sientas pánico vamos por ello.

La primera cosa es que primero (y como es usual cuando iniciamos un nuevo capítulo) vamos a crear una nueva rama:

$ git checkout -b chapter04

Sesion sin estado

Antes de que hagamos algo, algo debe estar claro: una API no maneja sesiones. Si no tienes experiencia construyendo este tipo de aplicaciones puede sonar un poco loco pero quédate conmigo. Un API puede ser sin estado lo cual significa por definición es una que provee una respuesta después de tú petición, y luego no requiere más atención. Lo cual significa que un estado previo o un estado futuro no es requerido para que el sistema trabaje.

El flujo para autenticar al usuario mediante una API es muy simple:

  1. La petición del cliente para el recurso sessions con las correspondientes credenciales (usualmente email y password)

  2. El server regresa el recurso user junto con su correspondiente token de autenticación

  3. Para cada página que requiere autenticación el cliente tiene que enviar el token de autenticación

Por supuesto estos no son los únicos 3 pasos a seguir, y en el paso 2 debería pensar, bien yo realmente ¿necesito responder con la información del usuario o solo el token de autenticación? Yo podría decir que eso realmente depende de tí, pero a mí me gusta regresar el usuario completo, de esta forma puedo mapearlo de inmediato en mi cliente y guardar otra posible solicitud que haya sido echa.

En esta sección y la siguiente vamos a enfocarnos en construir un controlador de sesiones junto a sus acciones correspondientes. Vamos entonces a completar el flujo de solicitudes agregando los accesos de autorización necesarios.

Presentación de JWT

Cuando nos acercamos a los tokens de autenticación, tenemos un estándar: el JSON Web Token (JWT).

JWT es un estándar abierto definido en RFC 75191. Este permite el intercambio seguro de tokens entre varias partes. - Wikipedia

En general un token JWT se compone de tres partes:

  • un header estructurado en JSON contiene por ejemplo la fecha de validación del token.

  • un payload estructurado en JSON puede contener cualquier dato. En nuestro caso, contiene el indetificador del usuario "conectado".

  • un signature que nos permite verificar que el token fue encriptado por nuestra aplicación y es por lo danto válido.

Estas tres partes son cada una codificadas en base64 y entonces concatenadas usando puntos (.). Lo cual nos da algo como:

Un token JWT válido
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Una ves decodificado, este token nos da la siguiente información:

La cabecera del token JWT
{ "alg": "HS256", "typ": "JWT" }
El payload de el token JWT
{ "sub": "1234567890", "name": "John Doe", "iat": 1516239022 }
Note
Para más información sobre tokens JWT te invito a visitar jwt.io

Esto tiene muchas ventajas justo como enviar información en payload de tokens. Por ejemplo, podemos elegir integrar información del usuario en el payload.

Configurando el token de autenticación

El estándar JWT tiene muchas implementaciones en varios lenguajes y librerías. Por supuesto, hay una gema de Ruby en este tema: ruby-jwt.

Asi que vamos a comenzar instalándola:

$ bundle add jwt

Una vez completada la siguiente línea es añadida a tu Gemfile:

gem "jwt", "~> 2.2"

La librería es muy simple. Hay dos métodos: JWT.encode y JWT.decode. Vamos a abrir una terminal con console rails y a correr algunas pruebas:

2.6.3 :001 > token = JWT.encode({message: 'Hello World'}, 'my_secret_key')
2.6.3 :002 > JWT.decode(token, 'my_secret_key')
 => [{"message"=>"Hello World"}, {"alg"=>"HS256"}]

En la primera línea codificamos un payload con la llave secreta my_secret_key. así obtenemos un token que podemos decodificar de manera simple. La segunda línea decodifica el token y vemos que podemos encontrar sin dilema nuestro payload.

Vamos a incluir toda la lógica en una clase JsonWebToken en un nuevo archivo localizado en lib/. Esto nos permite evitar el código duplicado. Esta clase justamente codificará y decodificará los tokens JWT. Así que aquí está la implementación.

lib/json_web_token.rb
class JsonWebToken
  SECRET_KEY = Rails.application.credentials.secret_key_base.to_s

  def self.encode(payload, exp = 24.hours.from_now)
    payload[:exp] = exp.to_i
    JWT.encode(payload, SECRET_KEY)
  end

  def self.decode(token)
    decoded = JWT.decode(token, SECRET_KEY).first
    HashWithIndifferentAccess.new decoded
  end
end

Yo se que es un montón de código pero lo revisaremos juntos.

  • el método JsonWebToken.encode se encarga de codificar el payload añadiendo una fecha de expiración de 24 horas por defecto. Además usamos la misma llave de encriptación que viene configurada con Rails.

  • el método JsonWebToken.decode decodifica el token JWT y obtiene el payload. Entonces usamos la clase HashWithIndifferentAccess proveída por Rails la cual nos permite recuperar un valor de un Hash con un Symbol ó String.

Ahí tienes. Para cargar el archivo en tú aplicación, necesitas especificar el directorio lib en la lista de _autoload de Ruby on rails. Para hacerlo, agrega la siguiente configuración al archivo application.rb:

config/application.rb
# ...
module MarketPlaceApi
  class Application < Rails::Application
    # ...
    config.eager_load_paths << Rails.root.join('lib')
  end
end

Y eso es todo. Ahora es tiempo de hacer un commit:

$ git add . && git commit -m "Setup JWT gem"

Controlador de Token

Tenemos sin embargo que configurar el sistema para generar un token JWT. Es ahora tiempo de crear una ruta que generará este token. Las acciones que implementaremos serán administradas como servicios RESTful: la conexión será gestionada por una petición POST a la acción create.

Para empezar, iniciaremos creando el controlador y el método create en el namespace /api/v1. Con Rails, una orden es suficiente:

$ rails generate controller api::v1::tokens create

Modificaremos la ruta un poco para respetar las convenciones REST:

config/routes.rb
Rails.application.routes.draw do
  namespace :api, defaults: { format: :json } do
    namespace :v1 do
      # ...
      resources :tokens, only: [:create]
    end
  end
end

Vamos a construir pruebas funcionales antes de ir más lejos. El comportamiento deseado es el siguiente:

  • Yo recibo un token si envío un email valido junto con el password

  • de otro modo el server responde un forbidden

Las pruebas por lo tanto se materializan de la siguiente forma:

test/controllers/api/v1/tokens_controller_test.rb
require 'test_helper'

class Api::V1::TokensControllerTest < ActionDispatch::IntegrationTest
  setup do
    @user = users(:one)
  end

  test 'should get JWT token' do
    post api_v1_tokens_url, params: { user: { email: @user.email, password: 'g00d_pa$$' } }, as: :json
    assert_response :success

    json_response = JSON.parse(response.body)
    assert_not_nil json_response['token']
  end

  test 'should not get JWT token' do
    post api_v1_tokens_url, params: { user: { email: @user.email, password: 'b@d_pa$$' } }, as: :json
    assert_response :unauthorized
  end
end

Te estarás preguntando: "¿pero como puedes saber la contraseña del usuario?". Simplemente usa el método BCrypt::Password.create en los fixtures de users:

test/fixtures/users.yml
one:
  email: one@one.org
  password_digest: <%= BCrypt::Password.create('g00d_pa$$') %>

En este preciso momento, si corres las pruebas obtendrás dos errores:

$ rake test

........E

Error:
Api::V1::TokensControllerTest#test_should_get_JWT_token:
JSON::ParserError: 767: unexpected token at ''


Failure:
Expected response to be a <401: unauthorized>, but was a <204: No Content>

Es normal. Ahora es tiempo de implementar la lógica para crear el token JWT. Es muy sencillo.

app/controllers/api/v1/tokens_controller.rb
class Api::V1::TokensController < ApplicationController
  def create
    @user = User.find_by_email(user_params[:email])
    if @user&.authenticate(user_params[:password])
      render json: {
        token: JsonWebToken.encode(user_id: @user.id),
        email: @user.email
      }
    else
      head :unauthorized
    end
  end

  private

  # Only allow a trusted parameter "white list" through.
  def user_params
    params.require(:user).permit(:email, :password)
  end
end

Es un montón de código pero es muy simple:

  1. Siempre filtramos los parámetros con el método user_params.

  2. Recuperamos el usuario con el método User.find_by_email (que es un método "mágico" de Active Record mientras el campo email esté presente en la base de datos) y recuperamos el usuario

  3. Usamos el método User#authenticate (el cual existe gracias a la gema bcrypt) con la contraseña como un parámetro. Bcrypt hará un hash de la contraseña y verifica si coincide con el atributo password_digest. La función regresa true si todo salió bien, false si no.

  4. Si la contraseña corresponde al hash, un JSON conteniendo el token generado con la clase JsonWebToken es devuelto. De otro modo, una respuesta vacía es devuelta con una cabecera unauthorized

¿Estas hasta aquí? ¡No te preocupes, esta terminado! Ahora tus pruebas deberían pasar.

$ rake test

...........

Finished in 0.226196s, 48.6304 runs/s, 70.7351 assertions/s.
11 runs, 16 assertions, 0 failures, 0 errors, 0 skips

¡Muy bien! Es tiempo de hacer un commit que contendrá todos nuestros cambios:

$ git add . && git commit -m "Setup tokens controller"

Usuario logueado

Entonces ya implementamos la siguiente lógica: la API retorna el token de autenticación a el cliente si las credenciales son correctas.

Pero ahora implementaremos la siguiente lógica: encontraremos el usuario correspondiente del token de autenticación proporcionado en la cabecera HTTP. Necesitamos hacerlo cada vez que este cliente solicite un entry point que requiera permisos.

Usaremos la cabecera HTTP Authorization que a menudo es usada para este propósito. También podemos usar un parámetro GET llamado apiKey pero prefiero usar una cabecera HTTP porque da contexto a la petición sin contaminar la URL con parámetros adicionales.

Por lo tanto, crearemos un método current_user para satisfacer nuestras necesidades. Este encontrará el usuario gracias a su token de autenticación que es enviado en cada petición.

Cuando se trata de autenticación, me gusta añadir todos los métodos asociados en un archivo separado. Entonces simplemente incluimos el archivo ApplicationController. De este modo, es muy fácil para probar de forma aislada. Vamos a crear el archivo en el directorio controllers/concerns con un método current_user que implementaremos después:

app/controllers/concerns/authenticable.rb
module Authenticable
  def current_user
    # TODO
  end
end

Entonces, vamos a crear un directorio concerns en tests/controllers/ y un archivo authenticable_test.rb para nuestras pruebas de a autenticación:

$ mkdir test/controllers/concerns
$ touch test/controllers/concerns/authenticable_test.rb

Como es usual, iniciamos por escribir nuestra prueba. En este caso, nuestro método current_user buscará un usuario por el token de autenticación en la cabecera HTTP Authorization. La prueba es muy básica:

test/controllers/concerns/authenticable_test.rb
# ...
class AuthenticableTest < ActionDispatch::IntegrationTest
  setup do
    @user = users(:one)
    @authentication = MockController.new
  end

  test 'should get user from Authorization token' do
    @authentication.request.headers['Authorization'] = JsonWebToken.encode(user_id: @user.id)
    assert_equal @user.id, @authentication.current_user.id
  end

  test 'should not get user from empty Authorization token' do
    @authentication.request.headers['Authorization'] = nil
    assert_nil @authentication.current_user
  end
end

Te estarás preguntando, "¿De donde viene el controlador MockController?", De hecho, éste es un Mock, por ejemplo una clase que imita el comportamiento de otra para probar un comportamiento

Podemos definir la clase MockController justo sobre nuestra prueba:

test/controllers/concerns/authenticable_test.rb
# ...
class MockController
  include Authenticable
  attr_accessor :request

  def initialize
    mock_request = Struct.new(:headers)
    self.request = mock_request.new({})
  end
end
# ...

La clase MockController simplemente incluye nuestro módulo Authenticable que probaremos. Este contiene un atributo request que contiene un simple Struct que imita el comportamiento de una petición Rails conteniendo un atributo headers de tipo Hash.

Entonces podemos implementar nuestras dos pruebas ahora

test/controllers/concerns/authenticable_test.rb
# ...
class AuthenticableTest < ActionDispatch::IntegrationTest
  setup do
    @user = users(:one)
    @authentication = MockController.new
  end

  test 'should get user from Authorization token' do
    @authentication.request.headers['Authorization'] = JsonWebToken.encode(user_id: @user.id)
    assert_not_nil @authentication.current_user
    assert_equal @user.id, @authentication.current_user.id
  end

  test 'should not get user from empty Authorization token' do
    @authentication.request.headers['Authorization'] = nil
    assert_nil @authentication.current_user
  end
end

Nuestra prueba debería fallar. Así que vamos a implementar el código para que ésta pase:

app/controllers/concerns/authenticable.rb
module Authenticable
  def current_user
    return @current_user if @current_user

    header = request.headers['Authorization']
    return nil if header.nil?

    decoded = JsonWebToken.decode(header)

    @current_user = User.find(decoded[:user_id]) rescue ActiveRecord::RecordNotFound
  end
end

Ahí tienes! Obtenemos el token desde la cabecera Authorization y buscamos el usuario correspondiente. Nada tan mágico.

Nuestra prueba debería pasar:

$ rake test
.............
13 runs, 18 assertions, 0 failures, 0 errors, 0 skips

Todo lo que tenemos que hacer es incluir el módulo Authenticable en la clase ApplicationController:

app/controllers/application_controller.rb
class ApplicationController < ActionController::API
  # ...
  include Authenticable
end

Y ahora es tiempo de hacer commit a nuestros cambios:

$ git add . && git commit -m "Adds authenticable module for managing authentication methods"

Autenticación con el token

La autorización juega un papel importante en la construcción de aplicaciones porque nos ayuda a definir que usuario tiene permisos para continuar.

Tenemos una ruta para actualizar el usuario, pero hay un problema: cualquiera puede actualizar cualquier usuario. En esta sección, vamos a implementar un método que requerirá al usuario estar logueado para prevenir accesos no autorizados.

Acciones de autorización

Es tiempo ahora de actualizar nuestro archivo users_controller.rb para negar el acceso a ciertas acciones. Vamos también a implementar el método current_user en las acciones update y destroy para asegurarnos que el usuario que esta logueado solo podrá actualizar sus datos y puede únicamente borrar (y solo) su cuenta.

Por lo tanto dividimos nuestra prueba en dos pruebas should update user y should destroy user.

Iniciamos por actualizar la prueba should update user.

test/controllers/api/v1/users_controller_test.rb
# ...
class Api::V1::UsersControllerTest < ActionDispatch::IntegrationTest
  # ...
  test "should update user" do
    patch api_v1_user_url(@user),
      params: { user: { email: @user.email } },
      headers: { Authorization: JsonWebToken.encode(user_id: @user.id) },
      as: :json
    assert_response :success
  end

  test "should forbid update user" do
    patch api_v1_user_url(@user), params: { user: { email: @user.email } }, as: :json
    assert_response :forbidden
  end
end

Puedes ver ahora que tenemos que añadir una cabecera Authorization para la acción de modificar usuarios. De lo contrario queremos recibir una respuesta forbidden.

Podemos pensar de forma similar para la prueba should forbid destroy user:

test/controllers/api/v1/users_controller_test.rb
# ...
class Api::V1::UsersControllerTest < ActionDispatch::IntegrationTest
  # ...
  test "should destroy user" do
    assert_difference('User.count', -1) do
      delete api_v1_user_url(@user), headers: { Authorization: JsonWebToken.encode(user_id: @user.id) }, as: :json
    end
    assert_response :no_content
  end

  test "should forbid destroy user" do
    assert_no_difference('User.count') do
      delete api_v1_user_url(@user), as: :json
    end
    assert_response :forbidden
  end
end

Por el momento estas pruebas pueden fallar como ya lo podrías esperar:

$ rails test test/controllers/api/v1/users_controller_test.rb
..F

Failure:
Expected response to be a <2XX: success>, but was a <403: Forbidden>

..F

Failure:
"User.count" didn t change by -1.
Expected: 0
  Actual: 1

La solución es muy simple. Vamos a añadir un before_action el cual llamará al método check_owner para las acciones update y destroy. De esta forma comprobamos que el usuario que corresponde al token JWT es el mismo que el usuario que necesita ser actualizado.

Ésta es la implementación:

app/controllers/api/v1/users_controller.rb
class Api::V1::UsersController < ApplicationController
  before_action :set_user, only: %i[show update destroy]
  before_action :check_owner, only: %i[update destroy]
  # ...

  private
  # ...
  def check_owner
    head :forbidden unless @user.id == current_user&.id
  end
end

¡Ahí tienes! La implementación es realmente simple. Es por lo tanto tiempo de hacer un commit:

$ git commit -am "Restrict actions for unauthorized users"
$ git checkout master
$ git merge chapter04

Conclusión

¡Yeah!, ¡lo hiciste! tienes medio camino terminado! Mantén este buen trabajo. Este capítulo fue largo y difícil pero es un gran paso a seguir para implementar un mecanismo sólido para manipular autenticación de usuarios. Incluso logramos tocar la superficie para implementar reglas simples de autenticación.

En el próximo capítulo nos enfocaremos en la personalización de las salidas JSON para el usuario con la gema fast_jsonapi y añadiremos un modelo product a la ecuación dando al usuario la habilidad para crear un producto y publicarlo para su venta.

Productos de usuario

En el capítulo anterior, implementamos el mecanismo de autenticación que usaremos a través de la aplicación.

Por el momento tenemos una implementación del modelo User pero el momento de la verdad ha llegado. Vamos a personalizar la salida JSON añadir un segundo recurso: los productos del usuario. Estos son los elementos que el usuario va a comprar en la aplicación y por lo tanto enlazaremos directamente.

Si estas familiarizado con Rails, ya sabes de que estoy hablando. Pero para aquellos que no lo saben, vamos a asociar el modelo User con el modelo Product usando los metodos de Active Record has_many y belongs_to

En este capítulo vamos a:

  • construir el modelo Product desde cero

  • asociarlo con el usuario

  • crear las entradas necesarias asi cualquier cliente puede acceder a la información.

Puedes clonar el proyecto hasta este punto:

$ git checkout tags/checkpoint_chapter05

Antes que iniciemos y como es usual cuando iniciamos con nuevas características necesitaremos crear una nueva rama:

$ git checkout -b chapter05

El modelo producto

Primero crearemos un modelo Product. Entonces añadiremos validaciones y finalmente lo asociamos con el modelo User. Como el modelo `User, el modelo Product será completamente probado y será automáticamente eliminado si el usuario es eliminado.

Los fundamentos del producto

La plantilla Product necesitara varios campos:

  • un atributo price para el precio del producto

  • un booleano published para saber si el producto ya está vendido o no

  • un title para definir un título sexy al producto

  • un user_id para asociar este producto particular a un usuario

    Como puedes adivinar lo generamos con el comando `rails generate`:
$ rails generate model Product title:string price:decimal published:boolean user:belongs_to
Running via Spring preloader in process 1476
      invoke  active_record
      create    db/migrate/20190608205942_create_products.rb
      create    app/models/product.rb
      invoke    test_unit
      create      test/models/product_test.rb
      create      test/fixtures/products.yml
Note
Usamos el tipo belongs_to para el atributo user. Este es un atajo que creará una columna user_id de tipo int y entonces añade una llave foránea a el campo users.id. En adición, user_id también será definido como un index (índice). Esta es una buena práctica para la asociación de llaves porque esto optimiza las consultas de la base de datos. No es obligatorio, pero es altamente recomendado.

El archivo de migración debería lucir así:

db/migrate/20190608205942_create_products.rb
class CreateProducts < ActiveRecord::Migration[6.0]
  def change
    create_table :products do |t|
      t.string :title
      t.decimal :price
      t.boolean :published
      t.belongs_to :user, null: false, foreign_key: true

      t.timestamps
    end
  end
end

Ahora solo tenemos que iniciar la migración:

$ rake db:migrate

Una prueba debería de fallar hasta este punto:

$ rake test
....E

Error:
Api::V1::UsersControllerTest#test_should_destroy_user:
ActiveRecord::InvalidForeignKey: SQLite3::ConstraintException: FOREIGN KEY constraint failed

rails test test/controllers/api/v1/users_controller_test.rb:43

Seguramente dirás:

¿Que?, ¡Pero no he tocado los usuarios!

Lo que he visto en el código de otros desarrolladores, cuando ellos trabajan con asociaciones, es que se olvidan de la destrucción de dependencias entre modelos. Lo que digo con esto es que si un usuario es eliminado, también lo deberían de ser los productos del usuario.

Necesitamos un usuario con uno de los productos para probar esta interacción entre modelos. Entones eliminaremos este usuario esperando que los productos desaparezcan con él. Rails ya tiene generado esto por nosotros. Echa un vistazo a el fixture de los productos:

test/fixtures/products.yml
one:
  title: MyString
  price: 9.99
  published: false
  user: one
# ...

Puedes ver que este fixture no usa el atributo user_id pero si user. Esto significa que el producto one tendrá un atributo user_id correspondiente al ID de usuario one.

Es por lo tanto necesario especificar un borrado en cascada a fin de que sea eliminado el producto one cuando el usuario one es eliminado. Vamos empezar con la prueba unitaria:

test/models/user_test.rb
# ...
class UserTest < ActiveSupport::TestCase
  # ...
  test 'destroy user should destroy linked product' do
    assert_difference('Product.count', -1) do
      users(:one).destroy
    end
  end
end

Justamente tienes que modificar el modelo User y especificar la relación has_many con la opción depend: :destroy. Veremos más tarde que hace este método con mas detalle.

app/models/user.rb
# ...
class User < ApplicationRecord
  # ...
  has_many :products, dependent: :destroy
end

Eso es todo. Ahor hacemos un commit:

$ git add . && git commit -m "Generate product model"

Validaciones del producto

Las validaciones son una parte importante cuando construimos cualquier tipo de aplicación. Esto evitará que cualquier dato basura sea guardado en la base de datos. En el producto tenemos que asegurarnos que por ejemplo el precio es un number (número) y que no es negativo.

También una cosa importante sobre la validación es validar que cada producto tiene un usuario. En este caso necesitamos validar la presencia del user_id. Puedes ver que estoy hablando en siguiente fragmento de código.

test/models/product_test.rb
# ...
class ProductTest < ActiveSupport::TestCase
  test "should have a positive price" do
    product = products(:one)
    product.price = -1
    assert_not product.valid?
  end
end

Ahora necesitamos añadir la implementación para hacer que la prueba pase:

app/models/product.rb
class Product < ApplicationRecord
  validates :title, :user_id, presence: true
  validates :price, numericality: { greater_than_or_equal_to: 0 }, presence: true
  belongs_to :user
end

La prueba ahora está en verde:

$ rake test
................

Tenemos un montón de código de buena calidad. Hagamos un commit y sigamos moviéndonos:

$ git commit -am "Adds some validations to products"

Endpoints de productos

Ahora es tiempo de empezar a construir los endpoints de los productos. Por ahora solo construiremos las cinco acciones REST. En el siguiente capítulo vamos a personalizar la salida JSON implementando la gema fast_jsonapi.

Primero necesitamos crear el controlador products_controller, y fácilmente podemos lograrlo con el comando:

$ rails generate controller api::v1::products
      create  app/controllers/api/v1/products_controller.rb
      invoke  test_unit
      create    test/controllers/api/v1/products_controller_test.rb

El comando anterior generará un montón de archivos que nos permitirán empezar a trabajar rápidamente. Lo que quiero decir con esto es ya generará el controlador y el archivo de prueba con un scoped (alcanse) hacia la versión 1 del API.

Como calentamiento iniciaremos bien y fácil construyendo la acción show para el producto.

Acción show para productos

Como es usual iniciaremos por añadir algunas especificaciones para la acción show para el producto en su controlador. La estrategia aquí es muy simple: justamente necesitamos crear un único producto y asegurar que la respuesta desde el server es la que esperamos.

test/controllers/api/v1/products_controller_test.rb
# ...
class Api::V1::ProductsControllerTest < ActionDispatch::IntegrationTest
  setup do
    @product = products(:one)
  end

  test "should show product" do
    get api_v1_product_url(@product), as: :json
    assert_response :success

    json_response = JSON.parse(self.response.body)
    assert_equal @product.title, json_response['title']
  end
end

Entonces añadimos el código que hará pasar las pruebas:

app/controllers/api/v1/products_controller.rb
class Api::V1::ProductsController < ApplicationController
  def show
    render json: Product.find(params[:id])
  end
end

¡Espera! Aun no corras las pruebas. Recuerda que necesitamos añadir el recuro al archivo routes.rb:

config/routes.rb
Rails.application.routes.draw do
  namespace :api, defaults: { format: :json } do
    namespace :v1 do
      resources :users, only: %i[show create update destroy]
      resources :tokens, only: [:create]
      resources :products, only: [:show]
    end
  end
end

Ahora nos aseguramos que las pruebas están bien y en verde:

$ rake test
.................

Como puedes notar ahora las especificaciones e implementación son muy sencillas. En realidad, se comportan igual que el usuario.

Listado de productos

Ahora es tiempo de devolver una lista de productos (los cuales serán mostrados como catálogo de productos de la tienda). Este endpoint debe ser accesible sin credenciales. Significa que no requerimos que el usuario este logueado para acceder a la información. Como es usual empezaremos escribiendo algunas pruebas:

test/controllers/api/v1/products_controller_test.rb
# ...
class Api::V1::ProductsControllerTest < ActionDispatch::IntegrationTest
  setup do
    @product = products(:one)
  end

  test "should show products" do
    get api_v1_products_url(), as: :json
    assert_response :success
  end

  test "should show product" do
    get api_v1_product_url(@product), as: :json
    assert_response :success

    json_response = JSON.parse(self.response.body)
    assert_equal @product.title, json_response['title']
  end
end

Vamos a la implementación, la cual por ahora está siendo un método index simple:

app/controllers/api/v1/products_controller.rb
class Api::V1::ProductsController < ApplicationController
  def index
    render json: Product.all
  end
  #...
end

No olvides añadir la ruta correspondiente:

config/routes.rb
Rails.application.routes.draw do
  namespace :api, defaults: { format: :json } do
    namespace :v1 do
      # ....
      resources :products, only: %i[show index]
    end
  end
end

Terminamos por ahora con el endopint al producto público. En la siguiente sección nos enfocaremos en la construcción de las acciones solicitando un usuario logueado para acceder a ellos. Dicho esto, haremos commit de estos cambios y continuamos.

$ git add . && git commit -m "Finishes modeling the product model along with user associations"

Creando productos

Crear productos es un poco más complejo porque necesitaremos una configuración adicional. La estrategia que seguiremos es asignar el producto creado al usuario que pertenece al token JWT proporcionado en la cabecera HTTP Authorization.

Así que iniciamos con el archivo products_controller_test.rb:

test/controllers/api/v1/products_controller_test.rb
# ...
class Api::V1::ProductsControllerTest < ActionDispatch::IntegrationTest
  # ...

  test 'should create product' do
    assert_difference('Product.count') do
      post api_v1_products_url,
           params: { product: { title: @product.title, price: @product.price, published: @product.published } },
           headers: { Authorization: JsonWebToken.encode(user_id: @product.user_id) },
           as: :json
    end
    assert_response :created
  end

  test 'should forbid create product' do
    assert_no_difference('Product.count') do
      post api_v1_products_url,
           params: { product: { title: @product.title, price: @product.price, published: @product.published } },
           as: :json
    end
    assert_response :forbidden
  end
end

¡Wow! Añadimos un montón de código. Si recuerdas la sección anterior, las pruebas son muy similares que las de la creación de usuarios. Excepto por algunos cambios menores.

De esta forma, podemos ver al usuario y la creación del producto asociado con el. Pero espera! Hay algo mejor.

Si adoptamos este enfoque, podemos incrementar el alcance de nuestro mecanismo de autenticación. Realmente construimos la lógica para obtener al usuario logueado desde la cabecera Authorization y asignarle un método current_user. Es por lo tanto bastante fácil de configurar simplemente añadiendo la cabecera de autorización a la solicitud y recuperando el usuario desde ahí. Entonces hagamoslo.

app/controllers/api/v1/products_controller.rb
class Api::V1::ProductsController < ApplicationController
  before_action :check_login, only: %i[create]
  # ...

  def create
    product = current_user.products.build(product_params)
    if product.save
      render json: product, status: :created
    else
      render json: { errors: product.errors }, status: :unprocessable_entity
    end
  end

  private

  def product_params
    params.require(:product).permit(:title, :price, :published)
  end
end

Como puedes ver, protegemos la acción create con el método check_login. También creamos al producto por asociación con el usuario. Yo agregué este método tan sencillo al concern del archivo authenticable.rb:

app/controllers/concerns/authenticable.rb
module Authenticable
  # ...
  protected

  def check_login
    head :forbidden unless self.current_user
  end
end

Una última cosa antes de hacer tus pruebas: la ruta necesaria:

config/routes.rb
Rails.application.routes.draw do
  namespace :api, defaults: { format: :json } do
    namespace :v1 do
      # ...
      resources :products, only: %i[show index create]
    end
  end
end

Ahora las pruebas deberían pasar:

$ rake test
....................

Actualizando los productos

Espero que por ahora entiendas la lógica para construir la acciones que vienen. En esta sección nos enfocaremos en la acción update que funcionará a la acción create. Solamente necesitamos buscar el producto desde la base de datos y actualizarlo.

Añadiremos primer la acción a las rutas así no nos olvidamos después:

config/routes.rb
Rails.application.routes.draw do
  namespace :api, defaults: { format: :json } do
    namespace :v1 do
      # ...
      resources :products, only: %i[show index create update]
    end
  end
end

Antes de iniciar borrando alguna prueba quiero aclarar que similarmente a la acción create vamos a dar alcance en el producto al con el método current_user. En este caso queremos asegurar que el producto que se está actualizando pertenece al usuario actual. Así que buscaremos los productos de la asociación user.products proveída por Rails.

Agreguemos algunas especificaciones:

test/controllers/api/v1/products_controller_test.rb
require 'test_helper'

class Api::V1::ProductsControllerTest < ActionDispatch::IntegrationTest
  # ...

  test 'should update product' do
    patch api_v1_product_url(@product),
          params: { product: { title: @product.title } },
          headers: { Authorization: JsonWebToken.encode(user_id: @product.user_id) },
          as: :json
    assert_response :success
  end

  test 'should forbid update product' do
    patch api_v1_product_url(@product),
          params: { product: { title: @product.title } },
          headers: { Authorization: JsonWebToken.encode(user_id: users(:two).id) },
          as: :json
    assert_response :forbidden
  end
end
Note
Tengo añadido un fixture correspondiente a un segundo usuario justo para verificar que el segundo usuario no puede modificar productos del primer usuario.

Las pruebas parecen complejas, pero echa un segundo vistazo. Son casi lo mismo que construimos para los usuarios.

Ahora vamos a implementar el código para hacer pasar nuestras pruebas:

app/controllers/api/v1/products_controller.rb
class Api::V1::ProductsController < ApplicationController
  before_action :set_product, only: %i[show update]
  before_action :check_login, only: %i[create]
  before_action :check_owner, only: %i[update]

  # ...

  def create
    product = current_user.products.build(product_params)
    if product.save
      render json: product, status: :created
    else
      render json: { errors: product.errors }, status: :unprocessable_entity
    end
  end

  def update
    if @product.update(product_params)
      render json: @product
    else
      render json: @product.errors, status: :unprocessable_entity
    end
  end

  private
  # ...

  def check_owner
    head :forbidden unless @product.user_id == current_user&.id
  end

  def set_product
    @product = Product.find(params[:id])
  end
end

La implementación es muy simple. Simplemente recuperaremos el producto desde el usuario conectad y simplemente lo actualizamos. Tenemos también agregadas esta acción a el before_action para prevenir cualquier usuario no autorizado desde la actualización de un producto.

Ahora las pruebas deberían pasar:

$ rake test
......................

Destruyendo productos

Nuestra última parada para los endpoints de los productos será la acción destroy (destruir). Podrías ahora imaginar cómo se vería esto. La estrategia aquí será demasiado similar a las acciones create y destroy: obtenemos al usuario logueado con el token JWT y entonces buscamos el producto desde la asociación user.products y finalmente lo destruimos, regresamos un código 204.

Vamos a iniciar de nuevo añadiendo el nombre de la ruta al archivo de rutas:

config/routes.rb
Rails.application.routes.draw do
  namespace :api, defaults: { format: :json } do
    namespace :v1 do
      resources :users, only: %i[show create update destroy]
      resources :tokens, only: [:create]
      resources :products
    end
  end
end

Después de esto, tenemos que añadir algunas pruebas como se muestra en este fragmento de código:

test/controllers/api/v1/products_controller_test.rb
# ...
class Api::V1::ProductsControllerTest < ActionDispatch::IntegrationTest
  # ...

  test "should destroy product" do
    assert_difference('Product.count', -1) do
      delete api_v1_product_url(@product), headers: { Authorization: JsonWebToken.encode(user_id: @product.user_id) }, as: :json
    end
    assert_response :no_content
  end

  test "should forbid destroy product" do
    assert_no_difference('Product.count') do
      delete api_v1_user_url(@product), headers: { Authorization: JsonWebToken.encode(user_id: users(:two).id) }, as: :json
    end
    assert_response :forbidden
  end
end

Ahora simplemente añadimos el código necesario para hacer pasar las pruebas:

app/controllers/api/v1/products_controller.rb
class Api::V1::ProductsController < ApplicationController
  before_action :set_product, only: %i[show update destroy]
  before_action :check_login, only: %i[create]
  before_action :check_owner, only: %i[update destroy]

  # ...

  def destroy
    @product.destroy
    head 204
  end

  # ...
end

Como puedes ver las cuatro líneas implementadas hacen el trabajo. Podemos correr las pruebas para asegurar que todo está bien y entonces haremos un commit de los cambios ya que hemos añadido un montón de código. También asegúrate que llamas a esta acción en el callback before_action al igual que en la acción update.

$ rake test
........................

Hagamos commit de los cambios:

$ git commit -am "Adds the products create, update and destroy actions"

Llenado de la base de datos

Vamos a llenar la base de datos con información falsa antes de continuar escribiendo más código. Vamos a usar los seeds para hacerlo.

Con el archivo db/seeds.rb, Rails nos da una forma fácil y rápida para asignar valores por defecto en una nueva instalación. Este es un simple archivo de Ruby que nos da completo acceso a clases y métodos de la aplicación. Así que no necesitas meter todo manualmente con la consola de Rails sino que puedes simplemente usar el archivo db/seeds.rb con el comando rake db:seed.

Asi que vamos a iniciar creando un usuario:

db/seeds.rb
User.delete_all
user = User.create! email: 'toto@toto.fr', password: 'toto123'
puts "Created a new user: #{user.email}"

Y ahora puedes crear un usuario simplemente ejecutando el siguiente comando:

$ rake db:seed
Created a new user: toto@toto.fr

Funciona. No sé tú, pero a mí me gusta tener datos ficticios para llenar correctamente mi base de datos de prueba. Solo que no siempre tengo la inspiración para dar sentido a mi archivo seed así que uso la gema faker. Vamos a configurarla:

$ bundle add faker

Ahora podemos usarla para crear cinco usuarios al mismo tiempo con diferentes emails.

db/seeds.rb
User.delete_all

5.times do
  user = User.create! email: Faker::Internet.email, password: 'locadex1234'
  puts "Created a new user: #{user.email}"
end

Y vamos a ver que pasa:

$ rake db:seed
Created a new user: barbar@greenholt.io
Created a new user: westonpaucek@ortizbotsford.net
Created a new user: ricardo@schneider.com
Created a new user: scott@moenerdman.biz
Created a new user: chelsie@wiza.net

Ahí lo tienes. Pero podemos ir más lejos creando productos asociados con estos usuarios:

db/seeds.rb
Product.delete_all
User.delete_all

3.times do
  user = User.create! email: Faker::Internet.email, password: 'locadex1234'
  puts "Created a new user: #{user.email}"

  2.times do
    product = Product.create!(
      title: Faker::Commerce.product_name,
      price: rand(1.0..100.0),
      published: true,
      user_id: user.id
    )
    puts "Created a brand new product: #{product.title}"
  end
end

Ahí lo tienes. El resultado es asombroso. En una orden podemos crear tres usuarios y seis productos:

$ rake db:seed
Created a new user: tova@beatty.org
Created a brand new product: Lightweight Steel Hat
Created a brand new product: Ergonomic Aluminum Lamp
Created a new user: tommyrunolfon@tremblay.biz
Created a brand new product: Durable Plastic Car
Created a brand new product: Ergonomic Leather Shirt
Created a new user: jordon@torp.io
Created a brand new product: Incredible Paper Hat
Created a brand new product: Sleek Concrete Pants

Hagamos un commit:

$ git commit -am "Create a seed to populate database"

Y como llegamos al final de nuestro capítulo, es tiempo de aplicar todas las modificaciones a la rama master haciendo un merge:

$ git checkout master
$ git merge chapter05

I make two little comments. I also see two things to update:

add es lang in rakefile: https://github.com/madeindjs/api_on_rails/blob/master/Rakefile#L4
upload the book on leanpubas YOUR book version and add a link https://github.com/madeindjs/api_on_rails#support-the-project (if you want it of course)
add a section "contributor" ith your name on readme: https://github.com/madeindjs/api_on_rails#license :)

I make two little comments. I also see two things to update:

add es lang in rakefile: https://github.com/madeindjs/api_on_rails/blob/master/Rakefile#L4
upload the book on leanpubas YOUR book version and add a link https://github.com/madeindjs/api_on_rails#support-the-project (if you want it of course)
add a section "contributor" ith your name on readme: https://github.com/madeindjs/api_on_rails#license :)

Conclusión

Espero que hayas disfrutado este capítulo. Es el más largo pero el código que hicimos juntos es una excelente base para el núcleo de nuestra aplicación.

En el siguiente capítulo, nos enfocaremos en personalizar la salido de los modelos usuarios y productos usando la gema fast_jsonapi. Esto nos permitirá filtrar fácilmente los atributos para mostrar y manipular asociaciones como objetos embebidos, por ejemplo.

Construyendo la repuesta JSON

En el capítulo anterior agregamos productos a la aplicación y creamos las rutas necesarias. Tenemos también asociado un producto con un usuario y restringidas algunas acciones del controlador products_controller.

Ahora puedes estar satisfecho con todo este trabajo. Pero todavía tenemos un montón de trabajo por hacer. Actualmente tenemos una salida JSON que no es perfecta. La salida JSON luce así:

{
  "products": [
      {
          "id": 1,
          "title": "Tag Case",
          "price": "98.7761933800815",
          "published": false,
          "user_id": 1,
          "created_at": "2018-12-20T12:47:26.686Z",
          "updated_at": "2018-12-20T12:47:26.686Z"
      },
    ]
}

Como sea buscamos una salida que no contenga los campos user_id, created_at y updated_at.

Una parte importante (y difícil) cuando estas creando tu API es decidir el formato de salida. Afortunadamente algunas organizaciones ya tienen encarado este tipo de problema y tienen establecidas algunas convenciones que descubrirás en este capítulo.

Puedes clonar el proyecoto hasta este punto:

$ git checkout tags/checkpoint_chapter06

Iniciemos con una nueva rama para este capítulo:

$ git checkout -b chapter06

Presentación de JSON:API

Una parte importante y difícil de crear tu API es decidir el formato de salida. Afortunadamente algunas convenciones ya existen. Ciertamente las más usada es JSON:API.

La documentación de JSON:API nos da algunas reglas a seguir respecto al formateado del documento JSON.

En consecuencia, nuestro documento debería contener estas llaves:

  • data: que contiene la información que devolvemos

  • errors que contienen un arreglo de errores ocurridos

  • meta que contiene un meta objeto

El contenido de la llave data es demasiado estricto:

  • debe tener una llave de type correspondiente al tipo de modelo JSON (un article, un user, etc…​)

  • propiedades de los objetos deben ponerse en la llave attributes

  • enlaces de objetos deben colocarse en una llave relationships

En este capítulo vamos a personalizar la salida JSON usando la gema de Netflix: fast_jsonapi. Afortunadamente ya implementa todas las especificaciones JSON:API.

Así que instalemos la gema fast_jsonapi:

$ bundle add fast_jsonapi

Deberias estar listo para continuar este tutorial.

Serializar el usuario

FastJSON API usa serializers. Los serializadores representan clases Ruby que serán responsables de convertir un modelo en un Hash o un JSON.

Así que necesitamos añadir un archivo user_serializer.rb. Podemos hacerlo manualmente, pero la gema provee una interface de línea de comandos para hacerlo:

$ rails generate serializer User email
      create  app/serializers/user_serializer.rb

Esto habrá creado un archivo llamado user_serializer.rb bajo la ruta app/serializers. El nuevo archivo debería lucir como el siguiente archivo:

app/serializers/user_serializer.rb
class UserSerializer
  include FastJsonapi::ObjectSerializer
  attributes :email
end

Este serializer nos permitirá convertir nuestro objeto User a JSON implementando todas las especificaciones JSON:API. Como especificamos email como attributes lo recibimos en un arreglo data.

Vamos a intentar todo esto en la consola de rails con rails console:

2.6.3 :001 > UserSerializer.new( User.first ).serializable_hash
=> {:data=>{:id=>"25", :type=>:user, :attributes=>{:email=>"tova@beatty.org"}}}

Ahí tienes. Como puedes ver es realmente fácil. Ahora podemos usar nuestro nuevo serializer en nuestro controller:

app/controllers/api/v1/users_controller.rb
class Api::V1::UsersController < ApplicationController
  # ...
  def show
    render json: UserSerializer.new(@user).serializable_hash
  end

  def update
    if @user.update(user_params)
      render json: UserSerializer.new(@user).serializable_hash
    else
      # ...
    end
  end

  def create
    # ...
    if @user.save
      render json: UserSerializer.new(@user).serializable_hash, status: :created
    else
      # ...
    end
  end

  # ...
end

¿No es demasiado fácil? Como sea deberíamos tener una prueba que falla. Pruébalo por ti mismo:

$ rake test

Failure:
Expected: "one@one.org"
  Actual: nil

Por alguna razón la respuesta no es lo que esperábamos. Esto es porque la gema modifica la respuesta que teníamos anteriormente definida. Así que para pasar esta prueba tenemos que modificarla:

test/controllers/api/v1/users_controller_test.rb
# ...
class Api::V1::UsersControllerTest < ActionDispatch::IntegrationTest
  # ...
  test "should show user" do
    # ...
    assert_equal @user.email, json_response['data']['attributes']['email']
  end
  # ...
end

Si lo hiciste ahora la prueba pasa:

$ rake test
........................

Guardemos estos cambios y sigamos moviéndonos:

$ git add . && git commit -am "Adds user serializer for customizing the json output"

Serializado de productos

Ahora que entendemos cómo trabaja la gema de serialización es tiempo de personalizar la salida del producto. El primer paso es el mismo que hicimos en el capítulo previo. Necesitamos un serializador de producto. Así que hagámoslo:

$ rails generate serializer Product title price published
      create  app/serializers/product_serializer.rb

Ahora vamos a añadir atributos para serializar el producto:

app/serializers/product_serializer.rb
class ProductSerializer
  include FastJsonapi::ObjectSerializer
  attributes :title, :price, :published
end

Ahí está. No es tan complicado. Cambiemos nuestro controlador un poco.

app/controllers/api/v1/products_controller.rb
class Api::V1::ProductsController < ApplicationController
  # ...
  def index
    @products = Product.all
    render json: ProductSerializer.new(@products).serializable_hash
  end

  def show
    render json: ProductSerializer.new(@product).serializable_hash
  end

  def create
    product = current_user.products.build(product_params)
    if product.save
      render json: ProductSerializer.new(product).serializable_hash, status: :created
    else
      # ...
    end
  end

  def update
    if @product.update(product_params)
      render json: ProductSerializer.new(@product).serializable_hash
    else
      # ...
    end
  end
  # ...
end

Actualizamos nuestra prueba funcional:

test/controllers/api/v1/products_controller_test.rb
# ...
class Api::V1::ProductsControllerTest < ActionDispatch::IntegrationTest
  # ...
  test 'should show product' do
    # ...
    assert_equal @product.title, json_response['data']['attributes']['title']
  end
  # ...
end

Si quieres puedes revisar si la prueba pasa, pero debería. Guardemos estos pequeños cambios:

$ git add .
$ git commit -m "Adds product serializer for custom json output"

Serializar asociaciones

Hemos trabajado con serializadores y has notado que es muy simple. En algunos casos la decisión difícil es nombrar tus rutas o estructurar la salida JSON. Cuando se está trabajando con asociaciones entre modelos en la API hay muchos enfoques que puedes tomar.

No debemos preocuparnos de este problema en nuestro caso: Las especificaciones JSON:API lo hicieron por nosotros!

Para recapitular tenemos un tipo de asociación has_many entre usuarios y productos.

app/models/user.rb
class User < ApplicationRecord
  has_many :products, dependent: :destroy
  # ...
end
app/models/product.rb
class Product < ApplicationRecord
  belongs_to :user
  # ...
end

Es una buena idea integrar usuario en las salidas JSON de productos. Esto hará la salida más incomoda pero prevendrá al cliente de la API ejecutar otras peticiones para recibir información del usuario relacionada a los productos. Este método realmente puede salvarte de un enorme cuello de botella.

Teoría de la inyección de relaciones

Imagina un escenario donde pides a la API productos, pero en este caso tienes que mostrar alguna información del usuario.

Una posible solución podría ser añadir el atributo user_id a el product_serializer así podemos obtener el usuario correspondiente más tarde. Esto puede sonar como una buena idea, pero si estar preocupado sobre el rendimiento, o si las transacciones de la base de datos no son suficientemente rápidas, deberías reconsiderar éste enfoque. Deberías entender que de cada producto que recuperes, deberías recuperar su usuario correspondiente.

Enfrentando a este problema, tenemos varias alternativas.

Integrar en un meta atributo

La primera solución (una buena en mi opinión) es integrar identificadores de usuarios enlazados a los productos un meta atributo. Así obtenemos un JSON como abajo:

{
  "meta": { "user_ids": [1,2,3] },
  "data": [

  ]
}

Así que el cliente puede recuperar estos usuarios desde user_ids.

Incorporando el objeto en el atributo

Otra solución es incorporar el objeto user en el objeto product. Esto debería hacer a la primera petición lenta, pero de esta forma el cliente no necesita hacer otra petición adicional. Un ejemplo del resultado esperado se presenta a continuación:

{
  "data":
  [
    {
        "id": 1,
        "type": "product",
        "attributes": {
          "title": "First product",
          "price": "25.02",
          "published": false,
          "user": {
            "id": 2,
            "attributes": {
              "email": "stephany@lind.co.uk",
              "created_at": "2014-07-29T03:52:07.432Z",
              "updated_at": "2014-07-29T03:52:07.432Z",
              "auth_token": "Xbnzbf3YkquUrF_1bNkZ"
            }
          }
        }
    }
  ]
}

El problema con este enfoque es que tenemos duplicados del objeto `User' para cada producto que pertenece al mismo usuario:

{
  "data":
  [
    {
        "id": 1,
        "type": "product",
        "attributes": {
          "title": "First product",
          "price": "25.02",
          "published": false,
          "user": {
            "id": 2,
            "type": "user",
            "attributes": {
              "email": "stephany@lind.co.uk",
              "created_at": "2014-07-29T03:52:07.432Z",
              "updated_at": "2014-07-29T03:52:07.432Z",
              "auth_token": "Xbnzbf3YkquUrF_1bNkZ"
            }
          }
        }
    },
    {
        "id": 2,
        "type": "product",
        "attributes": {
          "title": "Second product",
          "price": "25.02",
          "published": false,
          "user": {
            "id": 2,
            "type": "user",
            "attributes": {
              "email": "stephany@lind.co.uk",
              "created_at": "2014-07-29T03:52:07.432Z",
              "updated_at": "2014-07-29T03:52:07.432Z",
              "auth_token": "Xbnzbf3YkquUrF_1bNkZ"
            }
          }
        }
    }
  ]
}

Incorporar las relaciones incluidas en `include

LA tercer solución (elegida por JSON:API) es una combinación de las primeras dos.

Incluiremos todas las relaciones en una llave include que contendrá todas las relaciones de los objetos previamente mencionados. También, cada objeto incluirá una llave de relación que define la relación y que debería encontrar en cada llave include.

Un JSON vale mas que mil palabras:

{
  "data":
  [
    {
        "id": 1,
        "type": "product",
        "attributes": {
          "title": "First product",
          "price": "25.02",
          "published": false
        },
        "relationships": {
          "user": {
            "id": 1,
            "type": "user"
          }
        }
    },
    {
        "id": 2,
        "type": "product",
        "attributes": {
          "title": "Second product",
          "price": "25.02",
          "published": false
        },
        "relationships": {
          "user": {
            "id": 1,
            "type": "user"
          }
        }
    }
  ],
  "include": [
    {
      "id": 2,
      "type": "user",
      "attributes": {
        "email": "stephany@lind.co.uk",
        "created_at": "2014-07-29T03:52:07.432Z",
        "updated_at": "2014-07-29T03:52:07.432Z",
        "auth_token": "Xbnzbf3YkquUrF_1bNkZ"
      }
    }
  ]
}

¿Ves la diferencia? Esta solución reduce drásticamente el tamaño del JSON y por lo tanto el ancho de banda utilizado.

Aplicación de la inyección de relaciones

Asi que incorporaremos el objeto user en el producto. Vamos a iniciar por añadir algunas pruebas.

Simplemente modificaremos la prueba Products#show para verificar que lo estamos recuperando:

test/controllers/api/v1/products_controller_test.rb
# ...
class Api::V1::ProductsControllerTest < ActionDispatch::IntegrationTest
  # ...
  test 'should show product' do
    get api_v1_product_url(@product), as: :json
    assert_response :success

    json_response = JSON.parse(response.body, symbolize_names: true)
    assert_equal @product.title, json_response.dig(:data, :attributes, :title)
    assert_equal @product.user.id.to_s, json_response.dig(:data, :relationships, :user, :data, :id)
    assert_equal @product.user.email, json_response.dig(:included, 0, :attributes, :email)
  end
  # ...
end

Ahora revisaremos tres cosas que el JSON debería retornar:

  1. este contiene el título del producto

  2. este contiene el ID del usuario ligado al producto

  3. la información del usuario esta incluida en la llave include

Note
Deberías haber notado que decidí usar el método Hash#dig. Este es un método Ruby que permite recuperar elementos en un Hash anidado evitando errores si un elemento no está presente.

Para pasar esta prueba iniciaremos por incluir la relación en el serializer:

app/serializers/product_serializer.rb
class ProductSerializer
  include FastJsonapi::ObjectSerializer
  attributes :title, :price, :published
  belongs_to :user
end

Esta adición añadirá una llave relationship conteniendo el identificador del usuario:

{
  "data": {
      "id": "1",
      "type": "product",
      "attributes": {
          "title": "Durable Marble Lamp",
          "price": "11.55",
          "published": true
      },
      "relationships": {
          "user": {
              "data": {
                  "id": "1",
                  "type": "user"
              }
          }
      }
  }
}

Esto nos permite corregir nuestras primeras dos afirmaciones. Ahora queremos incluir atributos de el usuario a quien pertenezca el producto. Para hacer esto simplemente necesitamos pasar una opción :include al serializer instanciado en el controlador controller. Entonces hagámoslo:

app/controllers/api/v1/products_controller.rb
class Api::V1::ProductsController < ApplicationController
  # ...
  def show
    options = { include: [:user] }
    render json: ProductSerializer.new(@product, options).serializable_hash
  end
  # ...
end

Ahí tienes. Ahora así es como debería lucir el JSON:

{
  "data": {
    ...
  },
  "included": [
    {
      "id": "1",
      "type": "user",
      "attributes": {
          "email": "staceeschultz@hahn.info"
      }
    }
  ]
}

Ahora las pruebas deberían pasar:

$ rake test
........................

Hagamos un commit para celebrar:

$ git commit -am "Add user relationship to product serializer"

Recuperar productos del usuario

¿Entiendes el principio? tenemos incluida información del usuario en el JSON de los productos. Podemos hacer lo mismo incluyendo información del producto relacionada a un usuario para la página /api/v1/users/1.

Empecemos con la prueba:

test/controllers/api/v1/users_controller_test.rb
# ...
class Api::V1::UsersControllerTest < ActionDispatch::IntegrationTest
  # ...
  test "should show user" do
    get api_v1_user_url(@user), as: :json
    assert_response :success

    json_response = JSON.parse(self.response.body, symbolize_names: true)
    assert_equal @user.email, json_response.dig(:data, :attributes, :email)
    assert_equal @user.products.first.id.to_s, json_response.dig(:data, :relationships, :products, :data, 0, :id)
    assert_equal @user.products.first.title, json_response.dig(:included, 0, :attributes, :title)
  end
  # ...
end

serializer:

app/serializers/user_serializer.rb
class UserSerializer
  include FastJsonapi::ObjectSerializer
  attributes :email
  has_many :products
end

Y para finalizar el controlador:

app/controllers/api/v1/users_controller.rb
class Api::V1::UsersController < ApplicationController
  # ...
  def show
    options = { include: [:products] }
    render json: UserSerializer.new(@user, options).serializable_hash
  end
  # ...
end

Ahí tienes. Obtenemos un JSON como el siguiente:

{
  "data": {
    "id": "1",
    "type": "user",
    "attributes": {
      "email": "staceeschultz@hahn.info"
    },
    "relationships": {
      "products": {
        "data": [
          { "id": "1", "type": "product" },
          { "id": "2", "type": "product" }
        ]
      }
    }
  },
  "included": [
    {
      "id": "1",
      "type": "product",
      "attributes": {
        "title": "Durable Marble Lamp",
        "price": "11.5537474980286",
        "published": true
      },
      "relationships": {
        "user": {
          "data": {
            "id": "1",
            "type": "user"
          }
        }
      }
    },
    {
        ...
    }
  ]
}

Fue realmente fácil. Hagamos un commit:

$ git commit -am "Add products relationship to user#show"

Buscando productos

En esta última sección continuaremos fortaleciendo la acción Products#index configurando un mecanismo de búsqueda muy simple permitiendo a cualquier cliente filtrar los resultados. Esta sección es opcional así que no tendrá impacto en los módulos de la aplicación. Pero si quiere practicar mas con las TDD (Test Driven Development) recomiendo que completes este último paso.

Yo uso Ransack ó pg_search para construir formas de busqueda extremamente rápido. Pero como el objetivo es aprender y buscar vamos a hacerlo muy sencillo. Creo que podemos construir un motor de búsqueda desde cero. Simplemente tenemos que considerar los criterios por los cuales filtraremos los atributos. Quédate en tu asiento vamos a hacer este viaje juntos.

Por lo tanto, filtraremos los productos de acuerdo a los siguientes criterios:

  • Por título

  • Por precio

  • Acomodar por fecha de creación

Esto parece pequeño y fácil, pero créeme, esto te dará dolor de cabeza si no lo planeas.

Por palabra clave

Crearemos un scope para encontrar los registros que coinciden con un patrón de caracteres en particular. Vamos a llamarlo filter_by_title.

Comenzaremos por añadir algunos fixtures con diferentes productos para probar:

test/fixtures/products.yml
one:
  title: TV Plosmo Philopps
  price: 9999.99
  published: false
  user: one

two:
  title: Azos Zeenbok
  price: 499.99
  published: false
  user: two

another_tv:
  title: Cheap TV
  price: 99.99
  published: false
  user: two

Y ahora podemos construir algunas pruebas:

test/models/product_test.rb
# ...
class ProductTest < ActiveSupport::TestCase
  # ...
  test "should filter products by name" do
    assert_equal 2, Product.filter_by_title('tv').count
  end

  test 'should filter products by name and sort them' do
    assert_equal [products(:another_tv), products(:one)], Product.filter_by_title('tv').sort
  end
end

La siguiente prueba se asegura que el método Product.filter_by_title buscará correctamente los productos de acuerdo con su título. Usamos el término tv en minúsculas para asegurar que nuestra búsqueda no sea sensitiva a mayúsculas y minúsculas.

app/models/product.rb
class Product < ApplicationRecord
  # ...
  scope :filter_by_title, lambda { |keyword|
    where('lower(title) LIKE ?', "%#{keyword.downcase}%")
  }
end
Note
scoping te permite especificar las consultas comúnmente usadas que pueden ser referenciadas como llamada de método en los modelos. Con estos scopes puedes enlazar métodos con Active Record como where, joins y includes porque un scope siempre retorna un objeto ActiveRecord::Relation. Te invito a que eches un vistazo en la documentación de Rail

Esta implementación es suficiente para que nuestras pruebas pasen:

$ rake test
..........................

Por precio

Para filtrar por precio, las cosas pueden ser un poco más delicadas. Separaremos la lógica del filtrado por precio en dos diferentes métodos: uno que buscará por productos con precio mayor al recibido y otro que busque aquellos que son menores que el precio. De esta forma, mantendremos algo de flexibilidad y podemos fácilmente probar el scope.

Vamos a iniciar por construir las pruebas del scope above_or_equal_to_price:

test/models/product_test.rb
# ...
class ProductTest < ActiveSupport::TestCase
  # ...
  test 'should filter products by price and sort them' do
    assert_equal [products(:two), products(:one)], Product.above_or_equal_to_price(200).sort
  end
end

La implementación es muy, muy sencilla:

app/models/product.rb
class Product < ApplicationRecord
  # ...
  scope :above_or_equal_to_price, lambda { |price|
    where('price >= ?', price)
  }
end

Esto es suficiente para convertir nuestra prueba en verde:

$ rake test
...........................

Puedes imaginar el comportamiento del método opuesto. Aquí está la prueba:

test/models/product_test.rb
# ...
class ProductTest < ActiveSupport::TestCase
  # ...
  test 'should filter products by price lower and sort them' do
    assert_equal [products(:another_tv)], Product.below_or_equal_to_price(200).sort
  end
end

y la implementación.

app/models/product.rb
class Product < ApplicationRecord
  # ...
  scope :below_or_equal_to_price, lambda { |price|
    where('price <= ?', price)
  }
end

Para nuestros motivos, vamos a hacer la prueba y revisar que todo está hermosamente en verde:

$ rake test
............................

Como puedes ver, no tuvimos muchos problemas. Vamos a añadir otro scope para acomodar los registros por la fecha de la última actualización. En el caso cuando el propietario de los productos decide actualizar alguna información seguramente buscará acomodar sus productos por la fecha de creación.

Ordenas por fecha de creación

Este scope es muy fácil. Vamos a añadir algunas pruebas primero:

test/models/product_test.rb
# ...
class ProductTest < ActiveSupport::TestCase
  # ...
  test 'should sort product by most recent' do
    # we will touch some products to update them
    products(:two).touch
    assert_equal [products(:another_tv), products(:one), products(:two)], Product.recent.to_a
  end
end

Y la implementación:

app/models/product.rb
class Product < ApplicationRecord
  # ...
  scope :recent, lambda {
    order(:updated_at)
  }
end

Todas nuestras pruebas deberían de pasar:

$ rake test
.............................

Vamos a guardar nuestros cambios:

$ git commit -am "Adds search scopes on the product model"

Motor de búsqueda

Ahora que tenemos lo básico para el motor de búsqueda que usaremos en nuestra aplicación, es tiempo para implementar un simple pero poderoso método de búsqueda. Este gestionará toda la lógica para recuperar los registros de los productos.

El método consistirá en enlazar todos los scope que creamos anteriormente y retornar el resultado. Comencemos añadiendo algunas pruebas:

test/models/product_test.rb
# ...
class ProductTest < ActiveSupport::TestCase
  # ...
  test 'search should not find "videogame" and "100" as min price' do
    search_hash = { keyword: 'videogame', min_price: 100 }
    assert Product.search(search_hash).empty?
  end

  test 'search should find cheap TV' do
    search_hash = { keyword: 'tv', min_price: 50, max_price: 150 }
    assert_equal [products(:another_tv)], Product.search(search_hash)
  end

  test 'should get all products when no parameters' do
    assert_equal Product.all.to_a, Product.search({})
  end

  test 'search should filter by product ids' do
    search_hash = { product_ids: [products(:one).id] }
    assert_equal [products(:one)], Product.search(search_hash)
  end
end

Añadimos un montón de código, pero te aseguro que la implementación es muy fácil. Tú puedes ir más lejos y añadir pruebas adicionales pero, en mi caso, no lo encontré necesario.

app/models/product.rb
class Product < ApplicationRecord
  # ...
  def self.search(params = {})
    products = params[:product_ids].present? ? Product.where(id: params[:product_ids]) : Product.all

    products = products.filter_by_title(params[:keyword]) if params[:keyword]
    products = products.above_or_equal_to_price(params[:min_price].to_f) if params[:min_price]
    products = products.below_or_equal_to_price(params[:max_price].to_f) if params[:max_price]
    products = products.recent if params[:recent]

    products
  end
end

Es importante notar que retornamos los productos como un objeto ActiveRecord::Relation así que podemos concatenar otros métodos si es necesario o paginarlos como veremos en los últimos capítulos. Simplemente actualizar la acción para recuperar los productos desde el método de búsqueda:

app/controllers/api/v1/products_controller.rb
class Api::V1::ProductsController < ApplicationController
  # ...
  def index
    @products = Product.search(params)
    render json: ProductSerializer.new(@products).serializable_hash
  end
  # ...
end

Podemos correr la suit completa de pruebas para asegurar que la aplicación está en buen estado hasta aquí:

$ rake test
.................................
33 runs, 49 assertions, 0 failures, 0 errors, 0 skips

Guardemos todos estos cambios:

$ git commit -am "Adds search class method to filter products"

Y como estamos en el vinal de nuestro capítulo, es tiempo de aplicar todas nuestras modificaciones a la rama master haciendo un merge:

$ git checkout master
$ git merge chapter06

Conclusión

Hasta ahora fue fácil gracias a la gema fast_jsonapi. En el próximo capítulo vamos a iniciar con la construcción del modelo Order (orden) que implicará usuarios en los productos.

Colocando órdenes

En el capítulo previo manejamos asociaciones entre productos y usuarios y como serializarlos a fin de escalar rápido y fácil. Ahora es tiempo de empezar a implementar las ordenes lo cual será una situación algo más compleja. Manejaremos asociaciones entre estos tres modelos. Debemos ser lo suficientemente inteligentes para manejar la salida JSON que estamos entregando.

En este capítulo haremos algunas cosas que están listadas a continuación:

  • Crear un modelo Order con sus correspondientes especificaciones

  • Manipular la salida JSON con asociación entre los modelos orden de usuario y producto

  • Enviar un mail de confirmación con el resumen de la orden

Entonces ahora todo está claro podemos ensuciarnos las manos. Puedes clonar el proyecto hasta este punto con:

$ git checkout tags/checkpoint_chapter07

Creemos una rama para empezar a trabajar:

$ git checkout -b chapter07

Modelando la orden

Si recuerdas asociaciones de modelos, el modelo Order esta asociado con usuarios y productos al mismo tiempo. Actualmente esto es muy simple de lograr en Rails. La parte difícil es cuando vamos a serializar estos objetos. Hablare más sobre esto en la siguiente sección.

Vamos a empezar creando el modelo order con una forma especial:

$ rails generate model order user:belongs_to total:decimal

El comando anterior generará el modelo order pero estoy tomando ventaja del método references para crear la llave foránea correspondiente para que la orden pertenezca a el usuario. Esto también añade la directiva belongs_to dentro del modelo. Vamos a migrar la base de datos.

$ rake db:migrate

Ahora es tiempo para escribir algunas pruebas dentro del archivo`order_test.rb`:

test/models/order_test.rb
# ...
class OrderTest < ActiveSupport::TestCase
  test 'Should have a positive total' do
    order = orders(:one)
    order.total = -1
    assert_not order.valid?
  end
end

La implementación es demasiado simple:

app/models/order.rb
class Order < ApplicationRecord
  belongs_to :user
  validates :total, numericality: { greater_than_or_equal_to: 0 }
  validates :total, presence: true
end

No olvides añadir la relación orders a nuestros usuarios especificando el borrado en cascada:

app/models/user.rb
class User < ApplicationRecord
  # ...
  has_many :products, dependent: :destroy
  has_many :orders, dependent: :destroy
  # ...
end

Las pruebas deberían pasar:

$ rake test
..................................

Y hacemos commit de todo esto:

$ git add . && git commit -m "Generate orders"

Ordenes y productos

Necesitamos configurar la asociación entre la order y el product y esto se hace con una asociación has-many-to-many. Como muchos productos pueden ser puestos en muchas ordenes y las ordenes puede tener múltiples productos. Así en este caso necesitamos un modelo intermedio el cual unirá estos otros dos objetos y mapeará las asociaciones apropiadas.

Vamos a genera este modelo:

$ rails generate model placement order:belongs_to product:belongs_to

Vamos a correr la migración en la base de datos:

$ rake db:migrate

La implementación es como:

app/models/product.rb
class Product < ApplicationRecord
  belongs_to :user
  has_many :placements, dependent: :destroy
  has_many :orders, through: :placements
  # ...
end
app/models/order.rb
class Order < ApplicationRecord
  has_many :placements, dependent: :destroy
  has_many :products, through: :placements
  # ...
end

Si has estado siguiendo el tutorial para la implementación , esta ya está lista debido a las references (referencias) que forman parte del comando generador del modelo. Podríamos añadir la opción inverse_of a el modelo placement para cada llamada belongs_to. Esto da un pequeño impulso cuando referenciamos al objeto padre.

app/models/placement.rb
class Placement < ApplicationRecord
  belongs_to :order
  belongs_to :product, inverse_of: :placements
end

Vamos a correr las pruebas de los modelos y asegurar que todo es verde:

$ rake test
..................................

Ahora que todo está bien y en verde vamos a hacer commit de los cambios y continuar.

$ git add . && git commit -m "Associates products and orders with a placements model"

Exponer el modelo usuario

Es tiempo de poner en orden el controlador para exponer las ordenes correctas. Si recuerdas el capítulo previo donde fast_jsonapi fue usada, deberías recordar que fue realmente fácil.

Vamos a definir primero que acciones tomará:

  1. Una acción de indexación para recuperar las ordenes de usuario actuales

  2. Una acción show para recuperar un comando particular desde el usuario actual

  3. Una acción de creación para generar la orden

Vamos a iniciar con la acción index. Primero tenemos el comando para crear el controlador:

$ rails generate controller api::v1::orders

Hasta este punto y antes de empezar a escribir algo de código tenemos que preguntarnos a nosotros mismos:

¿Debería dejar mis enpoints de ordenes anidado dentro de UserController o debería aislarlas?

La respuesta es realmente simple: esto depende de la carga o información que quieras exponer al desarrollador.

En nuestro caso, no haremos esto porque recuperaremos los comandos del usuario desde la ruta /orders. Vamos a iniciar con algunas pruebas:

test/controllers/api/v1/orders_controller_test.rb
# ...
class Api::V1::OrdersControllerTest < ActionDispatch::IntegrationTest
  setup do
    @order = orders(:one)
  end

  test 'should forbid orders for unlogged' do
    get api_v1_orders_url, as: :json
    assert_response :forbidden
  end

  test 'should show orders' do
    get api_v1_orders_url,
      headers: { Authorization: JsonWebToken.encode(user_id: @order.user_id) },
      as: :json
    assert_response :success

    json_response = JSON.parse(response.body)
    assert_equal @order.user.orders.count, json_response['data'].count
  end
end

Si corremos la suit de pruebas ahora ambas pruebas deberían de fallar como ya esperábamos. Esto es porque estas no tienen establecidas las rutas o acciones correctas. Iniciemos añadiendo las rutas:

config/routes.rb
Rails.application.routes.draw do
  namespace :api, defaults: { format: :json } do
    namespace :v1 do
      resources :orders, only: [:index]
      # ...
    end
  end
end

Ahora es tiempo para implementar la serialización de las ordenes:

$ rails generate serializer Order

Y vamos a añadir relaciones:

app/serializers/order_serializer.rb
class OrderSerializer
  include FastJsonapi::ObjectSerializer
  belongs_to :user
  has_many :products
end

Ahora es tiempo de implementar el controlador:

app/controllers/api/v1/orders_controller.rb
class Api::V1::OrdersController < ApplicationController
  before_action :check_login, only: %i[index]

  def index
    render json: OrderSerializer.new(current_user.orders).serializable_hash
  end
end

Y ahora todas nuestras pruebas deberían de pasar:

$ rake test
....................................
36 runs, 53 assertions, 0 failures, 0 errors, 0 skips

Nos gustan que nuestros commits sean muy atómicos, así que vamos a guardar estos cambios:

$ git add . && git commit -m "Adds the index action for order"

Renderizar una sola orden

Como ahora puedes imaginar esta ruta es muy fácil. Únicamente hacemos algunas configuraciones (rutas, acción de controlador) y esta sección estará terminada. También incluiremos productos relacionados a esta orden en la salida JSON.

Vamos a iniciar añadiendo algunas pruebas:

test/controllers/api/v1/orders_controller_test.rb
# ...
class Api::V1::OrdersControllerTest < ActionDispatch::IntegrationTest
  # ...
  test 'should show order' do
    get api_v1_order_url(@order),
        headers: { Authorization: JsonWebToken.encode(user_id: @order.user_id) },
        as: :json
    assert_response :success

    json_response = JSON.parse(response.body)
    include_product_attr = json_response['included'][0]['attributes']
    assert_equal @order.products.first.title, include_product_attr['title']
  end
end

Como puedes ver, la segunda parte de la prueba verifica que el producto está incluido en el JSON.

Vamos añadir la implementación para correr nuestras pruebas. En el archivo routes.rb añadimos la acción show a las rutas de comando:

config/routes.rb
# ...
Rails.application.routes.draw do
  # ...
  resources :orders, only: %i[index show]
  # ...
end

Y la implementación debería lucir como esto:

app/controllers/api/v1/orders_controller.rb
class Api::V1::OrdersController < ApplicationController
  before_action :check_login, only: %i[index show]
  # ...
  def show
    order = current_user.orders.find(params[:id])

    if order
      options = { include: [:products] }
      render json: OrderSerializer.new(order, options).serializable_hash
    else
      head 404
    end
  end
end

Nuestras pruebas deberían estar todas verdes:

$ rake test
.....................................
37 runs, 55 assertions, 0 failures, 0 errors, 0 skips

Vamos a hacer commit de los cambios y parar a crear la acción de crear orden:

$ git commit -am "Adds the show action for order"

Colocando y ordenando

Es tiempo ahora de dar la oportunidad de colocar algunas órdenes. Esto añadirá complejidad a la aplicación, pero no te preocupes, vamos a hacer cada cosa en su tiempo.

Antes de implementar esta característica, tomare tiempo para pensar sobre la implicación de crear un comando en la aplicación. No estoy hablando sobre configurar un servicio de transacción como el de Stripe ó Braintree pero algo como:

  • gestionamiento de productos out-of-stock (fuera de stock)

  • reducir el inventario del producto

  • añadir alguna validación para el colocamiento de ordenes para asegurar que hay los suficientes productos al momento de colocar la orden

Parece que aún hay mucho por hacer pero créeme: estar más cerca de lo que piensas y no es tan difícil como parece. Por ahora mantengámoslo simple y asumamos que aún tendremos suficientes productos para colocar cualquier número de órdenes. Solo estamos preocupados sobre la respuesta del servidor por el momento.

Si tu recuerdas el modelo de orden, necesitamos tres cosas:

  • un total para la orden

  • usuario que coloca la orden

  • productos para la orden

Basado en esta información podemos empezar añadiendo algunas pruebas:

test/controllers/api/v1/orders_controller_test.rb
# ...
class Api::V1::OrdersControllerTest < ActionDispatch::IntegrationTest
  setup do
    # ...
    @order_params = { order: {
      product_ids: [products(:one).id, products(:two).id],
      total: 50
    } }
  end

  # ...

  test 'should forbid create order for unlogged' do
    assert_no_difference('Order.count') do
      post api_v1_orders_url, params: @order_params, as: :json
    end
    assert_response :forbidden
  end

  test 'should create order with two products' do
    assert_difference('Order.count', 1) do
      post api_v1_orders_url,
        params: @order_params,
        headers: { Authorization: JsonWebToken.encode(user_id: @order.user_id) },
        as: :json
    end
    assert_response :created
  end
end

Como puedes ver estamos crean una variable order_params con los datos de la orden. ¿Puedes ver el problema aquí? Si no, lo explicare más tarde. Justamente añadimos el código necesario para hacer pasar la prueba.

Primero necesitamos añadir la acción a los recursos en el archivo de rutas:

config/routes.rb
# ...
Rails.application.routes.draw do
  # ...
  resources :orders, only: %i[index show create]
  # ...
end

Entonces la implementación es fácil:

app/controllers/api/v1/orders_controller.rb
class Api::V1::OrdersController < ApplicationController
  before_action :check_login, only: %i[index show create]
  # ...

  def create
    order = current_user.orders.build(order_params)

    if order.save
      render json: order, status: 201
    else
      render json: { errors: order.errors }, status: 422
    end
  end

  private

  def order_params
    params.require(:order).permit(:total, product_ids: [])
  end
end

Y ahora nuestras pruebas deberian estar en verde:

$ rake test
.......................................
39 runs, 59 assertions, 0 failures, 0 errors, 0 skips

Ok, entonces tenemos todo correcto y en verde. Ahora deberíamos movernos al siguiente capitulo, ¿correcto? Déjame detenerte justo aquí. Tenemos algunos errores serios en la aplicación, y estos no están relacionados al código por sí mismo, pero si en la parte del negocio.

No porque los las pruebas estén verdes, esto significa que la aplicación esta cubriendo la parte del negocio. Quería traer esto aquí porque en muchos casos es super fácil solo recibir parámetros y construir objetos desde esos parámetros pensando que siempre estamos recibiendo los datos correctos. En este caso particular no podemos confiar en eso, y la forma fácil de ver esto, es que le estamos dando al cliente la oportunidad de poner el total, ¡que locura!

Tenemos que añadir algunas validaciones o un callback para calcular el total de la orden y colocarlo entre el modelo. De esta forma ya no recibiremos más el atributo del total y asi tener el control total sobre este atributo. Vamos a hacer esto:

Primer necesitamos algunas especificaciones a el modelo de la orden:

test/models/order_test.rb
# ...
class OrderTest < ActiveSupport::TestCase

  setup do
    @order = orders(:one)
    @product1 = products(:one)
    @product2 = products(:two)
  end

  test 'Should set total' do
    order = Order.new user_id: @order.user_id
    order.products << products(:one)
    order.products << products(:two)
    order.save

    assert_equal (@product1.price + @product2.price), order.total
  end
end

Ahora podemos añadir la implementación:

app/models/order.rb
class Order < ApplicationRecord
  # ...
  def set_total!
    self.total = products.map(&:price).sum
  end
end

Ahora podemos incluir el método set_total! a un callback before_validation para asegurar que tiene el total correcto antes de ser validado.

app/models/order.rb
class Order < ApplicationRecord
  before_validation :set_total!
  # ...
end

Hasta este punto nos aseguramos que el total está siempre presente y es mayor o igual a cero. Esto significa que podemos quitar esas validaciones y quitar las especificaciones. Esperaré. Nuestras pruebas deberían pasar por ahora:

$ rake test

...........F

Failure:
OrderTest#test_Should_have_a_positive_total [/home/arousseau/github/madeindjs/market_place_api/test/models/order_test.rb:14]:
Expected true to be nil or false


rails test test/models/order_test.rb:11

............................

Finished in 0.542600s, 73.7191 runs/s, 110.5786 assertions/s.

¡Oops! Obtuvimos un failure (falla) en nuestra anterior prueba Should have a positive total. Es lógico desde que el total de la orden es calculado dinámicamente. Así que podemos simplemente quitar esta prueba que ha quedado obsoleta.

Nuestra prueba debería pasar. Guardemos nuestros cambios:

$ git commit -am "Adds the create method for the orders controller"

Enviar email de confirmación de la orden

La última sección para este capítulo es para enviar el mail de confirmación al usuario que ordenó. Si quiere saltar esta parte e ir al siguiente capítulo hazlo. Esta sección es más como un calentamiento.

Tal vez estas familiarizado con la manipulación de emails con Rails así que intentaremos hacer esto fácil y rápido. Primero creamos el order_mailer con un email llamado send_confirmation:

$ rails generate mailer order_mailer send_confirmation

Ahora agregamos algunas pruebas para los correos de la orden que acabamos de crear:

test/mailers/order_mailer_test.rb
# ...
class OrderMailerTest < ActionMailer::TestCase

  setup do
    @order = orders(:one)
  end

  test "should be set to be delivered to the user from the order passed in" do
    mail = OrderMailer.send_confirmation(@order)
    assert_equal "Order Confirmation", mail.subject
    assert_equal [@order.user.email], mail.to
    assert_equal ['no-reply@marketplace.com'], mail.from
    assert_match "Order: ##{@order.id}", mail.body.encoded
    assert_match "You ordered #{@order.products.count} products", mail.body.encoded
  end

end

Yo simplemente copie/pegue las pruebas desde la documentación y las adapte a nuestras necesidades. Ahora nos aseguramos que estas pruebas pasan.

Primero, añadimos el método OrderMailer#send_confirmation:

app/mailers/order_mailer.rb
class OrderMailer < ApplicationMailer
  default from: 'no-reply@marketplace.com'
  def send_confirmation(order)
    @order = order
    @user = @order.user
    mail to: @user.email, subject: 'Order Confirmation'
  end
end

Después de añadir este código añadimos las vistas correspondientes. Es una buena práctica incluir un texto de la versión como extra a la versión HTML.

<%# app/views/order_mailer/send_confirmation.text.erb %>
Order: #<%= @order.id %>
You ordered <%= @order.products.count %> products:
<% @order.products.each do |product| %>
  <%= product.title %> - <%= number_to_currency product.price %>
<% end %>
<!-- app/views/order_mailer/send_confirmation.html.erb -->
<h1>Order: #<%= @order.id %></h1>
<p>You ordered <%= @order.products.count %> products:</p>
<ul>
  <% @order.products.each do |product| %>
    <li><%= product.title %> - <%= number_to_currency product.price %></li>
  <% end %>
</ul>

Ahora, nuestra prueba debería pasar:

$ rake test
........................................
40 runs, 66 assertions, 0 failures, 0 errors, 0 skips

Y ahora, solo llamamos al método OrderMailer#send_confirmation en la acción de crear en el controlador de la orden:

app/controllers/api/v1/orders_controller.rb
class Api::V1::OrdersController < ApplicationController
  # ...
  def create
    order = current_user.orders.build(order_params)

    if order.save
      OrderMailer.send_confirmation(order).deliver
      render json: order, status: 201
    else
      render json: { errors: order.errors }, status: 422
    end
  end
  # ...
end

Para asegurar que no rompimos nada, vamos a correr todas las pruebas:

$ rake test
........................................
40 runs, 66 assertions, 0 failures, 0 errors, 0 skips

Hagamos commit a todo para ya que está completa esta sección:

$ git add . && git commit -m "Adds order confirmation mailer"

Y como hemos llegado al final de nuestro capítulo, es tiempo de aplicar todas nuestras modificaciones a la rama master haciendo un `merge':

$ git checkout master
$ git merge chapter07

Conclusión

¡Eso es! ¡Lo hiciste! Puedes aplaudirte. Se que fue un largo tiempo pero créeme estas casi terminando.

En siguientes capítulos continuaremos trabajando en la plantilla de la orden y añadir validaciones cuando se hace una orden. Algunos escenarios son:

  • Que pasa cuando los productos no están disponibles?

  • Reducir la cantidad de los productos en progreso cuando se está ordenando

El siguiente capítulo será corto, pero es muy importante para la salud de la aplicación. así que no te lo saltes.

Mejorando las ordenes

En el capítulo anterior extendimos nuestra API para ordenar y enviar email de confirmación al usuario (solo para mejorar la experiencia del usuario). Este capítulo cuida algunas validaciones en el modelo de la orden, solo para asegurarse que se puede ordenar, algo como:

  • Reducir la cantidad del producto actual cuando se genera una orden

  • ¿Que pasa cuando no hay productos disponibles?

Probablemente necesitaremos actualiza un poco la salida JSON para las ordenes, pero no estropeemos las cosas.

Asi que ahora que tenemos todo claro podemos ensuciarnos las manos. Puedes clonar el proyecto hasta este punto con:

$ git checkout tags/checkpoint_chapter08

Vamos a crear una rama para empezar a trabajar:

$ git checkout -b chapter08

Decrementando la cantidad del producto

En esta primera parada vamos a trabajar en la actualización de la cantidad de producto para asegurar que cada pedido entregue la orden real. Actualmente el modelo product no tiene un atributo quantity. Así que vamos a hacer eso:

$ rails generate migration add_quantity_to_products quantity:integer

Espera, no corras las migraciones ahora. Le haremos unas pequeñas modificaciones. Como una buena práctica me gusta añadir los valores por defecto a la base de datos solo para asegurarme que no me equivoco con valores null. ¡Este es un caso perfecto!

Tu archivo de migración debería lucir como esto:

db/migrate/20190621105101_add_quantity_to_products.rb
class AddQuantityToProducts < ActiveRecord::Migration[6.0]
  def change
    add_column :products, :quantity, :integer, default: 0
  end
end

Ahora podemos migrar la base de datos:

$ rake db:migrate

Y no olvidemos actualizar los fixtures añadiendo el campo quantity (Yo elegí el valor 5 de manera aleatoria).

test/fixtures/products.yml
one:
  # ...
  quantity: 5

two:
  # ...
  quantity: 5

another_tv:
  # ...
  quantity: 5

Es tiempo ahora de reducir la cantidad de productos mientras una Orden está siendo procesada. La primera cosa probablemente que viene a la mente es hacerlo en el modelo Order. Esto es un misterio común.

Cuando trabajas con asociaciones Many-to-Many (muchos a muchos), nos olvidamos completamente del modelo de unión que en este caso es Placement. Placement es el mejor lugar para gestionar esto porque tiene accesos la orden y al producto. De esta forma, podemos fácilmente reducir el stock del producto.

Antes de empezar a implementar código, necesitamos cambiar la forma que manipulamos la creación de ordenes porque ahora tenemos que aceptar la cantidad para cada producto. Si recuerdas estamos esperando por una tabla de identificadores de producto. Intentaré mantener las cosas simples y enviar una tabla Hash con las llaves product_id y quantity.

Un ejemplo rápido podria ser algo como esto:

product_ids_and_quantities = [
  { product_id: 1, quantity: 4 },
  { product_id: 3, quantity: 5 }
]

Esto se pondrá difícil pero quédate conmigo. Vamos primero a construir algunas pruebas:

test/models/order_test.rb
# ...
class OrderTest < ActiveSupport::TestCase
  # ...

  test 'builds 2 placements for the order' do
    @order.build_placements_with_product_ids_and_quantities [
      { product_id: @product1.id, quantity: 2 },
      { product_id: @product2.id, quantity: 3 },
    ]

    assert_difference('Placement.count', 2) do
      @order.save
    end
  end
end

Entonces en la implementación:

app/models/order.rb
class Order < ApplicationRecord
  # ...

  # @param product_ids_and_quantities [Array<Hash>] something like this `[{product_id: 1, quantity: 2}]`
  # @yield [Placement] placements build
  def build_placements_with_product_ids_and_quantities(product_ids_and_quantities)
    product_ids_and_quantities.each do |product_id_and_quantity|
      placement = placements.build(product_id: product_id_and_quantity[:product_id])
      yield placement if block_given?
    end
  end
end

+ Y si corremos nuestras pruebas, deberían estar bien y en verde:

$ rake test
........................................
40 runs, 60 assertions, 0 failures, 0 errors, 0 skips

Lo que es build_placements_with_product_ids_and_quantities hará la colocación de objetos y luego ejecutará el método save para la ordenar todo será insertada en la base de datos. Un último paso antes de guardar esto es actualizar la prueba orders_controller_test junto con esta implementación.

Primero actualizamos el archivo orders_controller_test:

test/controllers/api/v1/orders_controller_test.rb
# ...
class Api::V1::OrdersControllerTest < ActionDispatch::IntegrationTest
  setup do
    @order = products(:one)
    @order_params = {
      order: {
        product_ids_and_quantities: [
          { product_id: products(:one).id, quantity: 2 },
          { product_id: products(:two).id, quantity: 3 },
        ]
      }
    }
  end

  # ...

  test 'should create order with two products and placements' do
    assert_difference('Order.count', 1) do
      assert_difference('Placement.count', 2) do
        post api_v1_orders_url, params: @order_params, as: :json
            headers: { Authorization: JsonWebToken.encode(user_id: @order.user_id) },
      end
    end
    assert_response :created
  end
end

Entonces necesitamos actualizar orders_controller:

app/controllers/api/v1/orders_controller.rb
class Api::V1::OrdersController < ApplicationController
  # ...

  def create
    order = Order.create! user: current_user
    order.build_placements_with_product_ids_and_quantities(order_params[:product_ids_and_quantities])

    if order.save
      OrderMailer.send_confirmation(order).deliver
      render json: order, status: :created
    else
      render json: { errors: order.errors }, status: :unprocessable_entity
    end
  end

  private

  def order_params
    params.require(:order).permit(product_ids_and_quantities: [:product_id, :quantity])
  end
end

Nota que también modifique el método OrdersController#order_params.

Por último, pero no menos importante, necesitamos actualizar el archivo que fabrica productos para asignar un valor alto de cantidad para tener algunos productos en stock.

Hagamos commit de estos cambios y continuemos:

$ git add .
$ git commit -m "Allows the order to be placed along with product quantity"

¿Notaste que no estamos guardando la cantidad por cada producto en ningún lado? Esta no es la forma de darle seguimiento. Esto puede ser reparado fácilmente. Solo añadamos un atributo quantity a el modelo Placement. De este modo para cada producto guardaremos su cantidad correspondiente. Vamos a iniciar creando la migración:

$ rails generate migration add_quantity_to_placements quantity:integer

Como con el atributo para la cantidad del producto deberíamos añadir un valor por defecto igual a 0. Recuerda que esto es opcional, pero me gusta este enfoque. El archivo de migración debería lucir así:

db/migrate/20190621114614_add_quantity_to_placements.rb
class AddQuantityToPlacements < ActiveRecord::Migration[6.0]
  def change
    add_column :placements, :quantity, :integer, default: 0
  end
end

Entonces corre las migraciones:

$ rake db:migrate

Ahora agregamos el atributo quantity en los fixtures:

test/fixtures/placements.yml
one:
  # ...
  quantity: 5

two:
  # ...
  quantity: 5

Ahora solo necesitamos actualizar la prueba build_placements_with_product_ids_and_quantities para añadir quantity para hacer los pedidos:

app/models/order.rb
class Order < ApplicationRecord
  # ...

  # @param product_ids_and_quantities [Array<Hash>] something like this `[{product_id: 1, quantity: 2}]`
  # @yield [Placement] placements build
  def build_placements_with_product_ids_and_quantities(product_ids_and_quantities)
    product_ids_and_quantities.each do |product_id_and_quantity|
      placement = placements.build(
        product_id: product_id_and_quantity[:product_id],
        quantity: product_id_and_quantity[:quantity],
      )
      yield placement if block_given?
    end
  end
end

Ahora nuestras pruebas deberían pasar:

$ rake test
........................................
40 runs, 61 assertions, 0 failures, 0 errors, 0 skips

Vamos a guardar los cambios:

$ git add . && git commit -m "Adds quantity to placements"

Entendiendo el modelo Placement

Es tiempo de actualizar la cantidad del producto cada que la orden es guardada, o más exacto cada que el placement (colocación) es creado. A fin de lograr esto vamos a añadir un método y entonces conectarlo con el callback after_create.

test/models/placement_test.rb
# ...
class PlacementTest < ActiveSupport::TestCase
  setup do
    @placement = placements(:one)
  end

  test 'decreases the product quantity by the placement quantity' do
    product = @placement.product

    assert_difference('product.quantity', -@placement.quantity) do
      @placement.decrement_product_quantity!
    end
  end
end

La implementación es bastante fácil como se muestra a continuación:

app/models/placement.rb
class Placement < ApplicationRecord
  # ...
  after_create :decrement_product_quantity!

  def decrement_product_quantity!
    product.decrement!(:quantity, quantity)
  end
end

Hagamos commit a nuestros cambios:

$ git commit -am "Decreases the product quantity by the placement quantity"

Validar la cantidad de productos

Desde el comienzo del capítulo, tenemos añadido el atributo quantity a el modelo del producto. Es ahora tiempo para validar si la cantidad de producto es suficiente para conciliar la orden. A fin de que hagamos las cosas más interesantes, vamos a hacer usando un validador personalizado.

Note
puedes consultar la documentación.

Primero necesitamos añadir un directorio validators en el directorio app (Rails lo incluirá por lo que no necesitamos preocuparnos de cargarlo).

$ mkdir app/validators
$ touch app/validators/enough_products_validator.rb

Antes que borremos cualquier línea de código, necesitamos asegurarnos de añadir especificaciones a el modelo Order para revisar si la orden puede ser realizada.

test/models/order_test.rb
# ...
class OrderTest < ActiveSupport::TestCase
  # ...

  test "an order should command not too much product than available" do
    @order.placements << Placement.new(product_id: @product1.id, quantity: (1 + @product1.quantity))

    assert_not @order.valid?
  end
end

Como puedes ver en la especificación, primero nos aseguramos que placement_2 este tratando de pedir mas productos de los que están disponibles, así que en este caso suponemos que la order (orden) no es válida.

La prueba por ahora debería fallar, vamos a convertirla en verde añadiendo el código del validador:

app/validators/enough_products_validator.rb
class EnoughProductsValidator < ActiveModel::Validator
  def validate(record)
    record.placements.each do |placement|
      product = placement.product
      if placement.quantity > product.quantity
        record.errors[product.title.to_s] << "Is out of stock, just #{product.quantity} left"
      end
    end
  end
end

Manipulo para añadir el mensaje a cada uno de los producto que están fuera de stock, pero puede manejarlo diferente si quieres. Ahora solamente necesito añadir el validador al modelo Order de esta forma:

app/models/order.rb
class Order < ApplicationRecord
  include ActiveModel::Validations
  # ...
  validates_with EnoughProductsValidator
  # ...
end

Guardemos los cambios:

$ git add . && git commit -m "Adds validator for order with not enough products on stock"

Actualizando el total

Notaste que el total está siendo calculado incorrectamente, porque actualmente este está añadiendo el precio para los productos en la orden independientemente de la cantidad solicitada. Déjame añadir el código para aclarar el problema:

Actualmente en el modelo order tenemos este método para calcular el monto a pagar:

app/models/order.rb
class Order < ApplicationRecord
  # ...
  def set_total!
    self.total = products.map(&:price).sum
  end
  # ...
end

Ahora en lugar de calcular el total solo añadiendo el precio del producto necesitamos multiplicarlo por la cantidad. Así que vamos a actualizar las especificaciones primero:

test/models/order_test.rb
# ...
class OrderTest < ActiveSupport::TestCase
  # ...

  test "Should set total" do
    @order.placements = [
      Placement.new(product_id: @product1.id, quantity: 2),
      Placement.new(product_id: @product2.id, quantity: 2)
    ]
    @order.set_total!
    expected_total = (@product1.price * 2) + (@product2.price * 2)

    assert_equal expected_total, @order.total
  end
end

Y la implementación es muy sencilla:

app/models/order.rb
class Order < ApplicationRecord
  # ...
  def set_total!
    self.total = self.placements
                     .map{ |placement| placement.product.price * placement.quantity }
                     .sum
  end
  # ...
end

Y las especificaciones deberían ser verdes:

$ rake test
..........................................
42 runs, 63 assertions, 0 failures, 0 errors, 0 skips

Vamos a guardar los cambios:

$ git commit -am "Updates the total calculation for order"

Y así es como llegamos al final de nuestro capítulo, es tiempo de aplicar todas nuestras modificaciones a la rama master haciendo un merge:

$ git checkout master
$ git merge chapter08

Conclusión

¡Oh, ahi tienes! ¡Déjame felicitarte! Es un largo camino desde el primer capítulo. Pero estas un paso más cerca, De hecho, el próximo capítulo será el último. Así que trata de aprovecharlo al máximo.

El último capítulo se enfocará en la forma de optimizar la API usando paginado, caché y tareas en segundo plano. Así que abróchate el cinturón, va a ser un viaje agitado.

Optimizaciones

Bienvenido a el último capítulo de este libro. Ha sido un largo camino, pero estas solo a un paso del final. En el capítulo anterior, completamos el modelado del modelo de la orden. Podríamos decir que el proyecto está finalizado, pero quiero cubrir algunos detalles importantes sobre la optimización. Los temas que discutiremos serán:

  • paginación

  • caché

  • optimización de las consultas SQL

  • la activación de CORS

Trataré de ir tan lejos como pueda intentando cubrir algunos escenarios comunes. Espero que estos escenarios sean útiles para algunos de tus proyectos.

Si tu empiezas leyendo hasta este punto, probablemente quieras el código, puedes clonarlo con esto:

$ git checkout tags/checkpoint_chapter09

Ahora vamos a crear una rama para empezar a trabajar:

$ git checkout -b chapter09

Paginación

Una estrategia muy común para optimizar un arreglo de registros desde la base de datos, es cargar solo algunos paginándolos y si tu estas familiarizado con esta técnica sabes que en Rails es realimente fácil lograrlos sobre todo si estas usando will_paginate ó kaminari.

Entonces solo la parte difícil aquí es como suponemos manipular la salida JSON dando la suficiente información al cliente sobre como esta paginado el arreglo. Si recuerdas el primer capítulo compartí algunos recursos y prácticas que iba a seguir aquí. Una de ellas fue http://jsonapi.org/ que es una página de mis favoritas.

Si leemos la sección de formato encontraremos una sub sección llamada Top Level y en algunas palabras se mencionan algunas cosas sobre paginación:

"meta": meta-información sobre un recurso, como la paginación.

Esto no es muy descriptivo pero al menos tenemos una pista de que buscar después sobre la implementación de la paginación, pero no te preocupes que es exactamente a donde estamos yendo ahora.

Comencemos con la lista de products.

Productos

Estamos iniciando bien y fácil paginando la lista de producto ya que no tenemos ningún tipo de restricción de acceso que nos lleve a pruebas más fáciles.

Primero necesitamos añadir la gema kaminari a nuestro Gemfile:

$ bundle add kaminari

Ahora podemos ir a la acción index en el controlador products_controller y añadir los métodos de paginación como se señala en la documentación:

app/controllers/api/v1/products_controller.rb
class Api::V1::ProductsController < ApplicationController
  # ...
  def index
    @products = Product.page(params[:page])
                       .per(params[:per_page])
                       .search(params)

    render json: ProductSerializer.new(@products).serializable_hash
  end
  # ...
end

Hasta ahora la única cosa que cambio es la consulta a la base de datos que justamente limita el resultado a 25 por página que es el valor por defecto. Pero no tenemos añadida información extra a la salida JSON.

Necesitamos proveer la información de paginación en el tag meta de la siguiente forma:

{
  "data": [
    ...
  ],
  "links": {
    "first": "/api/v1/products?page=1",
    "last": "/api/v1/products?page=30",
    "prev": "/api/v1/products",
    "next": "/api/v1/products?page=2"
  }
}

Ahora tenemos la estructura final para el tag meta que necesitamos en la salida de la repuesta JSON. Vamos primer a añadir algunas especificaciones-:

test/controllers/api/v1/products_controller_test.rb
# ...
class Api::V1::ProductsControllerTest < ActionDispatch::IntegrationTest
  # ...
  test 'should show products' do
    get api_v1_products_url, as: :json
    assert_response :success

    json_response = JSON.parse(response.body, symbolize_names: true)
    assert_not_nil json_response.dig(:links, :first)
    assert_not_nil json_response.dig(:links, :last)
    assert_not_nil json_response.dig(:links, :prev)
    assert_not_nil json_response.dig(:links, :next)
  end
  # ...
end

La prueba que acabamos de añadir debería fallar:

$ rake test
......................F

Failure:
Api::V1::ProductsControllerTest#test_should_show_products [test/controllers/api/v1/products_controller_test.rb:13]:
Expected nil to not be nil.

Vamos a añadir información de paginación. Construiremos una parte de esto en concerns para fragmentar mejor nuestro código:

app/controllers/concerns/paginable.rb
# app/controllers/concerns/paginable.rb
module Paginable
  protected

  def current_page
    (params[:page] || 1).to_i
  end

  def per_page
    (params[:per_page] || 20).to_i
  end
end

Y ahora podemos usarlo en el controlador.

app/controllers/api/v1/products_controller.rb
class Api::V1::ProductsController < ApplicationController
  include Paginable
  # ...

  def index
    @products = Product.page(current_page)
                       .per(per_page)
                       .search(params)

    options = {
      links: {
        first: api_v1_products_path(page: 1),
        last: api_v1_products_path(page: @products.total_pages),
        prev: api_v1_products_path(page: @products.prev_page),
        next: api_v1_products_path(page: @products.next_page),
      }
    }

    render json: ProductSerializer.new(@products, options).serializable_hash
  end
end

Ahora, si revisamos las especificaciones, estos deberían pasar todos:

$ rake test
..........................................
42 runs, 65 assertions, 0 failures, 0 errors, 0 skips

Ahora tenemos echa una super optimización para la ruta de lista de productos, depende del cliente para recuperar el parámetro de la page (página) para los registros.

Vamos a hacer estos cambios y continuar con la lista de comandos.

$ git add .
$ git commit -m "Adds pagination for the products index action to optimize response"

Lista de ordenes

Ahora es tiempo de hacer exactamente lo mismo para el enpoint de la lista de orders que debería ser realmente fácil de implementar. Pero primero vamos a añadir algunas especificaciones al archivo orders_controller_test.rb:

test/controllers/api/v1/orders_controller_test.rb
# ...
class Api::V1::OrdersControllerTest < ActionDispatch::IntegrationTest
  # ...
  test 'should show orders' do
    get api_v1_orders_url, headers: { Authorization: JsonWebToken.encode(user_id: @order.user_id) }, as: :json
    assert_response :success

    json_response = JSON.parse(response.body, symbolize_names: true)
    assert_equal @order.user.orders.count, json_response[:data].count
    assert_not_nil json_response.dig(:links, :first)
    assert_not_nil json_response.dig(:links, :last)
    assert_not_nil json_response.dig(:links, :prev)
    assert_not_nil json_response.dig(:links, :next)
  end
  # ...
end

Como ya deberías saber, nuestras pruebas no estarán pasando:

$ rake test
......................................F

Failure:
Api::V1::OrdersControllerTest#test_should_show_orders [test/controllers/api/v1/orders_controller_test.rb:28]:
Expected nil to not be nil.

Cambiemos el rojo en verde:

app/controllers/api/v1/orders_controller.rb
class Api::V1::OrdersController < ApplicationController
  include Paginable
  # ...

  def index
    @orders = current_user.orders
                          .page(current_page)
                          .per(per_page)

    options = {
      links: {
        first: api_v1_orders_path(page: 1),
        last: api_v1_orders_path(page: @orders.total_pages),
        prev: api_v1_orders_path(page: @orders.prev_page),
        next: api_v1_orders_path(page: @orders.next_page),
      }
    }

    render json: OrderSerializer.new(@orders, options).serializable_hash
  end
  # ...
end

Ahora todas las pruebas deberían pasar bien y en verde:

$ rake test
..........................................
42 runs, 67 assertions, 0 failures, 0 errors, 0 skips

Hagamos un commit, por que se viene una refactorización:

$ git commit -am "Adds pagination for orders index action"

Refactorizando la paginación

Si tú has seguido este tutorial o si tienes experiencia previa como desarrollador Rails, probablemente te guste mantener las cosas SECAS. Es posible que hayas notado que el código que acabamos de escribir está duplicado. Pienso que es un buen hábito hacer limpieza del código un poco cuando la funcionalidad esta implementada.

Primero limpiaremos estas pruebas que duplicamos en los archivos orders_controller_test.rb y products_controller_test.rb:

assert_not_nil json_response.dig(:links, :first)
assert_not_nil json_response.dig(:links, :last)
assert_not_nil json_response.dig(:links, :next)
assert_not_nil json_response.dig(:links, :prev)

Para factorizarlo, vamos a mover estas afirmaciones a el archivo test_helper.rb en un método que usaremos:

test/test_helper.rb
# ...
class ActiveSupport::TestCase
  # ...
  def assert_json_response_is_paginated json_response
    assert_not_nil json_response.dig(:links, :first)
    assert_not_nil json_response.dig(:links, :last)
    assert_not_nil json_response.dig(:links, :next)
    assert_not_nil json_response.dig(:links, :prev)
  end
end

Este método puede ahora ser usado para remplazar las cuatro afirmaciones en los archivos orders_controller_test.rb y products_controller_test.rb:

test/controllers/api/v1/orders_controller_test.rb
# ...
class Api::V1::OrdersControllerTest < ActionDispatch::IntegrationTest
  # ...
  test 'should show orders' do
    # ...
    assert_json_response_is_paginated json_response
  end
  # ...
end
test/controllers/api/v1/products_controller_test.rb
# ...
class Api::V1::ProductsControllerTest < ActionDispatch::IntegrationTest
  # ...
  test 'should show products' do
    # ...
    assert_json_response_is_paginated json_response
  end
  # ...
end

Y ambas especificaciones deberían pasar.

$ rake test
..........................................
42 runs, 71 assertions, 0 failures, 0 errors, 0 skips

Ahora tenemos terminado esta simple refactorización para las pruebas, podemos movernos a la implementación de la paginación para los controladores y limpiar cosas. Si tu recuerdas la acción de indexación para ambos controladores producto y orden, ambos tienen el mismo formato de paginación. Así que vamos a mover esta lógica dentro de un método llamado get_links_serializer_options en el archivo paginable.rb, así podemos acceder a el desde cualquier controlador que necesite paginación.

app/controllers/concerns/paginable.rb
module Paginable
  protected

  def get_links_serializer_options links_paths, collection
    {
      links: {
        first: send(links_paths, page: 1),
        last: send(links_paths, page: collection.total_pages),
        prev: send(links_paths, page: collection.prev_page),
        next: send(links_paths, page: collection.next_page),
      }
    }
  end
  # ...
end

Y ahora podemos sustituir el hash de paginación en ambos controladores para el método. Justo así:

app/controllers/api/v1/orders_controller.rb
class Api::V1::OrdersController < ApplicationController
  include Paginable
  # ...

  def index
    @orders = current_user.orders
                          .page(current_page)
                          .per(per_page)

    options = get_links_serializer_options('api_v1_orders_path', @orders)

    render json: OrderSerializer.new(@orders, options).serializable_hash
  end
  # ...
end
app/controllers/api/v1/products_controller.rb
class Api::V1::ProductsController < ApplicationController
  include Paginable
  # ...

  def index
    @products = Product.page(current_page)
                       .per(per_page)
                       .search(params)

    options = get_links_serializer_options('api_v1_products_path', @products)

    render json: ProductSerializer.new(@products, options).serializable_hash
  end
  # ...
end

Si corres las especificaciones para cada archivo deberían estar todas bien y verdes:

$ rake test
..........................................
42 runs, 71 assertions, 0 failures, 0 errors, 0 skips

Este debería ser un buen momento para hacer un commit a los cambios y movernos a la siguiente sección sobre el caché:

$ git commit -am "Factorize pagination"

Almacenamiento en cache del API

Actualmente esta es una implementación para almacenar en caché la gema fast_jsonapi que es realmente fácil de manipular. A pesar de que en la última versión de la gema, esta implementación puede cambiar, esta hace el trabajo.

Si hacemos una petición a la lista de productos, notaremos que el tiempo de respuesta toma cerca de 174 milisegundos usando cURL:

$ curl -w 'Total: %{time_total}\n' -o /dev/null -s http://localhost:3000/api/v1/products
Total: 0,137088
Note
La opción -w nos permite recuperar el tiempo de petición, -o redirecciona la respuesta a un archivo y -s esconde la pantalla de cURL

¡Añadiendo solo una línea a la clase ProductSerializer, veremos un significante incremento en el tiempo de respuesta!

app/serializers/order_serializer.rb
class OrderSerializer
  # ...
  cache_options enabled: true, cache_length: 12.hours
end
app/serializers/product_serializer.rb
class ProductSerializer
  # ...
  cache_options enabled: true, cache_length: 12.hours
end
app/serializers/user_serializer.rb
class UserSerializer
  # ...
  cache_options enabled: true, cache_length: 12.hours
end

¡Y esto es todo! Vamos a revisar la mejora:

$ curl -w 'Total: %{time_total}\n' -o /dev/null -s http://localhost:3000/api/v1/products
Total: 0,054786
$ curl -w 'Total: %{time_total}\n' -o /dev/null -s http://localhost:3000/api/v1/products
Total: 0,032341

Así que fuimos de 174 ms a 21 ms. ¡La mejora por lo tanto es enorme! Vamos a guardar nuestros cambios una última vez:

$ git commit -am "Adds caching for the serializers"

Consultas N+1

Consultas N+1* son una herida donde podemos tener un enrome impacto en el rendimiento de una aplicación. Este fenómeno a menudo ocurre cuando usamos ORM porque este genera automáticamente consultas SQL por nosotros. Esta herramienta tan practica es de doble filo porque puede genera un largo número de consultas SQL.

Algo que debemos saber sobre las consultas SQL es que es mejor limitar su número. En otras palabras, una repuesta larga es a menudo más eficiente que cientos de pequeñas.

Aquí está un ejemplo cuando queremos recuperar todos los usuarios que ya tiene un producto creado. Abre la consola de Rails con rails console y ejecuta el siguiente código Ruby:

Product.all.map { |product| product.user }

La consola interactiva de rails nos muestra consultas SQL que son generadas. Mira por ti mismo:

Vemos aquí que un largo número de peticiones son generadas:

  • Product.all = 1 petición para recuperar los productos

  • product.user = 1 petición SELECT "users".* FROM "users" WHERE "users". "id" =? LIMIT 1 [[[["id", 1]]] por producto recuperado

Por lo tanto el nombre "petición N+1" es ya que una solicitud se realiza a través de un enlace secundario.

Podemos arreglar esto simplemente usando includes. Includes pre-cargará los objetos secundarios en una simple petición. Es muy fácil de usar. Si repetimos el ejemplo anterior. Este es el resultado:

Product.includes(:user).all.map { |product| product.user }

La consola interactiva de Rails nos muestra las consultas SQL que son generadas. Mira por ti mismo:

Product Load (0.3ms)  SELECT "products".* FROM "products"
User Load (0.8ms)  SELECT "users".* FROM "users" WHERE "users"."id" IN (?, ?, ?)  [["id", 28], ["id", 29], ["id", 30]]

Rails crea una segunda petición que recuperará todos los usuarios a la vez.

Prevencion de peticiones N + 1

Imagina que queremos añadir propietarios de los productos a la ruta /products. Ya hemos visto que con la librería fast_jsonapi es muy fácil de hacer esto:

app/controllers/api/v1/products_controller.rb
class Api::V1::ProductsController < ApplicationController
  # ...
  def index
    # ...
    options = get_links_serializer_options('api_v1_products_path', @products)
    options[:include] = [:user]

    render json: ProductSerializer.new(@products, options).serializable_hash
  end
  # ...
end

Ahora vamos a hacer ua petición con cURL. Te recuerdo que nosotros debimos obtener un token de autenticación antes de acceder a la pagina.

$ curl -X POST --data "user[email]=ockymarvin@jacobi.co" --data "user[password]=locadex1234"  http://localhost:3000/api/v1/tokens
Note
"ockymarvin@jacobi.co" corresponde a un usurio creado en mi aplicación con el seed. En tu caso, probablemente fue diferente del mío desde que usamos la librería Faker.

Con la ayuda de el token obtenido, ahora podemos hacer una petición para acceder a los productos

$ curl --header "Authorization=ey..." http://localhost:3000/api/v1/products

Lo más probable es que veas varias respuestas en la consola Rails corriendo el servidor web.

Started GET "/api/v1/products" for 127.0.0.1 at 2019-06-26 13:36:19 +0200
Processing by Api::V1::ProductsController#index as JSON
   (0.1ms)  SELECT COUNT(*) FROM "products"
   app/controllers/concerns/paginable.rb:9:in `get_links_serializer_options'
  Product Load (0.2ms)  SELECT "products".* FROM "products" LIMIT ? OFFSET ?  [["LIMIT", 20], ["OFFSET", 0]]
  ↳ app/controllers/api/v1/products_controller.rb:16:in `index'
  User Load (0.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 36], ["LIMIT", 1]]
  ↳ app/controllers/api/v1/products_controller.rb:16:in `index'
   (0.5ms)  SELECT "products"."id" FROM "products" WHERE "products"."user_id" = ?  [["user_id", 36]]
   app/controllers/api/v1/products_controller.rb:16:in `index'
  CACHE User Load (0.0ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 36], ["LIMIT", 1]]
  ↳ app/controllers/api/v1/products_controller.rb:16:in `index'
  CACHE User Load (0.0ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 36], ["LIMIT", 1]]
  ↳ app/controllers/api/v1/products_controller.rb:16:in `index'
  CACHE User Load (0.0ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 36], ["LIMIT", 1]]

Es por lo tanto desafortunadamente muy fácil para crear consultas N+1. Afortunadamentes, esta es una gema que nos permite alertar cuando este tipo de situación ocurre: Bullet. Bullet nos notificará (por correo, growl notification, Slack, consola, etc…​) cuando encuentra una petición N+1.

Para instalarla, vamos añadir la gema al GemFile

$ bundle add bullet --group development

Y eso es suficiente para actualizar la configuración de nuestra aplicación para el entorno de desarrollo. En nuestro caso solo activaremos el modo rails_logger el cual será mostrado:

config/environments/development.rb
Rails.application.configure do
  # ...
  config.after_initialize do
    Bullet.enable = true
    Bullet.rails_logger = true
  end
end

Reinicia el servidor web y reinicia la última petición con cURL:

$ curl --header "Authorization=ey..." http://localhost:3000/api/v1/products

Y mira en la consola de Rails. Bullet nos dice que tiene justamente una petición N+1 detectada.

GET /api/v1/products
USE eager loading detected
  Product => [:user]
  Add to your finder: :includes => [:user]

Incluso nos dice como corregirla:

> Add to your search engine

includes ⇒ [: user]

Asi que corregimos nuestro error en el controlador:

app/controllers/api/v1/products_controller.rb
class Api::V1::ProductsController < ApplicationController
  # ...
  def index
    @products = Product.includes(:user)
                       .page(current_page)
                       .per(per_page)
                       .search(params)

    options = get_links_serializer_options('api_v1_products_path', @products)
    options[:include] = [:user]

    render json: ProductSerializer.new(@products, options).serializable_hash
  end
  # ...
end

¡Ahí tienes! Es tiempo de hacer nuestro commit.

$ git commit -am "Add bullet to avoid N+1 query"

Activación de CORS

En esta última sección, te hablaré sobre un último problema que probablemente encontraste si tú has trabajado con tu propia API.

Cuando haces una petición a un sitio externo (por ejemplo una petición vía AJAX), encontraras un error de este tipo:

Failed to load https://example.com/ No 'Access-Control-Allow-Origin' header is present on the requested resource. Origin "https://anfo.pl" is therefore not allowed access. If an opaque response serves your needs, set the request’s mode to "no-cors" to fetch the resource with CORS disabled.

"¿Pero que significa Access-Control-Allow-Origin?". El comportamiento que observas es el efecto de la implementación CORS del navegador. Antes de la estandarización de CORS, no había forma de llamar a una terminal de API bajo otro dominio por razones de seguridad. Esto ha sido (y todavía es hasta cierto punto) bloqueado por la política de el mismo origen.

CORS es un mecanismo que tiene como objetivo permitir peticione echas en su nombre y al mismo tiempo bloque algunas petición echa de modo deshonesto por scripts y se activa cuando haces una petición HTTP a:

  • un diferente campo

  • un diferente sub-dominio

  • un diferente puerto

  • un diferente protocolo

Vamos a habilitar manualmente esta característica para que cualquier cliente puede hacer peticiones a nuestra API.

Rails nos permite hacerlo esto fácilmente. Mira el archivo cors.rb localizado en el directorio initializers.

config/initializers/cors.rb
# ...

# Rails.application.config.middleware.insert_before 0, Rack::Cors do
#   allow do
#     origins 'example.com'
#
#     resource '*',
#       headers: :any,
#       methods: [:get, :post, :put, :patch, :delete, :options, :head]
#   end
# end

Ves. Es suficiente con quitar los comentarios del código y modificar un poco para limitar el acceso a algunos acciones o algunos verbos HTTP. En nuestro caso, esta configuración es muy conveniente para nosotros en este momento.

config/initializers/cors.rb
# ...

Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins 'example.com'
    resource '*',
      headers: :any,
      methods: [:get, :post, :put, :patch, :delete, :options, :head]
  end
end

Debemos instalar la gema rack-cors que esta comentada en el Gemfile:

$ bundle add rack-cors

¡Ahí tienes! Es tiempo de hacer nuestro último commit y fusionar nuestros cambios en la rama master.

$ git commit -am "Activate CORS"
$ git checkout master
$ git merge chapter09

Conclusión

Si llegaste hasta este punto, eso significa que terminaste el libro. ¡Buen trabajo! Te has convertido en un gran desarrollador API en Rails, tenlo por seguro.

Así que juntos hemos construido una API sólida y completa. Esta tiene todas las cualidades para destronar a Amazon, esta seguro. Te agradezco por ir a través de esta gran aventura conmigo, Espero que disfrutaras el viaje tanto como yo lo hice.

Me gustaría recordarte que el código fuente para este libro esta disponible en el formato Asciidoctor en GitHub. Así que no dudes en forkear el proyecto si quieres mejorarlo o corregir algún error que no vi.

Si te gusta este libro, no vaciles en hacérmelo saber por correo contact@rousseau-alexandre.fr. Estoy abierto cualquier crítica, buena o mala, junto a una buena cerveza :).