Rails API + Cache + Design Patterns + RSpec (Version En Español)

danielpenaloza

Daniel

Posted on November 23, 2024

Rails API + Cache + Design Patterns + RSpec (Version En Español)

Hey que tal el dia hoy vengo a presentarles un tutorial de como crear un API en Ruby On Rails, sin embargo en este tutorial quiero salirme un poco fuera de la caja de la simple creacion de un CRUD y por lo tanto lo que crearemos lo describo enseguida:

Configuracion de la aplicacion.

Conectarnos a un API externa para obtener elementos por medio de:

  • Faraday: Con esta gema crearemos la conexion y llamadas posteriores a el cliente.
  • VCR: Aqui gracias a este registraremos las llamadas HTTP a el cliente y utilizaremos cassettes (archivos generados por esta gema en formato yml) para poder crear pruebas de nuestras peticiones.

Pruebas en la aplicacion:

  • RSpec: Una de mis gemas favoritas.

El api al que nos conectaremos sera al de pokemon:

Agregaremos el patron de diseño proxy con la finalidad de guardar en cache la consulta al cliente por un periodo de 24 horas.

De igual forma vamos utilizar el Factory Method Pattern para poder crear las respuestas que retornaremos sean exitosas o fallidas. - https://refactoring.guru/design-patterns/factory-method

El repositorio de la aplicacion puede ser encontrado enseguida:

Pokemon Client API

Comentado lo anterior empezaremos por crear nuestra aplicacion, indicando que desamos crear la aplicacion sin el suite de pruebas por defecto y ademas que sera una API con el siguiente comando:

rails new api_project —api -T

Una vez creada la aplicacion procederemos a configurar nuestra aplicacion para poder crear pruebas y empezar a escribir codigo.
Empezaremos por abrir nuestro Gemfile y agregar las siguientes gemas en su ambiente respectivo como se mira enseguida:

group :test do
  gem 'vcr', '~> 6.3', '>= 6.3.1'
end

group :development, :test do
  # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
  gem "debug", platforms: %i[ mri windows ]
  gem 'rspec-rails', '~> 7.1'
end

# HTTP CLIENT
gem 'faraday', '~> 2.12', '>= 2.12.1'

# Redis - quitar el comentario de esta linea
gem "redis", ">= 4.0.1"
Enter fullscreen mode Exit fullscreen mode

Procederemos a instalar nuestras gemas con un bundle install y posteriormente a configurar RSpec y VCR.

  • Configuracion de RSpec.

    Utilizaremos el comando rails generate rspec:install lo cual nos creara una serie de archivos para configurar nuestra suite de pruebas y asi poder empezar a generar pruebas para nuestra aplicacion.

  • Configuracion VCR.

    Una vez instalada RSpec procederemos a abrir nuestro archivo rails_helper.rb y justo arriba del bloque de configuracion de RSpec procederemos a agregar el siguiente codigo:

require 'vcr'

VCR.configure do |c|
  c.cassette_library_dir = 'spec/vcr_cassettes'
  c.hook_into :faraday

  c.configure_rspec_metadata!
  c.default_cassette_options = {
    record: :new_episodes
  }
end
Enter fullscreen mode Exit fullscreen mode
  • Por ultimo debemos de activar el cache en nuestro ambiente de desarrollo ya que solo esta habilitado en produccion por defecto y esto lo haremos en la terminal ejecutando el siguiente comando: rails dev:cache

Una vez que terminamos esta configuracion basica es momento de empezar a tirar codigo y esto lo haremos no de la manera convencional de TDD es decir crear pruebas, que pase el codigo y despues refactorizar (red - green -refactor), debido a que en lo personal yo me siento mas comodo creando primero el codigo y despues generando las pruebas (por mi falta de experiencia posiblemente).

Sea como sea siempre debemos crear pruebas para nuestro codigo, en lo personal a mi siempre me gusta tener pruebas para reducir el gap de errores que pueden presentarse una vez que tengamos nuestra aplicacion lista para ser utilizada.

Dicho lo anterior empezaremos por agregar una nueva ruta en nuestra aplicacion dentro del archivo routes.rb como se muestra enseguida:

  namespace :api do
    namespace :v1 do
      get '/pokemon', to: 'pokemons#pokemon'
    end
  end
Enter fullscreen mode Exit fullscreen mode

Como vemos aqui estamos creando un namespace tanto para api como para la version de esta y esto lo hacemos como buena practica en caso de que en un futuro quisieramos tener una version 2 de la api con nuevas caracteristicas.

Despues procederemos a crear el controlador pokemons dentro de app/controllers/api/v1/pokemons_controller.rb con la siguiente estructura:

module Api
  module V1
    class PokemonsController < ApplicationController
      def pokemon
        if params[:pokemon_name].present?
          response = get_pokemon(pokemon_name: params[:pokemon_name])
          render json: response, status: :ok
        else
          render json:  { 'error' => 'please provide a valid parameter' }, status: :unprocessable_entity
        end
      end

      private

      def get_pokemon(pokemon_name:)
        ::V1::GetPokemonService.new(pokemon_name:).call
      end
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Aqui estamos creando nuestro metodo pokemon el cual revisa que desde donde hagamos la peticion este presente el parametro pokemon_name al momento de hacer una busqueda y en caso omiso retornaremos un error indicando que el parametro no es valido.

Es decir la unica URI valida sera la siguiente:
http://localhost:3000/api/v1/pokemon?pokemon_name=nombre_de_pokemon

Siguiendo con el flujo de nuestra api estamos mandando a llamar a un metodo privado con el nombre de get_pokemon el cual acepta el parametro pokemon_name.

Este es pasado a una nueva instancia del servicio GePokemonService el cual como es un servicio es llamado directamente con el metodo call.

Esta clase debera de estar dentro del directorio services/v1/get_pokemon_service.rb y tendra la siguiente estructura:

module V1
  class GetPokemonService
    attr_reader :pokemon_name

    def initialize(pokemon_name:)
      @pokemon_name = pokemon_name
    end

    def call
      get_pokemon
    end

    private

    def get_pokemon
      client = WebServices::PokemonConnection.new
      proxy = PokemonProxy.new(client)
      proxy.get_pokemon(pokemon_name:)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

En este punto llegamos a algo interesante y esto es el uso del patron proxy el cual nos permite tener un sustituto de un objeto para controlar su acceso.

Pero primero lo primero, tenemos una variable llamada client la cual es una instancia de la clase Faraday para conectarnos a el cliente externo con una configuracion especifica que pasamos dentro del un bloque. Esta clase debera de estar dentro del directorio web_services/pokemon_connection.rb

module WebServices
  class PokemonConnection
    def client(read_timeout = 30)
      Faraday.new(url: 'https://pokeapi.co/api/v2/') do |conn|
        conn.options.open_timeout = 30
        conn.options.read_timeout = read_timeout
        conn.request :json
        conn.response :logger, nil, { headers: false, bodies: false, errors: false }
        conn.response :json
        conn.adapter :net_http
      end
    end

    def get_pokemon(pokemon_name:)
      response = client.get("pokemon/#{pokemon_name}")
    rescue Faraday::Error => e
      { 'error' => e }
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Si analizamos un poco el codigo en este metodo hacemos una conexion directa al api mediante la instanciacion de Faraday pasandole como parametro la url a la que nos queremos conectar. Es necesario aclarar que este metodo no se ejecutara hasta que nosotros lo decidamos y en este caso sera mediante la llamada a el metodo get_pokemon cuando hacemos uso del metodo client.get.

Si les interesa conocer mas detalles de Faraday pueden revisar su documentacion en el siguiente vinculo:

https://lostisland.github.io/faraday

El metodo get_pokemon toma el nombre del pokemon y lo que hacemos enseguida es mandar a llamar a client.get(”/pokemon/pikachu”). Por ejemplo en donde el valor de client antes de hacer uso del metodo get sera https://pokeapi.co/api/v2/.
client.get("pokemon/pikachu")

Cuando estamos ejecutando el codigo anterior dentro de nuestro metodo get_pokemon en realidad estamos haciendo un get hacia la siguiente URI:
GET https://pokeapi.co/api/v2/pokemon/pikachu

Si todo es correcto obtendremos una respuesta con toda la informacion del pokemon pikachu y lo podemos probar justo en una ventana de nuestro navegador agregando la direccion https://pokeapi.co/api/v2/pokemon/pikachu con la finalidad de revisar el resultado de lo que nos respondera el API externo.

Acto seguido contamos con nuestro proxy el cual debe de tener la misma interfaz que la clase PokemonConnection esto quiere decir que deberemos de tener el metodo get_pokemon dentro de ella. Su ubicacion sera dentro de app/proxies/pokemon_proxy.rb y tendra el siguiente contenido:

class PokemonProxy
  EXPIRATION = 24.hours.freeze

  attr_reader :client, :cache

  def initialize(client)
    @client = client
    @cache = Rails.cache
  end

  def get_pokemon(pokemon_name:)
    return pokemon_response('on_cache', cache.read("pokemon_cached/#{pokemon_name}")) if cache.exist?("pokemon_cached/#{pokemon_name}")

    response = client.get_pokemon(pokemon_name:)
    if response.status == 200
      response.body['consulted_at'] = consulted_at
      cache.write("pokemon_cached/#{pokemon_name}", response, expires_in: EXPIRATION)
      pokemon_response('client_call', response)
    else
      pokemon_error_response(response)
    end
  end

  private

  def consulted_at
    Time.now.utc.strftime('%FT%T')
  end

  def pokemon_response(origin, response)
    {
      'origin': origin,
      'name': response.body['name'],
      'weight': response.body['weight'],
      'types': type(response.body['types']),
      'stats': stats(response.body['stats']),
      'consulted_at': response.body['consulted_at']
    }
  end

  def stats(stats)
    stats.each_with_object([]) do |stat, array|
      array << "#{stat.dig('stat', 'name')}: #{stat['base_stat']}"
    end
  end

  def type(types)
    types.map { |type| type.dig('type', 'name') }
  end

  def pokemon_error_response(response)
    {
      'error': response.body,
      'status': response.status
    }
  end
end
Enter fullscreen mode Exit fullscreen mode

Desde mi punto de vista esta clase esta haciendo demasiado y tiene mas de una responsabilidad; sin embargo para terminos de enseñanza considero que esta bien ya que al programar tambien debemos de refactorizar y esto lo haremos mas adelante ya que terminemos de crear las pruebas para nuestra API.

Empezaremos por explicar el metodo inicializador este cuenta con un cliente que le pasamos desde su llamada en la clase anterior y ademas agregamos a la variable de instancia @cache inicializando Rails.cache.

# Llamada de clase anterior
client = WebServices::PokemonConnection.new
proxy = PokemonProxy.new(client)    

def initialize(client)
  @client = client
  @cache = Rails.cache
end
Enter fullscreen mode Exit fullscreen mode

Despues tenemos el metodo get_pokemon que como comentamos anteriormente esta clase Proxy debe de tener la misma interfaz que el cliente y la explicaremos enseguida.

    return pokemon_response('on_cache', cache.read("pokemon_cached/#{pokemon_name}")) if cache.exist?("pokemon_cached/#{pokemon_name}")

    response = client.get_pokemon(pokemon_name:)
    if response.status == 200
      response.body['consulted_at'] = consulted_at
      cache.write("pokemon_cached/#{pokemon_name}", response, expires_in: EXPIRATION)
      pokemon_response('client_call', response)
    else
      pokemon_error_response(response)
    end
  end
Enter fullscreen mode Exit fullscreen mode

En la primera linea lo que hacemos es retornar un pokemon_response (veremos a continuacion el metodo) siempre y cuando la llave para cache (en este caso “pokemon_cached/pikachu”) exista con los argumentos on_cache y la llave anteriormente mencionada en cache.

En caso de que no exista esa llave para cache lo que hacemos es hacer uso el client que pasamos por parametro al momento de inicializar nuestra instancia y mandamos a llamar al metodo get_pokemon, en donde si el estatus de la respuesta es un 200 entonces a la respuesta que es un hash le agregaremos un nuevo elemento llamado consulted_at que sera la fecha y hora en la que se esta haciendo la consulta al cliente.

Posteriormente procedemos a guardar la respuesta de la llamada al cliente con la llave pokemon_cached/nombre_del_pokemon y ademas pasamos una tiempo de expiracion para esta respuesta con el fin de que durante 24 horas este disponible en cache y asi en futuras llamadas no vayamos a el cliente a preguntarle sobre el pokemon en cuestion.

Enseguida mandamos a llamar a el metodo pokemon_response con los argumentos client_call y la respuesta obtenida por parte del cliente.

En caso de que el estatus de la respuesta del cliente no haya sido favorable (diferente a 200) retornaremos un pokemon_error_response con la respuesta del cliente como argumento.

  def consulted_at
    Time.now.utc.strftime('%FT%T')
  end

  def pokemon_response(origin, response)
    {
      'origin': origin,
      'name': response.body['name'],
      'weight': response.body['weight'],
      'types': type(response.body['types']),
      'stats': stats(response.body['stats']),
      'consulted_at': response.body['consulted_at']
    }
  end

  def pokemon_error_response(response)
    {
      'error': response.body,
      'status': response.status
    }
  end
Enter fullscreen mode Exit fullscreen mode

Haciendo referencia a los metodos anteriormente explicados tenemos en primer lugar a consulted_at el cual su unica funcion es brindarnos la fecha y hora cuando es llamado.

pokemon_response cuenta con los parametros origin y response con los cuales construye un objeto del tipo Hash. La primera invocacion a este metodo nos debera de arrojar lo siguiente:

{
  :origin => "client_call",
  :name => "pikachu",
  :weight => 60,
  :types => ["electric"],
  :stats => [
    "hp: 35",
    "attack: 55",
    "defense: 40",
    "special-attack: 50",
    "special-defense: 50",
    "speed: 90"
   ],
   :consulted_at=>"2024-11-21T02:00:20"
}
Enter fullscreen mode Exit fullscreen mode

En una segunda y futuras invocaciones a este metodo tendremos como resultado lo siguiente:

{
  :origin => "on_cache",
  :name => "pikachu",
  :weight => 60,
  :types => ["electric"],
  :stats => [
    "hp: 35",
    "attack: 55",
    "defense: 40",
    "special-attack: 50",
    "special-defense: 50",
    "speed: 90"
   ],
   :consulted_at=>"2024-11-21T02:00:20"
}
Enter fullscreen mode Exit fullscreen mode

Mientras que nuestro metodo error_response nos dara el siguiente resultado:

{
    "error": "Not Found",
    "status": 404
}
Enter fullscreen mode Exit fullscreen mode

Hasta este punto nuestro API ya estara funcional y si lo queremos probar lo podemos hacer de la siguiente manera:

  • Iniciamos nuestro servidor de Rails con el comando rails s.
  • En otra terminal abriremos una consola de Rails y agregaremos lo siguiente:
require 'net/http'
require 'uri'

url = 'http://localhost:3000/api/v1/pokemon?pokemon_name=pikachu'
uri = URI(url)

response = Net::HTTP.get_response(uri)
response.body

# Resultado
"{\"origin\":\"client_call\",\"name\":\"pikachu\",\"weight\":60,\"types\":[\"electric\"],\"stats\":[\"hp: 35\",\"attack: 55\",\"defense: 40\",\"special-attack: 50\",\"special-defense: 50\",\"speed: 90\"],\"consulted_at\":\"2024-11-21T02:13:03\"}"
Enter fullscreen mode Exit fullscreen mode

Etapa De Pruebas

Para poder generar las pruebas debemos de pensar en la funcionalidad que actualmente tenemos y que nos da como resultado. Por lo tanto si analizamos nuestro codigo contamos con los siguientes escenarios:

1.- Tendremos un caso exitoso cuando el nombre del pokemon sea valido:

  • La primera peticion a este endpoint debe de traernos el origin como client_call.
  • La segunda peticion a este endpoint debe de traernos el origin como on_cache.

2.- Tendremos un caso fallido cuando:

  • El nombre del pokemon sea invalido.
  • El parametro pokemon_name no este presente como query params.

Basado en esto que conocemos de nuestra aplicacion procederemos a crear la siguiente prueba dentro de spec/requests/pokemons_spec.rb:

require 'rails_helper'

RSpec.describe Api::V1::PokemonsController, type: :request, vcr: { record: :new_episodes } do
  let(:memory_store) { ActiveSupport::Cache.lookup_store(:memory_store) }
  let(:pokemon_name) { 'pikachu' }

  before do
    allow(Rails).to receive(:cache).and_return(memory_store)
  end

  describe 'GET /api/v1/pokemon' do
    context 'with a valid pokemon' do
      it 'returns data from the client on the first request and caches it for subsequent requests' do
        # first call - fetch data from the client
        get "/api/v1/pokemon?pokemon_name=#{pokemon_name}"
        expect(response.status).to eq(200)
        pokemon = parse_response(response.body)
        expect(pokemon['origin']).to eq('client_call')
        pokemon_information(pokemon)
        expect(Rails.cache.exist?("pokemon_cached/#{pokemon_name}")).to eq(true)

        # second call - fetch data from cache
        get "/api/v1/pokemon?pokemon_name=#{pokemon_name}"
        pokemon = parse_response(response.body)
        expect(pokemon['origin']).to eq('on_cache')
        pokemon_information(pokemon)
      end
    end

    context 'with an invalid pokemon' do
      it 'returns an error' do
        get '/api/v1/pokemon?pokemon_name=unknown_pokemon'
        error = parse_response(response.body)
        expect(error['error']).to eq('Not Found')
        expect(error['status']).to eq(404)
      end
    end

    context 'with invalid parameters' do
      it 'returns an error' do
        get '/api/v1/pokemon?pokemon_namess=unknown_pokemon'
        error = parse_response(response.body)
        expect(error['error']).to eq('Invalid Parameters')
      end
    end
  end

  # Helper methods
  def pokemon_information(pokemon)
    expect(pokemon['name']).to eq('pikachu')
    expect(pokemon['weight']).to eq(60)
    expect(pokemon['types']).to eq(['electric'])
    expect(pokemon['stats']).to eq([
      'hp: 35',
      'attack: 55',
      'defense: 40',
      'special-attack: 50',
      'special-defense: 50',
      'speed: 90'
    ])
    expect(pokemon['consulted_at']).to be_present
  end

  def parse_response(response)
    JSON.parse(response)
  end
end
Enter fullscreen mode Exit fullscreen mode

En la primera linea nos encontraremos con el uso de VCR en donde solo se utilizara para las peticiones que hagamos a el cliente de pokemon en donde esperamos una respuesta exitosa o fallida segun como nos responda este.

Despues empezamos por crear dos lets los cuales se explican enseguida:

  • memory_store: Esta creara una instancia de cache.
  • pokemon_name: Definimos el nombre del pokemon que utilizaremos para mantener nuestro codigo DRY.

Enseguida contamos con un bloque before en donde unicamente stubeamos Rails.cache con la finalidad de que nos retorne una instancia de cache.

Y ahora si empezamos a probar nuestro endpoint que creamos con el siguiente bloque de codigo:

it 'returns data from the client on the first request and caches it for subsequent requests' do
  # first call - fetch data from the client
  get "/api/v1/pokemon?pokemon_name=#{pokemon_name}"
  expect(response.status).to eq(200)
  pokemon = parse_response(response.body)
  expect(pokemon['origin']).to eq('client_call')
  pokemon_information(pokemon)
  expect(Rails.cache.exist?("pokemon_cached/#{pokemon_name}")).to eq(true)

  # second call - fetch data from cache
  get "/api/v1/pokemon?pokemon_name=#{pokemon_name}"
  pokemon = parse_response(response.body)
  expect(pokemon['origin']).to eq('on_cache')
  pokemon_information(pokemon)
end
Enter fullscreen mode Exit fullscreen mode

Lo que hacemos en esta prueba es bien sencillo:

  • Mandamos a llamar a nuestro endpoint pokemon en donde le pasamos el query param pokemon_name con el nombre del pokemon (pikachu).
  • Esperamos que la respuesta obtenida sea un 200.
  • Parseamos el body de la respuesta obtenida.
  • El campo de nuestro json con el valor de origin debera de ser igual a client_call.
  • Posterior a esto validamos que el json retornado sea justo como nosotros lo deseamos, es decir:
  def pokemon_information(pokemon)
    expect(pokemon['name']).to eq('pikachu')
    expect(pokemon['weight']).to eq(60)
    expect(pokemon['types']).to eq(['electric'])
    expect(pokemon['stats']).to eq([
      'hp: 35',
      'attack: 55',
      'defense: 40',
      'special-attack: 50',
      'special-defense: 50',
      'speed: 90'
    ])
    expect(pokemon['consulted_at']).to be_present
  end
Enter fullscreen mode Exit fullscreen mode
  • Enseguida esperamos que se haya almacenado la respuesta obtenida con la llave pokemon_cached/pikachu.
  • Volvemos a llamar a nuestro endpoint pokemon justo con las mismas caracteristicas de la primera peticion.
  • Ahora esperamos que el campo de nuestro json con el valor de origin sea igual a on_cache.
  • Por ultimo validamos de nuevo que el json retornado cumpla con las caracteristicas necesarias.

Ahora vamos con los casos fallidos y el primero de estos nos retornara un 404 debido a que no se encontro el pokemon que le pasamos por parametro, no hay mucho que hablar esta es una prueba muy sencilla y esto es lo que esperamos.

context 'with an invalid pokemon' do
  it 'returns an error' do
    get '/api/v1/pokemon?pokemon_name=unknown_pokemon'
    error = parse_response(response.body)
    expect(error['error']).to eq('Not Found')
    expect(error['status']).to eq(404)
  end
end
Enter fullscreen mode Exit fullscreen mode

Posteriomente contamos con el ultimo caso que valida que si el parametro es invalido nos retorne un error en especifico.

context 'with invalid parameters' do
  it 'returns an error' do
    get '/api/v1/pokemon?pokemon_namess=unknown_pokemon'
    error = parse_response(response.body)
    expect(error['error']).to eq('Invalid Parameters')
  end
end
Enter fullscreen mode Exit fullscreen mode

Lo ultimo que tenemos es un par de helper method para evitar estar repitiendo codigo en nuestra spec.

Ahora procederemos a ejecutar nuestras pruebas con bundle exec rspec y esto nos dara como resultado que nuestras pruebas pasaran sin problema.

Ademas de esto se crearan unos archivos en formato YAML los cuales son los registros guardados dentro de spec/vcr_cassettes de las peticiones que hagamos al cliente de pokemon y estos nos permitiran si asi lo deseamos usar estos mismos para mas pruebas que deseemos hacer en un futuro con la respuesta obtenida de parte del cliente.

Una vez que hayamos terminado de ejecutar nuestraas pruebas sera necesario cambiar el hash del simbolo vcr de new_episodes a none como se muestra enseguida.

RSpec.describe Api::V1::PokemonsController, type: :request, vcr: { record: :none }
Enter fullscreen mode Exit fullscreen mode

Refactorizando
Hay especificamente dos partes que considero debemos de refactorizar la primera de ellas es el controlador de la siguiente manera:

module Api
  module V1
    class PokemonsController < ApplicationController
      def pokemon
        if params[:pokemon_name].present?
          response = get_pokemon(pokemon_name: params[:pokemon_name])
          render json: response.pokemon_body, status: response.status
        else
          render json: { 'error': 'Invalid Parameters' }, status: :unprocessable_entity
        end
      end

      private

      def get_pokemon(pokemon_name:)
        ::V1::GetPokemonService.new(pokemon_name:).call
      end
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Si nos damos cuenta dentro de nuestra respuesta al servicio GetPokemon estamos accediendo a pokemon_body y status de esta manera nos olvidamos de hardcodear en especifido el status de la peticion que le hagamos al cliente.

Pues bien para que esto funcione deberemos de aplicar una segunda refactorizacion en el proxy que hemos creado anteriormente de la siguiente manera:

class PokemonProxy
  EXPIRATION = 24.hours.freeze

  attr_reader :client, :cache

  def initialize(client)
    @client = client
    @cache = Rails.cache
  end

  def get_pokemon(pokemon_name:)
    return WebServices::FactoryResponse.create_response(origin: 'on_cache', response: cache.read("pokemon_cached/#{pokemon_name}"), type: 'success') if cache.exist?("pokemon_cached/#{pokemon_name}")

    response = client.get_pokemon(pokemon_name:)

    if response.status == 200
      response.body['consulted_at'] = consulted_at
      cache.write("pokemon_cached/#{pokemon_name}", response, expires_in: EXPIRATION)
      WebServices::FactoryResponse.create_response(origin: 'client_call', response:, type: 'success')
    else
      WebServices::FactoryResponse.create_response(origin: 'client_call', response:, type: 'failed')
    end
  end

  private

  def consulted_at
    Time.now.utc.strftime('%FT%T')
  end
end
Enter fullscreen mode Exit fullscreen mode

Anteriormente contabamos con metodos para retornar una respuesta exitosa o fallida dependiente de como nos respondia el cliente, sin embargo ahora estamos haciendo uso del patron de diseño Factory Method el cual practicamente lo utilizaremos para crear objetos (respuesta) basados en el tipo que le pasamos como argumentos.

Dicho lo anterior primero crearemos nuestro FactoryResponse con el siguiente contenido:

module WebServices
  class FactoryResponse
    def self.create_response(origin:, response:, type:)
      case type
      when 'success'
        PokemonResponse.new(origin:, response:)
      when 'failed'
        PokemonFailedResponse.new(response:)
      end
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Basado en el tipo que recibe esta clase mediante el metodo de clase create_response determinaremos el tipo de respuesta que debemos de retornar, en donde si es un caso exitoso retornaremos un PokemonResponse con el siguiente contenido:

module WebServices
  class PokemonResponse
    attr_reader :origin, :response

    def initialize(origin:, response:)
      @origin = origin
      @response = response
      @pokemon_body = pokemon_body
    end

    def pokemon_body
      {
        'origin': origin,
        'name': response.body['name'],
        'weight': response.body['weight'],
        'types': type(response.body['types']),
        'stats': stats(response.body['stats']),
        'consulted_at': response.body['consulted_at']
      }
    end

    def status
      response.status
    end

    private

    def stats(stats)
      stats.each_with_object([]) do |stat, array|
        array << "#{stat.dig('stat', 'name')}: #{stat['base_stat']}"
      end
    end

    def type(types)
      types = types.map { |type| type.dig('type', 'name') }
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

En caso contrario lo que retornaremos un PokemonFailedResponse con el siguiente contenido:

module WebServices
  class PokemonFailedResponse
    attr_reader :response

    def initialize(response:)
      @response = response
      @pokemon_body = pokemon_body
    end

    def pokemon_body
      {
        'error': response.body,
        'status': response.status
      }
    end

    def status
      response.status
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Con esto logramos que seguir el principio S de SOLID (Single Responability) y de esta manera podemos modificar en un futuro la respuesta exitosa o fallida en su clase correspondiente.

Ahora bien si ejecutamos bundle exec rspec nuestras pruebas deberan de pasar sin ningun problema y abremos terminado con la creacion del proyecto.

Por ultimo espero que este pequeño proyecto les haya gustado y si tienen alguna duda o comentario por favor haganmelo saber y con todo gusto estare ahi para responderles, buen dia a ti que estas leyendo y happy coding.

💖 💪 🙅 🚩
danielpenaloza
Daniel

Posted on November 23, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related