booqable 1.0.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/.rspec +2 -0
- data/.rubocop.yml +11 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +403 -0
- data/Rakefile +12 -0
- data/lib/booqable/auth.rb +114 -0
- data/lib/booqable/client.rb +81 -0
- data/lib/booqable/configurable.rb +143 -0
- data/lib/booqable/default.rb +215 -0
- data/lib/booqable/error.rb +428 -0
- data/lib/booqable/http.rb +383 -0
- data/lib/booqable/json_api_serializer.rb +266 -0
- data/lib/booqable/middleware/auth/api_key.rb +46 -0
- data/lib/booqable/middleware/auth/oauth.rb +88 -0
- data/lib/booqable/middleware/auth/single_use.rb +157 -0
- data/lib/booqable/middleware/base.rb +7 -0
- data/lib/booqable/middleware/raise_error.rb +29 -0
- data/lib/booqable/oauth_client.rb +72 -0
- data/lib/booqable/rate_limit.rb +51 -0
- data/lib/booqable/resource_proxy.rb +149 -0
- data/lib/booqable/resources.json +74 -0
- data/lib/booqable/resources.rb +68 -0
- data/lib/booqable/version.rb +5 -0
- data/lib/booqable.rb +85 -0
- data/sig/booqable.rbs +324 -0
- metadata +174 -0
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Booqable
|
|
4
|
+
# Custom error class for rescuing from all Booqable errors
|
|
5
|
+
#
|
|
6
|
+
# Provides detailed error information from API responses including
|
|
7
|
+
# status codes, headers, body content, and validation errors.
|
|
8
|
+
#
|
|
9
|
+
# @example Catching all Booqable errors
|
|
10
|
+
# begin
|
|
11
|
+
# Booqable.orders.find("invalid_id")
|
|
12
|
+
# rescue Booqable::Error => e
|
|
13
|
+
# puts "API Error: #{e.message}"
|
|
14
|
+
# puts "Status: #{e.response_status}"
|
|
15
|
+
# puts "Errors: #{e.errors}"
|
|
16
|
+
# end
|
|
17
|
+
class Error < StandardError
|
|
18
|
+
# @!attribute [r] context
|
|
19
|
+
# @return [Booqable::RateLimit, nil] Rate limit information when applicable
|
|
20
|
+
attr_reader :context
|
|
21
|
+
|
|
22
|
+
# Create and raise an appropriate error from an HTTP response
|
|
23
|
+
#
|
|
24
|
+
# @param response [Hash] HTTP response hash containing status, body, etc.
|
|
25
|
+
# @return [nil] Returns nil if no error class is determined for the response
|
|
26
|
+
# @raise [Booqable::Error] The appropriate error subclass for the response
|
|
27
|
+
def self.from_response(response)
|
|
28
|
+
if error = self.error_class_from_response(response)
|
|
29
|
+
raise error
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Returns the appropriate Booqable::Error subclass based
|
|
34
|
+
# on status and response message
|
|
35
|
+
#
|
|
36
|
+
# @param response [Hash] HTTP response
|
|
37
|
+
# @return [Booqable::Error, nil] Error instance for the response, or nil if no error class matches
|
|
38
|
+
# rubocop:disable Metrics/CyclomaticComplexity
|
|
39
|
+
def self.error_class_from_response(response)
|
|
40
|
+
status = response[:status].to_i
|
|
41
|
+
body = response[:body].to_s
|
|
42
|
+
# headers = response[:response_headers]
|
|
43
|
+
|
|
44
|
+
if klass = case status
|
|
45
|
+
when 400 then error_for_400(body)
|
|
46
|
+
when 401 then Booqable::Unauthorized
|
|
47
|
+
when 402 then error_for_402(body)
|
|
48
|
+
when 403 then Booqable::Forbidden
|
|
49
|
+
when 404 then error_for_404(body)
|
|
50
|
+
when 405 then Booqable::MethodNotAllowed
|
|
51
|
+
when 406 then Booqable::NotAcceptable
|
|
52
|
+
when 409 then Booqable::Conflict
|
|
53
|
+
when 410 then Booqable::Deprecated
|
|
54
|
+
when 415 then Booqable::UnsupportedMediaType
|
|
55
|
+
when 422 then error_for_422(body)
|
|
56
|
+
when 423 then Booqable::Locked
|
|
57
|
+
when 429 then Booqable::TooManyRequests
|
|
58
|
+
when 400..499 then Booqable::ClientError
|
|
59
|
+
when 500 then Booqable::InternalServerError
|
|
60
|
+
when 501 then Booqable::NotImplemented
|
|
61
|
+
when 502 then Booqable::BadGateway
|
|
62
|
+
when 503 then error_for_503(body)
|
|
63
|
+
when 500..599 then Booqable::ServerError
|
|
64
|
+
end
|
|
65
|
+
klass.new(response)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
# rubocop:enable Metrics/CyclomaticComplexity
|
|
69
|
+
|
|
70
|
+
def build_error_context
|
|
71
|
+
if RATE_LIMITED_ERRORS.include?(self.class)
|
|
72
|
+
@context = Booqable::RateLimit.from_response(@response)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Initialize a new Error
|
|
77
|
+
#
|
|
78
|
+
# @param response [Hash, nil] HTTP response hash containing error details
|
|
79
|
+
def initialize(response = nil)
|
|
80
|
+
@response = response
|
|
81
|
+
super(build_error_message)
|
|
82
|
+
build_error_context
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Return most appropriate error for 400 HTTP status code
|
|
86
|
+
# @private
|
|
87
|
+
# rubocop:disable Metrics/CyclomaticComplexity
|
|
88
|
+
def self.error_for_400(body)
|
|
89
|
+
case body
|
|
90
|
+
when /unwrittable_attribute/i
|
|
91
|
+
Booqable::ReadOnlyAttribute
|
|
92
|
+
when /unknown_attribute/i
|
|
93
|
+
Booqable::UnknownAttribute
|
|
94
|
+
when /extra fields should be an object/i
|
|
95
|
+
Booqable::ExtraFieldsInWrongFormat
|
|
96
|
+
when /fields should be an object/i
|
|
97
|
+
Booqable::FieldsInWrongFormat
|
|
98
|
+
when /page should be an object/i
|
|
99
|
+
Booqable::PageShouldBeAnObject
|
|
100
|
+
when /failed typecasting/i
|
|
101
|
+
Booqable::FailedTypecasting
|
|
102
|
+
when /invalid filter/i
|
|
103
|
+
Booqable::InvalidFilter
|
|
104
|
+
when /required filter/i
|
|
105
|
+
Booqable::RequiredFilter
|
|
106
|
+
else
|
|
107
|
+
Booqable::BadRequest
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
# rubocop:enable Metrics/CyclomaticComplexity
|
|
111
|
+
|
|
112
|
+
# Return most appropriate error for 402 HTTP status code
|
|
113
|
+
# @private
|
|
114
|
+
# rubocop:disable Metrics/CyclomaticComplexity
|
|
115
|
+
def self.error_for_402(body)
|
|
116
|
+
case body
|
|
117
|
+
when /feature_not_enabled/i
|
|
118
|
+
Booqable::FeatureNotEnabled
|
|
119
|
+
when /trial_expired/i
|
|
120
|
+
Booqable::TrialExpired
|
|
121
|
+
else
|
|
122
|
+
Booqable::PaymentRequired
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Return most appropriate error for 404 HTTP status code
|
|
127
|
+
# @private
|
|
128
|
+
# rubocop:disable Naming/VariableNumber
|
|
129
|
+
def self.error_for_404(body)
|
|
130
|
+
# rubocop:enable Naming/VariableNumber
|
|
131
|
+
case body
|
|
132
|
+
when /company not found/i
|
|
133
|
+
Booqable::CompanyNotFound
|
|
134
|
+
else
|
|
135
|
+
Booqable::NotFound
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Return most appropriate error for 422 HTTP status code
|
|
140
|
+
# @private
|
|
141
|
+
# rubocop:disable Naming/VariableNumber
|
|
142
|
+
def self.error_for_422(body)
|
|
143
|
+
# rubocop:enable Naming/VariableNumber
|
|
144
|
+
case body
|
|
145
|
+
when /is not a datetime/i
|
|
146
|
+
Booqable::InvalidDateTimeFormat
|
|
147
|
+
when /invalid date/i
|
|
148
|
+
Booqable::InvalidDateFormat
|
|
149
|
+
else
|
|
150
|
+
Booqable::UnprocessableEntity
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Return most appropriate error for 503 HTTP status code
|
|
155
|
+
# @private
|
|
156
|
+
# rubocop:disable Naming/VariableNumber
|
|
157
|
+
def self.error_for_503(body)
|
|
158
|
+
# rubocop:enable Naming/VariableNumber
|
|
159
|
+
if body =~ /read-only/
|
|
160
|
+
Booqable::ReadOnlyMode
|
|
161
|
+
else
|
|
162
|
+
Booqable::ServiceUnavailable
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Array of validation errors
|
|
167
|
+
# @return [Array<Hash>] Error info
|
|
168
|
+
def errors
|
|
169
|
+
if data.is_a?(Hash)
|
|
170
|
+
data[:errors] || []
|
|
171
|
+
else
|
|
172
|
+
[]
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Status code returned by the Booqable server.
|
|
177
|
+
#
|
|
178
|
+
# @return [Integer]
|
|
179
|
+
def response_status
|
|
180
|
+
@response[:status]
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Headers returned by the Booqable server.
|
|
184
|
+
#
|
|
185
|
+
# @return [Hash]
|
|
186
|
+
def response_headers
|
|
187
|
+
@response[:response_headers]
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Body returned by the Booqable server.
|
|
191
|
+
#
|
|
192
|
+
# @return [String]
|
|
193
|
+
def response_body
|
|
194
|
+
@response[:body]
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
private
|
|
198
|
+
|
|
199
|
+
def data
|
|
200
|
+
@data ||=
|
|
201
|
+
if (body = @response[:body]) && !body.empty?
|
|
202
|
+
if body.is_a?(String) &&
|
|
203
|
+
@response[:response_headers] &&
|
|
204
|
+
@response[:response_headers][:content_type] =~ /json/
|
|
205
|
+
|
|
206
|
+
Sawyer::Agent.serializer.decode(body)
|
|
207
|
+
else
|
|
208
|
+
body
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def response_message
|
|
214
|
+
case data
|
|
215
|
+
when Hash
|
|
216
|
+
data[:message]
|
|
217
|
+
when String
|
|
218
|
+
data
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def response_error
|
|
223
|
+
"Error: #{data[:error]}" if data.is_a?(Hash) && data[:error]
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def response_error_summary
|
|
227
|
+
return nil unless data.is_a?(Hash) && !Array(data[:errors]).empty?
|
|
228
|
+
|
|
229
|
+
summary = +"\nError summary:\n"
|
|
230
|
+
return summary << data[:errors] if data[:errors].is_a?(String)
|
|
231
|
+
|
|
232
|
+
summary << data[:errors].map do |error|
|
|
233
|
+
if error.is_a? Hash
|
|
234
|
+
error.map { |k, v| " #{k}: #{v}" }
|
|
235
|
+
else
|
|
236
|
+
" #{error}"
|
|
237
|
+
end
|
|
238
|
+
end.join("\n")
|
|
239
|
+
|
|
240
|
+
summary
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def build_error_message
|
|
244
|
+
return nil if @response.nil?
|
|
245
|
+
|
|
246
|
+
message = +"#{@response[:method].to_s.upcase} "
|
|
247
|
+
message << "#{redact_url(@response[:url].to_s.dup)}: "
|
|
248
|
+
message << "#{@response[:status]} - "
|
|
249
|
+
message << response_message.to_s unless response_message.nil?
|
|
250
|
+
message << response_error.to_s unless response_error.nil?
|
|
251
|
+
message << response_error_summary.to_s unless response_error_summary.nil?
|
|
252
|
+
message
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def redact_url(url_string)
|
|
256
|
+
Client::SECRETS.each do |token|
|
|
257
|
+
if url_string.include? token
|
|
258
|
+
url_string.gsub!(/#{token}=\S+/, "#{token}=(redacted)")
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
url_string
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Raised on errors in the 400-499 range
|
|
266
|
+
class ClientError < Error; end
|
|
267
|
+
|
|
268
|
+
# Raised when Booqable returns a 400 HTTP status code
|
|
269
|
+
class BadRequest < ClientError; end
|
|
270
|
+
|
|
271
|
+
# Raised when Booqable returns a 400 HTTP status code
|
|
272
|
+
# and body matches 'unwrittable_attribute'
|
|
273
|
+
class ReadOnlyAttribute < ClientError; end
|
|
274
|
+
|
|
275
|
+
# Raised when Booqable returns a 400 HTTP status code
|
|
276
|
+
# and body matches 'unknown_attribute'
|
|
277
|
+
class UnknownAttribute < ClientError; end
|
|
278
|
+
|
|
279
|
+
# Raised when Booqable returns a 400 HTTP status code
|
|
280
|
+
# and body matches 'fields should be an object'
|
|
281
|
+
class FieldsInWrongFormat < ClientError; end
|
|
282
|
+
|
|
283
|
+
# Raised when Booqable returns a 400 HTTP status code
|
|
284
|
+
# and body matches 'extra fields should be an object'
|
|
285
|
+
class ExtraFieldsInWrongFormat < ClientError; end
|
|
286
|
+
|
|
287
|
+
# Raised when Booqable returns a 400 HTTP status code
|
|
288
|
+
# and body matches 'page should be an object'
|
|
289
|
+
class PageShouldBeAnObject < ClientError; end
|
|
290
|
+
|
|
291
|
+
# Raised when Booqable returns a 400 HTTP status code
|
|
292
|
+
# and body matches 'failed typecasting'
|
|
293
|
+
class FailedTypecasting < ClientError; end
|
|
294
|
+
|
|
295
|
+
# Raised when Booqable returns a 400 HTTP status code
|
|
296
|
+
# and body matches 'invalid filter'
|
|
297
|
+
class InvalidFilter < ClientError; end
|
|
298
|
+
|
|
299
|
+
# Raised when Booqable returns a 400 HTTP status code
|
|
300
|
+
# and body matches 'required filter'
|
|
301
|
+
class RequiredFilter < ClientError; end
|
|
302
|
+
|
|
303
|
+
# Raised when Booqable returns a 401 HTTP status code
|
|
304
|
+
class Unauthorized < ClientError; end
|
|
305
|
+
|
|
306
|
+
# Raised when Booqable returns a 402 HTTP status code
|
|
307
|
+
class PaymentRequired < ClientError; end
|
|
308
|
+
|
|
309
|
+
# Raised when Booqable returns a 402 HTTP status code
|
|
310
|
+
# and body matches 'feature_not_enabled'
|
|
311
|
+
class FeatureNotEnabled < PaymentRequired; end
|
|
312
|
+
|
|
313
|
+
# Raised when Booqable returns a 402 HTTP status code
|
|
314
|
+
# and body matches 'trial_expired'
|
|
315
|
+
class TrialExpired < PaymentRequired; end
|
|
316
|
+
|
|
317
|
+
# Raised when Booqable returns a 403 HTTP status code
|
|
318
|
+
class Forbidden < ClientError; end
|
|
319
|
+
|
|
320
|
+
# Raised when Booqable returns a 403 HTTP status code
|
|
321
|
+
# and body matches 'rate limit exceeded'
|
|
322
|
+
class TooManyRequests < Forbidden; end
|
|
323
|
+
|
|
324
|
+
# Raised when Booqable returns a 404 HTTP status code
|
|
325
|
+
class NotFound < ClientError; end
|
|
326
|
+
|
|
327
|
+
# Raised when Booqable returns a 404 HTTP status code
|
|
328
|
+
# and body matches 'company not found'
|
|
329
|
+
class CompanyNotFound < NotFound; end
|
|
330
|
+
|
|
331
|
+
# Raised when Booqable returns a 405 HTTP status code
|
|
332
|
+
class MethodNotAllowed < ClientError; end
|
|
333
|
+
|
|
334
|
+
# Raised when Booqable returns a 406 HTTP status code
|
|
335
|
+
class NotAcceptable < ClientError; end
|
|
336
|
+
|
|
337
|
+
# Raised when Booqable returns a 409 HTTP status code
|
|
338
|
+
class Conflict < ClientError; end
|
|
339
|
+
|
|
340
|
+
# Raised when Booqable returns a 410 HTTP status code
|
|
341
|
+
class Deprecated < ClientError; end
|
|
342
|
+
|
|
343
|
+
# Raised when Booqable returns a 414 HTTP status code
|
|
344
|
+
class UnsupportedMediaType < ClientError; end
|
|
345
|
+
|
|
346
|
+
# Raised when Booqable returns a 423 HTTP status code
|
|
347
|
+
class Locked < ClientError; end
|
|
348
|
+
|
|
349
|
+
# Raised when Booqable returns a 422 HTTP status code
|
|
350
|
+
class UnprocessableEntity < ClientError; end
|
|
351
|
+
|
|
352
|
+
# Raised when Booqable returns a 422 HTTP status code and body matches 'is not a datetime'.
|
|
353
|
+
class InvalidDateTimeFormat < UnprocessableEntity; end
|
|
354
|
+
|
|
355
|
+
# Raised when Booqable returns a 422 HTTP status code and body matches 'invalid date'.
|
|
356
|
+
class InvalidDateFormat < UnprocessableEntity; end
|
|
357
|
+
|
|
358
|
+
# Raised on errors in the 500-599 range
|
|
359
|
+
class ServerError < Error; end
|
|
360
|
+
|
|
361
|
+
# Raised when Booqable returns a 500 HTTP status code
|
|
362
|
+
class InternalServerError < ServerError; end
|
|
363
|
+
|
|
364
|
+
# Raised when Booqable returns a 501 HTTP status code
|
|
365
|
+
class NotImplemented < ServerError; end
|
|
366
|
+
|
|
367
|
+
# Raised when Booqable returns a 502 HTTP status code
|
|
368
|
+
class BadGateway < ServerError; end
|
|
369
|
+
|
|
370
|
+
# Raised when Booqable returns a 503 HTTP status code
|
|
371
|
+
class ServiceUnavailable < ServerError; end
|
|
372
|
+
|
|
373
|
+
# Raised when Booqable returns a 503 HTTP status code
|
|
374
|
+
# and body matches 'read-only'
|
|
375
|
+
class ReadOnlyMode < ServerError; end
|
|
376
|
+
|
|
377
|
+
# Raised when Booqable configuration is invalid
|
|
378
|
+
class ConfigArgumentError < ArgumentError; end
|
|
379
|
+
|
|
380
|
+
# Raised when a company slug is not set in Booqable configuration
|
|
381
|
+
class CompanyRequired < ArgumentError
|
|
382
|
+
def initialize
|
|
383
|
+
super("Company ID is required. Please set `company_id` in Booqable configuration.")
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# Raised when a company ID is not set in Booqable configuration
|
|
388
|
+
# and single-use token auth method is used.
|
|
389
|
+
class SingleUseTokenCompanyIdRequired < ArgumentError
|
|
390
|
+
def initialize
|
|
391
|
+
super("Single use token company ID is required. Please set `single_use_token_company_id` in Booqable configuration.")
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# Raised when a user ID is not set in Booqable configuration
|
|
396
|
+
# and single-use token auth method is used.
|
|
397
|
+
class SingleUseTokenUserIdRequired < ArgumentError
|
|
398
|
+
def initialize
|
|
399
|
+
super("Single use token user ID is required. Please set `single_use_token_company_id` in Booqable configuration.")
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# Raised when a single-use token algorithm is not set in Booqable configuration
|
|
404
|
+
# and single-use token auth method is used.
|
|
405
|
+
class SingleUseTokenAlgorithmRequired < ConfigArgumentError
|
|
406
|
+
def initialize
|
|
407
|
+
super("Single use token algorithm is required. Please set `single_use_token_algorithm` in Booqable configuration.")
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# Raised when a private key or secret is not set in Booqable configuration
|
|
412
|
+
class PrivateKeyOrSecretRequired < ConfigArgumentError
|
|
413
|
+
def initialize
|
|
414
|
+
super("Private key or secret is required. Please set `single_use_token_private_key` or `single_use_token_secret` in Booqable configuration.")
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
class UnsupportedAPIVersion < ConfigArgumentError
|
|
419
|
+
def initialize
|
|
420
|
+
super("Unsupported API version configured. Only version '4' is supported.")
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# Raised when a required authentication parameter is missing
|
|
425
|
+
class RequiredAuthParamMissing < ArgumentError; end
|
|
426
|
+
|
|
427
|
+
RATE_LIMITED_ERRORS = [ Booqable::TooManyRequests ].freeze
|
|
428
|
+
end
|