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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b1e3793aadb8a9615fdddb8970acbc23564eec7965e8655558cc9062bdb43c00
|
|
4
|
+
data.tar.gz: fbae55d2bec65240dbbb44370411b452bb2461a8021668f70be3af67ee1decfe
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4cd81653bfe993a17a6552adfaceef0c0ea22f351ae900cc63dd436eb8461767c9d494d372cdc9458f44b5e5af0f6e4860aad49eabdca079ce5e59a32df15c53
|
|
7
|
+
data.tar.gz: cbf055f30acca04e2eeaf6aa510d90345ca603e50e016154626e363adca1e92f9a8f4e01f98bb238e32bb656a9d39b597071fb94f20d62cf1f98e09974e523ff
|
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.
|
|
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
|
-
|
|
1
|
+
4.0.7
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
- Send the `grant_type` and `redirect_uri` in the form body when exchanging an authorization code, the way OAuth 2.0 defines the token request. The grant type used to travel in the query string and the redirect URI was left out, which Fulfil accepts today but isn't required to.
|
|
4
|
+
|
|
5
|
+
- Separate the requested OAuth scopes with spaces instead of commas. Fulfil didn't recognize the comma separated list, and a workspace granted every scope the approving user had rather than the ones that were asked for.
|
|
6
|
+
|
|
7
|
+
- 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.
|
|
8
|
+
|
|
9
|
+
- 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.
|
|
10
|
+
|
|
11
|
+
- 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.
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
15
|
+
- 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.
|
|
16
|
+
|
|
17
|
+
- 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.
|
|
18
|
+
|
|
19
|
+
- 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.
|
|
20
|
+
|
|
21
|
+
- 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.
|
|
22
|
+
|
|
3
23
|
- 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`.
|
|
4
24
|
|
|
5
25
|
- 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.
|
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
|
-
|
|
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` (
|
|
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`.
|
|
@@ -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
|