fulfil_api 0.7.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +9 -1
- data/.ruby-version +1 -1
- data/CHANGELOG.md +13 -0
- data/README.md +149 -2
- data/app/controllers/concerns/fulfil_api/authenticated.rb +43 -0
- data/app/controllers/concerns/fulfil_api/installation_scoped.rb +56 -0
- data/app/controllers/fulfil_api/application_controller.rb +31 -0
- data/app/controllers/fulfil_api/installations_controller.rb +111 -0
- data/app/models/concerns/fulfil_api/installable.rb +47 -0
- data/app/models/fulfil_api/installation.rb +145 -0
- data/config/routes.rb +9 -0
- data/lib/fulfil_api/circuit_open.rb +9 -0
- data/lib/fulfil_api/client.rb +10 -6
- data/lib/fulfil_api/configuration.rb +72 -8
- data/lib/fulfil_api/engine.rb +14 -0
- data/lib/fulfil_api/middleware/circuit_breaker.rb +44 -0
- data/lib/fulfil_api/middleware.rb +59 -0
- data/lib/fulfil_api/oauth/authorization.rb +172 -0
- data/lib/fulfil_api/oauth/configuration.rb +96 -0
- data/lib/fulfil_api/oauth/token.rb +78 -0
- data/lib/fulfil_api/oauth.rb +37 -0
- data/lib/fulfil_api/relation.rb +2 -2
- data/lib/fulfil_api/resource/attribute_assignable.rb +12 -12
- data/lib/fulfil_api/tpl_client.rb +10 -6
- data/lib/fulfil_api/version.rb +1 -1
- data/lib/fulfil_api.rb +11 -0
- data/lib/generators/fulfil_api/install/install_generator.rb +72 -0
- data/lib/generators/fulfil_api/install/templates/create_fulfil_api_installations.rb +43 -0
- data/lib/generators/fulfil_api/install/templates/fulfil_api.rb +38 -0
- metadata +48 -11
data/config/routes.rb
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
FulfilApi::Engine.routes.draw do
|
|
4
|
+
resource :installation, only: %i[new]
|
|
5
|
+
|
|
6
|
+
# Fulfil sends the user back with a `GET`, so the installation cannot be
|
|
7
|
+
# created through the resourceful `POST /installation` route.
|
|
8
|
+
get "callback", to: "installations#create", as: :callback
|
|
9
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FulfilApi
|
|
4
|
+
# The {FulfilApi::CircuitOpen} is raised instead of sending a request while the
|
|
5
|
+
# circuit breaker has the Fulfil instance marked as failing.
|
|
6
|
+
#
|
|
7
|
+
# @see FulfilApi::Middleware::CircuitBreaker
|
|
8
|
+
class CircuitOpen < Error; end
|
|
9
|
+
end
|
data/lib/fulfil_api/client.rb
CHANGED
|
@@ -113,6 +113,9 @@ module FulfilApi
|
|
|
113
113
|
configure_persistent_connection(http)
|
|
114
114
|
end
|
|
115
115
|
|
|
116
|
+
# Retries and other safeguards wrap every request, so they're added first
|
|
117
|
+
FulfilApi::Middleware.apply(connection, configuration)
|
|
118
|
+
|
|
116
119
|
# Configuration of the request middleware
|
|
117
120
|
connection.request :json
|
|
118
121
|
|
|
@@ -136,11 +139,9 @@ module FulfilApi
|
|
|
136
139
|
# Tunes the underlying Net::HTTP::Persistent connection.
|
|
137
140
|
#
|
|
138
141
|
# The `net_http_persistent` adapter forces `max_retries` to 0 on every
|
|
139
|
-
# request
|
|
140
|
-
#
|
|
141
|
-
#
|
|
142
|
-
# as a read timeout. The config block runs after the adapter zeroes the
|
|
143
|
-
# value, so this takes effect.
|
|
142
|
+
# request. Retries are handled by {FulfilApi::Middleware::Retry} instead, so
|
|
143
|
+
# `max_retries` is only restored when it's configured explicitly. The config
|
|
144
|
+
# block runs after the adapter zeroes the value, so this takes effect.
|
|
144
145
|
#
|
|
145
146
|
# @param http [Net::HTTP::Persistent] The live persistent connection.
|
|
146
147
|
# @return [void]
|
|
@@ -155,7 +156,10 @@ module FulfilApi
|
|
|
155
156
|
|
|
156
157
|
# @return [Array] The cache key identifying a unique connection.
|
|
157
158
|
def connection_cache_key
|
|
158
|
-
[
|
|
159
|
+
[
|
|
160
|
+
configuration.merchant_id, configuration.request_options, configuration.connection_options,
|
|
161
|
+
configuration.retry_options, configuration.circuit_breaker
|
|
162
|
+
]
|
|
159
163
|
end
|
|
160
164
|
|
|
161
165
|
# @param relative_path [String] The relative path to the API endpoint.
|
|
@@ -7,22 +7,43 @@ module FulfilApi
|
|
|
7
7
|
# to these settings.
|
|
8
8
|
class Configuration
|
|
9
9
|
attr_accessor :access_token, :api_version, :merchant_id, :request_options, :tpl
|
|
10
|
-
attr_reader :connection_options
|
|
10
|
+
attr_reader :connection_options, :oauth, :retry_options
|
|
11
|
+
|
|
12
|
+
# @!attribute [rw] circuit_breaker
|
|
13
|
+
# @return [Faulty, nil] The Faulty instance that runs every request through a
|
|
14
|
+
# circuit per Fulfil instance, or nil to turn the circuit breaker off. See
|
|
15
|
+
# {FulfilApi::Middleware::CircuitBreaker}.
|
|
16
|
+
attr_accessor :circuit_breaker
|
|
11
17
|
|
|
12
18
|
DEFAULT_API_VERSION = "v2"
|
|
13
19
|
DEFAULT_REQUEST_OPTIONS = { open_timeout: 1, read_timeout: 5, write_timeout: 5, timeout: 5 }.freeze
|
|
14
20
|
|
|
15
21
|
# Tuning for the persistent (keep-alive) HTTP connection.
|
|
16
22
|
#
|
|
17
|
-
# `max_retries` re-enables Ruby's built-in retry for idempotent requests
|
|
18
|
-
# (GET/HEAD/PUT/DELETE/OPTIONS). The `net_http_persistent` adapter forces
|
|
19
|
-
# it to 0, which means a keep-alive socket the server has already dropped
|
|
20
|
-
# surfaces as a read timeout instead of being transparently retried on a
|
|
21
|
-
# fresh socket. POST is never auto-retried, so this is side-effect safe.
|
|
22
|
-
#
|
|
23
23
|
# `idle_timeout` and `pool_size` are passed through to the underlying
|
|
24
24
|
# Net::HTTP::Persistent connection when set.
|
|
25
|
-
|
|
25
|
+
#
|
|
26
|
+
# `max_retries` re-enables Ruby's built-in retry, which retries every request
|
|
27
|
+
# Net::HTTP considers idempotent (GET/HEAD/PUT/DELETE/OPTIONS) whatever its
|
|
28
|
+
# endpoint. Fulfil writes through PUT too, so it is off by default in favour
|
|
29
|
+
# of the endpoint scoped {#retry_options}.
|
|
30
|
+
DEFAULT_CONNECTION_OPTIONS = {}.freeze
|
|
31
|
+
|
|
32
|
+
# Retries for requests that failed before Fulfil could answer them: a dropped
|
|
33
|
+
# keep-alive socket, a refused connection or a timeout.
|
|
34
|
+
#
|
|
35
|
+
# `max_retries` caps the number of retries per request. `requests` maps an
|
|
36
|
+
# HTTP verb onto the endpoints that are safe to retry, either `true` for every
|
|
37
|
+
# endpoint or a list of patterns matched against the request path. Reads in
|
|
38
|
+
# Fulfil go out as PUT requests to `search_read` and `search_count`, so those
|
|
39
|
+
# are retried; every other PUT may write and is not.
|
|
40
|
+
DEFAULT_RETRY_OPTIONS = {
|
|
41
|
+
max_retries: 1,
|
|
42
|
+
requests: {
|
|
43
|
+
get: true,
|
|
44
|
+
put: [%r{/search_read\z}, %r{/search_count\z}]
|
|
45
|
+
}
|
|
46
|
+
}.freeze
|
|
26
47
|
|
|
27
48
|
# Initializes the configuration with optional settings.
|
|
28
49
|
#
|
|
@@ -48,8 +69,49 @@ module FulfilApi
|
|
|
48
69
|
@connection_options = DEFAULT_CONNECTION_OPTIONS.merge(options || {})
|
|
49
70
|
end
|
|
50
71
|
|
|
72
|
+
# Assigns the credentials of the OAuth app that was registered in Fulfil's
|
|
73
|
+
# authentication dashboard. Assigning `nil` resets to the defaults.
|
|
74
|
+
#
|
|
75
|
+
# @param options [Hash, FulfilApi::OAuth::Configuration, nil] The OAuth options to apply.
|
|
76
|
+
# @return [void]
|
|
77
|
+
def oauth=(options)
|
|
78
|
+
@oauth =
|
|
79
|
+
case options
|
|
80
|
+
when OAuth::Configuration then options
|
|
81
|
+
when Hash, NilClass then OAuth::Configuration.new(options)
|
|
82
|
+
else raise ArgumentError, "Expected Hash or FulfilApi::OAuth::Configuration, got #{options.class} instead"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Merges the provided retry options over the defaults. Assigning `nil` resets
|
|
87
|
+
# to the defaults.
|
|
88
|
+
#
|
|
89
|
+
# @example retrying reads three times, and one write endpoint as well
|
|
90
|
+
# config.retry_options = {
|
|
91
|
+
# max_retries: 3,
|
|
92
|
+
# requests: { get: true, put: [%r{/search_read\z}, %r{/stock.shipment.out/hold\z}] }
|
|
93
|
+
# }
|
|
94
|
+
#
|
|
95
|
+
# @param options [Hash, nil] The retry options to apply.
|
|
96
|
+
# @return [void]
|
|
97
|
+
def retry_options=(options)
|
|
98
|
+
@retry_options = DEFAULT_RETRY_OPTIONS.merge(options || {})
|
|
99
|
+
end
|
|
100
|
+
|
|
51
101
|
private
|
|
52
102
|
|
|
103
|
+
# {FulfilApi.with_config} duplicates the active configuration, which would
|
|
104
|
+
# otherwise share the very same OAuth configuration object with the copy.
|
|
105
|
+
# A block that changes an OAuth option would then leak that change into
|
|
106
|
+
# the configuration it was supposed to be reverted to.
|
|
107
|
+
#
|
|
108
|
+
# @param source [FulfilApi::Configuration] The configuration being copied.
|
|
109
|
+
# @return [void]
|
|
110
|
+
def initialize_copy(source)
|
|
111
|
+
super
|
|
112
|
+
@oauth = source.oauth.dup
|
|
113
|
+
end
|
|
114
|
+
|
|
53
115
|
# Sets the default options for the gem configuration.
|
|
54
116
|
#
|
|
55
117
|
# This method is called during initialization to ensure all configuration
|
|
@@ -60,6 +122,8 @@ module FulfilApi
|
|
|
60
122
|
self.api_version = DEFAULT_API_VERSION if api_version.nil?
|
|
61
123
|
self.request_options = DEFAULT_REQUEST_OPTIONS if request_options.nil?
|
|
62
124
|
self.connection_options = nil if connection_options.nil?
|
|
125
|
+
self.oauth = nil if oauth.nil?
|
|
126
|
+
self.retry_options = nil if retry_options.nil?
|
|
63
127
|
end
|
|
64
128
|
end
|
|
65
129
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/engine"
|
|
4
|
+
|
|
5
|
+
module FulfilApi
|
|
6
|
+
# Ships the OAuth flow as a mountable engine, so a Rails application only has
|
|
7
|
+
# to mount it and point Fulfil's whitelisted redirect URL at its callback.
|
|
8
|
+
#
|
|
9
|
+
# @example config/routes.rb
|
|
10
|
+
# mount FulfilApi::Engine => "/fulfil"
|
|
11
|
+
class Engine < ::Rails::Engine
|
|
12
|
+
isolate_namespace FulfilApi
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FulfilApi
|
|
4
|
+
module Middleware
|
|
5
|
+
# The {FulfilApi::Middleware::CircuitBreaker} runs every request through a
|
|
6
|
+
# [Faulty](https://github.com/ParentSquare/faulty) circuit, so a Fulfil instance
|
|
7
|
+
# that keeps failing is skipped for a while instead of tying up the threads of an
|
|
8
|
+
# application that talks to several of them.
|
|
9
|
+
#
|
|
10
|
+
# Each Fulfil instance gets its own circuit, shared by the {FulfilApi::Client} and
|
|
11
|
+
# the {FulfilApi::TplClient}. The thresholds and the storage come from the
|
|
12
|
+
# configured Faulty instance.
|
|
13
|
+
#
|
|
14
|
+
# It wraps the retries, so a request that failed after retrying counts as one
|
|
15
|
+
# failure. A response with a 4xx status code is an answer, and doesn't count.
|
|
16
|
+
class CircuitBreaker < Faraday::Middleware
|
|
17
|
+
# The errors that count as a failure of the Fulfil instance.
|
|
18
|
+
FAILURES = [Faraday::ConnectionFailed, Faraday::ServerError, Faraday::TimeoutError].freeze
|
|
19
|
+
|
|
20
|
+
# Faulty wraps the error of a failed request into one of its own. Raising the
|
|
21
|
+
# original error instead keeps the gem's errors the same with or without a
|
|
22
|
+
# circuit breaker. Only an open circuit, which has no original error, raises
|
|
23
|
+
# a {FulfilApi::CircuitOpen}.
|
|
24
|
+
ERROR_MAPPER = lambda do |_error_name, cause, circuit|
|
|
25
|
+
cause || FulfilApi::CircuitOpen.new("#{circuit.name} is open after repeated failures", details: {})
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param env [Faraday::Env] The environment of the request.
|
|
29
|
+
# @raise [FulfilApi::CircuitOpen] When the circuit of the Fulfil instance is open.
|
|
30
|
+
# @return [Faraday::Response]
|
|
31
|
+
def call(env)
|
|
32
|
+
circuit_for(env).run { @app.call(env) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# @param env [Faraday::Env] The environment of the request.
|
|
38
|
+
# @return [Faulty::Circuit]
|
|
39
|
+
def circuit_for(env)
|
|
40
|
+
options[:faulty].circuit("fulfil_api:#{env.url.host}", errors: FAILURES, error_mapper: ERROR_MAPPER)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FulfilApi
|
|
4
|
+
# The {FulfilApi::Middleware} sets up the Faraday middleware that both the
|
|
5
|
+
# {FulfilApi::Client} and the {FulfilApi::TplClient} run their requests through.
|
|
6
|
+
module Middleware
|
|
7
|
+
# The errors raised when a request never got an answer from Fulfil. An HTTP
|
|
8
|
+
# error response is an answer, and is never retried.
|
|
9
|
+
RETRYABLE_ERRORS = [Faraday::ConnectionFailed, Faraday::TimeoutError].freeze
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
# Adds the gem's middleware to a Faraday connection. Middleware runs in the
|
|
13
|
+
# order it's added, so the first one wraps everything after it.
|
|
14
|
+
#
|
|
15
|
+
# @param connection [Faraday::Connection] The connection being built.
|
|
16
|
+
# @param configuration [FulfilApi::Configuration] The configuration of the client.
|
|
17
|
+
# @return [void]
|
|
18
|
+
def apply(connection, configuration)
|
|
19
|
+
connection.use CircuitBreaker, faulty: configuration.circuit_breaker if configuration.circuit_breaker
|
|
20
|
+
connection.request :retry, retry_middleware_options(configuration.retry_options)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
# Translates the {FulfilApi::Configuration#retry_options} into the options of
|
|
26
|
+
# the `faraday-retry` middleware.
|
|
27
|
+
#
|
|
28
|
+
# Ruby's built-in retry, and `faraday-retry` by default, treat every PUT as
|
|
29
|
+
# idempotent, while Fulfil uses PUT both to read (`search_read`,
|
|
30
|
+
# `search_count`) and to write (updating a record, holding a shipment).
|
|
31
|
+
# Retrying a write that timed out could apply it twice, so no HTTP verb is
|
|
32
|
+
# retried wholesale and every request is matched against the configured
|
|
33
|
+
# endpoints instead.
|
|
34
|
+
#
|
|
35
|
+
# @param options [Hash] The retry options of the configuration.
|
|
36
|
+
# @return [Hash]
|
|
37
|
+
def retry_middleware_options(options)
|
|
38
|
+
requests = options.fetch(:requests, {})
|
|
39
|
+
|
|
40
|
+
{
|
|
41
|
+
max: options.fetch(:max_retries, 0),
|
|
42
|
+
methods: [],
|
|
43
|
+
exceptions: RETRYABLE_ERRORS,
|
|
44
|
+
retry_if: ->(env, _exception) { retryable_request?(env, requests) }
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @param env [Faraday::Env] The environment of the request.
|
|
49
|
+
# @param requests [Hash] The HTTP verbs mapped onto their retryable endpoints.
|
|
50
|
+
# @return [true, false] Whether the endpoint of the request is safe to retry.
|
|
51
|
+
def retryable_request?(env, requests)
|
|
52
|
+
patterns = requests[env.method]
|
|
53
|
+
return true if patterns == true
|
|
54
|
+
|
|
55
|
+
Array(patterns).any? { |pattern| pattern.match?(env.url.path) }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "faraday"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module FulfilApi
|
|
9
|
+
module OAuth
|
|
10
|
+
# Drives Fulfil's OAuth 2.0 authorization flow for a single merchant
|
|
11
|
+
# workspace: it builds the URL the user is sent to, and exchanges the
|
|
12
|
+
# authorization code they come back with for an access token.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# authorization = FulfilApi::OAuth::Authorization.new(merchant_id: "acme")
|
|
16
|
+
# state = FulfilApi::OAuth::Authorization.generate_state
|
|
17
|
+
#
|
|
18
|
+
# redirect_to authorization.url(state: state)
|
|
19
|
+
# # ... the user installs the app and returns with a `code` ...
|
|
20
|
+
# token = authorization.exchange(params[:code])
|
|
21
|
+
#
|
|
22
|
+
# @see https://docs.fulfil.io/developers/rest-api/authentication/oauth
|
|
23
|
+
class Authorization
|
|
24
|
+
AUTHORIZE_PATH = "/oauth/authorize"
|
|
25
|
+
MERCHANT_ID_FORMAT = /\A[a-z0-9][a-z0-9-]*\z/
|
|
26
|
+
TOKEN_PATH = "/oauth/token"
|
|
27
|
+
|
|
28
|
+
attr_reader :access_type, :client_id, :merchant_id, :redirect_uri, :scopes
|
|
29
|
+
|
|
30
|
+
# Fulfil requires a nonce that is unique per authorization request, so the
|
|
31
|
+
# callback can be verified to belong to the request that started it.
|
|
32
|
+
#
|
|
33
|
+
# @return [String]
|
|
34
|
+
def self.generate_state
|
|
35
|
+
SecureRandom.hex(24)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Strips the parts of a Fulfil instance URL that surround the workspace ID.
|
|
39
|
+
# Unlike {.normalize_merchant_id} it accepts whatever it is given, which
|
|
40
|
+
# is what a database normalization needs to do.
|
|
41
|
+
#
|
|
42
|
+
# @param merchant_id [String, nil] The raw workspace ID.
|
|
43
|
+
# @return [String]
|
|
44
|
+
def self.normalize(merchant_id)
|
|
45
|
+
merchant_id.to_s.strip.downcase
|
|
46
|
+
.delete_prefix("https://").delete_prefix("http://")
|
|
47
|
+
.delete_suffix("/")
|
|
48
|
+
.delete_suffix(".fulfil.io")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Normalizes a merchant's workspace ID into the bare subdomain of their
|
|
52
|
+
# Fulfil instance, so `acme`, `acme.fulfil.io`, and
|
|
53
|
+
# `https://acme.fulfil.io` are all accepted.
|
|
54
|
+
#
|
|
55
|
+
# The workspace ID ends up in the host of the URL the user is redirected
|
|
56
|
+
# to, so anything that is not a bare subdomain is rejected rather than
|
|
57
|
+
# coerced into one.
|
|
58
|
+
#
|
|
59
|
+
# @param merchant_id [String, nil] The raw workspace ID.
|
|
60
|
+
# @raise [FulfilApi::OAuth::MerchantIdInvalid] When the workspace ID is
|
|
61
|
+
# missing or is not a valid subdomain.
|
|
62
|
+
# @return [String]
|
|
63
|
+
def self.normalize_merchant_id(merchant_id)
|
|
64
|
+
normalized = normalize(merchant_id)
|
|
65
|
+
|
|
66
|
+
return normalized if MERCHANT_ID_FORMAT.match?(normalized)
|
|
67
|
+
|
|
68
|
+
raise MerchantIdInvalid, "#{merchant_id.inspect} is not a valid Fulfil workspace ID."
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @param merchant_id [String, nil] The workspace ID of the merchant to
|
|
72
|
+
# install the app on. Defaults to the configured merchant.
|
|
73
|
+
# @param redirect_uri [String, nil] The URL Fulfil redirects back to.
|
|
74
|
+
# Defaults to the configured redirect URI. It has to be whitelisted on
|
|
75
|
+
# the app in Fulfil's authentication dashboard.
|
|
76
|
+
# @param configuration [FulfilApi::Configuration] The configuration to
|
|
77
|
+
# read the OAuth app's credentials from.
|
|
78
|
+
def initialize(merchant_id: nil, redirect_uri: nil, configuration: FulfilApi.configuration)
|
|
79
|
+
@configuration = configuration
|
|
80
|
+
@oauth = configuration.oauth
|
|
81
|
+
|
|
82
|
+
raise ConfigurationError, "Please configure the OAuth app's client ID and secret" unless @oauth.configured?
|
|
83
|
+
|
|
84
|
+
@access_type = @oauth.access_type
|
|
85
|
+
@client_id = @oauth.client_id
|
|
86
|
+
@merchant_id = self.class.normalize_merchant_id(merchant_id.presence || configuration.merchant_id)
|
|
87
|
+
@redirect_uri = redirect_uri.presence || @oauth.redirect_uri
|
|
88
|
+
@scopes = @oauth.scopes
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Exchanges the authorization code from the callback for an access token.
|
|
92
|
+
#
|
|
93
|
+
# @param code [String] The authorization code Fulfil sent to the callback.
|
|
94
|
+
# @raise [FulfilApi::OAuth::TokenExchangeFailed] When Fulfil rejected the code.
|
|
95
|
+
# @return [FulfilApi::OAuth::Token]
|
|
96
|
+
def exchange(code)
|
|
97
|
+
raise TokenExchangeFailed, "No authorization code was provided" if code.blank?
|
|
98
|
+
|
|
99
|
+
response = connection.post(TOKEN_PATH, { code: code }) do |request|
|
|
100
|
+
request.params["grant_type"] = "authorization_code"
|
|
101
|
+
request.headers["Authorization"] = "Basic #{basic_credentials}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
Token.new(response.body)
|
|
105
|
+
rescue Faraday::Error => e
|
|
106
|
+
raise TokenExchangeFailed.new(e.message, details: response_details(e))
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The URL of Fulfil's consent screen. Sending the user here is the first
|
|
110
|
+
# step of the flow.
|
|
111
|
+
#
|
|
112
|
+
# @param state [String] The nonce to verify the callback with.
|
|
113
|
+
# @return [String]
|
|
114
|
+
def url(state:)
|
|
115
|
+
uri = URI.parse("#{api_endpoint}#{AUTHORIZE_PATH}")
|
|
116
|
+
uri.query = URI.encode_www_form(url_parameters(state))
|
|
117
|
+
uri.to_s
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
attr_reader :configuration
|
|
123
|
+
|
|
124
|
+
# @return [String]
|
|
125
|
+
def api_endpoint
|
|
126
|
+
"https://#{merchant_id}.fulfil.io"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# @return [String] The client's credentials as HTTP basic auth credentials.
|
|
130
|
+
def basic_credentials
|
|
131
|
+
Base64.strict_encode64("#{@oauth.client_id}:#{@oauth.client_secret}")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# The token endpoint lives outside of the versioned API namespace and
|
|
135
|
+
# expects a form encoded body, so it cannot reuse {FulfilApi::Client}.
|
|
136
|
+
#
|
|
137
|
+
# @return [Faraday::Connection]
|
|
138
|
+
def connection
|
|
139
|
+
Faraday.new(url: api_endpoint, request: configuration.request_options) do |connection|
|
|
140
|
+
connection.request :url_encoded
|
|
141
|
+
|
|
142
|
+
connection.response :json
|
|
143
|
+
connection.response :raise_error
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# @param exception [Faraday::Error] The error raised while exchanging the code.
|
|
148
|
+
# @return [Hash]
|
|
149
|
+
def response_details(exception)
|
|
150
|
+
{
|
|
151
|
+
response_body: exception.response_body,
|
|
152
|
+
response_headers: exception.response_headers,
|
|
153
|
+
response_status: exception.response_status
|
|
154
|
+
}
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# @param state [String] The nonce to verify the callback with.
|
|
158
|
+
# @return [Hash]
|
|
159
|
+
def url_parameters(state)
|
|
160
|
+
{
|
|
161
|
+
access_type: access_type,
|
|
162
|
+
client_id: client_id,
|
|
163
|
+
redirect_uri: redirect_uri,
|
|
164
|
+
response_type: "code",
|
|
165
|
+
state: state
|
|
166
|
+
}.tap do |parameters|
|
|
167
|
+
parameters[:scope] = scopes.join(",") if scopes.any?
|
|
168
|
+
end.compact
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FulfilApi
|
|
4
|
+
module OAuth
|
|
5
|
+
# Holds the credentials and options of the OAuth app that was registered in
|
|
6
|
+
# Fulfil's authentication dashboard, plus the Rails-specific settings used
|
|
7
|
+
# by {FulfilApi::Engine}.
|
|
8
|
+
#
|
|
9
|
+
# @see https://auth.fulfil.io/user/clients
|
|
10
|
+
class Configuration
|
|
11
|
+
ACCESS_TYPES = %i[offline_access user_session].freeze
|
|
12
|
+
|
|
13
|
+
# Fulfil's own default is `user_session`, but the gem defaults to
|
|
14
|
+
# `offline_access`. An app that talks to Fulfil from background jobs
|
|
15
|
+
# needs a token that outlives the user's web session, and personal
|
|
16
|
+
# access tokens are no longer a viable alternative now that they expire.
|
|
17
|
+
DEFAULT_ACCESS_TYPE = :offline_access
|
|
18
|
+
DEFAULT_AFTER_INSTALL_PATH = "/"
|
|
19
|
+
DEFAULT_MOUNT_PATH = "/fulfil"
|
|
20
|
+
DEFAULT_SCOPES = [].freeze
|
|
21
|
+
|
|
22
|
+
# What the engine's controllers inherit from until {#parent_controller} is
|
|
23
|
+
# set. It carries no authentication, which is why the flow refuses to run
|
|
24
|
+
# before an application has named a controller of its own.
|
|
25
|
+
UNCONFIGURED_PARENT_CONTROLLER = "ActionController::Base"
|
|
26
|
+
|
|
27
|
+
attr_accessor :after_install_path, :client_id, :client_secret, :parent_controller, :redirect_uri
|
|
28
|
+
attr_reader :access_type, :scopes
|
|
29
|
+
|
|
30
|
+
# @param options [Hash, nil] An optional list of configuration options.
|
|
31
|
+
def initialize(options = {})
|
|
32
|
+
(options || {}).each_pair do |key, value|
|
|
33
|
+
public_send(:"#{key}=", value) if respond_to?(:"#{key}=")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
set_default_options
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @param value [Symbol, String] Either `:offline_access` or `:user_session`.
|
|
40
|
+
# @raise [ArgumentError] When the access type is not supported by Fulfil.
|
|
41
|
+
# @return [void]
|
|
42
|
+
def access_type=(value)
|
|
43
|
+
@access_type = value.to_sym
|
|
44
|
+
|
|
45
|
+
return if ACCESS_TYPES.include?(@access_type)
|
|
46
|
+
|
|
47
|
+
raise ArgumentError, "#{value} is not a valid access type. Use :offline_access or :user_session instead."
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @return [true, false] Whether an access token can be requested at all.
|
|
51
|
+
def configured?
|
|
52
|
+
client_id.present? && client_secret.present?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [true, false] Whether a permanent, user-independent token is requested.
|
|
56
|
+
def offline_access?
|
|
57
|
+
access_type == :offline_access
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The class the engine's controllers inherit from.
|
|
61
|
+
#
|
|
62
|
+
# @return [Class]
|
|
63
|
+
def parent_controller_class
|
|
64
|
+
(parent_controller.presence || UNCONFIGURED_PARENT_CONTROLLER).constantize
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Whether the application named the controller the OAuth flow runs under.
|
|
68
|
+
#
|
|
69
|
+
# There is no sensible default: the flow has to run behind whatever the
|
|
70
|
+
# application uses to authenticate a request, and guessing wrong leaves
|
|
71
|
+
# an endpoint that lets a stranger install their own Fulfil workspace
|
|
72
|
+
# over the application's.
|
|
73
|
+
#
|
|
74
|
+
# @return [true, false]
|
|
75
|
+
def parent_controller_configured?
|
|
76
|
+
parent_controller.present?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @param value [Array<String>, String, nil] A list of scopes, or a string
|
|
80
|
+
# of scopes separated by commas or spaces.
|
|
81
|
+
# @return [void]
|
|
82
|
+
def scopes=(value)
|
|
83
|
+
@scopes = Array(value).flat_map { |scope| scope.to_s.split(Token::SCOPE_SEPARATOR) }.reject(&:empty?).uniq
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
# @return [void]
|
|
89
|
+
def set_default_options
|
|
90
|
+
self.access_type = DEFAULT_ACCESS_TYPE if access_type.nil?
|
|
91
|
+
self.after_install_path = DEFAULT_AFTER_INSTALL_PATH if after_install_path.nil?
|
|
92
|
+
self.scopes = DEFAULT_SCOPES if scopes.nil?
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FulfilApi
|
|
4
|
+
module OAuth
|
|
5
|
+
# Wraps the payload returned by Fulfil's `POST /oauth/token` endpoint.
|
|
6
|
+
#
|
|
7
|
+
# @example An `offline_access` payload
|
|
8
|
+
# {
|
|
9
|
+
# "access_token" => "user-xxxxx-xxxxxxx",
|
|
10
|
+
# "associated_user" => { "email" => "first.last@example.com", "id" => 1, "name" => "First Last" },
|
|
11
|
+
# "expires_in" => 3600,
|
|
12
|
+
# "offline_access_token" => "bot-xxxx",
|
|
13
|
+
# "scope" => ["user_session,party.party"],
|
|
14
|
+
# "token_type" => "Bearer"
|
|
15
|
+
# }
|
|
16
|
+
class Token
|
|
17
|
+
DEFAULT_TOKEN_TYPE = "Bearer"
|
|
18
|
+
SCOPE_SEPARATOR = /[\s,]+/
|
|
19
|
+
|
|
20
|
+
attr_reader :access_token, :associated_user, :expires_in, :offline_access_token, :scopes, :token_type
|
|
21
|
+
|
|
22
|
+
# @param payload [Hash] The parsed response body of the token endpoint.
|
|
23
|
+
def initialize(payload)
|
|
24
|
+
payload = (payload || {}).with_indifferent_access
|
|
25
|
+
|
|
26
|
+
@access_token = payload[:access_token].presence
|
|
27
|
+
@associated_user = (payload[:associated_user] || {}).with_indifferent_access
|
|
28
|
+
@expires_in = payload[:expires_in]&.to_i
|
|
29
|
+
@offline_access_token = payload[:offline_access_token].presence
|
|
30
|
+
@scopes = extract_scopes(payload[:scope])
|
|
31
|
+
@token_type = payload[:token_type].presence || DEFAULT_TOKEN_TYPE
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The moment the short-lived {#access_token} stops working. The
|
|
35
|
+
# {#offline_access_token} is not covered by this: it stays valid until
|
|
36
|
+
# the app is uninstalled from the workspace.
|
|
37
|
+
#
|
|
38
|
+
# @return [Time, nil]
|
|
39
|
+
def expires_at
|
|
40
|
+
return if expires_in.nil?
|
|
41
|
+
|
|
42
|
+
Time.now.utc + expires_in
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @return [true, false] Whether a permanent token was granted.
|
|
46
|
+
def offline?
|
|
47
|
+
offline_access_token.present?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Prefers the permanent token over the short-lived one, so a token used
|
|
51
|
+
# outside of the request it was granted in keeps working.
|
|
52
|
+
#
|
|
53
|
+
# @return [FulfilApi::AccessToken]
|
|
54
|
+
def to_access_token
|
|
55
|
+
FulfilApi::AccessToken.new(value, type: :oauth)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @return [String, nil] The token to authenticate API requests with.
|
|
59
|
+
def value
|
|
60
|
+
offline_access_token || access_token
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# Fulfil returns the granted scopes as an array holding a single string
|
|
66
|
+
# rather than as a list of scopes. Its docs show that string comma
|
|
67
|
+
# separated (`["user_session,party.party"]`), but a real workspace
|
|
68
|
+
# separates them with spaces (`["sale.order stock.move.view"]`), so both
|
|
69
|
+
# are flattened into a plain list.
|
|
70
|
+
#
|
|
71
|
+
# @param scope [Array<String>, String, nil] The granted scopes.
|
|
72
|
+
# @return [Array<String>]
|
|
73
|
+
def extract_scopes(scope)
|
|
74
|
+
Array(scope).flat_map { |value| value.to_s.split(SCOPE_SEPARATOR) }.reject(&:empty?).uniq
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FulfilApi
|
|
4
|
+
# The {FulfilApi::OAuth} namespace contains everything needed to obtain an
|
|
5
|
+
# access token through Fulfil's OAuth 2.0 authorization flow.
|
|
6
|
+
#
|
|
7
|
+
# Fulfil's flow has two access modes. The `user_session` mode returns a
|
|
8
|
+
# short-lived token tied to the user's web session, while the
|
|
9
|
+
# `offline_access` mode additionally returns an `offline_access_token` that
|
|
10
|
+
# remains valid until the app is uninstalled. There is no refresh token in
|
|
11
|
+
# either mode: an expired `user_session` token can only be replaced by
|
|
12
|
+
# sending the user through the flow again.
|
|
13
|
+
#
|
|
14
|
+
# @see https://docs.fulfil.io/developers/rest-api/authentication/oauth
|
|
15
|
+
module OAuth
|
|
16
|
+
class Error < FulfilApi::Error; end
|
|
17
|
+
|
|
18
|
+
# Raised when the flow is started without a client ID or secret.
|
|
19
|
+
class ConfigurationError < Error; end
|
|
20
|
+
|
|
21
|
+
# Raised when the merchant's workspace ID is missing or malformed. The
|
|
22
|
+
# workspace ID ends up in the host of the URL the user is redirected to,
|
|
23
|
+
# so an unchecked value would allow redirecting to an arbitrary host.
|
|
24
|
+
class MerchantIdInvalid < Error; end
|
|
25
|
+
|
|
26
|
+
# Raised when the user cancelled the installation, or when Fulfil reported
|
|
27
|
+
# an error on the callback.
|
|
28
|
+
class AuthorizationDenied < Error; end
|
|
29
|
+
|
|
30
|
+
# Raised when the `state` on the callback does not match the one generated
|
|
31
|
+
# when the flow was started, which means the callback cannot be trusted.
|
|
32
|
+
class StateMismatch < Error; end
|
|
33
|
+
|
|
34
|
+
# Raised when exchanging the authorization code for an access token failed.
|
|
35
|
+
class TokenExchangeFailed < Error; end
|
|
36
|
+
end
|
|
37
|
+
end
|