Skip to content

Commit

Permalink
Adds a token_cache configuration option to share tokens across proces…
Browse files Browse the repository at this point in the history
…ses (#11)
  • Loading branch information
prsimp authored and andremleblanc committed Jun 20, 2019
1 parent 0b3be15 commit 184fc1a
Show file tree
Hide file tree
Showing 16 changed files with 612 additions and 81 deletions.
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,19 @@ HelpScout::User.list
user = HelpScout::User.get(id)
```

### Caching Access Tokens

Since short-lived access tokens aren't likely to be embedded into environment variables, it can be difficult to share them across processes. To work around this, you can configure a `token_cache` (and optional `token_cache_key`) to be used to store and retrieve the token until expiry. In general any object that conforms to the `ActiveSupport::Cache::Store` API should work. For example, using an application's Rails cache:

```ruby
HelpScout.configuration.token_cache = Rails.cache
HelpScout.configuration.token_cache_key
# => 'help_scout_token_cache'
HelpScout.configuration.token_cache_key = 'my-own-key'
```

With caching configured, whenever the gem attempts to create an access token, it will first attempt to read a value from the cache using the configured cache key. If it's a hit, the cached values will be used to create a new `AccessToken`. If it's a miss, then the gem will make a request to the Help Scout API to retrieve a new token, writing the token's details to the cache before returning the new token.

## Development

After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
Expand Down
2 changes: 1 addition & 1 deletion lib/help_scout/api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ def send_request(action, path, params)
response = new_connection.send(action, path, params.compact)

if response.status == 401
access_token.invalidate!
access_token&.invalidate!
response = new_connection.send(action, path, params.compact)
end

Expand Down
50 changes: 28 additions & 22 deletions lib/help_scout/api/access_token.rb
Original file line number Diff line number Diff line change
@@ -1,32 +1,24 @@
# frozen_string_literal: true

require 'date'
require 'help_scout/api/access_token/cache'
require 'help_scout/api/access_token/request'

module HelpScout
class API
class AccessToken
class << self
def create
connection = HelpScout::API::Client.new(authorize: false).connection
response = connection.post('oauth2/token', token_request_params)

case response.status
when 200 then new HelpScout::Response.new(response).body
when 429 then raise HelpScout::API::ThrottleLimitReached, response.body&.dig('error')
else raise HelpScout::API::InternalError, "unexpected response (status #{response.status})"
end
end
cache = HelpScout::API::AccessToken::Cache.new
request = HelpScout::API::AccessToken::Request.new

def refresh!
HelpScout.api.access_token = create
cache.configured? ? cache.fetch_token { request.execute } : request.execute
end

private
def refresh!
return HelpScout.api.access_token unless HelpScout.access_token.nil? || HelpScout.access_token.stale?

def token_request_params
@_token_request_params ||= {
grant_type: 'client_credentials',
client_id: HelpScout.app_id,
client_secret: HelpScout.app_secret
}
HelpScout.api.access_token = create
end
end

Expand All @@ -35,10 +27,20 @@ def token_request_params

def initialize(params)
@value = params[:access_token]
@expires_in = params[:expires_in]
return unless @expires_in

@expires_at = Time.now.utc + expires_in
if params[:expires_at]
@expires_at = DateTime.parse(params[:expires_at].to_s).to_time.utc
elsif params[:expires_in]
@expires_in = params[:expires_in].to_i
@expires_at = (Time.now.utc + @expires_in)
end
end

def as_json(*)
{
access_token: value,
expires_at: expires_at
}
end

def expired?
Expand All @@ -48,10 +50,14 @@ def expired?
end

def invalid?
invalid
!!invalid # rubocop:disable Style/DoubleNegation
end

def invalidate!
cache = HelpScout::API::AccessToken::Cache.new

cache.delete if cache.configured?

self.invalid = true
end

Expand Down
40 changes: 40 additions & 0 deletions lib/help_scout/api/access_token/cache.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# frozen_string_literal: true

module HelpScout
class API
class AccessToken
class Cache
attr_reader :backend, :key

def initialize(backend: nil, key: nil)
@backend = backend || HelpScout.configuration.token_cache
@key = key || HelpScout.configuration.token_cache_key
end

def configured?
backend.present? && key.present?
end

def delete
backend.delete(key)
end

def fetch_token(&token_request)
raise ArgumentError, 'A request fallback block is required' unless block_given?

if (cached_token_json = backend.read(key))
AccessToken.new(JSON.parse(cached_token_json, symbolize_names: true))
else
token_request.call.tap { |token| write(token) }
end
end

private

def write(token)
backend.write(key, token.to_json, expires_in: token.expires_in)
end
end
end
end
end
36 changes: 36 additions & 0 deletions lib/help_scout/api/access_token/request.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# frozen_string_literal: true

module HelpScout
class API
class AccessToken
class Request
attr_reader :response

def execute
@response = request_token
end

private

def request_token
connection = API::Client.new(authorize: false).connection
http_response = connection.post('oauth2/token', token_request_params)

case http_response.status
when 200 then AccessToken.new(Response.new(http_response).body)
when 429 then raise API::ThrottleLimitReached, http_response.body&.dig('error')
else raise API::InternalError, "unexpected response (status #{http_response.status})"
end
end

def token_request_params
@_token_request_params ||= {
grant_type: 'client_credentials',
client_id: HelpScout.app_id,
client_secret: HelpScout.app_secret
}
end
end
end
end
end
17 changes: 9 additions & 8 deletions lib/help_scout/api/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,30 +10,31 @@ def initialize(authorize: true)
end

def connection
@_connection ||= begin
HelpScout::API::AccessToken.refresh! if authorize? && token_needs_refresh?
build_connection
@_connection ||= build_connection.tap do |conn|
if authorize?
HelpScout::API::AccessToken.refresh!
conn.authorization(:Bearer, access_token) if access_token
end
end
end

private

def access_token
HelpScout.access_token&.value
end

def authorize?
authorize
end

def build_connection
Faraday.new(url: BASE_URL) do |conn|
conn.request :json
conn.authorization(:Bearer, HelpScout.access_token.value) if authorize? && HelpScout.access_token&.value
conn.response(:json, content_type: /\bjson$/)
conn.adapter(Faraday.default_adapter)
end
end

def token_needs_refresh?
HelpScout.access_token.nil? || HelpScout.access_token.stale?
end
end
end
end
11 changes: 10 additions & 1 deletion lib/help_scout/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,22 @@

module HelpScout
class Configuration
attr_accessor :app_id, :app_secret, :default_mailbox
attr_accessor :app_id, :app_secret, :default_mailbox, :token_cache
attr_reader :access_token
attr_writer :token_cache_key

DEFAULT_CACHE_KEY = 'help_scout_token_cache'

def access_token=(token_value)
return unless token_value

@access_token = HelpScout::API::AccessToken.new(access_token: token_value)
end

def token_cache_key
return @token_cache_key if defined?(@token_cache_key)

@token_cache_key = DEFAULT_CACHE_KEY
end
end
end

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

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

11 changes: 0 additions & 11 deletions spec/integration/access_token_spec.rb

This file was deleted.

35 changes: 35 additions & 0 deletions spec/integration/api/access_token_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# frozen_string_literal: true

require 'active_support/cache/memory_store'

RSpec.describe HelpScout::API::AccessToken do
describe '.create' do
subject { described_class.create }

it 'returns an AccessToken' do
expect(subject).to be_a described_class
end

context 'when caching is configured' do
let(:cache) { double('token_cache') }

around { |example| with_config(token_cache: cache) { example.run } }

it 'attempts to fetch the token from the cache' do
expect_any_instance_of(HelpScout::API::AccessToken::Cache).to receive(:fetch_token)

subject
end
end

context 'when caching is not configured' do
around { |example| with_config(token_cache: nil) { example.run } }

it 'attempts to request a token' do
expect_any_instance_of(HelpScout::API::AccessToken::Request).to receive(:execute)

subject
end
end
end
end
Loading

0 comments on commit 184fc1a

Please sign in to comment.