choiceqr 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.md +21 -0
- data/README.md +243 -0
- data/lib/choiceqr/client.rb +248 -0
- data/lib/choiceqr/configuration.rb +14 -0
- data/lib/choiceqr/errors.rb +38 -0
- data/lib/choiceqr/key_transformer.rb +105 -0
- data/lib/choiceqr/resource.rb +84 -0
- data/lib/choiceqr/resources/areas.rb +37 -0
- data/lib/choiceqr/resources/base.rb +69 -0
- data/lib/choiceqr/resources/bookings.rb +32 -0
- data/lib/choiceqr/resources/categories.rb +39 -0
- data/lib/choiceqr/resources/cutlery.rb +18 -0
- data/lib/choiceqr/resources/dish_labels.rb +12 -0
- data/lib/choiceqr/resources/dish_options.rb +48 -0
- data/lib/choiceqr/resources/dishes.rb +58 -0
- data/lib/choiceqr/resources/feedbacks.rb +30 -0
- data/lib/choiceqr/resources/full_menu.rb +56 -0
- data/lib/choiceqr/resources/location_points.rb +31 -0
- data/lib/choiceqr/resources/orders.rb +59 -0
- data/lib/choiceqr/resources/pack.rb +36 -0
- data/lib/choiceqr/resources/place.rb +13 -0
- data/lib/choiceqr/resources/section_info.rb +12 -0
- data/lib/choiceqr/resources/sections.rb +38 -0
- data/lib/choiceqr/version.rb +3 -0
- data/lib/choiceqr.rb +38 -0
- metadata +172 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 07dec0ce2e854c10973278cf04728e882f6402babed554baed28ad04760b267e
|
|
4
|
+
data.tar.gz: 138b5ff77c3bfe9b9113fddd791bc32fbee5d59abe4a3a949c3037e72f7ee410
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 5214c6437aab17feab020cdd34046e8001406667efc3936f5d1469903a13da27723e11ed008778807a886768566d2de1ec1354b24f99759c470ef0eed38148e9
|
|
7
|
+
data.tar.gz: 33437df630e03f6718fd77d41df0e6fd5a195f81f4046fee2f18d9ea8f2f687bdc9b07590b8cc21e9709bb05efe083bcd744778e2e70e30993bcb46b227bac3a
|
data/LICENSE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Stockbird Team
|
|
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,243 @@
|
|
|
1
|
+
# choiceqr
|
|
2
|
+
|
|
3
|
+
Ruby API client for the [ChoiceQR Open API](https://open-api.choiceqr.com/docs#/).
|
|
4
|
+
|
|
5
|
+
Handles authentication and provides a clean interface to the place, menu, location, order, booking, and feedback resources. Built by [Stockbird](https://stockbird.app).
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
Add to your Gemfile:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
gem "choiceqr"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Or install directly:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
gem install choiceqr
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Requirements
|
|
22
|
+
|
|
23
|
+
- Ruby >= 3.3.0
|
|
24
|
+
- A ChoiceQR application and access token obtained via the [ChoiceQR OAuth flow](https://open-api.choiceqr.com/docs/content/authorization) — contact api@choiceqr.com to register.
|
|
25
|
+
|
|
26
|
+
## Quick start
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
client = ChoiceQR::Client.new(token: ENV["CHOICEQR_TOKEN"])
|
|
30
|
+
|
|
31
|
+
place = client.place.get
|
|
32
|
+
puts place.name
|
|
33
|
+
|
|
34
|
+
sections = client.sections.list
|
|
35
|
+
sections.each { |section| puts section.name }
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Authentication
|
|
39
|
+
|
|
40
|
+
ChoiceQR access tokens are obtained once via an OAuth-style authorization code flow and are valid for roughly five years — there is no refresh step to manage at runtime. Once you have the `code` from the "Ask permission" redirect, exchange it for a token:
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
result = ChoiceQR::Client.exchange_token(code: code, client_id: client_id, secret: secret)
|
|
44
|
+
result.token # => access token, valid ~5 years — store this securely
|
|
45
|
+
result.var_symbol # => uniq company identifier
|
|
46
|
+
result.domain # => company domain
|
|
47
|
+
|
|
48
|
+
client = ChoiceQR::Client.new(token: result.token)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
See [Authorization](https://open-api.choiceqr.com/docs/content/authorization) for the full flow (connecting your application to a client, permission dialog, callback URL).
|
|
52
|
+
|
|
53
|
+
## Resources
|
|
54
|
+
|
|
55
|
+
The following resource accessors are available on the client:
|
|
56
|
+
|
|
57
|
+
| Method | API path |
|
|
58
|
+
|---|---|
|
|
59
|
+
| `client.place` | `place` |
|
|
60
|
+
| `client.section_info` | `menu/:language/section-info/:sectionId` |
|
|
61
|
+
| `client.sections` | `menu/:language/sections` |
|
|
62
|
+
| `client.categories` | `menu/:language/categories`, scoped by section |
|
|
63
|
+
| `client.dishes` | `menu/:language/dishes`, scoped by category |
|
|
64
|
+
| `client.dish_options` | `menu/:language/options`, scoped by section |
|
|
65
|
+
| `client.dish_labels` | `menu/:language/dish-labels` |
|
|
66
|
+
| `client.pack` | `menu/:language/pack` |
|
|
67
|
+
| `client.cutlery` | `menu/:language/cutlery` (a single settings object, no id) |
|
|
68
|
+
| `client.full_menu` | `menu/:language/full/*` — bulk import, availability sync, marketplace sync |
|
|
69
|
+
| `client.areas` | `location/:language/areas` |
|
|
70
|
+
| `client.location_points` | `location/:language/points`, scoped by area |
|
|
71
|
+
| `client.orders` | `orders` |
|
|
72
|
+
| `client.bookings` | `bookings` |
|
|
73
|
+
| `client.feedbacks` | `feedbacks` |
|
|
74
|
+
|
|
75
|
+
Most menu/location methods accept a per-call `language:` override; when omitted, the client's `default_language` (`"en"` unless configured otherwise) is used:
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
client = ChoiceQR::Client.new(token: token, default_language: "de")
|
|
79
|
+
client.sections.list # uses "de"
|
|
80
|
+
client.sections.list(language: "cs") # overrides to "cs" for this call
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## CRUD operations
|
|
84
|
+
|
|
85
|
+
Attributes are passed as keyword arguments (or splat a Hash with `**`), not a positional Hash — this matches how most modern Ruby API clients read, and keeps every method's required path arguments (`id`, `section_id`, …) unambiguous from the optional attributes:
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
section = client.sections.create(name: "Drinks", pos_id: "10")
|
|
89
|
+
client.sections.update(section.id, name: "Beverages")
|
|
90
|
+
client.sections.set_position([id1, id2, id3])
|
|
91
|
+
client.sections.delete(section.id)
|
|
92
|
+
|
|
93
|
+
category = client.categories.create(name: "Hot", section: section.id)
|
|
94
|
+
client.categories.list(section.id)
|
|
95
|
+
|
|
96
|
+
dish = client.dishes.create(name: "Cappuccino", category: category.id, price: 420) # price is in cents
|
|
97
|
+
client.dishes.update(dish.id, name: "Double Espresso", price: 450)
|
|
98
|
+
client.dishes.patch(dish.id, active: false) # partial update
|
|
99
|
+
client.dishes.update_areas(dish.id, takeaway: true, delivery: false)
|
|
100
|
+
client.dishes.find_by_pos_id("your-pos-id")
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`update`/`patch`/position-bulk/attach/detach endpoints return `204 No Content` on success — the corresponding gem methods return `true` rather than a fetched Resource (there's no ETag or similar concurrency token to round-trip). `delete` also returns `true`.
|
|
104
|
+
|
|
105
|
+
### Full menu import and availability sync
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
client.full_menu.import(
|
|
109
|
+
sections: [{ pos_id: "1", name: "Main" }],
|
|
110
|
+
categories: [{ pos_id: "1", section_pos_id: "1", name: "Hot" }],
|
|
111
|
+
dishes: [{ pos_id: "1", category_pos_id: "1", name: "Coffee", price: 300 }],
|
|
112
|
+
preserve_missing_items: true
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
client.full_menu.sync_availability(dishes: [{ pos_id: "525", active: false }])
|
|
116
|
+
|
|
117
|
+
sync = client.full_menu.sync_marketplace_data(
|
|
118
|
+
dishes: [{ pos_id: "1", data: { WOLT: { price: 1000, name: "Wolt name" } } }]
|
|
119
|
+
)
|
|
120
|
+
client.full_menu.marketplace_sync_status(sync.id)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Orders
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
client.orders.list(since: Time.now - 3600, include_approved: true)
|
|
127
|
+
client.orders.list_archive(from: Time.now - 86_400 * 30, till: Time.now) # rate limit: 1 req / 5s
|
|
128
|
+
order = client.orders.get(id)
|
|
129
|
+
client.orders.get_by_guid(order.guid)
|
|
130
|
+
client.orders.update_delivery(order.id, delivery_status: "processing")
|
|
131
|
+
client.orders.cancel(order.id, reason: "Out of stock")
|
|
132
|
+
client.orders.close(order.id)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Bookings
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
client.bookings.list(from: Time.now, till: Time.now + 86_400 * 7) # rate limit: 1 req / 5s
|
|
139
|
+
booking = client.bookings.get(id)
|
|
140
|
+
client.bookings.confirm(booking.id, location_points: [point_id])
|
|
141
|
+
client.bookings.cancel(booking.id, cancel_reason: "Table no longer available")
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### Feedbacks
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
client.feedbacks.list(type: "ORDER")
|
|
148
|
+
client.feedbacks.create(
|
|
149
|
+
ref_id: order_id,
|
|
150
|
+
feedback: { type: "ORDER", rate_serve: 5, rate_dish: 4, language: "en", message: "Great!" },
|
|
151
|
+
customer: { name: "John Doe", phone: "+380501234567" }
|
|
152
|
+
) # rate limit: 1 req / 10s
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Response objects
|
|
156
|
+
|
|
157
|
+
All returned data is a `ChoiceQR::Resource` — a generic object backed by a snake_case symbol-keyed hash. Nested data (menu options, order items, etc.) is wrapped the same way at every depth, so dot access works throughout:
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
dish.name # dot notation
|
|
161
|
+
dish[:name] # symbol key
|
|
162
|
+
dish["posID"] # camelCase string key (also works)
|
|
163
|
+
dish.menu_options.first.list.first.price
|
|
164
|
+
dish.to_h # plain hash, nested Resources unwrapped
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
API keys are transformed as follows:
|
|
168
|
+
- `defaultLanguage` → `:default_language`
|
|
169
|
+
- `posID` → `:pos_id` (the one field ChoiceQR capitalizes as an acronym instead of plain camelCase)
|
|
170
|
+
- `_id` → `:id` (leading underscore stripped)
|
|
171
|
+
|
|
172
|
+
A field whose name collides with a real `Object` method (`hash`, `method`, `class`, `send`, …) isn't reachable via dot access — Ruby dispatches to the real method first. Use `resource[:hash]`-style hash access for those instead.
|
|
173
|
+
|
|
174
|
+
## Error handling
|
|
175
|
+
|
|
176
|
+
All errors inherit from `ChoiceQR::Error` and carry `http_status`, `http_body`, `http_headers`, and `error_name` (the API's own `"ValidationError"`/`"ServiceError"` classification, where present):
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
begin
|
|
180
|
+
client.dishes.get("nonexistent")
|
|
181
|
+
rescue ChoiceQR::NotFoundError
|
|
182
|
+
# unknown id
|
|
183
|
+
rescue ChoiceQR::AuthenticationError
|
|
184
|
+
# token missing or invalid
|
|
185
|
+
rescue ChoiceQR::ForbiddenError
|
|
186
|
+
# token doesn't have the required scope
|
|
187
|
+
rescue ChoiceQR::ValidationError => e
|
|
188
|
+
puts e.message
|
|
189
|
+
rescue ChoiceQR::RateLimitError
|
|
190
|
+
# 429 — the gem already retries a couple of times with backoff before this is raised
|
|
191
|
+
rescue ChoiceQR::ServerError
|
|
192
|
+
# 5xx
|
|
193
|
+
rescue ChoiceQR::Error => e
|
|
194
|
+
# catch-all
|
|
195
|
+
end
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Full error hierarchy:
|
|
199
|
+
|
|
200
|
+
```
|
|
201
|
+
ChoiceQR::Error
|
|
202
|
+
├── ChoiceQR::ConnectionError
|
|
203
|
+
├── ChoiceQR::TimeoutError
|
|
204
|
+
└── ChoiceQR::ClientError
|
|
205
|
+
├── ChoiceQR::ValidationError (400)
|
|
206
|
+
├── ChoiceQR::AuthenticationError (401)
|
|
207
|
+
├── ChoiceQR::ForbiddenError (403)
|
|
208
|
+
├── ChoiceQR::NotFoundError (404)
|
|
209
|
+
└── ChoiceQR::RateLimitError (429)
|
|
210
|
+
└── ChoiceQR::ServerError (5xx)
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## Rate limits
|
|
214
|
+
|
|
215
|
+
The API documents a general 60 req/sec limit, with some endpoints stricter still (booking/order-archive listing, feedback creation, availability/marketplace sync — see the relevant method's docs above). The gem automatically retries a request up to twice with backoff on `429`/`5xx` responses; persistent rate limiting still raises `ChoiceQR::RateLimitError`. The gem does not otherwise throttle requests client-side — back off in your own code if you're making high-volume calls.
|
|
216
|
+
|
|
217
|
+
## Configuration
|
|
218
|
+
|
|
219
|
+
```ruby
|
|
220
|
+
client = ChoiceQR::Client.new(
|
|
221
|
+
token: "...",
|
|
222
|
+
default_language: "en", # default: "en"
|
|
223
|
+
timeout: 60, # read timeout in seconds (default: 30)
|
|
224
|
+
open_timeout: 10, # connection timeout in seconds (default: 5)
|
|
225
|
+
logger: Logger.new($stdout)
|
|
226
|
+
)
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
## Webhooks
|
|
230
|
+
|
|
231
|
+
ChoiceQR pushes events (menu changes, new orders, booking updates, …) to a Webhook URL you configure when creating your application — there is no API for managing webhook subscriptions, so this gem does not include a webhook client. See [Webhooks](https://open-api.choiceqr.com/docs/content/webhooks) for the event payload shape; the `data` field of each event matches the corresponding resource's response schema, so you can wrap it yourself with `ChoiceQR::Resource.new(event["data"])` if useful.
|
|
232
|
+
|
|
233
|
+
## Development
|
|
234
|
+
|
|
235
|
+
```sh
|
|
236
|
+
bundle install
|
|
237
|
+
bundle exec rspec
|
|
238
|
+
bundle exec rubocop
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
## License
|
|
242
|
+
|
|
243
|
+
MIT — see [LICENSE.md](LICENSE.md).
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
require "faraday"
|
|
2
|
+
require "faraday/retry"
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module ChoiceQR
|
|
7
|
+
# Entry point for all API interactions.
|
|
8
|
+
#
|
|
9
|
+
# Usage:
|
|
10
|
+
# client = ChoiceQR::Client.new(token: "your_token")
|
|
11
|
+
#
|
|
12
|
+
# place = client.place.get
|
|
13
|
+
# sections = client.sections.list
|
|
14
|
+
# client.dishes.create(name: "Cappuccino", category: category_id, price: 420)
|
|
15
|
+
#
|
|
16
|
+
# # Most menu/location endpoints accept a per-call language override; the
|
|
17
|
+
# # client's default_language ("en" unless configured otherwise) is used
|
|
18
|
+
# # when omitted.
|
|
19
|
+
# client.sections.list(language: "de")
|
|
20
|
+
#
|
|
21
|
+
# See ChoiceQR::Client.exchange_token for obtaining a +token+ in the first
|
|
22
|
+
# place, and https://open-api.choiceqr.com/docs/content/authorization for
|
|
23
|
+
# the full OAuth flow.
|
|
24
|
+
class Client
|
|
25
|
+
API_BASE_URL = "https://open-api.choiceqr.com/".freeze
|
|
26
|
+
|
|
27
|
+
# Maps HTTP error status codes to [ErrorClass, default_message] pairs.
|
|
28
|
+
ERROR_MAP = {
|
|
29
|
+
400 => [ValidationError, "Bad request"],
|
|
30
|
+
401 => [AuthenticationError, "Missing or invalid token"],
|
|
31
|
+
403 => [ForbiddenError, "Insufficient rights"],
|
|
32
|
+
404 => [NotFoundError, "Resource not found"],
|
|
33
|
+
429 => [RateLimitError, "Rate limit exceeded"],
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
attr_reader :default_language
|
|
37
|
+
|
|
38
|
+
# @param token [String] long-lived access token obtained via the OAuth flow
|
|
39
|
+
# (see .exchange_token). Valid for ~5 years.
|
|
40
|
+
# @param default_language [String] default :language path segment for menu/location calls
|
|
41
|
+
# @param timeout [Integer] read timeout in seconds
|
|
42
|
+
# @param open_timeout [Integer] connection timeout in seconds
|
|
43
|
+
# @param logger [Logger, nil] optional logger; receives request/response details
|
|
44
|
+
#
|
|
45
|
+
# +default_language+/+timeout+/+open_timeout+/+logger+ each default to the
|
|
46
|
+
# matching value on +ChoiceQR.configuration+ (see ChoiceQR.configure).
|
|
47
|
+
def initialize(token:, default_language: ChoiceQR.configuration.default_language,
|
|
48
|
+
timeout: ChoiceQR.configuration.timeout, open_timeout: ChoiceQR.configuration.open_timeout,
|
|
49
|
+
logger: ChoiceQR.configuration.logger)
|
|
50
|
+
@token = token
|
|
51
|
+
@default_language = default_language.to_s
|
|
52
|
+
@timeout = timeout
|
|
53
|
+
@open_timeout = open_timeout
|
|
54
|
+
@logger = logger
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def place
|
|
58
|
+
@place ||= Resources::Place.new(self)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def section_info
|
|
62
|
+
@section_info ||= Resources::SectionInfo.new(self)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def sections
|
|
66
|
+
@sections ||= Resources::Sections.new(self)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def categories
|
|
70
|
+
@categories ||= Resources::Categories.new(self)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def dishes
|
|
74
|
+
@dishes ||= Resources::Dishes.new(self)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def dish_options
|
|
78
|
+
@dish_options ||= Resources::DishOptions.new(self)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def dish_labels
|
|
82
|
+
@dish_labels ||= Resources::DishLabels.new(self)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def pack
|
|
86
|
+
@pack ||= Resources::Pack.new(self)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def cutlery
|
|
90
|
+
@cutlery ||= Resources::Cutlery.new(self)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def full_menu
|
|
94
|
+
@full_menu ||= Resources::FullMenu.new(self)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def areas
|
|
98
|
+
@areas ||= Resources::Areas.new(self)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def location_points
|
|
102
|
+
@location_points ||= Resources::LocationPoints.new(self)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def orders
|
|
106
|
+
@orders ||= Resources::Orders.new(self)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def bookings
|
|
110
|
+
@bookings ||= Resources::Bookings.new(self)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def feedbacks
|
|
114
|
+
@feedbacks ||= Resources::Feedbacks.new(self)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Exchanges the authorization +code+ obtained from the "Ask permission"
|
|
118
|
+
# redirect for a long-lived access token. This is a one-time setup step;
|
|
119
|
+
# store the returned token and pass it to .new as +token:+.
|
|
120
|
+
#
|
|
121
|
+
# result = ChoiceQR::Client.exchange_token(code: code, client_id: client_id, secret: secret)
|
|
122
|
+
# result.token # => access token, valid ~5 years
|
|
123
|
+
# result.var_symbol # => uniq company identifier
|
|
124
|
+
# result.domain # => company domain
|
|
125
|
+
#
|
|
126
|
+
# See https://open-api.choiceqr.com/docs/content/authorization
|
|
127
|
+
def self.exchange_token(code:, client_id:, secret:,
|
|
128
|
+
timeout: ChoiceQR.configuration.timeout,
|
|
129
|
+
open_timeout: ChoiceQR.configuration.open_timeout)
|
|
130
|
+
response = post_token_request(code: code, client_id: client_id, secret: secret,
|
|
131
|
+
timeout: timeout, open_timeout: open_timeout)
|
|
132
|
+
body = parse_response_body(response.body)
|
|
133
|
+
|
|
134
|
+
return Resource.new(body) if response.success?
|
|
135
|
+
|
|
136
|
+
raise_error_for(response, body)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Makes an authenticated HTTP request. Used internally by the resource
|
|
140
|
+
# wrapper classes (client.sections, client.orders, …).
|
|
141
|
+
#
|
|
142
|
+
# @param method [Symbol] :get, :post, :put, :patch, :delete
|
|
143
|
+
# @param path [String] path relative to API_BASE_URL (e.g. "menu/en/sections/list")
|
|
144
|
+
# @param params [Hash] query parameters
|
|
145
|
+
# @param body [Hash, Array, nil] request body (will be JSON-encoded)
|
|
146
|
+
# @param headers [Hash] additional request headers
|
|
147
|
+
# @return [Hash] { body: parsed_response }
|
|
148
|
+
def request(method, path, params: {}, body: nil, headers: {})
|
|
149
|
+
response = execute_request(method, path, params: params, body: body, headers: headers)
|
|
150
|
+
handle_response(response)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
private
|
|
154
|
+
|
|
155
|
+
def execute_request(method, path, params:, body:, headers:)
|
|
156
|
+
connection.run_request(method, path, body&.to_json, request_headers(headers)) do |req|
|
|
157
|
+
req.params = params unless params.empty?
|
|
158
|
+
end
|
|
159
|
+
rescue Faraday::ConnectionFailed => e
|
|
160
|
+
raise ChoiceQR::ConnectionError, e.message
|
|
161
|
+
rescue Faraday::TimeoutError => e
|
|
162
|
+
raise ChoiceQR::TimeoutError, e.message
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def handle_response(response)
|
|
166
|
+
body = self.class.send(:parse_response_body, response.body)
|
|
167
|
+
|
|
168
|
+
return { body: body } if (200..299).cover?(response.status)
|
|
169
|
+
|
|
170
|
+
self.class.send(:raise_error_for, response, body)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def connection
|
|
174
|
+
@connection ||= Faraday.new(url: API_BASE_URL) do |f|
|
|
175
|
+
f.options.timeout = @timeout
|
|
176
|
+
f.options.open_timeout = @open_timeout
|
|
177
|
+
# The API documents a 60 req/sec rate limit (some endpoints are
|
|
178
|
+
# stricter — see individual resource methods) and returns 429 when
|
|
179
|
+
# exceeded; back off and retry a couple of times before giving up.
|
|
180
|
+
f.request :retry, max: 2, interval: 0.5, backoff_factor: 2,
|
|
181
|
+
retry_statuses: [429, 500, 502, 503, 504],
|
|
182
|
+
exceptions: Faraday::Retry::Middleware::DEFAULT_EXCEPTIONS
|
|
183
|
+
f.request :logger, @logger, headers: false, bodies: false if @logger
|
|
184
|
+
f.adapter Faraday.default_adapter
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def request_headers(extra = {})
|
|
189
|
+
{
|
|
190
|
+
"Authorization" => "Bearer #{@token}",
|
|
191
|
+
"Content-Type" => "application/json",
|
|
192
|
+
"Accept" => "application/json",
|
|
193
|
+
# Lets the API de-duplicate a request that is retried after a
|
|
194
|
+
# network/timeout issue instead of processing it twice.
|
|
195
|
+
"x-idempotence-key" => SecureRandom.uuid,
|
|
196
|
+
"User-Agent" => "choiceqr-ruby/#{ChoiceQR::VERSION} ruby/#{RUBY_VERSION}",
|
|
197
|
+
}.merge(extra)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
class << self
|
|
201
|
+
private
|
|
202
|
+
|
|
203
|
+
def post_token_request(code:, client_id:, secret:, timeout:, open_timeout:)
|
|
204
|
+
connection = Faraday.new(url: API_BASE_URL) do |f|
|
|
205
|
+
f.options.timeout = timeout
|
|
206
|
+
f.options.open_timeout = open_timeout
|
|
207
|
+
f.adapter Faraday.default_adapter
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
connection.post("auth/connect/token") do |req|
|
|
211
|
+
req.headers["Content-Type"] = "application/json"
|
|
212
|
+
req.body = JSON.generate(code: code, clientId: client_id, secret: secret)
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Shared by both the class-level .exchange_token and instance-level
|
|
217
|
+
# #request — the two entry points that talk to the API before/without
|
|
218
|
+
# going through the same instance.
|
|
219
|
+
def parse_response_body(body)
|
|
220
|
+
return nil if body.nil? || body.empty?
|
|
221
|
+
|
|
222
|
+
JSON.parse(body, symbolize_names: false)
|
|
223
|
+
rescue JSON::ParserError
|
|
224
|
+
body
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def raise_error_for(response, body)
|
|
228
|
+
klass, default_msg = ERROR_MAP[response.status]
|
|
229
|
+
klass ||= response.status >= 500 ? ServerError : Error
|
|
230
|
+
default_msg ||= response.status >= 500 ? "Server error" : "Unexpected status #{response.status}"
|
|
231
|
+
|
|
232
|
+
raise klass.new(
|
|
233
|
+
error_message(body, default_msg),
|
|
234
|
+
http_status: response.status,
|
|
235
|
+
http_body: response.body,
|
|
236
|
+
http_headers: response.headers,
|
|
237
|
+
error_name: body.is_a?(Hash) ? body["name"] : nil
|
|
238
|
+
)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def error_message(parsed_body, fallback)
|
|
242
|
+
return fallback unless parsed_body.is_a?(Hash)
|
|
243
|
+
|
|
244
|
+
parsed_body["message"] || fallback
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module ChoiceQR
|
|
2
|
+
class Configuration
|
|
3
|
+
attr_accessor :timeout, :open_timeout, :logger, :default_language
|
|
4
|
+
|
|
5
|
+
API_BASE_URL = "https://open-api.choiceqr.com/".freeze
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@timeout = 30
|
|
9
|
+
@open_timeout = 5
|
|
10
|
+
@logger = nil
|
|
11
|
+
@default_language = "en"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module ChoiceQR
|
|
2
|
+
# Base error — callers can rescue ChoiceQR::Error to catch everything.
|
|
3
|
+
class Error < StandardError
|
|
4
|
+
attr_reader :http_status, :http_body, :http_headers, :error_name
|
|
5
|
+
|
|
6
|
+
def initialize(msg = nil, http_status: nil, http_body: nil, http_headers: nil, error_name: nil)
|
|
7
|
+
super(msg)
|
|
8
|
+
@http_status = http_status
|
|
9
|
+
@http_body = http_body
|
|
10
|
+
@http_headers = http_headers
|
|
11
|
+
@error_name = error_name
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def to_s
|
|
15
|
+
http_status ? "(HTTP #{http_status}) #{super}" : super
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Network-level errors
|
|
20
|
+
class ConnectionError < Error; end
|
|
21
|
+
class TimeoutError < Error; end
|
|
22
|
+
|
|
23
|
+
# 4xx client errors
|
|
24
|
+
class ClientError < Error; end
|
|
25
|
+
# 400 — the API calls this "ValidationError" or "ServiceError"
|
|
26
|
+
class ValidationError < ClientError; end
|
|
27
|
+
# 401 — missing or invalid token
|
|
28
|
+
class AuthenticationError < ClientError; end
|
|
29
|
+
# 403 — token does not have the required scope/rights
|
|
30
|
+
class ForbiddenError < ClientError; end
|
|
31
|
+
# 404 — not documented in the API guidelines, but returned for unknown IDs
|
|
32
|
+
class NotFoundError < ClientError; end
|
|
33
|
+
# 429
|
|
34
|
+
class RateLimitError < ClientError; end
|
|
35
|
+
|
|
36
|
+
# 5xx server errors
|
|
37
|
+
class ServerError < Error; end
|
|
38
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
module ChoiceQR
|
|
2
|
+
# Bidirectional key transformation between the API's lowerCamelCase format
|
|
3
|
+
# and Ruby's conventional snake_case.
|
|
4
|
+
#
|
|
5
|
+
# API → Ruby (responses):
|
|
6
|
+
# "defaultLanguage" → :default_language
|
|
7
|
+
# "posID" → :pos_id
|
|
8
|
+
# "sectionPosID" → :section_pos_id
|
|
9
|
+
# "_id" → :id (leading underscore stripped)
|
|
10
|
+
#
|
|
11
|
+
# Ruby → API (request bodies):
|
|
12
|
+
# :default_language → "defaultLanguage"
|
|
13
|
+
# :pos_id → "posID" (see #merge_acronyms below)
|
|
14
|
+
# :section_pos_id → "sectionPosID"
|
|
15
|
+
# :_id → "_id" (a leading underscore is preserved, since
|
|
16
|
+
# some payloads reference an existing
|
|
17
|
+
# entity's id this way, e.g. Pack#create
|
|
18
|
+
# categories)
|
|
19
|
+
#
|
|
20
|
+
# Note: "PosID" is the one acronym the ChoiceQR API capitalizes in full
|
|
21
|
+
# instead of following plain camelCase ("Id") — and it shows up both as a
|
|
22
|
+
# standalone field and as a suffix on cross-reference fields (sectionPosID,
|
|
23
|
+
# categoryPosID, …) throughout the whole API, so #camelize special-cases
|
|
24
|
+
# the "pos"+"id" word pair wherever it appears rather than only matching
|
|
25
|
+
# the exact key "posID"/"pos_id".
|
|
26
|
+
module KeyTransformer
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# Recursively transforms all keys in a Hash (or Array of Hashes) from the
|
|
30
|
+
# API format to snake_case symbols.
|
|
31
|
+
def to_snake(obj)
|
|
32
|
+
case obj
|
|
33
|
+
when Hash
|
|
34
|
+
obj.transform_keys { |k| snake_key(k) }
|
|
35
|
+
.transform_values { |v| to_snake(v) }
|
|
36
|
+
when Array
|
|
37
|
+
obj.map { |v| to_snake(v) }
|
|
38
|
+
else
|
|
39
|
+
obj
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Recursively transforms all keys in a Hash (or Array of Hashes) from
|
|
44
|
+
# snake_case symbols/strings to lowerCamelCase strings for API requests.
|
|
45
|
+
def to_camel(obj)
|
|
46
|
+
case obj
|
|
47
|
+
when Hash
|
|
48
|
+
obj.transform_keys { |k| camel_key(k) }
|
|
49
|
+
.transform_values { |v| to_camel(v) }
|
|
50
|
+
when Array
|
|
51
|
+
obj.map { |v| to_camel(v) }
|
|
52
|
+
else
|
|
53
|
+
obj
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Single key: API string → snake_case symbol
|
|
58
|
+
def snake_key(key)
|
|
59
|
+
key.to_s
|
|
60
|
+
.delete_prefix("_") # strip leading underscore (_id → id)
|
|
61
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2') # ABCDef → ABC_def
|
|
62
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2') # camelCase → camel_case
|
|
63
|
+
.downcase
|
|
64
|
+
.to_sym
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Single key: snake_case symbol/string → lowerCamelCase string
|
|
68
|
+
def camel_key(key)
|
|
69
|
+
str = key.to_s
|
|
70
|
+
prefix = str.start_with?("_") ? "_" : ""
|
|
71
|
+
body = prefix.empty? ? str : str[1..]
|
|
72
|
+
|
|
73
|
+
prefix + camelize(body)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def camelize(str)
|
|
77
|
+
segments = merge_acronyms(str.split("_"))
|
|
78
|
+
return "" if segments.empty? # e.g. an all-underscore key like :_ or :__
|
|
79
|
+
|
|
80
|
+
segments[0] + segments[1..].join
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Joins an adjacent ["pos", "id"] word pair into the API's "posID"/"PosID"
|
|
84
|
+
# acronym spelling instead of capitalizing them individually into
|
|
85
|
+
# "posId"/"PosId". Every other word is capitalized normally, except the
|
|
86
|
+
# very first segment of the key, which is used verbatim (standard
|
|
87
|
+
# camelCase, and how a single-word key like :WOLT survives untouched).
|
|
88
|
+
def merge_acronyms(parts)
|
|
89
|
+
segments = []
|
|
90
|
+
i = 0
|
|
91
|
+
while i < parts.length
|
|
92
|
+
acronym = parts[i] == "pos" && parts[i + 1] == "id"
|
|
93
|
+
segments << next_segment(parts[i], first: segments.empty?, acronym: acronym)
|
|
94
|
+
i += acronym ? 2 : 1
|
|
95
|
+
end
|
|
96
|
+
segments
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def next_segment(word, first:, acronym:)
|
|
100
|
+
return first ? "posID" : "PosID" if acronym
|
|
101
|
+
|
|
102
|
+
first ? word : word.capitalize
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|