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