portage-ucp-shopify 0.2.0 → 0.3.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/CHANGELOG.md +30 -0
- data/lib/portage/ucp/shopify/adapter.rb +88 -47
- data/lib/portage/ucp/shopify/client.rb +30 -5
- data/lib/portage/ucp/shopify/configuration.rb +70 -0
- data/lib/portage/ucp/shopify/errors.rb +43 -0
- data/lib/portage/ucp/shopify/mapper.rb +148 -9
- data/lib/portage/ucp/shopify/queries.rb +77 -21
- data/lib/portage/ucp/shopify/version.rb +1 -1
- data/lib/portage/ucp/shopify.rb +1 -0
- metadata +5 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ac2f2ad9119012864ff7ab41827c1cff716f4f7b1092d200d2810d8cb2ef7b5b
|
|
4
|
+
data.tar.gz: 4225325c543f10ed8a2c1f0adda77667f6224ec70c8e3d412b666a55345a81f4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 49d90b4545a07f1685fe54d34bf53f0acc3919c0e1d8ca37398d3ac460ab460047ba42406d711c3e7c27c42c26d677c625114693be3914aa3676023dee6d5325
|
|
7
|
+
data.tar.gz: 81aba41eed72b01960b045978c9657de5b29d352df4ce52f3c165deee1f833d3ca2c9274e55e6a08d4a110ffcadcfb8dcf737eeff22c5412c517ca8e3cad5b12
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,36 @@ All notable changes to this project are documented here. Format loosely follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.0.0/); this project is
|
|
5
5
|
pre-1.0, so APIs may still shift between minor versions.
|
|
6
6
|
|
|
7
|
+
## [0.3.0] - 2026-08-27
|
|
8
|
+
|
|
9
|
+
- `metadata_field` config DSL (`Configuration`) — lets a merchant map their
|
|
10
|
+
own Shopify metafields onto UCP catalog attributes without a code change,
|
|
11
|
+
instead of every merchant-specific field needing a fork of `mapper.rb`.
|
|
12
|
+
- Four catalog/cart GraphQL shape fixes against the real Admin/Storefront
|
|
13
|
+
API, none catchable by `adapter_spec.rb`'s hand-rolled webmock stubs since
|
|
14
|
+
those fabricate response shapes by hand:
|
|
15
|
+
- `ProductVariant#price`/`#compareAtPrice` are the bare `Money` scalar (a
|
|
16
|
+
decimal string), not a `MoneyV2` object — the live Admin API 2026-04
|
|
17
|
+
schema rejects `{ amount currencyCode }` sub-selections on them.
|
|
18
|
+
- `ProductCompareAtPriceRange`'s fields are `minVariantCompareAtPrice`/
|
|
19
|
+
`maxVariantCompareAtPrice`, not `minVariantPrice`/`maxVariantPrice`
|
|
20
|
+
(those belong to `ProductPriceRangeV2` and were copy-pasted onto the
|
|
21
|
+
compare-at query and mapper).
|
|
22
|
+
- Cart `deliveryGroups` is a connection (`.nodes`), not a bare array.
|
|
23
|
+
- Cart `totalTaxAmount` is nullable — a fresh cart genuinely has no tax
|
|
24
|
+
amount yet rather than a zero one, until Shopify has enough context
|
|
25
|
+
(shipping address, tax-registered market) to compute it.
|
|
26
|
+
- Bounded retry with backoff and normalized conflict/throttle errors on
|
|
27
|
+
requests to Shopify, via the core gem's new `Support::Retry`.
|
|
28
|
+
- Per-cart/checkout mutations against Shopify are now serialized per session
|
|
29
|
+
(core gem's new `Support::SessionLock`), so two concurrent requests against
|
|
30
|
+
the same cart can't race each other.
|
|
31
|
+
- `existing_variant_id` support in the adapter conformance kit — Shopify
|
|
32
|
+
needs a Product GID for catalog lookups and a separate ProductVariant GID
|
|
33
|
+
for cart line items, which `existing_product_id` alone can't express.
|
|
34
|
+
- Adapter now runs the core gem's conformance kit against a real `Adapter`
|
|
35
|
+
through a real `Dispatcher` (`spec/portage/ucp/shopify/conformance_spec.rb`).
|
|
36
|
+
|
|
7
37
|
## [0.2.0] - 2026-08-21
|
|
8
38
|
|
|
9
39
|
- `CartInput.discountCodes` on create, `cartDiscountCodesUpdate` on update —
|
|
@@ -34,6 +34,20 @@ module Portage
|
|
|
34
34
|
# back to the cart that produced it — both are tracked adapter-side
|
|
35
35
|
# via Support::CheckoutState.
|
|
36
36
|
include Portage::Ucp::Support::CheckoutState
|
|
37
|
+
# #replace_lines, #apply_fulfillment and #submit_payment are each a
|
|
38
|
+
# read-modify-write across multiple Storefront calls with no atomic
|
|
39
|
+
# equivalent upstream — two concurrent calls against the same
|
|
40
|
+
# cart/checkout id can interleave and drop each other's writes, not
|
|
41
|
+
# just race on the same eventual value (§21). Support::SessionLock
|
|
42
|
+
# serializes by id so the second call waits instead of interleaving.
|
|
43
|
+
include Portage::Ucp::Support::SessionLock
|
|
44
|
+
# #poll_submission retries a SubmitThrottled cartSubmitForCompletion
|
|
45
|
+
# response instead of handing the caller an ambiguous
|
|
46
|
+
# "complete_in_progress" — safe because #submit_payment (its only
|
|
47
|
+
# caller) is always reached through #complete_checkout's dedup.
|
|
48
|
+
include Portage::Ucp::Support::Retry
|
|
49
|
+
|
|
50
|
+
SUBMIT_POLL_MAX_ATTEMPTS = 3
|
|
37
51
|
|
|
38
52
|
def initialize(client:)
|
|
39
53
|
super()
|
|
@@ -41,14 +55,15 @@ module Portage
|
|
|
41
55
|
end
|
|
42
56
|
|
|
43
57
|
def search_catalog(query:, limit:)
|
|
44
|
-
data = @client.admin_query(Queries
|
|
45
|
-
data.dig("products", "nodes").map { |node| Mapper.product(node) }
|
|
58
|
+
data = @client.admin_query(Queries.search_catalog_query, variables: { query: query, first: limit })
|
|
59
|
+
products = data.dig("products", "nodes").map { |node| Mapper.product(node) }
|
|
60
|
+
Portage::Ucp::CatalogSearchResult.new(products: products)
|
|
46
61
|
end
|
|
47
62
|
|
|
48
63
|
def get_product(product_id:)
|
|
49
|
-
data = @client.admin_query(Queries
|
|
64
|
+
data = @client.admin_query(Queries.product_by_id_query, variables: { id: product_id })
|
|
50
65
|
node = data["product"]
|
|
51
|
-
node && Mapper.product(node)
|
|
66
|
+
node && Portage::Ucp::ProductDetail.new(product: Mapper.product(node))
|
|
52
67
|
end
|
|
53
68
|
|
|
54
69
|
def get_cart(cart_id:)
|
|
@@ -195,15 +210,17 @@ module Portage
|
|
|
195
210
|
# optional and independent — an agent picking a rate on an
|
|
196
211
|
# already-addressed cart sends only the latter.
|
|
197
212
|
def apply_fulfillment(cart_id, fulfillment)
|
|
198
|
-
|
|
213
|
+
synchronize(cart_id) do
|
|
214
|
+
cart_node = fetch_cart_node(cart_id)
|
|
199
215
|
|
|
200
|
-
|
|
201
|
-
|
|
216
|
+
destination = fulfillment.shipping_methods.flat_map(&:destinations).first
|
|
217
|
+
cart_node = apply_delivery_address(cart_id, destination.address) if destination
|
|
202
218
|
|
|
203
|
-
|
|
204
|
-
|
|
219
|
+
selections = fulfillment.shipping_methods.flat_map(&:groups).filter_map { |g| delivery_selection(g) }
|
|
220
|
+
cart_node = apply_delivery_selections(cart_id, selections) unless selections.empty?
|
|
205
221
|
|
|
206
|
-
|
|
222
|
+
cart_node
|
|
223
|
+
end
|
|
207
224
|
end
|
|
208
225
|
|
|
209
226
|
def delivery_selection(group)
|
|
@@ -250,23 +267,27 @@ module Portage
|
|
|
250
267
|
end
|
|
251
268
|
|
|
252
269
|
def replace_lines(cart_id, line_items, discount_codes)
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
cart_node = if line_items.empty?
|
|
261
|
-
fetch_cart_node(cart_id)
|
|
262
|
-
else
|
|
263
|
-
added = @client.storefront_query(Queries::CART_LINES_ADD,
|
|
264
|
-
variables: { cartId: cart_id, lines: cart_lines(line_items) })
|
|
265
|
-
unwrap!(added, "cartLinesAdd")
|
|
266
|
-
end
|
|
267
|
-
return cart_node if discount_codes.nil?
|
|
270
|
+
synchronize(cart_id) do
|
|
271
|
+
current_line_ids = fetch_cart_node(cart_id).dig("lines", "nodes").map { |n| n["id"] }
|
|
272
|
+
unless current_line_ids.empty?
|
|
273
|
+
removed = @client.storefront_query(Queries::CART_LINES_REMOVE,
|
|
274
|
+
variables: { cartId: cart_id, lineIds: current_line_ids })
|
|
275
|
+
unwrap!(removed, "cartLinesRemove")
|
|
276
|
+
end
|
|
268
277
|
|
|
269
|
-
|
|
278
|
+
cart_node = if line_items.empty?
|
|
279
|
+
fetch_cart_node(cart_id)
|
|
280
|
+
else
|
|
281
|
+
added = @client.storefront_query(
|
|
282
|
+
Queries::CART_LINES_ADD,
|
|
283
|
+
variables: { cartId: cart_id, lines: cart_lines(line_items) }
|
|
284
|
+
)
|
|
285
|
+
unwrap!(added, "cartLinesAdd")
|
|
286
|
+
end
|
|
287
|
+
next cart_node if discount_codes.nil?
|
|
288
|
+
|
|
289
|
+
apply_discount_codes(cart_id, discount_codes)
|
|
290
|
+
end
|
|
270
291
|
end
|
|
271
292
|
|
|
272
293
|
def apply_discount_codes(cart_id, discount_codes)
|
|
@@ -279,19 +300,17 @@ module Portage
|
|
|
279
300
|
# — Shopify natively dedups that one call via SubmitAlreadyAccepted, on
|
|
280
301
|
# top of #complete_checkout's own dedup wrapper.
|
|
281
302
|
def submit_payment(checkout_id, payment_token, idempotency_key)
|
|
282
|
-
|
|
283
|
-
|
|
303
|
+
synchronize(checkout_id) do
|
|
304
|
+
cart_node = fetch_cart_node(checkout_id)
|
|
305
|
+
raise Portage::Ucp::Shopify::Error, "cart #{checkout_id} not found" unless cart_node
|
|
284
306
|
|
|
285
|
-
|
|
307
|
+
raise_if_any_line_unavailable!(cart_node)
|
|
286
308
|
|
|
287
|
-
|
|
309
|
+
pay_for_cart(checkout_id, cart_node.dig("cost", "totalAmount"), payment_token)
|
|
288
310
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
record_checkout_status(checkout_id, status)
|
|
293
|
-
order = link_cart_to_order(checkout_id) if status == "completed"
|
|
294
|
-
Mapper.checkout(cart_node, status: status, order: order)
|
|
311
|
+
status, order = poll_submission(checkout_id, idempotency_key)
|
|
312
|
+
Mapper.checkout(cart_node, status: status, order: order)
|
|
313
|
+
end
|
|
295
314
|
end
|
|
296
315
|
|
|
297
316
|
def pay_for_cart(checkout_id, total, payment_token)
|
|
@@ -304,16 +323,14 @@ module Portage
|
|
|
304
323
|
unwrap!(payment_data, "cartPaymentUpdate")
|
|
305
324
|
end
|
|
306
325
|
|
|
307
|
-
# Best-effort:
|
|
308
|
-
#
|
|
309
|
-
#
|
|
310
|
-
#
|
|
311
|
-
#
|
|
312
|
-
#
|
|
313
|
-
#
|
|
314
|
-
#
|
|
315
|
-
# place that can hand the freshly-created order id back to
|
|
316
|
-
# #complete_checkout's own caller — nothing else surfaces it.
|
|
326
|
+
# Best-effort: only called once `status` is "completed" (#poll_submission
|
|
327
|
+
# already retried through any SubmitThrottled), but if the order search
|
|
328
|
+
# index hasn't caught up yet even so, this silently finds nothing
|
|
329
|
+
# (returns nil, #get_order's checkout_id stays blank) rather than
|
|
330
|
+
# raising, matching the "not information-complete but schema-valid"
|
|
331
|
+
# posture used elsewhere in this file. Also the only place that can
|
|
332
|
+
# hand the freshly-created order id back to #complete_checkout's own
|
|
333
|
+
# caller — nothing else surfaces it.
|
|
317
334
|
def link_cart_to_order(checkout_id)
|
|
318
335
|
token = checkout_id[%r{Cart/([^?]+)}, 1]
|
|
319
336
|
return unless token
|
|
@@ -326,6 +343,29 @@ module Portage
|
|
|
326
343
|
Portage::Ucp::OrderConfirmation.new(id: order_node["id"], permalink_url: order_node["statusPageUrl"])
|
|
327
344
|
end
|
|
328
345
|
|
|
346
|
+
# Retries a SubmitThrottled cartSubmitForCompletion up to
|
|
347
|
+
# SUBMIT_POLL_MAX_ATTEMPTS, honoring each attempt's own pollAfter as
|
|
348
|
+
# the wait (see SubmitThrottled#retry_after). If Shopify is still
|
|
349
|
+
# throttled once attempts run out, that's surfaced to the caller as
|
|
350
|
+
# Portage::Ucp::UpstreamThrottledError rather than silently handing
|
|
351
|
+
# back an ambiguous "complete_in_progress" forever.
|
|
352
|
+
def poll_submission(checkout_id, idempotency_key)
|
|
353
|
+
with_retry(max_attempts: SUBMIT_POLL_MAX_ATTEMPTS) do
|
|
354
|
+
submit_data = @client.storefront_query(Queries::CART_SUBMIT_FOR_COMPLETION,
|
|
355
|
+
variables: { cartId: checkout_id, attemptToken: idempotency_key })
|
|
356
|
+
status = unwrap_submit!(submit_data)
|
|
357
|
+
record_checkout_status(checkout_id, status)
|
|
358
|
+
order = link_cart_to_order(checkout_id) if status == "completed"
|
|
359
|
+
[status, order]
|
|
360
|
+
end
|
|
361
|
+
rescue Portage::Ucp::Shopify::SubmitThrottled
|
|
362
|
+
raise Portage::Ucp::UpstreamThrottledError, "checkout #{checkout_id} still processing after retries"
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def retryable_error?(error)
|
|
366
|
+
error.is_a?(Portage::Ucp::Shopify::SubmitThrottled) || super
|
|
367
|
+
end
|
|
368
|
+
|
|
329
369
|
def unwrap!(data, field)
|
|
330
370
|
payload = data.fetch(field)
|
|
331
371
|
errors = payload["userErrors"]
|
|
@@ -358,8 +398,9 @@ module Portage
|
|
|
358
398
|
|
|
359
399
|
result = payload["result"]
|
|
360
400
|
raise Portage::Ucp::Shopify::Error, result.dig("errors", 0, "message") if result["errors"]
|
|
401
|
+
raise Portage::Ucp::Shopify::SubmitThrottled, result["pollAfter"] if result.key?("pollAfter")
|
|
361
402
|
|
|
362
|
-
|
|
403
|
+
"completed"
|
|
363
404
|
end
|
|
364
405
|
end
|
|
365
406
|
end
|
|
@@ -19,6 +19,12 @@ module Portage
|
|
|
19
19
|
# arbitrary order). The adapter picks whichever API actually has the
|
|
20
20
|
# data it needs (see Portage::Ucp::Shopify::Adapter).
|
|
21
21
|
class Client
|
|
22
|
+
# Retries a bare THROTTLED GraphQL response or a transport 5xx —
|
|
23
|
+
# safe here because every Adapter mutation calling into this Client
|
|
24
|
+
# is already wrapped in Support::Idempotency#dedup, and every read
|
|
25
|
+
# is naturally idempotent (see Support::Retry's own doc comment).
|
|
26
|
+
include Portage::Ucp::Support::Retry
|
|
27
|
+
|
|
22
28
|
DEFAULT_API_VERSION = "2026-04".freeze
|
|
23
29
|
|
|
24
30
|
def initialize(shop_domain:, admin_access_token: nil, storefront_access_token: nil,
|
|
@@ -31,15 +37,19 @@ module Portage
|
|
|
31
37
|
|
|
32
38
|
def admin_query(query, variables: {})
|
|
33
39
|
require_token!(@admin_access_token, "admin_access_token")
|
|
34
|
-
|
|
35
|
-
|
|
40
|
+
with_retry do
|
|
41
|
+
post("/admin/api/#{@api_version}/graphql.json",
|
|
42
|
+
headers: { "X-Shopify-Access-Token" => @admin_access_token }, query: query, variables: variables)
|
|
43
|
+
end
|
|
36
44
|
end
|
|
37
45
|
|
|
38
46
|
def storefront_query(query, variables: {})
|
|
39
47
|
require_token!(@storefront_access_token, "storefront_access_token")
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
48
|
+
with_retry do
|
|
49
|
+
post("/api/#{@api_version}/graphql.json",
|
|
50
|
+
headers: { "X-Shopify-Storefront-Access-Token" => @storefront_access_token },
|
|
51
|
+
query: query, variables: variables)
|
|
52
|
+
end
|
|
43
53
|
end
|
|
44
54
|
|
|
45
55
|
private
|
|
@@ -48,6 +58,18 @@ module Portage
|
|
|
48
58
|
raise ArgumentError, "Portage::Ucp::Shopify::Client requires #{name} for this call" unless token
|
|
49
59
|
end
|
|
50
60
|
|
|
61
|
+
# Shopify's GraphQL THROTTLED code has no HTTP status of its own, and
|
|
62
|
+
# a transport 5xx here has no well-formed userErrors/GraphQL body to
|
|
63
|
+
# fall back on — Support::Retry's default `#status`-based check
|
|
64
|
+
# covers neither, so this hands both to it explicitly.
|
|
65
|
+
def retryable_error?(error)
|
|
66
|
+
case error
|
|
67
|
+
when Portage::Ucp::Shopify::ServerError then true
|
|
68
|
+
when Portage::Ucp::Shopify::GraphqlError then error.throttled?
|
|
69
|
+
else super
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
51
73
|
def post(path, headers:, query:, variables:)
|
|
52
74
|
uri = URI("https://#{@shop_domain}#{path}")
|
|
53
75
|
request = Net::HTTP::Post.new(uri)
|
|
@@ -56,6 +78,9 @@ module Portage
|
|
|
56
78
|
request.body = JSON.generate({ query: query, variables: variables })
|
|
57
79
|
|
|
58
80
|
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
|
|
81
|
+
status = response.code.to_i
|
|
82
|
+
raise Portage::Ucp::Shopify::ServerError, status if status >= 500
|
|
83
|
+
|
|
59
84
|
body = JSON.parse(response.body)
|
|
60
85
|
raise Portage::Ucp::Shopify::GraphqlError, body["errors"] if body["errors"]
|
|
61
86
|
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Ucp
|
|
3
|
+
module Shopify
|
|
4
|
+
# Shopify-specific config, deliberately separate from core's own
|
|
5
|
+
# Portage::Ucp::Configuration (registry/authenticator/rate_limiter/...,
|
|
6
|
+
# already adapter-agnostic — see portage-ucp/lib/portage/ucp/configuration.rb).
|
|
7
|
+
# `Portage::Ucp::Shopify.configuration.metadata_fields` living on this
|
|
8
|
+
# singleton instead is what keeps Mapper's "nothing Shopify-shaped leaks
|
|
9
|
+
# past this file" posture intact: a Wix/WooCommerce consumer configuring
|
|
10
|
+
# their own adapter never sees Shopify's metafield config surface.
|
|
11
|
+
class Configuration
|
|
12
|
+
# Shopify caps `metafields(identifiers:)` at 250 identifiers per call
|
|
13
|
+
# (design-log §20). Enforced here rather than left to a confusing
|
|
14
|
+
# GraphQL cost-limit rejection from Shopify itself.
|
|
15
|
+
MAX_METAFIELD_IDENTIFIERS = 250
|
|
16
|
+
|
|
17
|
+
class InvalidMetadataField < ArgumentError; end
|
|
18
|
+
|
|
19
|
+
def initialize
|
|
20
|
+
@product_metadata_fields = []
|
|
21
|
+
@variant_metadata_fields = []
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Registers a merchant-defined metafield as a Product/Variant#metadata
|
|
25
|
+
# entry. `metafield:` is "namespace.key" (Shopify's own addressing,
|
|
26
|
+
# e.g. "custom.color_code"). `scope:` picks which GraphQL field
|
|
27
|
+
# (Product#metafields vs. ProductVariant#metafields — separate fields,
|
|
28
|
+
# separate cost, per Shopify's Admin API) the identifier is fetched
|
|
29
|
+
# through, since a real catalog needs both (color_hex naturally varies
|
|
30
|
+
# per variant, fabric_content is usually product-wide).
|
|
31
|
+
def metadata_field(key, metafield:, scope: :product)
|
|
32
|
+
namespace, metafield_key = metafield.split(".", 2)
|
|
33
|
+
unless metafield_key
|
|
34
|
+
raise InvalidMetadataField, "metafield: must be \"namespace.key\", got #{metafield.inspect}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
fields = fields_for(scope)
|
|
38
|
+
if fields.size >= MAX_METAFIELD_IDENTIFIERS
|
|
39
|
+
raise InvalidMetadataField,
|
|
40
|
+
"#{scope} metadata_fields already at Shopify's #{MAX_METAFIELD_IDENTIFIERS}-identifier " \
|
|
41
|
+
"metafields(identifiers:) cap — drop one before adding #{key.inspect}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
fields << { key: key.to_s, namespace: namespace, metafield_key: metafield_key }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Read by Queries (to build the metafields fragment) and Mapper (to
|
|
48
|
+
# know which response entry maps to which UCP key) — both index by
|
|
49
|
+
# scope, not by name, so this is the one shared accessor.
|
|
50
|
+
def fields_for(scope)
|
|
51
|
+
case scope
|
|
52
|
+
when :product then @product_metadata_fields
|
|
53
|
+
when :variant then @variant_metadata_fields
|
|
54
|
+
else raise InvalidMetadataField, "scope: must be :product or :variant, got #{scope.inspect}"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
class << self
|
|
60
|
+
def configuration
|
|
61
|
+
@configuration ||= Configuration.new
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def configure
|
|
65
|
+
yield configuration
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require "time"
|
|
2
|
+
|
|
1
3
|
module Portage
|
|
2
4
|
module Ucp
|
|
3
5
|
module Shopify
|
|
@@ -9,8 +11,16 @@ module Portage
|
|
|
9
11
|
# rejection (e.g. "line item not found").
|
|
10
12
|
class GraphqlError < Error
|
|
11
13
|
def initialize(errors)
|
|
14
|
+
@errors = errors
|
|
12
15
|
super(errors.map { |e| e["message"] }.join("; "))
|
|
13
16
|
end
|
|
17
|
+
|
|
18
|
+
# Shopify's cost-throttling reports THROTTLED as a top-level error's
|
|
19
|
+
# extensions.code, not an HTTP status — Client#retryable_error? uses
|
|
20
|
+
# this to hand it to Support::Retry alongside 429/5xx.
|
|
21
|
+
def throttled?
|
|
22
|
+
@errors.any? { |e| e.dig("extensions", "code") == "THROTTLED" }
|
|
23
|
+
end
|
|
14
24
|
end
|
|
15
25
|
|
|
16
26
|
# Raised when a mutation's `userErrors` array is non-empty.
|
|
@@ -19,6 +29,39 @@ module Portage
|
|
|
19
29
|
super("#{field} userErrors: #{errors.map { |e| e['message'] }.join('; ')}")
|
|
20
30
|
end
|
|
21
31
|
end
|
|
32
|
+
|
|
33
|
+
# Raised for a bare 5xx HTTP status from either GraphQL endpoint —
|
|
34
|
+
# unlike GraphqlError/UserError, there's no well-formed JSON body to
|
|
35
|
+
# read a message from at this layer. Retryable by
|
|
36
|
+
# Client#retryable_error?, same as any other platform's transport 5xx.
|
|
37
|
+
class ServerError < Error
|
|
38
|
+
attr_reader :status
|
|
39
|
+
|
|
40
|
+
def initialize(status)
|
|
41
|
+
@status = status
|
|
42
|
+
super("Shopify API server error (#{status})")
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Raised when cartSubmitForCompletion answers SubmitThrottled —
|
|
47
|
+
# Shopify is still processing the submission and says to poll again
|
|
48
|
+
# after `poll_after`. Adapter#poll_submission retries on this (see
|
|
49
|
+
# Support::Retry) instead of handing an ambiguous
|
|
50
|
+
# "complete_in_progress" straight back to the caller; if retries
|
|
51
|
+
# exhaust while Shopify is still throttled, it's re-raised as
|
|
52
|
+
# Portage::Ucp::UpstreamThrottledError.
|
|
53
|
+
class SubmitThrottled < Error
|
|
54
|
+
attr_reader :poll_after
|
|
55
|
+
|
|
56
|
+
def initialize(poll_after)
|
|
57
|
+
@poll_after = poll_after
|
|
58
|
+
super("cartSubmitForCompletion still processing, poll after #{poll_after}")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def retry_after
|
|
62
|
+
[Time.parse(poll_after) - Time.now, 0].max
|
|
63
|
+
end
|
|
64
|
+
end
|
|
22
65
|
end
|
|
23
66
|
end
|
|
24
67
|
end
|
|
@@ -21,20 +21,153 @@ module Portage
|
|
|
21
21
|
Portage::Ucp::Support::Amounts.decimal_to_minor(price["amount"])
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
+
# dev.ucp.shopping.catalog's Price (types/price.json) — the
|
|
25
|
+
# wire-shape counterpart to #money above, used everywhere a Product/
|
|
26
|
+
# Variant field carries currency directly rather than through the
|
|
27
|
+
# arithmetic-only Money type.
|
|
28
|
+
def price(node)
|
|
29
|
+
Portage::Ucp::Price.new(amount: minor_units(node), currency: node["currencyCode"])
|
|
30
|
+
end
|
|
31
|
+
|
|
24
32
|
def product(node)
|
|
33
|
+
currency = node.dig("priceRange", "minVariantPrice", "currencyCode")
|
|
25
34
|
Portage::Ucp::Product.new(
|
|
26
35
|
id: node["id"],
|
|
36
|
+
handle: node["handle"],
|
|
27
37
|
title: node["title"],
|
|
28
|
-
description: node
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
variants: node.dig("variants", "nodes").map { |v| variant(v) },
|
|
32
|
-
|
|
38
|
+
description: description(node),
|
|
39
|
+
price_range: price_range(node["priceRange"]),
|
|
40
|
+
list_price_range: compare_at_price_range(node["compareAtPriceRange"]),
|
|
41
|
+
variants: node.dig("variants", "nodes").map { |v| variant(v, description(node), currency) },
|
|
42
|
+
options: (node["options"] || []).map { |o| product_option(o) },
|
|
43
|
+
media: product_media(node.dig("featuredMedia", "nodes", 0)),
|
|
44
|
+
tags: node["tags"] || [],
|
|
45
|
+
url: node["onlineStoreUrl"],
|
|
46
|
+
metadata: metafields_metadata(node["metafields"], :product)
|
|
33
47
|
)
|
|
34
48
|
end
|
|
35
49
|
|
|
36
|
-
|
|
37
|
-
|
|
50
|
+
# Shopify's `type` tags a metafield with one of ~20 encodings
|
|
51
|
+
# (design-log §20 handoff). Only the ones where passing the raw
|
|
52
|
+
# string through would be actively wrong for an agent to consume get
|
|
53
|
+
# parsed here — `json`, `dimension`/`measurement` (both
|
|
54
|
+
# `{"value":..,"unit":..}`-shaped JSON strings), and any `list.*`
|
|
55
|
+
# (a JSON array of the scalar type). Everything else (plain text,
|
|
56
|
+
# numbers, dates, refs, ...) passes through as Shopify sent it.
|
|
57
|
+
METAFIELD_JSON_TYPES = %w[json dimension measurement].freeze
|
|
58
|
+
|
|
59
|
+
def parse_metafield_value(value, type)
|
|
60
|
+
return JSON.parse(value) if METAFIELD_JSON_TYPES.include?(type) || type&.start_with?("list.")
|
|
61
|
+
|
|
62
|
+
value
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# `metafields` is nil when nothing's configured for `scope` (the
|
|
66
|
+
# fragment itself was omitted from the query — see
|
|
67
|
+
# Queries.metafields_fragment) — that's the common case and returns
|
|
68
|
+
# nil rather than {}, matching Product/Variant#metadata's own
|
|
69
|
+
# nil-by-default. Otherwise it's Shopify's `metafields(identifiers:)`
|
|
70
|
+
# array, positional against the same
|
|
71
|
+
# `Portage::Ucp::Shopify.configuration.fields_for(scope)` list the
|
|
72
|
+
# query was built from, with a nil entry wherever that metafield
|
|
73
|
+
# isn't set on this product/variant.
|
|
74
|
+
def metafields_metadata(metafields, scope)
|
|
75
|
+
return nil if metafields.nil?
|
|
76
|
+
|
|
77
|
+
fields = Portage::Ucp::Shopify.configuration.fields_for(scope)
|
|
78
|
+
metadata = fields.zip(metafields).each_with_object({}) do |(field, metafield), acc|
|
|
79
|
+
next unless field && metafield && metafield["value"]
|
|
80
|
+
|
|
81
|
+
acc[field[:key]] = parse_metafield_value(metafield["value"], metafield["type"])
|
|
82
|
+
end
|
|
83
|
+
metadata.empty? ? nil : metadata
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def description(node)
|
|
87
|
+
Portage::Ucp::Description.new(plain: node["description"], html: node["descriptionHtml"])
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def price_range(node)
|
|
91
|
+
Portage::Ucp::PriceRange.new(min: price(node["minVariantPrice"]), max: price(node["maxVariantPrice"]))
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# nil when Shopify's compareAtPriceRange itself is nil (no variant
|
|
95
|
+
# has a compare-at price set) rather than a zeroed-out range —
|
|
96
|
+
# Product#list_price_range is optional, so "no strikethrough price"
|
|
97
|
+
# should mean the field is absent, not present-and-zero.
|
|
98
|
+
def compare_at_price_range(node)
|
|
99
|
+
return nil unless node
|
|
100
|
+
|
|
101
|
+
Portage::Ucp::PriceRange.new(min: price(node["minVariantCompareAtPrice"]),
|
|
102
|
+
max: price(node["maxVariantCompareAtPrice"]))
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def product_option(node)
|
|
106
|
+
values = (node["optionValues"] || []).map { |v| Portage::Ucp::OptionValue.new(id: v["id"], label: v["name"]) }
|
|
107
|
+
Portage::Ucp::ProductOption.new(name: node["name"], values: values)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def product_media(node)
|
|
111
|
+
image = node && node["image"]
|
|
112
|
+
return [] unless image
|
|
113
|
+
|
|
114
|
+
[Portage::Ucp::Media.new(type: "image", url: image["url"], alt_text: image["altText"],
|
|
115
|
+
width: image["width"], height: image["height"])]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# `barcode` is a single untyped Shopify string with no declared
|
|
119
|
+
# standard — inferred from length rather than asserted, since
|
|
120
|
+
# Shopify doesn't tag which GS1 standard (UPC-A/EAN-13/EAN-8) a
|
|
121
|
+
# given value follows. Anything else (an internal SKU-shaped
|
|
122
|
+
# barcode, a non-numeric value) is passed through as bare "GTIN"
|
|
123
|
+
# rather than dropped, so the field still reaches the agent.
|
|
124
|
+
BARCODE_TYPES = { 8 => "EAN", 12 => "UPC", 13 => "EAN" }.freeze
|
|
125
|
+
|
|
126
|
+
def barcodes(value)
|
|
127
|
+
return [] if value.nil? || value.empty?
|
|
128
|
+
|
|
129
|
+
[{ "type" => BARCODE_TYPES.fetch(value.length, "GTIN"), "value" => value }]
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# `product_description` is the parent Product's own Description —
|
|
133
|
+
# Shopify's ProductVariant has no description field of its own, and
|
|
134
|
+
# types/variant.json requires one, so this reuses the product's, same
|
|
135
|
+
# posture as Wix's variant title falling back to its product's (see
|
|
136
|
+
# Portage::Ucp::Wix::Mapper#variant).
|
|
137
|
+
# `price`/`compareAtPrice` come back as the bare `Money` scalar (a
|
|
138
|
+
# decimal string, no currency of its own) rather than a MoneyV2
|
|
139
|
+
# object — confirmed against the live Admin API 2026-04 schema, which
|
|
140
|
+
# rejects `{ amount currencyCode }` sub-selections on them. `currency`
|
|
141
|
+
# is the product's own (from priceRange), which every variant shares.
|
|
142
|
+
def scalar_price(amount, currency)
|
|
143
|
+
Portage::Ucp::Price.new(amount: Portage::Ucp::Support::Amounts.decimal_to_minor(amount), currency: currency)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def variant(node, product_description, currency)
|
|
147
|
+
Portage::Ucp::Variant.new(
|
|
148
|
+
id: node["id"],
|
|
149
|
+
title: node["title"],
|
|
150
|
+
description: product_description,
|
|
151
|
+
price: scalar_price(node["price"], currency),
|
|
152
|
+
sku: node["sku"],
|
|
153
|
+
barcodes: barcodes(node["barcode"]),
|
|
154
|
+
list_price: node["compareAtPrice"] && scalar_price(node["compareAtPrice"], currency),
|
|
155
|
+
availability: { "available" => node["availableForSale"] },
|
|
156
|
+
options: selected_options(node["selectedOptions"]),
|
|
157
|
+
media: variant_media(node["image"]),
|
|
158
|
+
metadata: metafields_metadata(node["metafields"], :variant)
|
|
159
|
+
)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def selected_options(nodes)
|
|
163
|
+
(nodes || []).map { |o| Portage::Ucp::SelectedOption.new(name: o["name"], label: o["value"]) }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def variant_media(image)
|
|
167
|
+
return [] unless image
|
|
168
|
+
|
|
169
|
+
[Portage::Ucp::Media.new(type: "image", url: image["url"], alt_text: image["altText"],
|
|
170
|
+
width: image["width"], height: image["height"])]
|
|
38
171
|
end
|
|
39
172
|
|
|
40
173
|
def cart(node)
|
|
@@ -106,7 +239,7 @@ module Portage
|
|
|
106
239
|
# modeled here), so every deliveryGroup collapses into a single
|
|
107
240
|
# synthesized FulfillmentMethod rather than one per Shopify group.
|
|
108
241
|
def checkout_fulfillment(node)
|
|
109
|
-
groups_nodes = node
|
|
242
|
+
groups_nodes = node.dig("deliveryGroups", "nodes") || []
|
|
110
243
|
return Portage::Ucp::CheckoutFulfillment.new if groups_nodes.empty?
|
|
111
244
|
|
|
112
245
|
groups = groups_nodes.map { |g| fulfillment_group(g) }
|
|
@@ -317,10 +450,16 @@ module Portage
|
|
|
317
450
|
end
|
|
318
451
|
|
|
319
452
|
# Builds the top-level totals array from Shopify's cost breakdown.
|
|
453
|
+
# `totalTaxAmount` is nullable on a real cart — Shopify doesn't
|
|
454
|
+
# compute tax until it has enough context (a shipping address, a
|
|
455
|
+
# tax-registered market), so a fresh cart genuinely has no tax
|
|
456
|
+
# amount yet rather than a zero one. Confirmed live against
|
|
457
|
+
# ucp-test-bc2vif1p.myshopify.com (design-log §17).
|
|
320
458
|
def totals(node)
|
|
321
459
|
cost = node["cost"]
|
|
460
|
+
tax = cost["totalTaxAmount"]
|
|
322
461
|
Portage::Ucp::Support::Totals.summary(subtotal: minor_units(cost["subtotalAmount"]),
|
|
323
|
-
tax: minor_units(
|
|
462
|
+
tax: tax ? minor_units(tax) : 0,
|
|
324
463
|
total: minor_units(cost["totalAmount"]))
|
|
325
464
|
end
|
|
326
465
|
end
|
|
@@ -5,29 +5,83 @@ module Portage
|
|
|
5
5
|
# and the response mapping (Mapper) can each be read and tested on their
|
|
6
6
|
# own.
|
|
7
7
|
module Queries
|
|
8
|
-
|
|
8
|
+
# `descriptionHtml` sourced alongside plain `description` — UCP's
|
|
9
|
+
# description.json accepts both, and Storefront sanitizes its own
|
|
10
|
+
# HTML output, satisfying the schema's "platforms MUST sanitize"
|
|
11
|
+
# note on the read side without this gem doing its own pass.
|
|
12
|
+
# `options`/`selectedOptions` and `sku`/`barcode` back the
|
|
13
|
+
# dev.ucp.shopping.catalog structured-attribute gap (design-log
|
|
14
|
+
# §20): GS1 identifiers and Size/Color axes as real fields instead
|
|
15
|
+
# of chaotic HTML the agent has to parse itself.
|
|
16
|
+
# `%<product_metafields>s`/`%<variant_metafields>s` carry whatever
|
|
17
|
+
# Portage::Ucp::Shopify.configuration.metadata_field entries a
|
|
18
|
+
# consumer has registered (design-log §20's metadata_field DSL).
|
|
19
|
+
# Kept as a template rather than the frozen constant the rest of this
|
|
20
|
+
# file uses: configured metafield identifiers aren't known until
|
|
21
|
+
# `Shopify.configure` runs, which happens after this file loads, so
|
|
22
|
+
# the fragment has to be built per call — see .product_fields below.
|
|
23
|
+
PRODUCT_FIELDS_TEMPLATE = <<~GRAPHQL.freeze
|
|
9
24
|
id
|
|
25
|
+
handle
|
|
10
26
|
title
|
|
11
27
|
description
|
|
28
|
+
descriptionHtml
|
|
12
29
|
onlineStoreUrl
|
|
13
|
-
|
|
14
|
-
priceRange { minVariantPrice { amount currencyCode } }
|
|
30
|
+
tags
|
|
31
|
+
priceRange { minVariantPrice { amount currencyCode } maxVariantPrice { amount currencyCode } }
|
|
32
|
+
compareAtPriceRange {
|
|
33
|
+
minVariantCompareAtPrice { amount currencyCode }
|
|
34
|
+
maxVariantCompareAtPrice { amount currencyCode }
|
|
35
|
+
}
|
|
36
|
+
featuredMedia: media(first: 1) { nodes { ... on MediaImage { image { url altText width height } } } }
|
|
37
|
+
options(first: 10) { name optionValues { id name } }
|
|
38
|
+
%<product_metafields>s
|
|
15
39
|
variants(first: 25) {
|
|
16
|
-
nodes {
|
|
40
|
+
nodes {
|
|
41
|
+
id title availableForSale sku barcode
|
|
42
|
+
price
|
|
43
|
+
compareAtPrice
|
|
44
|
+
selectedOptions { name value }
|
|
45
|
+
image { url altText width height }
|
|
46
|
+
%<variant_metafields>s
|
|
47
|
+
}
|
|
17
48
|
}
|
|
18
49
|
GRAPHQL
|
|
19
50
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
51
|
+
# Product-level and variant-level metafields are separate GraphQL
|
|
52
|
+
# fields with separate cost on Shopify's Admin API (Product#metafields
|
|
53
|
+
# vs. ProductVariant#metafields) — `scope` picks which configured list
|
|
54
|
+
# to build the identifiers from. Empty when nothing's configured for
|
|
55
|
+
# that scope, so an unconfigured consumer's query is byte-identical to
|
|
56
|
+
# before this fragment existed.
|
|
57
|
+
def self.metafields_fragment(scope)
|
|
58
|
+
fields = Portage::Ucp::Shopify.configuration.fields_for(scope)
|
|
59
|
+
return "" if fields.empty?
|
|
25
60
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
61
|
+
identifiers = fields.map { |f| %({namespace: "#{f[:namespace]}", key: "#{f[:metafield_key]}"}) }.join(", ")
|
|
62
|
+
"metafields(identifiers: [#{identifiers}]) { key namespace value type }"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.product_fields
|
|
66
|
+
format(PRODUCT_FIELDS_TEMPLATE, product_metafields: metafields_fragment(:product),
|
|
67
|
+
variant_metafields: metafields_fragment(:variant))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.search_catalog_query
|
|
71
|
+
<<~GRAPHQL
|
|
72
|
+
query SearchCatalog($query: String!, $first: Int!) {
|
|
73
|
+
products(query: $query, first: $first) { nodes { #{product_fields} } }
|
|
74
|
+
}
|
|
75
|
+
GRAPHQL
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.product_by_id_query
|
|
79
|
+
<<~GRAPHQL
|
|
80
|
+
query GetProduct($id: ID!) {
|
|
81
|
+
product(id: $id) { #{product_fields} }
|
|
82
|
+
}
|
|
83
|
+
GRAPHQL
|
|
84
|
+
end
|
|
31
85
|
|
|
32
86
|
CART_FIELDS = <<~GRAPHQL.freeze
|
|
33
87
|
id
|
|
@@ -48,13 +102,15 @@ module Portage
|
|
|
48
102
|
}
|
|
49
103
|
}
|
|
50
104
|
}
|
|
51
|
-
deliveryGroups {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
105
|
+
deliveryGroups(first: 10) {
|
|
106
|
+
nodes {
|
|
107
|
+
id
|
|
108
|
+
cartLines(first: 100) { nodes { id } }
|
|
109
|
+
deliveryOptions { handle title description deliveryMethodType estimatedCost { amount currencyCode } }
|
|
110
|
+
selectedDeliveryOption { handle }
|
|
111
|
+
deliveryAddress { address1 address2 city provinceCode zip firstName lastName phone
|
|
112
|
+
countryCode: countryCodeV2 }
|
|
113
|
+
}
|
|
58
114
|
}
|
|
59
115
|
discountCodes { code applicable }
|
|
60
116
|
discountAllocations {
|
data/lib/portage/ucp/shopify.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: portage-ucp-shopify
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tom Whitbread
|
|
@@ -15,14 +15,14 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - "~>"
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: '0.
|
|
18
|
+
version: '0.3'
|
|
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: '0.
|
|
25
|
+
version: '0.3'
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
27
|
name: rake
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -109,6 +109,7 @@ files:
|
|
|
109
109
|
- lib/portage/ucp/shopify/access_token_fetcher.rb
|
|
110
110
|
- lib/portage/ucp/shopify/adapter.rb
|
|
111
111
|
- lib/portage/ucp/shopify/client.rb
|
|
112
|
+
- lib/portage/ucp/shopify/configuration.rb
|
|
112
113
|
- lib/portage/ucp/shopify/errors.rb
|
|
113
114
|
- lib/portage/ucp/shopify/mapper.rb
|
|
114
115
|
- lib/portage/ucp/shopify/queries.rb
|
|
@@ -134,7 +135,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
134
135
|
- !ruby/object:Gem::Version
|
|
135
136
|
version: '0'
|
|
136
137
|
requirements: []
|
|
137
|
-
rubygems_version:
|
|
138
|
+
rubygems_version: 3.6.9
|
|
138
139
|
specification_version: 4
|
|
139
140
|
summary: Shopify adapter for portage-ucp — standard catalog/cart/checkout/order over
|
|
140
141
|
MCP and UCP
|