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.
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
@@ -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, which disables Ruby's built-in retry for idempotent requests.
140
- # Restoring it lets a stale keep-alive socket — one the server has already
141
- # closed — be retried transparently on a fresh socket instead of surfacing
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
- [configuration.merchant_id, configuration.request_options, configuration.connection_options]
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
- DEFAULT_CONNECTION_OPTIONS = { max_retries: 1 }.freeze
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