noosh24 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 35944f68c0f36a9daebdc66211a063f21907d22b2f56f8889f9f13ec21697717
4
+ data.tar.gz: 00fe1188d82f3e26598a4701c09a083b5243f82caf36cefe80787880d2b3fd85
5
+ SHA512:
6
+ metadata.gz: 9b30851283d807f00d366bab0ce751fef52d676a85ae2df3093bd189444cf952a6173754789e8b29874dc93188d0f18a73acae8c802505041b4a6aec0a006e03
7
+ data.tar.gz: 5b4491c44277a0724d676cdb6287ea892f1b2635c01e97538597b375a6e9995cdb0f6bb9c44bf4e9d68003e1d88efa95b70581d0101e4679c692205fcc3a1b2f
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Noosh24
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,197 @@
1
+ <div align="center">
2
+
3
+ <a href="https://developer.noosh24.com"><img src="https://raw.githubusercontent.com/noosh24/sdks/main/.github/assets/noosh-lockup.svg" alt="Noosh24" width="260"></a>
4
+
5
+ # Noosh24 Open API for Ruby
6
+
7
+ **The official Ruby client for the [Noosh24 Open API](https://developer.noosh24.com).**<br>
8
+ Menu, reservations, orders and bills, payments and the Z-report, tables, customers, loyalty, stock and reviews of a Noosh24 venue, plus creating and cancelling reservations.
9
+
10
+ <a href="https://github.com/noosh24/sdks/actions/workflows/ruby.yml"><img src="https://github.com/noosh24/sdks/actions/workflows/ruby.yml/badge.svg" alt="CI"></a> <img src="https://img.shields.io/badge/Ruby-3.3%2B-CC342D?logo=ruby&logoColor=white" alt="Ruby 3.3+"> <img src="https://img.shields.io/badge/RubyGems-noosh24-DC4A38" alt="RubyGems"> <a href="https://github.com/noosh24/sdks/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license"></a>
11
+
12
+ [Developer site](https://developer.noosh24.com) · [API reference](https://developer.noosh24.com/reference) · [All SDKs](https://github.com/noosh24/sdks) · [Report an issue](https://github.com/noosh24/sdks/issues)
13
+
14
+ </div>
15
+
16
+ > [!IMPORTANT]
17
+ > **Keep the API key on your server.** A key belongs to one venue and opens its data: guests' phone numbers, payments, and writing reservations. Never put it in a browser, a mobile app or a public repository.
18
+
19
+ **Contents:** [Requirements](#requirements) · [Install](#install) · [Quick start](#quick-start) · [Options](#options) · [Resources](#resources) · [Lists, filters and pages](#lists-filters-and-pages) · [Reservations](#reservations) · [Errors](#errors) · [Retries and rate limits](#retries-and-rate-limits) · [Development](#development) · [License](#license)
20
+
21
+ ## Requirements
22
+
23
+ - Ruby 3.3 or newer (tested on 3.3 and 4.0). No runtime dependencies: `net/http` and `json` from the standard library.
24
+ - Frozen value objects (`Data`) for every response.
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ gem install noosh24
30
+ ```
31
+
32
+ or in a `Gemfile`:
33
+
34
+ ```ruby
35
+ gem "noosh24", "~> 0.1"
36
+ ```
37
+
38
+ ## Quick start
39
+
40
+ ```ruby
41
+ require "noosh24"
42
+
43
+ noosh = Noosh24::Client.new(ENV.fetch("NOOSH24_API_KEY"))
44
+
45
+ me = noosh.whoami
46
+ puts me.restaurant.name, me.key.scopes.inspect
47
+
48
+ page = noosh.menu.list_products(
49
+ where: Noosh24.where.eq("category", "Salads").eq("available", true),
50
+ sort: "price desc",
51
+ limit: 50
52
+ )
53
+ page.each { |product| puts "#{product.name} #{product.price}" }
54
+ puts "#{page.total} products match"
55
+ ```
56
+
57
+ The key belongs to one venue, so there is no venue parameter anywhere. Keys are issued in the Noosh24 panel; keep them on your server, never in a browser or a mobile app.
58
+
59
+ ## Options
60
+
61
+ ```ruby
62
+ noosh = Noosh24::Client.new(
63
+ "nsk_...",
64
+ base_url: "https://open-api.noosh24.com/v1",
65
+ timeout: 30,
66
+ max_retries: 2,
67
+ user_agent: "acme-pos/2.1"
68
+ )
69
+ ```
70
+
71
+ `timeout` is in seconds and applies to connecting, writing and reading. `transport:` takes any object that responds to `call(request)` with a `Noosh24::Request` (`http_method`, `url`, `headers`, `body`, `timeout`) and returns a `Noosh24::Response` (`status`, `headers`, `body`); use it for proxies, custom TLS or tests. `sleep:` takes a callable that receives the seconds to wait between retries.
72
+
73
+ ## Resources
74
+
75
+ | Group | Methods |
76
+ | --- | --- |
77
+ | `whoami` | the venue, the key's scopes and the venue's modules |
78
+ | `menu` | `list_categories`, `list_products`, `iterate_products`, `get_product(id, expand:)`, `get_stop_list` |
79
+ | `reservations` | `list`, `iterate`, `get(id)`, `availability(date, time, guests:)`, `create(...)`, `cancel(id)` |
80
+ | `orders` | `list`, `iterate`, `get(id)`, `list_bills`, `iterate_bills`, `get_bill(id)` |
81
+ | `payments` | `list`, `iterate`, `get(id)`, `z_report(date = nil)` |
82
+ | `tables` | `list`, `list_halls` |
83
+ | `customers` | `list`, `iterate`, `get(id)` |
84
+ | `loyalty` | `get_program`, `list_cards`, `iterate_cards` |
85
+ | `stock` | `list_ingredients`, `iterate_ingredients`, `list_moves`, `iterate_moves`, `list_stores` |
86
+ | `reviews` | `list`, `iterate` |
87
+
88
+ Responses are frozen `Data` objects (`Noosh24::Product`, `Noosh24::Reservation`, `Noosh24::Order`, ...) with snake_case readers: `product.prep_minutes`, `reservation.starts_at`. Timestamps are `Time` objects in UTC (nanoseconds kept), money is `Float` in AZN. `model.raw` holds the JSON as the API sent it, including fields this version of the SDK does not know yet. `z_report` returns the report as a frozen `Hash`.
89
+
90
+ ## Lists, filters and pages
91
+
92
+ List methods take `limit:`, `offset:`, `sort:`, `where:` and, for products, `expand:`. They return a `Noosh24::Page` with `items`, `total`, `limit`, `offset`, `has_more?` and `next_offset`; a page is `Enumerable`.
93
+
94
+ ```ruby
95
+ orders = noosh.orders.list(
96
+ where: "status = 'served' AND createdAt >= '2026-11-01'",
97
+ sort: ["createdAt desc"],
98
+ limit: 100
99
+ )
100
+ ```
101
+
102
+ `where` is AND only, and field names are the API's camelCase names. Build it with `Noosh24.where` so strings and dates are quoted correctly:
103
+
104
+ ```ruby
105
+ condition = Noosh24.where
106
+ .eq("status", "confirmed")
107
+ .gte("startsAt", Time.new(2026, 11, 20, 0, 0, 0, "+04:00"))
108
+ .in("zone", ["Terrace", "Chef's table"])
109
+ ```
110
+
111
+ The builder has `eq`, `ne`, `gt`, `gte`, `lt`, `lte` and `in`, and takes strings, integers, floats, `true`/`false`, `Time` and `Date`.
112
+
113
+ The `iterate*` methods walk every page and fetch the next page only when you reach it. Without a block they return an `Enumerator`; with a block they yield each item:
114
+
115
+ ```ruby
116
+ noosh.payments.iterate(where: "createdAt >= '2026-11-01'") do |payment|
117
+ puts payment.amount
118
+ end
119
+
120
+ first_ten = noosh.customers.iterate.first(10)
121
+ ```
122
+
123
+ `offset` stops at 10000. For bigger exports, narrow `where` by date and walk one window at a time.
124
+
125
+ ## Reservations
126
+
127
+ ```ruby
128
+ free = noosh.reservations.availability("2026-11-20", "19:30", guests: 4)
129
+
130
+ begin
131
+ result = noosh.reservations.create(
132
+ date: "2026-11-20",
133
+ time: "19:30",
134
+ guests: 4,
135
+ guest_name: "Aysel Məmmədova",
136
+ phone: "+994501234567",
137
+ table_id: free.tables.first.id,
138
+ pre_order: [Noosh24::PreOrderLine.new(item_id: "0b6f3c1e", qty: 2)],
139
+ external_ref: "acme-booking-991"
140
+ )
141
+ rescue Noosh24::TableTakenError
142
+ warn "That table was just taken"
143
+ end
144
+
145
+ puts result.reservation.code, result.created? ? "created" : "already existed"
146
+ noosh.reservations.cancel(result.reservation.id)
147
+ ```
148
+
149
+ `create` also takes a `Noosh24::NewReservation` (`Noosh24::NewReservation.new(date:, time:, guests:, guest_name:, phone:, ...)`), and `date` may be a `Date`. A booking lands as `pending` and the venue confirms it. Always send `external_ref` (your own booking id): sending the same `external_ref` again returns the booking that already exists instead of making a second one, so a create is safe to retry. The client retries a create only when it carries an `external_ref`. You can cancel only bookings your key created.
150
+
151
+ ## Errors
152
+
153
+ Every API error is a subclass of `Noosh24::APIError` with `status`, `code` (the API's `error` field), `message`, `headers` (lower-cased names) and `body`. Branch on the class or on `code`, not on the message.
154
+
155
+ | `code` | Exception | Parent |
156
+ | --- | --- | --- |
157
+ | `validation_error` | `ValidationError` | `APIError` |
158
+ | `no_api_key` | `NoApiKeyError` | `AuthenticationError` |
159
+ | `invalid_api_key` | `InvalidApiKeyError` | `AuthenticationError` |
160
+ | `insufficient_scope` | `InsufficientScopeError` | `PermissionDeniedError` |
161
+ | `module_not_enabled` | `ModuleNotEnabledError` | `PermissionDeniedError` |
162
+ | `venue_suspended` | `VenueSuspendedError` | `PermissionDeniedError` |
163
+ | `not_your_reservation` | `NotYourReservationError` | `PermissionDeniedError` |
164
+ | `not_found` | `NotFoundError` | `APIError` |
165
+ | `table_not_found` | `TableNotFoundError` | `NotFoundError` |
166
+ | `table_taken` | `TableTakenError` | `ConflictError` |
167
+ | `cannot_cancel` | `CannotCancelError` | `ConflictError` |
168
+ | `outside_hours`, `in_the_past`, `table_too_small` | `OutsideHoursError`, `InThePastError`, `TableTooSmallError` | `UnprocessableError` |
169
+ | `rate_limit_exceeded` | `RateLimitError` (`retry_after`) | `APIError` |
170
+ | `temporarily_unavailable` | `TemporarilyUnavailableError` | `ServerError` |
171
+
172
+ All classes live under `Noosh24::`. An unknown code falls back to the class for its status (`ServerError` for any 5xx). A network failure raises `APIConnectionError`; a timeout raises `APITimeoutError` (a subclass of `APIConnectionError`). All of them extend `Noosh24::Error`, which extends `StandardError`. Bad arguments (an empty key or id, a bad `where` field or value) raise `ArgumentError`.
173
+
174
+ ## Retries and rate limits
175
+
176
+ A key gets 600 requests a minute. On `429` and `502`/`503`/`504` the client waits (`Retry-After` when the API sends it, otherwise 0.5 s, 1 s, 2 s ...) and tries again, up to `max_retries` times. It never waits more than 60 seconds; a longer `Retry-After` is raised to you as a `RateLimitError`. Reads, cancels and creates with an `external_ref` are retried; a create without an `external_ref` is not.
177
+
178
+ ## Development
179
+
180
+ ```bash
181
+ bundle install
182
+ bundle exec rake test
183
+ bundle exec rubocop
184
+ ```
185
+
186
+ Without a local Ruby 3.3+:
187
+
188
+ ```bash
189
+ docker run --rm -v "$PWD":/app -w /app ruby:3.3 sh -c 'bundle install && bundle exec rake'
190
+ docker run --rm -v "$PWD":/app -w /app ruby:4.0 sh -c 'bundle install && bundle exec rake'
191
+ ```
192
+
193
+ The models in `lib/noosh24/models.rb` are generated: `python3 tools/gen_ruby.py` from the repository root.
194
+
195
+ ## License
196
+
197
+ MIT
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Noosh24
4
+ Request = Data.define(:http_method, :url, :headers, :body, :timeout)
5
+ Response = Data.define(:status, :headers, :body)
6
+
7
+ class NetHTTPTransport
8
+ def call(request)
9
+ uri = URI(request.url)
10
+ http = Net::HTTP.new(uri.host, uri.port)
11
+ http.use_ssl = uri.scheme == "https"
12
+ http.open_timeout = request.timeout
13
+ http.read_timeout = request.timeout
14
+ http.write_timeout = request.timeout
15
+ response = http.start { |conn| conn.request(build(uri, request)) }
16
+ Response.new(status: response.code.to_i, headers: response.each_header.to_h, body: response.body.to_s)
17
+ end
18
+
19
+ private
20
+
21
+ def build(uri, request)
22
+ klass = request.http_method == "POST" ? Net::HTTP::Post : Net::HTTP::Get
23
+ message = klass.new(uri.request_uri)
24
+ request.headers.each { |name, value| message[name] = value }
25
+ message.body = request.body if request.body
26
+ message
27
+ end
28
+ end
29
+
30
+ class Client
31
+ DEFAULT_BASE_URL = "https://open-api.noosh24.com/v1"
32
+ RETRY_STATUSES = [429, 502, 503, 504].freeze
33
+ MAX_RETRY_WAIT = 60.0
34
+ TIMEOUT_ERRORS = [Timeout::Error, Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout].freeze
35
+ CONNECTION_ERRORS = [
36
+ SocketError, SystemCallError, IOError, EOFError, OpenSSL::SSL::SSLError,
37
+ Net::HTTPBadResponse, Net::ProtocolError
38
+ ].freeze
39
+
40
+ attr_reader :base_url, :timeout, :max_retries, :user_agent,
41
+ :menu, :reservations, :orders, :payments, :tables, :customers, :loyalty, :stock, :reviews
42
+
43
+ def initialize(api_key, base_url: nil, timeout: 30, max_retries: 2, user_agent: nil, transport: nil, sleep: nil)
44
+ key = api_key.to_s.strip
45
+ raise ArgumentError, "An API key is required" if key.empty?
46
+
47
+ @api_key = key
48
+ @base_url = (base_url || DEFAULT_BASE_URL).to_s.sub(%r{/+\z}, "")
49
+ @timeout = timeout
50
+ @max_retries = [Integer(max_retries), 0].max
51
+ agent = "noosh24-ruby/#{VERSION}"
52
+ @user_agent = user_agent.to_s.empty? ? agent : "#{user_agent} #{agent}"
53
+ @transport = transport || NetHTTPTransport.new
54
+ @sleep = sleep || ->(seconds) { Kernel.sleep(seconds) }
55
+
56
+ @menu = Menu.new(self)
57
+ @reservations = Reservations.new(self)
58
+ @orders = Orders.new(self)
59
+ @payments = Payments.new(self)
60
+ @tables = Tables.new(self)
61
+ @customers = Customers.new(self)
62
+ @loyalty = Loyalty.new(self)
63
+ @stock = Stock.new(self)
64
+ @reviews = Reviews.new(self)
65
+ end
66
+
67
+ def whoami
68
+ _, data = request("GET", "/whoami")
69
+ Whoami.from_hash(data)
70
+ end
71
+
72
+ def inspect
73
+ "#<#{self.class.name} base_url=#{base_url.inspect}>"
74
+ end
75
+
76
+ def request(http_method, path, query: nil, body: nil, retryable: true)
77
+ req = Request.new(
78
+ http_method: http_method,
79
+ url: @base_url + path + self.class.query_string(query),
80
+ headers: headers(body),
81
+ body: body.nil? ? nil : JSON.generate(body),
82
+ timeout: @timeout
83
+ )
84
+ attempt = 0
85
+ loop do
86
+ result, wait, error = perform(req, attempt)
87
+ return result if error.nil?
88
+ raise error unless retryable && attempt < @max_retries && !wait.nil?
89
+
90
+ @sleep.call(wait)
91
+ attempt += 1
92
+ end
93
+ end
94
+
95
+ def self.query_string(query)
96
+ pairs = []
97
+ (query || {}).each do |name, value|
98
+ Array(value).each do |item|
99
+ next if item.nil?
100
+
101
+ pairs << "#{escape(name.to_s)}=#{escape(item.to_s)}"
102
+ end
103
+ end
104
+ pairs.empty? ? "" : "?#{pairs.join('&')}"
105
+ end
106
+
107
+ def self.escape(text)
108
+ text.b.gsub(/[^A-Za-z0-9\-._~]/n) { |char| format("%%%02X", char.ord) }
109
+ end
110
+
111
+ private
112
+
113
+ def headers(body)
114
+ out = {
115
+ "Authorization" => "Bearer #{@api_key}",
116
+ "Accept" => "application/json",
117
+ "User-Agent" => @user_agent
118
+ }
119
+ out["Content-Type"] = "application/json" unless body.nil?
120
+ out
121
+ end
122
+
123
+ def perform(req, attempt)
124
+ response = @transport.call(req)
125
+ status = response.status.to_i
126
+ headers = (response.headers || {}).to_h { |name, value| [name.to_s.downcase, Array(value).join(", ")] }
127
+ return [[status, decode(status, headers, response.body.to_s)], nil, nil] if status.between?(200, 299)
128
+
129
+ [nil, retry_wait(status, headers, attempt), APIError.from_response(status, headers, response.body.to_s)]
130
+ rescue APIConnectionError => e
131
+ [nil, backoff(attempt), e]
132
+ rescue *TIMEOUT_ERRORS => e
133
+ [nil, backoff(attempt), APITimeoutError.new("Request timed out after #{@timeout} s: #{e.message}")]
134
+ rescue *CONNECTION_ERRORS => e
135
+ [nil, backoff(attempt), APIConnectionError.new("Could not reach the Noosh24 API: #{e.message}")]
136
+ end
137
+
138
+ def decode(status, headers, body)
139
+ return nil if body.empty?
140
+
141
+ parsed = begin
142
+ JSON.parse(body, freeze: true)
143
+ rescue JSON::ParserError
144
+ raise APIError.new(status, nil, "The API answered with a body that is not JSON", headers, body)
145
+ end
146
+ unless parsed.is_a?(Hash) && parsed.key?("data")
147
+ raise APIError.new(status, nil, 'The API answered without a "data" field', headers, body)
148
+ end
149
+
150
+ parsed["data"]
151
+ end
152
+
153
+ def retry_wait(status, headers, attempt)
154
+ return nil unless RETRY_STATUSES.include?(status)
155
+
156
+ header = headers["retry-after"]
157
+ return backoff(attempt) if header.nil?
158
+
159
+ wait = Float(header, exception: false)
160
+ return backoff(attempt) if wait.nil?
161
+
162
+ wait = [wait, 0.0].max
163
+ wait <= MAX_RETRY_WAIT ? wait : nil
164
+ end
165
+
166
+ def backoff(attempt)
167
+ [8.0, 0.5 * (2**attempt)].min
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Noosh24
4
+ module Model
5
+ EMPTY = {}.freeze
6
+
7
+ attr_reader :raw
8
+
9
+ def initialize(raw: EMPTY, **fields)
10
+ @raw = raw
11
+ super(**fields)
12
+ end
13
+ end
14
+
15
+ module Convert
16
+ module_function
17
+
18
+ def mapping(value)
19
+ value.is_a?(Hash) ? value : Model::EMPTY
20
+ end
21
+
22
+ def str(value)
23
+ case value
24
+ when nil, Hash, Array then nil
25
+ when String then value
26
+ else value.to_s.freeze
27
+ end
28
+ end
29
+
30
+ def int(value)
31
+ case value
32
+ when Integer then value
33
+ when Float then value.finite? ? value.to_i : nil
34
+ end
35
+ end
36
+
37
+ def float(value)
38
+ case value
39
+ when Integer, Float then value.to_f
40
+ end
41
+ end
42
+
43
+ def bool(value)
44
+ case value
45
+ when nil, Hash, Array then nil
46
+ when true, false then value
47
+ when Numeric then !value.zero?
48
+ when String then !value.empty?
49
+ else true
50
+ end
51
+ end
52
+
53
+ def time(value)
54
+ return nil unless value.is_a?(String) && !value.empty?
55
+
56
+ Time.iso8601(value.strip).utc.freeze
57
+ rescue ArgumentError
58
+ nil
59
+ end
60
+
61
+ def strings(value)
62
+ return [].freeze unless value.is_a?(Array)
63
+
64
+ value.filter_map { |item| str(item) }.freeze
65
+ end
66
+
67
+ def floats(value)
68
+ return {}.freeze unless value.is_a?(Hash)
69
+
70
+ value.each_with_object({}) do |(key, item), out|
71
+ out[key.to_s.freeze] = item.to_f if item.is_a?(Integer) || item.is_a?(Float)
72
+ end.freeze
73
+ end
74
+
75
+ def date_param(value)
76
+ value.is_a?(Date) || value.is_a?(Time) ? value.strftime("%Y-%m-%d") : value.to_s
77
+ end
78
+
79
+ def object(value, model)
80
+ value.is_a?(Hash) ? model.from_hash(value) : nil
81
+ end
82
+
83
+ def list_of(value, model)
84
+ return [].freeze unless value.is_a?(Array)
85
+
86
+ value.filter_map { |item| model.from_hash(item) if item.is_a?(Hash) }.freeze
87
+ end
88
+
89
+ def map_of(value, model)
90
+ return {}.freeze unless value.is_a?(Hash)
91
+
92
+ value.each_with_object({}) do |(key, item), out|
93
+ out[key.to_s.freeze] = model.from_hash(item) if item.is_a?(Hash)
94
+ end.freeze
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Noosh24
4
+ class Error < StandardError; end
5
+
6
+ class APIConnectionError < Error; end
7
+
8
+ class APITimeoutError < APIConnectionError; end
9
+
10
+ class APIError < Error
11
+ attr_reader :status, :code, :headers, :body
12
+
13
+ def initialize(status, code, message, headers = {}, body = "")
14
+ super(message)
15
+ @status = status
16
+ @code = code
17
+ @headers = (headers || {}).to_h { |key, value| [key.to_s.downcase, value] }.freeze
18
+ @body = body
19
+ end
20
+
21
+ def inspect
22
+ "#<#{self.class.name} status=#{status} code=#{code.inspect} message=#{message.inspect}>"
23
+ end
24
+
25
+ def self.from_response(status, headers, body)
26
+ parsed = parse(body)
27
+ code = parsed["error"].is_a?(String) ? parsed["error"] : nil
28
+ message = parsed["message"].is_a?(String) ? parsed["message"] : ""
29
+ message = code || "HTTP #{status}" if message.empty?
30
+ klass = BY_CODE[code] || BY_STATUS[status] || (status >= 500 ? ServerError : APIError)
31
+ klass.new(status, code, message, headers, body)
32
+ end
33
+
34
+ def self.parse(body)
35
+ parsed = JSON.parse(body.to_s)
36
+ parsed.is_a?(Hash) ? parsed : {}
37
+ rescue JSON::ParserError
38
+ {}
39
+ end
40
+ private_class_method :parse
41
+ end
42
+
43
+ class ValidationError < APIError; end
44
+
45
+ class AuthenticationError < APIError; end
46
+
47
+ class NoApiKeyError < AuthenticationError; end
48
+
49
+ class InvalidApiKeyError < AuthenticationError; end
50
+
51
+ class PermissionDeniedError < APIError; end
52
+
53
+ class InsufficientScopeError < PermissionDeniedError; end
54
+
55
+ class ModuleNotEnabledError < PermissionDeniedError; end
56
+
57
+ class VenueSuspendedError < PermissionDeniedError; end
58
+
59
+ class NotYourReservationError < PermissionDeniedError; end
60
+
61
+ class NotFoundError < APIError; end
62
+
63
+ class TableNotFoundError < NotFoundError; end
64
+
65
+ class ConflictError < APIError; end
66
+
67
+ class TableTakenError < ConflictError; end
68
+
69
+ class CannotCancelError < ConflictError; end
70
+
71
+ class UnprocessableError < APIError; end
72
+
73
+ class OutsideHoursError < UnprocessableError; end
74
+
75
+ class InThePastError < UnprocessableError; end
76
+
77
+ class TableTooSmallError < UnprocessableError; end
78
+
79
+ class RateLimitError < APIError
80
+ def retry_after
81
+ value = headers["retry-after"]
82
+ return nil if value.nil?
83
+
84
+ [Float(value).to_i, 0].max
85
+ rescue ArgumentError, TypeError, FloatDomainError
86
+ nil
87
+ end
88
+ end
89
+
90
+ class ServerError < APIError; end
91
+
92
+ class TemporarilyUnavailableError < ServerError; end
93
+
94
+ class APIError
95
+ BY_CODE = {
96
+ "validation_error" => ValidationError,
97
+ "no_api_key" => NoApiKeyError,
98
+ "invalid_api_key" => InvalidApiKeyError,
99
+ "insufficient_scope" => InsufficientScopeError,
100
+ "module_not_enabled" => ModuleNotEnabledError,
101
+ "venue_suspended" => VenueSuspendedError,
102
+ "not_your_reservation" => NotYourReservationError,
103
+ "not_found" => NotFoundError,
104
+ "table_not_found" => TableNotFoundError,
105
+ "table_taken" => TableTakenError,
106
+ "cannot_cancel" => CannotCancelError,
107
+ "outside_hours" => OutsideHoursError,
108
+ "in_the_past" => InThePastError,
109
+ "table_too_small" => TableTooSmallError,
110
+ "rate_limit_exceeded" => RateLimitError,
111
+ "temporarily_unavailable" => TemporarilyUnavailableError
112
+ }.freeze
113
+
114
+ BY_STATUS = {
115
+ 400 => ValidationError,
116
+ 401 => AuthenticationError,
117
+ 403 => PermissionDeniedError,
118
+ 404 => NotFoundError,
119
+ 409 => ConflictError,
120
+ 422 => UnprocessableError,
121
+ 429 => RateLimitError
122
+ }.freeze
123
+ end
124
+ end