commerce7-rails 0.2.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: d17bdff3aaa4490b2fda29996b277c0947c5dcb6539d4bc02b8d81a63cb50fd0
4
- data.tar.gz: b16fc3cdc51a00bf3e8563283db63fbaf2bc14d5508831038cb26c155ca3ad6e
3
+ metadata.gz: '094411b4f2757ca97c279f68504a9a76b3c2aad6a8db04ae2176cc82c9868430'
4
+ data.tar.gz: 24b9fdb82f090f504c900ef2fe10bad35a709cb4c4b622bc0595b9a4c00f3bce
5
5
  SHA512:
6
- metadata.gz: 52f6b435f3044adf60b7210a78b1d0b69ccfaa018d126f89432a764875d55a4a370d50c87928a7f7e4024314b3c857a863e6ecc8512ce221c0f281065190ccd6
7
- data.tar.gz: b7160af777302983bc93d39308bcf63a9f4058c177d5e1ddf5b37be021522fcb69dc5d109cbaf12db86550a45e006c52e18d67c98c8e92cbd18df6e56a4f5b94
6
+ metadata.gz: ba304ffc1ab7da1e5b2a3d9a9c1cc295402b3135c0803301765d7aec47fad3a7789d82ff16c0d0313835bbe4065608d55c74148a70a3e5799450dc2e8bd378c7
7
+ data.tar.gz: bfcfca822c4438ae7d09d90f6c8cb2fb3512a72f60784295ca46b824ed42b76f2cf4181463821360e69259a00e34340e059984e42358ca49e0c063dd3154dff7
data/README.md CHANGED
@@ -4,11 +4,34 @@ Rails building blocks for building a [Commerce7](https://www.commerce7.com/) App
4
4
 
5
5
  This gem owns the Commerce7-protocol plumbing. Your app owns the business logic: your tenant model's own fields, what a webhook handler actually does, what your App Extension pages render.
6
6
 
7
+ ## Requirements
8
+
9
+ - Rails 8.1.4 or later
10
+ - Faraday 2.14.4 or later
11
+
12
+ Both floors date from v0.3.0. Earlier versions allowed Rails 7.1+ and any Faraday 2.x.
13
+
14
+ **Why:** `json` 3.0 changed the signature of `JSON.parse`, and older Rails and Faraday releases call it in ways it no longer accepts. Nothing in your app has to request `json` 3 for this to happen. It arrives as a transitive dependency (rubocop 1.91 pulls it in, for one), and neither Rails nor Faraday caps `json` below 3, so Bundler will happily resolve a combination that breaks at runtime. The floors rule those combinations out.
15
+
16
+ - **Faraday 2.14.3 and earlier:** the JSON response middleware breaks, so every Commerce7 REST client response raises `Faraday::ParsingError: wrong number of arguments (given 2, expected 1)`.
17
+ - **Rails before 8.1.4:** `ActiveSupport::JSON.decode` breaks, so every `json`/`jsonb` column read raises. That includes the `raw_activation_payload` column the install generator creates. How it fails depends on the Rails line:
18
+
19
+ | Rails | With `json` 3 |
20
+ |---|---|
21
+ | 7.1.x, 8.0.x | `ArgumentError: unknown keyword: quirks_mode` |
22
+ | 7.2.x | JSON decoding works |
23
+ | 8.1.0 to 8.1.3.x | `ArgumentError: wrong number of arguments (given 2, expected 1)` |
24
+ | 8.1.4 and later | Works |
25
+
26
+ A gemspec can't say "7.2.x or 8.1.4 and later", so the floor is 8.1.4.
27
+
28
+ If you're stuck on an older Rails, stay on v0.2.1 and pin `json` below 3 in your app's Gemfile (`gem "json", "~> 2.21"`).
29
+
7
30
  ## Install
8
31
 
9
32
  ```ruby
10
33
  # Gemfile
11
- gem "commerce7-rails", github: "ERCubed/commerce7-rails", tag: "v0.2.0"
34
+ gem "commerce7-rails", github: "ERCubed/commerce7-rails", tag: "v0.3.0"
12
35
  ```
13
36
 
14
37
  ```
@@ -69,6 +92,19 @@ class Current < ActiveSupport::CurrentAttributes
69
92
  end
70
93
  ```
71
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
+
72
108
  ## Routes
73
109
 
74
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:
@@ -133,7 +169,30 @@ production:
133
169
 
134
170
  ```ruby
135
171
  client = Commerce7::Client.new(tenant)
136
- 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
137
196
  client.each_customer { |customer| ... }
138
197
  client.each_order { |order| ... }
139
198
  client.each_order(orderPaidDate: "gte:2026-01-01") { |order| ... } # params pass through as filters
@@ -142,7 +201,11 @@ client.each_inventory_location { |location| ... }
142
201
  client.fetch_order(order_id)
143
202
  ```
144
203
 
145
- 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.
146
209
 
147
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.
148
211
 
@@ -153,6 +216,7 @@ This gem exists so every app built on it starts from a "Yes" on Commerce7's App
153
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.
154
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.
155
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.
156
220
  - **Data deletion**: built in — soft-deactivate on uninstall, hard-delete after 30 days (`Commerce7::PurgeDeactivatedTenantsJob`).
157
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.
158
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.
@@ -18,15 +18,19 @@ module Commerce7
18
18
  # a cross-site iframe POST where the session cookie may not travel).
19
19
  protect_from_forgery with: :exception
20
20
 
21
- before_action :authenticate_staff!
22
-
23
21
  # Rails sends X-Frame-Options: SAMEORIGIN by default, which blocks
24
22
  # Commerce7's admin panel (a different origin) from framing this page at
25
23
  # all. Commerce7 doesn't publish a stable admin origin to scope a
26
24
  # replacement CSP frame-ancestors to, so this just drops the blanket
27
25
  # deny; a host app that wants a tighter CSP can add its own
28
26
  # frame-ancestors directive once that origin is confirmed.
29
- after_action { response.headers.delete("X-Frame-Options") }
27
+ #
28
+ # A before_action, declared ahead of authenticate_staff!, rather than an
29
+ # after_action: when auth fails, authenticate_staff! renders the error
30
+ # page and halts the chain, and after_actions never run — which left
31
+ # exactly the page staff most need to see blocked inside the iframe.
32
+ before_action { response.headers.delete("X-Frame-Options") }
33
+ before_action :authenticate_staff!
30
34
 
31
35
  rescue_from ActionController::ParameterMissing do |error|
32
36
  render plain: error.message, status: :bad_request
@@ -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.2.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.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eric Roberts
@@ -15,28 +15,28 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '7.1'
18
+ version: 8.1.4
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: '7.1'
25
+ version: 8.1.4
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: faraday
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
30
  - - ">="
31
31
  - !ruby/object:Gem::Version
32
- version: '2.0'
32
+ version: 2.14.4
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
- version: '2.0'
39
+ version: 2.14.4
40
40
  description: Activation/deactivation lifecycle, webhook dispatch, App Extension staff-JWT
41
41
  auth, the Commerce7 REST client, and the post-uninstall data purge Commerce7's security
42
42
  review requires — configured once, reused across every Commerce7 app.