ActionClient

Make HTTP calls by leveraging Rails rendering

This project is in its early phases of development

Its interface, behavior, and name are likely to change drastically before being published to RubyGems. Use at your own risk.

Usage

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" }.

Declaring the Client

First, declare the ArticlesClient as a descendant of ActionClient::Base:

class ArticlesClient < ActionClient::Base
end

Requests

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

Constructing the Request

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: application/json HTTP header based on the format of the action's template. In this 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.

Responses

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.

When ActionClient is able to infer the request's Content-Type to be either JSON or XML, it will parse the returned body value ahead of time.

Requests make with application/json will be parsed into Hash instances by JSON.parse, and requests made with application/xml will be parsed into Nokogiri::XML::Document instances by Nokogiri::XML.

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

Query Parameters

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.

ActiveJob integration

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.

Extending ActionClient::SubmissionJob

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 with_status: 500..599 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 with_status: option, to serve as a guard clause filter.

Configuration

Declaring default options

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

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.

In this example, ArticlesClient.configuration will read directly from the environment-aware config/clients/articles.yml file.

Consider the following configuration:

# config/clients/articles.yml
default: &default
  url: "https://staging.example.com"

development:
  <<: *default

test:
  <<: *default

production:
  <<: *default
  url: "https://example.com"

Then from the client class, read those values directly from configuration:

class ArticlesClient < ActionClient::Base
  default url: configuration.url
end

When a matching configuration file does not exist, ActionClient::Base.configuration returns an empty instance of ActiveSupport::OrderedOptions.

Declaring after_submit callbacks

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 do |status, headers, body|
    [status, headers, OpenStruct.new(body)]
  end
end

When declaring after_submit hooks, it's important to make sure that the block returns a Rack-compliant triplet of status, headers, and body.

Declaring Request-specific callbacks

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|
      [status, headers, Article.new(body)]
    end
  end
end

Request-level blocks are executed after class-level after_submit blocks.

Transforming the response's body

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|
      Article.new(body)
    end
  end
end

Executing after_submit for a range of HTTP Status Codes

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 with_status: 422 as a keyword argument:

class ArticlesClient < ActionClient::Base
  after_submit with_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 with_status: as either an Array or a Range:

class ArticlesClient < ActionClient::Base
  after_submit with_status: [401, 403] do |status, headers, body|
    raise MyApplication::SecurityError, body.fetch("error")
  end

  after_submit with_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 with_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 with_status: :unprocessable_entity do |body|
    raise MyApplication::ArgumentError, body.fetch("error")
  end

  after_submit with_status: [:unauthorized, :forbidden] do |body|
    raise MyApplication::SecurityError, body.fetch("error")
  end
end

Previews

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.

Installation

Add this line to your application's Gemfile:

gem "action_client", github: "thoughtbot/action_client", branch: "main"

And then execute:

$ bundle

Contributing

Contribution directions go here.

License

The gem is available as open source under the terms of the MIT License.