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.
@@ -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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Showroom
4
+ # Internal gem infrastructure (configuration, connection, errors, versioning).
5
+ module Core
6
+ # Current gem version.
7
+ VERSION = '0.1.0'
8
+ end
9
+ 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