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
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
5
|
+
module Showroom
|
|
6
|
+
module Core
|
|
7
|
+
# Resolves a raw store identifier into a canonical HTTPS base URL.
|
|
8
|
+
module StoreUrl
|
|
9
|
+
# Resolves +store+ to a base URL string.
|
|
10
|
+
#
|
|
11
|
+
# @param store [String, nil] raw store value (domain, URL, etc.)
|
|
12
|
+
# @return [String] canonical HTTPS base URL with no trailing slash
|
|
13
|
+
# @raise [ConfigurationError] if store is blank or contains a non-root path
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# StoreUrl.resolve('example.myshopify.com') # => "https://example.myshopify.com"
|
|
17
|
+
# StoreUrl.resolve('https://example.myshopify.com/') # => "https://example.myshopify.com"
|
|
18
|
+
# StoreUrl.resolve('http://example.myshopify.com') # => "https://example.myshopify.com"
|
|
19
|
+
def self.resolve(store)
|
|
20
|
+
raise ConfigurationError, 'store must not be blank' if store.nil? || store.strip.empty?
|
|
21
|
+
|
|
22
|
+
uri = parse_uri(store.strip)
|
|
23
|
+
validate_path!(uri, store.strip)
|
|
24
|
+
normalise(uri)
|
|
25
|
+
rescue URI::InvalidURIError => e
|
|
26
|
+
raise ConfigurationError, "invalid store URL: #{e.message}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param raw [String] stripped store string
|
|
30
|
+
# @return [URI::Generic]
|
|
31
|
+
def self.parse_uri(raw)
|
|
32
|
+
raw = "https://#{raw}" unless raw.match?(%r{\Ahttps?://}i)
|
|
33
|
+
URI.parse(raw)
|
|
34
|
+
end
|
|
35
|
+
private_class_method :parse_uri
|
|
36
|
+
|
|
37
|
+
# @param uri [URI::Generic]
|
|
38
|
+
# @param original [String] original stripped store input (for error messages)
|
|
39
|
+
# @raise [ConfigurationError] if the URI contains a non-root path
|
|
40
|
+
def self.validate_path!(uri, original)
|
|
41
|
+
path = uri.path.to_s.delete_suffix('/')
|
|
42
|
+
return if path.empty?
|
|
43
|
+
|
|
44
|
+
raise ConfigurationError, "store must be a bare domain, got path: #{original}"
|
|
45
|
+
end
|
|
46
|
+
private_class_method :validate_path!
|
|
47
|
+
|
|
48
|
+
# @param uri [URI::Generic]
|
|
49
|
+
# @return [String] canonical HTTPS base URL
|
|
50
|
+
def self.normalise(uri)
|
|
51
|
+
uri.scheme = 'https'
|
|
52
|
+
uri.path = ''
|
|
53
|
+
uri.query = nil
|
|
54
|
+
uri.fragment = nil
|
|
55
|
+
uri.to_s
|
|
56
|
+
end
|
|
57
|
+
private_class_method :normalise
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'faraday'
|
|
4
|
+
|
|
5
|
+
module Showroom
|
|
6
|
+
# Faraday HTTP layer (adapters, middleware).
|
|
7
|
+
module Http
|
|
8
|
+
# Faraday middleware classes.
|
|
9
|
+
module Middleware
|
|
10
|
+
# Faraday middleware that maps HTTP error statuses and parsing failures
|
|
11
|
+
# to Showroom error classes.
|
|
12
|
+
#
|
|
13
|
+
# @example Registration
|
|
14
|
+
# conn.use Showroom::Http::Middleware::RaiseError
|
|
15
|
+
class RaiseError < Faraday::Middleware
|
|
16
|
+
# Maps specific HTTP status codes to Showroom error classes.
|
|
17
|
+
STATUS_MAP = {
|
|
18
|
+
400 => Showroom::BadRequest,
|
|
19
|
+
404 => Showroom::NotFound,
|
|
20
|
+
422 => Showroom::UnprocessableEntity,
|
|
21
|
+
429 => Showroom::TooManyRequests
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
# Executes the middleware, rescuing network-level Faraday errors.
|
|
25
|
+
#
|
|
26
|
+
# @param env [Faraday::Env]
|
|
27
|
+
# @raise [ConnectionError] on connection or timeout failure
|
|
28
|
+
# @raise [InvalidResponse] on JSON parsing failure
|
|
29
|
+
def call(env)
|
|
30
|
+
@app.call(env).on_complete { |response_env| on_complete(response_env) }
|
|
31
|
+
rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
|
|
32
|
+
raise ConnectionError, e.message
|
|
33
|
+
rescue Faraday::ParsingError
|
|
34
|
+
raise InvalidResponse,
|
|
35
|
+
'Response was not JSON — store may be password-protected or blocking requests'
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
# Inspects the completed response and raises the appropriate error.
|
|
41
|
+
#
|
|
42
|
+
# @param env [Faraday::Env]
|
|
43
|
+
# @raise [InvalidResponse] when a 200 response has a non-JSON content-type
|
|
44
|
+
# @raise [ResponseError] (or subclass) for 4xx/5xx responses
|
|
45
|
+
def on_complete(env)
|
|
46
|
+
check_html_response!(env) if env.status == 200
|
|
47
|
+
raise_for_status!(env) if env.status >= 400
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @param env [Faraday::Env]
|
|
51
|
+
# @raise [InvalidResponse] if Content-Type indicates HTML
|
|
52
|
+
def check_html_response!(env)
|
|
53
|
+
content_type = env.response_headers['content-type'].to_s
|
|
54
|
+
return unless content_type.include?('text/html')
|
|
55
|
+
|
|
56
|
+
raise InvalidResponse,
|
|
57
|
+
'Response was not JSON — store may be password-protected or blocking requests'
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @param env [Faraday::Env]
|
|
61
|
+
# @raise [ResponseError] (or subclass) matching the status code
|
|
62
|
+
def raise_for_status!(env)
|
|
63
|
+
klass = error_class(env.status)
|
|
64
|
+
raise klass.new(nil, status: env.status, body: env.body, headers: env.response_headers)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @param status [Integer]
|
|
68
|
+
# @return [Class] the most specific matching Showroom error class
|
|
69
|
+
def error_class(status)
|
|
70
|
+
STATUS_MAP.fetch(status) do
|
|
71
|
+
if status >= 500
|
|
72
|
+
ServerError
|
|
73
|
+
else
|
|
74
|
+
ClientError
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Showroom
|
|
4
|
+
# Represents a Shopify collection with class-level query methods
|
|
5
|
+
# that delegate to {Showroom.client} and an instance-level products fetcher.
|
|
6
|
+
#
|
|
7
|
+
# @example Fetching collections
|
|
8
|
+
# collections = Showroom::Collection.where
|
|
9
|
+
# collection = Showroom::Collection.find('lorem-helmets')
|
|
10
|
+
#
|
|
11
|
+
# @example Fetching products in a collection
|
|
12
|
+
# collection.products(limit: 10)
|
|
13
|
+
class Collection < Resource
|
|
14
|
+
main_attrs :id, :title, :handle, :products_count
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
include Core::Countable
|
|
18
|
+
|
|
19
|
+
def index_path = '/collections.json'
|
|
20
|
+
def index_key = 'collections'
|
|
21
|
+
private :index_path, :index_key
|
|
22
|
+
|
|
23
|
+
# Fetches collections matching the given query parameters.
|
|
24
|
+
#
|
|
25
|
+
# @param params [Hash] Shopify query parameters
|
|
26
|
+
# @return [Array<Collection>]
|
|
27
|
+
def where(limit: Showroom.per_page, **params)
|
|
28
|
+
Showroom.client.get('/collections.json', params.merge(limit: limit))
|
|
29
|
+
.fetch('collections', [])
|
|
30
|
+
.map { |h| new(h) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Fetches a single collection by handle.
|
|
34
|
+
#
|
|
35
|
+
# @param handle [String] the collection handle
|
|
36
|
+
# @return [Collection]
|
|
37
|
+
# @raise [Showroom::NotFound] when the collection is not found
|
|
38
|
+
def find(handle)
|
|
39
|
+
Showroom.client.get("/collections/#{handle}.json")
|
|
40
|
+
.fetch('collection') { raise Showroom::NotFound, handle }
|
|
41
|
+
.then { |h| new(h) }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Returns the number of products in this collection as reported by the API.
|
|
46
|
+
#
|
|
47
|
+
# @return [Integer, nil]
|
|
48
|
+
def products_count
|
|
49
|
+
@attrs['products_count']
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Fetches a single page of products belonging to this collection.
|
|
53
|
+
#
|
|
54
|
+
# Returns at most one page of results. Use +limit: 250+ to maximise the
|
|
55
|
+
# number of products returned in a single request. For collections with
|
|
56
|
+
# more products than the page size, pass +page:+ explicitly to retrieve
|
|
57
|
+
# subsequent pages.
|
|
58
|
+
#
|
|
59
|
+
# @param params [Hash] Shopify query parameters (e.g. +limit:+, +page:+)
|
|
60
|
+
# @return [Array<Product>]
|
|
61
|
+
def products(**params)
|
|
62
|
+
Showroom.client.get("/collections/#{handle}/products.json", params)
|
|
63
|
+
.fetch('products', [])
|
|
64
|
+
.map { |h| Product.new(h) }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Showroom
|
|
4
|
+
# Represents a Shopify product with associations, convenience methods,
|
|
5
|
+
# and class-level query methods that delegate to {Showroom.client}.
|
|
6
|
+
#
|
|
7
|
+
# @example Fetching products
|
|
8
|
+
# products = Showroom::Product.where(product_type: 'Road Bike')
|
|
9
|
+
# product = Showroom::Product.find('lorem-road-bike')
|
|
10
|
+
class Product < Resource
|
|
11
|
+
main_attrs :id, :title, :handle, :vendor, :product_type, :price, :price_range
|
|
12
|
+
|
|
13
|
+
has_many :variants, ProductVariant
|
|
14
|
+
has_many :images, ProductImage
|
|
15
|
+
has_many :options, ProductOption
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
include Core::Countable
|
|
19
|
+
|
|
20
|
+
def index_path = '/products.json'
|
|
21
|
+
def index_key = 'products'
|
|
22
|
+
private :index_path, :index_key
|
|
23
|
+
|
|
24
|
+
# Fetches products matching the given query parameters.
|
|
25
|
+
#
|
|
26
|
+
# @param params [Hash] Shopify query parameters (e.g. product_type:, vendor:)
|
|
27
|
+
# @return [Array<Product>]
|
|
28
|
+
def where(limit: Showroom.per_page, **params)
|
|
29
|
+
Showroom.client.get('/products.json', params.merge(limit: limit))
|
|
30
|
+
.fetch('products', [])
|
|
31
|
+
.map { |h| new(h) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Fetches a single product by handle.
|
|
35
|
+
#
|
|
36
|
+
# @param handle [String] the product handle
|
|
37
|
+
# @return [Product]
|
|
38
|
+
# @raise [Showroom::NotFound] when the product is not found
|
|
39
|
+
def find(handle)
|
|
40
|
+
Showroom.client.get("/products/#{handle}.json")
|
|
41
|
+
.fetch('product') { raise Showroom::NotFound, handle }
|
|
42
|
+
.then { |h| new(h) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Returns an Enumerator that iterates over products across multiple pages.
|
|
46
|
+
#
|
|
47
|
+
# You must pass either +max_pages:+ (an explicit ceiling) or set
|
|
48
|
+
# +force_all_without_limit: true+ to acknowledge that the number of
|
|
49
|
+
# requests is unbounded. The latter emits a warning.
|
|
50
|
+
#
|
|
51
|
+
# @param max_pages [Integer, nil] maximum number of pages to fetch
|
|
52
|
+
# @param force_all_without_limit [Boolean] when true, fetch all pages
|
|
53
|
+
# up to +pagination_depth+ without a hard cap (emits a warning)
|
|
54
|
+
# @param params [Hash] additional query parameters
|
|
55
|
+
# @return [Enumerator<Product>]
|
|
56
|
+
# @raise [ArgumentError] when neither +max_pages:+ nor
|
|
57
|
+
# +force_all_without_limit: true+ is given
|
|
58
|
+
def all(max_pages: nil, force_all_without_limit: false, **params)
|
|
59
|
+
Enumerator.new do |yielder|
|
|
60
|
+
each_page(max_pages: max_pages, force_all_without_limit: force_all_without_limit,
|
|
61
|
+
**params) do |page_products, _page|
|
|
62
|
+
page_products.each { |p| yielder << p }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Iterates through pages of products, yielding each page.
|
|
68
|
+
#
|
|
69
|
+
# You must pass either +max_pages:+ or +force_all_without_limit: true+.
|
|
70
|
+
# Without an explicit ceiling, unbounded pagination can issue dozens of
|
|
71
|
+
# HTTP requests silently. When +force_all_without_limit: true+ is given,
|
|
72
|
+
# a warning is emitted and iteration proceeds up to +pagination_depth+.
|
|
73
|
+
#
|
|
74
|
+
# @param max_pages [Integer, nil] maximum number of pages to fetch
|
|
75
|
+
# @param force_all_without_limit [Boolean] bypass the requirement at your
|
|
76
|
+
# own risk; emits a +Kernel.warn+ and uses +pagination_depth+ as the cap
|
|
77
|
+
# @param limit [Integer] results per page (defaults to +Showroom.per_page+)
|
|
78
|
+
# @param params [Hash] additional query parameters
|
|
79
|
+
# @yield [products, page] the products for this page and the 1-based page number
|
|
80
|
+
# @yieldparam products [Array<Product>]
|
|
81
|
+
# @yieldparam page [Integer]
|
|
82
|
+
# @return [void]
|
|
83
|
+
# @raise [ArgumentError] when neither +max_pages:+ nor
|
|
84
|
+
# +force_all_without_limit: true+ is given
|
|
85
|
+
def each_page(max_pages: nil, force_all_without_limit: false, limit: Showroom.per_page, **params, &blk)
|
|
86
|
+
validate_pagination_args!(max_pages, force_all_without_limit)
|
|
87
|
+
effective_depth = max_pages || Showroom.client.pagination_depth
|
|
88
|
+
Showroom.client.paginate('/products.json', 'products', params.merge(limit: limit),
|
|
89
|
+
max_pages: effective_depth) do |items, page|
|
|
90
|
+
blk.call(items.map { |h| new(h) }, page)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def validate_pagination_args!(max_pages, force_all_without_limit)
|
|
97
|
+
if max_pages.nil? && !force_all_without_limit
|
|
98
|
+
raise ArgumentError,
|
|
99
|
+
'Product.each_page requires max_pages: N or force_all_without_limit: true. ' \
|
|
100
|
+
'Unbounded pagination can issue many HTTP requests. ' \
|
|
101
|
+
'Pass max_pages: to set an explicit ceiling.'
|
|
102
|
+
end
|
|
103
|
+
return unless force_all_without_limit && max_pages.nil?
|
|
104
|
+
|
|
105
|
+
warn '[Showroom] force_all_without_limit: true — pagination is unbounded and may issue ' \
|
|
106
|
+
"up to #{Showroom.client.pagination_depth} HTTP requests."
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Returns the lowest variant price as a String.
|
|
111
|
+
#
|
|
112
|
+
# @return [String, nil]
|
|
113
|
+
def price
|
|
114
|
+
variants.min_by { |v| v['price'].to_f }&.then { |v| v['price'] }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Returns a price range string ("min–max") or just the price if all variants
|
|
118
|
+
# share the same price.
|
|
119
|
+
#
|
|
120
|
+
# @return [String, nil]
|
|
121
|
+
def price_range
|
|
122
|
+
prices = variants.map { |v| v['price'] }.uniq
|
|
123
|
+
return nil if prices.empty?
|
|
124
|
+
|
|
125
|
+
prices.length == 1 ? prices.first : "#{prices.min} - #{prices.max}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Returns true when at least one variant is available for purchase.
|
|
129
|
+
#
|
|
130
|
+
# @return [Boolean]
|
|
131
|
+
def available?
|
|
132
|
+
variants.any?(&:available?)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Returns the first image, or nil if there are no images.
|
|
136
|
+
#
|
|
137
|
+
# @return [ProductImage, nil]
|
|
138
|
+
def featured_image
|
|
139
|
+
images.first
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Showroom
|
|
4
|
+
# Represents a Shopify product image.
|
|
5
|
+
#
|
|
6
|
+
# @example
|
|
7
|
+
# image = ProductImage.new('id' => 1, 'src' => 'https://example.com/img.jpg')
|
|
8
|
+
# image.src # => "https://example.com/img.jpg"
|
|
9
|
+
class ProductImage < Resource
|
|
10
|
+
main_attrs :id, :src, :position
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Showroom
|
|
4
|
+
# Represents a Shopify product option (e.g. Size, Color).
|
|
5
|
+
#
|
|
6
|
+
# @example
|
|
7
|
+
# opt = ProductOption.new('name' => 'Size', 'position' => 1, 'values' => ['S', 'M'])
|
|
8
|
+
# opt.name # => "Size"
|
|
9
|
+
# opt.values # => ["S", "M"]
|
|
10
|
+
class ProductOption < Resource
|
|
11
|
+
main_attrs :name, :position
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Showroom
|
|
4
|
+
# Represents a Shopify product variant.
|
|
5
|
+
#
|
|
6
|
+
# @example
|
|
7
|
+
# variant = ProductVariant.new('title' => 'S / Red', 'price' => '899.00', 'available' => true)
|
|
8
|
+
# variant.available? # => true
|
|
9
|
+
# variant.on_sale? # => false
|
|
10
|
+
class ProductVariant < Resource
|
|
11
|
+
main_attrs :id, :title, :price, :sku
|
|
12
|
+
|
|
13
|
+
# Returns true when the variant is available for purchase.
|
|
14
|
+
#
|
|
15
|
+
# @return [Boolean]
|
|
16
|
+
def available?
|
|
17
|
+
@attrs['available'] == true
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Returns true when +compare_at_price+ is present and greater than +price+.
|
|
21
|
+
#
|
|
22
|
+
# @return [Boolean]
|
|
23
|
+
def on_sale?
|
|
24
|
+
cap = @attrs['compare_at_price']
|
|
25
|
+
return false if cap.nil? || cap.to_s.empty?
|
|
26
|
+
|
|
27
|
+
cap.to_f > @attrs['price'].to_f
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Returns the selected options for this variant as an Array of values.
|
|
31
|
+
#
|
|
32
|
+
# Collects option1, option2, option3 in order, omitting nil values.
|
|
33
|
+
#
|
|
34
|
+
# @return [Array<String>]
|
|
35
|
+
def options
|
|
36
|
+
[@attrs['option1'], @attrs['option2'], @attrs['option3']].compact
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Showroom
|
|
4
|
+
# Base class for all Showroom model objects.
|
|
5
|
+
#
|
|
6
|
+
# Wraps a plain Hash, providing dot-notation attribute access, association
|
|
7
|
+
# DSL macros ({.has_many} / {.has_one}), deep serialization via {#to_h},
|
|
8
|
+
# and a configurable {#inspect} output via {.main_attrs}.
|
|
9
|
+
#
|
|
10
|
+
# @example Basic usage
|
|
11
|
+
# resource = Resource.new('id' => 1, 'title' => 'Foo')
|
|
12
|
+
# resource.id # => 1
|
|
13
|
+
# resource.title # => "Foo"
|
|
14
|
+
class Resource
|
|
15
|
+
# @return [Hash] the normalized attribute hash
|
|
16
|
+
attr_reader :attrs
|
|
17
|
+
|
|
18
|
+
# Initializes a Resource from a Hash, normalizing all keys to strings.
|
|
19
|
+
#
|
|
20
|
+
# @param hash [Hash] attribute data
|
|
21
|
+
def initialize(hash = {})
|
|
22
|
+
@attrs = hash.transform_keys(&:to_s)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
class << self
|
|
26
|
+
# Declares which attribute keys are included in {#inspect} output.
|
|
27
|
+
#
|
|
28
|
+
# @param keys [Array<Symbol>] attribute names to display in inspect
|
|
29
|
+
# @return [void]
|
|
30
|
+
def main_attrs(*keys)
|
|
31
|
+
@main_attrs = keys.map(&:to_s)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Returns the list of keys configured via {.main_attrs}.
|
|
35
|
+
#
|
|
36
|
+
# @return [Array<String>]
|
|
37
|
+
def main_attr_keys
|
|
38
|
+
@main_attrs || []
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Defines a reader that wraps the array at +attrs[name]+ as instances
|
|
42
|
+
# of +klass+.
|
|
43
|
+
#
|
|
44
|
+
# @param name [Symbol] attribute name (plural)
|
|
45
|
+
# @param klass [Class] model class to wrap each element in
|
|
46
|
+
# @return [void]
|
|
47
|
+
def has_many(name, klass) # rubocop:disable Naming/PredicatePrefix
|
|
48
|
+
define_method(name) do
|
|
49
|
+
@attrs[name.to_s] = Array(@attrs[name.to_s]).map do |item|
|
|
50
|
+
item.is_a?(klass) ? item : klass.new(item)
|
|
51
|
+
end
|
|
52
|
+
@attrs[name.to_s]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Defines a reader that wraps the hash at +attrs[name]+ as an instance
|
|
57
|
+
# of +klass+, or returns +nil+ when absent.
|
|
58
|
+
#
|
|
59
|
+
# @param name [Symbol] attribute name (singular)
|
|
60
|
+
# @param klass [Class] model class to wrap the value in
|
|
61
|
+
# @return [void]
|
|
62
|
+
def has_one(name, klass) # rubocop:disable Naming/PredicatePrefix
|
|
63
|
+
define_method(name) do
|
|
64
|
+
value = @attrs[name.to_s]
|
|
65
|
+
return nil if value.nil?
|
|
66
|
+
|
|
67
|
+
value.is_a?(klass) ? value : klass.new(value)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Raw access to an attribute by key.
|
|
73
|
+
#
|
|
74
|
+
# @param key [String, Symbol] attribute name
|
|
75
|
+
# @return [Object, nil]
|
|
76
|
+
def [](key)
|
|
77
|
+
@attrs[key.to_s]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Deep-converts the resource (and any nested resources) back to a plain Hash.
|
|
81
|
+
#
|
|
82
|
+
# @return [Hash]
|
|
83
|
+
def to_h
|
|
84
|
+
@attrs.transform_values { |v| deep_unwrap(v) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Returns a developer-friendly string showing {.main_attrs} values.
|
|
88
|
+
#
|
|
89
|
+
# @return [String]
|
|
90
|
+
def inspect
|
|
91
|
+
keys = self.class.main_attr_keys
|
|
92
|
+
pairs = keys.filter_map do |k|
|
|
93
|
+
value = @attrs[k]
|
|
94
|
+
"#{k}: #{value.inspect}" if @attrs.key?(k)
|
|
95
|
+
end
|
|
96
|
+
"#<#{self.class.name} #{pairs.join(', ')}>"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Compares two resources by their underlying attribute hashes.
|
|
100
|
+
#
|
|
101
|
+
# @param other [Object]
|
|
102
|
+
# @return [Boolean]
|
|
103
|
+
def ==(other)
|
|
104
|
+
other.is_a?(self.class) && other.attrs == @attrs
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Delegates attribute lookups to @attrs, and caches the method on first call.
|
|
108
|
+
#
|
|
109
|
+
# @param name [Symbol] method name
|
|
110
|
+
# @param args [Array] method arguments (none expected for attr readers)
|
|
111
|
+
# @return [Object, nil]
|
|
112
|
+
def method_missing(name, *args, &)
|
|
113
|
+
key = name.to_s
|
|
114
|
+
if @attrs.key?(key)
|
|
115
|
+
self.class.define_method(name) { @attrs[key] }
|
|
116
|
+
@attrs[key]
|
|
117
|
+
else
|
|
118
|
+
super
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# @param name [Symbol]
|
|
123
|
+
# @param include_private [Boolean]
|
|
124
|
+
# @return [Boolean]
|
|
125
|
+
def respond_to_missing?(name, include_private = false)
|
|
126
|
+
@attrs.key?(name.to_s) || super
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
# Recursively converts Resource instances and arrays back to plain values.
|
|
132
|
+
#
|
|
133
|
+
# @param value [Object]
|
|
134
|
+
# @return [Object]
|
|
135
|
+
def deep_unwrap(value)
|
|
136
|
+
case value
|
|
137
|
+
when Resource then value.to_h
|
|
138
|
+
when Array then value.map { |v| deep_unwrap(v) }
|
|
139
|
+
else value
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Showroom
|
|
4
|
+
module Search
|
|
5
|
+
# A lean article shape returned by the Shopify search suggest endpoint.
|
|
6
|
+
#
|
|
7
|
+
# @example
|
|
8
|
+
# suggestion = Showroom::Search::ArticleSuggestion.new('title' => 'How to Choose a Road Bike')
|
|
9
|
+
# suggestion.title # => "How to Choose a Road Bike"
|
|
10
|
+
class ArticleSuggestion < Suggestion
|
|
11
|
+
main_attrs :id, :title, :handle
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Showroom
|
|
4
|
+
module Search
|
|
5
|
+
# A lean collection shape returned by the Shopify search suggest endpoint.
|
|
6
|
+
#
|
|
7
|
+
# @example
|
|
8
|
+
# suggestion = Showroom::Search::CollectionSuggestion.new('title' => 'Lorem Helmets', 'handle' => 'lorem-helmets')
|
|
9
|
+
# suggestion.title # => "Lorem Helmets"
|
|
10
|
+
class CollectionSuggestion < Suggestion
|
|
11
|
+
main_attrs :id, :title, :handle
|
|
12
|
+
|
|
13
|
+
# @return [Class] the full model class for this suggestion type
|
|
14
|
+
def self.complete_model_class
|
|
15
|
+
Collection
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Showroom
|
|
4
|
+
module Search
|
|
5
|
+
# A lean page shape returned by the Shopify search suggest endpoint.
|
|
6
|
+
#
|
|
7
|
+
# @example
|
|
8
|
+
# suggestion = Showroom::Search::PageSuggestion.new(
|
|
9
|
+
# 'title' => 'About Lorem Bikes', 'handle' => 'about-lorem-bikes'
|
|
10
|
+
# )
|
|
11
|
+
# suggestion.title # => "About Lorem Bikes"
|
|
12
|
+
class PageSuggestion < Suggestion
|
|
13
|
+
main_attrs :id, :title, :handle
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Showroom
|
|
4
|
+
module Search
|
|
5
|
+
# A lean product shape returned by the Shopify search suggest endpoint.
|
|
6
|
+
#
|
|
7
|
+
# @example
|
|
8
|
+
# suggestion = Showroom::Search::ProductSuggestion.new('title' => 'Lorem Road Bike', 'price' => '899.00')
|
|
9
|
+
# suggestion.title # => "Lorem Road Bike"
|
|
10
|
+
# suggestion.price # => "899.00"
|
|
11
|
+
class ProductSuggestion < Suggestion
|
|
12
|
+
main_attrs :id, :title, :handle, :price
|
|
13
|
+
|
|
14
|
+
# @return [Class] the full model class for this suggestion type
|
|
15
|
+
def self.complete_model_class
|
|
16
|
+
Product
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Showroom
|
|
4
|
+
module Search
|
|
5
|
+
# A query suggestion returned by the Shopify search suggest endpoint.
|
|
6
|
+
#
|
|
7
|
+
# @example
|
|
8
|
+
# suggestion = Showroom::Search::QuerySuggestion.new('text' => 'lorem road bike')
|
|
9
|
+
# suggestion.text # => "lorem road bike"
|
|
10
|
+
class QuerySuggestion < Suggestion
|
|
11
|
+
main_attrs :text
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|