Skip to content

Instantly share code, notes, and snippets.

@ardecvz
Last active April 18, 2024 20:35
Show Gist options
  • Star 6 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ardecvz/7ada5b45fbed5709e6e79c3a9ed15b51 to your computer and use it in GitHub Desktop.
Save ardecvz/7ada5b45fbed5709e6e79c3a9ed15b51 to your computer and use it in GitHub Desktop.
A ready-to-use example that features an opinionated Faraday configuration, optionally serving as a starting point for your own HTTP client
├── bin
│   └── console
├── config
│   └── evil_martians_api.yml
├── lib
│   ├── evil_martians_api
│   │   ├── api
│   │   │   └── developers.rb
│   │   ├── client
│   │   │   └── configurable.rb
│   │   ├── middleware
│   │   │   ├── handle_connection_error_middleware.rb
│   │   │   └── raise_http_error_middleware.rb
│   │   ├── model
│   │   │   └── developers
│   │   │       ├── request.rb
│   │   │       └── response.rb
│   │   ├── client.rb
│   │   ├── config.rb
│   │   ├── errors.rb
│   │   ├── railtie.rb
│   │   └── version.rb
│   └── evil_martians_api.rb

#!/usr/bin/env ruby
# frozen_string_literal: true
require "irb"
require_relative "../lib/evil_martians_api"
EvilMartiansAPI::Client.configure do |config|
config.logger = Logger.new($stdout)
end
# NOTE: fake examples, it's for illustrative purposes only.
# response = EvilMartiansAPI::Client.new.get_developer("42")
# response = EvilMartiansAPI::Client.new.create_developer(team_number: "42", language: "Ruby")
binding.irb
# frozen_string_literal: true
$LOAD_PATH.unshift(File.expand_path(__dir__))
require "bundler/inline"
gemfile do
source "https://rubygems.org"
gem "addressable", "~> 2.7"
gem "anyway_config", ">= 2.0"
gem "faraday", ">= 2.0", "< 3.0"
gem "faraday-retry", ">= 1.0", "< 3.0"
gem "shale", "~> 1.0"
gem "zeitwerk", ">= 2.0"
end
require "zeitwerk"
require "addressable"
require "anyway_config"
require "faraday"
require "faraday/retry"
require "shale"
require "evil_martians_api/errors"
require "evil_martians_api/middleware/handle_connection_error_middleware"
require "evil_martians_api/middleware/raise_http_error_middleware"
require "evil_martians_api/railtie" if defined?(Rails)
module EvilMartiansAPI; end
loader = Zeitwerk::Loader.for_gem
loader.inflector.inflect("evil_martians_api" => "EvilMartiansAPI", "api" => "API")
loader.setup
# frozen_string_literal: true
module EvilMartiansAPI
class Client
extend Configurable
include Configurable
include API::Developers
# ...
ACCEPT_HEADER = "application/json"
# Initializes the Evil Martians API Client.
#
# @return [Client] A new instance of the Client
def initialize(**settings)
inherit_config!(self.class.config)
settings.each { |setting, value| config.public_send("#{setting}=", value) }
validate_config
end
# Establishes a connection with the Evil Martians endpoint.
#
# Used in API calls.
#
# @return [Faraday::Connection] A Faraday connection object which can be used to send requests
def connection
@connection ||= Faraday.new(url: config.api_endpoint) do |connection|
setup_access_token!(connection)
setup_timeouts!(connection)
setup_user_agent!(connection)
setup_logger!(connection)
setup_retries!(connection)
setup_error_handling!(connection)
setup_json!(connection)
end
end
private
def setup_access_token!(connection)
connection.request(:authorization, :Bearer, config.access_token)
end
def setup_timeouts!(connection)
connection.options.open_timeout = config.open_timeout
connection.options.timeout = config.read_timeout
end
def setup_user_agent!(connection)
connection.headers[:user_agent] = config.user_agent
end
def setup_logger!(connection)
connection.response(:logger, config.logger) if config.logger
end
def setup_retries!(connection)
return if config.max_retries <= 0
connection.request(
:retry,
max: config.max_retries,
interval: config.retry_interval,
backoff_factor: config.retry_backoff_factor,
exceptions: config.retriable_errors,
)
end
def setup_error_handling!(connection)
connection.use(:evil_martians_api_handle_connection_error)
connection.use(:evil_martians_api_raise_http_error)
end
def setup_json!(connection)
connection.headers[:accept] = ACCEPT_HEADER
connection.request(:json)
connection.response(:json)
end
end
end
# frozen_string_literal: true
module EvilMartiansAPI
class Config < Anyway::Config
REQUIRED_ATTRIBUTES = %i[access_token].freeze
# Load config from `config/evil_martians_api.yml` and `EVIL_MARTIANS_API_*` env variables.
config_name :evil_martians_api
# @!attribute api_endpoint
# @return [String] Base URL for accessing Evil Martians API
attr_config api_endpoint: "https://evilmartians.com/"
# @!attribute access_token
# @return [String] Token for accessing Evil Martians API
attr_config :access_token
# @!attribute open_timeout
# @return [Integer] Connection establishment waiting time, in seconds
attr_config open_timeout: 2
# @!attribute read_timeout
# @return [Integer] Response reading waiting time, in seconds
attr_config read_timeout: 5
# @!attribute user_agent
# @return [String] User-Agent for debugging purposes
attr_config user_agent: "evil_martians_api_client"
# @!attribute logger
# @return [Logger] Logging facility
attr_config :logger
# @!attribute max_retries
# @return [Integer] Number of attempts to retry the request
attr_config max_retries: 3
# @!attribute retry_interval
# @return [Integer] Delay in seconds between retry attempts
attr_config retry_interval: 1
# @!attribute retry_backoff_factor
# @return [Integer] Delay in seconds between retry attempts increase factor
attr_config retry_backoff_factor: 1
# @!attribute retriable_errors
# @return [StandardError] Errors that require request retry
attr_config retriable_errors: [Errors::ConnectionError, Errors::ServerError]
end
end
# Load config from this file and `EVIL_MARTIANS_API_*` env variables.
access_token: "GoodMartian"
# ...
# frozen_string_literal: true
module EvilMartiansAPI
module Errors
class ConfigurationError < StandardError; end
class ConnectionError < StandardError; end
class HttpError < StandardError; end
class ClientError < HttpError; end # 4xx
class ServerError < HttpError; end # 5xx
class InvalidRequestError < ClientError; end # 400
class UnauthorizedError < ClientError; end # 401
class ForbiddenError < ClientError; end # 403
class NotFoundError < ClientError; end # 404
class PayloadTooLargeError < ClientError; end # 413
class UnprocessableEntityError < ClientError; end # 422
class TooManyRequestsError < ClientError; end # 429
end
end
# frozen_string_literal: true
module EvilMartiansAPI
class Railtie < Rails::Railtie
initializer "evil_martians_api.configure", after: "initialize_logger" do
EvilMartiansAPI::Client.configure do |config|
config.user_agent = [
Rails.application.class.name.deconstantize.underscore,
Rails.env,
config.user_agent,
EvilMartiansAPI::VERSION,
].join(" - ") # => "dummy_application - production - evil_martians_api_client - 1.0.0"
config.logger = Rails.logger if Rails.env.local?
end
end
end
end
# frozen_string_literal: true
module EvilMartiansAPI
class Client
module Configurable
# Retrieves the current configuration.
#
# @return [Config] An instance of the Config class that holds the current configuration.
def config
@config ||= Config.new
end
# Allows block-level configuration using the current config object.
#
# @yield [Config] The current Config object.
def configure
yield(config) if block_given?
end
# Inherits configuration settings from another configuration object.
#
# It is used to inherit settings from the Client class to Client instances.
#
# @param other_config [Config] The configuration object from which settings will be inherited.
def inherit_config!(other_config)
other_config.to_h.each_key do |setting|
config.public_send("#{setting}=", other_config.public_send(setting))
end
end
# Validates the current configuration, checking for required attributes.
#
# @raise [Errors::ConfigurationError] If any required attributes are missing or empty.
# @return [true] If all required attributes are present and valid.
def validate_config
missing = Config::REQUIRED_ATTRIBUTES.select do |name|
value = config.public_send(name)
value.nil? || (value.is_a?(String) && value.empty?)
end
return true if missing.empty?
name = "#{Config.name}(config_name: #{Config.config_name})"
attrs = missing.join(", ")
msg = "The following config parameters for `#{name}` are missing or empty: #{attrs}"
raise Errors::ConfigurationError, msg
end
end
end
end
# frozen_string_literal: true
module EvilMartiansAPI
VERSION = "1.0.0"
end
# frozen_string_literal: true
module EvilMartiansAPI
module Middleware
# Handle connection errors in the context of the Faraday underlying HTTP client library.
#
# @raise [Errors::ConnectionError] Raises a custom ConnectionError.
#
class HandleConnectionErrorMiddleware < Faraday::Middleware
FARADAY_CONNECTION_ERRORS = [
Faraday::TimeoutError,
Faraday::ConnectionFailed,
Faraday::SSLError,
].freeze
def call(env)
@app.call(env)
rescue *FARADAY_CONNECTION_ERRORS => e
raise Errors::ConnectionError, e
end
Faraday::Middleware.register_middleware(
evil_martians_api_handle_connection_error: self,
)
end
end
end
# frozen_string_literal: true
module EvilMartiansAPI
module Middleware
# Handle HTTP errors by inspecting the HTTP response code and raising appropriate custom errors.
#
# @raise [Errors::ClientError, Errors::ServerError] Raises a coded ClientError or ServerError.
#
class RaiseHttpErrorMiddleware < Faraday::Middleware
CLIENT_ERRORS = {
400 => Errors::InvalidRequestError,
401 => Errors::UnauthorizedError,
403 => Errors::ForbiddenError,
404 => Errors::NotFoundError,
413 => Errors::PayloadTooLargeError,
422 => Errors::UnprocessableEntityError,
429 => Errors::TooManyRequestsError,
}.freeze
def on_complete(env)
handle_client_error(env)
handle_server_error(env)
end
private
def handle_client_error(env)
return if env.status < 400 || env.status >= 500
error = CLIENT_ERRORS.fetch(env.status, Errors::ClientError)
raise error, message(env)
end
def handle_server_error(env)
return if env.status < 500
raise Errors::ServerError, message(env)
end
def message(env)
"Server returned #{env.status}: #{env.body}. Headers: #{headers(env)}"
end
def headers(env)
env.response_headers.inspect
end
Faraday::Middleware.register_middleware(evil_martians_api_raise_http_error: self)
end
end
end
# frozen_string_literal: true
module EvilMartiansAPI
module API
# NOTE: fake examples, it's for illustrative purposes only.
#
module Developers
DEVELOPER_PATH = "developers/{developer_team_number}"
DEVELOPERS_PATH = "developers"
# Fetches a specific developer based on their team number.
#
# @param developer_team_number [String] The team number for the developer
#
# @return [Model::Reports::Response] A model representing the developer
#
# @example Fetch information for a specific developer
#
# EvilMartiansAPI::Client.new.get_developer("42")
def get_developer(developer_team_number)
response = connection.get(developer_path(developer_team_number))
Model::Developers::Response.from_json(response.body.to_json)
end
# Creates a new developer with specified parameters.
#
# @param params [Hash] The hash of attributes for the new developer.
#
# @return [Model::Developers::Response] A model representing the newly created developer
#
# @example Create a new developer with specified team number and language
#
# EvilMartiansAPI::Client.new.create_developer(team_number: "42", language: "Ruby")
def create_developer(params)
request = Model::Developers::Request.from_hash(params)
response = connection.post(DEVELOPERS_PATH, request.to_json)
Model::Developers::Response.from_json(response.body.to_json)
end
private
def developer_path(developer_team_number)
Addressable::Template.new(DEVELOPER_PATH)
.expand(developer_team_number: developer_team_number)
.to_s
end
end
end
end
# frozen_string_literal: true
module EvilMartiansAPI
module Model
module Developers
# NOTE: fake examples, it's for illustrative purposes only.
#
# For available alternatives for typed domain models,
# see https://evilmartians.com/chronicles/ideal-http-client#typed-domain-models
#
class Request < Shale::Mapper
attribute :team_number, Shale::Type::String
attribute :language, Shale::Type::String
hsh do
map :team_number, to: :team_number
map :language, to: :language
end
json do
map "team_number", to: :team_number
map "language", to: :language
end
end
end
end
end
# frozen_string_literal: true
module EvilMartiansAPI
module Model
module Developers
# NOTE: fake examples, it's for illustrative purposes only.
#
# For available alternatives for typed domain models,
# see https://evilmartians.com/chronicles/ideal-http-client#typed-domain-models
#
class Response < Shale::Mapper
attribute :team_number, Shale::Type::String
attribute :language, Shale::Type::String
json do
map "team_number", to: :team_number
map "language", to: :language
end
end
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment