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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 54a31ca6f73ce04f8fe8c32d673e781f53f34a6bab48220a907d57d0d9500a95
4
- data.tar.gz: afb4810f89069ce03865b4fdb683c7b4d6778f1028dc49e8a7122db063755ea0
3
+ metadata.gz: '094411b4f2757ca97c279f68504a9a76b3c2aad6a8db04ae2176cc82c9868430'
4
+ data.tar.gz: 24b9fdb82f090f504c900ef2fe10bad35a709cb4c4b622bc0595b9a4c00f3bce
5
5
  SHA512:
6
- metadata.gz: 347fb156c387dc45af3bffff70be82bb3c25f4e67ad5cce37de1fc6203be66dbe834b3d780931986296457601158dba77f0bdb3cab867555e5ba228f8ec9e858
7
- data.tar.gz: e2424555f6b6948f79fdd8f72cdee26a8997c10d2c93dafc51eac65226c6eac06122fd03d93a90f53ecb62a00f57ef6c487480158e364532f1eafffc2cd26e69
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
- client.each_club_membership { |membership| ... } # paginates automatically
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
- Handles pagination, 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.
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
- get("order/#{order_id}", {})
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
- records = get(path, params.merge(page: page, limit: PAGE_SIZE))[response_key] || []
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 get(path, params)
100
- response = with_rate_limit_retry { connection.get(path, params) }
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) }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Commerce7
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -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) —
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: commerce7-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eric Roberts