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"
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
- 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
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
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
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
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
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
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")
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
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
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
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
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
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"
}
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"
}
If we call to our method error_response we will have the following result:
{
"error": "Not Found",
"status": 404
}
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\"}"
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
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
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
- 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
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
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 }
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
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
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
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
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
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)