Skip to content

Commit

Permalink
[feat] Add request and response hooks (#278)
Browse files Browse the repository at this point in the history
  • Loading branch information
dcaballeroc authored Jul 27, 2023
1 parent f6b54ff commit 2c28ca1
Show file tree
Hide file tree
Showing 14 changed files with 629 additions and 6 deletions.
13 changes: 12 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,15 @@ test/version_tmp
tmp
vendor/
/easycop.yml
/.rubocop.yml
/.rubocop.yml
[._]*.s[a-v][a-z]
[._]*.sw[a-p]
[._]s[a-rt-v][a-z]
[._]ss[a-gi-z]
[._]sw[a-p]
Session.vim
Sessionx.vim
.netrwhist
*~
tags
[._]*.un~
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## Next Release

- Maps 400 status code responses to the new `BadRequestError` class
- Adds hooks to introspect the request and response of API calls (see `HTTP Hooks` section in the README for more details)

## v5.0.1 (2023-06-20)

Expand Down
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,49 @@ my_client = described_class.new(
)
```

### HTTP Hooks

Users can audit the HTTP requests and response being made by the library by subscribing to request and response events. To do so, pass a block to `subscribe_request_hook` and `subscribe_response_hook` methods of an instance of `EasyPost::Client`:

```ruby
require 'easypost'

client = EasyPost::Client.new(api_key: ENV['EASYPOST_API_KEY'])

# Returns a randomly-generated symbol which you can use to later unsubscribe the request hook
client.subscribe_request_hook do |request_data|
# Your code goes here
end
# Returns a randomly-generated symbol which you can use to later unsubscribe the response hook
client.subscribe_response_hook do |response_data|
# Your code goes here
end
```

You can also name your hook subscriptions by providing an optional parameter to the methods above:

```ruby
require 'easypost'

client = EasyPost::Client.new(api_key: ENV['EASYPOST_API_KEY'])

request_hook = client.subscribe_request_hook(:my_request_hook) do |request_data|
# Your code goes here
end
response_hook = client.subscribe_response_hook(:my_response_hook) do |response_data|
# Your code goes here
end

puts request_hook # :my_request_hook
puts response_hook # :my_response_hook
```

Keep in mind that subscribing a hook with the same name of an existing hook will replace the existing hook with the new one. A request hook and a response hook can share the same name.

#### A note on response hooks and custom HTTP connections

If you're using a custom HTTP connection, keep in mind that the `response_data` parameter that a response hook receives *will not be hydrated* with all the response data. You will have to inspect the `client_response_object` property in `response_data` to inspect the response code, response headers and response body.

## Documentation

API documentation can be found at: <https://easypost.com/docs/api>.
Expand Down
3 changes: 3 additions & 0 deletions lib/easypost.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,8 @@
# Internal Utilities
require 'easypost/internal_utilities'

# Hooks
require 'easypost/hooks'

module EasyPost
end
49 changes: 49 additions & 0 deletions lib/easypost/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
require_relative 'http_client'
require_relative 'internal_utilities'
require 'json'
require 'securerandom'

class EasyPost::Client
attr_reader :open_timeout, :read_timeout, :api_base
Expand Down Expand Up @@ -90,6 +91,54 @@ def make_request(
EasyPost::InternalUtilities::Json.convert_json_to_object(response.body, cls)
end

# Subscribe a request hook
#
# @param name [Symbol] the name of the hook. Defaults ot a ranom hexadecimal-based symbol
# @param block [Block] a code block that will be executed before a request is made
# @return [Symbol] the name of the request hook
def subscribe_request_hook(name = SecureRandom.hex.to_sym, &block)
EasyPost::Hooks.subscribe(:request, name, block)
end

# Unsubscribe a request hook
#
# @param name [Symbol] the name of the hook
# @return [Block] the hook code block
def unsubscribe_request_hook(name)
EasyPost::Hooks.unsubscribe(:request, name)
end

# Unsubscribe all request hooks
#
# @return [Hash] a hash containing all request hook subscriptions
def unsubscribe_all_request_hooks
EasyPost::Hooks.unsubscribe_all(:request)
end

# Subscribe a response hook
#
# @param name [Symbol] the name of the hook. Defaults ot a ranom hexadecimal-based symbol
# @param block [Block] a code block that will be executed upon receiving the response from a request
# @return [Symbol] the name of the response hook
def subscribe_response_hook(name = SecureRandom.hex.to_sym, &block)
EasyPost::Hooks.subscribe(:response, name, block)
end

# Unsubscribe a response hook
#
# @param name [Symbol] the name of the hook
# @return [Block] the hook code block
def unsubscribe_response_hook(name)
EasyPost::Hooks.unsubscribe(:response, name)
end

# Unsubscribe all response hooks
#
# @return [Hash] a hash containing all response hook subscriptions
def unsubscribe_all_response_hooks
EasyPost::Hooks.unsubscribe_all(:response)
end

private

def http_config
Expand Down
34 changes: 34 additions & 0 deletions lib/easypost/hooks.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# frozen_string_literal: true

module EasyPost::Hooks
def self.subscribe(type, name, block)
subscribers[type][name] = block

name
end

def self.unsubscribe(type, name)
subscribers[type].delete(name)
end

def self.unsubscribe_all(type)
subscribers.delete(type)
end

def self.notify(type, context)
subscribers[type].each_value { |subscriber| subscriber.call(context) }
end

def self.any_subscribers?(type)
!subscribers[type].empty?
end

def self.subscribers
@subscribers ||= Hash.new { |hash, key| hash[key] = {} }
end

private_class_method :subscribers
end

require_relative 'hooks/request_context'
require_relative 'hooks/response_context'
16 changes: 16 additions & 0 deletions lib/easypost/hooks/request_context.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# frozen_string_literal: true

class EasyPost::Hooks::RequestContext
attr_reader :method, :path, :headers, :request_body, :request_timestamp, :request_uuid

def initialize(method:, path:, headers:, request_body:, request_timestamp:, request_uuid:)
@method = method
@path = path
@headers = headers
@request_body = request_body
@request_timestamp = request_timestamp
@request_uuid = request_uuid

freeze
end
end
23 changes: 23 additions & 0 deletions lib/easypost/hooks/response_context.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# frozen_string_literal: true

class EasyPost::Hooks::ResponseContext
attr_reader :http_status, :method, :path, :headers, :response_body,
:request_timestamp, :response_timestamp, :request_uuid,
:client_response_object

def initialize(http_status:, method:, path:, headers:, response_body:,
request_timestamp:, response_timestamp:, request_uuid:,
client_response_object:)
@http_status = http_status
@method = method
@path = path
@headers = headers
@response_body = response_body
@request_timestamp = request_timestamp
@response_timestamp = response_timestamp
@request_uuid = request_uuid
@client_response_object = client_response_object

freeze
end
end
62 changes: 57 additions & 5 deletions lib/easypost/http_client.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# frozen_string_literal: true

require 'securerandom'

class EasyPost::HttpClient
def initialize(base_url, config, custom_client_exec = nil)
@base_url = base_url
Expand All @@ -20,17 +22,67 @@ def request(

uri = URI.parse("#{@base_url}/#{api_version}/#{path}")
headers = @config[:headers].merge(headers || {})
body = JSON.dump(EasyPost::InternalUtilities.objects_to_ids(body)) if body
serialized_body = JSON.dump(EasyPost::InternalUtilities.objects_to_ids(body)) if body
open_timeout = @config[:open_timeout]
read_timeout = @config[:read_timeout]
request_timestamp = Time.now
request_uuid = SecureRandom.uuid

if EasyPost::Hooks.any_subscribers?(:request)
request_context = EasyPost::Hooks::RequestContext.new(
method: method,
path: uri.to_s,
headers: headers,
request_body: body,
request_timestamp: request_timestamp,
request_uuid: request_uuid,
)
EasyPost::Hooks.notify(:request, request_context)
end

# Execute the request, return the response.

if @custom_client_exec
@custom_client_exec.call(method, uri, headers, open_timeout, read_timeout, body)
else
default_request_execute(method, uri, headers, open_timeout, read_timeout, body)
response = if @custom_client_exec
@custom_client_exec.call(method, uri, headers, open_timeout, read_timeout, serialized_body)
else
default_request_execute(method, uri, headers, open_timeout, read_timeout, serialized_body)
end
response_timestamp = Time.now

if EasyPost::Hooks.any_subscribers?(:response)
response_context = {
http_status: nil,
method: method,
path: uri.to_s,
headers: nil,
response_body: nil,
request_timestamp: request_timestamp,
response_timestamp: response_timestamp,
client_response_object: response,
request_uuid: request_uuid,
}

# If using a custom HTTP client, the user will have to infer these from the raw
# client_response_object attribute
if response.is_a?(Net::HTTPResponse)
response_body = begin
JSON.parse(response.body)
rescue JSON::ParseError
response.body
end
response_context.merge!(
{
http_status: response.code.to_i,
headers: response.each_header.to_h,
response_body: response_body,
},
)
end

EasyPost::Hooks.notify(:response, EasyPost::Hooks::ResponseContext.new(**response_context))
end

response
end

def default_request_execute(method, uri, headers, open_timeout, read_timeout, body = nil)
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit 2c28ca1

Please sign in to comment.