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 +7 -0
- data/CHANGELOG.md +10 -0
- data/LICENSE +21 -0
- data/README.md +131 -0
- data/exe/portage-ucp-shopify +23 -0
- data/lib/portage/ucp/shopify/access_token_fetcher.rb +36 -0
- data/lib/portage/ucp/shopify/adapter.rb +225 -0
- data/lib/portage/ucp/shopify/client.rb +67 -0
- data/lib/portage/ucp/shopify/errors.rb +24 -0
- data/lib/portage/ucp/shopify/mapper.rb +190 -0
- data/lib/portage/ucp/shopify/queries.rb +160 -0
- data/lib/portage/ucp/shopify/version.rb +7 -0
- data/lib/portage/ucp/shopify.rb +8 -0
- metadata +131 -0
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,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: []
|