portage-ucp-shopify 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 7b3dc91f25f74865134c14455b389377ae19c8fbbf8499d6b2830bbedec4ec26
4
+ data.tar.gz: b682672e45cf4242bbbfb67c005e3d1f521f5b3eff17faede2b4a36fe19bd5f6
5
+ SHA512:
6
+ metadata.gz: 5132d69c63d402f907f51ce56bcc97bc2d3ac41ded7554cba49de1b7d1cc333d17beeca7fdfd6c7e48e17458df3b998e8145afbd79bfebc9b8019761495770d0
7
+ data.tar.gz: 901cae55f88b03b6af29feb48c30656921f77c7f97a88366c05e5556b3d7a980062a9f6f52f6d71c6ddf6b90cdd6b7ad8a7c286e0472ffe8e8a9a0cf4a8b1be8
data/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. Format loosely follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.0.0/); this project is
5
+ pre-1.0, so APIs may still shift between minor versions.
6
+
7
+ ## [0.1.0] - Unreleased
8
+
9
+ - Initial pre-release. Shopify adapter against the Admin (catalog, order) and
10
+ Storefront (cart, checkout) GraphQL APIs.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tom Whitbread
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,131 @@
1
+ # portage-ucp-shopify
2
+
3
+ Shopify adapter for [`portage-ucp`](../portage-ucp). Implements `Portage::Ucp::Adapter` against Shopify's Admin (catalog, order) and Storefront (cart, checkout) GraphQL APIs. Generic only — no merchant-specific business logic. Plain `Net::HTTP`, no `shopify_api` runtime dependency.
4
+
5
+ ## What it covers
6
+
7
+ | UCP capability | Backing Shopify API | Notes |
8
+ |---|---|---|
9
+ | `dev.ucp.shopping.catalog` | Admin | `search_catalog`, `get_product` |
10
+ | `dev.ucp.shopping.cart` | Storefront (Cart) | `get_cart`, `create_cart`, `update_cart`, `cancel_cart` |
11
+ | `dev.ucp.shopping.checkout` | Storefront (same Cart object) | `create_checkout`, `get_checkout`, `update_checkout`, `complete_checkout`, `cancel_checkout` |
12
+ | `dev.ucp.shopping.order` | Admin | `get_order` |
13
+ | `dev.ucp.shopping.identity` | — | not implemented; Shopify's OAuth identity story lives in the separate Customer Account API, out of scope here |
14
+
15
+ Shopify has no separate "Checkout" object — Storefront's `Cart` **is** the checkout. The adapter tracks checkout status (`incomplete` / `completed` / `canceled` / `complete_in_progress`) itself, keyed by cart id, and resolves `Order#checkout_id` after completion via a `cart_token:` order search.
16
+
17
+ Update/replace operations (`update_cart`, `update_checkout`) are full-replacement: Storefront has no atomic "replace all lines" mutation, so the adapter removes every current line then re-adds the desired ones. Mutating methods dedup by `idempotency_key` in-process so a dropped-connection retry can't double-charge.
18
+
19
+ ## Installation
20
+
21
+ ```ruby
22
+ # Gemfile
23
+ gem "portage-ucp-shopify"
24
+ ```
25
+
26
+ ```bash
27
+ bundle install
28
+ ```
29
+
30
+ ## Setup
31
+
32
+ You need a shop domain plus an Admin API access token (for catalog/order) and a Storefront API access token (for cart/checkout). Either capability can be used alone if you only pass the token it needs.
33
+
34
+ ```ruby
35
+ require "portage/ucp/shopify"
36
+
37
+ client = Portage::Ucp::Shopify::Client.new(
38
+ shop_domain: "your-shop.myshopify.com",
39
+ admin_access_token: ENV.fetch("SHOPIFY_ADMIN_ACCESS_TOKEN"),
40
+ storefront_access_token: ENV.fetch("SHOPIFY_STOREFRONT_ACCESS_TOKEN")
41
+ )
42
+
43
+ adapter = Portage::Ucp::Shopify::Adapter.new(client: client)
44
+ ```
45
+
46
+ ### Fetching an Admin token from a custom app's client credentials
47
+
48
+ Shopify custom apps no longer expose a static, copy-once admin token — only a `client_id`/`client_secret`. Exchange those for a real access token via OAuth's `client_credentials` grant:
49
+
50
+ ```ruby
51
+ fetcher = Portage::Ucp::Shopify::AccessTokenFetcher.new(
52
+ shop_domain: "your-shop.myshopify.com",
53
+ client_id: ENV.fetch("SHOPIFY_CLIENT_ID"),
54
+ client_secret: ENV.fetch("SHOPIFY_CLIENT_SECRET")
55
+ )
56
+
57
+ result = fetcher.fetch
58
+ result.access_token # => admin_access_token to pass into Client.new
59
+ result.expires_in # => seconds until it needs refetching
60
+ ```
61
+
62
+ ## Usage
63
+
64
+ ```ruby
65
+ # Catalog
66
+ products = adapter.search_catalog(query: "hoodie", limit: 10)
67
+ product = adapter.get_product(product_id: products.first.id)
68
+
69
+ # Cart
70
+ cart = adapter.create_cart(
71
+ line_items: [{ product_id: product.variants.first[:id], quantity: 2 }],
72
+ idempotency_key: SecureRandom.uuid
73
+ )
74
+ cart = adapter.update_cart(cart_id: cart.id, line_items: [], idempotency_key: SecureRandom.uuid) # empties cart
75
+
76
+ # Checkout
77
+ checkout = adapter.create_checkout(
78
+ line_items: [{ product_id: product.variants.first[:id], quantity: 1 }],
79
+ idempotency_key: SecureRandom.uuid
80
+ )
81
+ checkout = adapter.complete_checkout(
82
+ checkout_id: checkout.id,
83
+ payment_token: single_use_token_from_payment_handler,
84
+ idempotency_key: SecureRandom.uuid
85
+ )
86
+
87
+ # Order
88
+ order = adapter.get_order(order_id: checkout.id) # only once linked post-completion
89
+ ```
90
+
91
+ `payment_token` must already be a single-use tokenized credential from a UCP payment handler (validated as non-PAN by `Portage::Ucp::PaymentTokenGuard` upstream) — it's passed straight into Storefront's `cartPaymentUpdate`.
92
+
93
+ ## Wiring into portage-ucp
94
+
95
+ Drop the adapter into a `Dispatcher` (or the MCP server) the same as any other backend:
96
+
97
+ ```ruby
98
+ dispatcher = Portage::Ucp::Dispatcher.new(adapter: adapter)
99
+
100
+ dispatcher.call(
101
+ capability: "dev.ucp.shopping.cart",
102
+ action: "create",
103
+ arguments: { line_items: [{ product_id: variant_id, quantity: 1 }], idempotency_key: SecureRandom.uuid }
104
+ )
105
+ ```
106
+
107
+ Because `link_identity` is left unoverridden, `Capability#advertised_for?` simply won't advertise `dev.ucp.shopping.identity` for this adapter — callers get an absent capability, not a 500.
108
+
109
+ ## Errors
110
+
111
+ ```ruby
112
+ Portage::Ucp::Shopify::Error # base class
113
+ Portage::Ucp::Shopify::GraphqlError # top-level GraphQL `errors` (bad query, throttled, auth rejected)
114
+ Portage::Ucp::Shopify::UserError # a mutation's non-empty `userErrors` (e.g. "line item not found")
115
+ ```
116
+
117
+ ## Development
118
+
119
+ ```bash
120
+ bundle exec rspec # tests (WebMock-stubbed, no live store needed)
121
+ bundle exec rubocop # lint
122
+
123
+ # fetch a real admin_access_token for a dev store, via client_credentials
124
+ SHOPIFY_SHOP_DOMAIN=your-shop.myshopify.com \
125
+ SHOPIFY_CLIENT_ID=... SHOPIFY_CLIENT_SECRET=... \
126
+ bundle exec rake shopify_access_token
127
+ ```
128
+
129
+ ## License
130
+
131
+ [MIT](LICENSE) — Copyright (c) 2026 Tom Whitbread.
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Standalone MCP server over stdio. Configure the shop connection via env vars,
5
+ # and — since this gem ships with permissive-nothing defaults (see README's
6
+ # "Security hooks") — wire a real Authenticator/RateLimiter/etc. via a config
7
+ # file loaded with PORTAGE_UCP_CONFIG, the same way `rackup -r` or Sidekiq's
8
+ # `-r` work — see ../examples/portage_ucp.rb for a starting point. Without
9
+ # one, the server still starts but rejects every mutating call.
10
+
11
+ require "portage/ucp"
12
+ require "portage/ucp/shopify"
13
+
14
+ require ENV["PORTAGE_UCP_CONFIG"] if ENV["PORTAGE_UCP_CONFIG"]
15
+
16
+ client = Portage::Ucp::Shopify::Client.new(
17
+ shop_domain: ENV.fetch("SHOPIFY_SHOP_DOMAIN"),
18
+ admin_access_token: ENV.fetch("SHOPIFY_ADMIN_ACCESS_TOKEN", nil),
19
+ storefront_access_token: ENV.fetch("SHOPIFY_STOREFRONT_ACCESS_TOKEN", nil)
20
+ )
21
+ adapter = Portage::Ucp::Shopify::Adapter.new(client: client)
22
+
23
+ Portage::Ucp::Mcp::Server.build(adapter: adapter).start
@@ -0,0 +1,36 @@
1
+ require "net/http"
2
+ require "json"
3
+
4
+ module Portage
5
+ module Ucp
6
+ module Shopify
7
+ # Exchanges a custom app's client_id/client_secret for an Admin API
8
+ # access token via Shopify's OAuth client_credentials grant.
9
+ #
10
+ # Shopify moved custom apps off static, copy-once tokens shown in the
11
+ # admin UI: creating a custom app now only exposes a client_id and
12
+ # client_secret, and the actual admin_access_token used by
13
+ # Portage::Ucp::Shopify::Client must be fetched (and re-fetched once
14
+ # expired) from POST /admin/oauth/access_token.
15
+ class AccessTokenFetcher
16
+ include Portage::Ucp::Support::TokenExchange
17
+
18
+ Result = Struct.new(:access_token, :expires_in, keyword_init: true)
19
+
20
+ def initialize(shop_domain:, client_id:, client_secret:)
21
+ @shop_domain = shop_domain
22
+ @client_id = client_id
23
+ @client_secret = client_secret
24
+ end
25
+
26
+ def fetch
27
+ body = exchange("https://#{@shop_domain}/admin/oauth/access_token",
28
+ { grant_type: "client_credentials", client_id: @client_id,
29
+ client_secret: @client_secret },
30
+ error_class: Portage::Ucp::Shopify::Error, form: true)
31
+ Result.new(access_token: body["access_token"], expires_in: body["expires_in"])
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,225 @@
1
+ require "bigdecimal"
2
+
3
+ module Portage
4
+ module Ucp
5
+ module Shopify
6
+ # Generic Portage::Ucp::Adapter over standard Shopify catalog/cart/checkout/
7
+ # order primitives (roadmap step 4) — no merchant-specific business
8
+ # logic. A merchant with bespoke checkout semantics (subscriptions,
9
+ # bundles, whatever) writes their own Adapter on top of the same
10
+ # contract, same as anyone on any other backend would (§7.1).
11
+ #
12
+ # Deliberately doesn't override `link_identity`: Shopify's OAuth identity
13
+ # story lives in the separate Customer Account API, out of scope for
14
+ # this generic adapter (see roadmap step 4's explicit "catalog/cart/
15
+ # checkout/order only"). Leaving it un-overridden means Capability#
16
+ # advertised_for? simply doesn't advertise dev.ucp.shopping.identity for
17
+ # this adapter — not a 500, just an absent capability.
18
+ #
19
+ # Catalog and Order are read through the Admin API (Storefront can't
20
+ # look up an arbitrary order without a customer session). Cart and
21
+ # Checkout are read/written through the Storefront API's Cart object —
22
+ # Admin has no "Cart"; a headless checkout is a Storefront Cart plus its
23
+ # `checkoutUrl` payment/completion mutations, not a separate Checkout
24
+ # object. UCP's create_checkout/update_checkout/complete_checkout are
25
+ # threaded onto that one underlying Cart id.
26
+ class Adapter < Portage::Ucp::Adapter
27
+ # Neither Storefront cart mutations nor cartPaymentUpdate take an
28
+ # idempotency key natively — cartSubmitForCompletion's attemptId is
29
+ # the one exception, see #submit_payment — so §9a dedup comes from
30
+ # Support::Idempotency's in-process table.
31
+ include Portage::Ucp::Support::Idempotency
32
+ # Shopify's Cart has no native "checkout status" field (Checkout is
33
+ # modeled as the same underlying Cart), and its Order has no field
34
+ # back to the cart that produced it — both are tracked adapter-side
35
+ # via Support::CheckoutState.
36
+ include Portage::Ucp::Support::CheckoutState
37
+
38
+ def initialize(client:)
39
+ super()
40
+ @client = client
41
+ end
42
+
43
+ def search_catalog(query:, limit:)
44
+ data = @client.admin_query(Queries::SEARCH_CATALOG, variables: { query: query, first: limit })
45
+ data.dig("products", "nodes").map { |node| Mapper.product(node) }
46
+ end
47
+
48
+ def get_product(product_id:)
49
+ data = @client.admin_query(Queries::GET_PRODUCT, variables: { id: product_id })
50
+ node = data["product"]
51
+ node && Mapper.product(node)
52
+ end
53
+
54
+ def get_cart(cart_id:)
55
+ cart_node = fetch_cart_node(cart_id)
56
+ cart_node && Mapper.cart(cart_node)
57
+ end
58
+
59
+ def create_cart(line_items:, idempotency_key:)
60
+ dedup(idempotency_key) { Mapper.cart(create_cart_node(line_items)) }
61
+ end
62
+
63
+ # Full replacement, matching UCP's real cart semantics: Storefront has
64
+ # no atomic "replace all lines" mutation, so this removes every current
65
+ # line then adds the desired ones back (two Storefront calls).
66
+ def update_cart(cart_id:, line_items:, idempotency_key:)
67
+ dedup(idempotency_key) { Mapper.cart(replace_lines(cart_id, line_items)) }
68
+ end
69
+
70
+ # Shopify has no cart-cancellation mutation — carts simply expire.
71
+ # Returns the cart unchanged; there's nothing more accurate to do here
72
+ # against the real API.
73
+ def cancel_cart(cart_id:, idempotency_key:)
74
+ dedup(idempotency_key) { get_cart(cart_id: cart_id) }
75
+ end
76
+
77
+ def create_checkout(line_items:, idempotency_key:)
78
+ dedup(idempotency_key) do
79
+ cart_node = create_cart_node(line_items)
80
+ record_checkout_status(cart_node["id"], "incomplete")
81
+ Mapper.checkout(cart_node, status: "incomplete")
82
+ end
83
+ end
84
+
85
+ def get_checkout(checkout_id:)
86
+ cart_node = fetch_cart_node(checkout_id)
87
+ cart_node && Mapper.checkout(cart_node, status: checkout_status(checkout_id))
88
+ end
89
+
90
+ # Full replacement, same rationale as #update_cart — Shopify checkout
91
+ # *is* the cart object.
92
+ def update_checkout(checkout_id:, line_items:, idempotency_key:)
93
+ dedup(idempotency_key) do
94
+ cart_node = replace_lines(checkout_id, line_items)
95
+ record_checkout_status(checkout_id, "incomplete")
96
+ Mapper.checkout(cart_node, status: "incomplete")
97
+ end
98
+ end
99
+
100
+ # `payment_token` is the single-use tokenized credential from a UCP
101
+ # payment handler (already validated as non-PAN by PaymentTokenGuard
102
+ # before this is ever called, per §9). It's threaded straight into
103
+ # Storefront's `cartPaymentUpdate` as a vaulted single-use token — the
104
+ # exact `paymentMethod` sub-shape is payment-handler-specific per
105
+ # Shopify's docs and needs confirming against a real dev store
106
+ # (roadmap step 5); this passes the token through rather than guessing
107
+ # a shape that only a live handler negotiation can pin down.
108
+ def complete_checkout(checkout_id:, payment_token:, idempotency_key:)
109
+ dedup(idempotency_key) { submit_payment(checkout_id, payment_token, idempotency_key) }
110
+ end
111
+
112
+ def cancel_checkout(checkout_id:, idempotency_key:)
113
+ dedup(idempotency_key) do
114
+ record_checkout_status(checkout_id, "canceled")
115
+ cart_node = fetch_cart_node(checkout_id)
116
+ Mapper.checkout(cart_node, status: "canceled")
117
+ end
118
+ end
119
+
120
+ def get_order(order_id:)
121
+ data = @client.admin_query(Queries::GET_ORDER, variables: { id: order_id })
122
+ node = data["order"]
123
+ node && Mapper.order(node, checkout_id: checkout_id_for(order_id))
124
+ end
125
+
126
+ private
127
+
128
+ def fetch_cart_node(cart_id)
129
+ @client.storefront_query(Queries::GET_CART, variables: { id: cart_id })["cart"]
130
+ end
131
+
132
+ def cart_lines(line_items)
133
+ line_items.map { |li| { merchandiseId: li[:product_id], quantity: li[:quantity] } }
134
+ end
135
+
136
+ def create_cart_node(line_items)
137
+ data = @client.storefront_query(Queries::CART_CREATE, variables: { input: { lines: cart_lines(line_items) } })
138
+ unwrap!(data, "cartCreate")
139
+ end
140
+
141
+ def replace_lines(cart_id, line_items)
142
+ current_line_ids = fetch_cart_node(cart_id).dig("lines", "nodes").map { |n| n["id"] }
143
+ unless current_line_ids.empty?
144
+ removed = @client.storefront_query(Queries::CART_LINES_REMOVE,
145
+ variables: { cartId: cart_id, lineIds: current_line_ids })
146
+ unwrap!(removed, "cartLinesRemove")
147
+ end
148
+ return fetch_cart_node(cart_id) if line_items.empty?
149
+
150
+ added = @client.storefront_query(Queries::CART_LINES_ADD,
151
+ variables: { cartId: cart_id, lines: cart_lines(line_items) })
152
+ unwrap!(added, "cartLinesAdd")
153
+ end
154
+
155
+ # Reuses idempotency_key as cartSubmitForCompletion's own attemptId too
156
+ # — Shopify natively dedups that one call via SubmitAlreadyAccepted, on
157
+ # top of #complete_checkout's own dedup wrapper.
158
+ def submit_payment(checkout_id, payment_token, idempotency_key)
159
+ cart_node = fetch_cart_node(checkout_id)
160
+ raise Portage::Ucp::Shopify::Error, "cart #{checkout_id} not found" unless cart_node
161
+
162
+ pay_for_cart(checkout_id, cart_node.dig("cost", "totalAmount"), payment_token)
163
+
164
+ submit_data = @client.storefront_query(Queries::CART_SUBMIT_FOR_COMPLETION,
165
+ variables: { cartId: checkout_id, attemptToken: idempotency_key })
166
+ status = unwrap_submit!(submit_data)
167
+ record_checkout_status(checkout_id, status)
168
+ order = link_cart_to_order(checkout_id) if status == "completed"
169
+ Mapper.checkout(cart_node, status: status, order: order)
170
+ end
171
+
172
+ def pay_for_cart(checkout_id, total, payment_token)
173
+ payment_data = @client.storefront_query(
174
+ Queries::CART_PAYMENT_UPDATE,
175
+ variables: { cartId: checkout_id,
176
+ payment: { totalAmount: total,
177
+ singleUseTokenPayment: { paymentAmount: total, singleUseToken: payment_token } } }
178
+ )
179
+ unwrap!(payment_data, "cartPaymentUpdate")
180
+ end
181
+
182
+ # Best-effort: if `status` is `complete_in_progress` (SubmitThrottled),
183
+ # the order doesn't exist yet, so there's nothing to look up — a poller
184
+ # calling #get_checkout again later would need to retry this too, which
185
+ # this adapter doesn't do on its own. If the order search index hasn't
186
+ # caught up yet even for a synchronously-completed cart, this silently
187
+ # finds nothing (returns nil, #get_order's checkout_id stays blank)
188
+ # rather than raising, matching the "not information-complete but
189
+ # schema-valid" posture used elsewhere in this file. Also the only
190
+ # place that can hand the freshly-created order id back to
191
+ # #complete_checkout's own caller — nothing else surfaces it.
192
+ def link_cart_to_order(checkout_id)
193
+ token = checkout_id[%r{Cart/([^?]+)}, 1]
194
+ return unless token
195
+
196
+ data = @client.admin_query(Queries::ORDER_BY_CART_TOKEN, variables: { query: "cart_token:#{token}" })
197
+ order_node = data.dig("orders", "nodes", 0)
198
+ return unless order_node
199
+
200
+ record_order_checkout(order_node["id"], checkout_id)
201
+ Portage::Ucp::OrderConfirmation.new(id: order_node["id"], permalink_url: order_node["statusPageUrl"])
202
+ end
203
+
204
+ def unwrap!(data, field)
205
+ payload = data.fetch(field)
206
+ errors = payload["userErrors"]
207
+ raise Portage::Ucp::Shopify::UserError.new(field, errors) if errors && !errors.empty?
208
+
209
+ payload.fetch("cart")
210
+ end
211
+
212
+ def unwrap_submit!(data)
213
+ payload = data.fetch("cartSubmitForCompletion")
214
+ errors = payload["userErrors"]
215
+ raise Portage::Ucp::Shopify::UserError.new("cartSubmitForCompletion", errors) if errors && !errors.empty?
216
+
217
+ result = payload["result"]
218
+ raise Portage::Ucp::Shopify::Error, result.dig("errors", 0, "message") if result["errors"]
219
+
220
+ result.key?("pollAfter") ? "complete_in_progress" : "completed"
221
+ end
222
+ end
223
+ end
224
+ end
225
+ end
@@ -0,0 +1,67 @@
1
+ require "net/http"
2
+ require "json"
3
+
4
+ module Portage
5
+ module Ucp
6
+ module Shopify
7
+ # Minimal GraphQL client over Shopify's Admin and Storefront APIs.
8
+ #
9
+ # Deliberately plain Net::HTTP, not the `shopify_api` gem: `shopify_api`
10
+ # requires a global `ShopifyAPI::Context.setup` and session object, which
11
+ # forces every consumer onto its config/session model just to make two
12
+ # GraphQL calls. A generic adapter that any Ruby app can drop in (§4 of
13
+ # the plan) is better served by a client that only needs a shop domain
14
+ # and the two tokens a merchant already has — no framework coupling, and
15
+ # trivially stubbable with WebMock in specs.
16
+ #
17
+ # Admin and Storefront are separate Shopify APIs with separate tokens and
18
+ # separate capabilities (Admin has no "Cart"; Storefront can't look up an
19
+ # arbitrary order). The adapter picks whichever API actually has the
20
+ # data it needs (see Portage::Ucp::Shopify::Adapter).
21
+ class Client
22
+ DEFAULT_API_VERSION = "2026-04".freeze
23
+
24
+ def initialize(shop_domain:, admin_access_token: nil, storefront_access_token: nil,
25
+ api_version: DEFAULT_API_VERSION)
26
+ @shop_domain = shop_domain
27
+ @admin_access_token = admin_access_token
28
+ @storefront_access_token = storefront_access_token
29
+ @api_version = api_version
30
+ end
31
+
32
+ def admin_query(query, variables: {})
33
+ require_token!(@admin_access_token, "admin_access_token")
34
+ post("/admin/api/#{@api_version}/graphql.json", headers: { "X-Shopify-Access-Token" => @admin_access_token },
35
+ query: query, variables: variables)
36
+ end
37
+
38
+ def storefront_query(query, variables: {})
39
+ require_token!(@storefront_access_token, "storefront_access_token")
40
+ post("/api/#{@api_version}/graphql.json",
41
+ headers: { "X-Shopify-Storefront-Access-Token" => @storefront_access_token },
42
+ query: query, variables: variables)
43
+ end
44
+
45
+ private
46
+
47
+ def require_token!(token, name)
48
+ raise ArgumentError, "Portage::Ucp::Shopify::Client requires #{name} for this call" unless token
49
+ end
50
+
51
+ def post(path, headers:, query:, variables:)
52
+ uri = URI("https://#{@shop_domain}#{path}")
53
+ request = Net::HTTP::Post.new(uri)
54
+ request["Content-Type"] = "application/json"
55
+ headers.each { |key, value| request[key] = value }
56
+ request.body = JSON.generate({ query: query, variables: variables })
57
+
58
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
59
+ body = JSON.parse(response.body)
60
+ raise Portage::Ucp::Shopify::GraphqlError, body["errors"] if body["errors"]
61
+
62
+ body["data"]
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,24 @@
1
+ module Portage
2
+ module Ucp
3
+ module Shopify
4
+ class Error < StandardError; end
5
+
6
+ # Raised when a GraphQL response carries top-level `errors` (malformed
7
+ # query, throttled, auth rejected) — distinct from a mutation's
8
+ # `userErrors`, which is a well-formed response describing a business
9
+ # rejection (e.g. "line item not found").
10
+ class GraphqlError < Error
11
+ def initialize(errors)
12
+ super(errors.map { |e| e["message"] }.join("; "))
13
+ end
14
+ end
15
+
16
+ # Raised when a mutation's `userErrors` array is non-empty.
17
+ class UserError < Error
18
+ def initialize(field, errors)
19
+ super("#{field} userErrors: #{errors.map { |e| e['message'] }.join('; ')}")
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,190 @@
1
+ module Portage
2
+ module Ucp
3
+ module Shopify
4
+ # Converts Shopify GraphQL response nodes into the protocol-layer value
5
+ # objects from Portage::Ucp::ValueObjects (Product/Cart/LineItem/Checkout/Order/
6
+ # Money) — nothing Shopify-shaped is allowed to leak past this file.
7
+ #
8
+ # UCP's LineItem#item.id is Shopify's variant GID, not the parent
9
+ # product's GID: Shopify carts hold variants (a specific size/color), and
10
+ # a product with one variant just uses that variant's id.
11
+ module Mapper
12
+ module_function
13
+
14
+ # Both take a Shopify MoneyV2 node ({amount:, currencyCode:}) rather
15
+ # than a bare amount — the arithmetic itself is Support::Amounts'.
16
+ def money(price)
17
+ Portage::Ucp::Support::Amounts.money(price["amount"], price["currencyCode"])
18
+ end
19
+
20
+ def minor_units(price)
21
+ Portage::Ucp::Support::Amounts.decimal_to_minor(price["amount"])
22
+ end
23
+
24
+ def product(node)
25
+ Portage::Ucp::Product.new(
26
+ id: node["id"],
27
+ title: node["title"],
28
+ description: node["description"],
29
+ price: money(node.dig("priceRange", "minVariantPrice")),
30
+ available: node["availableForSale"],
31
+ variants: node.dig("variants", "nodes").map { |v| variant(v) },
32
+ url: node["onlineStoreUrl"]
33
+ )
34
+ end
35
+
36
+ def variant(node)
37
+ { id: node["id"], title: node["title"], available: node["availableForSale"], price: money(node["price"]) }
38
+ end
39
+
40
+ def cart(node)
41
+ Portage::Ucp::Cart.new(
42
+ id: node["id"],
43
+ line_items: node.dig("lines", "nodes").map { |n| cart_line_item(n) },
44
+ currency: node.dig("cost", "subtotalAmount", "currencyCode"),
45
+ totals: totals(node)
46
+ )
47
+ end
48
+
49
+ def cart_line_item(node)
50
+ merchandise = node["merchandise"]
51
+ line_total = minor_units(node.dig("cost", "totalAmount"))
52
+ Portage::Ucp::LineItem.new(
53
+ id: node["id"],
54
+ item: Portage::Ucp::Item.new(id: merchandise["id"], title: merchandise.dig("product", "title"),
55
+ price: minor_units(merchandise["price"])),
56
+ quantity: node["quantity"],
57
+ totals: Portage::Ucp::Support::Totals.line(line_total)
58
+ )
59
+ end
60
+
61
+ # `status` isn't a Shopify Cart field — Cart/Checkout is one object in
62
+ # Shopify's model, so the adapter tracks status itself across the
63
+ # create/update/complete/cancel lifecycle and passes it in here.
64
+ def checkout(node, status:, order: nil)
65
+ Portage::Ucp::Checkout.new(
66
+ id: node["id"],
67
+ status: status,
68
+ line_items: node.dig("lines", "nodes").map { |n| cart_line_item(n) },
69
+ currency: node.dig("cost", "subtotalAmount", "currencyCode"),
70
+ totals: totals(node),
71
+ links: [],
72
+ order: order
73
+ )
74
+ end
75
+
76
+ # `checkout_id` isn't a Shopify Order field — nothing on Order links
77
+ # back to its originating cart, so the adapter resolves it itself (via
78
+ # a cart_token order search at completion time, see
79
+ # Portage::Ucp::Shopify::Adapter#link_cart_to_order) and passes it in here,
80
+ # the same way #checkout's `status:` is caller-supplied.
81
+ def order(node, checkout_id: "")
82
+ subtotal_amount = minor_units(node.dig("currentSubtotalPriceSet", "shopMoney"))
83
+ total_amount = minor_units(node.dig("currentTotalPriceSet", "shopMoney"))
84
+ Portage::Ucp::Order.new(
85
+ id: node["id"],
86
+ checkout_id: checkout_id,
87
+ permalink_url: node["statusPageUrl"],
88
+ line_items: node.dig("lineItems", "nodes").map { |n| order_line_item(n) },
89
+ fulfillment: fulfillment(node),
90
+ currency: node.dig("currentTotalPriceSet", "shopMoney", "currencyCode"),
91
+ totals: Portage::Ucp::Support::Totals.summary(subtotal: subtotal_amount, total: total_amount)
92
+ )
93
+ end
94
+
95
+ def order_line_item(node)
96
+ variant_node = node["variant"]
97
+ line_total = minor_units(node.dig("discountedTotalSet", "shopMoney"))
98
+ total = node["currentQuantity"]
99
+ fulfilled = total - node["unfulfilledQuantity"]
100
+ Portage::Ucp::OrderLineItem.new(
101
+ id: node["id"],
102
+ item: Portage::Ucp::Item.new(id: variant_node && variant_node["id"],
103
+ title: variant_node && variant_node["title"],
104
+ price: variant_node && minor_units(variant_node["price"])),
105
+ quantity: { original: node["quantity"], total: total, fulfilled: fulfilled },
106
+ totals: Portage::Ucp::Support::Totals.line(line_total),
107
+ status: Portage::Ucp::Support::LineItemStatus.derive(total: total, fulfilled: fulfilled)
108
+ )
109
+ end
110
+
111
+ # DeliveryMethodType (Shopify) -> method_type enum (UCP). Unmapped
112
+ # values (new enum members Shopify adds later) fall back to "shipping"
113
+ # rather than raising, so a schema-valid guess beats a hard failure.
114
+ DELIVERY_METHOD_TYPES = {
115
+ "SHIPPING" => "shipping", "LOCAL" => "shipping", "PICKUP_POINT" => "shipping",
116
+ "PICK_UP" => "pickup", "RETAIL" => "pickup",
117
+ "NONE" => "digital"
118
+ }.freeze
119
+
120
+ # FulfillmentDisplayStatus (Shopify) -> fulfillment_event `type` (UCP).
121
+ # Unmapped values fall back to "processing", same rationale as above.
122
+ FULFILLMENT_EVENT_TYPES = {
123
+ "ATTEMPTED_DELIVERY" => "failed_attempt", "CANCELED" => "canceled", "CONFIRMED" => "processing",
124
+ "DELAYED" => "in_transit", "DELIVERED" => "delivered", "FAILURE" => "failed_attempt",
125
+ "FULFILLED" => "shipped", "CARRIER_PICKED_UP" => "shipped", "IN_TRANSIT" => "in_transit",
126
+ "LABEL_PRINTED" => "processing", "LABEL_PURCHASED" => "processing", "LABEL_VOIDED" => "canceled",
127
+ "MARKED_AS_FULFILLED" => "shipped", "NOT_DELIVERED" => "undeliverable", "OUT_FOR_DELIVERY" => "in_transit",
128
+ "READY_FOR_PICKUP" => "processing", "PICKED_UP" => "delivered", "SUBMITTED" => "processing"
129
+ }.freeze
130
+
131
+ # Builds the buyer-facing Fulfillment (schemas/shopping/order.json's
132
+ # `expectations`/`events`) from Shopify's fulfillmentOrders (what's
133
+ # expected to ship, and to where) and fulfillments (what actually
134
+ # shipped, with tracking).
135
+ def fulfillment(node)
136
+ Portage::Ucp::Fulfillment.new(
137
+ expectations: (node.dig("fulfillmentOrders", "nodes") || []).map { |n| expectation(n) },
138
+ events: (node["fulfillments"] || []).map { |n| fulfillment_event(n) }
139
+ )
140
+ end
141
+
142
+ def expectation(node)
143
+ Portage::Ucp::Expectation.new(
144
+ id: node["id"],
145
+ line_items: (node.dig("lineItems", "nodes") || []).map { |n| order_line_ref(n, n["totalQuantity"]) },
146
+ method_type: DELIVERY_METHOD_TYPES.fetch(node.dig("deliveryMethod", "methodType"), "shipping"),
147
+ destination: postal_address(node["destination"]),
148
+ fulfillable_on: node["fulfillAt"]
149
+ )
150
+ end
151
+
152
+ def postal_address(node)
153
+ return {} unless node
154
+
155
+ {
156
+ "street_address" => node["address1"], "extended_address" => node["address2"],
157
+ "address_locality" => node["city"], "address_region" => node["province"],
158
+ "address_country" => node["countryCode"], "postal_code" => node["zip"],
159
+ "first_name" => node["firstName"], "last_name" => node["lastName"], "phone_number" => node["phone"]
160
+ }.compact
161
+ end
162
+
163
+ def fulfillment_event(node)
164
+ tracking = node.dig("trackingInfo", 0) || {}
165
+ line_items = (node.dig("fulfillmentLineItems", "nodes") || [])
166
+ .map { |n| order_line_ref(n, n["quantity"]) }
167
+ .select { |n| n["quantity"]&.positive? }
168
+ Portage::Ucp::FulfillmentEvent.new(
169
+ id: node["id"], occurred_at: node["createdAt"],
170
+ type: FULFILLMENT_EVENT_TYPES.fetch(node["displayStatus"], "processing"),
171
+ line_items: line_items,
172
+ tracking_number: tracking["number"], tracking_url: tracking["url"], carrier: tracking["company"]
173
+ )
174
+ end
175
+
176
+ def order_line_ref(node, quantity)
177
+ { "id" => node.dig("lineItem", "id"), "quantity" => quantity }
178
+ end
179
+
180
+ # Builds the top-level totals array from Shopify's cost breakdown.
181
+ def totals(node)
182
+ cost = node["cost"]
183
+ Portage::Ucp::Support::Totals.summary(subtotal: minor_units(cost["subtotalAmount"]),
184
+ tax: minor_units(cost["totalTaxAmount"]),
185
+ total: minor_units(cost["totalAmount"]))
186
+ end
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,160 @@
1
+ module Portage
2
+ module Ucp
3
+ module Shopify
4
+ # Raw GraphQL documents, kept separate from Adapter so the request shape
5
+ # and the response mapping (Mapper) can each be read and tested on their
6
+ # own.
7
+ module Queries
8
+ PRODUCT_FIELDS = <<~GRAPHQL.freeze
9
+ id
10
+ title
11
+ description
12
+ onlineStoreUrl
13
+ availableForSale
14
+ priceRange { minVariantPrice { amount currencyCode } }
15
+ variants(first: 25) {
16
+ nodes { id title availableForSale price { amount currencyCode } }
17
+ }
18
+ GRAPHQL
19
+
20
+ SEARCH_CATALOG = <<~GRAPHQL.freeze
21
+ query SearchCatalog($query: String!, $first: Int!) {
22
+ products(query: $query, first: $first) { nodes { #{PRODUCT_FIELDS} } }
23
+ }
24
+ GRAPHQL
25
+
26
+ GET_PRODUCT = <<~GRAPHQL.freeze
27
+ query GetProduct($id: ID!) {
28
+ product(id: $id) { #{PRODUCT_FIELDS} }
29
+ }
30
+ GRAPHQL
31
+
32
+ CART_FIELDS = <<~GRAPHQL.freeze
33
+ id
34
+ checkoutUrl
35
+ cost { subtotalAmount { amount currencyCode } totalTaxAmount { amount currencyCode }
36
+ totalAmount { amount currencyCode } }
37
+ lines(first: 100) {
38
+ nodes {
39
+ id quantity
40
+ cost { totalAmount { amount currencyCode } }
41
+ merchandise { ... on ProductVariant { id product { id title } price { amount currencyCode } } }
42
+ }
43
+ }
44
+ GRAPHQL
45
+
46
+ GET_CART = <<~GRAPHQL.freeze
47
+ query GetCart($id: ID!) {
48
+ cart(id: $id) { #{CART_FIELDS} }
49
+ }
50
+ GRAPHQL
51
+
52
+ CART_CREATE = <<~GRAPHQL.freeze
53
+ mutation CartCreate($input: CartInput!) {
54
+ cartCreate(input: $input) { cart { #{CART_FIELDS} } userErrors { field message } }
55
+ }
56
+ GRAPHQL
57
+
58
+ CART_LINES_ADD = <<~GRAPHQL.freeze
59
+ mutation CartLinesAdd($cartId: ID!, $lines: [CartLineInput!]!) {
60
+ cartLinesAdd(cartId: $cartId, lines: $lines) {
61
+ cart { #{CART_FIELDS} }
62
+ userErrors { field message }
63
+ }
64
+ }
65
+ GRAPHQL
66
+
67
+ CART_LINES_REMOVE = <<~GRAPHQL.freeze
68
+ mutation CartLinesRemove($cartId: ID!, $lineIds: [ID!]!) {
69
+ cartLinesRemove(cartId: $cartId, lineIds: $lineIds) {
70
+ cart { #{CART_FIELDS} }
71
+ userErrors { field message }
72
+ }
73
+ }
74
+ GRAPHQL
75
+
76
+ CART_BUYER_IDENTITY_UPDATE = <<~GRAPHQL.freeze
77
+ mutation CartBuyerIdentityUpdate($cartId: ID!, $buyerIdentity: CartBuyerIdentityInput!) {
78
+ cartBuyerIdentityUpdate(cartId: $cartId, buyerIdentity: $buyerIdentity) {
79
+ cart { #{CART_FIELDS} }
80
+ userErrors { field message }
81
+ }
82
+ }
83
+ GRAPHQL
84
+
85
+ CART_PAYMENT_UPDATE = <<~GRAPHQL.freeze
86
+ mutation CartPaymentUpdate($cartId: ID!, $payment: CartPaymentInput!) {
87
+ cartPaymentUpdate(cartId: $cartId, payment: $payment) {
88
+ cart { #{CART_FIELDS} }
89
+ userErrors { field message }
90
+ }
91
+ }
92
+ GRAPHQL
93
+
94
+ CART_SUBMIT_FOR_COMPLETION = <<~GRAPHQL.freeze
95
+ mutation CartSubmitForCompletion($cartId: ID!, $attemptToken: String!) {
96
+ cartSubmitForCompletion(cartId: $cartId, attemptId: $attemptToken) {
97
+ result {
98
+ ... on SubmitSuccess { attemptId }
99
+ ... on SubmitAlreadyAccepted { attemptId }
100
+ ... on SubmitFailed { checkoutUrl errors { message } }
101
+ ... on SubmitThrottled { pollAfter }
102
+ }
103
+ userErrors { field message }
104
+ }
105
+ }
106
+ GRAPHQL
107
+
108
+ GET_ORDER = <<~GRAPHQL.freeze
109
+ query GetOrder($id: ID!) {
110
+ order(id: $id) {
111
+ id
112
+ statusPageUrl
113
+ currentTotalPriceSet { shopMoney { amount currencyCode } }
114
+ currentSubtotalPriceSet { shopMoney { amount currencyCode } }
115
+ lineItems(first: 100) {
116
+ nodes {
117
+ id quantity currentQuantity unfulfilledQuantity
118
+ discountedTotalSet { shopMoney { amount currencyCode } }
119
+ variant { id title price { amount currencyCode } }
120
+ }
121
+ }
122
+ fulfillmentOrders(first: 25) {
123
+ nodes {
124
+ id
125
+ fulfillAt
126
+ deliveryMethod { methodType }
127
+ destination { address1 address2 city province zip countryCode firstName lastName phone }
128
+ lineItems(first: 100) {
129
+ nodes { totalQuantity lineItem { id } }
130
+ }
131
+ }
132
+ }
133
+ fulfillments(first: 25) {
134
+ id
135
+ displayStatus
136
+ createdAt
137
+ trackingInfo(first: 5) { company number url }
138
+ fulfillmentLineItems(first: 100) {
139
+ nodes { quantity lineItem { id } }
140
+ }
141
+ }
142
+ }
143
+ }
144
+ GRAPHQL
145
+
146
+ # Storefront's cartSubmitForCompletion never returns the resulting
147
+ # order's id (its SubmitSuccess payload only carries attemptId) — the
148
+ # Admin API's `orders(query:)` search supports a documented
149
+ # `cart_token:` filter ("the token references the cart that's
150
+ # associated with an order"), which is the only way to reconcile a
151
+ # completed cart back to the Order it produced.
152
+ ORDER_BY_CART_TOKEN = <<~GRAPHQL.freeze
153
+ query OrderByCartToken($query: String!) {
154
+ orders(first: 1, query: $query) { nodes { id statusPageUrl } }
155
+ }
156
+ GRAPHQL
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,7 @@
1
+ module Portage
2
+ module Ucp
3
+ module Shopify
4
+ VERSION = "0.1.0".freeze
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,8 @@
1
+ require "portage/ucp"
2
+ require_relative "shopify/version"
3
+ require_relative "shopify/errors"
4
+ require_relative "shopify/client"
5
+ require_relative "shopify/access_token_fetcher"
6
+ require_relative "shopify/queries"
7
+ require_relative "shopify/mapper"
8
+ require_relative "shopify/adapter"
metadata ADDED
@@ -0,0 +1,131 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: portage-ucp-shopify
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Tom Whitbread
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-08-14 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: portage-ucp
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rspec
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '3.13'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '3.13'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rubocop
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.88'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.88'
55
+ - !ruby/object:Gem::Dependency
56
+ name: webmock
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.24'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.24'
69
+ - !ruby/object:Gem::Dependency
70
+ name: yard
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '0.9'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '0.9'
83
+ description: Implements Portage::Ucp::Adapter against Shopify's Admin (catalog, order)
84
+ and Storefront (cart, checkout) GraphQL APIs. Generic only — no merchant-specific
85
+ business logic. Plain Net::HTTP, no shopify_api runtime dependency.
86
+ email:
87
+ executables:
88
+ - portage-ucp-shopify
89
+ extensions: []
90
+ extra_rdoc_files: []
91
+ files:
92
+ - CHANGELOG.md
93
+ - LICENSE
94
+ - README.md
95
+ - exe/portage-ucp-shopify
96
+ - lib/portage/ucp/shopify.rb
97
+ - lib/portage/ucp/shopify/access_token_fetcher.rb
98
+ - lib/portage/ucp/shopify/adapter.rb
99
+ - lib/portage/ucp/shopify/client.rb
100
+ - lib/portage/ucp/shopify/errors.rb
101
+ - lib/portage/ucp/shopify/mapper.rb
102
+ - lib/portage/ucp/shopify/queries.rb
103
+ - lib/portage/ucp/shopify/version.rb
104
+ homepage: https://github.com/tomtom87/Portage/tree/main/portage-ucp-shopify
105
+ licenses:
106
+ - MIT
107
+ metadata:
108
+ source_code_uri: https://github.com/tomtom87/Portage/tree/main/portage-ucp-shopify
109
+ changelog_uri: https://github.com/tomtom87/Portage/blob/main/portage-ucp-shopify/CHANGELOG.md
110
+ rubygems_mfa_required: 'true'
111
+ post_install_message:
112
+ rdoc_options: []
113
+ require_paths:
114
+ - lib
115
+ required_ruby_version: !ruby/object:Gem::Requirement
116
+ requirements:
117
+ - - ">="
118
+ - !ruby/object:Gem::Version
119
+ version: '3.2'
120
+ required_rubygems_version: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
125
+ requirements: []
126
+ rubygems_version: 3.5.22
127
+ signing_key:
128
+ specification_version: 4
129
+ summary: Shopify adapter for portage-ucp — standard catalog/cart/checkout/order over
130
+ MCP and UCP
131
+ test_files: []