apparel-monster 1.0.1
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/LICENSE +21 -0
- data/exe/apparel-monster +164 -0
- data/lib/apparel_monster/version.rb +5 -0
- data/lib/apparel_monster.rb +317 -0
- metadata +57 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: ec51b45705597515cdba7a3c7cdeb39027d03ed466cda70bf887f81ed2be5c18
|
|
4
|
+
data.tar.gz: 7bc15557e9696d998630802c84154e31ccf4b518170359eca92b718c9b19e628
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 854c9ae0a4e8109c0b659a47612651ce3c7476d72bbda8614b97533265434a888d3350c22bad5f1cec72c9a0fc800b57489dc5939413e46c203d48a1356b55eb
|
|
7
|
+
data.tar.gz: 55922bbf5d80e63d5a617b14cfa97c626d16ed645e3fef168418df63b38c27a29a64780ca304bf5b31e68e3270f0be0f4ab6d719d471fa8824eefab7bd12d96a
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Michael Lugassy
|
|
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/exe/apparel-monster
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# apparel-monster — the store from a terminal.
|
|
5
|
+
#
|
|
6
|
+
# Identifies itself as ruby-cli rather than ruby-sdk, so the store can tell a
|
|
7
|
+
# person poking at the API from an integration running in production. Same
|
|
8
|
+
# client, one flag apart.
|
|
9
|
+
#
|
|
10
|
+
# Prints JSON unless --text is passed: the usual reader here is a script or an
|
|
11
|
+
# agent, not a person.
|
|
12
|
+
|
|
13
|
+
require "json"
|
|
14
|
+
require "optparse"
|
|
15
|
+
require_relative "../lib/apparel_monster"
|
|
16
|
+
|
|
17
|
+
USAGE = <<~TEXT
|
|
18
|
+
apparel-monster #{ApparelMonster::VERSION} — agent commerce CLI for apparel.monster
|
|
19
|
+
|
|
20
|
+
USAGE
|
|
21
|
+
apparel-monster <command> [args] [flags]
|
|
22
|
+
|
|
23
|
+
COMMANDS
|
|
24
|
+
search <query> Search the catalog
|
|
25
|
+
product <id...> Full detail for product or variant ids
|
|
26
|
+
ask <question> Natural-language search (NLWeb)
|
|
27
|
+
pricing Plans, limits and merchandise price ranges
|
|
28
|
+
buy <variant-id> Mint an Apple Pay / Google Pay link for one variant
|
|
29
|
+
watch <variant-id> <price> Watch a variant for a target price
|
|
30
|
+
watch-status <id> Poll a price watch
|
|
31
|
+
|
|
32
|
+
FLAGS
|
|
33
|
+
--limit N Results to return
|
|
34
|
+
--category PATH Browse a category instead of searching
|
|
35
|
+
--sort SORT relevance | cheapest | expensive | newest | name | popular
|
|
36
|
+
--base URL Point at another origin
|
|
37
|
+
--text Human-readable output instead of JSON
|
|
38
|
+
--stream Stream results as they arrive (ask)
|
|
39
|
+
|
|
40
|
+
The API needs no credentials. This is a demo store: orders are placed against
|
|
41
|
+
a test gateway, nothing ships and no money moves.
|
|
42
|
+
TEXT
|
|
43
|
+
|
|
44
|
+
options = { base: ApparelMonster::DEFAULT_BASE }
|
|
45
|
+
parser = OptionParser.new do |opts|
|
|
46
|
+
opts.on("--limit N", Integer) { |v| options[:limit] = v }
|
|
47
|
+
opts.on("--category PATH") { |v| options[:category] = v }
|
|
48
|
+
opts.on("--sort SORT") { |v| options[:sort] = v }
|
|
49
|
+
opts.on("--base URL") { |v| options[:base] = v }
|
|
50
|
+
opts.on("--text") { options[:text] = true }
|
|
51
|
+
opts.on("--stream") { options[:stream] = true }
|
|
52
|
+
opts.on("-h", "--help") { puts USAGE; exit 0 }
|
|
53
|
+
opts.on("--version") { puts ApparelMonster::VERSION; exit 0 }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
args = parser.parse(ARGV)
|
|
57
|
+
command = args.shift
|
|
58
|
+
|
|
59
|
+
if command.nil? || command == "help"
|
|
60
|
+
puts USAGE
|
|
61
|
+
exit 0
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Just enough formatting to read a result without piping through jq.
|
|
65
|
+
def humanise(data)
|
|
66
|
+
if data.is_a?(Hash) && data["products"].is_a?(Array)
|
|
67
|
+
return "No products matched. Try fewer words, or browse: apparel-monster search --category categories/men" if data["products"].empty?
|
|
68
|
+
|
|
69
|
+
return data["products"].map { |product|
|
|
70
|
+
prices = product.fetch("variants", []).map { |v| v["effective_price"] || v["price"] }.compact
|
|
71
|
+
sizes = product.fetch("variants", []).map { |v| v.dig("options", "Size") }.compact.uniq
|
|
72
|
+
lines = [prices.any? ? format("%s $%.2f", product["title"], prices.min) : product["title"]]
|
|
73
|
+
lines << " #{product['category']}" if product["category"]
|
|
74
|
+
lines << " sizes: #{sizes.join(', ')}" if sizes.any?
|
|
75
|
+
lines << " #{product['url']}"
|
|
76
|
+
lines.join("\n")
|
|
77
|
+
}.join("\n\n")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
if data.is_a?(Hash) && data["results"].is_a?(Array)
|
|
81
|
+
if data["results"].empty?
|
|
82
|
+
query = data.dig("_meta", "query")
|
|
83
|
+
return "No match for #{query.inspect}. /ask matches keywords, not meaning — try fewer words " \
|
|
84
|
+
'("coat" rather than "warm winter coat").'
|
|
85
|
+
end
|
|
86
|
+
return data["results"].map { |r| "#{r['name']}\n #{r['description']}\n #{r['url']}" }.join("\n\n")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
JSON.pretty_generate(data)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def emit(data, options)
|
|
93
|
+
puts options[:text] ? humanise(data) : JSON.pretty_generate(data)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
store = ApparelMonster::Client.new(base_url: options[:base], cli: true)
|
|
97
|
+
|
|
98
|
+
begin
|
|
99
|
+
case command
|
|
100
|
+
when "search"
|
|
101
|
+
emit(store.search(args.join(" ").empty? ? nil : args.join(" "),
|
|
102
|
+
limit: options[:limit], category: options[:category], sort: options[:sort]), options)
|
|
103
|
+
|
|
104
|
+
when "product"
|
|
105
|
+
abort "product needs at least one id" if args.empty?
|
|
106
|
+
emit(store.product(args), options)
|
|
107
|
+
|
|
108
|
+
when "ask"
|
|
109
|
+
abort "ask needs a question" if args.empty?
|
|
110
|
+
question = args.join(" ")
|
|
111
|
+
if options[:stream]
|
|
112
|
+
store.ask_stream(question, limit: options[:limit]) do |event, data|
|
|
113
|
+
next unless event == "result"
|
|
114
|
+
|
|
115
|
+
puts options[:text] ? "#{data['name']} — #{data['url']}" : JSON.generate(data)
|
|
116
|
+
end
|
|
117
|
+
else
|
|
118
|
+
emit(store.ask(question, limit: options[:limit]), options)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
when "pricing"
|
|
122
|
+
# The prose document rather than an endpoint: it is the canonical answer to
|
|
123
|
+
# "what does this cost", and markdown reads fine here.
|
|
124
|
+
require "net/http"
|
|
125
|
+
uri = URI("#{store.base_url}/pricing.md")
|
|
126
|
+
request = Net::HTTP::Get.new(uri)
|
|
127
|
+
store.headers("text/markdown").each { |k, v| request[k] = v }
|
|
128
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
129
|
+
http.use_ssl = true
|
|
130
|
+
puts http.request(request).body
|
|
131
|
+
|
|
132
|
+
when "buy"
|
|
133
|
+
abort "buy needs a variant id — get one from `apparel-monster search`" if args.empty?
|
|
134
|
+
result = store.wallet([{ "variant_id" => args.first, "quantity" => 1 }])
|
|
135
|
+
if options[:text]
|
|
136
|
+
puts "Open this to pay:\n #{result['wallet_url']}"
|
|
137
|
+
else
|
|
138
|
+
emit(result, options)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
when "watch"
|
|
142
|
+
abort "watch needs a variant id and a target price" if args.length < 2
|
|
143
|
+
result = store.watch_price(args[0], args[1].to_f)
|
|
144
|
+
if options[:text]
|
|
145
|
+
puts result["status"] == "already_met" ? "Already #{result['current_price']} — below your #{result['target_price']} target." : "Watching. Poll: #{result['poll_url']}"
|
|
146
|
+
else
|
|
147
|
+
emit(result, options)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
when "watch-status"
|
|
151
|
+
abort "watch-status needs a watch id" if args.empty?
|
|
152
|
+
emit(store.price_watch(args.first), options)
|
|
153
|
+
|
|
154
|
+
else
|
|
155
|
+
warn "Unknown command: #{command}\n\n"
|
|
156
|
+
puts USAGE
|
|
157
|
+
exit 1
|
|
158
|
+
end
|
|
159
|
+
rescue ApparelMonster::Error => e
|
|
160
|
+
# The API's own message already names the missing fields and the next call;
|
|
161
|
+
# repeating them here would only make it longer.
|
|
162
|
+
warn "#{e.code || 'error'}: #{e.message}"
|
|
163
|
+
exit 1
|
|
164
|
+
end
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
require_relative "apparel_monster/version"
|
|
8
|
+
|
|
9
|
+
# Apparel Monster SDK: a dependency-free client for the agent commerce API.
|
|
10
|
+
#
|
|
11
|
+
# No API key appears in this file and that is not an omission. search, product,
|
|
12
|
+
# cart, checkout, wallet, price-watch, batch and /ask are anonymous. Only order
|
|
13
|
+
# history (+track+) needs OAuth. If something asks you for an Apparel Monster
|
|
14
|
+
# API key, it is not us.
|
|
15
|
+
#
|
|
16
|
+
# Standard library only — net/http, no faraday, no httparty — so installing
|
|
17
|
+
# this pulls in nothing and it works inside an agent sandbox with no gem
|
|
18
|
+
# server.
|
|
19
|
+
#
|
|
20
|
+
# store = ApparelMonster::Client.new
|
|
21
|
+
# hit = store.search("denim shirt", limit: 1)["products"].first
|
|
22
|
+
# store.add_to_cart(hit["variants"].first["id"])
|
|
23
|
+
module ApparelMonster
|
|
24
|
+
DEFAULT_BASE = "https://apparel.monster"
|
|
25
|
+
API = "/api/v1"
|
|
26
|
+
|
|
27
|
+
# Raised for any non-2xx response.
|
|
28
|
+
#
|
|
29
|
+
# The interesting attribute is +missing+: the API answers a bad call by
|
|
30
|
+
# naming every field it needed and did not get, what else each one is
|
|
31
|
+
# accepted as, and an example value. That detail is folded into the message
|
|
32
|
+
# too, because the common case is a model reading +message+ and nothing else.
|
|
33
|
+
class Error < StandardError
|
|
34
|
+
attr_reader :status, :code, :missing, :next_call, :contract, :body, :url
|
|
35
|
+
|
|
36
|
+
def initialize(status, body, url)
|
|
37
|
+
@status = status
|
|
38
|
+
@body = body || {}
|
|
39
|
+
@code = @body["error"]
|
|
40
|
+
@missing = @body["missing"]
|
|
41
|
+
@next_call = @body["next_call"]
|
|
42
|
+
@contract = @body["contract"]
|
|
43
|
+
@url = url
|
|
44
|
+
super(describe)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def describe
|
|
50
|
+
parts = [@body["message"] || "Request failed with #{@status}"]
|
|
51
|
+
|
|
52
|
+
if @missing.is_a?(Array) && @missing.any?
|
|
53
|
+
described = @missing.map do |field|
|
|
54
|
+
names = (field["accepts"] || [field["field"]]).join(" or ")
|
|
55
|
+
"#{field['field']} (send as #{names}, e.g. #{field['example'].inspect})"
|
|
56
|
+
end
|
|
57
|
+
parts << "Missing: #{described.join('; ')}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
parts << "Next call: #{JSON.generate(@next_call)}" if @next_call
|
|
61
|
+
|
|
62
|
+
parts.join(" ")
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# A client for one shopping session.
|
|
67
|
+
#
|
|
68
|
+
# Two things this does that a raw Net::HTTP call does not: it carries the
|
|
69
|
+
# session id the API mints on the first search (forgetting it is the usual
|
|
70
|
+
# way an integration ends up with four empty carts and no order), and it
|
|
71
|
+
# identifies itself so the store can attribute the resulting order to this
|
|
72
|
+
# SDK rather than to "some script".
|
|
73
|
+
class Client
|
|
74
|
+
attr_accessor :session_id, :access_token
|
|
75
|
+
attr_reader :base_url
|
|
76
|
+
|
|
77
|
+
# @param base_url [String] override the origin
|
|
78
|
+
# @param session_id [String, nil] resume an existing agent session
|
|
79
|
+
# @param access_token [String, nil] OAuth token, only needed by #track
|
|
80
|
+
# @param timeout [Numeric] seconds
|
|
81
|
+
# @param cli [Boolean] identify as the CLI rather than the library
|
|
82
|
+
def initialize(base_url: DEFAULT_BASE, session_id: nil, access_token: nil, timeout: 30, cli: false)
|
|
83
|
+
@base_url = base_url.to_s.sub(%r{/+\z}, "")
|
|
84
|
+
@session_id = session_id
|
|
85
|
+
@access_token = access_token
|
|
86
|
+
@timeout = timeout
|
|
87
|
+
@client = cli ? "ruby-cli" : "ruby-sdk"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# ---- catalog -------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
# Search the catalog, or browse it when +query+ is nil.
|
|
93
|
+
#
|
|
94
|
+
# Mints the session id every later call needs, so this is almost always the
|
|
95
|
+
# first call you make.
|
|
96
|
+
def search(query = nil, category: nil, sort: nil, limit: nil, page: nil)
|
|
97
|
+
post("/search", "query" => query, "category" => category, "sort" => sort, "limit" => limit, "page" => page)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Full detail for product ids or variant ids, mixed freely.
|
|
101
|
+
def product(ids)
|
|
102
|
+
post("/product", "ids" => Array(ids).map(&:to_s))
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# ---- cart ----------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
# Add a VARIANT — not a product. A product with five sizes has five
|
|
108
|
+
# variants and five prices, and adding a product id is the most common
|
|
109
|
+
# mistake made against this API.
|
|
110
|
+
def add_to_cart(variant_id, quantity = 1)
|
|
111
|
+
post("/cart", "action" => "add", "variant_id" => variant_id, "quantity" => quantity)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def update_cart_item(variant_id, quantity)
|
|
115
|
+
post("/cart", "action" => "update", "variant_id" => variant_id, "quantity" => quantity)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def remove_from_cart(variant_id)
|
|
119
|
+
post("/cart", "action" => "remove", "variant_id" => variant_id)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def view_cart
|
|
123
|
+
post("/cart", "action" => "view")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Set the email and address; returns the cart with +shipping_options+
|
|
127
|
+
# priced against it.
|
|
128
|
+
#
|
|
129
|
+
# Required: email (once), line_one, city, postal_code. A missing one raises
|
|
130
|
+
# ApparelMonster::Error naming all of them at once, with the aliases each
|
|
131
|
+
# is accepted under.
|
|
132
|
+
def set_billing(address = {})
|
|
133
|
+
post("/cart", stringify(address).merge("action" => "billing"))
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def set_shipping(shipping_id, address = {})
|
|
137
|
+
post("/cart", stringify(address).merge("action" => "shipping", "shipping_id" => shipping_id))
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def apply_coupon(code)
|
|
141
|
+
post("/cart", "action" => "coupon", "coupon_code" => code)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Attach a payment token. The field is +token+ — not +payment_token+, not
|
|
145
|
+
# +card_token+. Demo store on a test gateway: any non-empty string is
|
|
146
|
+
# accepted and nothing is ever charged.
|
|
147
|
+
def set_payment(token)
|
|
148
|
+
post("/cart", "action" => "payment", "token" => token)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# ---- checkout ------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
# Place the order. Idempotent per session.
|
|
154
|
+
def checkout(callback_url: nil)
|
|
155
|
+
post("/checkout", "callback_url" => callback_url)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# A hosted Apple Pay / Google Pay link, for when a human can tap.
|
|
159
|
+
#
|
|
160
|
+
# The shortcut past the whole cart flow: hand it variant ids and it returns
|
|
161
|
+
# a URL that collects the shopper's email, address and card on their own
|
|
162
|
+
# device, so your agent never handles any of them.
|
|
163
|
+
def wallet(items)
|
|
164
|
+
normalised = Array(items).map do |item|
|
|
165
|
+
item.is_a?(Hash) ? stringify(item) : { "variant_id" => item.to_s, "quantity" => 1 }
|
|
166
|
+
end
|
|
167
|
+
post("/wallet", "items" => normalised)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Order status and shipments. The only call that needs OAuth.
|
|
171
|
+
def track(params = {})
|
|
172
|
+
post("/track", stringify(params))
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# ---- price watch ---------------------------------------------------
|
|
176
|
+
|
|
177
|
+
# Be told when a variant reaches a target price. Answers 202 with a
|
|
178
|
+
# +poll_url+ and a +job_id+; a target at or above today's price is not
|
|
179
|
+
# queued at all and comes back +already_met+.
|
|
180
|
+
def watch_price(variant_id, target_price, callback_url: nil)
|
|
181
|
+
post("/price-watch",
|
|
182
|
+
"variant_id" => variant_id, "target_price" => target_price, "callback_url" => callback_url)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def price_watch(watch_id)
|
|
186
|
+
request(Net::HTTP::Get, "#{@base_url}#{API}/price-watch/#{URI.encode_www_form_component(watch_id)}")
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# ---- batch ---------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
# Up to 10 operations in one round trip, executed in order. Each result
|
|
192
|
+
# carries its own status: the batch answers 200 whenever it ran, even if
|
|
193
|
+
# everything inside it failed.
|
|
194
|
+
def batch(operations, stop_on_error: false)
|
|
195
|
+
raise ArgumentError, "batch takes at most 10 operations" if operations.length > 10
|
|
196
|
+
|
|
197
|
+
post("/batch", "operations" => operations, "stop_on_error" => stop_on_error)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# ---- natural language ----------------------------------------------
|
|
201
|
+
|
|
202
|
+
# Ask in plain language (NLWeb); returns schema.org Product items. There is
|
|
203
|
+
# no model behind it — it runs the same catalog search — which is why it
|
|
204
|
+
# returns nothing rather than inventing a product.
|
|
205
|
+
def ask(query, limit: nil)
|
|
206
|
+
params = { "query" => query }
|
|
207
|
+
params["limit"] = limit if limit
|
|
208
|
+
request(Net::HTTP::Get, "#{@base_url}/ask?#{URI.encode_www_form(params)}")
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# The same question, streamed: yields [event, data] as +start+, one
|
|
212
|
+
# +result+ per hit, then +complete+.
|
|
213
|
+
def ask_stream(query, limit: nil)
|
|
214
|
+
return enum_for(:ask_stream, query, limit: limit) unless block_given?
|
|
215
|
+
|
|
216
|
+
params = { "query" => query, "streaming" => "true" }
|
|
217
|
+
params["limit"] = limit if limit
|
|
218
|
+
uri = URI("#{@base_url}/ask?#{URI.encode_www_form(params)}")
|
|
219
|
+
|
|
220
|
+
http(uri).request(get_request(uri, "text/event-stream")) do |response|
|
|
221
|
+
event = "message"
|
|
222
|
+
data = +""
|
|
223
|
+
response.read_body do |chunk|
|
|
224
|
+
chunk.each_line do |raw|
|
|
225
|
+
line = raw.chomp
|
|
226
|
+
if line.start_with?("event:")
|
|
227
|
+
event = line[6..].strip
|
|
228
|
+
elsif line.start_with?("data:")
|
|
229
|
+
data << line[5..].strip
|
|
230
|
+
elsif line.empty?
|
|
231
|
+
# A blank line terminates an SSE frame; anything before one is a
|
|
232
|
+
# partial frame and must not be yielded.
|
|
233
|
+
unless data.empty?
|
|
234
|
+
begin
|
|
235
|
+
yield [event, JSON.parse(data)]
|
|
236
|
+
rescue JSON::ParserError
|
|
237
|
+
nil
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
event = "message"
|
|
241
|
+
data = +""
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# ---- plumbing ------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
def headers(accept = "application/json")
|
|
251
|
+
suffix = @client == "ruby-cli" ? "-cli" : ""
|
|
252
|
+
result = {
|
|
253
|
+
"Content-Type" => "application/json",
|
|
254
|
+
"Accept" => accept,
|
|
255
|
+
# Two spellings of one fact: the User-Agent is what the edge sees
|
|
256
|
+
# without the application being involved, X-Agent-Client is what
|
|
257
|
+
# survives an environment that rewrites User-Agent.
|
|
258
|
+
"User-Agent" => "apparel-monster-ruby#{suffix}/#{VERSION} (+https://github.com/mluggy/apparel-monster-dev)",
|
|
259
|
+
"X-Agent-Client" => "#{@client}/#{VERSION}"
|
|
260
|
+
}
|
|
261
|
+
result["Authorization"] = "Bearer #{@access_token}" if @access_token
|
|
262
|
+
result
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
private
|
|
266
|
+
|
|
267
|
+
def stringify(hash)
|
|
268
|
+
(hash || {}).each_with_object({}) { |(k, v), out| out[k.to_s] = v }
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def with_session(body)
|
|
272
|
+
return body unless @session_id
|
|
273
|
+
|
|
274
|
+
{ "session" => { "id" => @session_id } }.merge(body)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def post(path, body = {})
|
|
278
|
+
payload = with_session(body.reject { |_k, v| v.nil? })
|
|
279
|
+
request(Net::HTTP::Post, "#{@base_url}#{API}#{path}", JSON.generate(payload))
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def http(uri)
|
|
283
|
+
client = Net::HTTP.new(uri.host, uri.port)
|
|
284
|
+
client.use_ssl = uri.scheme == "https"
|
|
285
|
+
client.open_timeout = @timeout
|
|
286
|
+
client.read_timeout = @timeout
|
|
287
|
+
client
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def get_request(uri, accept)
|
|
291
|
+
Net::HTTP::Get.new(uri).tap { |r| headers(accept).each { |k, v| r[k] = v } }
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def request(verb, url, body = nil)
|
|
295
|
+
uri = URI(url)
|
|
296
|
+
req = verb.new(uri)
|
|
297
|
+
headers.each { |k, v| req[k] = v }
|
|
298
|
+
req.body = body if body
|
|
299
|
+
|
|
300
|
+
response = http(uri).request(req)
|
|
301
|
+
parsed = begin
|
|
302
|
+
JSON.parse(response.body.to_s)
|
|
303
|
+
rescue JSON::ParserError
|
|
304
|
+
nil
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
raise Error.new(response.code.to_i, parsed, url) unless response.is_a?(Net::HTTPSuccess)
|
|
308
|
+
|
|
309
|
+
# The session arrives on the first search and is reused from then on.
|
|
310
|
+
if parsed.is_a?(Hash) && parsed.dig("session", "id")
|
|
311
|
+
@session_id = parsed["session"]["id"]
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
parsed
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: apparel-monster
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Michael Lugassy
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-09-16 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: |
|
|
14
|
+
Search the catalog, build a cart, place an order, mint an Apple Pay / Google Pay
|
|
15
|
+
link, watch a price, or ask in natural language. No authentication: every call
|
|
16
|
+
except order history is anonymous. Standard library only, no runtime dependencies.
|
|
17
|
+
apparel.monster is a demo storefront — orders are placed against a test gateway
|
|
18
|
+
and nothing is ever fulfilled.
|
|
19
|
+
email:
|
|
20
|
+
executables:
|
|
21
|
+
- apparel-monster
|
|
22
|
+
extensions: []
|
|
23
|
+
extra_rdoc_files: []
|
|
24
|
+
files:
|
|
25
|
+
- LICENSE
|
|
26
|
+
- exe/apparel-monster
|
|
27
|
+
- lib/apparel_monster.rb
|
|
28
|
+
- lib/apparel_monster/version.rb
|
|
29
|
+
homepage: https://apparel.monster/developers
|
|
30
|
+
licenses:
|
|
31
|
+
- MIT
|
|
32
|
+
metadata:
|
|
33
|
+
homepage_uri: https://apparel.monster/developers
|
|
34
|
+
source_code_uri: https://github.com/mluggy/apparel-monster-dev
|
|
35
|
+
bug_tracker_uri: https://github.com/mluggy/apparel-monster-dev/issues
|
|
36
|
+
documentation_uri: https://apparel.monster/developers
|
|
37
|
+
rubygems_mfa_required: 'false'
|
|
38
|
+
post_install_message:
|
|
39
|
+
rdoc_options: []
|
|
40
|
+
require_paths:
|
|
41
|
+
- lib
|
|
42
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: 2.7.0
|
|
47
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
48
|
+
requirements:
|
|
49
|
+
- - ">="
|
|
50
|
+
- !ruby/object:Gem::Version
|
|
51
|
+
version: '0'
|
|
52
|
+
requirements: []
|
|
53
|
+
rubygems_version: 3.0.3.1
|
|
54
|
+
signing_key:
|
|
55
|
+
specification_version: 4
|
|
56
|
+
summary: SDK and CLI for the Apparel Monster agent commerce API.
|
|
57
|
+
test_files: []
|