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
"API on Rails 6" por Alexandre Rousseau es compartido de acuerdo a Creative Commons Attribution - Attribution-ShareAlike 4.0 International. Construido sobre este libro http://apionrails.icalialabs.com/book/.
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.
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
$ /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:
# 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.
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.
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:
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/`
.
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:
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:
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
:
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 .
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í:
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í:
# ...
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:
$ 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:
-
que el email tenga un formato válido
-
que el email sea único
-
que la contraseña siempre contenga algo
Estas tres reglas son definidas por el siguiente código:
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.
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:
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 '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 '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 '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á:
-
añadir la gema al Gemfile recuperando la versión más reciente
-
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:
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.
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:
-
generar el controlador
users_controller
. -
añadir las pruebas correspondientes
-
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
Por lo tanto, vamos a implementar la prueba funcional que verifica el acceso al método Users#show
.
# ...
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:
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:
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
:
# ...
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:
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
.
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:
# ...
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
:
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:
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:
# ...
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:
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
:
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:
-
La petición del cliente para el recurso
sessions
con las correspondientes credenciales (usualmente email y password) -
El server regresa el recurso
user
junto con su correspondiente token de autenticación -
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:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Una ves decodificado, este token nos da la siguiente información:
{ "alg": "HS256", "typ": "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.
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 claseHashWithIndifferentAccess
proveída por Rails la cual nos permite recuperar un valor de unHash
con unSymbol
ó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
:
# ...
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:
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:
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
:
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.
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:
-
Siempre filtramos los parámetros con el método
user_params
. -
Recuperamos el usuario con el método
User.find_by_email
(que es un método "mágico" de Active Record mientras el campoemail
esté presente en la base de datos) y recuperamos el usuario -
Usamos el método
User#authenticate
(el cual existe gracias a la gemabcrypt
) con la contraseña como un parámetro. Bcrypt hará un hash de la contraseña y verifica si coincide con el atributopassword_digest
. La función regresatrue
si todo salió bien,false
si no. -
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 cabeceraunauthorized
¿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:
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:
# ...
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:
# ...
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
# ...
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:
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
:
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.
# ...
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:
# ...
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:
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 usuarioComo 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í:
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:
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:
# ...
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.
# ...
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.
# ...
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:
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.
# ...
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:
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
:
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:
# ...
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:
class Api::V1::ProductsController < ApplicationController
def index
render json: Product.all
end
#...
end
No olvides añadir la ruta correspondiente:
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
:
# ...
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.
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
:
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:
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:
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:
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:
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:
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:
# ...
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:
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:
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.
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:
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:
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:
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:
# ...
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:
class ProductSerializer
include FastJsonapi::ObjectSerializer
attributes :title, :price, :published
end
Ahí está. No es tan complicado. Cambiemos nuestro controlador un poco.
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:
# ...
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.
class User < ApplicationRecord
has_many :products, dependent: :destroy
# ...
end
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:
# ...
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:
-
este contiene el título del producto
-
este contiene el ID del usuario ligado al producto
-
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:
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:
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:
# ...
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:
class UserSerializer
include FastJsonapi::ObjectSerializer
attributes :email
has_many :products
end
Y para finalizar el controlador:
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:
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:
# ...
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.
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
:
# ...
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:
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:
# ...
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.
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:
# ...
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:
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:
# ...
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.
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:
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`:
# ...
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:
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:
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:
class Product < ApplicationRecord
belongs_to :user
has_many :placements, dependent: :destroy
has_many :orders, through: :placements
# ...
end
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.
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á:
-
Una acción de indexación para recuperar las ordenes de usuario actuales
-
Una acción show para recuperar un comando particular desde el usuario actual
-
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:
# ...
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:
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:
class OrderSerializer
include FastJsonapi::ObjectSerializer
belongs_to :user
has_many :products
end
Ahora es tiempo de implementar el controlador:
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:
# ...
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:
# ...
Rails.application.routes.draw do
# ...
resources :orders, only: %i[index show]
# ...
end
Y la implementación debería lucir como esto:
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:
# ...
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:
# ...
Rails.application.routes.draw do
# ...
resources :orders, only: %i[index show create]
# ...
end
Entonces la implementación es fácil:
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:
# ...
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:
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.
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:
# ...
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
:
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:
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:
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).
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:
# ...
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:
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
:
# ...
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
:
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í:
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:
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:
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
.
# ...
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:
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.
# ...
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:
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:
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:
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:
# ...
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:
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:
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-:
# ...
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
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.
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
:
# ...
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:
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:
# ...
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
:
# ...
class Api::V1::OrdersControllerTest < ActionDispatch::IntegrationTest
# ...
test 'should show orders' do
# ...
assert_json_response_is_paginated json_response
end
# ...
end
# ...
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.
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í:
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
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!
class OrderSerializer
# ...
cache_options enabled: true, cache_length: 12.hours
end
class ProductSerializer
# ...
cache_options enabled: true, cache_length: 12.hours
end
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ónSELECT "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:
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:
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:
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
.
# ...
# 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.
# ...
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 :).