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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2971bc621921930210bce77c057d6da8ce549cfe476fb1fc29d72f10c4447303
4
- data.tar.gz: 97658bf767d8909e359ad66441de0f81108982c6383070778eb04d3386c2fb3d
3
+ metadata.gz: 6f433cc5985d728b55fbc2d60bfc4b953c816ec0ca75dc46d33a30999571cef9
4
+ data.tar.gz: f66f3c19a009cd3592cbe73a97c04fea29654c6147c90c8000c2361cb5326309
5
5
  SHA512:
6
- metadata.gz: 020e57b3a676a3c56f2d2b76e5b269010764d3a95f41ef19848f0477e4f77ac9db9871252c68efe77fe35af1fbd6f319d8283f43b578caf7e09e8de4651120a9
7
- data.tar.gz: 51af4a10179668acf8977fca9dccfc133bbb25516c7464cb87bd05f9abf0a5926b1631620cf9265a9637b7b0ffa8c7b9a6618bf9ee7b75912005eb3f1722e1a3
6
+ metadata.gz: 6a4af75edfecaf1c3b9c64efa2be766b044153298da60af42df2c7e9bb3d8f5fdf43b9951ab62ad30868424b2eeaa5915c2add9059fedc48458cb99f3371eed9
7
+ data.tar.gz: 00b77b892735a6f29a569785c0795b4dd64c13e91091bbb840689a8fe689d76730dc4408041c6e3a0bef08b4fe02d77020cd9d25d66bfd40c170644fe277017a
data/.rubocop.yml CHANGED
@@ -6,9 +6,11 @@ plugins:
6
6
  AllCops:
7
7
  Exclude:
8
8
  - bin/*
9
+ - lib/generators/**/templates/**/*
10
+ - tmp/**/*
9
11
  - vendor/**/*
10
12
  NewCops: enable
11
- TargetRubyVersion: 3.0
13
+ TargetRubyVersion: 3.2
12
14
 
13
15
  Layout/LineLength:
14
16
  Exclude:
@@ -33,3 +35,9 @@ Style/StringLiterals:
33
35
 
34
36
  Style/StringLiteralsInInterpolation:
35
37
  EnforcedStyle: double_quotes
38
+
39
+ Style/ArgumentsForwarding:
40
+ # The test helper is a documented, public API. Anonymous forwarding would hide
41
+ # the keyword arguments it accepts from both the reader and its YARD docs.
42
+ Exclude:
43
+ - lib/fulfil_api/test_helper.rb
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- 3.3.4
1
+ 4.0.7
data/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  ## [Unreleased]
2
2
 
3
+ - Require Rails 8.0 and Ruby 3.2 or newer. Every application using the gem is on Rails 8 already, so carrying support for older versions bought nothing.
4
+
5
+ - Add support for Fulfil's OAuth 2.0 authorization flow, so applications no longer depend on personal access tokens now that Fulfil caps their lifetime. `FulfilApi::OAuth::Authorization` builds the consent screen URL and exchanges the authorization code for a token, and `config.oauth` holds the OAuth app's credentials.
6
+ - Ship the flow as a Rails engine that is only loaded inside a Rails application. `bin/rails generate fulfil_api:install` writes the initializer, adds the migration for `FulfilApi::Installation`, and mounts the engine. Installing changes nothing about how an application behaves until `config.oauth.parent_controller` names the controller the flow runs under, so an application still on personal access tokens can move to OAuth in its own time. There is no default: the engine's controllers inherit from it, and guessing wrong leaves an endpoint a stranger can use to record their own Fulfil workspace over the application's.
7
+ - Add the `FulfilApi::Authenticated` controller concern, which runs every action with a client authenticated as the current installation and sends a user without a usable token through the OAuth flow.
8
+ - Add the `FulfilApi::Installable` model concern, so an application serving many merchants can keep one installation per record instead of one for the whole application. A record is connected to a single Fulfil workspace.
9
+
10
+ - Add an optional circuit breaker, backed by [Faulty](https://github.com/ParentSquare/faulty), that stops sending requests to a Fulfil instance that keeps failing, so a struggling instance can't tie up every thread of an application that talks to several of them. Pass a `Faulty` instance as `circuit_breaker`; each Fulfil instance gets its own circuit, and while it's open requests raise a `FulfilApi::CircuitOpen` without touching the network.
11
+
12
+ - Retry only the requests that are safe to retry. Ruby's built-in retry treated every `PUT` as idempotent, but Fulfil writes through `PUT` too (updating a record, holding a shipment), so a write that timed out could be sent twice. Retries now run through `faraday-retry`, configured to only retry `GET` requests and `PUT` requests to `search_read` and `search_count` by default, configurable through the new `retry_options`. The built-in retry is off by default again; `connection_options: { max_retries: ... }` still turns it on.
13
+
14
+ - Expand a nested field into its relation even when the field carries the same name as the relation it hangs off. Selecting both `tracking_number` and `tracking_number.tracking_number` on a shipment used to drop the relation and leave the tracking number as a plain string on the shipment itself, because the gem decided where a field belonged by matching names in the path instead of counting positions.
15
+
16
+ - Publish an `ActiveSupport::Notifications` event named `error.fulfil_api` whenever a `FulfilApi::Error` is raised, so an application can report failures of the Fulfil API to its APM without rescuing every call into the gem. Subscribe with `FulfilApi.on_error { |error| ... }`, or through `ActiveSupport::Notifications` directly to also reach the status code, response body and response headers of a `FulfilApi::HttpError`.
17
+
3
18
  - Raise a `FulfilApi::HttpError` instead of a generic `FulfilApi::Error` when a request to Fulfil fails, with a dedicated subclass per HTTP status code (e.g. `FulfilApi::HttpError::TooManyRequests` for a 429). The exception message is now the description reported by Fulfil rather than its serialized response body, and the status code, body and headers are available through `#status_code`, `#response_body` and `#response_headers`. `FulfilApi::HttpError` inherits from `FulfilApi::Error`, so existing rescues keep working.
4
19
 
5
20
  - Re-enable Ruby's built-in retry for idempotent requests on the persistent connection, which the `net_http_persistent` adapter disables by forcing `max_retries` to `0`. This recovers stale keep-alive sockets transparently instead of surfacing them as read timeouts.
data/README.md CHANGED
@@ -57,7 +57,7 @@ This makes it easy to use different settings in contexts with different constrai
57
57
  ```ruby
58
58
  FulfilApi.with_config(
59
59
  request_options: { open_timeout: 5, read_timeout: 60, write_timeout: 30 },
60
- connection_options: { max_retries: 3, idle_timeout: 10 }
60
+ retry_options: { max_retries: 3 }
61
61
  ) do
62
62
  # Long-running work against the Fulfil API
63
63
  end
@@ -77,13 +77,160 @@ The following configuration options are (currently) available throught both conf
77
77
 
78
78
  > **NOTE:** With the persistent (keep-alive) adapter there is no single whole-request `timeout`; Faraday resolves `read_timeout`, `open_timeout`, and `write_timeout` independently. `read_timeout` is the value that governs a slow or stalled response.
79
79
 
80
+ - `retry_options` (`Hash`): Which requests [`faraday-retry`](https://github.com/lostisland/faraday-retry) retries when they fail before Fulfil answers them (a dropped keep-alive socket, a refused connection or a timeout). An error response from Fulfil is never retried. Supported keys:
81
+ - `max_retries` (default `1`): The maximum number of retries per request.
82
+ - `requests` (default: every `GET`, and `PUT` requests to `search_read` and `search_count`): A hash of HTTP verbs, each mapped to `true` for every endpoint or to a list of patterns matched against the request path. Fulfil reads through `PUT` as well as writes through it, so only the read endpoints are retried by default. Retrying a write that timed out could apply it twice.
83
+
84
+ ```ruby
85
+ FulfilApi.configure do |config|
86
+ config.retry_options = {
87
+ max_retries: 2,
88
+ requests: { get: true, put: [%r{/search_read\z}, %r{/search_count\z}, %r{/model/sale\.sale/\d+\z}] }
89
+ }
90
+ end
91
+ ```
92
+
93
+ - `circuit_breaker` (`Faulty`, optional): A [Faulty](https://github.com/ParentSquare/faulty) instance to stop sending requests to a Fulfil instance that keeps failing. Off by default. See [Stopping requests to a failing Fulfil instance](#stopping-requests-to-a-failing-fulfil-instance).
94
+
80
95
  - `connection_options` (`Hash`): Tuning for the persistent (keep-alive) connection. Supported keys:
81
- - `max_retries` (default `1`): Re-enables Ruby's built-in retry for **idempotent** requests (`GET`/`HEAD`/`PUT`/`DELETE`/`OPTIONS`). The `net_http_persistent` adapter disables this by forcing it to `0`, which makes a keep-alive socket the server has already dropped surface as a read timeout instead of being retried transparently on a fresh socket. `POST` is never auto-retried, so this is side-effect safe. Set to `0` to restore the adapter's default behaviour.
96
+ - `max_retries` (optional): Re-enables Ruby's built-in retry, which retries **every** idempotent request (`GET`/`HEAD`/`PUT`/`DELETE`/`OPTIONS`) whatever its endpoint, writes through `PUT` included. Off by default; prefer `retry_options`.
82
97
  - `idle_timeout` (`Integer`, optional): Seconds a pooled socket may sit idle before it is recycled. Lower this towards your server's keep-alive window to shrink the stale-socket window for non-idempotent requests.
83
98
  - `pool_size` (`Integer`, optional): Maximum number of concurrent connections kept in the pool.
84
99
 
85
100
  > **NOTE:** When retries are enabled, the worst-case time for a request is roughly `(max_retries + 1) × read_timeout`. On platforms with a hard request cap (e.g. Heroku's 30s router limit), keep `read_timeout` low enough that this product stays under the cap.
86
101
 
102
+ - `oauth` (`FulfilApi::OAuth::Configuration`): The credentials and options of the OAuth app used by the [OAuth flow](#authenticating-with-oauth-rails). Assign it a `Hash`, or set its options one by one on `config.oauth`.
103
+
104
+ ### Stopping requests to a failing Fulfil instance
105
+
106
+ An application that talks to several Fulfil instances shouldn't let one struggling instance tie up all of its threads. The gem can run every request through a [Faulty](https://github.com/ParentSquare/faulty) circuit breaker. Faulty isn't a dependency of the gem, so add it to your own `Gemfile` first:
107
+
108
+ ```ruby
109
+ gem "faulty"
110
+ ```
111
+
112
+ Then hand the gem a Faulty instance. Its `circuit_defaults` set the thresholds, and its storage decides whether the state is kept per process (in memory) or shared between processes (e.g. Redis):
113
+
114
+ ```ruby
115
+ FulfilApi.configure do |config|
116
+ config.circuit_breaker = Faulty.new(
117
+ storage: Faulty::Storage::Redis.new(client: Redis.new),
118
+ circuit_defaults: { rate_threshold: 0.5, sample_threshold: 5, cool_down: 60 }
119
+ )
120
+ end
121
+ ```
122
+
123
+ Every Fulfil instance gets its own circuit, so one failing instance never stops requests to another. The `Client` and the `TplClient` of the same instance share a circuit. Timeouts, dropped connections and 5xx responses count as failures. A 4xx response is an answer and doesn't count, and a request that failed after retrying counts once.
124
+
125
+ While a circuit is closed, failed requests raise the same `FulfilApi::HttpError` they always do. Once it opens, requests raise a `FulfilApi::CircuitOpen` without touching the network, until Faulty's cool-down lets a test request through.
126
+
127
+ ### Authenticating with OAuth (Rails)
128
+
129
+ Fulfil caps the lifetime of personal access tokens, so an application that has to keep working unattended needs an OAuth token instead. The gem ships the whole flow as a Rails engine: a user is sent to Fulfil's consent screen, comes back with an authorization code, and the resulting token is stored as a `FulfilApi::Installation`.
130
+
131
+ The Rails parts of the gem only load when the gem is used from within a Rails application. Everywhere else, `fulfil_api` stays the plain HTTP client it has always been.
132
+
133
+ > **NOTE:** The engine requires Rails 8.0 or newer.
134
+
135
+ #### Setting it up
136
+
137
+ 1. Create an app in Fulfil's [authentication dashboard](https://auth.fulfil.io/user/clients) and whitelist `https://your-app.example.com/fulfil/callback` as a redirection URL.
138
+ 2. Run the install generator and its migration:
139
+
140
+ ```shell
141
+ $ bin/rails generate fulfil_api:install
142
+ $ bin/rails db:migrate
143
+ ```
144
+
145
+ The generator writes `config/initializers/fulfil_api.rb`, adds the migration for the `fulfil_api_installations` table, and mounts the engine at `/fulfil`. None of that changes how the application behaves: the routes have no callers, and the flow refuses to run until step 3 below. An application still on personal access tokens can install the gem and move to OAuth later.
146
+
147
+ 3. Fill in the app's credentials, and name the controller the flow runs under:
148
+
149
+ ```ruby
150
+ # config/initializers/fulfil_api.rb
151
+
152
+ FulfilApi.configure do |config|
153
+ config.merchant_id = ENV.fetch("FULFIL_MERCHANT_ID", nil)
154
+
155
+ config.oauth.client_id = ENV.fetch("FULFIL_OAUTH_CLIENT_ID", nil)
156
+ config.oauth.client_secret = ENV.fetch("FULFIL_OAUTH_CLIENT_SECRET", nil)
157
+ config.oauth.scopes = %w[sale.sale]
158
+
159
+ config.oauth.parent_controller = "AuthenticatedController"
160
+ end
161
+ ```
162
+
163
+ > **NOTE:** `parent_controller` has no default on purpose. The engine's controllers inherit from it, so it has to be a controller that authenticates the request — and, in an application serving many merchants, one that establishes the current tenant. An unauthenticated flow lets a stranger walk through it and record their own Fulfil workspace over yours; a flow outside the application's tenancy records installations against the wrong owner. Until it is set, the flow raises `FulfilApi::OAuth::ConfigurationError`.
164
+
165
+ > **NOTE:** The access tokens are encrypted at rest with Active Record Encryption. Run `bin/rails db:encryption:init` and store the keys in your credentials if the application does not use it yet.
166
+
167
+ #### Available OAuth options
168
+
169
+ - `client_id` / `client_secret` (`String`): The app's credentials, found under **App Credentials** in Fulfil's authentication dashboard.
170
+ - `scopes` (`Array<String>`, default `[]`): The scopes to request. See [https://developers.fulfil.io](https://developers.fulfil.io) for the full list.
171
+ - `access_type` (`Symbol`, default `:offline_access`): `:offline_access` grants a permanent token that keeps working when no user is around, which is what background jobs need. `:user_session` grants a token that expires with the user's Fulfil session. Fulfil has no refresh token, so an expired `:user_session` token can only be replaced by walking through the flow again.
172
+ - `after_install_path` (`String` or callable, default `"/"`): Where the user ends up after installing the app, unless they were sent to the flow from somewhere else. A callable receives the `FulfilApi::Installation` that was created.
173
+ - `parent_controller` (`String`, **required**): The controller the engine's own controllers inherit from, so the flow picks up the application's authentication, layout, and multi-tenancy. No default — see the note above.
174
+ - `redirect_uri` (`String`, optional): Defaults to the engine's callback URL, derived from the incoming request. Set it when the application sits behind a proxy that rewrites the host.
175
+
176
+ #### Kicking off the flow automatically
177
+
178
+ Include `FulfilApi::Authenticated` in a controller. Every action then runs with a client authenticated as the current installation, and a user without a usable token is sent through the flow and returned to where they were headed.
179
+
180
+ ```ruby
181
+ class SalesOrdersController < ApplicationController
182
+ include FulfilApi::Authenticated
183
+
184
+ def index
185
+ @sales_orders = FulfilApi::Resource.set(model_name: "sale.sale").limit(50)
186
+ end
187
+ end
188
+ ```
189
+
190
+ #### One installation per tenant
191
+
192
+ By default the installation belongs to the application as a whole, which is what an application built for a single merchant needs. An application serving many merchants ties the installation to a record instead:
193
+
194
+ ```ruby
195
+ class Shop < ApplicationRecord
196
+ include FulfilApi::Installable
197
+ end
198
+ ```
199
+
200
+ A record is connected to a single Fulfil workspace. Installing it on another one replaces what came before rather than leaving the record connected to two, and drops the token the previous workspace granted.
201
+
202
+ Point the flow at the current tenant by overriding two methods in `ApplicationController`:
203
+
204
+ ```ruby
205
+ class ApplicationController < ActionController::Base
206
+ private
207
+
208
+ def fulfil_installation_owner
209
+ Current.shop
210
+ end
211
+
212
+ def fulfil_merchant_id
213
+ Current.shop.fulfil_merchant_id
214
+ end
215
+ end
216
+ ```
217
+
218
+ Outside of a request — in a background job, for instance — go through the record:
219
+
220
+ ```ruby
221
+ shop.with_fulfil_config do |client|
222
+ client.get("model/sale.sale")
223
+ end
224
+ ```
225
+
226
+ `FulfilApi::Installation` is a plain Active Record model, so an application-wide token works the same way:
227
+
228
+ ```ruby
229
+ FulfilApi::Installation.global.sole.with_config do |client|
230
+ client.get("model/sale.sale")
231
+ end
232
+ ```
233
+
87
234
  ### Querying the Fulfil API
88
235
 
89
236
  > **NOTE:** Currently, the gem is under heavy development. The querying interface of the gem is really basic at the moment. In the future, we will closer match the querying interface of `ActiveRecord`.
@@ -178,6 +325,50 @@ A request that never reached Fulfil — a connection reset, a DNS failure, a tim
178
325
 
179
326
  `FulfilApi::HttpError` inherits from `FulfilApi::Error`, the base class of every error in this gem, so code that already rescues `FulfilApi::Error` keeps working unchanged.
180
327
 
328
+ ### Subscribing to Errors
329
+
330
+ Rescuing tells you about the one call you wrapped. To report *every* failure of the Fulfil API — to count rate limit hits in your APM, to page on authentication failures, to log what Fulfil actually said — subscribe once instead.
331
+
332
+ Every `FulfilApi::Error` publishes an `ActiveSupport::Notifications` event named `error.fulfil_api` when it is raised. `FulfilApi.on_error` is the shorthand for listening to it:
333
+
334
+ ```ruby
335
+ # config/initializers/fulfil_api.rb
336
+
337
+ FulfilApi.on_error do |error|
338
+ Appsignal.increment_counter("fulfil_api_errors", 1, error: error.class.name)
339
+ end
340
+ ```
341
+
342
+ Because every error carries its own class, you can report only the failures you care about:
343
+
344
+ ```ruby
345
+ FulfilApi.on_error do |error|
346
+ next unless error.is_a?(FulfilApi::HttpError::TooManyRequests)
347
+
348
+ Appsignal.increment_counter("fulfil_api_rate_limit_exceeded")
349
+ end
350
+ ```
351
+
352
+ Subscribing through `ActiveSupport::Notifications` directly gives you the full payload, which carries the HTTP details of a `FulfilApi::HttpError` as separate keys — handy as metric dimensions:
353
+
354
+ ```ruby
355
+ ActiveSupport::Notifications.subscribe(FulfilApi::Error::EVENT_NAME) do |event|
356
+ event.payload[:exception] # => ["FulfilApi::HttpError::TooManyRequests", "Try again later."]
357
+ event.payload[:exception_object] # => the FulfilApi::HttpError::TooManyRequests instance
358
+ event.payload[:status_code] # => 429
359
+ event.payload[:response_headers] # => { "retry-after" => "5", ... }
360
+ event.payload[:response_body] # => the raw response of Fulfil
361
+ end
362
+ ```
363
+
364
+ Both methods return the subscriber, so you can stop listening again with `ActiveSupport::Notifications.unsubscribe(subscriber)`.
365
+
366
+ A few things worth knowing:
367
+
368
+ - The event is published when an error is **raised**, not when it is built. Rescuing it afterwards does not suppress the notification, and retrying a request publishes one event per attempt — which is exactly what you want when counting rate limit hits.
369
+ - The subscriber runs on the thread that raised the error, while the error travels up the stack. Keep it cheap and hand anything slow to a background job.
370
+ - A subscriber cannot change the behaviour of your application. If it raises, the exception is swallowed and reported on `$stderr` rather than replacing the `FulfilApi::Error` on its way up.
371
+
181
372
  ### Using the 3PL (TPL) Client
182
373
 
183
374
  The gem also includes a client for Fulfil's [3PL Integration API](https://fulfil-3pl-integration-api.readme.io/reference/getting-started-with-your-api). This is a separate API that allows third-party logistics providers to interact with Fulfil on behalf of a merchant.
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FulfilApi
4
+ # Guarantees that every action of a controller runs with a working Fulfil API
5
+ # client, by sending the user through the OAuth flow when it does not.
6
+ #
7
+ # The user is returned to the action they were trying to reach once the app is
8
+ # installed, so a missing or expired token is a detour rather than a dead
9
+ # end.
10
+ #
11
+ # @example
12
+ # class SalesOrdersController < ApplicationController
13
+ # include FulfilApi::Authenticated
14
+ #
15
+ # def index
16
+ # @sales_orders = FulfilApi::Resource.set(model_name: "sale.sale").limit(50)
17
+ # end
18
+ # end
19
+ module Authenticated
20
+ extend ActiveSupport::Concern
21
+ include FulfilApi::InstallationScoped
22
+
23
+ included do
24
+ around_action :with_fulfil_installation
25
+ end
26
+
27
+ private
28
+
29
+ # @return [void]
30
+ def redirect_to_fulfil_installation
31
+ redirect_to fulfil_api.new_installation_path(
32
+ merchant_id: fulfil_merchant_id, return_to: request.fullpath
33
+ )
34
+ end
35
+
36
+ # @return [void]
37
+ def with_fulfil_installation(&)
38
+ return redirect_to_fulfil_installation unless fulfil_installation&.usable?
39
+
40
+ fulfil_installation.with_config(&)
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FulfilApi
4
+ # Answers which Fulfil installation a request belongs to.
5
+ #
6
+ # Out of the box that is the application-wide installation on the configured
7
+ # merchant, which is what a Rails application built for a single merchant
8
+ # needs. An application serving many merchants overrides these methods in
9
+ # its own `ApplicationController` to scope the installation to the current
10
+ # tenant.
11
+ #
12
+ # The engine's own controllers include this module below the application's
13
+ # parent controller. The defaults therefore defer to an override further up
14
+ # the chain, or the OAuth callback would ignore the tenant and record an
15
+ # application-wide installation.
16
+ #
17
+ # @example An application with an installation per shop
18
+ # class ApplicationController < ActionController::Base
19
+ # private
20
+ #
21
+ # def fulfil_installation_owner
22
+ # Current.shop
23
+ # end
24
+ #
25
+ # def fulfil_merchant_id
26
+ # Current.shop.fulfil_merchant_id
27
+ # end
28
+ # end
29
+ module InstallationScoped
30
+ extend ActiveSupport::Concern
31
+
32
+ private
33
+
34
+ # @return [FulfilApi::Installation, nil]
35
+ def fulfil_installation
36
+ return @fulfil_installation if defined?(@fulfil_installation)
37
+
38
+ @fulfil_installation = FulfilApi::Installation.installed_on(
39
+ merchant_id: fulfil_merchant_id, owner: fulfil_installation_owner
40
+ )
41
+ end
42
+
43
+ # The record the installation belongs to. `nil` means the installation is
44
+ # shared by the whole application.
45
+ #
46
+ # @return [ActiveRecord::Base, nil]
47
+ def fulfil_installation_owner
48
+ defined?(super) ? super : nil
49
+ end
50
+
51
+ # @return [String, nil] The Fulfil workspace of the current request.
52
+ def fulfil_merchant_id
53
+ defined?(super) ? super : FulfilApi.configuration.merchant_id
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FulfilApi
4
+ # Inherits from the controller named by `config.oauth.parent_controller`, so
5
+ # the engine's controllers pick up the host application's authentication,
6
+ # layout, and multi-tenancy.
7
+ #
8
+ # Until an application names one, the flow refuses to run rather than falling
9
+ # back to something that looks reasonable. An unauthenticated OAuth flow lets
10
+ # a stranger walk through it and record their own Fulfil workspace over the
11
+ # application's, and a flow that runs outside the application's tenancy
12
+ # silently records an installation against the wrong owner.
13
+ class ApplicationController < FulfilApi.configuration.oauth.parent_controller_class
14
+ include FulfilApi::InstallationScoped
15
+
16
+ before_action :verify_parent_controller!
17
+
18
+ private
19
+
20
+ # @raise [FulfilApi::OAuth::ConfigurationError]
21
+ # @return [void]
22
+ def verify_parent_controller!
23
+ return if FulfilApi.configuration.oauth.parent_controller_configured?
24
+
25
+ raise FulfilApi::OAuth::ConfigurationError,
26
+ "Set config.oauth.parent_controller to the controller that authenticates a request in this " \
27
+ "application before using the OAuth flow. Without one, anyone can reach the flow and record " \
28
+ "their own Fulfil workspace over yours."
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FulfilApi
4
+ # Walks a user through Fulfil's OAuth 2.0 authorization flow.
5
+ #
6
+ # `GET /installation/new` sends the user to Fulfil's consent screen, and
7
+ # `GET /callback` turns the authorization code they return with into an
8
+ # {FulfilApi::Installation}. The callback URL is the one that has to be
9
+ # whitelisted on the app in Fulfil's authentication dashboard.
10
+ class InstallationsController < FulfilApi::ApplicationController
11
+ MERCHANT_ID_SESSION_KEY = "fulfil_api.oauth.merchant_id"
12
+ RETURN_TO_SESSION_KEY = "fulfil_api.oauth.return_to"
13
+ STATE_SESSION_KEY = "fulfil_api.oauth.state"
14
+
15
+ # Handles the callback of the authorization flow.
16
+ #
17
+ # @raise [FulfilApi::OAuth::AuthorizationDenied] When the user cancelled the installation.
18
+ # @raise [FulfilApi::OAuth::StateMismatch] When the callback cannot be tied
19
+ # to the authorization request that started it.
20
+ # @return [void]
21
+ def create
22
+ merchant_id = session.delete(MERCHANT_ID_SESSION_KEY)
23
+ return_to = session.delete(RETURN_TO_SESSION_KEY)
24
+ verify_state!
25
+
26
+ authorization = authorization_for(merchant_id)
27
+ installation = FulfilApi::Installation.install(
28
+ authorization.exchange(params[:code]),
29
+ merchant_id: authorization.merchant_id,
30
+ owner: fulfil_installation_owner
31
+ )
32
+
33
+ redirect_to return_to.presence || after_install_path(installation), allow_other_host: false
34
+ end
35
+
36
+ # Kicks off the authorization flow by sending the user to Fulfil.
37
+ #
38
+ # @return [void]
39
+ def new
40
+ authorization = authorization_for(params[:merchant_id])
41
+ remember_authorization_request(authorization)
42
+
43
+ redirect_to authorization.url(state: session[STATE_SESSION_KEY]), allow_other_host: true
44
+ end
45
+
46
+ private
47
+
48
+ # @param installation [FulfilApi::Installation] The installation that was just created.
49
+ # @return [String]
50
+ def after_install_path(installation)
51
+ path = FulfilApi.configuration.oauth.after_install_path
52
+ path.respond_to?(:call) ? path.call(installation) : path
53
+ end
54
+
55
+ # Fulfil does not send the workspace back to the callback, so the one the
56
+ # flow started on is kept in the session. Without it, a workspace the user
57
+ # picked when starting the flow would be lost, and the code exchanged on
58
+ # whatever {#fulfil_merchant_id} returns instead.
59
+ #
60
+ # @param merchant_id [String, nil] The workspace to install the app on.
61
+ # Defaults to the workspace of the current request.
62
+ # @return [FulfilApi::OAuth::Authorization]
63
+ def authorization_for(merchant_id)
64
+ FulfilApi::OAuth::Authorization.new(
65
+ merchant_id: merchant_id.presence || fulfil_merchant_id,
66
+ redirect_uri: FulfilApi.configuration.oauth.redirect_uri.presence || callback_url
67
+ )
68
+ end
69
+
70
+ # Only paths within the application are remembered, so the `return_to`
71
+ # parameter cannot be used to bounce a user off to another host.
72
+ #
73
+ # @return [true, false]
74
+ def internal_return_to?
75
+ params[:return_to].present? && params[:return_to].to_s.start_with?("/") &&
76
+ !params[:return_to].to_s.start_with?("//")
77
+ end
78
+
79
+ # Keeps what the callback needs to finish the flow: the nonce to verify,
80
+ # the workspace to exchange the code on, and where to send the user after.
81
+ #
82
+ # @param authorization [FulfilApi::OAuth::Authorization] The authorization being requested.
83
+ # @return [void]
84
+ def remember_authorization_request(authorization)
85
+ session[MERCHANT_ID_SESSION_KEY] = authorization.merchant_id
86
+ session[STATE_SESSION_KEY] = FulfilApi::OAuth::Authorization.generate_state
87
+ session[RETURN_TO_SESSION_KEY] = params[:return_to] if internal_return_to?
88
+ end
89
+
90
+ # @raise [FulfilApi::OAuth::AuthorizationDenied]
91
+ # @return [void]
92
+ def verify_authorization!
93
+ return if params[:error].blank?
94
+
95
+ raise FulfilApi::OAuth::AuthorizationDenied,
96
+ "Fulfil denied the authorization request: #{params[:error_description].presence || params[:error]}"
97
+ end
98
+
99
+ # @raise [FulfilApi::OAuth::AuthorizationDenied]
100
+ # @raise [FulfilApi::OAuth::StateMismatch]
101
+ # @return [void]
102
+ def verify_state!
103
+ state = session.delete(STATE_SESSION_KEY)
104
+ verify_authorization!
105
+
106
+ return if state.present? && ActiveSupport::SecurityUtils.secure_compare(state, params[:state].to_s)
107
+
108
+ raise FulfilApi::OAuth::StateMismatch, "The state of the callback does not match the authorization request"
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FulfilApi
4
+ # Gives a record its own Fulfil installation, so an application that serves
5
+ # many merchants can keep one access token per tenant instead of one for the
6
+ # whole application. A record is connected to a single Fulfil workspace:
7
+ # installing on another one replaces what came before.
8
+ #
9
+ # @example
10
+ # class Shop < ApplicationRecord
11
+ # include FulfilApi::Installable
12
+ # end
13
+ #
14
+ # shop.with_fulfil_config do |client|
15
+ # client.get("model/sale.sale")
16
+ # end
17
+ #
18
+ # The concern deliberately leaves the Fulfil workspace of a record alone: that
19
+ # is usually a column on the record itself, which the authorization flow
20
+ # reads through `fulfil_merchant_id` in the host's ApplicationController.
21
+ module Installable
22
+ extend ActiveSupport::Concern
23
+
24
+ included do
25
+ has_one :fulfil_installation, as: :owner, class_name: "FulfilApi::Installation", dependent: :destroy
26
+ end
27
+
28
+ # @return [true, false] Whether the record can talk to the Fulfil API.
29
+ def fulfil_installed?
30
+ fulfil_installation&.usable? || false
31
+ end
32
+
33
+ # Runs a block against the Fulfil API as this record.
34
+ #
35
+ # @yieldparam [FulfilApi::Client] client A client authenticated as this record.
36
+ # @raise [FulfilApi::OAuth::Error] When the record has no usable installation.
37
+ # @return [void]
38
+ def with_fulfil_config(&)
39
+ unless fulfil_installed?
40
+ raise FulfilApi::OAuth::Error,
41
+ "#{self.class.name} #{id} has no usable Fulfil installation"
42
+ end
43
+
44
+ fulfil_installation.with_config(&)
45
+ end
46
+ end
47
+ end