Make HTTP calls by leveraging Rails rendering
Its interface, behavior, and name are likely to change drastically before being published to RubyGems. Use at your own risk.
Considering a hypothetical scenario where we need to make a POST
request to https://example.com/articles
with a JSON payload of { "title": "Hello, World" }
.
First, declare the ArticlesClient
as a descendant of ActionClient::Base
:
class ArticlesClient < ActionClient::Base
end
Next, declare the request method. In this case, the semantics are similar to
Rails' existing controller naming conventions, so let's lean
into that by declaring the create
action so that it accepts a title:
option:
class ArticlesClient < ActionClient::Base
def create(title:)
end
end
Our client action will need to make an HTTP POST
request to
https://example.com/articles
, so let's declare that call:
class ArticlesClient < ActionClient::Base
def create(title:)
post url: "https://example.com/articles"
end
end
The request will need a payload for its body, so let's declare the template as
app/views/articles_client/create.json.erb
:
{ "title": <%= @title %> }
Since the template needs access to the @title
instance variable, update the
client's request action to declare it:
class ArticlesClient < ActionClient::Base
def create(title:)
@title = title
post url: "https://example.com/articles"
end
end
By default, ActionClient
will deduce the request's
Content-Type
and Accept
HTTP headers based
on the format of the action's template. In this example's case, since we've
declared .json.erb
, the Content-Type
will be set to application/json
. The
same would be true for a template named create.json.jbuilder
.
If we were to declare the template as create.xml.erb
or create.xml.builder
,
the Content-Type
header would be set to application/xml
.
For requests that have not explicitly set the Accept
header and cannot infer
it from the body's template format, a URL with a file extension will be used to
determine the Accept
header.
Finally, it's time to submit the request.
In the application code that needs to make the HTTP call, invoke the #submit
method:
request = ArticlesClient.create(title: "Hello, World")
response = request.submit
The #submit
call transmits the HTTP request, and processes the response
through a stack of Rack middleware.
The return value is an instance of a Rack::Response
, which
responds to #status
, #headers
, and #body
.
If you'd prefer to deal with the Rack status-headers-body
triplet directly, you can coerce the
Rack::Response
into an Array
for multiple assignment by
splatting (*
) the return value directly:,
request = ArticlesClient.create(title: "Hello, World")
status, headers, body = *request.submit
When ActionClient
is able to infer the request's Content-Type
to be either
JSON, JSON-LD, or XML, it will parse the returned body
value ahead of
time.
Responses with Content-Type: application/json
headers will be parsed into
Ruby objects by JSON.parse
. JSON objects will
become instances of
HashWithIndifferentAccess
, so that keys can be
accessed via Symbol
or String
.
Responses with Content-Type: application/xml
headers will be parsed into
Nokogiri::XML::Document
instances by
Nokogiri::XML
.
If the response body is invalid JSON or XML, #submit
will raise an
ActionClient::ParseError
. You can rescue
from this exception specifically,
then access both the original response #body
and the #content_type
from the
instance:
def fetch_articles
response = ArticlesClient.all.submit
# ...
response.body.map { |attributes| Article.new(attributes) }
rescue ActionClient::ParseError => error
Rails.logger.warn "Failed to parse body: #{error.body}. Falling back to empty result set"
[]
end
It's important to note that parsing occurs before any other middlewares declared
in ActionClient::Base
descendants. If your invocation rescue
block catches
an exception, none of the middlewares would have been run at that point in the
execution.
To set a request's query parameters, pass them a Hash
under the query:
option:
class ArticlesClient < ActionClient::Base
def all(search_term:)
get url: "https://examples.com/articles", query: { q: search_term }
end
end
You can also pass query parameters directly as part of the url:
or path:
option:
class ArticlesClient < ActionClient::Base
default url: "https://examples.com"
def all(search_term:, **query_parameters)
get path: "/articles?q={search_term}", query: query_parameters
end
end
When a key-value pair exists in both the path:
(or url:
) option and query:
option, the value present in the URL will be overridden by the query:
value.
If the call to the Client HTTP request can occur outside of Rails'
request-response cycle, transmit it in the background by calling
#submit_later
:
request = ArticlesClient.create(title: "Hello, from ActiveJob!")
request.submit_later(wait: 1.hour)
All options passed to #submit_later
will be forwarded
along to ActiveJob
.
To emphasize the immediacy of submitting a Request inline, #submit_now
is an
alias for #submit
.
In some cases, we'll need to take action after a client submits a request from a background worker.
To enqueued an ActionClient::Base
descendant class' requests with a custom
ActiveJob
, first declare the job:
# app/jobs/articles_client_job.rb
class ArticlesClientJob < ActionClient::SubmissionJob
after_perform only_status: 500..599 do
status, headers, body = *response
Rails.logger.info("Retrying ArticlesClient job with status: #{status}...")
retry_job queue: "low_priority"
end
end
Similarly, to execute an after_perform
for statuses that do not match
the given status codes, declare the except_status:
option:
# app/jobs/articles_client_job.rb
class ArticlesClientJob < ActionClient::SubmissionJob
after_perform except_status: 200 do
status, headers, body = *response
Rails.logger.info("Retrying ArticlesClient job with status: #{status}...")
retry_job queue: "low_priority"
end
end
Within the block, the Rack triplet is available as response
.
Next, configure your client class to enqueue jobs with that class:
class ArticlesClient < ActionClient::Base
self.submission_job = ArticlesClientJob
end
The ActionClient::SubmissionJob
provides an extended version of
ActiveJob::Base.after_perform
that accepts a only_status:
option, to serve as a guard clause filter.
Descendants of ActionClient::Base
can specify some defaults:
class ArticlesClient < ActionClient::Base
default url: "https://example.com"
default headers: { "Authorization": "Token #{ENV.fetch('EXAMPLE_API_TOKEN')}" }
def create(title:)
post path: "/articles", locals: { title: title }
end
end
When specifying default headers:
values, descendant key-value pairs will
override inherited key-value pairs. Consider the following inheritance
hierarchy:
class ApplicationClient < ActionClient::Base
default headers: {
"X-Special": "abc123",
"Content-Type": "text/plain",
}
end
class ArticlesClient < ApplicationClient
default headers: {
"Content-Type": "application/json"
}
end
Requests made by the ArticlesClient
will inherit the X-Special
header from
the ApplicationClient
, and will override the Content-Type
header to
application/json
, since it's declared in the descendant class.
Default values can be overridden on a request-by-request basis.
When a default url:
key is specified, a request's full URL will be built by
joining the base default url: ...
value with the request's path:
option.
Consider the following configuration file declared at
config/clients/articles.yml
:
# config/clients/articles.yml
default: &default
url: "https://staging.example.com"
development:
<<: *default
test:
<<: *default
production:
<<: *default
url: "https://example.com"
In this example, the ArticlesClient.configuration
will read directly from the
environment-aware config/clients/articles.yml
file.
The client class can access those values directly from .configuration
:
class ArticlesClient < ActionClient::Base
default url: configuration.url
end
If there are url:
or headers:
declarations in the configuration file, they
will implicitly be forwarded as arguments to default
.
When a matching configuration file does not exist,
ActionClient::Base.configuration
returns an empty instance of
ActiveSupport::OrderedOptions
.
By default, ActionClient
will parse each response's body String
based on the
value of the Content-Type
header. Out of the box, ActionClient
supports
parsing application/json
and application/xml
headers.
This feature is powered by an extensible set of configurations. If you'd like to
declare additional parsers for other Content-Type
values, or you'd like to
override the existing parsers, declare a Hash
mapping from Content-Type
values to callable blocks that accept a single String argument containing the
response's body String
:
# config/application.rb
config.action_client.parsers = {
"text/plain": -> (body) { body.strip },
}
When submitting requests from an ActionClient::Base
descendant, it can be
useful to modify the response's body before returning the response to the
caller.
As an example, consider instantiating OpenStruct
instances from
each response body by declaring an after_submit
hook:
class ArticlesClient < ActionClient::Base
after_submit { |body| response.body = OpenStruct.new(body) }
end
Alternatively, after_submit
blocks can accept a Rack triplet of arguments:
class ArticlesClient < ActionClient::Base
after_submit do |status, headers, body|
if status == 201
response.body = OpenStruct.new(body)
end
end
end
In addition to passing a block argument, you can specify a method name:
class ArticlesClient < ActionClient::Base
after_submit :wrap_in_open_struct
# ...
private def wrap_in_open_struct(body)
response.body = OpenStruct.new(body)
end
end
To specify a Request-specific callback, pass a block argument that
accepts a Rack
triplet.
For example, assuming that an Article
model class exists and accepts
attributes as a Hash
, consider constructing an instance from the body
:
class ArticlesClient < ActionClient::Base
default url: "https://example.com"
def create(title:)
@title = title
post path: "/articles" do |status, headers, body|
if status == 201
response.body = Article.new(body)
end
end
end
end
Request-level blocks are executed after class-level after_submit
blocks.
When your callback is only interested in modifying the body
, you can declare
it with a single block argument:
class ArticlesClient < ActionClient::Base
default url: "https://example.com"
def create(title:)
@title = title
post path: "/articles" do |body|
response.body = Article.new(body)
end
end
end
In some cases, a ActionClient::Base
descendant might want to execute an
after_submit
hook for a certain set of Request actions.
To specify which actions an after_submit
hook should execute for, declare an
after_submit
that passes theirs names as the only:
option:
class ArticlesClient < ActionClient::Base
after_submit(only: [:create, :update]) { |body| Article.new(body) }
def create
# ...
end
def update
# ...
end
def all
# ...
end
end
Similarly, to specify which actions an after_submit
hook should not
execute for, declare an after_submit
that passes theirs names as the except:
option:
class ArticlesClient < ActionClient::Base
after_submit(except: [:all]) { |body| Article.new(body) }
def create
# ...
end
def update
# ...
end
def all
# ...
end
end
In some cases, applications might want to raise Errors based on a response's HTTP Status Code.
For example, when a response has a 422 HTTP Status, the server is indicating that there were invalid parameters.
To map that to an application-specific error code, declare an after_submit
that passes a only_status: 422
as a keyword argument:
class ArticlesClient < ActionClient::Base
after_submit only_status: 422 do |status, headers, body|
raise MyApplication::InvalidDataError, body.fetch("error")
end
end
In some cases, there are multiple HTTP Status codes that might map to a similar concept. For example, a 401 and 403 might correspond to similar concepts in your application, and you might want to handle them the same way.
You can pass them to after_submit only_status:
as either an
Array
or a Range
:
class ArticlesClient < ActionClient::Base
after_submit only_status: [401, 403] do |status, headers, body|
raise MyApplication::SecurityError, body.fetch("error")
end
after_submit only_status: 401..403 do |status, headers, body|
raise MyApplication::SecurityError, body.fetch("error")
end
end
If the block is only concerned with the value of the body
, declare the block
with a single argument:
class ArticlesClient < ActionClient::Base
after_submit only_status: 422 do |body|
raise MyApplication::ArgumentError, body.fetch("error")
end
end
When passing the HTTP Status Code singularly or as an Array
,
after_submit
will also accept a Symbol
that corresponds to the name of the
Status Code:
class ArticlesClient < ActionClient::Base
after_submit only_status: :unprocessable_entity do |body|
raise MyApplication::ArgumentError, body.fetch("error")
end
after_submit only_status: [:unauthorized, :forbidden] do |body|
raise MyApplication::SecurityError, body.fetch("error")
end
end
To exclude responses that don't match a particular set of HTTP Status codes,
declare the after_submit
with except_status:
instead:
class ArticlesClient < ActionClient::Base
after_submit except_status: [:success, :created] do |body|
raise MyApplication::ArgumentError, body.fetch("error")
end
end
Inspired by ActionMailer::Previews
, you can view
previews for an exemplary outbound HTTP request:
# test/clients/previews/articles_client_preview.rb
class ArticlesClientPreview < ActionClient::Preview
def create
ArticlesClient.create(title: "Hello, from Previews!")
end
end
To view the URL, headers and payload that would be generated by that request, visit http://localhost:3000/rails/action_client/clients/articles_client/create.
Each request's preview page also include a copy-pastable, terminal-ready cURL
command.
By default, Preview routes are available in all environments except for
production
, and can be changed by setting
config.action_client.enable_previews
.
By default, Preview declarations are loaded from test/clients/previews
, and
can be changed by setting config.action_client.previews_path
.
# config/application.rb
config.action_client.enable_previews = true
config.action_client.previews_path = "spec/previews/clients"
To integrate ActionClient
-provided assertions into your tests, include the
ActionClient::TestHelpers
module:
# test/controllers/articles_controller_test.rb
class ArticlesControllerTest < ActionDispatch::IntegrationTest
include ActionClient::TestHelpers
# ...
end
ActionClient
provides a collection of assertions to verify that
requests processed by invoking #submit_later
are enqueued or performed.
Inspired by the Rails-provided ActiveJob::TestHelper
assertions, ActionClient
assertions verify that a
request has been performed, or been enqueued to be performed by a background
job.
When config.active_job.queue_adapter = :test
:
assert_enqueued_request(request, **options, &block)
assert_no_enqueued_requests(client_class, **options, &block)
# wrap the test's Exercise Phase
test "#create enqueues a request to the Articles API" do
assert_enqueued_request ArticlesClient.create(title: "Hello, World")) do
post articles_path(params: { article: { title: "Hello, World" } })
end
end
# or assert after the test's Exercise Phase (requires Rails >= 6.0)
test "#create enqueues a request to the Articles API" do
post articles_path(params: { article: { title: "Hello, World" } })
assert_enqueued_request ArticlesClient.create(title: "Hello, World"))
end
# wrap the test's Exercise Phase
test "#create does not enqueue a request to the Articles API when invalid" do
assert_no_enqueued_requests ArticlesClient do
post articles_path(params: { article: { title: "" } })
end
end
# or assert after the test's Exercise Phase (requires Rails >= 6.0)
test "#create does not enqueue a request to the Articles API when invalid" do
post articles_path(params: { article: { title: "" } })
assert_no_enqueued_requests ArticlesClient
end
When config.active_job.queue_adapter = :inline
:
assert_performed_request(request, **options, &block)
assert_no_performed_requests(client_class, **options, &block)
# wrap the test's Exercise Phase
test "#create enqueues a request to the Articles API" do
assert_performed_request ArticlesClient.create(title: "Hello, World")) do
post articles_path(params: { article: { title: "Hello, World" } })
end
end
# or assert after test's the Exercise Phase
test "#create enqueues a request to the Articles API" do
perform_enqueued_jobs do
post articles_path(params: { article: { title: "Hello, World" } })
assert_performed_request ArticlesClient.create(title: "Hello, World"))
end
end
# wrap the test's Exercise Phase
test "#create does not enqueue a request to the Articles API when invalid" do
assert_no_performed_requests ArticlesClient do
post articles_path(params: { article: { title: "" } })
end
end
# or assert after test's the Exercise Phase
test "#create does not enqueue a request to the Articles API when invalid" do
perform_enqueued_jobs do
post articles_path(params: { article: { title: "" } })
assert_no_performed_requests ArticlesClient
end
end
Add this line to your application's Gemfile:
gem "action_client", github: "thoughtbot/action_client", branch: "main"
And then execute:
$ bundle
This project's Ruby code is linted by standard. New code that is added through Pull Requests cannot include any linting violations.
To helper ensure that your contributions don't contain any violations, please consider integrating Standard into your editor workflow.
The gem is available as open source under the terms of the MIT License.