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.
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