Cómo crear una API REST en Rails testeada, documentada y con buenas prácticas en 1 minuto y 54 segundos
Por la precisión del tiempo en el título se darán cuenta de que no estoy hablando de una estimación. Casi 2 minutos es realmente el tiempo que me tomó crear la típica API de blog posts con sus comentarios.
Los modelos:
class Blog < ApplicationRecord
has_many :comments
end
class Comment < ApplicationRecord
belongs_to :blog
end
Los endpoints:
GET /api/v1/blogs
POST /api/v1/blogs
GET /api/v1/blogs/:id
PUT /api/v1/blogs/:id
DELETE /api/v1/blogs/:id
GET /api/v1/comments/:id
PUT /api/v1/comments/:id
DELETE /api/v1/comments/:id
GET /api/v1/blogs/:blog_id/comments
POST /api/v1/blogs/:blog_id/comments
Suponiendo que me creen el “casi 2 minutos”, imagino que estarán pensado que en ese tiempo no se puede hacer nada o quizás, con mucha fe y velocidad, se podría algo como esto:
class BlogsController < ApplicationController
before_action :set_blog, only: [:show, :update, :destroy]
def index
[@blogs](http://twitter.com/blogs) = Blog.all
render json: [@blogs](http://twitter.com/blogs)
end
def show
render json: [@blog](http://twitter.com/blog)
end
def create
[@blog](http://twitter.com/blog) = Blog.new(blog_params)
if [@blog](http://twitter.com/blog).save
render json: [@blog](http://twitter.com/blog), status: :created
else
render json: [@blog](http://twitter.com/blog).errors, status: :unprocessable_entity
end
end
def update
if [@blog](http://twitter.com/blog).update(blog_params)
render json: [@blog](http://twitter.com/blog)
else
render json: [@blog](http://twitter.com/blog).errors, status: :unprocessable_entity
end
end
def destroy
[@blog](http://twitter.com/blog).destroy
end
private
def set_blog
[@blog](http://twitter.com/blog) = Plop.find(params[:id])
end
def blog_params
params.require(:blog).permit(:title, :body)
end
end
A simple vista parece suficiente pero veamos algunos problemas:
En cada endpoint se está especificando el status que debe retornar. Un poco verboso siendo que en un CRUD común y corriente un POST a la colección siempre devuelve un HTTP status code 201 y un GET a un recurso un 200, ¿verdad?
En el update o create se están manejando los errores de validación ¿Eso quiere decir que en todos los endpoints donde tenga validaciones voy a tener la misma lógica? ojalá que no.
Para devolver la respuesta se usa render json: .... Esto es un poco peligroso (a nivel de buenas prácticas) porque anima al desarrollador a no respetar una estructura. Es decir, invita a tener formatos de respuesta diferentes en los distintos endpoints de la API. Poco estándar, ¿no?
El BlogsController hereda directamente de ApplicationController. Esto es malo porque, salvo que nuestra app sea solo una API, de ahí también heredan los controllers de nuestra aplicación web. El problema con esto es que si tenemos solo un controlador base (el ApplicationController), muchos métodos globales de la app no tendrán sentido en el contexto de la API y viceversa.
No hay manejo de versiones. Quizás hoy no es un problema pero si mañana tenemos que sacar la versión 2 de está increíble y original idea, vamos a extrañar no haber manejado este problema de entrada.
No hay documentación. Una API no documentada es una que no se usa. Nada más que decir.
Y los endpoints de los comentarios?
- Finalmente, el mayor problema es que la API no funciona! ¿¡cómo!?, ¿¡dónde!? En el método set_blog escribí por error Plop en lugar de Blog . Sí, ni lo digan… Esto con tests no pasaba.
Imagino que a esta altura ya estarán pensando: bueno ya, ¿cómo hiciste para crear una API que no tenga los problemas mencionados anteriormente en ese tiempo? La respuesta es: ¡no lo hice! La gema Power API que desarrollamos en Platanus lo hizo por mí.
La prueba:
El resultado:
Explicación en pasos:
1 ~ Ejecuté el instalador de la gema:
rails g power_api:install
Es necesario correr este comando para tener el setup inicial sobre el que se apoyará nuestra API. Puedes ver el detalle de lo que se genera/instala/configura aquí.
2 ~ Generé el controller de blogs
rails g power_api:controller blog --allow-filters --use-paginator --version-number=1 --attributes=title body
Esto agrega el controlador:
class Api::V1::BlogsController < Api::V1::BaseController
def index
respond_with paginate(filtered_collection(Blog.all))
end
def show
respond_with blog
end
def create
respond_with Blog.create!(blog_params)
end
def update
respond_with blog.update!(blog_params)
end
def destroy
respond_with blog.destroy!
end
private
def blog
[@blog](http://twitter.com/blog) ||= Blog.find_by!(id: params[:id])
end
def blog_params
params.require(:blog).permit(
:title,
:body
)
end
end
El serializer:
class Api::V1::BlogSerializer < ActiveModel::Serializer
type :blog
attributes(
:title,
:body
)
end
Los tests:
require ‘swagger_helper’
describe ‘API V1 Blogs’, swagger_doc: ‘v1/swagger.json’ do
path ‘/blogs’ do
get ‘Retrieves Blogs’ do
description ‘Retrieves all the blogs’
produces ‘application/json’
let(:collection_count) { 5 }
let(:expected_collection_count) { collection_count }
before { create_list(:blog, collection_count) }
response ‘200’, ‘Blogs retrieved’ do
schema(‘$ref’ => ‘#/definitions/blogs_collection’)
run_test! do |response|
expect(JSON.parse(response.body)[‘data’].count).to eq(expected_collection_count)
end
end
end
post ‘Creates Blog’ do
description ‘Creates Blog’
consumes ‘application/json’
produces ‘application/json’
parameter(name: :blog, in: :body)
response ‘201’, ‘blog created’ do
let(:blog) do
{
title: ‘Some title’,
body: ‘Some body’
}
end
run_test!
end
end
end
path ‘/blogs/{id}’ do
parameter name: :id, in: :path, type: :integer
let(:existent_blog) { create(:blog) }
let(:id) { existent_blog.id }
get ‘Retrieves Blog’ do
produces ‘application/json’
response ‘200’, ‘blog retrieved’ do
schema(‘$ref’ => ‘#/definitions/blog_resource’)
run_test!
end
response ‘404’, ‘invalid blog id’ do
let(:id) { ‘invalid’ }
run_test!
end
end
put ‘Updates Blog’ do
description ‘Updates Blog’
consumes ‘application/json’
produces ‘application/json’
parameter(name: :blog, in: :body)
response ‘200’, ‘blog updated’ do
let(:blog) do
{
title: ‘Some title’,
body: ‘Some body’
}
end
run_test!
end
end
delete ‘Deletes Blog’ do
produces ‘application/json’
description ‘Deletes specific blog’
response ‘204’, ‘blog deleted’ do
run_test!
end
response ‘404’, ‘blog not found’ do
let(:id) { ‘invalid’ }
run_test!
end
end
end
end
El resultado:
3 ~ Generé el controller de comments
rails g power_api:controller comment --attributes=body --parent-resource=blog
Este paso es similar al anterior pero, por haber especificado la opción --parent-resource=blog, el recurso se creará “nesteado” en blogs
class Api::V1::CommentsController < Api::V1::BaseController
def index
respond_with comments
end
def show
respond_with comment
end
def create
respond_with comments.create!(comment_params)
end
def update
respond_with comment.update!(comment_params)
end
def destroy
respond_with comment.destroy!
end
private
def comment
[@comment](http://twitter.com/comment) ||= Comment.find_by!(id: params[:id])
end
def comments
[@comments](http://twitter.com/comments) ||= blog.comments
end
def blog
[@blog](http://twitter.com/blog) ||= Blog.find_by!(id: params[:blog_id])
end
def comment_params
params.require(:comment).permit(
:body
)
end
end
4 ~ Generé la documentación
rails rswag:specs:swaggerize
Gracias a la gema rswag que incluye Power API podemos generar, a partir de los tests, la documentación. Esto se hace con el comando anterior.
5 ~ Corrí los tests
bundle exec rspec spec/integration
Para probar que todo se creó correctamente.
6 ~ Levanté el server.
rails s
Para probar manualmente que todo funcionó bien :)
Ahora que parece que todo funcionó correctamente, repasemos una vez más los problemas que encontré en la versión inicial para confirmar que la gema los resuelve:
En cada endpoint se está especificando el status …
Ya no es necesario porque la gema incluye un api_responder que maneja el HTTP status code desde los HTTP verbs.
En el update o create se están manejando los errores de validación…
No se hace más en el controller ya que se usa un concern que viene incluido para manejar excepciones.
Para devolver la respuesta se usa render json: .... Esto es un poco peligroso (a nivel de buenas prácticas) porque anima al desarrollador a no respetar una estructura…
Ahora se utilizan serializers de la gema Active Model Serializers configurada para devolver respuestas con el formato de json api.
El BlogsController hereda directamente de ApplicationController …
Power API genera una estructura que permite que cada versión de la API tenga su propio BaseController que además hereda de uno pensado para el manejo exclusivo de APIs.
No hay manejo de versiones…
El manejo de versiones lo hacemos con la gema versionist.
No hay documentación…
La creamos con rswag.
Finalmente, el mayor problema es que la API no funciona!
Power API genera los specs ;)