Code Monkey home page Code Monkey logo

action_client's Introduction

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

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.

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

Response body parsing

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.

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

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

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.

Configuring your clients in YAML

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.

Configuring config.action_client.parser

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 },
}

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 { |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

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|
      if status == 201
        response.body = Article.new(body)
      end
    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|
      response.body = Article.new(body)
    end
  end
end

Executing after_submit for a set of Requests

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

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 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

Excluding an after_submit for a range of HTTP Status Codes

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

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.

Configuring Previews

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"

Testing

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

ActiveJob Assertions

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

Installation

Add this line to your application's Gemfile:

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

And then execute:

$ bundle

Contributing

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.

License

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

action_client's People

Contributors

georgebrock avatar seanpdoyle avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar

action_client's Issues

Rails configuration integration

Clients might want to declare a root default url: value like so:

class ArticlesClient < ActionClient::Base
  default url: "https://example.com"
end

However, that URL might vary across environments.

Rails.application.config_for is an environment-aware tool for reading configuration files.

Establishing conventions for how to integrate with config_for could be helpful.

For example, consider a config/clients/articles.yml file that declares a URL for each environment:

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

development:
  <<: *default

test:
  <<: *default

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

Consider a potential .configuration method provided by ActionClient::Base that is pre-wired to read from that file:

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

For sensitive values, consider embedding encrypted credentials directly into the .yml file through ERB:

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

development:
  <<: *default

test:
  <<: *default

production:
  <<: *default
  url: "https://example.com"
  client_secret: <%= Rails.application.credentials[:articles].client_secret %>

Then, call that value from the client:

class ArticlesClient < ActionClient::Base
  default url: configuration.url
  default headers: {
    "X-Api-Key": configuration.client_secret,
   }
end

Rails Generator Integration

Some problems that generators could help solve:

  • how do I install this into my application?

    • generate ApplicationClient
    • generate app/views/clients/.keep
  • how do I write my first client?

    • generator for client name and action
    • generate view template and preview

Maybe we can look to ActionMailer's guides for inspiration.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.