Rails API + Cache + Design Patterns (English Version)

danielpenaloza

Daniel

Posted on November 24, 2024

Rails API + Cache + Design Patterns (English Version)

Hey whats up today it’s my pleassure to present a new tutorial for the creation of an API with Ruby On Rails. However this tutorial will be made it out of the box and i just want to cover more stuff than a simple CRUD so here it’s what we are going to build:

Setup of the application

Make a connection with an external API to retrieve elements we will use:

  • Faraday: With this geam we could create the connection with the cliente and make future requests.
  • VCR: We will record the HTTP call to the cliente and we will use the cassttes (generate files in YAML format with this geam) for the creation of tests in our requests.

Tests

  • RSpec: Not much to say.

The API that we will connect to.

The use of the proxy design pattern with the goal of store in cache the first client call for a period of 24 hours.
-https://refactoring.guru/design-patterns/proxy

The Factory Method Pattern for the creation of the responses thata we are going to provide
- https://refactoring.guru/design-patterns/factory-method

The repository of this project is here:

https://github.com/Daniel-Penaloza/pokemon_client_api

We are going to start the creation of our application without the default test suite and also the project need to be an API. We can achive this with the following command:

rails new api_project —api -T

Now it’s time of the configuration process so we need to open our Gemfile and add the following gems as the following code:

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

We need to run bundle install and then we need to make the setup of RSpec and VCR.

  • RSpec setup:
    We can run the command rails generate rspec:install and this will create the boilerplate code that we need to run our specs.

  • VCR setup:
    Once installed and configured Rspec we need to open our file rails_helper.rb y just above the configuration block of RSpec we will proceed to add the following code:

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
  • Lastly we need to active the cache on our development environment due that there is only activated in production by default so we need to execute the following command in terminal.
rails dev:cache
Enter fullscreen mode Exit fullscreen mode

Now is time to coding and we are goint to make this not in a regular TDD wat that means the creation of tests, then make that the test pass and then the refactorization (red - green - refactor) and this is because personally i feel more comfortable with the creation of the code base and then with the tests (this can be for my lack of experience).

Whatever we allways need to add test to our code i love to add tests to all my code to reduce the error gap that can be presente once that the app is ready for use.

With that on mind we need to add a new route to or applicacion inside of routes.rb as follows:

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

As we see we are creating a namaspace both for the api and the version and this is a due a good practice because maybe in a future we can have a new version of our API with new features.

Now is moment of the creation of our pokemons controller inside of app/controlles/api/v1/pokemons_controller with the following content:

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

In this chunk of code we are creating a pokemon method that check that the name of the provided parameters is pokemon_name at the moment of make a request; otherwire we will return an error indicating that the parameter is invalid.

So the only valid URI is:

http://localhost:3000/api/v1/pokemon?pokemon_name=nombre_de_pokemon
Enter fullscreen mode Exit fullscreen mode

Following the flow of our api we are calling the private method get_pokemon which accepts the pokemon_name parameter.

This is passed to a new instance of the service GetPokemonService which is a service invoked via call.

This class should be inside of our directory services/v1/get_pokemon_service.rb and need to follow the next structure:

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

At this point we have something very interesting and is the use of a proxy pattern which allow us to have a substitute of an object to controll his access.

But first thing firs, we hace a variable client which is an instance of the Fadaday class to be connected to our external client with a specific configuracion that we have inside of our block. This class should be inside of 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

The client method make a direct connection with the API via the instantiation of Faraday passing along the url parameter that we are going to use to make the connection. We need to have in mind that this method only will be execute until we decide and in this case will be via the method call of get_pokemon when we use the get method of the client this means client.get.

If you wan to now more details about Faraday you can check the official documentation:

https://lostisland.github.io/faraday/#/

The get_pokemon takes the pokemon name and then send this name of the pokemon trough client.get(”/pokemon/pikachu”). Where the value of the client just before of the use of this method as https://pokeapi.co/api/v2.

client.get("pokemon/pikachu")
Enter fullscreen mode Exit fullscreen mode

When we are executing the previous code in our get_pokemon method in reality we are making a get request to the following URI:

GET https://pokeapi.co/api/v2/pokemon/pikachu
Enter fullscreen mode Exit fullscreen mode

If everything is correct we will have a response with all the information of pikachu and we can test this in a new browser window inserting the following uri https://pokeapi.co/api/v2/pokemon/pikachu just to know the result of the external API call.

Next we have a proxy which need to have the same interface of the class PokemonConnection this means that we need to have the method get_pokemon inside of that class. The locaiton of this proxy class should be in app/proxies/pokemon_proxy.rb and will have the following content:

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

learning purposes will leave this class in that way in this moment and we will comeback later to make an improvement with refactorization.

Now it’s time to explain the lines of codes, first we have in the initializer a client as parameter that was passed in the last call and also we have a instance variable called cache that initializes Rails.cache.

# Call from the previous class
client = WebServices::PokemonConnection.new
proxy = PokemonProxy.new(client)    

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

Then we have the method get_pokemon that as we say previously this class need to have the same interface of the client and here is a brief explanation of what we are doing.

  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
Enter fullscreen mode Exit fullscreen mode

In the first line we just return a pokemon_response (we will explain this method next) if the cache key (”pokemon_cached/pikacu”) exits with the arguments on_cache and the previous key mentioned.

If not exist on cache then we make use of the client that we pass in the previous call at the moment of the initialization of our new instance y we call the method get_pokemon where is the status of the response is 200 then to the response body we just add a new field called consulted_at that will be the hour and date of the client call.

Next we store the response of the client call in cache with the key pokemon_cached/pokemon_name and also we add an extra argument to store this response only for 24 hours. In that way if we make future calls to our endpoint we will retrieve the response from the cache instead of make a client call.

In case that the status of the response of the cliente can’t be success (differente to 200) we will return a pokemon_error_resopnse with the response of the client as an argument.

  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

Now it’s time to explain the private methods that we need on this class first we have our method consulted_at that only give us the date and hour when is invoked.

pokemon_response have the parameteres origin and response and with that ones we construct a Hast object. So in the first call of this method we will have the following result:

{
  :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

And then on future calls we will have:

{
  :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

If we call to our method error_response we will have the following result:

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

At this point our API can be available to work as we expected and if we want to test our api we can make it as follows:

  • We need to start our rails server with rails s.
  • In other terminal we need to open the rails console with rails c and add the following code.
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

Test Stage

For the generation of our test suite we need to think about the funcionality that we have by now and what we get as result. Therefore if we analyze our code we have the following scenarios:

1.- We wil have a success case when the name of the pokemon is valid:

  • The first requet to our endpoint will bring us the origin as client_call.
  • The second request to our endpoint will bring us the origin as on_cache.

2.- We will have a failed case when:

  • The name of the pokemon is invalid.
  • The pokemon_name parameter it’s not presen as query params.

With that on mind we will proceed to crear the following test inside of spec/requests/pokemons_spec.rb with the following content:

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

In the first line we hace the use of VCR that will be used for record the requests that we make to the pokemon client.

Then we are going to create two lets:

  • memory_store: Create a cache instance.
  • pokemon_name: We just define the name of the pokemon that will use to keep our code DRY.

Next we have our before clock where we only make a stube of Rails.cache with the goald of return an instance of cache.

Now is time to create our specs to test our endpoint with the following code block:

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

What we do in this test is very easy:

  • We call our endpoint pokemon where we pass along the query param pokemon_name with the name of the pokemon (pikcachu in this case).
  • We expect that the status of the response be 200.
  • Parse the body of the obtainer response.
  • We expect that the value of our origin field in the json be equals as client_call.
  • Then we just validate that the returned json be as we expect, this means:
  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
  • Then we expect that the response is store in our cache with the key pokemon_cached/pikachu.
  • We make againg our endpoint pokemont just with the same previous characteristics that the first request.
  • Following we expect that the field of our json origin has the value of on_cache.
  • Lastly we validate again the returned json to accomplish with the necessary characteristics.

Now we can go with our failed cases and the firs one will retorn a 404 due that we don’t find a pokemon that we pass as query param, there is no much to say this is a very easy test and this is what we expect:

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

Subsequently we hace the last failed case where if the parameter is invalid we will have a specific error.

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

Finally in our test the last thing that we have are a couple of helper methods with the purpose of have our specs as DRY as we can.

Now we can execute our tests with the command bundle exec rspec and this will bring us as result that all our tests are ok.

In addition to this the fisrt time that we run our specs this will generate some YAML files undder spec/vcr_cassettes of the requests that we make in our test and the pokemon client. In that way if we cant to make a change later in our custom response we don’t need to hit the client again and we can use the response that we have in that cassettes.

Once that we already run our specs is time to change the first line of our spec in the RSpec block updating the sympolo record: :new_episodes to record: :none.

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

Refactorization.

There are two places where i want to make some changes one of them first is the controller as follows:

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

If we check the code inside of our response we are accessing to two methods pokemon_body and status in that way we are avoiding hardcoding this values and retrieve the status of the response.

Well if we want that this works we need to apply a second refactorization using the in our proxy as follows:

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

Previously we have methods that return a succesfull or failed responde depending on how the client responds. However not we are making use ot the design pattern Factory Method which allow us to create objects (in this cases responses) based in the type of object that we pass as an argument.

So first we need to create our FactoryResponse class with the class method create_response as follows:

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

So if the type is success then we will create a new instance of PokemonResponse:

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

Otherwise if the response is failed we will return a new instance of PokemonFailedResponse

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

With this we achieve to follow the principle of Single Responsability and in that way we can change in the future the success or failed response when the times arrives in just one file at a time.

Now if we execute bunlde exec rspec our test should be passing without any problem y we will finish the creation of the project.

I really hope you liked this tiny project and iy you jhave any questions or comments please let me know and i’ll be happy to answer them. Good day to yoou who are reading and learning, Happy coding.

💖 💪 🙅 🚩
danielpenaloza
Daniel

Posted on November 24, 2024

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

Sign up to receive the latest update from our blog.

Related