diff --git a/README.md b/README.md index 2de1eb7..9bca20a 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ As you may know this project take me some times. So if you want to support me yo - Rails 6 - [English version](https://leanpub.com/apionrails6/) - [French version](https://leanpub.com/apionrails6-fr) + - [Spanish version](https://leanpub.com/apionrails6-es) Or you can support me with Liberapay: @@ -49,3 +50,7 @@ rake build:pdf[version,lang] # Build a PDF version ## License This book is under [MIT license](https://opensource.org/licenses/MIT) and [Creative Common BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) + +## Contributors + +- [Oscar Téllez](https://github.com/oscartzgz) (spanish translation) \ No newline at end of file diff --git a/Rakefile b/Rakefile index 0bc5caf..71e8100 100644 --- a/Rakefile +++ b/Rakefile @@ -1,7 +1,7 @@ require 'asciidoctor' require 'asciidoctor-pdf' -LANGS = %w[en fr].freeze +LANGS = %w[en fr es].freeze VERSIONS = %w[5 6].freeze OUTPUT_DIR = File.join __dir__, 'build' THEMES_DIR = File.join __dir__, 'themes' diff --git a/rails6/es/api_on_rails.adoc b/rails6/es/api_on_rails.adoc new file mode 100644 index 0000000..0b1f013 --- /dev/null +++ b/rails6/es/api_on_rails.adoc @@ -0,0 +1,55 @@ += API on Rails 6 +Alexandre Rousseau +v6.0.5, 2020-01-09 +:doctype: book +:toc: +:imagesdir: img +:title-logo-image: image:logo.svg[] +:homepage: https://github.com/madeindjs/api_on_rails/ +:source-highlighter: rouge +// epub tags +:copyright: CC-BY-SA 4.0, MIT +:keywords: Rails, API, Ruby, Software +:lang: es +:author: Alexandre Rousseau, Oscar Téllez +:description: Aprende las mejores prácticas para construir una API usando Ruby on Rails 6 +:front-cover-image: image:cover.svg[] +:revdate: 2020-01-09 + +include::chapter00-before.adoc[] + +<<< + +include::chapter01-introduction.adoc[] + +<<< + +include::chapter02-api.adoc[] + +<<< + +include::chapter03-presenting-users.adoc[] + +<<< + +include::chapter04-athentification.adoc[] + +<<< + +include::chapter05-user-products.adoc[] + +<<< + +include::chapter06-improve-json.adoc[] + +<<< + +include::chapter07-placing-orders.adoc[] + +<<< + +include::chapter08-improve-orders.adoc[] + +<<< + +include::chapter09-optimization.adoc[] diff --git a/rails6/es/chapter00-before.adoc b/rails6/es/chapter00-before.adoc new file mode 100644 index 0000000..638f50a --- /dev/null +++ b/rails6/es/chapter00-before.adoc @@ -0,0 +1,46 @@ +[#chapter00-before] += Antes + +== Prefacio + +"API on Rails 6" está basado en http://apionrails.icalialabs.com/book/["APIs on Rails: Building REST APIs with Rails"]. Fue publicado inicialmente en 2014 por https://twitter.com/kurenn[Abraham Kuri] bajo la licencia http://opensource.org/licenses/MIT[MIT] y http://people.freebsd.org/~phk/[Beerware]. + +La primera versión no es mantenida y fue planeada para Ruby on Rails 4 la cual no https://guides.rubyonrails.org/maintenance_policy.html#security-issues[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 http://rousseau-alexandre.fr[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 (https://isignif.fr[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 https://rubygems.org/profiles/madeindjs[my Rubygems.org profile]. La mayoría de mis proyectos están en GitHub así que no dudes en http://github.com/madeindjs/[seguirme]. + +Todo el código fuente de este libro está en formato https://asciidoctor.org/[Asciidoctor] disponible en https://github.com/madeindjs/api_on_rails[GitHub]. Por lo tanto, siéntete libre de hacer un https://github.com/madeindjs/api_on_rails/fork[fork] al proyecto si quieres mejorarlo o corregir errores que no noté. + +== Derechos de autor y licencia + +Este libro está bajo la http://opensource.org/licenses/MIT[licencia MIT]. Todo el código fuente del libro está en el formato https://fr.wikipedia.org/wiki/Markdown[Markdown] disponible en https://github.com/madeindjs/api_on_rails[GitHub] + +.Licencia MIT +**** +Copyright 2019 Alexandre Rousseau + +Por la presente se concede permiso, libre de cargos, a cualquier persona que obtenga una copia de este software y de los archivos de documentación asociados (el "Software"), a utilizar el Software sin restricción, incluyendo sin limitación los derechos a usar, copiar, modificar, fusionar, publicar, distribuir, sublicenciar, y/o vender copias del Software, y a permitir a las personas a las que se les proporcione el Software a hacer lo mismo, sujeto a las siguientes condiciones: + +El aviso de copyright anterior y este aviso de permiso se incluirán en todas las copias o partes sustanciales del Software. +EL SOFTWARE SE PROPORCIONA "COMO ESTÁ", SIN GARANTÍA DE NINGÚN TIPO, EXPRESA O IMPLÍCITA, INCLUYENDO, PERO NO LIMITADO A GARANTÍAS DE COMERCIALIZACIÓN, IDONEIDAD PARA UN PROPÓSITO PARTICULAR E INCUMPLIMIENTO. EN NINGÚN CASO LOS AUTORES O PROPIETARIOS DE LOS DERECHOS DE AUTOR SERÁN RESPONSABLES DE NINGUNA RECLAMACIÓN, DAÑOS U OTRAS RESPONSABILIDADES, YA SEA EN UNA ACCIÓN DE CONTRATO, AGRAVIO O CUALQUIER OTRO MOTIVO, DERIVADAS DE, FUERA DE O EN CONEXIÓN CON EL SOFTWARE O SU USO U OTRO TIPO DE ACCIONES EN EL SOFTWARE. +**** + +"API on Rails 6" por https://github.com/madeindjs/api_on_rails[Alexandre Rousseau] es compartido de acuerdo a http://creativecommons.org/licenses/by-sa/4.0/[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 https://unsplash.com/@siloine?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText[Yoann Siloine] quien publicó en https://unsplash.com[Unsplash]. + +== Agradecimientos + +Un gran "gracias" a todos los contribuidores de GitHub quienes mantienen este libro vivo. En orden alfabético: + +* https://github.com/airdry[airdry] +* https://github.com/Landris18[Landris18] +* https://github.com/lex111[lex111] +* https://github.com/cuilei5205189[cuilei5205189] +* https://github.com/franklinjosmell[franklinjosmell] +* https://github.com/notapatch[notapatch] +* https://github.com/tacataca[tacataca] diff --git a/rails6/es/chapter01-introduction.adoc b/rails6/es/chapter01-introduction.adoc new file mode 100644 index 0000000..3e43236 --- /dev/null +++ b/rails6/es/chapter01-introduction.adoc @@ -0,0 +1,248 @@ +[#chapter01-introduction] += Introducción + +Bienvenido a API on Rails 6, un tutorial con esteroides para enseñarte el mejor camino para construir tú siguiente API con Rails. El propósito de este libro es proveer una metodología comprensiva para desarrollar una API RESTful siguiendo las mejores prácticas. + +Al finalizar este libro, tu podrás crear tu propia API e integrarla con cualquier cliente como un navegador web o aplicación móvil. El código generado esta codeado con Ruby on Rails 6.0 que es la versión actual. + +El propósito de este libro no es solamente enseñarte como construir un API con Rails sino mucho mejor enseñarte como construir una API *evolutiva* y *mantenible* con Rails. Esto es, mejorar tu conocimiento actual con Rails. En esta sección, aprenderás a: + +- Usar Git para control de versiones +- Construir respuestas JSON +- Probar tus end-points con pruebas unitarias y funcionales +- Configurar autenticación con JSON Web Tokens (JWT) +- Usar la especificación JSON:API +- Optimizar y hacer cache de la API + +Recomiendo enérgicamente que sigas todos los pasos en este libro. Intenta no saltarte capítulos porque doy algunos tips y trucos para improvisar tus habilidades a través del libro. Puedes considerarte a ti mismo el personaje principal de un videojuego que gana un nivel en cada capítulo. + + +En este primer capítulo explicaré como configurar tu entorno de desarrollo (en caso que aún no lo sepas). Luego vamos a crear una aplicación llamada `market_place_api`. Me aseguraré que te enseño las mejores practicas que he aprendido durante mi experiencia. Esto significa que vamos a iniciar usando *Git* justo después de inicializar el proyecto. + +Vamos a crear la aplicación siguiendo un método simple de trabajo que usé a diario en los siguientes capítulos. Vamos a desarrollar una aplicación completa usando Test Driven Development(TDD). También explicaré el interés de usar una API para tu siguiente proyecto y eligiendo un adecuado formato de respuesta como JSON o XML. Mas allá, vamos a tener nuestras manos sobre el código y completar lo básico de la aplicación construyendo todos los caminos necesarios. También vamos a implementar acceso seguro a la API implementando autenticación por intercambio de cabeceras HTTP. Finalmente, en el último capítulo, vamos a añadir técnicas de optimización para mejorar la estructura y tiempos de respuesta del servidor. + +La aplicación final rozará la superficie de iniciar una tienda donde los usuario pueden realizar ordenes, subir productos y más. Hay muchas opciones allá afuera para echar a andar una tienda en linea, como http://shopify.com[Shopify], http://spreecommerce.com/[Spree] o http://magento.com[Magento]. + + +== Convenciones en este libro + +Las convenciones en este libro están basadas en este http://www.railstutorial.org/book/beginning#sec-conventions[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: + +[source,bash] +---- +$ 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 mailto:contact@rousseau-alexandre.fr[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 http://www.aptana.com/products/radrails[RadRails] o http://www.jetbrains.com/ruby/index.html[RubyMine], ambos están bien soportados y vienen con muchas integraciones 'out of the box'. + +*Editor de texto*: En lo personal uso http://www.vim.org/[vim] como mi editor por defecto con https://github.com/carlhuda/janus[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 http://www.sublimetext.com/[Sublime Text] que es multi plataforma, fácil de aprender y personalizable (este es probablemente tú mejor opción), esta altamente inspirado por http://macromates.com/[TextMate] (solo disponible para Mac OS). Una tercera opción es usando un muy reciente editor de texto de los chicos de http://gitub.com[GitHub] llamado https://atom.io/[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 http://icalialabs.github.io/kaishi/[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 http://www.iterm2.com/#/section/home[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 http://www.mozilla.org/en-US/firefox/new/[Firefox] inmediatamente, pero algunos otros desarrolladores pueden decir https://www.google.com/intl/en/chrome/browser/[Chrome] o incluso https://www.apple.com/safari/[Safari]. Cualquiera de ellos ayudara a construir la aplicación que buscas, ellos vienen con un buen inspector no justamente para el DOM pero para el análisis de red y muchas otras características que ya conoces. + +=== Manejador de paquetes + +* *Mac OS*: Hay muchas opciones para gestionar o instalar tus paquetes en tu Mac, como el https://www.macports.org/[Mac Ports] ó http://brew.sh/[Homebrew], ambos son buenas opciones pero yo elegiría la última, he encontrado menos problemas cuando instalo software y lo administro. Para instalar `brew` solo ejecuta en la consola lo siguiente: + +[source,bash] +---- +$ /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: + +[source,bash] +---- +$ ruby -v +---- + +Rails 6.0 requiere la instalación de la versión 2.5 o mayor. + +Yo recomiendo usar http://rvm.io/[Ruby Version Manager (RVM)] ó http://rbenv.org/[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: + +[source,bash] +---- +$ gpg --keyserver hkp://keys.gnupg.net --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB +$ \curl -sSL https://get.rvm.io | bash +---- + +Ahora instalaremos ruby: + +[source,bash] +---- +$ 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: + +[source,bash] +---- +$ gem update --system +---- + +En algunos casos si estas en Mac OS, necesitarás instalar algunas librerías extras: + +[source,bash] +---- +$ brew install libtool libxslt libksba openssl +---- + +Luego instalamos las gemas necesarias e ignoramos la documentación para cada una: + +[source,bash] +---- +$ gem install bundler +$ gem install rails -v 6.0.0 +---- + +Revisamos que todo funciona correctamente: + +[source,bash] +---- +$ rails -v +Rails 6.0.0 +---- + +==== Base de datos + +Recomiendo mucho que instales http://www.postgresql.org/[Postgresql] para gestionar tus bases de datos. Pero aquí usaremos http://www.sqlite.org/[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: + +[source,bash] +---- +$ sudo apt-get install libxslt-dev libxml2-dev libsqlite3-dev +---- + +ó + +[source,bash] +---- +$ 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: + +[source,bash] +---- +$ 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 https://github.com/madeindjs/api_on_rails_6[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: + +[source,bash] +---- +$ git config --global user.name "Aquí pon tu nombre" +$ git config --global user.email "Aquí pon tu email" +---- + +Rails también provee un archivo _.gitignore_ para ignorar algunos archivos a los que no queramos dar seguimiento. El archivo _.gitignore_ por defecto puede lucir como se ve a continuación: + +..gitignore +---- +# Ignore bundler config. +/.bundle + +# Ignore the default SQLite database. +/db/*.sqlite3 +/db/*.sqlite3-journal + +# Ignore all logfiles and tempfiles. +/log/* +/tmp/* +!/log/.keep +!/tmp/.keep + +# Ignore uploaded files in development. +/storage/* +!/storage/.keep +.byebug_history + +# Ignore master key for decrypting credentials and more. +/config/master.key +---- + +Después de modificar el archivo _.gitignore_ únicamente necesitamos añadir los archivos y hacer _commit_ de los cambios, para ello usamos los siguientes comandos: + +[source,bash] +---- +$ 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_: + +[source,bash] +---- +$ git remote add origin git@github.com:madeindjs/market_place_api_6.git +---- + +Entonces hacemos _push_(empujamos) el código: + +[source,bash] +---- +$ 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! diff --git a/rails6/es/chapter02-api.adoc b/rails6/es/chapter02-api.adoc new file mode 100644 index 0000000..cf31868 --- /dev/null +++ b/rails6/es/chapter02-api.adoc @@ -0,0 +1,218 @@ +[#chapter02-api] += 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: + +[source,bash] +---- +$ 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. + +image:data_model.png[Esquema conexiones entre los modelos] + +Resumiendo, el `user`(usuario) podrá realizar muchas `orders`(ordenes/pedidos), subir múltiples `products`(productos) los cuales pueden tener muchas `images`(imágenes) ó `comments`(comentarios) de otros usuarios de la aplicación. + +No construiremos vistas para mostrar o interactuar con la API, así que no hagas de esto un gran tutorial. Para ello hay muchas opciones allá afuera como los frameworks de javascript (https://angularjs.org/[Angular], https://vuejs.org/[Vue.js], https://reactjs.org/[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 https://www.getpostman.com/[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 http://en.wikipedia.org/wiki/Application_programming_interface[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 (https://angularjs.org/[Angular] ó https://vuejs.org/[Vue.js] por ejemplo). También hay grandes bibliotecas para Objetive-C como https://github.com/AFNetworking/AFNetworking[AFNetworking] ó http://restkit.org/[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 http://www.w3.org/2005/Incubator/wcl/matching.html[patrón URI] suficientemente claro para saber que recurso está expuesto. El protocolo o estructura del que estoy hablando es http://en.wikipedia.org/wiki/Representational_state_transfer[REST] que significa Transferencia de Estado Representacional(Representational state transfer) según la definición de Wikipedia. + +[source,soap] +---- +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: + +La APIs RESTful debe seguir al menos tres simples pautas: + +* Una base http://en.wikipedia.org/wiki/Uniform_resource_identifier[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 http://en.wikipedia.org/wiki/HTTP_method#Request_methods[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: + +[source,bash] +---- +$ 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. + +[source,ruby] +.config/routes.rb +---- +Rails.application.routes.draw do + # ... +end +---- + +Primero que todo borra todo el código comentado que viene en el archivo, no lo vamos a necesitar. Entonces haz un commit, solo como un calentamiento: + +[source,bash] +---- +$ 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. + +[source,bash] +---- +$ mkdir app/controllers/api +---- + +Entonces agregamos el nombre de espacio dentro de nuestro archivo _routes.rb_: + +[source,ruby] +.config/routes.rb +---- +Rails.application.routes.draw do + # Api definition + namespace :api do + # We are going to list our resources here + end +end +---- + +Por definición un espacio de nombres en el archivo `routes.rb`. Rails automáticamente mapeara que espacio de nombres corresponde al folder de los _controlladores_, en nuestro caso el directorio `api/``. + +.Archivos multimedia soportados por Rails +**** +Rails soporta 35 tipos diferentes de archivos multimedia, puedes listarlos accediendo a la clase SET del módulo Mime: + +[source,bash] +---- +$ rails c +2.6.3 :001 > Mime::SET.collect(&:to_s) + => ["text/html", "text/plain", "text/javascript", "text/css", "text/calendar", "text/csv", "text/vcard", "text/vtt", "image/png", "image/jpeg", "image/gif", "image/bmp", "image/tiff", "image/svg+xml", "video/mpeg", "audio/mpeg", "audio/ogg", "audio/aac", "video/webm", "video/mp4", "font/otf", "font/ttf", "font/woff", "font/woff2", "application/xml", "application/rss+xml", "application/atom+xml", "application/x-yaml", "multipart/form-data", "application/x-www-form-urlencoded", "application/json", "application/pdf", "application/zip", "application/gzip"] +---- +**** + +Esto es importante porque vamos a trabajar con JSON, uno de los http://en.wikipedia.org/wiki/Internet_media_type[tipos MIME] aceptados por Rails, solo necesitamos especificar que este es el formato por defecto: + +[source,ruby] +.config/routes.rb +---- +Rails.application.routes.draw do + # Api definition + namespace :api, defaults: { format: :json } do + # We are going to list our resources here + end +end +---- + +Hasta este punto no hemos hecho nada loco. Ahora lo que queremos es una _base_uri_ que incluye la versión de la API. Pero hagamos commit antes de ir a la siguiente sección: + +[source,bash] +---- +$ 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: + +[source,ruby] +.config/routes.rb +---- +Rails.application.routes.draw do + # Api definition + namespace :api, defaults: { format: :json } do + # We are going to list our resources here + end +end +---- + +Ahora es tiempo de configurar algunas otras restricciones para propósitos de versionado. Deberías preocuparte por versionar tú aplicación desde el inicio pues le dará una mejor estructura a tu api, y cuando hagas cambios, puedes dar a los desarrolladores que están consumiendo tu api la oportunidad de adaptar las nuevas características mientras las viejas quedan obsoletas. Este es un excelente http://railscasts.com/episodes/350-rest-api-versioning[railscast] explicando esto. + +Para establecer la versión del API, primero necesitamos agregar otro directorio en el de `api` que antes creamos: + +[source,bash] +---- +$ 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`: + +[source,ruby] +.config/routes.rb +---- +Rails.application.routes.draw do + # Api definition + namespace :api, defaults: { format: :json } do + namespace :v1 do + # We are going to list our resources here + end + end +end +---- + +Hasta este punto, el API puede ser alcanzada a través de la URL. Por ejemplo con esta configuración un end-point para recuperar un producto podría ser algo como: . + + +.Patrones Comunes del API +**** +Puedes encontrar muchas formas de configurar un _base_uri_ cuando construimos un api siguiendo diferentes patrones, asumiendo que estamos versionando nuestra api: + +* `api.example.com/`: En mi opinión este es el camino a seguir, te da una mejor interfaz y aislamiento, y a largo plazo puede ayudarte a http://www.makeuseof.com/tag/optimize-your-dns-for-faster-internet/[escalar rápidamente] +* `example.com/api/`: Este patrón es muy común, y es actualmente un buen camino a seguir cuando no quieres poner bajo espacio de nombres tu api en un subdominio +* `example.com/api/v1`: parece buena idea, poniendo la versión del api mediante la URL, parece como un patrón descriptivo, pero esta forma te forza a incluir la URL en cada petición, así que si en algún momento decides cambiar este patrón, se convierte en un problema de mantenimiento a largo plazo. + +Estas son algunas prácticas en la construcción de una API que recomiendan no versionar el API a través de la URL. Es verdad. El desarrollador no debería conocer la versión que está usando. En términos de simplicidad, he decidido dejar esta convención, que podremos aplicar en una segunda fase. +**** + +Es tiempo de hacer _commit_: + +[source,bash] +---- +$ 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`: + +[source,bash] +---- +$ 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: + +* https://github.com/Sutto/rocket_pants[RocketPants] +* https://github.com/bploetz/versionist[Versionist] + +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á https://github.com/madeindjs/market_place_api_6/releases/tag/checkpoint_chapter03[aquí]. diff --git a/rails6/es/chapter03-presenting-users.adoc b/rails6/es/chapter03-presenting-users.adoc new file mode 100644 index 0000000..6a325cc --- /dev/null +++ b/rails6/es/chapter03-presenting-users.adoc @@ -0,0 +1,700 @@ +[#chapter03-presenting-users] += 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: + +[source,bash] +---- +$ git checkout tags/checkpoint_chapter03 +---- + +Como ya estarás imaginando hay muchas soluciones de autenticación para Rails, https://github.com/binarylogic/authlogic[AuthLogic], https://github.com/thoughtbot/clearance[Clearance] y https://github.com/plataformatec/devise[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 https://github.com/codahale/bcrypt-ruby[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: + +[source,bash] +---- +$ 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: + +[source,bash] +---- +$ rails generate model User email:string password_digest:string +invoke active_record + create db/migrate/20190603195146_create_users.rb + create app/models/user.rb + invoke test_unit + create test/models/user_test.rb + create test/fixtures/users.yml +---- + +NOTE: El _modelo_ es el elemento que contiene la información o datos así como la lógica relacionada a esa información: validación, lectura y guardado. + +¡Este comando genera un montón de archivos! No te preocupes revisaremos uno por uno. + +El archivo de migración contenido en el forder `db/migrate` contiene la *migración* que describe los cambios que realizará en la base de datos. Este archivo puede lucir así: + +.db/migrate/20190603195146_create_users.rb +[source,ruby] +---- +class CreateUsers < ActiveRecord::Migration[6.0] + def change + create_table :users do |t| + t.string :email + t.string :password_digest + + t.timestamps + end + end +end +---- + +NOTE: La fecha insertada al inicio del nombre del archivo de migración debiera ser diferente para ti ya que corresponde a la fecha de creación de la migración. + +Haremos un pequeño cambio a la migración a fin de añadir algunas validaciones a la base de datos. Con rails es una práctica común hacer validaciones directamente en el modelo Ruby. Es buena práctica hacer algo en el esquema de la base de datos. + +Por lo tanto haremos dos restricciones adicionales: + +- email es forzoso: usaremos la propiedad `null: false`. +- email debe ser único: añadiremos un índice para la columna email con la propiedad `unique: true`. +- password es forzoso: usamos la propiedad `null: false`. + +La migración quedaría así: + +.db/migrate/20190603195146_create_users.rb +[source,ruby] +---- +# ... +create_table :users do |t| + t.string :email, null: false + t.index :email, unique: true + t.string :password_digest, null: false + # ... +end +---- + +Una vez completa la migración, podemos correr los cambios con el siguiente comando: + +.db/migrate/20190603195146_create_users.rb +[source,ruby] +---- +$ 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 https://guides.rubyonrails.org/active_record_validations.html[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: + +.app/models/user.rb +[source,ruby] +---- +class User < ApplicationRecord + validates :email, uniqueness: true + validates_format_of :email, with: /@/ + validates :password_digest, presence: true +end +---- + +Ahí tienes. Rails una sintaxis simple y el código es muy legible. + +.Validación del Email +**** +Habrás notado que la validación del email es muy simplista solo validando la presencia de una `@`. + +Es normal. + +Hay infinidad de excepciones en la dirección de un correo electrónico https://davidcel.is/posts/stop-validating-email-addresses-with-regex/[que incluso `Mira todos estos espacios!@example.com` es una dirección de correo valida]. Por lo tanto, es mejor para favorecer un enfoque sencillo y confirmar la dirección de correo enviando un email. +**** + +==== Pruebas unitarias + +Finalizamos con las pruebas unitarias. Aquí usaremos Minitest un framework de pruebas que es proporcionado por defecto con Rails. + +Minitest está basado en _Fixtures_ que te permiten llenar tu base de datos con datos predefinidos*. Los _Fixtures_ están definidos en un archivo YAML en el directorio `tests/fixtures`. Hay un archivo por plantilla. + + +Debemos por lo tanto iniciar actualizando nuestros `tests/fixtures`. + +NOTE: _fixtures_ no están diseñados para crear todas los datos que tus pruebas necesitan. Solo te permiten definir los datos básicos que tu aplicación necesita. + +Así que comenzamos por crear un _fixture_ definiendo un usuario: + +.test/fixtures/users.yml +[source,yaml] +---- +one: + email: one@one.org + password_digest: hashed_password +---- + +Ahora podemos crear tres pruebas: + +- 1. Verifica que un usuario con datos correctos es válido: + +.test/models/user_test.rb +[source,ruby] +---- +# ... +test 'user with a valid email should be valid' do + user = User.new(email: 'test@test.org', password_digest: 'test') + assert user.valid? +end +---- + +- 2. Verifica que un usuario con un email erróneo no es válido: + +.test/models/user_test.rb +[source,ruby] +---- +# ... +test 'user with invalid email should be invalid' do + user = User.new(email: 'test', password_digest: 'test') + assert_not user.valid? +end +---- + +- 3. Verifica que un nuevo usuario con email no es válido. Así que usamos el mismo email que creamos en el _fixture_. + +.test/models/user_test.rb +[source,ruby] +---- +# ... +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: + +[source,bash] +---- +$ 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: + +[source,bash] +---- +$ 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. - https://github.com/codahale/bcrypt-ruby#why-you-should-use-bcrypt[fuente - Porque deberías usar bcrypt(en inglés)] + +Así que vamos a usar la gema bcrypt para *hashear* la contraseña. + +NOTE: Hashear es el proceso de transformar un arreglo de caracteres en un _Hash_. Este _Hash_ no te permite encontrar el arreglo de caracteres original. Pero como sea, podemos fácilmente usarlo para encontrar si un arreglo de caracteres dado coincide con el _hash_ que almacenamos. + +Primero debemos agregar la gema Bcrypt al _Gemfile_. Podemos usar el comando `bundle add`. Que hará: + +1. añadir la gema al Gemfile recuperando la versión más reciente +2. ejecutar el comando `bundle install` el cual instalará la gema y actualizará el archivo _Gemfile.lock_ "bloqueando" la versión actual de la gema + +Por lo tanto, ejecutamos el siguiente comando: + +[source,bash] +---- +$ bundle add bcrypt +---- + +Una vez que el comando es ejecutado, la siguiente línea es añadida al final del _Gemfile_: + +[source,ruby] +.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 https://github.com/rails/rails/blob/6-0-stable/activemodel/lib/active_model/secure_password.rb#L61[`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. + +[source,ruby] +.app/models/user.rb +---- +class User < ApplicationRecord + # ... + has_secure_password +end +---- + +`has_secure_password` agrega las siguientes validaciones: + +* La contraseña debe estar presente en la creación. +* La longitud de la contraseña debe ser menor o igual a 72 bytes. +* La confirmación de la contraseña usa el atributo `password_confirmation` (si es enviado) + +En adición, este método añadirá un atributo `User#password` que será automáticamente hasheado y guardado en el atributo `User#password_digest`. + +Vamos a intentarlo ahora mismo en la consola de Rails. Abre una consola con `rails console`: + +[source,ruby] +---- +2.6.3 :001 > User.create! email: 'toto@toto.org', password: '123456' + =># +---- + +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`: + +[source,ruby] +---- +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: + +[source,bash] +---- +$ git commit -am "Setup Bcrypt" +---- + +== Creando usuarios + +Es tiempo de hacer nuestro primer "entry point". Iniciaremos por construir la acción `show` que responderá con información de un usuario único en formato JSON. Los pasos son: + +1. generar el controlador `users_controller`. +2. añadir las pruebas correspondientes +3. construir el código real. + +Vamos a enfocarnos primero en generar el controlador y las pruebas funcionales. + +En orden para respetar la vista de nuestra API, vamos a cortar nuestra aplicación usando *modules* (módulos). La sintaxis por lo tanto es la siguiente: + +[source,bash] +---- +$ rails generate controller api::v1::users +---- + +Este comando creará el archivo `users_controller_test.rb`. Antes de ir más lejos hay dos cosas que queremos probar en nuestra API: + +* La estructura JSON que devuelve el servidor +* El código de la respuesta HTTP que devuelve el servidor + +.Códigos HTTP más comunes +**** +El primer dígito de el código de estado especifica una de las 5 clases de respuesta. El mínimo indispensable para un cliente HTTP es que este una de estas 5 clases. Esta es una lista de los códigos HTTP comúnmente usados: + +* `200`: Respuesta estándar para una solicitud HTTP exitosa. Usualmente en solicitudes `GET` +* `201`: La petición fue recibida y resulta en la creación del nuevo recurso. Después de una solicitud `POST` +* `204`: El servidor tiene una petición procesada con éxito, pero no se regresó ningún contenido. Esto es usual en una solicitud `DELETE` exitosa. +* `400`: La petición no se puede ejecutar debido a una sintaxis incorrecta. Puede suceder para cualquier tipo de solicitud. +* 401: Similar al 403, pero especialmente usada al solicitar autenticación y ha fallado o aún no se ha proporcionado. Puede suceder en cualquier tipo de solicitud. +* `404`: El recurso solicitado no fue encontrado, pero podría estar disponible en el futuro. Usualmente concierne a la petición `GET`. +* 500: Un mensaje de error genérico, dado cuando una condición inesperada ha sido encontrada y ningún otro mensaje especifico es apropiado. + +Para una lista completa de códigos HTTP, mira este https://en.wikipedia.org/wiki/List_of_HTTP_status_codes[articulo de Wikipedia (en inglés)]. +**** + +Por lo tanto, vamos a implementar la prueba funcional que verifica el acceso al método `Users#show`. + + +[source,ruby] +.test/controllers/api/v1/users_controller_test.rb +---- +# ... +class Api::V1::UsersControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:one) + end + + test "should show user" do + get api_v1_user_url(@user), as: :json + assert_response :success + # Test to ensure response contains the correct email + json_response = JSON.parse(self.response.body) + assert_equal @user.email, json_response['email'] + end +end +---- + + +Entonces simplemente agrega la acción a tu controlador. Es extremadamente simple: + +[source,ruby] +.app/controllers/api/v1/users_controller.rb +---- +class Api::V1::UsersController < ApplicationController + # GET /users/1 + def show + render json: User.find(params[:id]) + end +end +---- + +Si corres la prueba con `rails test` obtienes el siguiente error: + +[source,bash] +---- +$ rails test + +...E + +Error: +UsersControllerTest#test_should_show_user: +DRb::DRbRemoteError: undefined method \`api_v1_user_url' for # (NoMethodError) + test/controllers/users_controller_test.rb:9:in `block in ' +---- + +¡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: + +[source,ruby] +.config/routes.rb +---- +Rails.application.routes.draw do + namespace :api, defaults: { format: :json } do + namespace :v1 do + resources :users, only: [:show] + end + end +end +---- + +Las pruebas ahora deberían pasar: + +---- +$ rails test +.... +4 runs, 5 assertions, 0 failures, 0 errors, 0 skips +---- + +Como siempre, después de añadir una característica que nos satisface, vamos a hacer un _commit_: + +[source,bash] +---- +$ 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. +[source,bash] +---- +$ rails s +---- + +Entonces cambia de nuevo a tu otra terminal y corre: + +[source,bash] +---- +$ 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`: + +[source,ruby] +.test/controllers/users_controller_test.rb +---- +# ... +class Api::V1::UsersControllerTest < ActionDispatch::IntegrationTest + # ... + test "should create user" do + assert_difference('User.count') do + post api_v1_users_url, params: { user: { email: 'test@test.org', password: '123456' } }, as: :json + end + assert_response :created + end + + test "should not create user with taken email" do + assert_no_difference('User.count') do + post api_v1_users_url, params: { user: { email: @user.email, password: '123456' } }, as: :json + end + assert_response :unprocessable_entity + end +end +---- + +Es un montón de código. No te preocupes explicare todo: + +* En el primer test revisamos la creación de un usuario enviando una petición POST valida. Entonces, revisamos que un usuario adicional ahora existe en la base de datos y que el código HTTP de respuesta es `created` (código de estado 201) +* En el segundo test revisamos que el usuario no es creado usando una dirección de correo que ya está en uso. Entonces, revisamos que el código HTTP de respuesta es `unprocessable_entity` (código de estado 422) + +Hasta este punto, la prueba debería de fallar (como esperábamos): + +[source,bash] +---- +$ rails test +...E +---- + +Asi que es tiempo de implementar el código para que nuestra prueba sea exitosa: + +[source,ruby] +.app/controllers/api/v1/users_controller.rb +---- +class Api::V1::UsersController < ApplicationController + # ... + + # POST /users + def create + @user = User.new(user_params) + + if @user.save + render json: @user, status: :created + else + render json: @user.errors, status: :unprocessable_entity + end + end + + private + + # Only allow a trusted parameter "white list" through. + def user_params + params.require(:user).permit(:email, :password) + end +end +---- + +Recuerda que cada vez que agregamos una entrada en nuestra API debemos agregar esta acción en nuestro archivo `routes.rb`. + +[source,ruby] +.config/routes.rb +---- +Rails.application.routes.draw do + namespace :api, defaults: { format: :json } do + namespace :v1 do + resources :users, only: %i[show create] + end + end +end +---- + +Como puedes ver, la implementación es bastante simple. También hemos añadido el método privado `user_params` para proteger de la asignación masiva de atributos. Ahora nuestra prueba debería de pasar: + +[source,bash] +---- +$ 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: + +[source,bash] +---- +$ 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: + +[source,ruby] +.test/controllers/users_controller_test.rb +---- +# ... +class Api::V1::UsersControllerTest < ActionDispatch::IntegrationTest + # ... + test "should update user" do + patch api_v1_user_url(@user), params: { user: { email: @user.email, password: '123456' } }, as: :json + assert_response :success + end + + test "should not update user when invalid params are sent" do + patch api_v1_user_url(@user), params: { user: { email: 'bad_email', password: '123456' } }, as: :json + assert_response :unprocessable_entity + end +end +---- + +Para que la prueba se exitosa, debemos construir la acción update en el archivo `users_controller.rb` y agregar la ruta al archivo `routes.rb`. Como puedes ver, tenemos mucho código duplicado, vamos a rediseñar nuestra prueba en el capítulo 4. Primero añadimos la acción al archivo `routes.rb`: + +[source,ruby] +.config/routes.rb +---- +Rails.application.routes.draw do + # ... + resources :users, only: %i[show create update] + # ... +end +---- + +Entonces implementamos la acción update en el controlador del usuario y corremos las pruebas: + +[source,ruby] +.app/controllers/api/v1/users_controller.rb +---- +class Api::V1::UsersController < ApplicationController + before_action :set_user, only: %i[show update] + + # GET /users/1 + def show + render json: @user + end + + # ... + + # PATCH/PUT /users/1 + def update + if @user.update(user_params) + render json: @user, status: :ok + else + render json: @user.errors, status: :unprocessable_entity + end + end + + private + # ... + + def set_user + @user = User.find(params[:id]) + end +end + +---- + +Todas nuestras pruebas deberían pasar: + +[source,bash] +---- +$ rails test +........ +8 runs, 11 assertions, 0 failures, 0 errors, 0 skips +---- + +Hacemos un _commit_ ya que todo funciona: + +[source,bash] +---- +$ 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: + +[source,ruby] +.test/controllers/users_controller_test.rb +---- +# ... +class Api::V1::UsersControllerTest < ActionDispatch::IntegrationTest + # ... + + test "should destroy user" do + assert_difference('User.count', -1) do + delete api_v1_user_url(@user), as: :json + end + assert_response :no_content + end +end +---- + +Como puedes ver, la prueba es muy simple. Únicamente respondemos con estado *204* que significa `No Content` (Sin contenido). También podríamos devolver un código de estado *200*, pero encuentro más natural la respuesta `No Content` (Sin contenido) en este caso porque eliminamos un recurso y una respuesta exitosa podría ser bastante. + +La implementación de la acción de destrucción es muy simple: + +[source,ruby] +.app/controllers/api/v1/users_controller.rb +---- +class Api::V1::UsersController < ApplicationController + before_action :set_user, only: %i[show update destroy] + # ... + + # DELETE /users/1 + def destroy + @user.destroy + head 204 + end + + # ... +end +---- + +No olvides añadir la acción `destroy` en el archivo `routes.rb`: + +[source,ruby] +.config/routes.rb +---- +Rails.application.routes.draw do + # ... + resources :users, only: %i[show create update destroy] + # ... +end +---- + +Las pruebas deberían de pasar si todo es correcto: + +[source,bash] +---- +$ 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. + +[source,bash] +---- +$ 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_: + +[source,bash] +---- +$ 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! diff --git a/rails6/es/chapter04-athentification.adoc b/rails6/es/chapter04-athentification.adoc new file mode 100644 index 0000000..fa71d72 --- /dev/null +++ b/rails6/es/chapter04-athentification.adoc @@ -0,0 +1,554 @@ +[#chapter05-athentification] += 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: + +[source,bash] +---- +$ 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: + +[source,bash] +---- +$ 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. - https://wikipedia.org/wiki/JSON_Web_Token_Web_Token[Wikipedia] + +En general un token JWT se compone de tres partes: + +- un *header* estructurado en JSON contiene por ejemplo la fecha de validación del token. +- un *payload* estructurado en JSON puede contener *cualquier dato*. En nuestro caso, contiene el indetificador del usuario "conectado". +- un *signature* que nos permite verificar que el token fue encriptado por nuestra aplicación y es por lo danto válido. + +Estas tres partes son cada una codificadas en base64 y entonces concatenadas usando puntos (`.`). Lo cual nos da algo como: + +.Un token JWT válido +---- +eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c +---- + +Una ves decodificado, este token nos da la siguiente información: + +.La cabecera del token JWT +[source,json] +---- +{ "alg": "HS256", "typ": "JWT" } +---- + +.El payload de el token JWT +[source,json] +---- +{ "sub": "1234567890", "name": "John Doe", "iat": 1516239022 } +---- + +NOTE: Para más información sobre tokens JWT te invito a visitar https://jwt.io[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: https://github.com/jwt/ruby-jwt[ruby-jwt]. + +Asi que vamos a comenzar instalándola: + +[source,bash] +---- +$ bundle add jwt +---- + +Una vez completada la siguiente línea es añadida a tu _Gemfile_: + +[source,ruby] +---- +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: + +[source,ruby] +---- +2.6.3 :001 > token = JWT.encode({message: 'Hello World'}, 'my_secret_key') +2.6.3 :002 > JWT.decode(token, 'my_secret_key') + => [{"message"=>"Hello World"}, {"alg"=>"HS256"}] +---- + +En la primera línea codificamos un _payload_ con la llave secreta `my_secret_key`. así obtenemos un token que podemos decodificar de manera simple. La segunda línea decodifica el token y vemos que podemos encontrar sin dilema nuestro _payload_. + +Vamos a incluir toda la lógica en una clase `JsonWebToken` en un nuevo archivo localizado en `lib/`. Esto nos permite evitar el código duplicado. Esta clase justamente codificará y decodificará los tokens JWT. Así que aquí está la implementación. + +.lib/json_web_token.rb +[source,ruby] +---- +class JsonWebToken + SECRET_KEY = Rails.application.secrets.secret_key_base.to_s + + def self.encode(payload, exp = 24.hours.from_now) + payload[:exp] = exp.to_i + JWT.encode(payload, SECRET_KEY) + end + + def self.decode(token) + decoded = JWT.decode(token, SECRET_KEY).first + HashWithIndifferentAccess.new decoded + end +end +---- + +Yo se que es un montón de código pero lo revisaremos juntos. + +- el método `JsonWebToken.encode` se encarga de codificar el _payload_ añadiendo una fecha de expiración de 24 horas por defecto. Además usamos la misma llave de encriptación que viene configurada con Rails. +- el método `JsonWebToken.decode` decodifica el token JWT y obtiene el _payload_. Entonces usamos la clase https://api.rubyonrails.org/classes/ActiveSupport/HashWithIndifferentAccess.html[`HashWithIndifferentAccess`] proveída por Rails la cual nos permite recuperar un valor de un `Hash` con un `Symbol` ó `String`. + +Ahí tienes. Para cargar el archivo en tú aplicación, necesitas especificar el directorio `lib` en la lista de _autoload de Ruby on rails. Para hacerlo, agrega la siguiente configuración al archivo `application.rb`: + +.config/application.rb +[source,ruby] +---- +# ... +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: + +[source,bash] +---- +$ 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: + + +[source,bash] +---- +$ rails generate controller api::v1::tokens create +---- + +Modificaremos la ruta un poco para respetar las convenciones _REST_: + +.config/routes.rb +[source,ruby] +---- +Rails.application.routes.draw do + namespace :api, defaults: { format: :json } do + namespace :v1 do + # ... + resources :tokens, only: [:create] + end + end +end +---- + + +Vamos a construir pruebas funcionales antes de ir más lejos. El comportamiento deseado es el siguiente: + +- Yo recibo un token si envío un email valido junto con el password +- de otro modo el server responde un `forbidden` + +Las pruebas por lo tanto se materializan de la siguiente forma: + +.test/controllers/api/v1/tokens_controller_test.rb +[source,ruby] +---- +require 'test_helper' + +class Api::V1::TokensControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:one) + end + + test 'should get JWT token' do + post api_v1_tokens_url, params: { user: { email: @user.email, password: 'g00d_pa$$' } }, as: :json + assert_response :success + + json_response = JSON.parse(response.body) + assert_not_nil json_response['token'] + end + + test 'should not get JWT token' do + post api_v1_tokens_url, params: { user: { email: @user.email, password: 'b@d_pa$$' } }, as: :json + assert_response :unauthorized + end +end +---- + +Te estarás preguntando: "¿pero como puedes saber la contraseña del usuario?". Simplemente usa el método `BCrypt::Password.create` en los _fixtures_ de `users`: + +.test/fixtures/users.yml +[source,yaml] +---- +one: + email: one@one.org + password_digest: <%= BCrypt::Password.create('g00d_pa$$') %> +---- + +En este preciso momento, si corres las pruebas obtendrás dos errores: + +[source,bash] +---- +$ rake test + +........E + +Error: +Api::V1::TokensControllerTest#test_should_get_JWT_token: +JSON::ParserError: 767: unexpected token at '' + + +Failure: +Expected response to be a <401: unauthorized>, but was a <204: No Content> +---- + +Es normal. Ahora es tiempo de implementar la lógica para crear el token JWT. Es muy sencillo. + +.app/controllers/api/v1/tokens_controller.rb +[source,ruby] +---- +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 campo `email` esté presente en la base de datos) y recuperamos el usuario +. Usamos el método `User#authenticate` (el cual existe gracias a la gema `bcrypt`) con la contraseña como un parámetro. Bcrypt hará un _hash_ de la contraseña y verifica si coincide con el atributo `password_digest`. La función regresa `true` si todo salió bien, `false` si no. +. Si la contraseña corresponde al _hash_, un JSON conteniendo el _token_ generado con la clase `JsonWebToken` es devuelto. De otro modo, una respuesta vacía es devuelta con una cabecera `unauthorized` + +¿Estas hasta aquí? ¡No te preocupes, esta terminado! Ahora tus pruebas deberían pasar. + +[source,bash] +---- +$ 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: + +[source,bash] +---- +$ 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: + +[source,ruby] +.app/controllers/concerns/authenticable.rb +---- +module Authenticable + def current_user + # TODO + end +end +---- + +Entonces, vamos a crear un directorio `concerns` en `tests/controllers/` y un archivo `authenticable_test.rb` para nuestras pruebas de a autenticación: + + +[source,bash] +---- +$ 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: + +[source,ruby] +.test/controllers/concerns/authenticable_test.rb +---- +# ... +class AuthenticableTest < ActionDispatch::IntegrationTest + setup do + @user = users(:one) + @authentication = MockController.new + end + + test 'should get user from Authorization token' do + @authentication.request.headers['Authorization'] = JsonWebToken.encode(user_id: @user.id) + assert_equal @user.id, @authentication.current_user.id + end + + test 'should not get user from empty Authorization token' do + @authentication.request.headers['Authorization'] = nil + assert_nil @authentication.current_user + end +end +---- + +Te estarás preguntando, "¿De donde viene el controlador `MockController`?", De hecho, éste es un _Mock_, por ejemplo una clase que imita el comportamiento de otra para probar un comportamiento + +Podemos definir la clase `MockController` justo sobre nuestra prueba: + +[source,ruby] +.test/controllers/concerns/authenticable_test.rb +---- +# ... +class MockController + include Authenticable + attr_accessor :request + + def initialize + mock_request = Struct.new(:headers) + self.request = mock_request.new({}) + end +end +# ... +---- + +La clase `MockController` simplemente incluye nuestro módulo `Authenticable` que probaremos. Este contiene un atributo `request` que contiene un simple https://ruby-doc.org/core-2.6.3/Struct.html[`Struct`] que imita el comportamiento de una petición Rails conteniendo un atributo `headers` de tipo `Hash`. + +Entonces podemos implementar nuestras dos pruebas ahora + +[source,ruby] +.test/controllers/concerns/authenticable_test.rb +---- +# ... +class AuthenticableTest < ActionDispatch::IntegrationTest + setup do + @user = users(:one) + @authentication = MockController.new + end + + test 'should get user from Authorization token' do + @authentication.request.headers['Authorization'] = JsonWebToken.encode(user_id: @user.id) + assert_not_nil @authentication.current_user + assert_equal @user.id, @authentication.current_user.id + end + + test 'should not get user from empty Authorization token' do + @authentication.request.headers['Authorization'] = nil + assert_nil @authentication.current_user + end +end +---- + +Nuestra prueba debería fallar. Así que vamos a implementar el código para que ésta pase: + +[source,ruby] +.app/controllers/concerns/authenticable.rb +---- +module Authenticable + def current_user + return @current_user if @current_user + + header = request.headers['Authorization'] + return nil if header.nil? + + decoded = JsonWebToken.decode(header) + + @current_user = User.find(decoded[:user_id]) rescue ActiveRecord::RecordNotFound + end +end +---- + +Ahí tienes! Obtenemos el token desde la cabecera `Authorization` y buscamos el usuario correspondiente. Nada tan mágico. + +Nuestra prueba debería pasar: + +[source,bash] +---- +$ 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`: + +[source,ruby] +.app/controllers/application_controller.rb +---- +class ApplicationController < ActionController::API + # ... + include Authenticable +end +---- + +Y ahora es tiempo de hacer _commit_ a nuestros cambios: + +[source,bash] +---- +$ git add . && git commit -m "Adds authenticable module for managing authentication methods" +---- + +== Autenticación con el token + +La autorización juega un papel importante en la construcción de aplicaciones porque nos ayuda a definir que usuario tiene permisos para continuar. + +Tenemos una ruta para actualizar el usuario, pero hay un problema: cualquiera puede actualizar cualquier usuario. En esta sección, vamos a implementar un método que requerirá al usuario estar logueado para prevenir accesos no autorizados. + +=== Acciones de autorización + +Es tiempo ahora de actualizar nuestro archivo `users_controller.rb` para negar el acceso a ciertas acciones. Vamos también a implementar el método `current_user` en las acciones `update` y `destroy` para asegurarnos que el usuario que esta logueado solo podrá actualizar sus datos y puede únicamente borrar (y solo) su cuenta. + +Por lo tanto dividimos nuestra prueba en dos pruebas _should update user_ y _should destroy user_. + +Iniciamos por actualizar la prueba _should update user_. + +.test/controllers/api/v1/users_controller_test.rb +[source,ruby] +---- +# ... +class Api::V1::UsersControllerTest < ActionDispatch::IntegrationTest + # ... + test "should update user" do + patch api_v1_user_url(@user), + params: { user: { email: @user.email } }, + headers: { Authorization: JsonWebToken.encode(user_id: @user.id) }, + as: :json + assert_response :success + end + + test "should forbid update user" do + patch api_v1_user_url(@user), params: { user: { email: @user.email } }, as: :json + assert_response :forbidden + end +end +---- + +Puedes ver ahora que tenemos que añadir una cabecera _Authorization_ para la acción de modificar usuarios. De lo contrario queremos recibir una respuesta _forbidden_. + +Podemos pensar de forma similar para la prueba _should forbid destroy user_: + +.test/controllers/api/v1/users_controller_test.rb +[source,ruby] +---- +# ... +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: + +[source,bash] +---- +$ 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: + +[source,ruby] +.app/controllers/api/v1/users_controller.rb +---- +class Api::V1::UsersController < ApplicationController + before_action :set_user, only: %i[show update destroy] + before_action :check_owner, only: %i[update destroy] + # ... + + private + # ... + def check_owner + head :forbidden unless @user.id == current_user&.id + end +end +---- + +¡Ahí tienes! La implementación es realmente simple. Es por lo tanto tiempo de hacer un _commit_: + +[source,bash] +---- +$ 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 https://github.com/Netflix/fast_jsonapi[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. diff --git a/rails6/es/chapter05-user-products.adoc b/rails6/es/chapter05-user-products.adoc new file mode 100644 index 0000000..fa65300 --- /dev/null +++ b/rails6/es/chapter05-user-products.adoc @@ -0,0 +1,770 @@ +[#chapter05-user-products] += 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: + +[source,bash] +---- +$ git checkout tags/checkpoint_chapter05 +---- + +Antes que iniciemos y como es usual cuando iniciamos con nuevas características necesitaremos crear una nueva rama: + +[source,bash] +---- +$ git checkout -b chapter05 +---- + +== El modelo producto + +Primero crearemos un modelo `Product`. Entonces añadiremos validaciones y finalmente lo asociamos con el modelo `User. Como el modelo `User`, el modelo `Product` será completamente probado y será automáticamente eliminado si el usuario es eliminado. + +=== Los fundamentos del producto + +La plantilla `Product` necesitara varios campos: + +* un atributo `price` para el precio del producto +* un booleano `published` para saber si el producto ya está vendido o no +* un `title` para definir un título sexy al producto +* un `user_id` para asociar este producto particular a un usuario + + Como puedes adivinar lo generamos con el comando `rails generate`: + +[source,bash] +---- +$ 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í: + +[source,ruby] +.db/migrate/20190608205942_create_products.rb +---- +class CreateProducts < ActiveRecord::Migration[6.0] + def change + create_table :products do |t| + t.string :title + t.decimal :price + t.boolean :published + t.belongs_to :user, null: false, foreign_key: true + + t.timestamps + end + end +end +---- + +Ahora solo tenemos que iniciar la migración: + +[source,bash] +---- +$ rake db:migrate +---- + +Una prueba debería de fallar hasta este punto: + +[source,bash] +---- +$ rake test +....E + +Error: +Api::V1::UsersControllerTest#test_should_destroy_user: +ActiveRecord::InvalidForeignKey: SQLite3::ConstraintException: FOREIGN KEY constraint failed + +rails test test/controllers/api/v1/users_controller_test.rb:43 +---- + +Seguramente dirás: + +> ¿Que?, ¡Pero no he tocado los usuarios! + +Lo que he visto en el código de otros desarrolladores, cuando ellos trabajan con asociaciones, es que se olvidan de la destrucción de dependencias entre modelos. Lo que digo con esto es que si un usuario es eliminado, también lo deberían de ser los productos del usuario. + +Necesitamos un usuario con uno de los productos para probar esta interacción entre modelos. Entones eliminaremos este usuario esperando que los productos desaparezcan con él. Rails ya tiene generado esto por nosotros. Echa un vistazo a el _fixture_ de los productos: + + +.test/fixtures/products.yml +[source,yaml] +---- +one: + title: MyString + price: 9.99 + published: false + user: one +# ... +---- + +Puedes ver que este _fixture_ no usa el atributo `user_id` pero si `user`. Esto significa que el producto `one` tendrá un atributo `user_id` correspondiente al ID de usuario `one`. + +Es por lo tanto necesario especificar un borrado en cascada a fin de que sea eliminado el producto `one` cuando el usuario `one` es eliminado. Vamos empezar con la prueba unitaria: + + +.test/models/user_test.rb +[source,ruby] +---- +# ... +class UserTest < ActiveSupport::TestCase + # ... + test 'destroy user should destroy linked product' do + assert_difference('Product.count', -1) do + users(:one).destroy + end + end +end +---- + +Justamente tienes que modificar el modelo `User` y especificar la relación `has_many` con la opción `depend: :destroy`. Veremos más tarde que hace este método con mas detalle. + +.app/models/user.rb +[source,ruby] +---- +# ... +class User < ApplicationRecord + # ... + has_many :products, dependent: :destroy +end +---- +<<< +Eso es todo. Ahor hacemos un _commit_: + +[source,bash] +---- +$ 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. + +[source,ruby] +.test/models/product_test.rb +---- +# ... +class ProductTest < ActiveSupport::TestCase + test "should have a positive price" do + product = products(:one) + product.price = -1 + assert_not product.valid? + end +end +---- + +Ahora necesitamos añadir la implementación para hacer que la prueba pase: + +[source,ruby] +.app/models/product.rb +---- +class Product < ApplicationRecord + validates :title, :user_id, presence: true + validates :price, numericality: { greater_than_or_equal_to: 0 }, presence: true + belongs_to :user +end +---- + +La prueba ahora está en verde: + +[source,bash] +---- +$ rake test +................ +---- + +Tenemos un montón de código de buena calidad. Hagamos un commit y sigamos moviéndonos: + +[source,bash] +---- +$ 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 https://github.com/Netflix/fast_jsonapi[fast_jsonapi]. + +Primero necesitamos crear el controlador `products_controller`, y fácilmente podemos lograrlo con el comando: + +[source,bash] +---- +$ 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. + +[source,ruby] +.test/controllers/api/v1/products_controller_test.rb +---- +# ... +class Api::V1::ProductsControllerTest < ActionDispatch::IntegrationTest + setup do + @product = products(:one) + end + + test "should show product" do + get api_v1_product_url(@product), as: :json + assert_response :success + + json_response = JSON.parse(self.response.body) + assert_equal @product.title, json_response['title'] + end +end +---- + +Entonces añadimos el código que hará pasar las pruebas: + +[source,ruby] +.app/controllers/api/v1/products_controller.rb +---- +class Api::V1::ProductsController < ApplicationController + def show + render json: Product.find(params[:id]) + end +end +---- + +¡Espera! Aun no corras las pruebas. Recuerda que necesitamos añadir el recuro al archivo `routes.rb`: + +[source,ruby] +.config/routes.rb +---- +Rails.application.routes.draw do + namespace :api, defaults: { format: :json } do + namespace :v1 do + resources :users, only: %i[show create update destroy] + resources :tokens, only: [:create] + resources :products, only: [:show] + end + end +end +---- + +Ahora nos aseguramos que las pruebas están bien y en verde: + +[source,bash] +---- +$ 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: + +[source,ruby] +.test/controllers/api/v1/products_controller_test.rb +---- +# ... +class Api::V1::ProductsControllerTest < ActionDispatch::IntegrationTest + setup do + @product = products(:one) + end + + test "should show products" do + get api_v1_products_url(), as: :json + assert_response :success + end + + test "should show product" do + get api_v1_product_url(@product), as: :json + assert_response :success + + json_response = JSON.parse(self.response.body) + assert_equal @product.title, json_response['title'] + end +end +---- + +Vamos a la implementación, la cual por ahora está siendo un método `index` simple: + +[source,ruby] +.app/controllers/api/v1/products_controller.rb +---- +class Api::V1::ProductsController < ApplicationController + def index + render json: Product.all + end + #... +end +---- + +No olvides añadir la ruta correspondiente: + +[source,ruby] +.config/routes.rb +---- +Rails.application.routes.draw do + namespace :api, defaults: { format: :json } do + namespace :v1 do + # .... + resources :products, only: %i[show index] + end + end +end +---- + +Terminamos por ahora con el endopint al producto público. En la siguiente sección nos enfocaremos en la construcción de las acciones solicitando un usuario logueado para acceder a ellos. Dicho esto, haremos commit de estos cambios y continuamos. + +[source,bash] +---- +$ 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`: + +[source,ruby] +.test/controllers/api/v1/products_controller_test.rb +---- +# ... +class Api::V1::ProductsControllerTest < ActionDispatch::IntegrationTest + # ... + + test 'should create product' do + assert_difference('Product.count') do + post api_v1_products_url, + params: { product: { title: @product.title, price: @product.price, published: @product.published } }, + headers: { Authorization: JsonWebToken.encode(user_id: @product.user_id) }, + as: :json + end + assert_response :created + end + + test 'should forbid create product' do + assert_no_difference('Product.count') do + post api_v1_products_url, + params: { product: { title: @product.title, price: @product.price, published: @product.published } }, + as: :json + end + assert_response :forbidden + end +end +---- + +¡Wow! Añadimos un montón de código. Si recuerdas la sección anterior, las pruebas son muy similares que las de la creación de usuarios. Excepto por algunos cambios menores. + +De esta forma, podemos ver al usuario y la creación del producto asociado con el. Pero espera! Hay algo mejor. + +Si adoptamos este enfoque, podemos incrementar el alcance de nuestro mecanismo de autenticación. Realmente construimos la lógica para obtener al usuario logueado desde la cabecera `Authorization` y asignarle un método `current_user`. Es por lo tanto bastante fácil de configurar simplemente añadiendo la cabecera de autorización a la solicitud y recuperando el usuario desde ahí. Entonces hagamoslo. + +[source,ruby] +.app/controllers/api/v1/products_controller.rb +---- +class Api::V1::ProductsController < ApplicationController + before_action :check_login, only: %i[create] + # ... + + def create + product = current_user.products.build(product_params) + if product.save + render json: product, status: :created + else + render json: { errors: product.errors }, status: :unprocessable_entity + end + end + + private + + def product_params + params.require(:product).permit(:title, :price, :published) + end +end +---- + +Como puedes ver, protegemos la acción `create` con el método `check_login`. También creamos al producto por asociación con el usuario. Yo agregué este método tan sencillo al _concern_ del archivo `authenticable.rb`: + +[source,ruby] +.app/controllers/concerns/authenticable.rb +---- +module Authenticable + # ... + protected + + def check_login + head :forbidden unless self.current_user + end +end +---- + +Una última cosa antes de hacer tus pruebas: la ruta necesaria: + +[source,ruby] +.config/routes.rb +---- +Rails.application.routes.draw do + namespace :api, defaults: { format: :json } do + namespace :v1 do + # ... + resources :products, only: %i[show index create] + end + end +end + +---- + +Ahora las pruebas deberían pasar: + +.... +$ rake test +.................... +.... + + +=== Actualizando los productos + +Espero que por ahora entiendas la lógica para construir la acciones que vienen. En esta sección nos enfocaremos en la acción `update` que funcionará a la acción `create`. Solamente necesitamos buscar el producto desde la base de datos y actualizarlo. + +Añadiremos primer la acción a las rutas así no nos olvidamos después: + +[source,ruby] +.config/routes.rb +---- +Rails.application.routes.draw do + namespace :api, defaults: { format: :json } do + namespace :v1 do + # ... + resources :products, only: %i[show index create update] + end + end +end +---- + +Antes de iniciar borrando alguna prueba quiero aclarar que similarmente a la acción `create` vamos a dar alcance en el producto al con el método `current_user`. En este caso queremos asegurar que el producto que se está actualizando pertenece al usuario actual. Así que buscaremos los productos de la asociación `user.products` proveída por Rails. + +Agreguemos algunas especificaciones: + +[source,ruby] +.test/controllers/api/v1/products_controller_test.rb +---- +require 'test_helper' + +class Api::V1::ProductsControllerTest < ActionDispatch::IntegrationTest + # ... + + test 'should update product' do + patch api_v1_product_url(@product), + params: { product: { title: @product.title } }, + headers: { Authorization: JsonWebToken.encode(user_id: @product.user_id) }, + as: :json + assert_response :success + end + + test 'should forbid update product' do + patch api_v1_product_url(@product), + params: { product: { title: @product.title } }, + headers: { Authorization: JsonWebToken.encode(user_id: users(:two).id) }, + as: :json + assert_response :forbidden + end +end +---- + + +NOTE: Tengo añadido un _fixture_ correspondiente a un segundo usuario justo para verificar que el segundo usuario no puede modificar productos del primer usuario. + +Las pruebas parecen complejas, pero echa un segundo vistazo. Son casi lo mismo que construimos para los usuarios. + +Ahora vamos a implementar el código para hacer pasar nuestras pruebas: + +[source,ruby] +.app/controllers/api/v1/products_controller.rb +---- +class Api::V1::ProductsController < ApplicationController + before_action :set_product, only: %i[show update] + before_action :check_login, only: %i[create] + before_action :check_owner, only: %i[update] + + # ... + + def create + product = current_user.products.build(product_params) + if product.save + render json: product, status: :created + else + render json: { errors: product.errors }, status: :unprocessable_entity + end + end + + def update + if @product.update(product_params) + render json: @product + else + render json: @product.errors, status: :unprocessable_entity + end + end + + private + # ... + + def check_owner + head :forbidden unless @product.user_id == current_user&.id + end + + def set_product + @product = Product.find(params[:id]) + end +end +---- + +La implementación es muy simple. Simplemente recuperaremos el producto desde el usuario conectad y simplemente lo actualizamos. Tenemos también agregadas esta acción a el `before_action` para prevenir cualquier usuario no autorizado desde la actualización de un producto. + +Ahora las pruebas deberían pasar: + +[source,bash] +---- +$ 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: + +[source,ruby] +.config/routes.rb +---- +Rails.application.routes.draw do + namespace :api, defaults: { format: :json } do + namespace :v1 do + resources :users, only: %i[show create update destroy] + resources :tokens, only: [:create] + resources :products + end + end +end +---- + +Después de esto, tenemos que añadir algunas pruebas como se muestra en este fragmento de código: + +[source,ruby] +.test/controllers/api/v1/products_controller_test.rb +---- +# ... +class Api::V1::ProductsControllerTest < ActionDispatch::IntegrationTest + # ... + + test "should destroy product" do + assert_difference('Product.count', -1) do + delete api_v1_product_url(@product), headers: { Authorization: JsonWebToken.encode(user_id: @product.user_id) }, as: :json + end + assert_response :no_content + end + + test "should forbid destroy product" do + assert_no_difference('Product.count') do + delete api_v1_user_url(@product), headers: { Authorization: JsonWebToken.encode(user_id: users(:two).id) }, as: :json + end + assert_response :forbidden + end +end +---- + + +Ahora simplemente añadimos el código necesario para hacer pasar las pruebas: + +[source,ruby] +.app/controllers/api/v1/products_controller.rb +---- +class Api::V1::ProductsController < ApplicationController + before_action :set_product, only: %i[show update destroy] + before_action :check_login, only: %i[create] + before_action :check_owner, only: %i[update destroy] + + # ... + + def destroy + @product.destroy + head 204 + end + + # ... +end +---- + +Como puedes ver las cuatro líneas implementadas hacen el trabajo. Podemos correr las pruebas para asegurar que todo está bien y entonces haremos un commit de los cambios ya que hemos añadido un montón de código. También asegúrate que llamas a esta acción en el callback `before_action` al igual que en la acción `update`. + +[source,bash] +---- +$ rake test +........................ +---- + +Hagamos commit de los cambios: + +[source,bash] +---- +$ git commit -am "Adds the products create, update and destroy actions" +---- + + +== Llenado de la base de datos + +Vamos a llenar la base de datos con información falsa antes de continuar escribiendo más código. Vamos a usar los _seeds_ para hacerlo. + +Con el archivo `db/seeds.rb`, Rails nos da una forma fácil y rápida para asignar valores por defecto en una nueva instalación. Este es un simple archivo de Ruby que nos da completo acceso a clases y métodos de la aplicación. Así que no necesitas meter todo manualmente con la consola de Rails sino que puedes simplemente usar el archivo `db/seeds.rb` con el comando `rake db:seed`. + +Asi que vamos a iniciar creando un usuario: + +.db/seeds.rb +[source,ruby] +---- +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: + + +[source,bash] +---- +$ 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 https://github.com/stympy/faker[`faker`]. Vamos a configurarla: + +[source,bash] +---- +$ bundle add faker +---- + +Ahora podemos usarla para crear cinco usuarios al mismo tiempo con diferentes emails. + +.db/seeds.rb +[source,ruby] +---- +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: + +[source,bash] +---- +$ rake db:seed +Created a new user: barbar@greenholt.io +Created a new user: westonpaucek@ortizbotsford.net +Created a new user: ricardo@schneider.com +Created a new user: scott@moenerdman.biz +Created a new user: chelsie@wiza.net +---- + +Ahí lo tienes. Pero podemos ir más lejos creando productos asociados con estos usuarios: + + +.db/seeds.rb +[source,ruby] +---- +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: + +[source,bash] +---- +$ 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_: + +[source,bash] +---- +$ 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_: + +[source,bash] +---- +$ 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 https://github.com/Netflix/fast_jsonapi[fast_jsonapi]. Esto nos permitirá filtrar fácilmente los atributos para mostrar y manipular asociaciones como objetos embebidos, por ejemplo. diff --git a/rails6/es/chapter06-improve-json.adoc b/rails6/es/chapter06-improve-json.adoc new file mode 100644 index 0000000..af2c941 --- /dev/null +++ b/rails6/es/chapter06-improve-json.adoc @@ -0,0 +1,984 @@ +[#chapter06-improve-json] += 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í: + +[source,json] +---- +{ + "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 buscamso 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: + +[source,bash] +---- +$ git checkout tags/checkpoint_chapter06 +---- + +Iniciemos con una nueva rama para este capítulo: + +[source,bash] +---- +$ git checkout -b chapter06 +---- + +== Presentación de https://jsonapi.org/[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 https://jsonapi.org/[JSON:API]. + +La https://jsonapi.org/format/#document-structure[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 https://jsonapi.org/format/#document-meta[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: https://github.com/Netflix/fast_jsonapi[fast_jsonapi]. Afortunadamente ya implementa todas las especificaciones https://jsonapi.org/[JSON:API]. + +Así que instalemos la gema `fast_jsonapi`: + +[source,bash] +---- +$ 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 https://ruby-doc.org/core-2.6.3/Hash.html[`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: + +[source,bash] +---- +$ 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: + +[source,ruby] +.app/serializers/user_serializer.rb +---- +class UserSerializer + include FastJsonapi::ObjectSerializer + attributes :email +end +---- + +Este _serializer_ nos permitirá convertir nuestro objeto `User` a JSON implementando todas las especificaciones JSON:API. Como especificamos `email` como `attributes` lo recibimos en un arreglo `data`. + +Vamos a intentar todo esto en la consola de rails con `rails console`: + +[source,ruby] +---- +2.6.3 :001 > UserSerializer.new( User.first ).serializable_hash +=> {:data=>{:id=>"25", :type=>:user, :attributes=>{:email=>"tova@beatty.org"}}} +---- + +Ahí tienes. Como puedes ver es realmente fácil. Ahora podemos usar nuestro nuevo _serializer_ en nuestro _controller_: + + +.app/controllers/api/v1/users_controller.rb +[source,ruby] +---- +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: + +[source,bash] +---- +$ 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: + +[source,ruby] +.test/controllers/api/v1/users_controller_test.rb +---- +# ... +class Api::V1::UsersControllerTest < ActionDispatch::IntegrationTest + # ... + test "should show user" do + # ... + assert_equal @user.email, json_response['data']['attributes']['email'] + end + # ... +end +---- + +Si lo hiciste ahora la prueba pasa: + +[source,bash] +---- +$ rake test +........................ +---- + +Guardemos estos cambios y sigamos moviéndonos: + +[source,bash] +---- +$ 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: + +[source,bash] +---- +$ rails generate serializer Product title price published + create app/serializers/product_serializer.rb +---- + +Ahora vamos a añadir atributos para serializar el producto: + +[source,ruby] +.app/serializers/product_serializer.rb +---- +class ProductSerializer + include FastJsonapi::ObjectSerializer + attributes :title, :price, :published +end +---- + +Ahí está. No es tan complicado. Cambiemos nuestro controlador un poco. + +[source,ruby] +.app/controllers/api/v1/products_controller.rb +---- +class Api::V1::ProductsController < ApplicationController + # ... + def index + @products = Product.all + render json: ProductSerializer.new(@products).serializable_hash + end + + def show + render json: ProductSerializer.new(@product).serializable_hash + end + + def create + product = current_user.products.build(product_params) + if product.save + render json: ProductSerializer.new(product).serializable_hash, status: :created + else + # ... + end + end + + def update + if @product.update(product_params) + render json: ProductSerializer.new(@product).serializable_hash + else + # ... + end + end + # ... +end +---- + +Actualizamos nuestra prueba funcional: + +[source,ruby] +.test/controllers/api/v1/products_controller_test.rb +---- +# ... +class Api::V1::ProductsControllerTest < ActionDispatch::IntegrationTest + # ... + test 'should show product' do + # ... + assert_equal @product.title, json_response['data']['attributes']['title'] + end + # ... +end +---- + +Si quieres puedes revisar si la prueba pasa, pero debería. Guardemos estos pequeños cambios: + +[source, bash] +---- +$ 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. + +[source,ruby] +.app/models/user.rb +---- +class User < ApplicationRecord + has_many :products, dependent: :destroy + # ... +end +---- + +[source,ruby] +.app/models/product.rb +---- +class Product < ApplicationRecord + belongs_to :user + # ... +end +---- + +Es una buena idea integrar usuario en las salidas JSON de productos. Esto hará la salida más incomoda pero prevendrá al cliente de la API ejecutar otras peticiones para recibir información del usuario relacionada a los productos. Este método realmente puede salvarte de un enorme cuello de botella. + +== Teoría de la inyección de relaciones + +Imagina un escenario donde pides a la API productos, pero en este caso tienes que mostrar alguna información del usuario. + +Una posible solución podría ser añadir el atributo `user_id` a el `product_serializer` así podemos obtener el usuario correspondiente más tarde. Esto puede sonar como una buena idea, pero si estar preocupado sobre el rendimiento, o si las transacciones de la base de datos no son suficientemente rápidas, deberías reconsiderar éste enfoque. Deberías entender que de cada producto que recuperes, deberías recuperar su usuario correspondiente. + +Enfrentando a este problema, tenemos varias alternativas. + +=== Integrar en un meta atributo + +La primera solución (una buena en mi opinión) es integrar identificadores de usuarios enlazados a los productos un meta atributo. Así obtenemos un JSON como abajo: + +[source,json] +---- +{ + "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: + +[source,json] +---- +{ + "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: + +[source,json] +---- +{ + "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: + +[source,json] +---- +{ + "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: + + +[source,ruby] +.test/controllers/api/v1/products_controller_test.rb +---- +# ... +class Api::V1::ProductsControllerTest < ActionDispatch::IntegrationTest + # ... + test 'should show product' do + get api_v1_product_url(@product), as: :json + assert_response :success + + json_response = JSON.parse(response.body, symbolize_names: true) + assert_equal @product.title, json_response.dig(:data, :attributes, :title) + assert_equal @product.user.id.to_s, json_response.dig(:data, :relationships, :user, :data, :id) + assert_equal @product.user.email, json_response.dig(:included, 0, :attributes, :email) + end + # ... +end +---- + +Ahora revisaremos tres cosas que el JSON debería retornar: + +. 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 https://ruby-doc.org/core-2.6.3/Hash.html#method-i-dig[`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_: + +[source,ruby] +.app/serializers/product_serializer.rb +---- +class ProductSerializer + include FastJsonapi::ObjectSerializer + attributes :title, :price, :published + belongs_to :user +end +---- + +Esta adición añadirá una llave `relationship` conteniendo el identificador del usuario: + +[source,json] +---- +{ + "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: + +[source,ruby] +.app/controllers/api/v1/products_controller.rb +---- +class Api::V1::ProductsController < ApplicationController + # ... + def show + options = { include: [:user] } + render json: ProductSerializer.new(@product, options).serializable_hash + end + # ... +end +---- + +Ahí tienes. Ahora así es como debería lucir el JSON: + +[source,json] +---- +{ + "data": { + ... + }, + "included": [ + { + "id": "1", + "type": "user", + "attributes": { + "email": "staceeschultz@hahn.info" + } + } + ] +} +---- + +Ahora las pruebas deberían pasar: + +[source,bash] +---- +$ rake test +........................ +---- + +Hagamos un _commit_ para celebrar: + +[source,bash] +---- +$ 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: + +[source,ruby] +.test/controllers/api/v1/users_controller_test.rb +---- +# ... +class Api::V1::UsersControllerTest < ActionDispatch::IntegrationTest + # ... + test "should show user" do + get api_v1_user_url(@user), as: :json + assert_response :success + + json_response = JSON.parse(self.response.body, symbolize_names: true) + assert_equal @user.email, json_response.dig(:data, :attributes, :email) + assert_equal @user.products.first.id.to_s, json_response.dig(:data, :relationships, :products, :data, 0, :id) + assert_equal @user.products.first.title, json_response.dig(:included, 0, :attributes, :title) + end + # ... +end +---- + +_serializer_: + +[source,ruby] +.app/serializers/user_serializer.rb +---- +class UserSerializer + include FastJsonapi::ObjectSerializer + attributes :email + has_many :products +end +---- + +Y para finalizar el controlador: + +[source,ruby] +.app/controllers/api/v1/users_controller.rb +---- +class Api::V1::UsersController < ApplicationController + # ... + def show + options = { include: [:products] } + render json: UserSerializer.new(@user, options).serializable_hash + end + # ... +end +---- + +Ahí tienes. Obtenemos un JSON como el siguiente: + +[source,json] +---- +{ + "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_: + +[source,bash] +---- +$ 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 https://github.com/activerecord-hackery/ransack[Ransack] ó https://github.com/casecommons/pg_search[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: + +[source,yaml] +.test/fixtures/products.yml +---- +one: + title: TV Plosmo Philopps + price: 9999.99 + published: false + user: one + +two: + title: Azos Zeenbok + price: 499.99 + published: false + user: two + +another_tv: + title: Cheap TV + price: 99.99 + published: false + user: two +---- + +Y ahora podemos construir algunas pruebas: + +[source,ruby] +.test/models/product_test.rb +---- +# ... +class ProductTest < ActiveSupport::TestCase + # ... + test "should filter products by name" do + assert_equal 2, Product.filter_by_title('tv').count + end + + test 'should filter products by name and sort them' do + assert_equal [products(:another_tv), products(:one)], Product.filter_by_title('tv').sort + end +end +---- + +La siguiente prueba se asegura que el método `Product.filter_by_title` buscará correctamente los productos de acuerdo con su título. Usamos el término `tv` en minúsculas para asegurar que nuestra búsqueda no sea sensitiva a mayúsculas y minúsculas. + +[source,ruby] +.app/models/product.rb +---- +class Product < ApplicationRecord + # ... + scope :filter_by_title, lambda { |keyword| + where('lower(title) LIKE ?', "%#{keyword.downcase}%") + } +end +---- + +NOTE: _scoping_ te permite especificar las consultas comúnmente usadas que pueden ser referenciadas como llamada de método en los modelos. Con estos __scopes__ puedes enlazar métodos con Active Record como `where`, `joins` y `includes` porque un _scope_ siempre retorna un objeto https://api.rubyonrails.org/classes/ActiveRecord/Relation.html[`ActiveRecord::Relation`]. Te invito a que eches un vistazo en la https://guides.rubyonrails.org/active_record_querying.html#scopes_record_querying.html#scopes[documentación de Rail] + +Esta implementación es suficiente para que nuestras pruebas pasen: + +[source,bash] +---- +$ 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`: + +[source,ruby] +.test/models/product_test.rb +---- +# ... +class ProductTest < ActiveSupport::TestCase + # ... + test 'should filter products by price and sort them' do + assert_equal [products(:two), products(:one)], Product.above_or_equal_to_price(200).sort + end +end +---- + +La implementación es muy, muy sencilla: + +[source,ruby] +.app/models/product.rb +---- +class Product < ApplicationRecord + # ... + scope :above_or_equal_to_price, lambda { |price| + where('price >= ?', price) + } +end +---- + +Esto es suficiente para convertir nuestra prueba en verde: + +[source,bash] +---- +$ rake test +........................... +---- + +Puedes imaginar el comportamiento del método opuesto. Aquí está la prueba: + +[source,ruby] +.test/models/product_test.rb +---- +# ... +class ProductTest < ActiveSupport::TestCase + # ... + test 'should filter products by price lower and sort them' do + assert_equal [products(:another_tv)], Product.below_or_equal_to_price(200).sort + end +end +---- + +y la implementación. + +[source,ruby] +.app/models/product.rb +---- +class Product < ApplicationRecord + # ... + scope :below_or_equal_to_price, lambda { |price| + where('price <= ?', price) + } +end +---- + +Para nuestros motivos, vamos a hacer la prueba y revisar que todo está hermosamente en verde: + +[source,bash] +---- +$ 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: + +[source,ruby] +.test/models/product_test.rb +---- +# ... +class ProductTest < ActiveSupport::TestCase + # ... + test 'should sort product by most recent' do + # we will touch some products to update them + products(:two).touch + assert_equal [products(:another_tv), products(:one), products(:two)], Product.recent.to_a + end +end +---- + +Y la implementación: + +[source,ruby] +.app/models/product.rb +---- +class Product < ApplicationRecord + # ... + scope :recent, lambda { + order(:updated_at) + } +end +---- + +Todas nuestras pruebas deberían de pasar: + +[source,bash] +---- +$ rake test +............................. +---- + +Vamos a guardar nuestros cambios: + +[source,bash] +---- +$ 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: + +[source,ruby] +.test/models/product_test.rb +---- +# ... +class ProductTest < ActiveSupport::TestCase + # ... + test 'search should not find "videogame" and "100" as min price' do + search_hash = { keyword: 'videogame', min_price: 100 } + assert Product.search(search_hash).empty? + end + + test 'search should find cheap TV' do + search_hash = { keyword: 'tv', min_price: 50, max_price: 150 } + assert_equal [products(:another_tv)], Product.search(search_hash) + end + + test 'should get all products when no parameters' do + assert_equal Product.all.to_a, Product.search({}) + end + + test 'search should filter by product ids' do + search_hash = { product_ids: [products(:one).id] } + assert_equal [products(:one)], Product.search(search_hash) + end +end +---- + +Añadimos un montón de código, pero te aseguro que la implementación es muy fácil. Tú puedes ir más lejos y añadir pruebas adicionales pero, en mi caso, no lo encontré necesario. + +[source,ruby] +.app/models/product.rb +---- +class Product < ApplicationRecord + # ... + def self.search(params = {}) + products = params[:product_ids].present? ? Product.where(id: params[:product_ids]) : Product.all + + products = products.filter_by_title(params[:keyword]) if params[:keyword] + products = products.above_or_equal_to_price(params[:min_price].to_f) if params[:min_price] + products = products.below_or_equal_to_price(params[:max_price].to_f) if params[:max_price] + products = products.recent if params[:recent] + + products + end +end +---- + +Es importante notar que retornamos los productos como un objeto https://api.rubyonrails.org/classes/ActiveRecord/Relation.html[`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: + +[source,ruby] +.app/controllers/api/v1/products_controller.rb +---- +class Api::V1::ProductsController < ApplicationController + # ... + def index + @products = Product.search(params) + render json: ProductSerializer.new(@products).serializable_hash + end + # ... +end +---- + +Podemos correr la suit completa de pruebas para asegurar que la aplicación está en buen estado hasta aquí: + +[source,bash] +---- +$ rake test +................................. +33 runs, 49 assertions, 0 failures, 0 errors, 0 skips +---- + +Guardemos todos estos cambios: + +[source,bash] +---- +$ 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`: + +[source,bash] +---- +$ git checkout master +$ git merge chapter06 +---- + +== Conclusión + +Hasta ahora fue fácil gracias a la gema https://github.com/Netflix/fast_jsonapi_jsonapi[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. diff --git a/rails6/es/chapter07-placing-orders.adoc b/rails6/es/chapter07-placing-orders.adoc new file mode 100644 index 0000000..fe8e36c --- /dev/null +++ b/rails6/es/chapter07-placing-orders.adoc @@ -0,0 +1,693 @@ +[#chapter07-placing-orders] += 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 color 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: + +[source,bash] +---- +$ git checkout tags/checkpoint_chapter07 +---- + +Creemos una rama para empezar a trabajar: + +[source,bash] +---- +$ 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: + +[source,bash] +---- +$ 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. + +[source,bash] +---- +$ rake db:migrate +---- + +Ahora es tiempo para escribir algunas pruebas dentro del archivo`order_test.rb`: + +[source,ruby] +.test/models/order_test.rb +---- +# ... +class OrderTest < ActiveSupport::TestCase + test 'Should have a positive total' do + order = orders(:one) + order.total = -1 + assert_not order.valid? + end +end +---- + +La implementación es demasiado simple: + +[source,ruby] +.app/models/order.rb +---- +class Order < ApplicationRecord + belongs_to :user + validates :total, numericality: { greater_than_or_equal_to: 0 } + validates :total, presence: true +end +---- + +No olvides añadir la relación `orders` a nuestros usuarios especificando el borrado en cascada: + +[source,ruby] +.app/models/user.rb +---- +class User < ApplicationRecord + # ... + has_many :products, dependent: :destroy + has_many :orders, dependent: :destroy + # ... +end +---- + +Las pruebas deberían pasar: + +[source,bash] +---- +$ rake test +.................................. +---- + +Y hacemos _commit_ de todo esto: + +[source,bash] +---- +$ 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: + +[source,bash] +---- +$ rails generate model placement order:belongs_to product:belongs_to +---- + +Vamos a correr la migración en la base de datos: + +[source,bash] +---- +$ rake db:migrate +---- + +La implementación es como: + +[source,ruby] +.app/models/product.rb +---- +class Product < ApplicationRecord + belongs_to :user + has_many :placements, dependent: :destroy + has_many :orders, through: :placements + # ... +end +---- + +[source,ruby] +.app/models/order.rb +---- +class Order < ApplicationRecord + has_many :placements, dependent: :destroy + has_many :products, through: :placements + # ... +end +---- + +Si has estado siguiendo el tutorial para la implementación , esta ya está lista debido a las `references` (referencias) que forman parte del comando generador del modelo. Podríamos añadir la opción `inverse_of` a el modelo `placement` para cada llamada `belongs_to`. Esto da un pequeño impulso cuando referenciamos al objeto padre. + +[source,ruby] +.app/models/placement.rb +---- +class Placement < ApplicationRecord + belongs_to :order + belongs_to :product, inverse_of: :placements +end +---- + +Vamos a correr las pruebas de los _modelos_ y asegurar que todo es verde: + +[source,bash] +---- +$ rake test +.................................. +---- + +Ahora que todo está bien y en verde vamos a hacer commit de los cambios y continuar. + +[source,bash] +---- +$ 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 https://github.com/Netflix/fast_jsonapi_jsonapi[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: + +[source,bash] +---- +$ 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: + +[source,ruby] +.test/controllers/api/v1/orders_controller_test.rb +---- +# ... +class Api::V1::OrdersControllerTest < ActionDispatch::IntegrationTest + setup do + @order = orders(:one) + end + + test 'should forbid orders for unlogged' do + get api_v1_orders_url, as: :json + assert_response :forbidden + end + + test 'should show orders' do + get api_v1_orders_url, + headers: { Authorization: JsonWebToken.encode(user_id: @order.user_id) }, + as: :json + assert_response :success + + json_response = JSON.parse(response.body) + assert_equal @order.user.orders.count, json_response['data'].count + end +end +---- + +Si corremos la suit de pruebas ahora ambas pruebas deberían de fallar como ya esperábamos. Esto es porque estas no tienen establecidas las rutas o acciones correctas. Iniciemos añadiendo las rutas: + +[source,ruby] +.config/routes.rb +---- +Rails.application.routes.draw do + namespace :api, defaults: { format: :json } do + namespace :v1 do + resources :orders, only: [:index] + # ... + end + end +end +---- + +Ahora es tiempo para implementar la serialización de las ordenes: + + + +[source,bash] +---- +$ rails generate serializer Order +---- + +Y vamos a añadir relaciones: + +.app/serializers/order_serializer.rb +[source,ruby] +---- +class OrderSerializer + include FastJsonapi::ObjectSerializer + belongs_to :user + has_many :products +end +---- + +Ahora es tiempo de implementar el controlador: + +[source,ruby] +.app/controllers/api/v1/orders_controller.rb +---- +class Api::V1::OrdersController < ApplicationController + before_action :check_login, only: %i[index] + + def index + render json: OrderSerializer.new(current_user.orders).serializable_hash + end +end +---- + +Y ahora todas nuestras pruebas deberían de pasar: + +[source,bash] +---- +$ 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: + +[source,bash] +---- +$ 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: + +[source,ruby] +.test/controllers/api/v1/orders_controller_test.rb +---- +# ... +class Api::V1::OrdersControllerTest < ActionDispatch::IntegrationTest + # ... + test 'should show order' do + get api_v1_order_url(@order), + headers: { Authorization: JsonWebToken.encode(user_id: @order.user_id) }, + as: :json + assert_response :success + + json_response = JSON.parse(response.body) + include_product_attr = json_response['included'][0]['attributes'] + assert_equal @order.products.first.title, include_product_attr['title'] + end +end +---- + +Como puedes ver, la segunda parte de la prueba verifica que el producto está incluido en el JSON. + +Vamos añadir la implementación para correr nuestras pruebas. En el archivo `routes.rb` añadimos la acción `show` a las rutas de comando: + +[source,ruby] +.config/routes.rb +---- +# ... +Rails.application.routes.draw do + # ... + resources :orders, only: %i[index show] + # ... +end +---- + +Y la implementación debería lucir como esto: + +[source,ruby] +.app/controllers/api/v1/orders_controller.rb +---- +class Api::V1::OrdersController < ApplicationController + before_action :check_login, only: %i[index show] + # ... + def show + order = current_user.orders.find(params[:id]) + + if order + options = { include: [:products] } + render json: OrderSerializer.new(order, options).serializable_hash + else + head 404 + end + end +end +---- + +Nuestras pruebas deberían estar todas verdes: + +[source,bash] +---- +$ 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: + +[source,bash] +---- +$ 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 https://stripe.com/[Stripe] ó https://www.braintreepayments.com/[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: + +[source,ruby] +.test/controllers/api/v1/orders_controller_test.rb +---- +# ... +class Api::V1::OrdersControllerTest < ActionDispatch::IntegrationTest + setup do + # ... + @order_params = { order: { + product_ids: [products(:one).id, products(:two).id], + total: 50 + } } + end + + # ... + + test 'should forbid create order for unlogged' do + assert_no_difference('Order.count') do + post api_v1_orders_url, params: @order_params, as: :json + end + assert_response :forbidden + end + + test 'should create order with two products' do + assert_difference('Order.count', 1) do + post api_v1_orders_url, + params: @order_params, + headers: { Authorization: JsonWebToken.encode(user_id: @order.user_id) }, + as: :json + end + assert_response :created + end +end +---- + +Como puedes ver estamos crean una variable `order_params` con los datos de la orden. ¿Puedes ver el problema aquí? Si no, lo explicare más tarde. Justamente añadimos el código necesario para hacer pasar la prueba. + +Primero necesitamos añadir la acción a los recursos en el archivo de rutas: + +[source,ruby] +.config/routes.rb +---- +# ... +Rails.application.routes.draw do + # ... + resources :orders, only: %i[index show create] + # ... +end +---- + +Entonces la implementación es fácil: + +[source,ruby] +.app/controllers/api/v1/orders_controller.rb +---- +class Api::V1::OrdersController < ApplicationController + before_action :check_login, only: %i[index show create] + # ... + + def create + order = current_user.orders.build(order_params) + + if order.save + render json: order, status: 201 + else + render json: { errors: order.errors }, status: 422 + end + end + + private + + def order_params + params.require(:order).permit(:total, product_ids: []) + end +end +---- + +Y ahora nuestras pruebas deberian estar en verde: + +[source,bash] +---- +$ 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: + +[source,ruby] +.test/models/order_test.rb +---- +# ... +class OrderTest < ActiveSupport::TestCase + + setup do + @order = orders(:one) + @product1 = products(:one) + @product2 = products(:two) + end + + test 'Should set total' do + order = Order.new user_id: @order.user_id + order.products << products(:one) + order.products << products(:two) + order.save + + assert_equal (@product1.price + @product2.price), order.total + end +end +---- + +Ahora podemos añadir la implementación: + +[source,ruby] +.app/models/order.rb +---- +class Order < ApplicationRecord + # ... + def set_total! + self.total = products.map(&:price).sum + end +end +---- + +Ahora podemos incluir el método `set_total!` a un callback `before_validation` para asegurar que tiene el total correcto antes de ser validado. + +[source,ruby] +.app/models/order.rb +---- +class Order < ApplicationRecord + before_validation :set_total! + # ... +end +---- + +Hasta este punto nos aseguramos que el total está siempre presente y es mayor o igual a cero. Esto significa que podemos quitar esas validaciones y quitar las especificaciones. Esperaré. Nuestras pruebas deberían pasar por ahora: + +[source,bash] +---- +$ 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: + +[source,bash] +---- +$ 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`: + +[source,bash] +---- +$ rails generate mailer order_mailer send_confirmation +---- + +Ahora agregamos algunas pruebas para los correos de la orden que acabamos de crear: + +[source,ruby] +.test/mailers/order_mailer_test.rb +---- +# ... +class OrderMailerTest < ActionMailer::TestCase + + setup do + @order = orders(:one) + end + + test "should be set to be delivered to the user from the order passed in" do + mail = OrderMailer.send_confirmation(@order) + assert_equal "Order Confirmation", mail.subject + assert_equal [@order.user.email], mail.to + assert_equal ['no-reply@marketplace.com'], mail.from + assert_match "Order: ##{@order.id}", mail.body.encoded + assert_match "You ordered #{@order.products.count} products", mail.body.encoded + end + +end +---- + +Yo simplemente copie/pegue las pruebas desde la documentación y las adapte a nuestras necesidades. Ahora nos aseguramos que estas pruebas pasan. + +Primero, añadimos el método `OrderMailer#send_confirmation`: + +[source,ruby] +.app/mailers/order_mailer.rb +---- +class OrderMailer < ApplicationMailer + default from: 'no-reply@marketplace.com' + def send_confirmation(order) + @order = order + @user = @order.user + mail to: @user.email, subject: 'Order Confirmation' + end +end +---- + +Después de añadir este código añadimos las vistas correspondientes. Es una buena práctica incluir un texto de la versión como extra a la versión HTML. + + +[source,erb] +---- +<%# 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 %> +---- + +[source,erb] +---- + +

Order: #<%= @order.id %>

+

You ordered <%= @order.products.count %> products:

+
    + <% @order.products.each do |product| %> +
  • <%= product.title %> - <%= number_to_currency product.price %>
  • + <% end %> +
+---- + +Ahora, nuestra prueba debería pasar: + +[source,bash] +---- +$ 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: + +[source,ruby] +.app/controllers/api/v1/orders_controller.rb +---- +class Api::V1::OrdersController < ApplicationController + # ... + def create + order = current_user.orders.build(order_params) + + if order.save + OrderMailer.send_confirmation(order).deliver + render json: order, status: 201 + else + render json: { errors: order.errors }, status: 422 + end + end + # ... +end +---- + +Para asegurar que no rompimos nada, vamos a correr todas las pruebas: + +[source,bash] +---- +$ rake test +........................................ +40 runs, 66 assertions, 0 failures, 0 errors, 0 skips +---- + +Hagamos commit a todo para ya que está completa esta sección: + +[source,bash] +---- +$ 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': + +[source,bash] +---- +$ 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. diff --git a/rails6/es/chapter08-improve-orders.adoc b/rails6/es/chapter08-improve-orders.adoc new file mode 100644 index 0000000..1c70856 --- /dev/null +++ b/rails6/es/chapter08-improve-orders.adoc @@ -0,0 +1,496 @@ +[#chapter08-improve_orders] += 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: + +[source,ruby] +---- +$ git checkout tags/checkpoint_chapter08 +---- + +Vamos a crear una rama para empezar a trabajar: + +[source,ruby] +---- +$ 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: + +[source,bash] +---- +$ 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: + +[source,ruby] +.db/migrate/20190621105101_add_quantity_to_products.rb +---- +class AddQuantityToProducts < ActiveRecord::Migration[6.0] + def change + add_column :products, :quantity, :integer, default: 0 + end +end +---- + +Ahora podemos migrar la base de datos: + +[source,bash] +---- +$ rake db:migrate +---- + +Y no olvidemos actualizar los _fixtures_ añadiendo el campo *quantity* (Yo elegí el valor `5` de manera aleatoria). + +[source,yml] +.test/fixtures/products.yml +---- +one: + # ... + quantity: 5 + +two: + # ... + quantity: 5 + +another_tv: + # ... + quantity: 5 +---- + + +Es tiempo ahora de reducir la cantidad de productos mientras una `Orden` está siendo procesada. La primera cosa probablemente que viene a la mente es hacerlo en el modelo `Order`. Esto es un misterio común. + +Cuando trabajas con asociaciones _Many-to-Many_ (muchos a muchos), nos olvidamos completamente del modelo de unión que en este caso es `Placement`. `Placement` es el mejor lugar para gestionar esto porque tiene accesos la orden y al producto. De esta forma, podemos fácilmente reducir el stock del producto. + +Antes de empezar a implementar código, necesitamos cambiar la forma que manipulamos la creación de ordenes porque ahora tenemos que aceptar la cantidad para cada producto. Si recuerdas estamos esperando por una tabla de identificadores de producto. Intentaré mantener las cosas simples y enviar una tabla Hash con las llaves `product_id` y `quantity`. + +Un ejemplo rápido podria ser algo como esto: + +[source,ruby] +---- +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: + +[source,ruby] +.test/models/order_test.rb +---- +# ... +class OrderTest < ActiveSupport::TestCase + # ... + + test 'builds 2 placements for the order' do + @order.build_placements_with_product_ids_and_quantities [ + { product_id: @product1.id, quantity: 2 }, + { product_id: @product2.id, quantity: 3 }, + ] + + assert_difference('Placement.count', 2) do + @order.save + end + end +end +---- + + +Entonces en la implementación: + +[source,ruby] +.app/models/order.rb +---- +class Order < ApplicationRecord + # ... + + # @param product_ids_and_quantities [Array] 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: + +[source,bash] +---- +$ 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`: + +[source,ruby] +.test/controllers/api/v1/orders_controller_test.rb +---- +# ... +class Api::V1::OrdersControllerTest < ActionDispatch::IntegrationTest + setup do + @order = products(:one) + @order_params = { + order: { + product_ids_and_quantities: [ + { product_id: products(:one).id, quantity: 2 }, + { product_id: products(:two).id, quantity: 3 }, + ] + } + } + end + + # ... + + test 'should create order with two products and placements' do + assert_difference('Order.count', 1) do + assert_difference('Placement.count', 2) do + post api_v1_orders_url, params: @order_params, as: :json + headers: { Authorization: JsonWebToken.encode(user_id: @order.user_id) }, + end + end + assert_response :created + end +end +---- + +Entonces necesitamos actualizar `orders_controller`: + +[source,ruby] +.app/controllers/api/v1/orders_controller.rb +---- +class Api::V1::OrdersController < ApplicationController + # ... + + def create + order = Order.create! user: current_user + order.build_placements_with_product_ids_and_quantities(order_params[:product_ids_and_quantities]) + + if order.save + OrderMailer.send_confirmation(order).deliver + render json: order, status: :created + else + render json: { errors: order.errors }, status: :unprocessable_entity + end + end + + private + + def order_params + params.require(:order).permit(product_ids_and_quantities: [:product_id, :quantity]) + end +end +---- + + +Nota que también modifique el método `OrdersController#order_params`. + +Por último, pero no menos importante, necesitamos actualizar el archivo que fabrica productos para asignar un valor alto de cantidad para tener algunos productos en stock. + +Hagamos commit de estos cambios y continuemos: + +[source,bash] +---- +$ 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: + +[source,bash] +---- +$ 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í: + +[source,ruby] +.db/migrate/20190621114614_add_quantity_to_placements.rb +---- +class AddQuantityToPlacements < ActiveRecord::Migration[6.0] + def change + add_column :placements, :quantity, :integer, default: 0 + end +end +---- + +Entonces corre las migraciones: + +[source,bash] +---- +$ rake db:migrate +---- + +Ahora agregamos el atributo `quantity` en los _fixtures_: + +[source,yml] +.test/fixtures/placements.yml +---- +one: + # ... + quantity: 5 + +two: + # ... + quantity: 5 +---- + +Ahora solo necesitamos actualizar la prueba `build_placements_with_product_ids_and_quantities` para añadir `quantity` para hacer los pedidos: + +[source,ruby] +.app/models/order.rb +---- +class Order < ApplicationRecord + # ... + + # @param product_ids_and_quantities [Array] 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: + +[source,bash] +---- +$ rake test +........................................ +40 runs, 61 assertions, 0 failures, 0 errors, 0 skips +---- + +Vamos a guardar los cambios: + +[source,bash] +---- +$ 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`. + +[source,ruby] +.test/models/placement_test.rb +---- +# ... +class PlacementTest < ActiveSupport::TestCase + setup do + @placement = placements(:one) + end + + test 'decreases the product quantity by the placement quantity' do + product = @placement.product + + assert_difference('product.quantity', -@placement.quantity) do + @placement.decrement_product_quantity! + end + end +end +---- + +La implementación es bastante fácil como se muestra a continuación: + +[source,ruby] +.app/models/placement.rb +---- +class Placement < ApplicationRecord + # ... + after_create :decrement_product_quantity! + + def decrement_product_quantity! + product.decrement!(:quantity, quantity) + end +end +---- + + +Hagamos _commit_ a nuestros cambios: + +[source,bash] +---- +$ 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 https://guides.rubyonrails.org/active_record_validations.html#performing-custom-validations[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). + +[source,bash] +---- +$ 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. + +[source,ruby] +.test/models/order_test.rb +---- +# ... +class OrderTest < ActiveSupport::TestCase + # ... + + test "an order should command not too much product than available" do + @order.placements << Placement.new(product_id: @product1.id, quantity: (1 + @product1.quantity)) + + assert_not @order.valid? + end +end +---- + +Como puedes ver en la especificación, primero nos aseguramos que `placement_2` este tratando de pedir mas productos de los que están disponibles, así que en este caso suponemos que la `order` (orden) no es válida. + +La prueba por ahora debería fallar, vamos a convertirla en verde añadiendo el código del validador: + +[source,ruby] +.app/validators/enough_products_validator.rb +---- +class EnoughProductsValidator < ActiveModel::Validator + def validate(record) + record.placements.each do |placement| + product = placement.product + if placement.quantity > product.quantity + record.errors[product.title.to_s] << "Is out of stock, just #{product.quantity} left" + end + end + end +end +---- + +Manipulo para añadir el mensaje a cada uno de los producto que están fuera de stock, pero puede manejarlo diferente si quieres. Ahora solamente necesito añadir el validador al modelo `Order` de esta forma: + +[source,ruby] +.app/models/order.rb +---- +class Order < ApplicationRecord + include ActiveModel::Validations + # ... + validates_with EnoughProductsValidator + # ... +end +---- + +Guardemos los cambios: + +[source,bash] +---- +$ 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: + +[source,ruby] +.app/models/order.rb +---- +class Order < ApplicationRecord + # ... + def set_total! + self.total = products.map(&:price).sum + end + # ... +end +---- + +Ahora en lugar de calcular el `total` solo añadiendo el precio del producto necesitamos multiplicarlo por la cantidad. Así que vamos a actualizar las especificaciones primero: + +[source,ruby] +.test/models/order_test.rb +---- +# ... +class OrderTest < ActiveSupport::TestCase + # ... + + test "Should set total" do + @order.placements = [ + Placement.new(product_id: @product1.id, quantity: 2), + Placement.new(product_id: @product2.id, quantity: 2) + ] + @order.set_total! + expected_total = (@product1.price * 2) + (@product2.price * 2) + + assert_equal expected_total, @order.total + end +end +---- + +Y la implementación es muy sencilla: + +[source,ruby] +.app/models/order.rb +---- +class Order < ApplicationRecord + # ... + def set_total! + self.total = self.placements + .map{ |placement| placement.product.price * placement.quantity } + .sum + end + # ... +end +---- + +Y las especificaciones deberían ser verdes: + +[source,bash] +---- +$ rake test +.......................................... +42 runs, 63 assertions, 0 failures, 0 errors, 0 skips +---- + +Vamos a guardar los cambios: + +[source,bash] +---- +$ 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_: + +[source,bash] +---- +$ 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. diff --git a/rails6/es/chapter09-optimization.adoc b/rails6/es/chapter09-optimization.adoc new file mode 100644 index 0000000..c9e6e68 --- /dev/null +++ b/rails6/es/chapter09-optimization.adoc @@ -0,0 +1,739 @@ +[#chapter09-optimization] += 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: + +[source,bash] +---- +$ git checkout tags/checkpoint_chapter09 +---- + +Ahora vamos a crear una rama para empezar a trabajar: + +[source,bash] +---- +$ 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 https://github.com/mislav/will_paginate[will_paginate] ó https://github.com/amatsuda/kaminari[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 http://jsonapi.org/format/#document-structure-top-level[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 https://github.com/amatsuda/kaminari[kaminari] a nuestro `Gemfile`: + +[source,bash] +---- +$ 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: + +[source,ruby] +.app/controllers/api/v1/products_controller.rb +---- +class Api::V1::ProductsController < ApplicationController + # ... + def index + @products = Product.page(params[:page]) + .per(params[:per_page]) + .search(params) + + render json: ProductSerializer.new(@products).serializable_hash + end + # ... +end +---- + +Hasta ahora la única cosa que cambio es la consulta a la base de datos que justamente limita el resultado a 25 por página que es el valor por defecto. Pero no tenemos añadida información extra a la salida JSON. + +Necesitamos proveer la información de paginación en el tag `meta` de la siguiente forma: + +[source,json] +---- +{ + "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-: + +[source,ruby] +.test/controllers/api/v1/products_controller_test.rb +---- +# ... +class Api::V1::ProductsControllerTest < ActionDispatch::IntegrationTest + # ... + test 'should show products' do + get api_v1_products_url, as: :json + assert_response :success + + json_response = JSON.parse(response.body, symbolize_names: true) + assert_not_nil json_response.dig(:links, :first) + assert_not_nil json_response.dig(:links, :last) + assert_not_nil json_response.dig(:links, :prev) + assert_not_nil json_response.dig(:links, :next) + end + # ... +end +---- + +La prueba que acabamos de añadir debería fallar: + +[source,bash] +---- +$ 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: + +[source,ruby] +.app/controllers/concerns/paginable.rb +---- +# app/controllers/concerns/paginable.rb +module Paginable + protected + + def current_page + (params[:page] || 1).to_i + end + + def per_page + (params[:per_page] || 20).to_i + end +end +---- + +Y ahora podemos usarlo en el controlador. + +[source,ruby] +.app/controllers/api/v1/products_controller.rb +---- +class Api::V1::ProductsController < ApplicationController + include Paginable + # ... + + def index + @products = Product.page(current_page) + .per(per_page) + .search(params) + + options = { + links: { + first: api_v1_products_path(page: 1), + last: api_v1_products_path(page: @products.total_pages), + prev: api_v1_products_path(page: @products.prev_page), + next: api_v1_products_path(page: @products.next_page), + } + } + + render json: ProductSerializer.new(@products, options).serializable_hash + end +end +---- + +Ahora, si revisamos las especificaciones, estos deberían pasar todos: + +[source,bash] +---- +$ 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. + +[source,bash] +---- +$ 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`: + +[source,ruby] +.test/controllers/api/v1/orders_controller_test.rb +---- +# ... +class Api::V1::OrdersControllerTest < ActionDispatch::IntegrationTest + # ... + test 'should show orders' do + get api_v1_orders_url, headers: { Authorization: JsonWebToken.encode(user_id: @order.user_id) }, as: :json + assert_response :success + + json_response = JSON.parse(response.body, symbolize_names: true) + assert_equal @order.user.orders.count, json_response[:data].count + assert_not_nil json_response.dig(:links, :first) + assert_not_nil json_response.dig(:links, :last) + assert_not_nil json_response.dig(:links, :prev) + assert_not_nil json_response.dig(:links, :next) + end + # ... +end +---- + +Como ya deberías saber, nuestras pruebas no estarán pasando: + +[source,bash] +---- +$ 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: + + +[source,ruby] +.app/controllers/api/v1/orders_controller.rb +---- +class Api::V1::OrdersController < ApplicationController + include Paginable + # ... + + def index + @orders = current_user.orders + .page(current_page) + .per(per_page) + + options = { + links: { + first: api_v1_orders_path(page: 1), + last: api_v1_orders_path(page: @orders.total_pages), + prev: api_v1_orders_path(page: @orders.prev_page), + next: api_v1_orders_path(page: @orders.next_page), + } + } + + render json: OrderSerializer.new(@orders, options).serializable_hash + end + # ... +end +---- + +Ahora todas las pruebas deberían pasar bien y en verde: + +[source,bash] +---- +$ rake test +.......................................... +42 runs, 67 assertions, 0 failures, 0 errors, 0 skips +---- + + +Hagamos un commit, por que se viene una refactorización: + +[source,bash] +---- +$ 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`: + +[source,ruby] +---- +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: + +[source,ruby] +.test/test_helper.rb +---- +# ... +class ActiveSupport::TestCase + # ... + def assert_json_response_is_paginated json_response + assert_not_nil json_response.dig(:links, :first) + assert_not_nil json_response.dig(:links, :last) + assert_not_nil json_response.dig(:links, :next) + assert_not_nil json_response.dig(:links, :prev) + end +end +---- + +Este método puede ahora ser usado para remplazar las cuatro afirmaciones en los archivos `orders_controller_test.rb` y `products_controller_test.rb`: + +[source,ruby] +.test/controllers/api/v1/orders_controller_test.rb +---- +# ... +class Api::V1::OrdersControllerTest < ActionDispatch::IntegrationTest + # ... + test 'should show orders' do + # ... + assert_json_response_is_paginated json_response + end + # ... +end +---- + +[source,ruby] +.test/controllers/api/v1/products_controller_test.rb +---- +# ... +class Api::V1::ProductsControllerTest < ActionDispatch::IntegrationTest + # ... + test 'should show products' do + # ... + assert_json_response_is_paginated json_response + end + # ... +end +---- + +Y ambas especificaciones deberían pasar. + +[source,bash] +---- +$ 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. + + +[source,ruby] +.app/controllers/concerns/paginable.rb +---- +module Paginable + protected + + def get_links_serializer_options links_paths, collection + { + links: { + first: send(links_paths, page: 1), + last: send(links_paths, page: collection.total_pages), + prev: send(links_paths, page: collection.prev_page), + next: send(links_paths, page: collection.next_page), + } + } + end + # ... +end +---- + +Y ahora podemos sustituir el hash de paginación en ambos controladores para el método. Justo así: + +[source,ruby] +.app/controllers/api/v1/orders_controller.rb +---- +class Api::V1::OrdersController < ApplicationController + include Paginable + # ... + + def index + @orders = current_user.orders + .page(current_page) + .per(per_page) + + options = get_links_serializer_options('api_v1_orders_path', @orders) + + render json: OrderSerializer.new(@orders, options).serializable_hash + end + # ... +end +---- + +[source,ruby] +.app/controllers/api/v1/products_controller.rb +---- +class Api::V1::ProductsController < ApplicationController + include Paginable + # ... + + def index + @products = Product.page(current_page) + .per(per_page) + .search(params) + + options = get_links_serializer_options('api_v1_products_path', @products) + + render json: ProductSerializer.new(@products, options).serializable_hash + end + # ... +end +---- + +Si corres las especificaciones para cada archivo deberían estar todas bien y verdes: + +[source,bash] +---- +$ 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é: + +[source,bash] +---- +$ 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: + +[source,bash] +---- +$ 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! + +[source,ruby] +.app/serializers/order_serializer.rb +---- +class OrderSerializer + # ... + cache_options enabled: true, cache_length: 12.hours +end +---- + +[source,ruby] +.app/serializers/product_serializer.rb +---- +class ProductSerializer + # ... + cache_options enabled: true, cache_length: 12.hours +end +---- + +[source,ruby] +.app/serializers/user_serializer.rb +---- +class UserSerializer + # ... + cache_options enabled: true, cache_length: 12.hours +end +---- + +¡Y esto es todo! Vamos a revisar la mejora: + +[source,bash] +---- +$ 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: + +[source,ruby] +---- +$ 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: + +[source,ruby] +---- +Product.all.map { |product| product.user } +---- + +La consola interactiva de rails nos muestra consultas SQL que son generadas. Mira por ti mismo: + +Vemos aquí que un largo número de peticiones son generadas: + +- `Product.all` = 1 petición para recuperar los productos +- `product.user` = 1 petición `SELECT "users".* FROM "users" WHERE "users". "id" =? LIMIT 1 [[[["id", 1]]]` por producto recuperado + +Por lo tanto el nombre "petición N+1" es ya que una solicitud se realiza a través de un enlace secundario. + +Podemos arreglar esto simplemente usando `includes`. `Includes` **pre-cargará** los objetos secundarios en una simple petición. Es muy fácil de usar. Si repetimos el ejemplo anterior. Este es el resultado: + +[source,ruby] +---- +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: + +[source,sql] +---- +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: + +[source,ruby] +.app/controllers/api/v1/products_controller.rb +---- +class Api::V1::ProductsController < ApplicationController + # ... + def index + # ... + options = get_links_serializer_options('api_v1_products_path', @products) + options[:include] = [:user] + + render json: ProductSerializer.new(@products, options).serializable_hash + end + # ... +end +---- + +Ahora vamos a hacer ua petición con cURL. Te recuerdo que nosotros debimos obtener un token de autenticación antes de acceder a la pagina. + +[source,bash] +---- +$ 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 + +[source,bash] +---- +$ 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. + +[source,sql] +---- +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: https://github.com/flyerhzm/bullet[Bullet]. Bullet nos notificará (por correo, http://growl.info/[growl notification], https://slack.com[Slack], consola, etc...) cuando encuentra una petición N+1. + +Para instalarla, vamos añadir la _gema_ al _GemFile_ + +[source,bash] +---- +$ 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: + +[source,ruby] +.config/environments/development.rb +---- +Rails.application.configure do + # ... + config.after_initialize do + Bullet.enable = true + Bullet.rails_logger = true + end +end +---- + +Reinicia el servidor web y reinicia la última petición con cURL: + +[source,bash] +---- +$ 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: + + +[source,ruby] +.app/controllers/api/v1/products_controller.rb +---- +class Api::V1::ProductsController < ApplicationController + # ... + def index + @products = Product.includes(:user) + .page(current_page) + .per(per_page) + .search(params) + + options = get_links_serializer_options('api_v1_products_path', @products) + options[:include] = [:user] + + render json: ProductSerializer.new(@products, options).serializable_hash + end + # ... +end +---- + +¡Ahí tienes! Es tiempo de hacer nuestro _commit_. + +[source,bash] +---- +$ 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`. + + +[source,ruby] +.config/initializers/cors.rb +---- +# ... + +# Rails.application.config.middleware.insert_before 0, Rack::Cors do +# allow do +# origins 'example.com' +# +# resource '*', +# headers: :any, +# methods: [:get, :post, :put, :patch, :delete, :options, :head] +# end +# end +---- + +Ves. Es suficiente con quitar los comentarios del código y modificar un poco para limitar el acceso a algunos acciones o algunos verbos HTTP. En nuestro caso, esta configuración es muy conveniente para nosotros en este momento. + +[source,ruby] +.config/initializers/cors.rb +---- +# ... + +Rails.application.config.middleware.insert_before 0, Rack::Cors do + allow do + origins 'example.com' + resource '*', + headers: :any, + methods: [:get, :post, :put, :patch, :delete, :options, :head] + end +end +---- + +Debemos instalar la gema `rack-cors` que esta comentada en el `Gemfile`: + +[source,bash] +---- +$ bundle add rack-cors +---- + +¡Ahí tienes! Es tiempo de hacer nuestro último commit y fusionar nuestros cambios en la rama master. + + +[source,bash] +---- +$ 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 https://www.amazon.com/[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 https://asciidoctor.org[Asciidoctor] en https://github.com/asciidoctor/asciidoctor[GitHub]. Así que no dudes en https://github.com/madeindjs/api_on_rails[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 mailto:contact@rousseau-alexandre.fr[contact@rousseau-alexandre.fr]. Estoy abierto cualquier crítica, buena o mala, junto a una buena cerveza :). diff --git a/rails6/es/img/cover.svg b/rails6/es/img/cover.svg new file mode 100644 index 0000000..be9d63c --- /dev/null +++ b/rails6/es/img/cover.svg @@ -0,0 +1,155 @@ + + + + + + + + image/svg+xml + + + + + + + + + + Alexandre Rousseau + + + "APIonRails":6 + + + + + + + { + } + + + + diff --git a/rails6/es/img/data_model.png b/rails6/es/img/data_model.png new file mode 100644 index 0000000..468185d Binary files /dev/null and b/rails6/es/img/data_model.png differ diff --git a/rails6/es/img/logo.svg b/rails6/es/img/logo.svg new file mode 100644 index 0000000..7e46d25 --- /dev/null +++ b/rails6/es/img/logo.svg @@ -0,0 +1,139 @@ + + + + + + + + + + image/svg+xml + + + + + + + + "APIonRails":6 + + + + + + + { + } + + + + diff --git a/rails6/es/img/pow_running.png b/rails6/es/img/pow_running.png new file mode 100644 index 0000000..9c6ffde Binary files /dev/null and b/rails6/es/img/pow_running.png differ