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