api-response-presenter
The api-response-presenter gem provides a flexible and easy-to-use interface for presenting API responses using Faraday or
RestClient with the possibility to configure global settings or per-instance settings. It leverages
the Dry::Configurable for configurations, ensuring high performance and full test coverage.
Supported Ruby Versions
This library oficially supports the following Ruby versions:
- MRI
>=2.7.4
Installation
Add this line to your application's Gemfile:
gem 'api-response-presenter'
And then execute:
bundle install
Or install it yourself as:
gem install api-response-presenter
Usage
Configuration
You can configure api-response-presenter globally in an initializer or setup block:
# config/initializers/api_response.rb
ApiResponse.config.some_option = 'some_value'
# or
ApiResponse.configure do |config|
config.adapter = :faraday # or :rest_client, :excon, :http
config.monad = false
config.extract_from_body = ->(body) { body }
config.struct = nil
config.raw_response = false
config.error_json = false
config.default_return_value = nil
config.default_status = :conflict
config.default_error_key = :external_api_error
config.default_error = 'External Api error'
# dependency injection
config.success_processor = ApiResponse::Processor::Success
config.failure_processor = ApiResponse::Processor::Failure
config.parser = ApiResponse::Parser
config. = {}
end
or on instance config, provide block (see: BasicUsage).
Basic Example
Here is a basic example of using api_response to process an API response:
response = Faraday.get('https://api.example.com/data')
result = ApiResponse::Presenter.call(response) do |config|
config.monad = true
end
# or
# Usefull for using in another libraries
result ||= ApiResponse::Presenter.call(response, monad: true)
if result.success?
puts "Success: #{result.success}"
else
puts "Error: #{result.failure}"
end
Also you can create decorator for using functionality e.g.
module ApiResponseHandler # or ExternalApiBaseClass
private def with_presentation(response, **, &)
ApiResponse::Presenter.call(response, **, &)
end
end
class ExtenalApiService < ExternalApiBaseClass
# or include ApiResponseHandler
...
def get_external_data(*, **, &)
response = get('/data', *)
with_presentation(response, **, &)
end
...
end
Config options
ApiResponse.config.adapter: response adapter that you are using.- Default:
:faraday. - Available values:
:rest_client,:excon,:httpand others. Checks that response respond to#status(only Faraday and Excon) or#code(others)
- Default:
ApiResponse.config.monadwrap result into dry-monads- Default:
false - Example:
ApiResponse::Presenter.call(response, monad: true) # => Success({})orFailure({error:, status:, error_key:}) - Note: if you use
ApiResponse::Presenter.callwith monad: true, you should use#success?and#failure?methods to check result - Options only for
ApiResponse.config.monad = true: ApiResponse.config.default_statusdefault status forApiResponse::Presenter.callif response is not success. You can provide symbol or integer.- Default:
:conflict
- Default:
ApiResponse.config.symbol_statusoption for symbolize status from response (or default status if it an Integer).- Default:
true - Example:
ApiResponse::Presenter.call(response, monad: true, default_status: 500, symbol_status: false) # => Failure({error:, status: 500, error_key:})
- Default:
ApiResponse.config.default_error_keydefault error key forApiResponse::Presenter.callif response is not success- Default:
:external_api_error
- Default:
ApiResponse.config.default_errordefault error message forApiResponse::Presenter.callif response is not success- Default:
'External Api error'
- Default:
- Default:
ApiResponse.config.extract_from_bodyprocedure that is applied to theresponse.bodyafter it has been parsed from JSON string to Ruby hash with symbolize keys.- Default:
->(body) { body }. - Example lambdas:
->(b) { b.first },->(b) { b.slice(:id, :name) },-> (b) { b.deep_stringify_keys )}
- Default:
ApiResponse.config.structstruct for pack your extracted value from body.- Default:
nil - Note: packing only into classes with key value constructors (e.g.
MyAwesomeStruct.new(**attrs), notStruct.new(attrs)) - Recommend to use dry-struct or Ruby#OpenStruct
- Default:
ApiResponse.config.raw_responsereturns raw response, that you passes into class.- Default:
false - Example:
ApiResponse::Presenter.call(Faraday::Response<...>, raw_response: true) # => Faraday::Response<...>
- Default:
ApiResponse.config.error_jsonreturns error message from response body if it is JSON (parsed with symbolize keys)- Default:
false - Example:
ApiResponse::Presenter.call(Response<body: "{\"error\": \"some_error\"}">, error_json: true) # => {error: "some_error"}
- Default:
ApiResponse.config.default_return_valuedefault value forApiResponse::Presenter.callif response is not success- Default:
nil - Example:
ApiResponse::Presenter.call(response, default_return_value: []) # => []
- Default:
NOTE: You can override global settings on instance config, provide block (see: BasicUsage). Params options has higher priority than global settings and block settings.
Examples:
Interactors:
class ExternalApiCaller < ApplicationInteractor
class Response < Dry::Struct
attribute :data, Types::Array
end
def call
response = RestClient.get('https://api.example.com/data') # => body: "{\"data\": [{\"id\": 1, \"name\": \"John\"}]}"
ApiResponse::Presenter.call(response) do |config|
config.adapter = :rest_client
config.monad = true
config.struct = Response
config.default_status = 400 # no matter what status came in fact
config.symbol_status = true # return :bad_request instead of 400
config.default_error = 'ExternalApiCaller api error' # instead of response error field (e.g. body[:error])
end
end
end
def MyController
def index
result = ExternalApiCaller.call
if result.success?
render json: result.success # => ExternalApiCaller::Response<data: [{id: 1, name: "John"}]> => {data: [{id: 1, name: "John"}]}
else
render json: {error: result.failure[:error]}, status: result.failure[:status] # => {error: "ExternalApiCaller api error"}, status: 400
end
end
end
ExternalApi services
class EmployeeApiService
class Employee < Dry::Struct
attribute :id, Types::Integer
attribute :name, Types::String
end
def self.get_employees(monad: false, adapter: :faraday, **)
# or (params, presenter_options = {})
response = Faraday.get('https://api.example.com/data', params) # => body: "{\"data\": [{\"id\": 1, \"name\": \"John\"}]}"
page = .fetch(:page, 1)
per = .fetch(:per, 5)
ApiResponse::Presenter.call(response, monad: monad, adapter: adapter) do |c|
c.extract_from_body = ->(body) { Kaminari.paginate_array(body[:data]).page(page).per(per) }
c.struct = Employee
c.default_return_value = []
end
end
end
class MyController
def index
employees = EmployeeApiService.get_employees(page: 1, per: 5)
if employees.any?
render json: employees # => [Employee<id: 1, name: "John">] => [{id: 1, name: "John"}]
else
render json: {error: 'No employees found'}, status: 404
end
end
end
Customization
Processors
You can customize the response processing by providing a block to ApiResponse::Presenter.call or redefine global processors and parser:
All of them must implement .new(response, config: ApiResponse.config).call method.
You can use not default config in your processor, just pass it as a second named argument.
- Redefine
ApiResponse::Processor::Success# contains logic for success response (status/code 100-399) - Redefine
ApiResponse::Processor::Failure# contains logic for failure response (status/code 400-599) - Redefine
ApiResponse::Parser# contains logic for parsing response body (e.g.Oj.load(response.body))
class MyClass
def initialize(response, config: ApiResponse.config)
@response = response
@config = config
end
def call
# your custom logic
end
end
or with Dry::Initializer
require 'dry/initializer'
class MyClass
extend Dry::Initializer
option :response
option :config, default: -> { ApiResponse.config }
def call
# your custom logic
end
end
You can use your custom processor or parser in ApiResponse::Presenter.call or redefine in global settings:
ApiResponse.config.success_processor = MyClass
ApiResponse.config.failure_processor = MyClass
ApiResponse.config.parser = MyClass
or
ApiResponse::Presenter.call(response, success_processor: MyClass, failure_processor: MyClass, parser: MyClass)
NOTE: If you are using Faraday with Oj middleware to parse json body already, you should redefine parser like this (in next gem version will be available configuring parsing (on/off))
# config/initializers/api_response.rb
require 'api_response'
class EmptyParser
attr_reader :response, :config
def initialize(response, config: nil)
@response = response
@config = config
end
def call
response.body
end
end
ApiResponse.configure do |config|
config.parser = EmptyParser
end
Options
Also you can add custom options to ApiResponse.config.options = {} and use it in your processor or parser:
ApiResponse.config do |config|
config.[:my_option] = 'my_value'
config.[:my_another_option] = 'my_another_value'
end
or
ApiResponse::Presenter.call(response, success_processor: MyClass, options: {my_option: 'my_value', my_another_option: 'my_another_value'})
Example:
class MyCustomParser
attr_reader :response, :config
def initialize(response, config: ApiResponse.config)
@response = response
@config = config
end
def call
JSON.parse(response.body, symbolize_names: true) # or Oj.load(response.body, symbol_keys: true)
rescue JSON::ParserError => e
raise ::ParseError.new(e) if config.[:raise_on_failure]
response.body
end
end
class MyCustomFailureProcessor
class BadRequestError < StandardError; end
attr_reader :response, :config
def initialize(response, config: ApiResponse.config)
@response = response
@config = config
end
def call
parsed_body = config.parser.new(response).call
raise BadRequestError.new(parsed_body) if config.[:raise_on_failure]
{error: parsed_body, status: response.status || config.default_status, error_key: :external_api_error}
end
end
ApiResponse.config do |config|
config.failure_processor = MyCustomFailureProcessor
config.parser = MyCustomParser
config.[:raise_on_failure] = true
end
response = Faraday.get('https://api.example.com/endpoint_that_will_fail')
ApiResponse::Presenter.call(response) # => raise BadRequestError
Contributing
Bug reports and pull requests are welcome on GitHub
License
See LICENSE file.