Cómo crear una API REST en Rails testeada, documentada y con buenas prácticas en 1 minuto y 54 segundos

Software abr. 03, 2020

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 ;)

¡Genial! Te has suscrito con éxito.
¡Genial! Ahora, completa el checkout para tener acceso completo.
¡Bienvenido de nuevo! Has iniciado sesión con éxito.
Éxito! Su cuenta está totalmente activada, ahora tienes acceso a todo el contenido.