commerce7-rails 0.3.0 → 0.4.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.
- checksums.yaml +4 -4
- data/README.md +43 -2
- data/app/services/commerce7/client.rb +67 -6
- data/lib/commerce7/configuration.rb +24 -0
- data/lib/commerce7/version.rb +1 -1
- data/lib/generators/commerce7/install/templates/commerce7.rb +6 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '094411b4f2757ca97c279f68504a9a76b3c2aad6a8db04ae2176cc82c9868430'
|
|
4
|
+
data.tar.gz: 24b9fdb82f090f504c900ef2fe10bad35a709cb4c4b622bc0595b9a4c00f3bce
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ba304ffc1ab7da1e5b2a3d9a9c1cc295402b3135c0803301765d7aec47fad3a7789d82ff16c0d0313835bbe4065608d55c74148a70a3e5799450dc2e8bd378c7
|
|
7
|
+
data.tar.gz: bfcfca822c4438ae7d09d90f6c8cb2fb3512a72f60784295ca46b824ed42b76f2cf4181463821360e69259a00e34340e059984e42358ca49e0c063dd3154dff7
|
data/README.md
CHANGED
|
@@ -92,6 +92,19 @@ class Current < ActiveSupport::CurrentAttributes
|
|
|
92
92
|
end
|
|
93
93
|
```
|
|
94
94
|
|
|
95
|
+
### Limiting which resources the client can read
|
|
96
|
+
|
|
97
|
+
Set `allowed_resources` to the Commerce7 resources your app reads. `Commerce7::Client` then refuses any other resource before sending a request, through the generic methods and the named helpers alike:
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
Commerce7.configure do |c|
|
|
101
|
+
# ...
|
|
102
|
+
c.allowed_resources = %w[club-membership order customer]
|
|
103
|
+
end
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
A resource is the first segment of an API path (`order` in `order/123`), so sub-paths of a listed resource are allowed. Keep the list matching the scopes registered for your app in Commerce7's Developer Center: Commerce7 enforces those scopes too, and this list is the app's own declaration of them, in code. Left unset (`nil`), every resource is allowed. The install generator starts new apps at `[]`, so each resource is added deliberately. Entries are checked when set, so a typo fails at boot.
|
|
107
|
+
|
|
95
108
|
## Routes
|
|
96
109
|
|
|
97
110
|
This gem mounts nothing — you declare routes exactly as you would for any in-app controller, just pointing at the gem's classes, so the URLs already registered in Commerce7's Developer Center (Install/Uninstall URLs, an App Extension's iframe src) stay stable and under your control:
|
|
@@ -156,7 +169,30 @@ production:
|
|
|
156
169
|
|
|
157
170
|
```ruby
|
|
158
171
|
client = Commerce7::Client.new(tenant)
|
|
159
|
-
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### Any endpoint
|
|
175
|
+
|
|
176
|
+
The generic methods reach every read endpoint in Commerce7's API, with pagination, rate-limit retries, and the tenant header handled for you:
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
client.each("club-membership") { |membership| ... } # paginates automatically
|
|
180
|
+
client.each("customer", lastName: "Smith") { |customer| ... } # filters pass through as query params
|
|
181
|
+
client.each("customer").first(10) # without a block, returns an Enumerator
|
|
182
|
+
client.fetch("customer", customer_id) # one record: GET customer/{id}
|
|
183
|
+
client.get("customer/#{customer_id}/address") # any other GET, returns the parsed body
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
`each` reads records from the response key Commerce7 names after the resource: the path's last segment, pluralized and camelCased (`"club-membership"` reads `clubMemberships`). If an endpoint uses a different key, pass `key: "theKey"`. A response without the expected key raises `ApiError` rather than quietly yielding nothing. Filters can be keywords or a hash; `key` is reserved for this method, so pass a filter literally named `key` in the hash.
|
|
187
|
+
|
|
188
|
+
Paths must be relative (`"customer"`, `"order/123"`): segments of letters, digits, `-` and `_`. Anything else (a full URL, `..`, percent-encoding) raises `Commerce7::Client::InvalidRequestError` before a request is made, because a full URL would otherwise make Faraday send the request, App ID and Secret included, to that host. `fetch` applies the same rule to the id, which often comes from a URL param or webhook. `InvalidRequestError` is a `Client::Error`, so code that already rescues those treats a tampered id like any failed lookup. A resource outside `allowed_resources` (see [Configure](#limiting-which-resources-the-client-can-read)) raises it too.
|
|
189
|
+
|
|
190
|
+
### Named helpers
|
|
191
|
+
|
|
192
|
+
These wrap `each`/`fetch` for the resources the apps built on this gem use, with notes on what each record carries:
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
client.each_club_membership { |membership| ... } # embeds the customer and club
|
|
160
196
|
client.each_customer { |customer| ... }
|
|
161
197
|
client.each_order { |order| ... }
|
|
162
198
|
client.each_order(orderPaidDate: "gte:2026-01-01") { |order| ... } # params pass through as filters
|
|
@@ -165,7 +201,11 @@ client.each_inventory_location { |location| ... }
|
|
|
165
201
|
client.fetch_order(order_id)
|
|
166
202
|
```
|
|
167
203
|
|
|
168
|
-
|
|
204
|
+
### Read only
|
|
205
|
+
|
|
206
|
+
The client only sends GET requests. There's deliberately no POST/PUT/DELETE: an app that writes to Commerce7 needs broader API permissions, changes its answer on Commerce7's security review, and needs care around retries and audit logging. Writes will be added when an app actually needs them.
|
|
207
|
+
|
|
208
|
+
Every call handles the 100 req/min rate limit (retries on 429 using `Retry-After` when present, exponential backoff otherwise), and raises `Commerce7::Client::AuthenticationError` / `RateLimitedError` / `ApiError` as appropriate.
|
|
169
209
|
|
|
170
210
|
`Commerce7::AccountClient` validates the staff JWT Commerce7 passes into an App Extension iframe — used internally by `Commerce7::ExtensionController`, but available directly if you need it.
|
|
171
211
|
|
|
@@ -176,6 +216,7 @@ This gem exists so every app built on it starts from a "Yes" on Commerce7's App
|
|
|
176
216
|
- **Server-to-server auth**: `Commerce7::BaseController` requires HTTP Basic Auth (your `webhook_credentials`) on every activation/deactivation/webhook POST, and audits both successful and failed attempts.
|
|
177
217
|
- **App Extension auth**: `Commerce7::ExtensionController` validates the staff JWT Commerce7 passes into every iframe load against Commerce7's own `/account/user` endpoint — a real error page on failure, never a bare status code.
|
|
178
218
|
- **PII**: `raw_activation_payload` (the installing staff member's name/email) is your model's column to encrypt — see Install above.
|
|
219
|
+
- **Least-privilege API access**: the client is read only (no POST/PUT/DELETE), refuses any resource outside `allowed_resources`, and rejects anything but a plain relative path or id before sending, so a tampered id or a full URL can never send your App ID and Secret anywhere but Commerce7.
|
|
179
220
|
- **Data deletion**: built in — soft-deactivate on uninstall, hard-delete after 30 days (`Commerce7::PurgeDeactivatedTenantsJob`).
|
|
180
221
|
- **Webhook auth + idempotency**: Basic Auth on every delivery; handlers are expected to be idempotent by construction, since Commerce7 exposes no delivery id to dedupe against.
|
|
181
222
|
- **Audit trail**: every security-relevant event (server auth success/failure, activation/deactivation, staff extension auth, webhook-driven dispatch, the post-uninstall purge) flows through your configured `audit` hook — user identity, event type, timestamp, success/failure, and origin.
|
|
@@ -15,6 +15,10 @@ module Commerce7
|
|
|
15
15
|
class AuthenticationError < Error; end
|
|
16
16
|
class RateLimitedError < Error; end
|
|
17
17
|
class ApiError < Error; end
|
|
18
|
+
# A path or id that isn't safe to send. A Client::Error, so callers that
|
|
19
|
+
# already rescue those (e.g. around a lookup by an id from a URL) handle a
|
|
20
|
+
# tampered value like any other failed lookup.
|
|
21
|
+
class InvalidRequestError < Error; end
|
|
18
22
|
|
|
19
23
|
# Trailing slash matters: Faraday/URI joins a relative path onto this by
|
|
20
24
|
# RFC 3986 merge rules, so without it "v1" gets treated as a filename and
|
|
@@ -22,6 +26,14 @@ module Commerce7
|
|
|
22
26
|
BASE_URL = "https://api.commerce7.com/v1/"
|
|
23
27
|
PAGE_SIZE = 50
|
|
24
28
|
MAX_RETRIES = 3
|
|
29
|
+
# A relative API path: segments of letters, digits, - and _, joined by
|
|
30
|
+
# single slashes. Never a full URL (Faraday would send the request, App
|
|
31
|
+
# ID/Secret included, to that host instead), never "." or ".." segments,
|
|
32
|
+
# and no "%", so nothing percent-encoded (e.g. %2e%2e for "..") can be
|
|
33
|
+
# decoded into a different path by the server.
|
|
34
|
+
PATH_FORMAT = %r{\A[A-Za-z0-9_-]+(?:/[A-Za-z0-9_-]+)*\z}
|
|
35
|
+
# A record id, which becomes one path segment. Commerce7 ids are UUIDs.
|
|
36
|
+
ID_FORMAT = /\A[A-Za-z0-9_-]+\z/
|
|
25
37
|
|
|
26
38
|
def initialize(tenant, base_url: BASE_URL, sleeper: ->(seconds) { sleep(seconds) })
|
|
27
39
|
@app_id, @app_secret_key = Commerce7.configuration.app_credentials.call
|
|
@@ -76,18 +88,68 @@ module Commerce7
|
|
|
76
88
|
# orderId from Commerce7. Returns the order hash, which carries a
|
|
77
89
|
# top-level customerId same as club-membership's.
|
|
78
90
|
def fetch_order(order_id)
|
|
79
|
-
|
|
91
|
+
fetch("order", order_id)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Generic read access to any Commerce7 list endpoint, with the same
|
|
95
|
+
# pagination and rate-limit handling as the named methods above:
|
|
96
|
+
#
|
|
97
|
+
# client.each("club-membership") { |membership| ... }
|
|
98
|
+
# client.each("customer", lastName: "Smith") { |customer| ... }
|
|
99
|
+
# client.each("some-endpoint", key: "unusualKey") { |record| ... }
|
|
100
|
+
#
|
|
101
|
+
# Records are read from the response key Commerce7 names after the
|
|
102
|
+
# resource: the path's last segment, pluralized and camelCased
|
|
103
|
+
# ("club-membership" => "clubMemberships"). Pass `key:` when an endpoint
|
|
104
|
+
# differs. A response without that key raises ApiError rather than
|
|
105
|
+
# quietly yielding nothing. Filters can be keywords (as above) or a hash;
|
|
106
|
+
# `key` is the one name reserved for this method, so pass a filter that
|
|
107
|
+
# happens to be called "key" in the params hash.
|
|
108
|
+
def each(path, params = {}, key: nil, **filters, &block)
|
|
109
|
+
return enum_for(:each, path, params, key: key, **filters) unless block_given?
|
|
110
|
+
|
|
111
|
+
each_record(path, key || response_key_for(path), params.merge(filters), require_key: true, &block)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# A single record by id from any endpoint: fetch("customer", id) is
|
|
115
|
+
# GET customer/{id}. The id often comes from outside (a URL param, a
|
|
116
|
+
# webhook), so anything but a plain identifier raises InvalidRequestError
|
|
117
|
+
# rather than being sent.
|
|
118
|
+
def fetch(path, id)
|
|
119
|
+
id = id.to_s
|
|
120
|
+
raise InvalidRequestError, "Commerce7 record id must be a plain identifier, got #{id.inspect}" unless id.match?(ID_FORMAT)
|
|
121
|
+
|
|
122
|
+
get("#{path}/#{id}")
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Any GET, for endpoints that don't fit `each`/`fetch`. Returns the parsed
|
|
126
|
+
# response body. Read-only on purpose: this client has no POST/PUT/DELETE.
|
|
127
|
+
def get(path, params = {})
|
|
128
|
+
path = path.to_s # validate and send the same string
|
|
129
|
+
raise InvalidRequestError, "Commerce7 API path must be relative, like \"customer\" or \"order/123\", got #{path.inspect}" unless path.match?(PATH_FORMAT)
|
|
130
|
+
resource = path.split("/").first
|
|
131
|
+
unless Commerce7.configuration.resource_allowed?(resource)
|
|
132
|
+
raise InvalidRequestError, "Commerce7 resource #{resource.inspect} is not in Commerce7.configuration.allowed_resources (#{Commerce7.configuration.allowed_resources.join(', ')})"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
response = with_rate_limit_retry { connection.get(path, params) }
|
|
136
|
+
handle_response(response)
|
|
80
137
|
end
|
|
81
138
|
|
|
82
139
|
private
|
|
83
140
|
|
|
84
141
|
attr_reader :tenant, :base_url, :sleeper
|
|
85
142
|
|
|
86
|
-
def each_record(path, response_key, params = {})
|
|
143
|
+
def each_record(path, response_key, params = {}, require_key: false)
|
|
87
144
|
page = 1
|
|
88
145
|
|
|
89
146
|
loop do
|
|
90
|
-
|
|
147
|
+
body = get(path, params.merge(page: page, limit: PAGE_SIZE))
|
|
148
|
+
if require_key && !body.key?(response_key)
|
|
149
|
+
raise ApiError, "Commerce7 response for #{path.inspect} has no #{response_key.inspect} key (keys: #{body.keys.join(', ')}); pass key: to Client#each"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
records = body[response_key] || []
|
|
91
153
|
records.each { |record| yield record }
|
|
92
154
|
|
|
93
155
|
break if records.size < PAGE_SIZE
|
|
@@ -96,9 +158,8 @@ module Commerce7
|
|
|
96
158
|
end
|
|
97
159
|
end
|
|
98
160
|
|
|
99
|
-
def
|
|
100
|
-
|
|
101
|
-
handle_response(response)
|
|
161
|
+
def response_key_for(path)
|
|
162
|
+
path.split("/").last.tr("-", "_").pluralize.camelize(:lower)
|
|
102
163
|
end
|
|
103
164
|
|
|
104
165
|
def with_rate_limit_retry
|
|
@@ -28,6 +28,30 @@ module Commerce7
|
|
|
28
28
|
# land in the same trail as the rest of the app's.
|
|
29
29
|
attr_accessor :audit
|
|
30
30
|
|
|
31
|
+
# The Commerce7 API resources this app may read, e.g.
|
|
32
|
+
# %w[club-membership order customer]: the first segment of an API path
|
|
33
|
+
# ("order" in "order/123"). Commerce7::Client refuses any other resource
|
|
34
|
+
# before sending a request, so an app can only reach what it declares,
|
|
35
|
+
# and this list should match the scopes registered for the app in
|
|
36
|
+
# Commerce7's Developer Center. nil (the default) allows every resource;
|
|
37
|
+
# [] allows none.
|
|
38
|
+
attr_reader :allowed_resources
|
|
39
|
+
|
|
40
|
+
def allowed_resources=(resources)
|
|
41
|
+
@allowed_resources = resources.nil? ? nil : Array(resources).map do |resource|
|
|
42
|
+
resource = resource.to_s
|
|
43
|
+
unless resource.match?(/\A[A-Za-z0-9_-]+\z/)
|
|
44
|
+
raise ArgumentError, "Commerce7 allowed_resources entries are bare resource names like \"order\", got #{resource.inspect}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
resource
|
|
48
|
+
end.freeze
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def resource_allowed?(resource)
|
|
52
|
+
allowed_resources.nil? || allowed_resources.include?(resource.to_s)
|
|
53
|
+
end
|
|
54
|
+
|
|
31
55
|
def initialize
|
|
32
56
|
@tenant_class_name = "Tenant"
|
|
33
57
|
@webhook_credentials = -> { raise_unconfigured!(:webhook_credentials) }
|
data/lib/commerce7/version.rb
CHANGED
|
@@ -19,6 +19,12 @@ Commerce7.configure do |c|
|
|
|
19
19
|
# webhook-driven mutations, the post-uninstall purge) land in the same
|
|
20
20
|
# trail as the rest of the app's. See the README's "Audit trail" section.
|
|
21
21
|
c.audit = ->(**kwargs) { AuditEvent.record!(**kwargs) }
|
|
22
|
+
|
|
23
|
+
# The Commerce7 API resources this app reads, e.g. %w[club-membership order].
|
|
24
|
+
# Commerce7::Client refuses anything else. Keep it matching the scopes
|
|
25
|
+
# registered for this app in the Developer Center. Starts empty, so add
|
|
26
|
+
# each resource deliberately as the app starts using it.
|
|
27
|
+
c.allowed_resources = []
|
|
22
28
|
end
|
|
23
29
|
|
|
24
30
|
# Runs once, after a tenant activates (first install or a reinstall) —
|