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 +7 -0
- data/LICENSE +21 -0
- data/README.md +197 -0
- data/lib/noosh24/client.rb +170 -0
- data/lib/noosh24/convert.rb +97 -0
- data/lib/noosh24/errors.rb +124 -0
- data/lib/noosh24/models.rb +951 -0
- data/lib/noosh24/page.rb +58 -0
- data/lib/noosh24/reservation_input.rb +64 -0
- data/lib/noosh24/resources.rb +247 -0
- data/lib/noosh24/version.rb +5 -0
- data/lib/noosh24/where.rb +89 -0
- data/lib/noosh24.rb +18 -0
- metadata +56 -0
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
|