showroom 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 +23 -0
- data/LICENSE +674 -0
- data/README.md +200 -0
- data/lib/showroom/client.rb +95 -0
- data/lib/showroom/core/configurable.rb +77 -0
- data/lib/showroom/core/connection.rb +88 -0
- data/lib/showroom/core/countable.rb +79 -0
- data/lib/showroom/core/default.rb +61 -0
- data/lib/showroom/core/error.rb +53 -0
- data/lib/showroom/core/store_url.rb +60 -0
- data/lib/showroom/core/version.rb +9 -0
- data/lib/showroom/http/middleware/raise_error.rb +81 -0
- data/lib/showroom/models/collection.rb +67 -0
- data/lib/showroom/models/product.rb +142 -0
- data/lib/showroom/models/product_image.rb +12 -0
- data/lib/showroom/models/product_option.rb +13 -0
- data/lib/showroom/models/product_variant.rb +39 -0
- data/lib/showroom/models/resource.rb +143 -0
- data/lib/showroom/models/search/article_suggestion.rb +14 -0
- data/lib/showroom/models/search/collection_suggestion.rb +19 -0
- data/lib/showroom/models/search/page_suggestion.rb +16 -0
- data/lib/showroom/models/search/product_suggestion.rb +20 -0
- data/lib/showroom/models/search/query_suggestion.rb +14 -0
- data/lib/showroom/models/search/result.rb +55 -0
- data/lib/showroom/models/search/suggestion.rb +50 -0
- data/lib/showroom/models/search.rb +28 -0
- data/lib/showroom/models.rb +20 -0
- data/lib/showroom.rb +106 -0
- metadata +93 -0
data/README.md
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# Showroom
|
|
2
|
+
|
|
3
|
+
A Ruby gem wrapping Shopify's public, unauthenticated JSON endpoints — no app, no token, no session required.
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
Showroom.configure { |c| c.store = 'acme.myshopify.com' }
|
|
7
|
+
|
|
8
|
+
Showroom::Product.where(limit: 5).each { |p| puts "#{p.title} — #{p.variants.first.price}" }
|
|
9
|
+
Showroom::Product.find('my-bike').available?
|
|
10
|
+
Showroom::Product.all.first(100)
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
gem install showroom
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Or in your Gemfile:
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
gem 'showroom'
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Configuration
|
|
26
|
+
|
|
27
|
+
### Single-store (module-level)
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
Showroom.configure do |c|
|
|
31
|
+
c.store = 'acme.myshopify.com' # required
|
|
32
|
+
c.per_page = 50 # default, max 250
|
|
33
|
+
c.pagination_depth = 50 # max pages for .all / each_page
|
|
34
|
+
c.timeout = 30 # seconds
|
|
35
|
+
c.open_timeout = 10 # seconds
|
|
36
|
+
c.user_agent = 'MyApp/1.0' # optional override
|
|
37
|
+
end
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
All options can also be set via environment variables:
|
|
41
|
+
|
|
42
|
+
| Variable | Key |
|
|
43
|
+
|-------------------------|--------------------|
|
|
44
|
+
| `SHOWROOM_STORE` | `store` |
|
|
45
|
+
| `SHOWROOM_USER_AGENT` | `user_agent` |
|
|
46
|
+
| `SHOWROOM_PER_PAGE` | `per_page` |
|
|
47
|
+
| `SHOWROOM_TIMEOUT` | `timeout` |
|
|
48
|
+
| `SHOWROOM_OPEN_TIMEOUT` | `open_timeout` |
|
|
49
|
+
|
|
50
|
+
### Multi-store (per-client instances)
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
acme = Showroom::Client.new(store: 'acme.myshopify.com')
|
|
54
|
+
globo = Showroom::Client.new(store: 'globo.myshopify.com')
|
|
55
|
+
|
|
56
|
+
acme.products(limit: 5).map(&:title)
|
|
57
|
+
globo.product('road-bike').price
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Products
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
# List (single page)
|
|
64
|
+
Showroom::Product.where(limit: 10, product_type: 'Road Bike')
|
|
65
|
+
|
|
66
|
+
# Single
|
|
67
|
+
product = Showroom::Product.find('lorem-road-bike')
|
|
68
|
+
product.title # => "Lorem Road Bike"
|
|
69
|
+
product.handle # => "lorem-road-bike"
|
|
70
|
+
product.vendor # => "Lorem Cycles"
|
|
71
|
+
product.available? # => true (any variant in stock)
|
|
72
|
+
product.price # => "749.00" (lowest variant price)
|
|
73
|
+
product.price_range # => "749.00–899.00"
|
|
74
|
+
product.featured_image # => #<Showroom::ProductImage ...>
|
|
75
|
+
|
|
76
|
+
# Variants
|
|
77
|
+
product.variants.each do |v|
|
|
78
|
+
puts "#{v.title} #{v.price} on_sale=#{v.on_sale?} available=#{v.available?}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# All pages — returns an Enumerator
|
|
82
|
+
Showroom::Product.all.each { |p| puts p.title }
|
|
83
|
+
|
|
84
|
+
# Explicit page iteration
|
|
85
|
+
Showroom::Product.each_page(limit: 250) do |batch, page|
|
|
86
|
+
puts "Page #{page}: #{batch.size} products"
|
|
87
|
+
end
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Module-level shortcuts
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
Showroom.products(limit: 5) # => Array<Product>
|
|
94
|
+
Showroom.product('lorem-road-bike') # => Product
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Search
|
|
98
|
+
|
|
99
|
+
Showroom wraps Shopify's `/search/suggest.json` endpoint via `Showroom.search`.
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
result = Showroom.search('road bike', types: [:product, :collection], limit: 5)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Parameters
|
|
106
|
+
|
|
107
|
+
| Parameter | Type | Default | Description |
|
|
108
|
+
|-----------|------|---------|-------------|
|
|
109
|
+
| `q` (first arg) | String | — | Search query |
|
|
110
|
+
| `types:` | Array\<Symbol\> | `[:product, :collection]` | Resource types to include |
|
|
111
|
+
| `limit:` | Integer | `per_page` config | Max results per type |
|
|
112
|
+
|
|
113
|
+
Available `types:` values: `:product`, `:collection`, `:page`, `:article`, `:query`.
|
|
114
|
+
|
|
115
|
+
### Accessing results
|
|
116
|
+
|
|
117
|
+
```ruby
|
|
118
|
+
result = Showroom.search('lorem', types: [:product, :collection, :page, :article, :query])
|
|
119
|
+
|
|
120
|
+
result.products # => Array<Search::ProductSuggestion>
|
|
121
|
+
result.collections # => Array<Search::CollectionSuggestion>
|
|
122
|
+
result.pages # => Array<Search::PageSuggestion>
|
|
123
|
+
result.articles # => Array<Search::ArticleSuggestion>
|
|
124
|
+
result.queries # => Array<Search::QuerySuggestion>
|
|
125
|
+
|
|
126
|
+
result.products.first.title # => "Lorem Road Bike"
|
|
127
|
+
result.queries.first.text # => "lorem road bike"
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Loading full models
|
|
131
|
+
|
|
132
|
+
Product and collection suggestions expose a `#load` method that fetches the complete model record:
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
suggestion = result.products.first
|
|
136
|
+
product = suggestion.load # => Showroom::Product (full record, makes one HTTP request)
|
|
137
|
+
|
|
138
|
+
suggestion = result.collections.first
|
|
139
|
+
collection = suggestion.load # => Showroom::Collection
|
|
140
|
+
|
|
141
|
+
# Page, article, and query suggestions do not support #load
|
|
142
|
+
result.pages.first.load # => NoMethodError
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Error handling
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
begin
|
|
149
|
+
Showroom::Product.find('does-not-exist')
|
|
150
|
+
rescue Showroom::NotFound => e
|
|
151
|
+
puts "404: #{e.message}"
|
|
152
|
+
rescue Showroom::TooManyRequests
|
|
153
|
+
puts "Rate limited — back off and retry"
|
|
154
|
+
rescue Showroom::InvalidResponse
|
|
155
|
+
puts "Store may be password-protected or blocking requests"
|
|
156
|
+
rescue Showroom::ConnectionError
|
|
157
|
+
puts "Network error"
|
|
158
|
+
rescue Showroom::Error => e
|
|
159
|
+
puts "Other Showroom error: #{e}"
|
|
160
|
+
end
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Error hierarchy
|
|
164
|
+
|
|
165
|
+
```
|
|
166
|
+
Showroom::Error
|
|
167
|
+
├── ConfigurationError bad or missing store URL
|
|
168
|
+
├── ConnectionError network failure, timeout
|
|
169
|
+
├── InvalidResponse 200 OK but body is HTML (password-protected store)
|
|
170
|
+
└── ResponseError HTTP status >= 400
|
|
171
|
+
├── ClientError 4xx
|
|
172
|
+
│ ├── BadRequest 400
|
|
173
|
+
│ ├── NotFound 404
|
|
174
|
+
│ ├── UnprocessableEntity 422
|
|
175
|
+
│ └── TooManyRequests 429
|
|
176
|
+
└── ServerError 5xx
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Custom middleware
|
|
180
|
+
|
|
181
|
+
```ruby
|
|
182
|
+
Showroom.configure do |c|
|
|
183
|
+
c.store = 'acme.myshopify.com'
|
|
184
|
+
c.middleware = ->(conn) {
|
|
185
|
+
conn.response :logger
|
|
186
|
+
}
|
|
187
|
+
end
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## Caveats
|
|
191
|
+
|
|
192
|
+
- **Password-protected stores** return `200 OK` with an HTML body. Showroom raises `InvalidResponse` in this case.
|
|
193
|
+
- **Rate limits** — Shopify's public endpoints allow roughly 2 req/s per IP. Showroom raises `TooManyRequests` (429) but does not retry automatically. Add your own back-off logic.
|
|
194
|
+
- **`/products.json` may be disabled** on some stores. You'll receive a `NotFound` or `ServerError`.
|
|
195
|
+
- **User-Agent** — some stores block the default Faraday UA. Showroom sets its own identifying header by default; override via `c.user_agent` if needed.
|
|
196
|
+
- **Search result ordering is not stable** — `/search/suggest.json` does not guarantee a consistent order across requests. Results with equal relevance scores may alternate non-deterministically. Do not rely on `result.products.first` being the same between calls.
|
|
197
|
+
|
|
198
|
+
## License
|
|
199
|
+
|
|
200
|
+
[GNU General Public License v3.0 or later](LICENSE).
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'core/configurable'
|
|
4
|
+
require_relative 'core/connection'
|
|
5
|
+
require_relative 'models'
|
|
6
|
+
|
|
7
|
+
module Showroom
|
|
8
|
+
# A configured HTTP client for a single Showroom store.
|
|
9
|
+
#
|
|
10
|
+
# Combines {Core::Configurable} (configuration DSL) with
|
|
11
|
+
# {Core::Connection} (Faraday-based HTTP).
|
|
12
|
+
#
|
|
13
|
+
# @example Single-store usage
|
|
14
|
+
# client = Showroom::Client.new(store: 'example.myshopify.com')
|
|
15
|
+
# client.get('/products.json', limit: 10)
|
|
16
|
+
#
|
|
17
|
+
# @example Multi-store usage
|
|
18
|
+
# eu_client = Showroom::Client.new(store: 'eu.shop.com')
|
|
19
|
+
# us_client = Showroom::Client.new(store: 'us.shop.com')
|
|
20
|
+
class Client
|
|
21
|
+
include Core::Configurable
|
|
22
|
+
include Core::Connection
|
|
23
|
+
|
|
24
|
+
# Initializes a new Client with the given options.
|
|
25
|
+
#
|
|
26
|
+
# Resets all keys to defaults first, then applies any provided options.
|
|
27
|
+
#
|
|
28
|
+
# @param opts [Hash] configuration overrides (keys from {Core::Configurable::KEYS})
|
|
29
|
+
# @option opts [String] :store the Shopify store domain or URL
|
|
30
|
+
# @option opts [String] :user_agent custom User-Agent string
|
|
31
|
+
# @option opts [Integer] :per_page results per page (clamped to MAX_PER_PAGE)
|
|
32
|
+
# @option opts [Integer] :pagination_depth maximum pages to fetch
|
|
33
|
+
# @option opts [Integer] :open_timeout connection timeout in seconds
|
|
34
|
+
# @option opts [Integer] :timeout read timeout in seconds
|
|
35
|
+
# @option opts [#call, nil] :middleware Faraday middleware proc
|
|
36
|
+
# @option opts [Hash] :connection_options extra Faraday connection options
|
|
37
|
+
def initialize(**opts)
|
|
38
|
+
reset!
|
|
39
|
+
opts.each { |key, value| send(:"#{key}=", value) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Fetches products from the store.
|
|
43
|
+
#
|
|
44
|
+
# @param params [Hash] Shopify query parameters
|
|
45
|
+
# @return [Array<Product>]
|
|
46
|
+
def products(**params)
|
|
47
|
+
get('/products.json', params).fetch('products', []).map { |h| Product.new(h) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Fetches a single product by handle.
|
|
51
|
+
#
|
|
52
|
+
# @param handle [String] the product handle
|
|
53
|
+
# @return [Product]
|
|
54
|
+
# @raise [Showroom::NotFound] when the product is not found
|
|
55
|
+
def product(handle)
|
|
56
|
+
get("/products/#{handle}.json")
|
|
57
|
+
.fetch('product') { raise Showroom::NotFound, handle }
|
|
58
|
+
.then { |h| Product.new(h) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Fetches collections from the store.
|
|
62
|
+
#
|
|
63
|
+
# @param params [Hash] Shopify query parameters
|
|
64
|
+
# @return [Array<Collection>]
|
|
65
|
+
def collections(**params)
|
|
66
|
+
get('/collections.json', params).fetch('collections', []).map { |h| Collection.new(h) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Fetches a single collection by handle.
|
|
70
|
+
#
|
|
71
|
+
# @param handle [String] the collection handle
|
|
72
|
+
# @return [Collection]
|
|
73
|
+
# @raise [Showroom::NotFound] when the collection is not found
|
|
74
|
+
def collection(handle)
|
|
75
|
+
get("/collections/#{handle}.json")
|
|
76
|
+
.fetch('collection') { raise Showroom::NotFound, handle }
|
|
77
|
+
.then { |h| Collection.new(h) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Calls the Shopify search suggest endpoint and returns a {Search::Result}.
|
|
81
|
+
#
|
|
82
|
+
# @param query_str [String] the search query
|
|
83
|
+
# @param types [Array<Symbol>] resource types to search (e.g. +:product+, +:collection+)
|
|
84
|
+
# @param limit [Integer] maximum results per type (defaults to {#per_page})
|
|
85
|
+
# @param params [Hash] additional query parameters forwarded to the API
|
|
86
|
+
# @return [Search::Result]
|
|
87
|
+
def search(query_str, types: %i[product collection], limit: per_page, **params)
|
|
88
|
+
query = { q: query_str, 'resources[limit]' => limit }
|
|
89
|
+
query['resources[type]'] = types.join(',') unless types.empty?
|
|
90
|
+
query.merge!(params)
|
|
91
|
+
raw = get('/search/suggest.json', query)
|
|
92
|
+
Search::Result.new(raw.dig('resources', 'results') || {})
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'default'
|
|
4
|
+
|
|
5
|
+
module Showroom
|
|
6
|
+
module Core
|
|
7
|
+
# Mixin that provides configuration DSL for the Showroom module and Client.
|
|
8
|
+
#
|
|
9
|
+
# When extended into a module or class it adds `configure`, `reset!`,
|
|
10
|
+
# `options`, and `same_options?`, plus individual key accessors.
|
|
11
|
+
#
|
|
12
|
+
# @example Module-level usage
|
|
13
|
+
# Showroom.configure do |c|
|
|
14
|
+
# c.store = 'example.myshopify.com'
|
|
15
|
+
# end
|
|
16
|
+
module Configurable
|
|
17
|
+
# Ordered list of all supported configuration keys.
|
|
18
|
+
KEYS = %i[
|
|
19
|
+
store
|
|
20
|
+
user_agent
|
|
21
|
+
per_page
|
|
22
|
+
pagination_depth
|
|
23
|
+
open_timeout
|
|
24
|
+
timeout
|
|
25
|
+
middleware
|
|
26
|
+
connection_options
|
|
27
|
+
debug
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
30
|
+
# @!method store
|
|
31
|
+
# @return [String, nil]
|
|
32
|
+
# @!method store=(value)
|
|
33
|
+
# @param value [String, nil]
|
|
34
|
+
# (Similar accessors exist for all KEYS.)
|
|
35
|
+
KEYS.each { |key| attr_accessor key }
|
|
36
|
+
|
|
37
|
+
# Yields self for block-style configuration.
|
|
38
|
+
#
|
|
39
|
+
# @yield [self]
|
|
40
|
+
# @return [self]
|
|
41
|
+
def configure
|
|
42
|
+
yield self
|
|
43
|
+
self
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Resets all keys to their defaults from {Default}.
|
|
47
|
+
#
|
|
48
|
+
# @return [void]
|
|
49
|
+
def reset!
|
|
50
|
+
KEYS.each { |key| send(:"#{key}=", Default.public_send(key)) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Returns a frozen hash snapshot of the current configuration.
|
|
54
|
+
#
|
|
55
|
+
# @return [Hash{Symbol => Object}]
|
|
56
|
+
def options
|
|
57
|
+
KEYS.to_h { |key| [key, send(key)] }.freeze
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Returns true when +other_options+ matches the current configuration.
|
|
61
|
+
#
|
|
62
|
+
# @param other_options [Hash]
|
|
63
|
+
# @return [Boolean]
|
|
64
|
+
def same_options?(other_options)
|
|
65
|
+
options == other_options
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Clamps per_page so it never exceeds {Default::MAX_PER_PAGE}.
|
|
69
|
+
#
|
|
70
|
+
# @param value [Integer]
|
|
71
|
+
# @return [void]
|
|
72
|
+
def per_page=(value)
|
|
73
|
+
@per_page = [value.to_i, Default::MAX_PER_PAGE].min
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'faraday'
|
|
4
|
+
|
|
5
|
+
module Showroom
|
|
6
|
+
module Core
|
|
7
|
+
# Mixin that provides HTTP connectivity via Faraday.
|
|
8
|
+
#
|
|
9
|
+
# Expects the including class to expose configuration keys from
|
|
10
|
+
# {Configurable}: +store+, +user_agent+, +open_timeout+, +timeout+,
|
|
11
|
+
# +middleware+, and +connection_options+.
|
|
12
|
+
module Connection
|
|
13
|
+
# @return [Faraday::Response, nil] the last HTTP response object
|
|
14
|
+
attr_reader :last_response
|
|
15
|
+
|
|
16
|
+
# Memoized Faraday connection built from the current configuration.
|
|
17
|
+
#
|
|
18
|
+
# @return [Faraday::Connection]
|
|
19
|
+
def agent
|
|
20
|
+
@agent ||= build_agent
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Performs a GET request and returns the parsed response body.
|
|
24
|
+
#
|
|
25
|
+
# @param path [String] path relative to the store base URL
|
|
26
|
+
# @param params [Hash] query parameters
|
|
27
|
+
# @return [Object] parsed JSON body (Hash or Array)
|
|
28
|
+
def get(path, params = {})
|
|
29
|
+
puts "GET #{path} with params #{params}" if Showroom.debug
|
|
30
|
+
@last_response = agent.get(path, params)
|
|
31
|
+
@last_response.body
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Iterates through paginated responses, yielding each page of items.
|
|
35
|
+
#
|
|
36
|
+
# Stops when a page returns an empty array or +pagination_depth+ is
|
|
37
|
+
# reached.
|
|
38
|
+
#
|
|
39
|
+
# @param path [String] path relative to the store base URL
|
|
40
|
+
# @param key [String] top-level JSON key containing the items array
|
|
41
|
+
# @param params [Hash] base query parameters (page/limit are added automatically)
|
|
42
|
+
# @yield [items, page] items on the current page and the page number
|
|
43
|
+
# @yieldparam items [Array] the deserialized items for this page
|
|
44
|
+
# @yieldparam page [Integer] 1-based page number
|
|
45
|
+
# @return [void]
|
|
46
|
+
def paginate(path, key, params = {}, max_pages: pagination_depth, &blk)
|
|
47
|
+
page_limit = per_page
|
|
48
|
+
|
|
49
|
+
(1..max_pages).each do |page|
|
|
50
|
+
paged_params = params.merge(limit: page_limit, page: page)
|
|
51
|
+
body = get(path, paged_params)
|
|
52
|
+
items = body.is_a?(Hash) ? body[key] || body[key.to_s] : body
|
|
53
|
+
|
|
54
|
+
break if items.nil? || items.empty?
|
|
55
|
+
|
|
56
|
+
blk.call(items, page)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# Builds and returns a new Faraday connection.
|
|
63
|
+
#
|
|
64
|
+
# @return [Faraday::Connection]
|
|
65
|
+
def build_agent
|
|
66
|
+
base = StoreUrl.resolve(store)
|
|
67
|
+
opts = (connection_options || {}).merge(url: base)
|
|
68
|
+
Faraday.new(opts) do |conn|
|
|
69
|
+
configure_conn(conn)
|
|
70
|
+
middleware&.call(conn)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Applies default headers, timeouts, and middleware to +conn+.
|
|
75
|
+
#
|
|
76
|
+
# @param conn [Faraday::Connection]
|
|
77
|
+
# @return [void]
|
|
78
|
+
def configure_conn(conn)
|
|
79
|
+
conn.headers['User-Agent'] = user_agent
|
|
80
|
+
conn.options.open_timeout = open_timeout
|
|
81
|
+
conn.options.timeout = timeout
|
|
82
|
+
conn.use Http::Middleware::RaiseError
|
|
83
|
+
conn.response :json, content_type: /\bjson\b/, parser_options: { symbolize_names: false }
|
|
84
|
+
conn.adapter Faraday.default_adapter
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Showroom
|
|
4
|
+
module Core
|
|
5
|
+
# Mixin that adds {#calculate_count} to model classes exposing a paginated
|
|
6
|
+
# index endpoint.
|
|
7
|
+
#
|
|
8
|
+
# Uses an exponential probe followed by binary search to locate the last
|
|
9
|
+
# non-empty page, then derives the total count. Costs O(log n) HTTP
|
|
10
|
+
# requests where n is the number of pages.
|
|
11
|
+
#
|
|
12
|
+
# The count is **approximate** — items may be added or removed between
|
|
13
|
+
# requests.
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# Product.calculate_count # => 2847 (O(log n) requests)
|
|
17
|
+
# Collection.calculate_count # => 42
|
|
18
|
+
module Countable
|
|
19
|
+
MAX_PER_PAGE = 250
|
|
20
|
+
MAX_PAGE = 100
|
|
21
|
+
MAX_COUNT = MAX_PER_PAGE * MAX_PAGE # 25_000
|
|
22
|
+
|
|
23
|
+
# Estimates the total number of items via binary search over pages.
|
|
24
|
+
#
|
|
25
|
+
# Shopify's public endpoints reject page numbers above 100, so the
|
|
26
|
+
# maximum reportable count is **25,000** (100 pages × 250 per page).
|
|
27
|
+
# Stores with more items will return 25,000. Any +limit:+ key in
|
|
28
|
+
# +params+ is ignored — the probe always uses +MAX_PER_PAGE+ (250).
|
|
29
|
+
#
|
|
30
|
+
# @param params [Hash] additional query parameters forwarded to the
|
|
31
|
+
# index endpoint (e.g. +product_type:+, +vendor:+). +limit:+ is ignored.
|
|
32
|
+
# @return [Integer] approximate total item count, capped at 25,000
|
|
33
|
+
def calculate_count(**params)
|
|
34
|
+
fetch = ->(page) { page_size(page, **params.except(:limit)) }
|
|
35
|
+
upper = probe_upper_bound(fetch)
|
|
36
|
+
return 0 if upper == 1 && fetch.call(1).zero?
|
|
37
|
+
|
|
38
|
+
tally(fetch, upper)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def tally(fetch, upper)
|
|
44
|
+
result = binary_search(fetch, upper / 2, upper)
|
|
45
|
+
total = (result[:lower] * MAX_PER_PAGE) + (result[:upper_size] || fetch.call(result[:upper]))
|
|
46
|
+
if total >= MAX_COUNT
|
|
47
|
+
warn "[Showroom] calculate_count hit the #{MAX_COUNT} item ceiling — the store likely has more."
|
|
48
|
+
end
|
|
49
|
+
total
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def probe_upper_bound(fetch)
|
|
53
|
+
upper = 1
|
|
54
|
+
upper = [upper * 2, MAX_PAGE].min while fetch.call(upper) == MAX_PER_PAGE && upper < MAX_PAGE
|
|
55
|
+
upper
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def binary_search(fetch, lower, upper)
|
|
59
|
+
upper_size = nil
|
|
60
|
+
lower, upper, upper_size = binary_search_step(fetch, lower, upper) while lower < upper - 1
|
|
61
|
+
{ lower: lower, upper: upper, upper_size: upper_size }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def binary_search_step(fetch, lower, upper)
|
|
65
|
+
mid = (lower + upper) / 2
|
|
66
|
+
size = fetch.call(mid)
|
|
67
|
+
size == MAX_PER_PAGE ? [mid, upper, nil] : [lower, mid, size]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def page_size(page, **params)
|
|
71
|
+
body = Showroom.client.get(index_path, params.merge(limit: MAX_PER_PAGE, page: page))
|
|
72
|
+
items = body.is_a?(Hash) ? body[index_key] : body
|
|
73
|
+
items&.size || 0
|
|
74
|
+
rescue Showroom::BadRequest
|
|
75
|
+
0
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Showroom
|
|
4
|
+
module Core
|
|
5
|
+
# Provides default configuration values for the gem, with optional
|
|
6
|
+
# overrides via environment variables.
|
|
7
|
+
module Default
|
|
8
|
+
# Maximum allowed value for per_page.
|
|
9
|
+
MAX_PER_PAGE = 250
|
|
10
|
+
|
|
11
|
+
# @return [nil] store is required and has no default
|
|
12
|
+
def self.store
|
|
13
|
+
ENV.fetch('SHOWROOM_STORE', nil)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# @return [String] default User-Agent header value
|
|
17
|
+
def self.user_agent
|
|
18
|
+
ENV.fetch(
|
|
19
|
+
'SHOWROOM_USER_AGENT',
|
|
20
|
+
"Showroom/#{VERSION} (+https://github.com/01max/showroom; Ruby/#{RUBY_VERSION})"
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @return [Integer] number of results per page, clamped to MAX_PER_PAGE
|
|
25
|
+
def self.per_page
|
|
26
|
+
raw = ENV.fetch('SHOWROOM_PER_PAGE', MAX_PER_PAGE).to_i
|
|
27
|
+
[raw, MAX_PER_PAGE].min
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @return [Integer] maximum number of pages to fetch during pagination
|
|
31
|
+
def self.pagination_depth
|
|
32
|
+
50
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @return [Integer] open (connect) timeout in seconds
|
|
36
|
+
def self.open_timeout
|
|
37
|
+
ENV.fetch('SHOWROOM_OPEN_TIMEOUT', 10).to_i
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [Integer] read timeout in seconds
|
|
41
|
+
def self.timeout
|
|
42
|
+
ENV.fetch('SHOWROOM_TIMEOUT', 30).to_i
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @return [nil] no custom middleware by default
|
|
46
|
+
def self.middleware
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @return [Hash] extra options passed to Faraday connection
|
|
51
|
+
def self.connection_options
|
|
52
|
+
{}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [Boolean] whether to print debug output for each request
|
|
56
|
+
def self.debug # rubocop:disable Naming/PredicateMethod
|
|
57
|
+
ENV.fetch('SHOWROOM_DEBUG', nil) == '1'
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Showroom
|
|
4
|
+
# Base error class for all Showroom errors.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when the gem is misconfigured (e.g. missing or invalid store URL).
|
|
8
|
+
class ConfigurationError < Error; end
|
|
9
|
+
|
|
10
|
+
# Raised when a network-level failure occurs (connection refused, timeout, etc.).
|
|
11
|
+
class ConnectionError < Error; end
|
|
12
|
+
|
|
13
|
+
# Raised when the response body is not JSON (e.g. store is password-protected).
|
|
14
|
+
class InvalidResponse < Error; end
|
|
15
|
+
|
|
16
|
+
# Raised for HTTP responses with status >= 400.
|
|
17
|
+
#
|
|
18
|
+
# @attr_reader status [Integer] HTTP status code
|
|
19
|
+
# @attr_reader body [String, nil] raw response body
|
|
20
|
+
# @attr_reader headers [Hash] response headers
|
|
21
|
+
class ResponseError < Error
|
|
22
|
+
attr_reader :status, :body, :headers
|
|
23
|
+
|
|
24
|
+
# @param message [String] error message
|
|
25
|
+
# @param status [Integer] HTTP status code
|
|
26
|
+
# @param body [String, nil] raw response body
|
|
27
|
+
# @param headers [Hash] response headers
|
|
28
|
+
def initialize(message = nil, status: nil, body: nil, headers: {})
|
|
29
|
+
super(message || "HTTP #{status}")
|
|
30
|
+
@status = status
|
|
31
|
+
@body = body
|
|
32
|
+
@headers = headers
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Raised for HTTP 4xx responses.
|
|
37
|
+
class ClientError < ResponseError; end
|
|
38
|
+
|
|
39
|
+
# Raised for HTTP 400 Bad Request.
|
|
40
|
+
class BadRequest < ClientError; end
|
|
41
|
+
|
|
42
|
+
# Raised for HTTP 404 Not Found.
|
|
43
|
+
class NotFound < ClientError; end
|
|
44
|
+
|
|
45
|
+
# Raised for HTTP 422 Unprocessable Entity.
|
|
46
|
+
class UnprocessableEntity < ClientError; end
|
|
47
|
+
|
|
48
|
+
# Raised for HTTP 429 Too Many Requests.
|
|
49
|
+
class TooManyRequests < ClientError; end
|
|
50
|
+
|
|
51
|
+
# Raised for HTTP 5xx responses.
|
|
52
|
+
class ServerError < ResponseError; end
|
|
53
|
+
end
|