DEV Community

Daniel
Daniel

Posted on

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.

Top comments (0)