fulfil_api 0.7.1 → 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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +9 -1
  3. data/.ruby-version +1 -1
  4. data/CHANGELOG.md +15 -0
  5. data/README.md +193 -2
  6. data/app/controllers/concerns/fulfil_api/authenticated.rb +43 -0
  7. data/app/controllers/concerns/fulfil_api/installation_scoped.rb +56 -0
  8. data/app/controllers/fulfil_api/application_controller.rb +31 -0
  9. data/app/controllers/fulfil_api/installations_controller.rb +111 -0
  10. data/app/models/concerns/fulfil_api/installable.rb +47 -0
  11. data/app/models/fulfil_api/installation.rb +145 -0
  12. data/config/routes.rb +9 -0
  13. data/lib/fulfil_api/circuit_open.rb +9 -0
  14. data/lib/fulfil_api/client.rb +10 -6
  15. data/lib/fulfil_api/configuration.rb +72 -8
  16. data/lib/fulfil_api/engine.rb +14 -0
  17. data/lib/fulfil_api/error/notifiable.rb +90 -0
  18. data/lib/fulfil_api/error.rb +38 -0
  19. data/lib/fulfil_api/http_error.rb +15 -0
  20. data/lib/fulfil_api/middleware/circuit_breaker.rb +44 -0
  21. data/lib/fulfil_api/middleware.rb +59 -0
  22. data/lib/fulfil_api/oauth/authorization.rb +172 -0
  23. data/lib/fulfil_api/oauth/configuration.rb +96 -0
  24. data/lib/fulfil_api/oauth/token.rb +78 -0
  25. data/lib/fulfil_api/oauth.rb +37 -0
  26. data/lib/fulfil_api/relation.rb +2 -2
  27. data/lib/fulfil_api/resource/attribute_assignable.rb +12 -12
  28. data/lib/fulfil_api/tpl_client.rb +10 -6
  29. data/lib/fulfil_api/version.rb +1 -1
  30. data/lib/fulfil_api.rb +12 -0
  31. data/lib/generators/fulfil_api/install/install_generator.rb +72 -0
  32. data/lib/generators/fulfil_api/install/templates/create_fulfil_api_installations.rb +43 -0
  33. data/lib/generators/fulfil_api/install/templates/fulfil_api.rb +38 -0
  34. metadata +49 -11
@@ -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
@@ -48,8 +48,8 @@ module FulfilApi
48
48
  #
49
49
  # @yield [resource] Yields each resource object to the given block.
50
50
  # @return [Enumerator, self] Returns an Enumerator if no block is given; otherwise, returns self.
51
- def each(&block)
52
- all.each(&block)
51
+ def each(&)
52
+ all.each(&)
53
53
  end
54
54
 
55
55
  # Resets any of the previously provided query conditions.
@@ -76,23 +76,23 @@ module FulfilApi
76
76
  # $ build_attribute("warehouse.id", 10)
77
77
  # => { "warehouse" => { "id" => 10 } }
78
78
  #
79
- # @param attribute_names [String, Symbol] The expanded list of attribute names
79
+ # @example attribute where the relation and its field share a name
80
+ # $ build_attribute("tracking_number.tracking_number", "T04DDA5014242536")
81
+ # => { "tracking_number" => { "tracking_number" => "T04DDA5014242536" } }
82
+ #
83
+ # @param name [String, Symbol] The attribute name
80
84
  # @param value [Any] The attribute value
81
85
  # @return [Hash] The newly build attribute
82
- def build_attribute(name, value) # rubocop:disable Metrics/MethodLength
83
- attribute_names = name.to_s.split(".")
84
- attribute = {}
85
- attribute_level = attribute
86
+ def build_attribute(name, value)
87
+ *relation_names, attribute_name = name.to_s.split(".")
88
+ return {} if attribute_name.nil?
86
89
 
87
- attribute_names.each do |attribute_name|
88
- if attribute_name == attribute_names.last
89
- attribute_level[attribute_name] = type_cast_attribute_value(value)
90
- else
91
- attribute_level[attribute_name] ||= {}
92
- attribute_level = attribute_level[attribute_name]
93
- end
90
+ attribute = {}
91
+ attribute_level = relation_names.reduce(attribute) do |level, relation_name|
92
+ level[relation_name] ||= {}
94
93
  end
95
94
 
95
+ attribute_level[attribute_name] = type_cast_attribute_value(value)
96
96
  attribute
97
97
  end
98
98
 
@@ -128,6 +128,9 @@ module FulfilApi
128
128
  configure_persistent_connection(http)
129
129
  end
130
130
 
131
+ # Retries and other safeguards wrap every request, so they're added first
132
+ FulfilApi::Middleware.apply(connection, configuration)
133
+
131
134
  # Configuration of the request middleware
132
135
  connection.request :json
133
136
 
@@ -151,11 +154,9 @@ module FulfilApi
151
154
  # Tunes the underlying Net::HTTP::Persistent connection.
152
155
  #
153
156
  # The `net_http_persistent` adapter forces `max_retries` to 0 on every
154
- # request, which disables Ruby's built-in retry for idempotent requests.
155
- # Restoring it lets a stale keep-alive socket — one the server has already
156
- # closed — be retried transparently on a fresh socket instead of surfacing
157
- # as a read timeout. The config block runs after the adapter zeroes the
158
- # value, so this takes effect.
157
+ # request. Retries are handled by {FulfilApi::Middleware::Retry} instead, so
158
+ # `max_retries` is only restored when it's configured explicitly. The config
159
+ # block runs after the adapter zeroes the value, so this takes effect.
159
160
  #
160
161
  # @param http [Net::HTTP::Persistent] The live persistent connection.
161
162
  # @return [void]
@@ -170,7 +171,10 @@ module FulfilApi
170
171
 
171
172
  # @return [Array] The cache key identifying a unique connection.
172
173
  def connection_cache_key
173
- [merchant_id, api_version, configuration.request_options, configuration.connection_options]
174
+ [
175
+ merchant_id, api_version, configuration.request_options, configuration.connection_options,
176
+ configuration.retry_options, configuration.circuit_breaker
177
+ ]
174
178
  end
175
179
 
176
180
  # @param relative_path [String] The relative path to the API endpoint.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FulfilApi
4
- VERSION = "0.7.1"
4
+ VERSION = "0.8.0"
5
5
  end
data/lib/fulfil_api.rb CHANGED
@@ -3,17 +3,29 @@
3
3
  require "zeitwerk"
4
4
 
5
5
  loader = Zeitwerk::Loader.for_gem
6
+ loader.inflector.inflect("oauth" => "OAuth")
7
+ loader.ignore("#{__dir__}/fulfil_api/engine.rb")
6
8
  loader.ignore("#{__dir__}/fulfil_api/test_helper.rb")
9
+ loader.ignore("#{__dir__}/generators")
7
10
  loader.setup
8
11
 
9
12
  require "active_support"
13
+ require "active_support/cache"
10
14
  require "active_support/core_ext/enumerable"
11
15
  require "active_support/core_ext/hash/deep_merge"
12
16
  require "active_support/core_ext/hash/indifferent_access"
13
17
  require "active_support/core_ext/module/delegation"
14
18
  require "active_support/core_ext/object/blank"
19
+ require "active_support/notifications"
20
+ require "faraday"
21
+ require "faraday/retry"
15
22
 
16
23
  module FulfilApi
17
24
  end
18
25
 
19
26
  loader.eager_load
27
+
28
+ # The Rails-specific parts of the gem — the engine, its models, its controllers,
29
+ # and its generators — are only loaded when the gem is used from within a
30
+ # Rails application.
31
+ require "fulfil_api/engine" if defined?(Rails::Engine)
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module FulfilApi
7
+ module Generators
8
+ # Sets a Rails application up for Fulfil's OAuth flow: it writes an
9
+ # initializer, adds the migration for {FulfilApi::Installation}, and mounts
10
+ # {FulfilApi::Engine}.
11
+ #
12
+ # @example
13
+ # bin/rails generate fulfil_api:install
14
+ class InstallGenerator < Rails::Generators::Base
15
+ include ActiveRecord::Generators::Migration
16
+
17
+ source_root File.expand_path("templates", __dir__)
18
+
19
+ desc "Installs the Fulfil OAuth initializer, migration, and engine route."
20
+
21
+ # @return [void]
22
+ def copy_initializer
23
+ template "fulfil_api.rb", "config/initializers/fulfil_api.rb"
24
+ end
25
+
26
+ # @return [void]
27
+ def create_migration_file
28
+ migration_template "create_fulfil_api_installations.rb",
29
+ "db/migrate/create_fulfil_api_installations.rb"
30
+ end
31
+
32
+ # @return [void]
33
+ def mount_engine
34
+ route %(mount FulfilApi::Engine => "#{FulfilApi::OAuth::Configuration::DEFAULT_MOUNT_PATH}")
35
+ end
36
+
37
+ # @return [void]
38
+ def show_readme
39
+ say(remaining_steps)
40
+ end
41
+
42
+ private
43
+
44
+ # @return [String] The Active Record migration version to generate against.
45
+ def migration_version
46
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
47
+ end
48
+
49
+ # Nothing changes in the application until these are done, so an existing
50
+ # application can install the gem without switching to OAuth yet.
51
+ #
52
+ # @return [String]
53
+ def remaining_steps
54
+ <<~MESSAGE
55
+
56
+ Fulfil's OAuth flow is installed. To finish setting it up:
57
+
58
+ 1. Run `bin/rails db:migrate` to create the fulfil_api_installations table.
59
+ 2. Run `bin/rails db:encryption:init` and store the keys in your credentials,
60
+ unless Active Record encryption is already configured. The access tokens
61
+ are encrypted at rest.
62
+ 3. Create an app on https://auth.fulfil.io/user/clients and whitelist
63
+ #{FulfilApi::OAuth::Configuration::DEFAULT_MOUNT_PATH}/callback as a redirection URL.
64
+ 4. Fill in FULFIL_OAUTH_CLIENT_ID and FULFIL_OAUTH_CLIENT_SECRET.
65
+ 5. Set config.oauth.parent_controller to the controller that authenticates
66
+ a request in this application. The OAuth flow refuses to run without it.
67
+
68
+ MESSAGE
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateFulfilApiInstallations < ActiveRecord::Migration<%= migration_version %>
4
+ def change
5
+ create_table :fulfil_api_installations do |t|
6
+ # An installation without an owner is the application-wide installation.
7
+ t.references :owner, polymorphic: true, null: true, index: false
8
+
9
+ t.string :merchant_id, null: false
10
+
11
+ # Fulfil grants the permanent token only in `offline_access` mode, and the
12
+ # short-lived one is the only token in `user_session` mode. Neither is
13
+ # required on its own; FulfilApi::Installation requires one of the two.
14
+ t.text :access_token
15
+ t.text :offline_access_token
16
+
17
+ # The gem fills the token type in rather than relying on Fulfil to send it.
18
+ t.string :token_type, null: false, default: "Bearer"
19
+
20
+ # Nullable because Active Record serialization writes an empty list as
21
+ # NULL and reads it back as one, so the column is never meaningfully
22
+ # blank even though it can be NULL.
23
+ t.text :scopes
24
+
25
+ # Describes the short-lived token only. A permanent token has no expiry:
26
+ # it is revoked by uninstalling the app, not by time.
27
+ t.datetime :expires_at
28
+
29
+ # Who walked through the authorization flow. Recorded for support, nothing
30
+ # in the gem depends on it, and Fulfil is free to leave parts of it out.
31
+ t.integer :associated_user_id
32
+ t.string :associated_user_email
33
+ t.string :associated_user_name
34
+
35
+ t.timestamps
36
+ end
37
+
38
+ # An owner is connected to a single Fulfil workspace. Databases treat NULLs
39
+ # as distinct, so this index does not constrain the application-wide
40
+ # installation; FulfilApi::Installation validates that case.
41
+ add_index :fulfil_api_installations, %i[owner_type owner_id], unique: true
42
+ end
43
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ FulfilApi.configure do |config|
4
+ # The subdomain of the Fulfil workspace this application talks to. Leave it
5
+ # blank when every tenant brings its own workspace, and override
6
+ # `fulfil_merchant_id` in ApplicationController instead.
7
+ config.merchant_id = ENV.fetch("FULFIL_MERCHANT_ID", nil)
8
+
9
+ # The credentials of the app registered on https://auth.fulfil.io/user/clients.
10
+ config.oauth.client_id = ENV.fetch("FULFIL_OAUTH_CLIENT_ID", nil)
11
+ config.oauth.client_secret = ENV.fetch("FULFIL_OAUTH_CLIENT_SECRET", nil)
12
+
13
+ # The scopes to request. See https://developers.fulfil.io for the full list.
14
+ config.oauth.scopes = []
15
+
16
+ # `:offline_access` grants a permanent token that keeps working when no user
17
+ # is around, which is what background jobs need. `:user_session` grants a
18
+ # token that expires with the user's Fulfil session.
19
+ config.oauth.access_type = :offline_access
20
+
21
+ # Where the user ends up after installing the app, unless they were sent to
22
+ # the flow from somewhere else. Accepts a path or a callable taking the
23
+ # FulfilApi::Installation that was created.
24
+ config.oauth.after_install_path = "/"
25
+
26
+ # REQUIRED before the OAuth flow can be used. The engine's controllers inherit
27
+ # from this one, so it has to be a controller that authenticates the request
28
+ # and — in an application serving many merchants — establishes the current
29
+ # tenant. There is deliberately no default: an unauthenticated flow lets a
30
+ # stranger record their own Fulfil workspace over yours, and a flow outside
31
+ # the application's tenancy records installations against the wrong owner.
32
+ #
33
+ # config.oauth.parent_controller = "AuthenticatedController"
34
+
35
+ # Fulfil requires the redirect URL to be whitelisted on the app. It defaults
36
+ # to the engine's callback URL, which is derived from the incoming request.
37
+ # config.oauth.redirect_uri = "https://example.com/fulfil/callback"
38
+ end