portage-ucp 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 +48 -0
- data/lib/portage/ucp/adapter.rb +4 -2
- data/lib/portage/ucp/errors.rb +22 -0
- data/lib/portage/ucp/reference_adapter.rb +282 -0
- data/lib/portage/ucp/rspec.rb +175 -0
- data/lib/portage/ucp/schema_validator.rb +23 -2
- data/lib/portage/ucp/support/api_error.rb +9 -6
- data/lib/portage/ucp/support/http_client.rb +9 -2
- data/lib/portage/ucp/support/idempotency.rb +22 -3
- data/lib/portage/ucp/support/retry.rb +66 -0
- data/lib/portage/ucp/support/session_lock.rb +40 -0
- data/lib/portage/ucp/value_objects.rb +161 -1
- data/lib/portage/ucp/version.rb +1 -1
- data/lib/portage/ucp/wire_envelope.rb +5 -1
- data/lib/portage/ucp.rb +3 -0
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8ce343c7abccfbe4c1a8cf2ed0db6340d588c770e959fef42aec644ef6a69800
|
|
4
|
+
data.tar.gz: 7d1a96bb2e150b822c1dedbe95b8b3f14fbced7d6be9b5287e120e3e8152059f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0cd44b69ef19d804c91696b6477e3616eb9dd64c326754f5ff7f142ff494bdaf42b1bd4686888dcb1d7c6e6198b737cc501384278a9a6d2441173b6191029eab
|
|
7
|
+
data.tar.gz: 22a096bb7ab8cfaa6285ad12b8c5dfd8098c40f4f538115688e03ad5eac59273f16eca247d1409e28f8f30c730d9ef7cb7c7a5d19586bdacb0c0f002f0b61e9a
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,54 @@ 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
|
+
- `Portage::Ucp::Support::Retry` (`lib/portage/ucp/support/retry.rb`) —
|
|
10
|
+
bounded retry with backoff for adapters, plus normalized conflict/throttle
|
|
11
|
+
errors on `Support::ApiError` so a caller can distinguish "retry this" from
|
|
12
|
+
"don't."
|
|
13
|
+
- `Portage::Ucp::Support::SessionLock` (`lib/portage/ucp/support/session_lock.rb`)
|
|
14
|
+
— serializes per-cart/checkout mutations against a single upstream session,
|
|
15
|
+
used by the Shopify and Wix adapters to stop concurrent cart writes from
|
|
16
|
+
racing the same checkout.
|
|
17
|
+
- `Support::Idempotency` is now thread-safe under concurrent duplicate calls
|
|
18
|
+
— the dedup table write was not atomic, so two requests with the same
|
|
19
|
+
idempotency key arriving together could both miss the cache and both hit
|
|
20
|
+
the adapter.
|
|
21
|
+
- Conformance kit: the repeated-idempotency-key example no longer passes on
|
|
22
|
+
output equality alone. An adapter wired to a fixed-response test double
|
|
23
|
+
returns identical output whether or not it deduped, so the example now also
|
|
24
|
+
asserts the key reached `Support::Idempotency`'s dedup table when the
|
|
25
|
+
adapter includes that module, and `warn`s (rather than silently passing)
|
|
26
|
+
when it doesn't.
|
|
27
|
+
|
|
28
|
+
- `Portage::Ucp::ReferenceAdapter` (`lib/portage/ucp/reference_adapter.rb`) —
|
|
29
|
+
the in-memory `Adapter` roadmap §8 step 1 called for and design-log §17
|
|
30
|
+
flagged as missing outside `spec/support/fake_adapter.rb`, ships with the
|
|
31
|
+
gem now. Implements every capability including
|
|
32
|
+
`discount_codes_supported?`/`fulfillment_supported?`/`link_identity` — the
|
|
33
|
+
first adapter in this repo to back identity linking at all.
|
|
34
|
+
- `Portage::Ucp::RSpec`/`portage/ucp/rspec.rb` — the adapter conformance kit
|
|
35
|
+
design-log §17 called "the missing piece that turns 'any backend that
|
|
36
|
+
implements Adapter' from a README claim into something checked": an
|
|
37
|
+
`it_behaves_like "a portage adapter"` shared-examples suite checking the
|
|
38
|
+
contract's behavioral guarantees (idempotency dedup, the PAN guard,
|
|
39
|
+
schema-conformant wire output, `OutOfStockError` on a stale-stock line) —
|
|
40
|
+
not loaded by `require "portage/ucp"`, opt-in via `require
|
|
41
|
+
"portage/ucp/rspec"` since it pulls in RSpec itself. Exercised against
|
|
42
|
+
`ReferenceAdapter` in this gem's own suite
|
|
43
|
+
(`spec/reference_adapter_conformance_spec.rb`); wired into each adapter
|
|
44
|
+
gem's own spec suite (`spec/portage/ucp/<platform>/conformance_spec.rb`)
|
|
45
|
+
as follow-up.
|
|
46
|
+
- Conformance kit: `existing_variant_id` alongside `existing_product_id`, for
|
|
47
|
+
adapters (Shopify) where a catalog lookup id and a cart line-item id are
|
|
48
|
+
different GIDs. Defaults to `existing_product_id`, so every other adapter
|
|
49
|
+
is unaffected.
|
|
50
|
+
- `search_catalog`/`get_product` output is schema-wrapped like every other
|
|
51
|
+
capability now — previously returned a bare array/`Product` with no
|
|
52
|
+
`to_wire_h`, so the dispatcher's schema-wrap never touched it and nothing
|
|
53
|
+
caught it drifting from `catalog_search.json`/`catalog_lookup.json`.
|
|
54
|
+
|
|
7
55
|
## [0.2.0] - 2026-08-21
|
|
8
56
|
|
|
9
57
|
- `Portage::Ucp::OutOfStockError` — the contract for `#complete_checkout`
|
data/lib/portage/ucp/adapter.rb
CHANGED
|
@@ -4,9 +4,11 @@ module Portage
|
|
|
4
4
|
# Unoverridden methods leave that capability out of the manifest.
|
|
5
5
|
class Adapter
|
|
6
6
|
# --- Catalog (dev.ucp.shopping.catalog) ---
|
|
7
|
-
# @return [
|
|
7
|
+
# @return [Portage::Ucp::CatalogSearchResult]
|
|
8
8
|
def search_catalog(query:, limit:) = not_implemented
|
|
9
|
-
#
|
|
9
|
+
# nil when the product isn't found, same not-found posture as
|
|
10
|
+
# get_cart/get_checkout/get_order.
|
|
11
|
+
# @return [Portage::Ucp::ProductDetail, nil]
|
|
10
12
|
def get_product(product_id:) = not_implemented
|
|
11
13
|
|
|
12
14
|
# --- Cart (dev.ucp.shopping.cart) ---
|
data/lib/portage/ucp/errors.rb
CHANGED
|
@@ -16,5 +16,27 @@ module Portage
|
|
|
16
16
|
# standardized "out_of_stock"/"item_unavailable" error codes
|
|
17
17
|
# (schemas/shopping/types/error_code.json).
|
|
18
18
|
class OutOfStockError < Error; end
|
|
19
|
+
# Raised when a mutation collides with a concurrent write upstream (HTTP
|
|
20
|
+
# 409, or a platform's own optimistic-concurrency rejection) — the
|
|
21
|
+
# resource changed between this call's read and its write. Support::Retry
|
|
22
|
+
# deliberately never retries this itself: the state it read is already
|
|
23
|
+
# stale, so retrying blindly would just collide again. The caller is
|
|
24
|
+
# expected to re-read (get_cart/get_checkout) and resubmit against
|
|
25
|
+
# current state. Maps to UCP's freeform "conflict" error code —
|
|
26
|
+
# error_code.json's examples list isn't exhaustive ("freeform codes are
|
|
27
|
+
# permitted"); "conflict" follows the same snake_case convention as its
|
|
28
|
+
# "out_of_stock"/"payment_failed" examples, same justification
|
|
29
|
+
# OutOfStockError above relies on.
|
|
30
|
+
class ConflictError < Error; end
|
|
31
|
+
# Raised when Support::Retry exhausts its bounded backoff against a
|
|
32
|
+
# genuinely-retryable upstream throttle (Shopify GraphQL THROTTLED, HTTP
|
|
33
|
+
# 429, or cartSubmitForCompletion's SubmitThrottled pollAfter) and the
|
|
34
|
+
# platform still hasn't done the work. Named apart from
|
|
35
|
+
# RateLimitExceededError — that one is this gem's own pluggable
|
|
36
|
+
# RateLimiter rejecting a call before it ever reaches the network; this
|
|
37
|
+
# is the upstream platform itself refusing after every retry. Maps to
|
|
38
|
+
# UCP's freeform "rate_limited" error code, same convention as
|
|
39
|
+
# ConflictError above.
|
|
40
|
+
class UpstreamThrottledError < Error; end
|
|
19
41
|
end
|
|
20
42
|
end
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
|
|
3
|
+
module Portage
|
|
4
|
+
module Ucp
|
|
5
|
+
# In-memory Adapter implementing every capability in the contract,
|
|
6
|
+
# including dev.ucp.shopping.discount/fulfillment/identity — roadmap §8
|
|
7
|
+
# step 1 called for one of these ("no real backend required to prove the
|
|
8
|
+
# protocol layer works") and it never shipped; `spec/support/fake_adapter.rb`
|
|
9
|
+
# filled that gap for the core gem's own specs, but stayed test-only,
|
|
10
|
+
# catalog/cart/checkout/order only, and undocumented outside this repo.
|
|
11
|
+
#
|
|
12
|
+
# Two jobs: a copy-paste starting point for a third-party adapter author
|
|
13
|
+
# (see README "Writing your own adapter"), and the fixture the
|
|
14
|
+
# conformance kit (Portage::Ucp::RSpec, lib/portage/ucp/rspec.rb) runs its
|
|
15
|
+
# own shared examples against to prove the kit itself is correct.
|
|
16
|
+
#
|
|
17
|
+
# Not a mock — every mutating action does the real bookkeeping (line-item
|
|
18
|
+
# totals, checkout status transitions, order adjustments) an adapter over
|
|
19
|
+
# a live platform would, just against an in-process Hash instead of an
|
|
20
|
+
# HTTP API. `seed_product` is the one method with no `Adapter` contract
|
|
21
|
+
# counterpart — there is no real backend to seed real data into.
|
|
22
|
+
class ReferenceAdapter < Portage::Ucp::Adapter
|
|
23
|
+
include Portage::Ucp::Support::Idempotency
|
|
24
|
+
include Portage::Ucp::Support::CheckoutState
|
|
25
|
+
|
|
26
|
+
# Products whose id starts with this prefix are treated as sold out —
|
|
27
|
+
# #complete_checkout raises Portage::Ucp::OutOfStockError for them, the
|
|
28
|
+
# same way a real adapter's stock re-check (Adapter#complete_checkout's
|
|
29
|
+
# docs, design-log §16) would. Lets the conformance kit exercise that
|
|
30
|
+
# path without depending on adapter-specific seed data.
|
|
31
|
+
OUT_OF_STOCK_PREFIX = "oos_".freeze
|
|
32
|
+
|
|
33
|
+
def initialize
|
|
34
|
+
super
|
|
35
|
+
@products = {}
|
|
36
|
+
@carts = {}
|
|
37
|
+
@checkouts = {}
|
|
38
|
+
@orders = {}
|
|
39
|
+
@identities = {}
|
|
40
|
+
@next_id = 0
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Not part of the Adapter contract — this adapter has no backend to
|
|
44
|
+
# seed real data into, so callers (a spec, a README example) hand it
|
|
45
|
+
# catalog fixtures directly.
|
|
46
|
+
def seed_product(product)
|
|
47
|
+
@products[product.id] = product
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def search_catalog(query:, limit:)
|
|
51
|
+
matches = @products.values.select { |p| p.title.downcase.include?(query.downcase) }.first(limit)
|
|
52
|
+
Portage::Ucp::CatalogSearchResult.new(products: matches)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def get_product(product_id:)
|
|
56
|
+
product = @products[product_id]
|
|
57
|
+
product && Portage::Ucp::ProductDetail.new(product: product)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def get_cart(cart_id:) = @carts[cart_id]
|
|
61
|
+
|
|
62
|
+
def create_cart(line_items:, idempotency_key:, discount_codes: nil)
|
|
63
|
+
dedup(idempotency_key) do
|
|
64
|
+
id = next_id("cart")
|
|
65
|
+
@carts[id] = build_cart(id, line_items, discount_codes)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def update_cart(cart_id:, line_items:, idempotency_key:, discount_codes: nil)
|
|
70
|
+
dedup(idempotency_key) do
|
|
71
|
+
@carts[cart_id] = build_cart(cart_id, line_items, discount_codes, previous: @carts[cart_id])
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def cancel_cart(cart_id:, idempotency_key:)
|
|
76
|
+
dedup(idempotency_key) do
|
|
77
|
+
@carts.delete(cart_id)
|
|
78
|
+
Portage::Ucp::Cart.new(id: cart_id, line_items: [], currency: "USD", totals: zero_totals)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def create_checkout(line_items:, idempotency_key:, discount_codes: nil, fulfillment: nil)
|
|
83
|
+
dedup(idempotency_key) do
|
|
84
|
+
id = next_id("chk")
|
|
85
|
+
record_checkout_status(id, "incomplete")
|
|
86
|
+
@checkouts[id] = build_checkout(id, line_items, discount_codes, fulfillment, status: "incomplete")
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def get_checkout(checkout_id:) = @checkouts[checkout_id]
|
|
91
|
+
|
|
92
|
+
def update_checkout(checkout_id:, line_items:, idempotency_key:, discount_codes: nil, fulfillment: nil)
|
|
93
|
+
dedup(idempotency_key) do
|
|
94
|
+
record_checkout_status(checkout_id, "incomplete")
|
|
95
|
+
@checkouts[checkout_id] = build_checkout(checkout_id, line_items, discount_codes, fulfillment,
|
|
96
|
+
status: "incomplete", previous: @checkouts[checkout_id])
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# payment_token: is part of the Adapter contract's call signature (and
|
|
101
|
+
# already validated as non-PAN by PaymentTokenGuard before this ever
|
|
102
|
+
# runs, per §9) but this in-memory adapter has no payment processor to
|
|
103
|
+
# pass it on to — nothing here should hold onto it any longer than the
|
|
104
|
+
# single call needs to, so it's accepted and left unused rather than
|
|
105
|
+
# stored (see .rubocop.yml's Lint/UnusedMethodArgument exclude, same
|
|
106
|
+
# posture as the abstract Adapter#complete_checkout it overrides).
|
|
107
|
+
def complete_checkout(checkout_id:, payment_token:, idempotency_key:)
|
|
108
|
+
dedup(idempotency_key) do
|
|
109
|
+
checkout = @checkouts.fetch(checkout_id)
|
|
110
|
+
raise_if_any_line_out_of_stock!(checkout)
|
|
111
|
+
|
|
112
|
+
order = store_order(checkout)
|
|
113
|
+
confirmation = Portage::Ucp::OrderConfirmation.new(id: order.id, permalink_url: order.permalink_url)
|
|
114
|
+
record_checkout_status(checkout_id, "completed")
|
|
115
|
+
@checkouts[checkout_id] = Portage::Ucp::Checkout.new(**checkout.to_h, status: "completed",
|
|
116
|
+
order: confirmation)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def cancel_checkout(checkout_id:, idempotency_key:)
|
|
121
|
+
dedup(idempotency_key) do
|
|
122
|
+
record_checkout_status(checkout_id, "canceled")
|
|
123
|
+
checkout = @checkouts.fetch(checkout_id)
|
|
124
|
+
@checkouts[checkout_id] = Portage::Ucp::Checkout.new(**checkout.to_h, status: "canceled")
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def get_order(order_id:) = @orders[order_id]
|
|
129
|
+
|
|
130
|
+
def cancel_order(order_id:, idempotency_key:, reason: nil)
|
|
131
|
+
dedup(idempotency_key) do
|
|
132
|
+
add_adjustment(order_id, type: "cancellation", status: "completed", description: reason)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def request_return(order_id:, line_items:, idempotency_key:, reason: nil)
|
|
137
|
+
dedup(idempotency_key) do
|
|
138
|
+
add_adjustment(order_id, type: "return", status: "pending", line_items: line_items, description: reason)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def refund_order(order_id:, line_items:, idempotency_key:, reason: nil)
|
|
143
|
+
dedup(idempotency_key) do
|
|
144
|
+
add_adjustment(order_id, type: "refund", status: "completed", line_items: line_items, description: reason)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def discount_codes_supported? = true
|
|
149
|
+
def fulfillment_supported? = true
|
|
150
|
+
|
|
151
|
+
# Accepts any non-blank token and mints a stable identity for it — real
|
|
152
|
+
# OAuth verification is a platform concern this in-memory adapter has
|
|
153
|
+
# no platform to defer to, same posture as PaymentTokenGuard drawing
|
|
154
|
+
# the line at "rejects the clearest misuse" rather than proving
|
|
155
|
+
# validity.
|
|
156
|
+
def link_identity(oauth_token:)
|
|
157
|
+
raise Portage::Ucp::AuthenticationError, "blank oauth_token" if oauth_token.to_s.empty?
|
|
158
|
+
|
|
159
|
+
@identities[oauth_token] ||= Portage::Ucp::Identity.new(
|
|
160
|
+
subject: "user_#{Digest::SHA256.hexdigest(oauth_token)[0, 12]}",
|
|
161
|
+
email: nil, linked_at: Time.now.utc.iso8601
|
|
162
|
+
)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
private
|
|
166
|
+
|
|
167
|
+
def add_adjustment(order_id, type:, status:, line_items: [], description: nil)
|
|
168
|
+
order = @orders.fetch(order_id)
|
|
169
|
+
adjustment = Portage::Ucp::Adjustment.new(
|
|
170
|
+
id: next_id("adj"), type: type, occurred_at: Time.now.utc.iso8601, status: status,
|
|
171
|
+
line_items: adjustment_line_items(line_items), totals: adjustment_totals(order, line_items),
|
|
172
|
+
description: description
|
|
173
|
+
)
|
|
174
|
+
@orders[order_id] = Portage::Ucp::Order.new(**order.to_h, adjustments: order.adjustments + [adjustment])
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def adjustment_line_items(line_items)
|
|
178
|
+
return nil if line_items.empty?
|
|
179
|
+
|
|
180
|
+
line_items.map { |li| { "id" => li[:id], "quantity" => -li[:quantity] } }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def adjustment_totals(order, line_items)
|
|
184
|
+
return nil if line_items.empty?
|
|
185
|
+
|
|
186
|
+
amount = line_items.sum do |li|
|
|
187
|
+
order_line = order.line_items.find { |oli| oli.id == li[:id] }
|
|
188
|
+
unit_price = order_line.totals.find { |t| t.type == "total" }.amount / order_line.quantity
|
|
189
|
+
unit_price * li[:quantity]
|
|
190
|
+
end
|
|
191
|
+
[Portage::Ucp::Total.new(type: "total", amount: -amount)]
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def store_order(checkout)
|
|
195
|
+
id = next_id("ord")
|
|
196
|
+
order = Portage::Ucp::Order.new(
|
|
197
|
+
id: id, checkout_id: checkout.id, permalink_url: "https://example.com/orders/#{id}",
|
|
198
|
+
line_items: checkout.line_items, fulfillment: Portage::Ucp::Fulfillment.new,
|
|
199
|
+
currency: checkout.currency, totals: checkout.totals
|
|
200
|
+
)
|
|
201
|
+
@orders[id] = order
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def build_cart(id, requested_line_items, discount_codes, previous: nil)
|
|
205
|
+
line_items = build_line_items(requested_line_items)
|
|
206
|
+
discounts = discounts_for(discount_codes, previous&.discounts)
|
|
207
|
+
Portage::Ucp::Cart.new(id: id, line_items: line_items, currency: "USD",
|
|
208
|
+
totals: totals_for(line_items, discounts), discounts: discounts)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def build_checkout(id, requested_line_items, discount_codes, fulfillment, status:, previous: nil)
|
|
212
|
+
line_items = build_line_items(requested_line_items)
|
|
213
|
+
discounts = discounts_for(discount_codes, previous&.discounts)
|
|
214
|
+
Portage::Ucp::Checkout.new(
|
|
215
|
+
id: id, status: status, line_items: line_items, currency: "USD",
|
|
216
|
+
totals: totals_for(line_items, discounts), links: [], discounts: discounts,
|
|
217
|
+
fulfillment: fulfillment || previous&.fulfillment || Portage::Ucp::CheckoutFulfillment.new
|
|
218
|
+
)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Fixed 10% off, applied only for the well-known code "SAVE10" — enough
|
|
222
|
+
# for a conformance spec/adapter author to see a real AppliedDiscount
|
|
223
|
+
# round-trip without inventing a discount engine this adapter has no
|
|
224
|
+
# reason to model in full.
|
|
225
|
+
def discounts_for(discount_codes, previous)
|
|
226
|
+
return previous || Portage::Ucp::Discounts.new if discount_codes.nil?
|
|
227
|
+
|
|
228
|
+
applied = if discount_codes.include?("SAVE10")
|
|
229
|
+
[Portage::Ucp::AppliedDiscount.new(title: "10% off",
|
|
230
|
+
amount: 0, code: "SAVE10")]
|
|
231
|
+
else
|
|
232
|
+
[]
|
|
233
|
+
end
|
|
234
|
+
Portage::Ucp::Discounts.new(codes: discount_codes, applied: applied)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# `req[:product_id]` is looked up against the featured (first) variant
|
|
238
|
+
# — the same variant #search_catalog/#get_product would show as the
|
|
239
|
+
# listing default — since this in-memory adapter's fixtures are
|
|
240
|
+
# single-variant products and Item#id is spec'd as a variant id
|
|
241
|
+
# (types/variant.json: "Used as item.id in checkout"), not a product
|
|
242
|
+
# id.
|
|
243
|
+
def build_line_items(requested)
|
|
244
|
+
requested.map do |req|
|
|
245
|
+
product = @products.fetch(req[:product_id])
|
|
246
|
+
variant = product.variants.first
|
|
247
|
+
total = variant.price.amount * req[:quantity]
|
|
248
|
+
Portage::Ucp::LineItem.new(
|
|
249
|
+
id: next_id("li"),
|
|
250
|
+
item: Portage::Ucp::Item.new(id: variant.id, title: product.title, price: variant.price.amount),
|
|
251
|
+
quantity: req[:quantity],
|
|
252
|
+
totals: [Portage::Ucp::Total.new(type: "subtotal", amount: total),
|
|
253
|
+
Portage::Ucp::Total.new(type: "total", amount: total)]
|
|
254
|
+
)
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def totals_for(line_items, discounts)
|
|
259
|
+
subtotal = line_items.sum { |li| li.totals.find { |t| t.type == "total" }.amount }
|
|
260
|
+
discount_amount = discounts.applied.sum(&:amount)
|
|
261
|
+
[Portage::Ucp::Total.new(type: "subtotal", amount: subtotal),
|
|
262
|
+
Portage::Ucp::Total.new(type: "total", amount: subtotal - discount_amount)]
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def zero_totals
|
|
266
|
+
[Portage::Ucp::Total.new(type: "subtotal", amount: 0), Portage::Ucp::Total.new(type: "total", amount: 0)]
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def raise_if_any_line_out_of_stock!(checkout)
|
|
270
|
+
unavailable = checkout.line_items.select { |li| li.item.id.start_with?(OUT_OF_STOCK_PREFIX) }
|
|
271
|
+
return if unavailable.empty?
|
|
272
|
+
|
|
273
|
+
raise Portage::Ucp::OutOfStockError, "no longer available: #{unavailable.map { |li| li.item.title }.join(', ')}"
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def next_id(prefix)
|
|
277
|
+
@next_id += 1
|
|
278
|
+
"#{prefix}_#{@next_id}"
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
require "portage/ucp"
|
|
2
|
+
|
|
3
|
+
module Portage
|
|
4
|
+
module Ucp
|
|
5
|
+
# Adapter conformance kit — design-log §17: "Nothing currently checks a
|
|
6
|
+
# third-party Adapter against the contract before its capability shows up
|
|
7
|
+
# in a manifest." `SchemaValidator` (see README, "Spec conformance")
|
|
8
|
+
# checks wire *shape*; this checks the behavioral guarantees §9 promises
|
|
9
|
+
# that schema-valid output can still violate — idempotency not actually
|
|
10
|
+
# deduped, a raw PAN reaching the adapter, a capability advertised whose
|
|
11
|
+
# own output doesn't round-trip through the schema it claims to speak.
|
|
12
|
+
#
|
|
13
|
+
# Not loaded by `require "portage/ucp"` — this pulls in RSpec itself,
|
|
14
|
+
# which the core gem otherwise has zero runtime dependency on (only a
|
|
15
|
+
# development one, per its own README). An adapter author's own spec
|
|
16
|
+
# suite opts in explicitly:
|
|
17
|
+
#
|
|
18
|
+
# require "portage/ucp/rspec"
|
|
19
|
+
#
|
|
20
|
+
# RSpec.describe MyAdapter do
|
|
21
|
+
# it_behaves_like "a portage adapter" do
|
|
22
|
+
# let(:adapter) { MyAdapter.new(client: my_test_client) }
|
|
23
|
+
# let(:existing_product_id) { "known-good-product-id" }
|
|
24
|
+
# # optional — only needed when a purchasable line item is a
|
|
25
|
+
# # *different* id than the catalog product id (Shopify: a
|
|
26
|
+
# # ProductVariant GID vs. the parent Product GID). Defaults to
|
|
27
|
+
# # existing_product_id, which is correct for any backend where
|
|
28
|
+
# # "the product" and "the thing you add to a cart" share one id.
|
|
29
|
+
# # let(:existing_variant_id) { "known-good-purchasable-id" }
|
|
30
|
+
# # optional — enables the out-of-stock example:
|
|
31
|
+
# # let(:out_of_stock_product_id) { "known-sold-out-product-id" }
|
|
32
|
+
# end
|
|
33
|
+
# end
|
|
34
|
+
#
|
|
35
|
+
# `existing_product_id` must resolve to a real, in-stock, purchasable
|
|
36
|
+
# product against whatever backend `adapter` is wired to (a live
|
|
37
|
+
# sandbox store, or webmock/VCR stubs — this kit doesn't care which).
|
|
38
|
+
# Every example below skips itself when the adapter under test doesn't
|
|
39
|
+
# advertise the capability it needs, so a catalog/cart-only adapter
|
|
40
|
+
# (§16's Etsy/Instagram shape) still runs the kit cleanly.
|
|
41
|
+
module RSpec
|
|
42
|
+
module_function
|
|
43
|
+
|
|
44
|
+
def advertised?(adapter, capability_name)
|
|
45
|
+
capability = Portage::Ucp::CapabilityRegistry.default.find(capability_name)
|
|
46
|
+
capability&.advertised_for?(adapter)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
RSpec.shared_examples "a portage adapter" do
|
|
53
|
+
let(:dispatcher) { Portage::Ucp::Dispatcher.new(adapter: adapter) }
|
|
54
|
+
let(:schema_validator) { Portage::Ucp::SchemaValidator.new }
|
|
55
|
+
let(:conformance_idempotency_key) { "conformance-#{object_id}-#{rand(1_000_000)}" }
|
|
56
|
+
let(:existing_variant_id) { existing_product_id }
|
|
57
|
+
|
|
58
|
+
def checkout_capability_advertised?
|
|
59
|
+
Portage::Ucp::RSpec.advertised?(adapter, "dev.ucp.shopping.checkout")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def catalog_capability_advertised?
|
|
63
|
+
Portage::Ucp::RSpec.advertised?(adapter, "dev.ucp.shopping.catalog")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def create_conformance_checkout(idempotency_key: conformance_idempotency_key)
|
|
67
|
+
dispatcher.call(
|
|
68
|
+
capability: "dev.ucp.shopping.checkout", action: "create_checkout",
|
|
69
|
+
arguments: { line_items: [{ product_id: existing_variant_id, quantity: 1 }],
|
|
70
|
+
idempotency_key: idempotency_key }
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it "produces a create_checkout response that validates against UCP's own checkout schema" do
|
|
75
|
+
skip "adapter does not advertise dev.ucp.shopping.checkout" unless checkout_capability_advertised?
|
|
76
|
+
|
|
77
|
+
response = create_conformance_checkout
|
|
78
|
+
errors = schema_validator.errors_for("schemas/shopping/checkout.json", response[:structuredContent])
|
|
79
|
+
|
|
80
|
+
expect(errors).to eq([]), "create_checkout's response doesn't validate: #{errors.join('; ')}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The core gem's own dedup table, when the adapter uses it (every bundled
|
|
84
|
+
# adapter includes `Support::Idempotency`). nil for an adapter that dedupes
|
|
85
|
+
# some other way — see the example below for why that costs it a check.
|
|
86
|
+
def conformance_dedup_table
|
|
87
|
+
return nil unless adapter.is_a?(Portage::Ucp::Support::Idempotency)
|
|
88
|
+
|
|
89
|
+
adapter.instance_variable_get(:@idempotency_results)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
it "dedupes a repeated idempotency_key on create_checkout rather than re-running the mutation (§9a)" do
|
|
93
|
+
skip "adapter does not advertise dev.ucp.shopping.checkout" unless checkout_capability_advertised?
|
|
94
|
+
|
|
95
|
+
first = create_conformance_checkout
|
|
96
|
+
second = create_conformance_checkout
|
|
97
|
+
|
|
98
|
+
expect(second[:structuredContent]).to eq(first[:structuredContent]),
|
|
99
|
+
"a repeated idempotency_key produced a different result — " \
|
|
100
|
+
"the adapter isn't deduping mutating calls per §9a"
|
|
101
|
+
|
|
102
|
+
# Equal output is necessary but nowhere near sufficient, and this is the
|
|
103
|
+
# trap §17 named: an adapter wired to webmock/VCR stubs that answer every
|
|
104
|
+
# request with one fixed response returns identical output whether or not
|
|
105
|
+
# it deduped anything, so the assertion above passes for the wrong reason
|
|
106
|
+
# on exactly the test setup an adapter author is most likely to write.
|
|
107
|
+
# When the adapter uses `Support::Idempotency`, check the table itself —
|
|
108
|
+
# an adapter that never deduped has no entry under the key at all.
|
|
109
|
+
table = conformance_dedup_table
|
|
110
|
+
if table.nil?
|
|
111
|
+
warn "[portage conformance] #{adapter.class} doesn't include " \
|
|
112
|
+
"Portage::Ucp::Support::Idempotency, so dedup was only checked by output equality — " \
|
|
113
|
+
"which a fixed-response test double satisfies without deduping. Assert the dedup " \
|
|
114
|
+
"yourself (e.g. `expect(stub).to have_been_requested.once`) in your own spec."
|
|
115
|
+
else
|
|
116
|
+
expect(table).to include(conformance_idempotency_key),
|
|
117
|
+
"create_checkout returned equal output for a repeated idempotency_key but recorded " \
|
|
118
|
+
"nothing in the dedup table — the second call re-ran the mutation and only looked " \
|
|
119
|
+
"deduped because the backend double answers every request identically (§9a)"
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
it "produces a search_catalog response that validates against UCP's own catalog search schema" do
|
|
124
|
+
skip "adapter does not advertise dev.ucp.shopping.catalog" unless catalog_capability_advertised?
|
|
125
|
+
|
|
126
|
+
response = dispatcher.call(capability: "dev.ucp.shopping.catalog", action: "search_catalog",
|
|
127
|
+
arguments: { query: "", limit: 5 })
|
|
128
|
+
errors = schema_validator.errors_for("schemas/shopping/catalog_search.json#/$defs/search_response",
|
|
129
|
+
response[:structuredContent])
|
|
130
|
+
|
|
131
|
+
expect(errors).to eq([]), "search_catalog's response doesn't validate: #{errors.join('; ')}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
it "produces a get_product response that validates against UCP's own catalog lookup schema" do
|
|
135
|
+
skip "adapter does not advertise dev.ucp.shopping.catalog" unless catalog_capability_advertised?
|
|
136
|
+
|
|
137
|
+
response = dispatcher.call(capability: "dev.ucp.shopping.catalog", action: "get_product",
|
|
138
|
+
arguments: { product_id: existing_product_id })
|
|
139
|
+
errors = schema_validator.errors_for("schemas/shopping/catalog_lookup.json#/$defs/get_product_response",
|
|
140
|
+
response[:structuredContent])
|
|
141
|
+
|
|
142
|
+
expect(errors).to eq([]), "get_product's response doesn't validate: #{errors.join('; ')}"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
it "never lets a raw, Luhn-valid PAN reach the adapter's complete_checkout (§9's PCI boundary)" do
|
|
146
|
+
skip "adapter does not advertise dev.ucp.shopping.checkout" unless checkout_capability_advertised?
|
|
147
|
+
|
|
148
|
+
checkout_id = create_conformance_checkout[:structuredContent]["id"]
|
|
149
|
+
expect(adapter).not_to receive(:complete_checkout)
|
|
150
|
+
|
|
151
|
+
expect do
|
|
152
|
+
dispatcher.call(capability: "dev.ucp.shopping.checkout", action: "complete_checkout",
|
|
153
|
+
arguments: { checkout_id: checkout_id, payment_token: "4242424242424242",
|
|
154
|
+
idempotency_key: "#{conformance_idempotency_key}-complete" })
|
|
155
|
+
end.to raise_error(Portage::Ucp::RawPanRejectedError)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
it "raises Portage::Ucp::OutOfStockError from complete_checkout for a line that's gone out of stock" do
|
|
159
|
+
skip "no out_of_stock_product_id given — this example is opt-in" unless respond_to?(:out_of_stock_product_id)
|
|
160
|
+
skip "adapter does not advertise dev.ucp.shopping.checkout" unless checkout_capability_advertised?
|
|
161
|
+
|
|
162
|
+
checkout = dispatcher.call(
|
|
163
|
+
capability: "dev.ucp.shopping.checkout", action: "create_checkout",
|
|
164
|
+
arguments: { line_items: [{ product_id: out_of_stock_product_id, quantity: 1 }],
|
|
165
|
+
idempotency_key: "#{conformance_idempotency_key}-oos" }
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
expect do
|
|
169
|
+
dispatcher.call(capability: "dev.ucp.shopping.checkout", action: "complete_checkout",
|
|
170
|
+
arguments: { checkout_id: checkout[:structuredContent]["id"],
|
|
171
|
+
payment_token: "sptk_conformance_test_token",
|
|
172
|
+
idempotency_key: "#{conformance_idempotency_key}-oos-complete" })
|
|
173
|
+
end.to raise_error(Portage::Ucp::OutOfStockError)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -20,11 +20,17 @@ module Portage
|
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
# @param relative_path [String] e.g. "schemas/shopping/cart.json", matching
|
|
23
|
-
# the vendored path under schemas/<version>/.
|
|
23
|
+
# the vendored path under schemas/<version>/. A path with a
|
|
24
|
+
# `#/$defs/...` fragment (e.g.
|
|
25
|
+
# "schemas/shopping/catalog_search.json#/$defs/search_response")
|
|
26
|
+
# validates against that named subschema instead of the document
|
|
27
|
+
# root — for schemas like catalog_search.json/catalog_lookup.json
|
|
28
|
+
# that define several request/response shapes as siblings under
|
|
29
|
+
# $defs rather than being one schema per file.
|
|
24
30
|
# @return [Array<String>] human-readable validation error messages: empty
|
|
25
31
|
# means `data` conforms.
|
|
26
32
|
def errors_for(relative_path, data)
|
|
27
|
-
schemer = JSONSchemer.schema(
|
|
33
|
+
schemer = JSONSchemer.schema(schema_for(relative_path), ref_resolver: method(:resolve_ref))
|
|
28
34
|
schemer.validate(data).map { |error| JSONSchemer::Errors.pretty(error) }
|
|
29
35
|
end
|
|
30
36
|
|
|
@@ -40,6 +46,21 @@ module Portage
|
|
|
40
46
|
|
|
41
47
|
private
|
|
42
48
|
|
|
49
|
+
# A bare path loads and validates against the document root, same as
|
|
50
|
+
# before. A path carrying a fragment becomes a single-$ref schema
|
|
51
|
+
# pointing at that fragment, resolved the same way any other
|
|
52
|
+
# cross-document $ref in these vendored schemas is (via #resolve_ref) —
|
|
53
|
+
# $defs entries can themselves $ref sibling $defs by a local "#/..."
|
|
54
|
+
# pointer (catalog_lookup.json's get_product_response -> #/$defs/
|
|
55
|
+
# detail_product being one), so the fragment must be resolved by the
|
|
56
|
+
# schema library against the whole loaded document, not sliced out of
|
|
57
|
+
# it here.
|
|
58
|
+
def schema_for(relative_path)
|
|
59
|
+
return load(relative_path) unless relative_path.include?("#")
|
|
60
|
+
|
|
61
|
+
{ "$ref" => "#{@base_url}#{relative_path}" }
|
|
62
|
+
end
|
|
63
|
+
|
|
43
64
|
def resolve_ref(uri)
|
|
44
65
|
relative_path = uri.to_s.delete_prefix(@base_url).sub(/#.*/, "")
|
|
45
66
|
load(relative_path)
|
|
@@ -6,16 +6,19 @@ module Portage
|
|
|
6
6
|
# everything the Wix gem raises) — a module rather than a base class
|
|
7
7
|
# precisely to leave that hierarchy alone.
|
|
8
8
|
#
|
|
9
|
-
# Carries the
|
|
10
|
-
# HTTP status (which Support::NotFound#nil_on_not_found reads)
|
|
11
|
-
# body
|
|
12
|
-
#
|
|
9
|
+
# Carries the three things every gem's ApiError carried identically: the
|
|
10
|
+
# HTTP status (which Support::NotFound#nil_on_not_found reads), the
|
|
11
|
+
# parsed body, and — when the response sent one — the Retry-After
|
|
12
|
+
# header Support::Retry consults to time a 429's backoff. What differs
|
|
13
|
+
# per platform is only where the human-readable message lives inside
|
|
14
|
+
# the body, which is the `detail` hook.
|
|
13
15
|
module ApiError
|
|
14
|
-
attr_reader :status, :body
|
|
16
|
+
attr_reader :status, :body, :retry_after
|
|
15
17
|
|
|
16
|
-
def initialize(status, body)
|
|
18
|
+
def initialize(status, body, retry_after: nil)
|
|
17
19
|
@status = status
|
|
18
20
|
@body = body
|
|
21
|
+
@retry_after = retry_after
|
|
19
22
|
super("#{api_label} API error (#{status}): #{detail(body)}")
|
|
20
23
|
end
|
|
21
24
|
|
|
@@ -40,9 +40,16 @@ module Portage
|
|
|
40
40
|
# answer a successful DELETE with 204 and no content at all.
|
|
41
41
|
def parse!(response)
|
|
42
42
|
parsed = response.body.nil? || response.body.empty? ? {} : JSON.parse(response.body)
|
|
43
|
-
|
|
43
|
+
return parsed if response.is_a?(Net::HTTPSuccess)
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
status = response.code.to_i
|
|
46
|
+
# 409 means "you lost a race with a concurrent write" the same way
|
|
47
|
+
# on every REST platform behind this module — normalized here to
|
|
48
|
+
# Portage::Ucp::ConflictError rather than each gem's own ApiError,
|
|
49
|
+
# since it's cross-cutting, not platform-specific (see errors.rb).
|
|
50
|
+
raise Portage::Ucp::ConflictError, "conflict (409): #{parsed}" if status == 409
|
|
51
|
+
|
|
52
|
+
raise api_error_class.new(status, parsed, retry_after: response["Retry-After"])
|
|
46
53
|
end
|
|
47
54
|
|
|
48
55
|
def api_error_class
|
|
@@ -12,13 +12,32 @@ module Portage
|
|
|
12
12
|
# workers needs a shared store, which is a consumer concern the same
|
|
13
13
|
# way RateLimiter is.
|
|
14
14
|
module Idempotency
|
|
15
|
+
# Guards lazy init of each instance's lock table below — brief and
|
|
16
|
+
# only touched once per instance, not on the hot dedup path.
|
|
17
|
+
INIT_MUTEX = Mutex.new
|
|
18
|
+
|
|
15
19
|
private
|
|
16
20
|
|
|
17
21
|
def dedup(idempotency_key)
|
|
18
|
-
|
|
19
|
-
|
|
22
|
+
init_idempotency_locks!
|
|
23
|
+
|
|
24
|
+
key_lock = @idempotency_mutex.synchronize { @idempotency_locks[idempotency_key] ||= Mutex.new }
|
|
25
|
+
|
|
26
|
+
key_lock.synchronize do
|
|
27
|
+
return @idempotency_results[idempotency_key] if @idempotency_results.key?(idempotency_key)
|
|
28
|
+
|
|
29
|
+
@idempotency_results[idempotency_key] = yield
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def init_idempotency_locks!
|
|
34
|
+
return if @idempotency_mutex
|
|
20
35
|
|
|
21
|
-
|
|
36
|
+
INIT_MUTEX.synchronize do
|
|
37
|
+
@idempotency_mutex ||= Mutex.new
|
|
38
|
+
@idempotency_results ||= {}
|
|
39
|
+
@idempotency_locks ||= {}
|
|
40
|
+
end
|
|
22
41
|
end
|
|
23
42
|
end
|
|
24
43
|
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Ucp
|
|
3
|
+
module Support
|
|
4
|
+
# Bounded exponential backoff + jitter around a single upstream call.
|
|
5
|
+
#
|
|
6
|
+
# #with_retry is only ever safe to wrap around a call that is either
|
|
7
|
+
# naturally idempotent (a read) or already sitting inside a
|
|
8
|
+
# Support::Idempotency#dedup block (a mutation memoized by
|
|
9
|
+
# idempotency_key) — every bundled adapter's mutating method already
|
|
10
|
+
# dedups, so its Client-level HTTP/GraphQL calls satisfy this by
|
|
11
|
+
# construction. Retrying a non-idempotent mutation that ISN'T inside
|
|
12
|
+
# dedup can double it up: a request that partially applied upstream
|
|
13
|
+
# before the connection dropped gets reapplied on retry. Never call
|
|
14
|
+
# #with_retry around a bare mutation outside dedup.
|
|
15
|
+
#
|
|
16
|
+
# Only retries signals that mean "the upstream didn't do the work, try
|
|
17
|
+
# again": HTTP 429 (honoring Retry-After when present), 5xx, and
|
|
18
|
+
# platform-specific throttling (Shopify's GraphQL THROTTLED code,
|
|
19
|
+
# cartSubmitForCompletion's SubmitThrottled pollAfter) via
|
|
20
|
+
# #retryable_error? overrides. Everything else — 4xx business
|
|
21
|
+
# rejections, UserError, OutOfStockError, ConflictError — passes
|
|
22
|
+
# straight through un-retried.
|
|
23
|
+
module Retry
|
|
24
|
+
DEFAULT_MAX_ATTEMPTS = 4
|
|
25
|
+
DEFAULT_BASE_DELAY = 0.25
|
|
26
|
+
DEFAULT_MAX_DELAY = 4.0
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def with_retry(max_attempts: DEFAULT_MAX_ATTEMPTS, base_delay: DEFAULT_BASE_DELAY,
|
|
31
|
+
max_delay: DEFAULT_MAX_DELAY)
|
|
32
|
+
attempt = 0
|
|
33
|
+
|
|
34
|
+
begin
|
|
35
|
+
attempt += 1
|
|
36
|
+
yield
|
|
37
|
+
rescue StandardError => e
|
|
38
|
+
raise unless retryable_error?(e)
|
|
39
|
+
raise if attempt >= max_attempts
|
|
40
|
+
|
|
41
|
+
sleep(retry_delay(e, attempt, base_delay, max_delay))
|
|
42
|
+
retry
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Default covers the common REST shape: any error carrying a `status`
|
|
47
|
+
# of 429 or 5xx (Support::ApiError-including errors all expose this).
|
|
48
|
+
# Platform clients override to recognize signals with no HTTP status
|
|
49
|
+
# of their own (e.g. a GraphQL THROTTLED extension code).
|
|
50
|
+
def retryable_error?(error)
|
|
51
|
+
error.respond_to?(:status) && (error.status == 429 || (500..599).cover?(error.status))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Honors an upstream Retry-After (seconds) when the error carries
|
|
55
|
+
# one; otherwise backs off exponentially with full jitter.
|
|
56
|
+
def retry_delay(error, attempt, base_delay, max_delay)
|
|
57
|
+
retry_after = error.respond_to?(:retry_after) ? error.retry_after : nil
|
|
58
|
+
return [retry_after.to_f, 0].max if retry_after
|
|
59
|
+
|
|
60
|
+
exponential = [base_delay * (2**(attempt - 1)), max_delay].min
|
|
61
|
+
rand * exponential
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Ucp
|
|
3
|
+
module Support
|
|
4
|
+
# §21: multi-call mutations (read-modify-write against Shopify's
|
|
5
|
+
# Storefront API, or the equivalent REST sequence on Wix) aren't
|
|
6
|
+
# atomic upstream, so two concurrent calls against the same cart or
|
|
7
|
+
# checkout id can interleave and drop each other's writes — not just
|
|
8
|
+
# a lost update, lines vanish. This serializes calls per id so a
|
|
9
|
+
# concurrent duplicate waits for the first to finish instead of
|
|
10
|
+
# racing it.
|
|
11
|
+
#
|
|
12
|
+
# In-process only, same caveat as RateLimiter and Idempotency: a
|
|
13
|
+
# multi-process deployment needs a shared lock, which is a consumer
|
|
14
|
+
# concern. Keep #synchronize as the only method a consumer touches so
|
|
15
|
+
# a Redis-backed replacement can drop in without adapters changing.
|
|
16
|
+
module SessionLock
|
|
17
|
+
INIT_MUTEX = Mutex.new
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def synchronize(session_id)
|
|
22
|
+
init_session_locks!
|
|
23
|
+
|
|
24
|
+
key_lock = @session_lock_mutex.synchronize { @session_locks[session_id] ||= Mutex.new }
|
|
25
|
+
|
|
26
|
+
key_lock.synchronize { yield }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def init_session_locks!
|
|
30
|
+
return if @session_lock_mutex
|
|
31
|
+
|
|
32
|
+
INIT_MUTEX.synchronize do
|
|
33
|
+
@session_lock_mutex ||= Mutex.new
|
|
34
|
+
@session_locks ||= {}
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -1,9 +1,169 @@
|
|
|
1
1
|
module Portage
|
|
2
2
|
module Ucp
|
|
3
3
|
# Internal arithmetic helper only — never appears in a wire shape directly.
|
|
4
|
+
# Cart/Checkout/Order totals are bare minor-unit integers (Total#amount)
|
|
5
|
+
# once they reach a value object; Money exists so Support::Amounts has
|
|
6
|
+
# somewhere to hand back both the minor-unit amount and its currency
|
|
7
|
+
# together mid-conversion. Product/Variant carry currency-bearing wire
|
|
8
|
+
# fields directly (Price, below) rather than through Money.
|
|
4
9
|
Money = Data.define(:amount_minor, :currency)
|
|
5
10
|
|
|
6
|
-
|
|
11
|
+
# schemas/shopping/types/price.json — the wire-shape counterpart to Money
|
|
12
|
+
# above: an integer minor-unit amount paired with its currency, appearing
|
|
13
|
+
# directly in Product/Variant payloads.
|
|
14
|
+
Price = Data.define(:amount, :currency) do
|
|
15
|
+
def to_wire_h = { "amount" => amount, "currency" => currency }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# schemas/shopping/types/price_range.json
|
|
19
|
+
PriceRange = Data.define(:min, :max) do
|
|
20
|
+
def to_wire_h = { "min" => min.to_wire_h, "max" => max.to_wire_h }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# schemas/shopping/types/description.json — the spec requires at least
|
|
24
|
+
# one of plain/html/markdown; that's a request-time authoring concern
|
|
25
|
+
# this value object doesn't enforce, same posture as PostalAddress
|
|
26
|
+
# leaving every field optional.
|
|
27
|
+
Description = Data.define(:plain, :html, :markdown) do
|
|
28
|
+
def initialize(plain: nil, html: nil, markdown: nil) = super
|
|
29
|
+
|
|
30
|
+
def to_wire_h = { "plain" => plain, "html" => html, "markdown" => markdown }.compact
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# schemas/shopping/types/category.json
|
|
34
|
+
Category = Data.define(:value, :taxonomy) do
|
|
35
|
+
def initialize(value:, taxonomy: nil) = super
|
|
36
|
+
|
|
37
|
+
def to_wire_h = { "value" => value, "taxonomy" => taxonomy }.compact
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# schemas/shopping/types/media.json
|
|
41
|
+
Media = Data.define(:type, :url, :alt_text, :width, :height) do
|
|
42
|
+
def initialize(type:, url:, alt_text: nil, width: nil, height: nil) = super
|
|
43
|
+
|
|
44
|
+
def to_wire_h
|
|
45
|
+
{ "type" => type, "url" => url, "alt_text" => alt_text, "width" => width, "height" => height }.compact
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# schemas/shopping/types/option_value.json — a selectable value for a
|
|
50
|
+
# ProductOption (e.g. "Blue" under "Color").
|
|
51
|
+
OptionValue = Data.define(:id, :label) do
|
|
52
|
+
def initialize(label:, id: nil) = super
|
|
53
|
+
|
|
54
|
+
def to_wire_h = { "id" => id, "label" => label }.compact
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# schemas/shopping/types/product_option.json
|
|
58
|
+
ProductOption = Data.define(:name, :values) do
|
|
59
|
+
def to_wire_h = { "name" => name, "values" => values.map(&:to_wire_h) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# schemas/shopping/types/selected_option.json — which OptionValue a
|
|
63
|
+
# Variant actually carries for a given option (e.g. Size: Large).
|
|
64
|
+
SelectedOption = Data.define(:name, :id, :label) do
|
|
65
|
+
def initialize(name:, label:, id: nil) = super
|
|
66
|
+
|
|
67
|
+
def to_wire_h = { "name" => name, "id" => id, "label" => label }.compact
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# schemas/shopping/types/rating.json
|
|
71
|
+
Rating = Data.define(:value, :scale_max, :scale_min, :count) do
|
|
72
|
+
def initialize(value:, scale_max:, scale_min: 1, count: nil) = super
|
|
73
|
+
|
|
74
|
+
def to_wire_h
|
|
75
|
+
{ "value" => value, "scale_min" => scale_min, "scale_max" => scale_max, "count" => count }.compact
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# schemas/shopping/types/variant.json — a purchasable variant of a
|
|
80
|
+
# Product. `id` is what LineItem#item.id actually references at
|
|
81
|
+
# checkout, not Product#id.
|
|
82
|
+
# `barcodes` and `availability` are left as plain wire-shaped hashes
|
|
83
|
+
# (`{"type" =>, "value" =>}` / `{"available" =>, "status" =>}`) rather
|
|
84
|
+
# than their own Data types — both are simple, spec doesn't name them as
|
|
85
|
+
# standalone $refs, and the parent's `to_wire_h` needs them exactly as
|
|
86
|
+
# given, same posture as Adjustment#line_items' inline hashes.
|
|
87
|
+
Variant = Data.define(:id, :title, :description, :price, :sku, :barcodes, :list_price, :availability, :options,
|
|
88
|
+
:media, :tags, :metadata) do
|
|
89
|
+
def initialize(id:, title:, description:, price:, sku: nil, barcodes: [], list_price: nil, availability: nil,
|
|
90
|
+
options: [], media: [], tags: [], metadata: nil)
|
|
91
|
+
super
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def to_wire_h
|
|
95
|
+
{ "id" => id, "title" => title, "description" => description.to_wire_h,
|
|
96
|
+
"price" => price.to_wire_h }.merge(optional_wire_h)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def optional_wire_h
|
|
102
|
+
h = { "sku" => sku, "list_price" => list_price&.to_wire_h, "availability" => availability }.compact
|
|
103
|
+
h["barcodes"] = barcodes unless barcodes.empty?
|
|
104
|
+
h["options"] = options.map(&:to_wire_h) unless options.empty?
|
|
105
|
+
h["media"] = media.map(&:to_wire_h) unless media.empty?
|
|
106
|
+
h["tags"] = tags unless tags.empty?
|
|
107
|
+
h["metadata"] = metadata if metadata
|
|
108
|
+
h
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# schemas/shopping/types/product.json — requires id/title/description/
|
|
113
|
+
# price_range/variants (variants minItems: 1). `metadata` is the spec's
|
|
114
|
+
# own sanctioned extension point for business-defined custom data
|
|
115
|
+
# (Shopify/Wix metafields etc.) — see Portage::Ucp::Shopify.configure's
|
|
116
|
+
# metadata_field.
|
|
117
|
+
Product = Data.define(:id, :title, :description, :price_range, :variants, :handle, :url, :categories,
|
|
118
|
+
:list_price_range, :media, :options, :tags, :metadata, :rating) do
|
|
119
|
+
def initialize(id:, title:, description:, price_range:, variants:, handle: nil, url: nil, categories: [],
|
|
120
|
+
list_price_range: nil, media: [], options: [], tags: [], metadata: nil, rating: nil)
|
|
121
|
+
super
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def to_wire_h
|
|
125
|
+
{ "id" => id, "title" => title, "description" => description.to_wire_h,
|
|
126
|
+
"price_range" => price_range.to_wire_h, "variants" => variants.map(&:to_wire_h) }.merge(optional_wire_h)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
def optional_wire_h
|
|
132
|
+
h = { "handle" => handle, "url" => url, "list_price_range" => list_price_range&.to_wire_h,
|
|
133
|
+
"metadata" => metadata, "rating" => rating&.to_wire_h }.compact
|
|
134
|
+
h["categories"] = categories.map(&:to_wire_h) unless categories.empty?
|
|
135
|
+
h["media"] = media.map(&:to_wire_h) unless media.empty?
|
|
136
|
+
h["options"] = options.map(&:to_wire_h) unless options.empty?
|
|
137
|
+
h["tags"] = tags unless tags.empty?
|
|
138
|
+
h
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# schemas/shopping/catalog_search.json#/$defs/search_response — the
|
|
143
|
+
# container Adapter#search_catalog actually returns; `products` alone
|
|
144
|
+
# isn't schema-conformant (search_response requires `ucp` + `products` at
|
|
145
|
+
# the top level, not a bare array). `messages` entries are left as plain
|
|
146
|
+
# wire-shaped hashes, same rationale as Variant#barcodes above.
|
|
147
|
+
CatalogSearchResult = Data.define(:products, :messages) do
|
|
148
|
+
def initialize(products:, messages: []) = super
|
|
149
|
+
|
|
150
|
+
def to_wire_h
|
|
151
|
+
h = { "products" => products.map(&:to_wire_h) }
|
|
152
|
+
h["messages"] = messages unless messages.empty?
|
|
153
|
+
h
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# schemas/shopping/catalog_lookup.json#/$defs/get_product_response — the
|
|
158
|
+
# container Adapter#get_product returns when the product is found (nil
|
|
159
|
+
# otherwise, same not-found posture as get_cart/get_checkout/get_order).
|
|
160
|
+
# Doesn't model detail_product's `selected`/`options` availability-signal
|
|
161
|
+
# extension (interactive variant narrowing) — Adapter#get_product's
|
|
162
|
+
# signature has no `selected:`/`preferences:` params to source it from
|
|
163
|
+
# yet; unresearched, same status as design-log §16's other backlog items.
|
|
164
|
+
ProductDetail = Data.define(:product) do
|
|
165
|
+
def to_wire_h = { "product" => product.to_wire_h }
|
|
166
|
+
end
|
|
7
167
|
|
|
8
168
|
# One cost-breakdown entry (schemas/shopping/types/total.json). `amount` is a
|
|
9
169
|
# signed integer in the parent object's currency's minor units.
|
data/lib/portage/ucp/version.rb
CHANGED
|
@@ -11,7 +11,11 @@ module Portage
|
|
|
11
11
|
ENVELOPES = {
|
|
12
12
|
"dev.ucp.shopping.cart" => -> { { "version" => SPEC_VERSION } },
|
|
13
13
|
"dev.ucp.shopping.checkout" => -> { { "version" => SPEC_VERSION, "payment_handlers" => {} } },
|
|
14
|
-
"dev.ucp.shopping.order" => -> { { "version" => SPEC_VERSION } }
|
|
14
|
+
"dev.ucp.shopping.order" => -> { { "version" => SPEC_VERSION } },
|
|
15
|
+
# response_catalog_schema (catalog_search.json's search_response and
|
|
16
|
+
# catalog_lookup.json's get_product_response both require it) — same
|
|
17
|
+
# minimal shape as cart/order, no capability-specific extra field.
|
|
18
|
+
"dev.ucp.shopping.catalog" => -> { { "version" => SPEC_VERSION } }
|
|
15
19
|
}.freeze
|
|
16
20
|
|
|
17
21
|
def self.wrap(capability_name, payload_hash)
|
data/lib/portage/ucp.rb
CHANGED
|
@@ -13,6 +13,9 @@ require_relative "ucp/support/totals"
|
|
|
13
13
|
require_relative "ucp/support/line_item_status"
|
|
14
14
|
require_relative "ucp/support/idempotency"
|
|
15
15
|
require_relative "ucp/support/checkout_state"
|
|
16
|
+
require_relative "ucp/support/session_lock"
|
|
17
|
+
require_relative "ucp/support/retry"
|
|
18
|
+
require_relative "ucp/reference_adapter"
|
|
16
19
|
require_relative "ucp/support/api_error"
|
|
17
20
|
require_relative "ucp/support/not_found"
|
|
18
21
|
require_relative "ucp/support/http_client"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: portage-ucp
|
|
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
|
|
@@ -170,7 +170,9 @@ files:
|
|
|
170
170
|
- lib/portage/ucp/rack/manifest_endpoint.rb
|
|
171
171
|
- lib/portage/ucp/rack/webhook_endpoint.rb
|
|
172
172
|
- lib/portage/ucp/rate_limiter.rb
|
|
173
|
+
- lib/portage/ucp/reference_adapter.rb
|
|
173
174
|
- lib/portage/ucp/resolver.rb
|
|
175
|
+
- lib/portage/ucp/rspec.rb
|
|
174
176
|
- lib/portage/ucp/schema_validator.rb
|
|
175
177
|
- lib/portage/ucp/support/amounts.rb
|
|
176
178
|
- lib/portage/ucp/support/api_error.rb
|
|
@@ -179,6 +181,8 @@ files:
|
|
|
179
181
|
- lib/portage/ucp/support/idempotency.rb
|
|
180
182
|
- lib/portage/ucp/support/line_item_status.rb
|
|
181
183
|
- lib/portage/ucp/support/not_found.rb
|
|
184
|
+
- lib/portage/ucp/support/retry.rb
|
|
185
|
+
- lib/portage/ucp/support/session_lock.rb
|
|
182
186
|
- lib/portage/ucp/support/token_exchange.rb
|
|
183
187
|
- lib/portage/ucp/support/totals.rb
|
|
184
188
|
- lib/portage/ucp/value_objects.rb
|
|
@@ -275,7 +279,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
275
279
|
- !ruby/object:Gem::Version
|
|
276
280
|
version: '0'
|
|
277
281
|
requirements: []
|
|
278
|
-
rubygems_version:
|
|
282
|
+
rubygems_version: 3.6.9
|
|
279
283
|
specification_version: 4
|
|
280
284
|
summary: Expose a commerce backend to AI shopping agents over MCP and UCP
|
|
281
285
|
test_files: []
|