portage-ucp-etsy 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 +11 -0
- data/LICENSE +21 -0
- data/README.md +114 -0
- data/lib/portage/ucp/etsy/access_token_fetcher.rb +40 -0
- data/lib/portage/ucp/etsy/adapter.rb +107 -0
- data/lib/portage/ucp/etsy/client.rb +44 -0
- data/lib/portage/ucp/etsy/errors.rb +20 -0
- data/lib/portage/ucp/etsy/mapper.rb +147 -0
- data/lib/portage/ucp/etsy/version.rb +7 -0
- data/lib/portage/ucp/etsy.rb +7 -0
- metadata +129 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 3e24ea2159a17a36dacb77e235330db44249daa453f86a81ae149604167760cf
|
|
4
|
+
data.tar.gz: d1a0f696169cfe5e5872732154e2e59faedfcad982c333b9e0cc35cf2c7e5140
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 5133bf3834abd51fd2f2e14780a4d2798712195bc4bb55879e7fa5479770ae794c74d1cee41121d7b8ed34fdcd508674c31a944109f79ff93141e48a35007a9b
|
|
7
|
+
data.tar.gz: ea611876c6565b1c16ff2a85635f13d42cf37b5292a654ad550c6554142b9bdb0a711e745b4dc011fc89ddcf7cfaae304a41d6a720dfc01cfa802a5475ca0195
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
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. Etsy adapter against Open API v3 — catalog and order
|
|
10
|
+
real, checkout redirect-link only (no cart/checkout endpoint in Etsy's
|
|
11
|
+
public API).
|
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,114 @@
|
|
|
1
|
+
# portage-ucp-etsy
|
|
2
|
+
|
|
3
|
+
Etsy adapter for [`portage-ucp`](../portage-ucp). Implements `Portage::Ucp::Adapter` against Etsy's Open API v3. Generic only — no merchant-specific business logic. Plain `Net::HTTP`, no Etsy SDK runtime dependency.
|
|
4
|
+
|
|
5
|
+
## What it covers — and what it deliberately doesn't
|
|
6
|
+
|
|
7
|
+
Unlike the other adapters in this project, this is **catalog + redirect-link checkout + order**, not a full transactional adapter. Etsy's public API has no cart, checkout, or add-to-cart endpoint at all — buying only ever happens on etsy.com itself.
|
|
8
|
+
|
|
9
|
+
| UCP capability | Backing Etsy API | Notes |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| `dev.ucp.shopping.catalog` | Open API v3 | `search_catalog` (client-side title filter, see below), `get_product` |
|
|
12
|
+
| `dev.ucp.shopping.checkout` | — | `create_checkout`/`get_checkout` only. `update_checkout`/`complete_checkout`/`cancel_checkout` are left unoverridden — calling them raises `Portage::Ucp::NotImplementedError` rather than pretending to do something. `dev.ucp.shopping.checkout` is still advertised (one overridden method is enough), so an agent discovers what's actually backed by trying it. |
|
|
13
|
+
| `dev.ucp.shopping.order` | Open API v3 (shop receipts) | `get_order` |
|
|
14
|
+
| `dev.ucp.shopping.cart` | — | not implemented at all; there's no Etsy cart resource to back it |
|
|
15
|
+
| `dev.ucp.shopping.identity` | — | not implemented; Etsy buyer/seller OAuth identity is a separate concern from the shop-owner token used here |
|
|
16
|
+
|
|
17
|
+
**`create_checkout`'s `Checkout#links` point at each requested listing's own etsy.com page** — the closest real equivalent to "add to cart and check out" this API allows. These Checkout objects are **not real Etsy resources** — nothing on Etsy's side tracks them. They live only in the `Adapter` instance's memory (`get_checkout` just reads back what `create_checkout` stored), so they don't survive a process restart or a different `Adapter` instance. `get_order`'s `checkout_id` is always blank for the same underlying reason: there's no real checkout for a receipt to link back to.
|
|
18
|
+
|
|
19
|
+
**`search_catalog` is weaker than every other adapter's.** Etsy's shop-listings endpoint supports no keyword filter (only limit/offset/sort) — this fetches a page of active listings and filters by title client-side. Fine for a small shop, misleading for a large one with more listings than fit in one page.
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
# Gemfile
|
|
25
|
+
gem "portage-ucp-etsy"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
bundle install
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Setup
|
|
33
|
+
|
|
34
|
+
You need an OAuth access_token (from the shop owner's one-time consent — Etsy's authorization-code+PKCE flow, outside this gem's scope) and your app's `x-api-key` (the OAuth client's keystring, required on every request in addition to the bearer token).
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
require "portage/ucp/etsy"
|
|
38
|
+
|
|
39
|
+
client = Portage::Ucp::Etsy::Client.new(
|
|
40
|
+
access_token: ENV.fetch("ETSY_ACCESS_TOKEN"),
|
|
41
|
+
api_key: ENV.fetch("ETSY_API_KEY")
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
adapter = Portage::Ucp::Etsy::Adapter.new(client: client, shop_id: ENV.fetch("ETSY_SHOP_ID"))
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Refreshing the access token
|
|
48
|
+
|
|
49
|
+
Etsy access tokens expire quickly and **rotate the refresh_token on every use** — persist the new one each time, the old one stops working immediately.
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
fetcher = Portage::Ucp::Etsy::AccessTokenFetcher.new(
|
|
53
|
+
client_id: ENV.fetch("ETSY_CLIENT_ID"),
|
|
54
|
+
refresh_token: ENV.fetch("ETSY_REFRESH_TOKEN")
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
result = fetcher.fetch
|
|
58
|
+
result.access_token # => pass into Client.new
|
|
59
|
+
result.refresh_token # => save this — the old one is now invalid
|
|
60
|
+
result.expires_in # => seconds until it needs refreshing again
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Usage
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
# Catalog — product_id is Etsy's listing_id
|
|
67
|
+
products = adapter.search_catalog(query: "mug", limit: 10)
|
|
68
|
+
product = adapter.get_product(product_id: products.first.id)
|
|
69
|
+
|
|
70
|
+
# Checkout — a redirect, not a real transaction
|
|
71
|
+
checkout = adapter.create_checkout(
|
|
72
|
+
line_items: [{ product_id: product.variants.first[:id], quantity: 1 }],
|
|
73
|
+
idempotency_key: SecureRandom.uuid
|
|
74
|
+
)
|
|
75
|
+
checkout.links.first.url # => hand this to the shopper/agent to complete the purchase on etsy.com
|
|
76
|
+
|
|
77
|
+
# Order
|
|
78
|
+
order = adapter.get_order(order_id: some_receipt_id)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Wiring into portage-ucp
|
|
82
|
+
|
|
83
|
+
Drop the adapter into a `Dispatcher` (or the MCP server) the same as any other backend:
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
dispatcher = Portage::Ucp::Dispatcher.new(adapter: adapter)
|
|
87
|
+
|
|
88
|
+
dispatcher.call(
|
|
89
|
+
capability: "dev.ucp.shopping.checkout",
|
|
90
|
+
action: "create_checkout",
|
|
91
|
+
arguments: { line_items: [{ product_id: listing_id, quantity: 1 }], idempotency_key: SecureRandom.uuid }
|
|
92
|
+
)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Errors
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
Portage::Ucp::Etsy::Error # base class
|
|
99
|
+
Portage::Ucp::Etsy::ApiError # any non-2xx response from Etsy's Open API v3
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Development
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
bundle exec rspec # tests (WebMock-stubbed, no live Etsy account needed)
|
|
106
|
+
bundle exec rubocop # lint
|
|
107
|
+
|
|
108
|
+
# refresh a real access_token for a connected shop
|
|
109
|
+
ETSY_CLIENT_ID=... ETSY_REFRESH_TOKEN=... bundle exec rake etsy_access_token
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## License
|
|
113
|
+
|
|
114
|
+
[MIT](LICENSE) — Copyright (c) 2026 Tom Whitbread.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
module Portage
|
|
5
|
+
module Ucp
|
|
6
|
+
module Etsy
|
|
7
|
+
# Exchanges a refresh_token for a new access_token via Etsy's OAuth2
|
|
8
|
+
# `/v3/public/oauth/token` endpoint.
|
|
9
|
+
#
|
|
10
|
+
# Doesn't handle the *initial* authorization-code exchange (that needs
|
|
11
|
+
# a PKCE code_verifier from an interactive consent redirect, which is
|
|
12
|
+
# a one-time setup step outside this gem's scope) — only the refresh
|
|
13
|
+
# step a long-running server needs repeatedly. Unlike Shopify/Wix/
|
|
14
|
+
# Magento, Etsy **rotates** the refresh_token on every use: the
|
|
15
|
+
# response's `refresh_token` is a new value, and the one passed in
|
|
16
|
+
# becomes invalid immediately, so callers must persist the returned
|
|
17
|
+
# one before the next refresh, not just the access_token.
|
|
18
|
+
class AccessTokenFetcher
|
|
19
|
+
include Portage::Ucp::Support::TokenExchange
|
|
20
|
+
|
|
21
|
+
Result = Struct.new(:access_token, :refresh_token, :expires_in, keyword_init: true)
|
|
22
|
+
|
|
23
|
+
ENDPOINT = "https://api.etsy.com/v3/public/oauth/token".freeze
|
|
24
|
+
|
|
25
|
+
def initialize(client_id:, refresh_token:)
|
|
26
|
+
@client_id = client_id
|
|
27
|
+
@refresh_token = refresh_token
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def fetch
|
|
31
|
+
body = exchange(ENDPOINT,
|
|
32
|
+
{ grant_type: "refresh_token", client_id: @client_id, refresh_token: @refresh_token },
|
|
33
|
+
error_class: Portage::Ucp::Etsy::Error, description: "token refresh failed")
|
|
34
|
+
Result.new(access_token: body["access_token"], refresh_token: body["refresh_token"],
|
|
35
|
+
expires_in: body["expires_in"])
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Ucp
|
|
3
|
+
module Etsy
|
|
4
|
+
# Generic Portage::Ucp::Adapter over Etsy's Open API v3 — no
|
|
5
|
+
# merchant-specific business logic, same posture as the other
|
|
6
|
+
# adapters in this project.
|
|
7
|
+
#
|
|
8
|
+
# IMPORTANT: unlike Shopify/Wix/WooCommerce/BigCommerce/Magento, this
|
|
9
|
+
# is deliberately a **catalog + redirect-link checkout + order**
|
|
10
|
+
# adapter, not a full transactional one — Etsy's public API has no
|
|
11
|
+
# cart, checkout, or add-to-cart endpoint at all. Buying only ever
|
|
12
|
+
# happens on etsy.com itself. This adapter therefore:
|
|
13
|
+
#
|
|
14
|
+
# - Doesn't override `get_cart`/`create_cart`/`update_cart`/
|
|
15
|
+
# `cancel_cart` at all — `dev.ucp.shopping.cart` stays unadvertised.
|
|
16
|
+
# - Overrides only `create_checkout`/`get_checkout`. `Checkout#links`
|
|
17
|
+
# carries one link per requested listing, pointing straight at that
|
|
18
|
+
# listing's own etsy.com page — the closest real equivalent to
|
|
19
|
+
# "add this to your cart and check out" this API allows. `update_
|
|
20
|
+
# checkout`/`complete_checkout`/`cancel_checkout` are left
|
|
21
|
+
# unoverridden on purpose: there's nothing to update or complete
|
|
22
|
+
# programmatically, and calling them raises
|
|
23
|
+
# Portage::Ucp::NotImplementedError rather than silently pretending
|
|
24
|
+
# to do something. `dev.ucp.shopping.checkout` still gets advertised
|
|
25
|
+
# (Capability#advertised_for? only needs one overridden method), so
|
|
26
|
+
# an agent sees the capability and can discover which actions are
|
|
27
|
+
# actually backed by trying them.
|
|
28
|
+
# - The Checkout objects `create_checkout` returns are **not real
|
|
29
|
+
# Etsy resources** — nothing on Etsy's side tracks them. They live
|
|
30
|
+
# only in this Adapter instance's memory (`get_checkout` reads back
|
|
31
|
+
# what `create_checkout` stored), so they don't survive a process
|
|
32
|
+
# restart or a different Adapter instance.
|
|
33
|
+
# - `get_order`'s `checkout_id` is always blank — there's no real
|
|
34
|
+
# checkout for a receipt to link back to in the first place (see
|
|
35
|
+
# above), a more fundamental gap than WooCommerce/Magento needing
|
|
36
|
+
# adapter-side tracking for a *real* checkout.
|
|
37
|
+
#
|
|
38
|
+
# Deliberately doesn't override `link_identity` either — Etsy buyer/
|
|
39
|
+
# seller OAuth identity is a separate concern from the shop-owner
|
|
40
|
+
# access_token this gem uses for catalog/order reads.
|
|
41
|
+
class Adapter < Portage::Ucp::Adapter
|
|
42
|
+
# §9a dedup via Support::Idempotency, so an agent's retry on a
|
|
43
|
+
# dropped connection doesn't build a second, different in-memory
|
|
44
|
+
# Checkout for the same intent.
|
|
45
|
+
include Portage::Ucp::Support::Idempotency
|
|
46
|
+
# Etsy answers a missing listing or receipt with a 404 rather than
|
|
47
|
+
# an empty body, which UCP's reads report as nil.
|
|
48
|
+
include Portage::Ucp::Support::NotFound
|
|
49
|
+
|
|
50
|
+
def initialize(client:, shop_id:)
|
|
51
|
+
super()
|
|
52
|
+
@client = client
|
|
53
|
+
@shop_id = shop_id
|
|
54
|
+
@checkouts = {}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Etsy's shop-listings endpoint supports no keyword filter at all
|
|
58
|
+
# (only limit/offset/sort) — this fetches a page of active listings
|
|
59
|
+
# and filters by title client-side, so it's a much weaker "search"
|
|
60
|
+
# than every other adapter's real server-side query. Fine for a
|
|
61
|
+
# small shop, misleading for a large one.
|
|
62
|
+
def search_catalog(query:, limit:)
|
|
63
|
+
data = @client.get("/shops/#{@shop_id}/listings/active?limit=100")
|
|
64
|
+
matches = (data["results"] || []).select { |l| l["title"].to_s.downcase.include?(query.downcase) }
|
|
65
|
+
matches.first(limit).map { |node| Mapper.product(node) }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def get_product(product_id:)
|
|
69
|
+
nil_on_not_found do
|
|
70
|
+
node = @client.get("/listings/#{product_id}")
|
|
71
|
+
next nil unless node["listing_id"]
|
|
72
|
+
|
|
73
|
+
inventory = @client.get("/listings/#{product_id}/inventory")
|
|
74
|
+
Mapper.product(node.merge("variants_detail" => inventory))
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def create_checkout(line_items:, idempotency_key:)
|
|
79
|
+
dedup(idempotency_key) do
|
|
80
|
+
listings = line_items.map { |li| listing_with_quantity(li) }
|
|
81
|
+
checkout_id = "etsy-checkout-#{idempotency_key}"
|
|
82
|
+
checkout = Mapper.checkout(listings, id: checkout_id, status: "incomplete")
|
|
83
|
+
@checkouts[checkout_id] = checkout
|
|
84
|
+
checkout
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def get_checkout(checkout_id:)
|
|
89
|
+
@checkouts[checkout_id]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def get_order(order_id:)
|
|
93
|
+
nil_on_not_found do
|
|
94
|
+
node = @client.get("/shops/#{@shop_id}/receipts/#{order_id}")
|
|
95
|
+
node["receipt_id"] ? Mapper.order(node) : nil
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def listing_with_quantity(line_item)
|
|
102
|
+
@client.get("/listings/#{line_item[:product_id]}").merge("quantity" => line_item[:quantity])
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
module Portage
|
|
5
|
+
module Ucp
|
|
6
|
+
module Etsy
|
|
7
|
+
# Minimal REST client over Etsy's Open API v3
|
|
8
|
+
# (`api.etsy.com/v3/application`).
|
|
9
|
+
#
|
|
10
|
+
# Deliberately plain Net::HTTP, not an Etsy SDK (there isn't an
|
|
11
|
+
# official Ruby one) — trivially stubbable with WebMock.
|
|
12
|
+
#
|
|
13
|
+
# Every call needs *two* credentials, not one: a Bearer access_token
|
|
14
|
+
# from the shop owner's OAuth consent (see
|
|
15
|
+
# Portage::Ucp::Etsy::AccessTokenFetcher) **and** the app's own
|
|
16
|
+
# `x-api-key` (the OAuth client's keystring) on every single request —
|
|
17
|
+
# Etsy checks both independently, unlike Shopify/Wix/WooCommerce/
|
|
18
|
+
# Magento, where the bearer token alone is sufficient.
|
|
19
|
+
class Client
|
|
20
|
+
include Portage::Ucp::Support::HttpClient
|
|
21
|
+
|
|
22
|
+
BASE_URL = "https://api.etsy.com/v3/application".freeze
|
|
23
|
+
|
|
24
|
+
def initialize(access_token:, api_key:)
|
|
25
|
+
@access_token = access_token
|
|
26
|
+
@api_key = api_key
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def get(path)
|
|
30
|
+
request(Net::HTTP::Get, path)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def request(http_method, path)
|
|
36
|
+
json_request(http_method, "#{BASE_URL}#{path}",
|
|
37
|
+
headers: { "Authorization" => "Bearer #{@access_token}", "x-api-key" => @api_key })
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def api_error_class = Portage::Ucp::Etsy::ApiError
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Ucp
|
|
3
|
+
module Etsy
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
# Raised for any non-2xx response from Etsy's Open API v3
|
|
7
|
+
# (`api.etsy.com/v3/application`) — a non-2xx status with a JSON
|
|
8
|
+
# `{error, error_description}` (OAuth) or `{error}` (REST) body.
|
|
9
|
+
class ApiError < Error
|
|
10
|
+
include Portage::Ucp::Support::ApiError
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def detail(body)
|
|
15
|
+
body["error_description"] || body["error"] || body
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Ucp
|
|
3
|
+
module Etsy
|
|
4
|
+
# Converts Etsy Open API v3 response bodies into the protocol-layer
|
|
5
|
+
# value objects from Portage::Ucp::ValueObjects — nothing Etsy-shaped
|
|
6
|
+
# is allowed to leak past this file.
|
|
7
|
+
#
|
|
8
|
+
# Etsy's `Money` type (`{amount, divisor, currency_code}`) is already
|
|
9
|
+
# an integer, unlike every other adapter in this project — `amount /
|
|
10
|
+
# divisor` gives the decimal value, but `amount` itself is already in
|
|
11
|
+
# the currency's minor units when `divisor` is 100 (the only value
|
|
12
|
+
# Etsy documents ever returning), so no BigDecimal conversion is
|
|
13
|
+
# needed here at all.
|
|
14
|
+
module Mapper
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def money(node)
|
|
18
|
+
Portage::Ucp::Money.new(amount_minor: node["amount"], currency: node["currency_code"])
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# `node["variants_detail"]` is adapter-populated, not a real Etsy
|
|
22
|
+
# field: a listing's own resource has no variant/SKU breakdown at
|
|
23
|
+
# all — that's a second call to the separate Inventory endpoint
|
|
24
|
+
# (`/listings/{id}/inventory`), made only for #get_product's
|
|
25
|
+
# single-listing path, same reasoning as every other adapter's N+1
|
|
26
|
+
# variant fetch.
|
|
27
|
+
def product(node)
|
|
28
|
+
Portage::Ucp::Product.new(
|
|
29
|
+
id: node["listing_id"].to_s,
|
|
30
|
+
title: node["title"],
|
|
31
|
+
description: node["description"],
|
|
32
|
+
price: money(node["price"]),
|
|
33
|
+
available: node["state"] == "active" && node["quantity"].to_i.positive?,
|
|
34
|
+
variants: variants(node),
|
|
35
|
+
url: node["url"]
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# A listing with no inventory-level variation is its own single
|
|
40
|
+
# implicit variant, same as a single-variant Shopify product using
|
|
41
|
+
# that variant's own id.
|
|
42
|
+
def variants(node)
|
|
43
|
+
detail = node["variants_detail"]
|
|
44
|
+
unless detail
|
|
45
|
+
return [{ id: node["listing_id"].to_s, title: node["title"],
|
|
46
|
+
available: node["state"] == "active" && node["quantity"].to_i.positive?,
|
|
47
|
+
price: money(node["price"]) }]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
(detail["products"] || []).reject { |p| p["is_deleted"] }.map { |p| variant(p) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def variant(node)
|
|
54
|
+
title = (node["property_values"] || []).flat_map { |p| p["values"] }.join(" / ")
|
|
55
|
+
offering = (node["offerings"] || []).find { |o| o["is_enabled"] } || {}
|
|
56
|
+
{ id: node["product_id"].to_s, title: title, available: offering["quantity"].to_i.positive?,
|
|
57
|
+
price: money(offering["price"] || {}) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# `id:` is caller-supplied: there's no real Etsy checkout resource
|
|
61
|
+
# behind this at all — see Portage::Ucp::Etsy::Adapter's class-level
|
|
62
|
+
# comment. `links` points at each listing's own public Etsy URL
|
|
63
|
+
# rather than a cart, since Etsy's public API has no way to deep-
|
|
64
|
+
# link a multi-item add-to-cart flow.
|
|
65
|
+
def checkout(listings, id:, status:)
|
|
66
|
+
line_items = listings.map { |l| checkout_line_item(l) }
|
|
67
|
+
currency = listings.first&.dig("price", "currency_code")
|
|
68
|
+
Portage::Ucp::Checkout.new(
|
|
69
|
+
id: id,
|
|
70
|
+
status: status,
|
|
71
|
+
line_items: line_items,
|
|
72
|
+
currency: currency,
|
|
73
|
+
totals: totals(listings),
|
|
74
|
+
links: listings.map { |l| Portage::Ucp::Link.new(type: "checkout", url: l["url"], title: l["title"]) }
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def checkout_line_item(node)
|
|
79
|
+
Portage::Ucp::LineItem.new(
|
|
80
|
+
id: node["listing_id"].to_s,
|
|
81
|
+
item: Portage::Ucp::Item.new(id: node["listing_id"].to_s, title: node["title"],
|
|
82
|
+
price: node.dig("price", "amount")),
|
|
83
|
+
quantity: node["quantity"] || 1,
|
|
84
|
+
totals: Portage::Ucp::Support::Totals.line(line_total(node))
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def line_total(node)
|
|
89
|
+
node.dig("price", "amount") * (node["quantity"] || 1)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def totals(listings)
|
|
93
|
+
subtotal = listings.sum { |l| line_total(l) }
|
|
94
|
+
Portage::Ucp::Support::Totals.summary(subtotal: subtotal, total: subtotal)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Etsy core has no per-transaction fulfillment tracking on the
|
|
98
|
+
# Receipt resource beyond a single receipt-wide `is_shipped` flag —
|
|
99
|
+
# every line gets the same coarse status, same simplification as
|
|
100
|
+
# Portage::Ucp::WooCommerce::Mapper/Portage::Ucp::Magento::Mapper.
|
|
101
|
+
#
|
|
102
|
+
# `permalink_url` is left blank — Etsy's API doesn't return a
|
|
103
|
+
# public, shareable receipt URL (buyers see receipts inside their
|
|
104
|
+
# own account, not via a link). `checkout_id` is always blank too,
|
|
105
|
+
# for a more fundamental reason than the other adapters: this
|
|
106
|
+
# gem's Checkout is never a real Etsy resource (see
|
|
107
|
+
# Portage::Ucp::Etsy::Adapter), so there is no "checkout that led
|
|
108
|
+
# to this receipt" for the adapter to have recorded in the first
|
|
109
|
+
# place.
|
|
110
|
+
def receipt_status(node)
|
|
111
|
+
return "fulfilled" if node["is_shipped"]
|
|
112
|
+
return "removed" if node["status"] == "canceled"
|
|
113
|
+
|
|
114
|
+
"processing"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def order(node)
|
|
118
|
+
status = receipt_status(node)
|
|
119
|
+
Portage::Ucp::Order.new(
|
|
120
|
+
id: node["receipt_id"].to_s,
|
|
121
|
+
checkout_id: "",
|
|
122
|
+
permalink_url: "",
|
|
123
|
+
line_items: (node["transactions"] || []).map { |n| order_line_item(n, status) },
|
|
124
|
+
fulfillment: Portage::Ucp::Fulfillment.new,
|
|
125
|
+
currency: node.dig("total_price", "currency_code"),
|
|
126
|
+
totals: Portage::Ucp::Support::Totals.summary(subtotal: money(node["subtotal"]).amount_minor,
|
|
127
|
+
total: money(node["total_price"]).amount_minor)
|
|
128
|
+
)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def order_line_item(node, status)
|
|
132
|
+
quantity = node["quantity"]
|
|
133
|
+
fulfilled = Portage::Ucp::Support::LineItemStatus.fulfilled_quantity(status, quantity)
|
|
134
|
+
line_total = node.dig("price", "amount") * quantity
|
|
135
|
+
Portage::Ucp::OrderLineItem.new(
|
|
136
|
+
id: node["transaction_id"].to_s,
|
|
137
|
+
item: Portage::Ucp::Item.new(id: node["listing_id"].to_s, title: node["title"],
|
|
138
|
+
price: node.dig("price", "amount")),
|
|
139
|
+
quantity: { original: quantity, total: quantity, fulfilled: fulfilled },
|
|
140
|
+
totals: Portage::Ucp::Support::Totals.line(line_total),
|
|
141
|
+
status: status
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: portage-ucp-etsy
|
|
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 Etsy's Open API v3. Catalog
|
|
84
|
+
and order are real; checkout is redirect-link only (links to etsy.com) since Etsy's
|
|
85
|
+
public API has no cart/checkout endpoint. Generic only — no merchant-specific business
|
|
86
|
+
logic. Plain Net::HTTP, no Etsy SDK runtime dependency.
|
|
87
|
+
email:
|
|
88
|
+
executables: []
|
|
89
|
+
extensions: []
|
|
90
|
+
extra_rdoc_files: []
|
|
91
|
+
files:
|
|
92
|
+
- CHANGELOG.md
|
|
93
|
+
- LICENSE
|
|
94
|
+
- README.md
|
|
95
|
+
- lib/portage/ucp/etsy.rb
|
|
96
|
+
- lib/portage/ucp/etsy/access_token_fetcher.rb
|
|
97
|
+
- lib/portage/ucp/etsy/adapter.rb
|
|
98
|
+
- lib/portage/ucp/etsy/client.rb
|
|
99
|
+
- lib/portage/ucp/etsy/errors.rb
|
|
100
|
+
- lib/portage/ucp/etsy/mapper.rb
|
|
101
|
+
- lib/portage/ucp/etsy/version.rb
|
|
102
|
+
homepage: https://github.com/tomtom87/Portage/tree/main/portage-ucp-etsy
|
|
103
|
+
licenses:
|
|
104
|
+
- MIT
|
|
105
|
+
metadata:
|
|
106
|
+
source_code_uri: https://github.com/tomtom87/Portage/tree/main/portage-ucp-etsy
|
|
107
|
+
changelog_uri: https://github.com/tomtom87/Portage/blob/main/portage-ucp-etsy/CHANGELOG.md
|
|
108
|
+
rubygems_mfa_required: 'true'
|
|
109
|
+
post_install_message:
|
|
110
|
+
rdoc_options: []
|
|
111
|
+
require_paths:
|
|
112
|
+
- lib
|
|
113
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
114
|
+
requirements:
|
|
115
|
+
- - ">="
|
|
116
|
+
- !ruby/object:Gem::Version
|
|
117
|
+
version: '3.2'
|
|
118
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
119
|
+
requirements:
|
|
120
|
+
- - ">="
|
|
121
|
+
- !ruby/object:Gem::Version
|
|
122
|
+
version: '0'
|
|
123
|
+
requirements: []
|
|
124
|
+
rubygems_version: 3.5.22
|
|
125
|
+
signing_key:
|
|
126
|
+
specification_version: 4
|
|
127
|
+
summary: Etsy adapter for portage-ucp — catalog/order over MCP and UCP, redirect-link
|
|
128
|
+
checkout
|
|
129
|
+
test_files: []
|