fulfil_api 0.7.2 → 0.8.1
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 +20 -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 +181 -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
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FulfilApi
|
|
4
|
+
# An {FulfilApi::Installation} records that the OAuth app was installed on a
|
|
5
|
+
# merchant's Fulfil workspace, and holds the access token that came out of
|
|
6
|
+
# it.
|
|
7
|
+
#
|
|
8
|
+
# An installation without an owner is the application-wide installation, which
|
|
9
|
+
# is what a Rails application built for a single merchant needs. Assigning an
|
|
10
|
+
# owner scopes the installation to a record instead, which is what an
|
|
11
|
+
# application serving many merchants needs — see {FulfilApi::Installable}.
|
|
12
|
+
#
|
|
13
|
+
# An owner has at most one installation, so a record is always tied to a single
|
|
14
|
+
# Fulfil workspace. The same holds for the application-wide installation.
|
|
15
|
+
class Installation < ActiveRecord::Base
|
|
16
|
+
belongs_to :owner, polymorphic: true, optional: true
|
|
17
|
+
|
|
18
|
+
encrypts :access_token
|
|
19
|
+
encrypts :offline_access_token
|
|
20
|
+
|
|
21
|
+
normalizes :merchant_id, with: ->(merchant_id) { FulfilApi::OAuth::Authorization.normalize(merchant_id) }
|
|
22
|
+
|
|
23
|
+
validates :merchant_id, presence: true
|
|
24
|
+
validates :owner_id, uniqueness: { message: "is already connected to a Fulfil workspace", scope: :owner_type }
|
|
25
|
+
|
|
26
|
+
# Which of the two tokens is granted depends on the access mode, so neither
|
|
27
|
+
# column can be required on its own. An installation carrying neither is
|
|
28
|
+
# not an installation: it authenticates nothing, and {#usable?} would send
|
|
29
|
+
# the user back through the flow forever.
|
|
30
|
+
validates :access_token, presence: { message: "or an offline access token has to be present" }, unless: :offline?
|
|
31
|
+
|
|
32
|
+
scope :global, -> { where(owner: nil) }
|
|
33
|
+
|
|
34
|
+
serialize :scopes, coder: JSON, type: Array
|
|
35
|
+
|
|
36
|
+
# Records the outcome of a completed authorization flow.
|
|
37
|
+
#
|
|
38
|
+
# Installing replaces whatever the owner was connected to before, rather than
|
|
39
|
+
# leaving it connected to two Fulfil workspaces at once.
|
|
40
|
+
#
|
|
41
|
+
# @param token [FulfilApi::OAuth::Token] The granted access token.
|
|
42
|
+
# @param merchant_id [String] The workspace the app was installed on.
|
|
43
|
+
# @param owner [ActiveRecord::Base, nil] The record the installation belongs
|
|
44
|
+
# to, or `nil` for the application-wide installation.
|
|
45
|
+
# @return [FulfilApi::Installation]
|
|
46
|
+
def self.install(token, merchant_id:, owner: nil)
|
|
47
|
+
installation = find_or_initialize_by(owner: owner)
|
|
48
|
+
installation.merchant_id = merchant_id
|
|
49
|
+
|
|
50
|
+
# A token granted by one workspace is worthless on another, so moving the
|
|
51
|
+
# owner to a different workspace drops what the previous one granted.
|
|
52
|
+
installation.offline_access_token = nil if installation.merchant_id_changed?
|
|
53
|
+
|
|
54
|
+
installation.assign_token(token)
|
|
55
|
+
installation.save!
|
|
56
|
+
installation
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Looks up the installation to authenticate a Fulfil API request with.
|
|
60
|
+
#
|
|
61
|
+
# @param owner [ActiveRecord::Base, nil] The record the installation belongs to.
|
|
62
|
+
# @param merchant_id [String, nil] The workspace to find the installation for.
|
|
63
|
+
# @return [FulfilApi::Installation, nil]
|
|
64
|
+
def self.installed_on(merchant_id: FulfilApi.configuration.merchant_id, owner: nil)
|
|
65
|
+
return if merchant_id.blank?
|
|
66
|
+
|
|
67
|
+
find_by(owner: owner, merchant_id: merchant_id)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Copies the granted token onto the installation.
|
|
71
|
+
#
|
|
72
|
+
# Re-authorizing in `user_session` mode does not return an
|
|
73
|
+
# `offline_access_token`, so an already granted permanent token is kept
|
|
74
|
+
# rather than wiped.
|
|
75
|
+
#
|
|
76
|
+
# @param token [FulfilApi::OAuth::Token] The granted access token.
|
|
77
|
+
# @return [void]
|
|
78
|
+
def assign_token(token)
|
|
79
|
+
assign_attributes(
|
|
80
|
+
access_token: token.access_token,
|
|
81
|
+
expires_at: token.expires_at,
|
|
82
|
+
scopes: token.scopes,
|
|
83
|
+
token_type: token.token_type
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
assign_associated_user(token.associated_user)
|
|
87
|
+
self.offline_access_token = token.offline_access_token if token.offline?
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Whether the short-lived token has passed its expiry. It says nothing about
|
|
91
|
+
# the permanent token, which is only revoked by uninstalling the app.
|
|
92
|
+
#
|
|
93
|
+
# @return [true, false]
|
|
94
|
+
def expired?
|
|
95
|
+
expires_at.present? && expires_at <= Time.current
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# @return [true, false] Whether a permanent token was granted.
|
|
99
|
+
def offline?
|
|
100
|
+
offline_access_token.present?
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# @return [FulfilApi::AccessToken]
|
|
104
|
+
def to_access_token
|
|
105
|
+
FulfilApi::AccessToken.new(offline_access_token.presence || access_token, type: :oauth)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Whether the installation can still authenticate requests, or whether the
|
|
109
|
+
# user has to walk through the authorization flow again.
|
|
110
|
+
#
|
|
111
|
+
# @return [true, false]
|
|
112
|
+
def usable?
|
|
113
|
+
return true if offline?
|
|
114
|
+
|
|
115
|
+
access_token.present? && !expired?
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Runs a block against the Fulfil API as this installation.
|
|
119
|
+
#
|
|
120
|
+
# @example
|
|
121
|
+
# installation.with_config do |client|
|
|
122
|
+
# client.get("model/sale.sale")
|
|
123
|
+
# end
|
|
124
|
+
#
|
|
125
|
+
# @yieldparam [FulfilApi::Client] client A client authenticated as this installation.
|
|
126
|
+
# @return [void]
|
|
127
|
+
def with_config
|
|
128
|
+
FulfilApi.with_config(access_token: to_access_token, merchant_id: merchant_id) do
|
|
129
|
+
yield(FulfilApi.client)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
# @param user [Hash] The user who walked through the authorization flow.
|
|
136
|
+
# @return [void]
|
|
137
|
+
def assign_associated_user(user)
|
|
138
|
+
assign_attributes(
|
|
139
|
+
associated_user_email: user[:email],
|
|
140
|
+
associated_user_id: user[:id],
|
|
141
|
+
associated_user_name: user[:name]
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
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,181 @@
|
|
|
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, token_parameters(code)) do |request|
|
|
100
|
+
request.headers["Authorization"] = "Basic #{basic_credentials}"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
Token.new(response.body)
|
|
104
|
+
rescue Faraday::Error => e
|
|
105
|
+
raise TokenExchangeFailed.new(e.message, details: response_details(e))
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# The URL of Fulfil's consent screen. Sending the user here is the first
|
|
109
|
+
# step of the flow.
|
|
110
|
+
#
|
|
111
|
+
# @param state [String] The nonce to verify the callback with.
|
|
112
|
+
# @return [String]
|
|
113
|
+
def url(state:)
|
|
114
|
+
uri = URI.parse("#{api_endpoint}#{AUTHORIZE_PATH}")
|
|
115
|
+
uri.query = URI.encode_www_form(url_parameters(state))
|
|
116
|
+
uri.to_s
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
attr_reader :configuration
|
|
122
|
+
|
|
123
|
+
# @return [String]
|
|
124
|
+
def api_endpoint
|
|
125
|
+
"https://#{merchant_id}.fulfil.io"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# @return [String] The client's credentials as HTTP basic auth credentials.
|
|
129
|
+
def basic_credentials
|
|
130
|
+
Base64.strict_encode64("#{@oauth.client_id}:#{@oauth.client_secret}")
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# The token endpoint lives outside of the versioned API namespace and
|
|
134
|
+
# expects a form encoded body, so it cannot reuse {FulfilApi::Client}.
|
|
135
|
+
#
|
|
136
|
+
# @return [Faraday::Connection]
|
|
137
|
+
def connection
|
|
138
|
+
Faraday.new(url: api_endpoint, request: configuration.request_options) do |connection|
|
|
139
|
+
connection.request :url_encoded
|
|
140
|
+
|
|
141
|
+
connection.response :json
|
|
142
|
+
connection.response :raise_error
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# @param exception [Faraday::Error] The error raised while exchanging the code.
|
|
147
|
+
# @return [Hash]
|
|
148
|
+
def response_details(exception)
|
|
149
|
+
{
|
|
150
|
+
response_body: exception.response_body,
|
|
151
|
+
response_headers: exception.response_headers,
|
|
152
|
+
response_status: exception.response_status
|
|
153
|
+
}
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# @param code [String] The authorization code Fulfil sent to the callback.
|
|
157
|
+
# @return [Hash]
|
|
158
|
+
def token_parameters(code)
|
|
159
|
+
{
|
|
160
|
+
code: code,
|
|
161
|
+
grant_type: "authorization_code",
|
|
162
|
+
redirect_uri: redirect_uri
|
|
163
|
+
}.compact
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# @param state [String] The nonce to verify the callback with.
|
|
167
|
+
# @return [Hash]
|
|
168
|
+
def url_parameters(state)
|
|
169
|
+
{
|
|
170
|
+
access_type: access_type,
|
|
171
|
+
client_id: client_id,
|
|
172
|
+
redirect_uri: redirect_uri,
|
|
173
|
+
response_type: "code",
|
|
174
|
+
state: state
|
|
175
|
+
}.tap do |parameters|
|
|
176
|
+
parameters[:scope] = scopes.join(" ") if scopes.any?
|
|
177
|
+
end.compact
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|