├── 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
Last active
March 29, 2025 17:33
-
-
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
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# Load config from this file and `EVIL_MARTIANS_API_*` env variables. | |
access_token: "GoodMartian" | |
# ... |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# frozen_string_literal: true | |
module EvilMartiansAPI | |
VERSION = "1.0.0" | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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