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,383 @@
|
|
|
1
|
+
module Booqable
|
|
2
|
+
# HTTP request methods for {Booqable::Client}
|
|
3
|
+
#
|
|
4
|
+
# Provides low-level HTTP methods for making requests to the Booqable API.
|
|
5
|
+
# Handles authentication, pagination, rate limiting, and response parsing.
|
|
6
|
+
# All methods support both query parameters and request bodies as appropriate.
|
|
7
|
+
#
|
|
8
|
+
# @example Making a GET request
|
|
9
|
+
# client.get("/orders", include: "customer")
|
|
10
|
+
#
|
|
11
|
+
# @example Making a POST request with data
|
|
12
|
+
# client.post("/orders", data: { type: "order", attributes: { name: "New Order" } })
|
|
13
|
+
#
|
|
14
|
+
# @example Using pagination
|
|
15
|
+
# orders = client.paginate("/orders", page: { size: 50 })
|
|
16
|
+
module HTTP
|
|
17
|
+
# Headers that can be passed as top-level options for convenience
|
|
18
|
+
CONVENIENCE_HEADERS = Set.new(%i[accept content_type user_agent])
|
|
19
|
+
|
|
20
|
+
# Make a HTTP GET request
|
|
21
|
+
#
|
|
22
|
+
# @param url [String] The path, relative to {#api_endpoint}
|
|
23
|
+
# @param options [Hash] Query and header params for request
|
|
24
|
+
# @return [Sawyer::Resource]
|
|
25
|
+
def get(url, options = {})
|
|
26
|
+
request :get, url, options
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Make a HTTP post request
|
|
30
|
+
#
|
|
31
|
+
# @param url [String] The path, relative to {#api_endpoint}
|
|
32
|
+
# @param options [Hash] Body and header params for request
|
|
33
|
+
# @return [Sawyer::Resource]
|
|
34
|
+
def post(url, options = {})
|
|
35
|
+
request :post, url, options
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Make a HTTP put request
|
|
39
|
+
#
|
|
40
|
+
# @param url [String] The path, relative to {#api_endpoint}
|
|
41
|
+
# @param options [Hash] Body and header params for request
|
|
42
|
+
# @return [Sawyer::Resource]
|
|
43
|
+
def put(url, options = {})
|
|
44
|
+
request :put, url, options
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Make a HTTP patch request
|
|
48
|
+
#
|
|
49
|
+
# @param url [String] The path, relative to {#api_endpoint}
|
|
50
|
+
# @param options [Hash] Body and header params for request
|
|
51
|
+
# @return [Sawyer::Resource]
|
|
52
|
+
def patch(url, options = {})
|
|
53
|
+
request :patch, url, options
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Make a HTTP delete request
|
|
57
|
+
#
|
|
58
|
+
# @param url [String] The path, relative to {#api_endpoint}
|
|
59
|
+
# @param options [Hash] Body and header params for request
|
|
60
|
+
# @return [Sawyer::Resource]
|
|
61
|
+
def delete(url, options = {})
|
|
62
|
+
request :delete, url, options
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Make a HTTP head request
|
|
66
|
+
#
|
|
67
|
+
# @param url [String] The path, relative to {#api_endpoint}
|
|
68
|
+
# @param options [Hash] Query and header params for request
|
|
69
|
+
# @return [Sawyer::Resource]
|
|
70
|
+
def head(url, options = {})
|
|
71
|
+
request :head, url, options
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Make a HTTP request to the Booqable API
|
|
75
|
+
#
|
|
76
|
+
# Low-level request method that handles authentication, error handling,
|
|
77
|
+
# and response processing. All other HTTP methods delegate to this method.
|
|
78
|
+
#
|
|
79
|
+
# @param method [Symbol] HTTP method (e.g. :get, :post, :put, :delete)
|
|
80
|
+
# @param path [String] API endpoint path (e.g. "/products"), relative to {#api_endpoint}
|
|
81
|
+
# @param data [Hash, String] Request body data (JSON or form-encoded)
|
|
82
|
+
# @param options [Hash] Additional request options (headers, etc.)
|
|
83
|
+
# @return [Sawyer::Resource] Response object with data and metadata
|
|
84
|
+
# @raise [Booqable::Error] For API errors or HTTP failures
|
|
85
|
+
#
|
|
86
|
+
# @example Making a custom request
|
|
87
|
+
# client.request(:get, "/orders", { include: "customer" })
|
|
88
|
+
def request(method, path, data, options = {})
|
|
89
|
+
if data.is_a?(Hash) && options.empty?
|
|
90
|
+
data[:headers] = default_headers.merge(data.delete(:headers) || {})
|
|
91
|
+
|
|
92
|
+
if accept = data.delete(:accept)
|
|
93
|
+
data[:headers][:accept] = accept
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
options = data.dup
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
options = parse_options_with_convenience_headers(options) if [ :get, :head ].include?(method)
|
|
100
|
+
|
|
101
|
+
@last_response = response = agent.call(method, normalized_path(path), data, options)
|
|
102
|
+
response_data_with_correct_encoding(response)
|
|
103
|
+
rescue Booqable::Error => e
|
|
104
|
+
@last_response = nil
|
|
105
|
+
raise e
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Normalize a path to ensure it is a valid URL path
|
|
109
|
+
#
|
|
110
|
+
# Removes leading slashes and normalizes the path to prevent
|
|
111
|
+
# directory traversal attacks and ensure consistent formatting.
|
|
112
|
+
#
|
|
113
|
+
# @param path [String] The path to normalize
|
|
114
|
+
# @return [String] Normalized path without leading slash
|
|
115
|
+
# @api private
|
|
116
|
+
def normalized_path(path)
|
|
117
|
+
relative_path = path.to_s.sub(%r{^/}, "") # Remove leading slash
|
|
118
|
+
Addressable::URI.parse(relative_path.to_s).normalize.to_s
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Response for the last HTTP request
|
|
122
|
+
#
|
|
123
|
+
# @return [Sawyer::Response, nil] Last response object or nil if no request was made
|
|
124
|
+
def last_response
|
|
125
|
+
@last_response
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Make a paginated request to the Booqable API.
|
|
129
|
+
#
|
|
130
|
+
# @param url [String] The path, relative to {#api_endpoint}
|
|
131
|
+
# @param options [Hash] Query and header params for request
|
|
132
|
+
# @param block [Block] Block to perform the data concatination of the
|
|
133
|
+
# multiple requests. The block is called with two parameters, the first
|
|
134
|
+
# contains the contents of the requests so far and the second parameter
|
|
135
|
+
# contains the latest response.
|
|
136
|
+
# @return [Sawyer::Resource]
|
|
137
|
+
def paginate(url, options = {})
|
|
138
|
+
if @per_page || @auto_paginate
|
|
139
|
+
options[:page] ||= {}
|
|
140
|
+
options[:page][:size] ||= @per_page || (@auto_paginate ? 25 : nil)
|
|
141
|
+
options[:page][:number] ||= 1
|
|
142
|
+
options[:stats] ||= { total: "count" } # otherwise we don't get the total count in the response
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
data = request(:get, url, options)[:data]
|
|
146
|
+
|
|
147
|
+
if @auto_paginate && total_present_in_stats?
|
|
148
|
+
# While there are more results to fetch, and we have not hit the rate limit
|
|
149
|
+
while total_count = last_response_body[:meta][:stats][:total][:count] > data.length && rate_limit.remaining > 0
|
|
150
|
+
options[:page][:number] = options[:page][:number] + 1
|
|
151
|
+
|
|
152
|
+
request(:get, url, options.dup)
|
|
153
|
+
|
|
154
|
+
data.concat(@last_response.data[:data]) if @last_response.data[:data].is_a?(Array)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
data
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Get rate limit information from the last response
|
|
162
|
+
#
|
|
163
|
+
# Extracts rate limiting information from the HTTP headers of the
|
|
164
|
+
# most recent API response. This includes remaining requests, reset time,
|
|
165
|
+
# and limit information.
|
|
166
|
+
#
|
|
167
|
+
# @return [RateLimit] Rate limit information object
|
|
168
|
+
# @see RateLimit
|
|
169
|
+
def rate_limit
|
|
170
|
+
RateLimit.from_response(@last_response)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Get or create the Faraday connection instance
|
|
174
|
+
#
|
|
175
|
+
# Returns a memoized Faraday connection configured with the appropriate
|
|
176
|
+
# middleware stack, authentication, and connection options.
|
|
177
|
+
#
|
|
178
|
+
# @return [Faraday::Connection] HTTP connection instance
|
|
179
|
+
# @api private
|
|
180
|
+
def faraday
|
|
181
|
+
@faraday ||= Faraday.new(faraday_options)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Get default HTTP headers for requests
|
|
185
|
+
#
|
|
186
|
+
# Returns the standard headers that should be included with every
|
|
187
|
+
# API request, including content type, accept headers, and user agent.
|
|
188
|
+
#
|
|
189
|
+
# @return [Hash] Default headers hash
|
|
190
|
+
# @api private
|
|
191
|
+
def default_headers
|
|
192
|
+
{
|
|
193
|
+
accept: default_media_type,
|
|
194
|
+
content_type: default_media_type,
|
|
195
|
+
user_agent: user_agent
|
|
196
|
+
}
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Get Faraday connection options
|
|
200
|
+
#
|
|
201
|
+
# Builds the configuration hash for the Faraday connection including
|
|
202
|
+
# URL, middleware builder, proxy settings, and SSL verification options.
|
|
203
|
+
#
|
|
204
|
+
# @return [Hash] Faraday connection options
|
|
205
|
+
# @api private
|
|
206
|
+
def faraday_options
|
|
207
|
+
opts = connection_options || { headers: default_headers }
|
|
208
|
+
|
|
209
|
+
opts[:url] = api_endpoint
|
|
210
|
+
opts[:builder] = faraday_builder
|
|
211
|
+
opts[:proxy] = proxy if proxy
|
|
212
|
+
|
|
213
|
+
if opts[:ssl].nil?
|
|
214
|
+
opts[:ssl] = { verify_mode: @ssl_verify_mode } if @ssl_verify_mode
|
|
215
|
+
else
|
|
216
|
+
verify = connection_options[:ssl][:verify]
|
|
217
|
+
opts[:ssl] = {
|
|
218
|
+
verify: verify,
|
|
219
|
+
verify_mode: verify == false ? 0 : @ssl_verify_mode
|
|
220
|
+
}
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
opts
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Get or create the Faraday middleware builder
|
|
227
|
+
#
|
|
228
|
+
# Creates a middleware stack with authentication and optionally removes
|
|
229
|
+
# retry middleware based on configuration. The builder is memoized for
|
|
230
|
+
# performance.
|
|
231
|
+
#
|
|
232
|
+
# @return [Faraday::RackBuilder] Middleware builder instance
|
|
233
|
+
# @api private
|
|
234
|
+
def faraday_builder
|
|
235
|
+
@faraday_builder ||= @middleware.dup.tap do |builder|
|
|
236
|
+
inject_auth_middleware(builder)
|
|
237
|
+
# Remove retry middleware if no_retries is enabled
|
|
238
|
+
if no_retries
|
|
239
|
+
builder.handlers.delete_if { |handler| handler.klass == Faraday::Retry::Middleware }
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Get or create the Sawyer agent for API requests
|
|
245
|
+
#
|
|
246
|
+
# Returns a memoized Sawyer::Agent configured with the API endpoint,
|
|
247
|
+
# serializer, and optional logging. Sawyer handles the low-level HTTP
|
|
248
|
+
# communication and response parsing.
|
|
249
|
+
#
|
|
250
|
+
# @return [Sawyer::Agent] HTTP agent instance
|
|
251
|
+
# @api private
|
|
252
|
+
def agent
|
|
253
|
+
@agent ||= Sawyer::Agent.new(api_endpoint,
|
|
254
|
+
sawyer_options) do |agent|
|
|
255
|
+
agent.response :logger, logger, bodies: true if logger
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Get logger instance for debug output
|
|
260
|
+
#
|
|
261
|
+
# Creates a logger that outputs to STDOUT with DEBUG level when
|
|
262
|
+
# debug mode is enabled. Returns nil when debug is disabled.
|
|
263
|
+
#
|
|
264
|
+
# @return [Logger, nil] Logger instance or nil if debug is disabled
|
|
265
|
+
# @api private
|
|
266
|
+
def logger
|
|
267
|
+
@logger ||= Logger.new(STDOUT).tap do |logger|
|
|
268
|
+
logger.level = Logger::DEBUG
|
|
269
|
+
end if debug?
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Get configuration options for Sawyer agent
|
|
273
|
+
#
|
|
274
|
+
# Returns the configuration hash for the Sawyer agent including
|
|
275
|
+
# the Faraday connection, link parser, and JSON:API serializer.
|
|
276
|
+
#
|
|
277
|
+
# @return [Hash] Sawyer agent configuration options
|
|
278
|
+
# @api private
|
|
279
|
+
def sawyer_options
|
|
280
|
+
{
|
|
281
|
+
faraday: faraday,
|
|
282
|
+
# simple link parser
|
|
283
|
+
link_parser: Sawyer::LinkParsers::Simple.new,
|
|
284
|
+
# use our own JSON API serializer
|
|
285
|
+
serializer: sawyer_serializer
|
|
286
|
+
}
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Get the JSON:API serializer for Sawyer
|
|
290
|
+
#
|
|
291
|
+
# Returns the configured JSON:API serializer that handles encoding
|
|
292
|
+
# and decoding of request/response bodies according to the JSON:API
|
|
293
|
+
# specification.
|
|
294
|
+
#
|
|
295
|
+
# @return [Booqable::JsonApiSerializer] Serializer instance
|
|
296
|
+
# @api private
|
|
297
|
+
def sawyer_serializer
|
|
298
|
+
Booqable::JsonApiSerializer.any_json
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Get the decoded body of the last response
|
|
302
|
+
#
|
|
303
|
+
# Parses the raw response body from the last HTTP request using
|
|
304
|
+
# the JSON:API serializer. Returns nil if no response is available.
|
|
305
|
+
#
|
|
306
|
+
# @return [Hash, nil] Parsed response body or nil
|
|
307
|
+
# @api private
|
|
308
|
+
def last_response_body
|
|
309
|
+
sawyer_serializer.decode(@last_response.body) if @last_response
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Parse options and extract convenience headers
|
|
313
|
+
#
|
|
314
|
+
# Processes request options to extract convenience headers (like accept,
|
|
315
|
+
# content_type, user_agent) from the top level and moves them to the
|
|
316
|
+
# headers hash for proper HTTP header handling.
|
|
317
|
+
#
|
|
318
|
+
# @param options [Hash] Request options that may contain convenience headers
|
|
319
|
+
# @return [Hash] Processed options with headers properly organized
|
|
320
|
+
# @api private
|
|
321
|
+
def parse_options_with_convenience_headers(options)
|
|
322
|
+
headers = options.delete(:headers) || {}
|
|
323
|
+
|
|
324
|
+
CONVENIENCE_HEADERS.each do |h|
|
|
325
|
+
if header = options.delete(h)
|
|
326
|
+
headers[h] = header
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
query = options.delete(:query) || {}
|
|
331
|
+
|
|
332
|
+
opts = { query: options }
|
|
333
|
+
opts[:query].merge!(query) if query.is_a?(Hash)
|
|
334
|
+
opts[:headers] = headers unless headers.empty?
|
|
335
|
+
opts
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# Ensure response data has correct character encoding
|
|
339
|
+
#
|
|
340
|
+
# Reads the charset from the Content-Type header and forces the correct
|
|
341
|
+
# encoding on string response data. Returns the response data unchanged
|
|
342
|
+
# if no charset is specified or data is not a string.
|
|
343
|
+
#
|
|
344
|
+
# @param response [Sawyer::Response] HTTP response object
|
|
345
|
+
# @return [Object] Response data with correct encoding
|
|
346
|
+
# @api private
|
|
347
|
+
def response_data_with_correct_encoding(response)
|
|
348
|
+
content_type = response.headers.fetch("content-type", "")
|
|
349
|
+
return response.data unless content_type.include?("charset") && response.data.is_a?(String)
|
|
350
|
+
|
|
351
|
+
reported_encoding = content_type.match(/charset=([^ ]+)/)[1]
|
|
352
|
+
response.data.force_encoding(reported_encoding)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# Get the full API endpoint URL
|
|
356
|
+
#
|
|
357
|
+
# Constructs the complete API endpoint URL from the configured company,
|
|
358
|
+
# domain, protocol, and API version. Validates that the API version is
|
|
359
|
+
# supported and that a company is specified.
|
|
360
|
+
#
|
|
361
|
+
# @return [String] Complete API endpoint URL
|
|
362
|
+
# @raise [UnsupportedAPIVersion] If the API version is not supported
|
|
363
|
+
# @raise [CompanyRequired] If no company is configured
|
|
364
|
+
# @api private
|
|
365
|
+
def api_endpoint
|
|
366
|
+
@api_endpoint ||= begin
|
|
367
|
+
raise UnsupportedAPIVersion unless %w[4 boomerang].include?(api_version.to_s)
|
|
368
|
+
raise CompanyRequired if company_id.nil? || company_id.empty?
|
|
369
|
+
Addressable::URI.join("#{api_protocol}://#{company_id}.#{api_domain}/", "api/", api_version.to_s).to_s
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# Checks if the total count is present in the last response stats.
|
|
374
|
+
#
|
|
375
|
+
# @return [Boolean] true if total count is present, false otherwise
|
|
376
|
+
def total_present_in_stats?
|
|
377
|
+
last_response_body &&
|
|
378
|
+
last_response_body[:meta] &&
|
|
379
|
+
last_response_body[:meta][:stats] &&
|
|
380
|
+
last_response_body[:meta][:stats][:total]
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
end
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
module Booqable
|
|
2
|
+
# JSON API serializer with support for multiple JSON backends
|
|
3
|
+
#
|
|
4
|
+
# Handles encoding and decoding of JSON API responses with automatic
|
|
5
|
+
# relationship population and attribute transformation. Supports multiple
|
|
6
|
+
# JSON libraries including the standard JSON gem, Yajl, and MultiJson.
|
|
7
|
+
#
|
|
8
|
+
# See: https://github.com/lostisland/sawyer/blob/142c8fd9ee82bc01dd71e1929be0e4fd975fd9ed/lib/sawyer/serializer.rb
|
|
9
|
+
#
|
|
10
|
+
# @example Basic usage
|
|
11
|
+
# serializer = Booqable::JsonApiSerializer.any_json
|
|
12
|
+
# data = { "name" => "Order", "created_at" => Time.now }
|
|
13
|
+
# encoded = serializer.encode(data)
|
|
14
|
+
# decoded = serializer.decode(encoded)
|
|
15
|
+
class JsonApiSerializer
|
|
16
|
+
# Get the first available JSON serializer
|
|
17
|
+
#
|
|
18
|
+
# Tries different JSON libraries in order of preference and returns
|
|
19
|
+
# the first one that's available.
|
|
20
|
+
#
|
|
21
|
+
# @return [Booqable::JsonApiSerializer] A serializer instance
|
|
22
|
+
# @raise [RuntimeError] If no JSON library is available
|
|
23
|
+
def self.any_json
|
|
24
|
+
yajl || multi_json || json || begin
|
|
25
|
+
raise RuntimeError, "Sawyer requires a JSON gem: yajl, multi_json, or json"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Create a serializer using the Yajl JSON library
|
|
30
|
+
#
|
|
31
|
+
# @return [Booqable::JsonApiSerializer, nil] Serializer instance or nil if Yajl unavailable
|
|
32
|
+
def self.yajl
|
|
33
|
+
require "yajl"
|
|
34
|
+
new(Yajl)
|
|
35
|
+
rescue LoadError
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Create a serializer using the standard JSON library
|
|
39
|
+
#
|
|
40
|
+
# @return [Booqable::JsonApiSerializer, nil] Serializer instance or nil if JSON unavailable
|
|
41
|
+
def self.json
|
|
42
|
+
require "json"
|
|
43
|
+
new(JSON)
|
|
44
|
+
rescue LoadError
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Create a serializer using the MultiJson library
|
|
48
|
+
#
|
|
49
|
+
# @return [Booqable::JsonApiSerializer, nil] Serializer instance or nil if MultiJson unavailable
|
|
50
|
+
def self.multi_json
|
|
51
|
+
require "multi_json"
|
|
52
|
+
new(MultiJson)
|
|
53
|
+
rescue LoadError
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Create a serializer using the MessagePack library
|
|
57
|
+
#
|
|
58
|
+
# @return [Booqable::JsonApiSerializer, nil] Serializer instance or nil if MessagePack unavailable
|
|
59
|
+
def self.message_pack
|
|
60
|
+
require "msgpack"
|
|
61
|
+
new(MessagePack, :pack, :unpack)
|
|
62
|
+
rescue LoadError
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Initialize a new serializer
|
|
66
|
+
#
|
|
67
|
+
# Wraps a serialization format for JSON API processing. Nested objects are
|
|
68
|
+
# prepared for serialization (such as changing Times to ISO 8601 Strings).
|
|
69
|
+
# Any serialization format that responds to #dump and #load will work.
|
|
70
|
+
#
|
|
71
|
+
# @param format [Object] The JSON library to use (e.g., JSON, Yajl)
|
|
72
|
+
# @param dump_method_name [Symbol, nil] Method name for encoding (default: :dump)
|
|
73
|
+
# @param load_method_name [Symbol, nil] Method name for decoding (default: :load)
|
|
74
|
+
def initialize(format, dump_method_name = nil, load_method_name = nil)
|
|
75
|
+
@format = format
|
|
76
|
+
@dump = @format.method(dump_method_name || :dump)
|
|
77
|
+
@load = @format.method(load_method_name || :load)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Encode an object to JSON
|
|
81
|
+
#
|
|
82
|
+
# Encodes an Object (usually a Hash or Array of Hashes) with special
|
|
83
|
+
# handling for dates, times, and nested structures.
|
|
84
|
+
#
|
|
85
|
+
# @param data [Object] Object to be encoded
|
|
86
|
+
# @return [String] JSON-encoded string
|
|
87
|
+
def encode(data)
|
|
88
|
+
@dump.call(encode_object(data))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
alias dump encode
|
|
92
|
+
|
|
93
|
+
# Decode JSON data to Ruby objects
|
|
94
|
+
#
|
|
95
|
+
# Decodes a JSON string into Ruby objects (usually a Hash or Array of
|
|
96
|
+
# Hashes) with JSON API relationship population and attribute transformation.
|
|
97
|
+
#
|
|
98
|
+
# @param data [String, nil] JSON string to be decoded
|
|
99
|
+
# @return [Object, nil] Decoded Ruby object, or nil for empty/nil input
|
|
100
|
+
def decode(data)
|
|
101
|
+
return nil if data.nil? || data.strip.empty?
|
|
102
|
+
decoded = decode_object(@load.call(data))
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
alias load decode
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def encode_object(data)
|
|
110
|
+
case data
|
|
111
|
+
when Hash then encode_hash(data)
|
|
112
|
+
when Array then data.map { |o| encode_object(o) }
|
|
113
|
+
else data
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def encode_hash(hash)
|
|
118
|
+
hash.keys.each do |key|
|
|
119
|
+
case value = hash[key]
|
|
120
|
+
when Date then hash[key] = value.to_time.utc.xmlschema
|
|
121
|
+
when Time then hash[key] = value.utc.xmlschema
|
|
122
|
+
when Hash then hash[key] = encode_hash(value)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
hash
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def decode_object(data)
|
|
129
|
+
case data
|
|
130
|
+
when Hash then decode_hash(data)
|
|
131
|
+
when Array then data.map { |o| decode_object(o) }
|
|
132
|
+
else data
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def decode_hash(hash)
|
|
137
|
+
populate_relationships(hash["data"], hash["included"]) if hash.key?("included")
|
|
138
|
+
|
|
139
|
+
transform_hash_keys(hash)
|
|
140
|
+
|
|
141
|
+
hash.keys.each do |key|
|
|
142
|
+
hash[key.to_sym] = decode_hash_value(key, hash.delete(key))
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
hash
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def transform_hash_keys(hash)
|
|
149
|
+
hash["_includes"] = hash.delete("included") if hash.key?("included")
|
|
150
|
+
|
|
151
|
+
case hash
|
|
152
|
+
when Array
|
|
153
|
+
hash = hash.map { |item| transform_hash(item, hash) }
|
|
154
|
+
when Hash
|
|
155
|
+
hash = transform_hash(hash)
|
|
156
|
+
else hash
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def transform_hash(hash, parent = nil)
|
|
161
|
+
transform_attributes(hash)
|
|
162
|
+
transform_relationships(hash, parent)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def transform_attributes(hash)
|
|
166
|
+
if hash.key?("attributes")
|
|
167
|
+
attributes = hash.delete("attributes")
|
|
168
|
+
hash.merge!(attributes)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def transform_relationships(hash, parent)
|
|
173
|
+
return unless hash.key?("relationships")
|
|
174
|
+
|
|
175
|
+
hash["_relationships"] = hash.delete("relationships")
|
|
176
|
+
hash["_relationships"].each do |key, value|
|
|
177
|
+
next unless value.is_a?(Hash) && value.key?("data")
|
|
178
|
+
|
|
179
|
+
relationship_data = value["data"]
|
|
180
|
+
|
|
181
|
+
# Handle single relationship (to-one)
|
|
182
|
+
if relationship_data.is_a?(Hash)
|
|
183
|
+
hash[key] = relationship_data
|
|
184
|
+
# Handle multiple relationships (to-many)
|
|
185
|
+
elsif relationship_data.is_a?(Array)
|
|
186
|
+
hash[key] = relationship_data
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
hash.delete("_relationships")
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def populate_relationships(obj, includes = [])
|
|
194
|
+
case obj
|
|
195
|
+
when Array
|
|
196
|
+
obj.each do |item|
|
|
197
|
+
populate_relationships(item, includes)
|
|
198
|
+
end
|
|
199
|
+
when Hash
|
|
200
|
+
if obj.key?("relationships")
|
|
201
|
+
obj["relationships"].each do |key, value|
|
|
202
|
+
next unless value.is_a?(Hash) && value.key?("data")
|
|
203
|
+
|
|
204
|
+
relationship_data = value["data"]
|
|
205
|
+
|
|
206
|
+
# Handle single relationship (to-one)
|
|
207
|
+
if relationship_data.is_a?(Hash)
|
|
208
|
+
if relationship_data.key?("id") && relationship_data.key?("type")
|
|
209
|
+
found_include = includes.find { |inc| inc["id"] == relationship_data["id"] && inc["type"] == relationship_data["type"] }
|
|
210
|
+
if found_include
|
|
211
|
+
value["data"] = found_include
|
|
212
|
+
# Recursively populate nested relationships
|
|
213
|
+
populate_relationships(found_include, includes)
|
|
214
|
+
else
|
|
215
|
+
value["data"] = relationship_data
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
# Handle multiple relationships (to-many)
|
|
219
|
+
elsif relationship_data.is_a?(Array)
|
|
220
|
+
value["data"] = relationship_data.map do |relationship|
|
|
221
|
+
if relationship.is_a?(Hash) && relationship.key?("id") && relationship.key?("type")
|
|
222
|
+
found_include = includes.find { |inc| inc["id"] == relationship["id"] && inc["type"] == relationship["type"] }
|
|
223
|
+
if found_include
|
|
224
|
+
# Recursively populate nested relationships
|
|
225
|
+
populate_relationships(found_include, includes)
|
|
226
|
+
found_include
|
|
227
|
+
else
|
|
228
|
+
relationship
|
|
229
|
+
end
|
|
230
|
+
else
|
|
231
|
+
relationship
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def decode_hash_value(key, value)
|
|
241
|
+
if time_field?(key, value)
|
|
242
|
+
if value.is_a?(String)
|
|
243
|
+
begin
|
|
244
|
+
Time.parse(value)
|
|
245
|
+
rescue ArgumentError
|
|
246
|
+
value
|
|
247
|
+
end
|
|
248
|
+
elsif value.is_a?(Integer) || value.is_a?(Float)
|
|
249
|
+
Time.at(value)
|
|
250
|
+
else
|
|
251
|
+
value
|
|
252
|
+
end
|
|
253
|
+
elsif value.is_a?(Hash)
|
|
254
|
+
decode_hash(value)
|
|
255
|
+
elsif value.is_a?(Array)
|
|
256
|
+
value.map { |o| decode_hash_value(key, o) }
|
|
257
|
+
else
|
|
258
|
+
value
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def time_field?(key, value)
|
|
263
|
+
value && (key =~ /_(at|on)\z/ || key =~ /(\A|_)date\z/)
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
module Booqable
|
|
2
|
+
module Middleware
|
|
3
|
+
module Auth
|
|
4
|
+
# Faraday middleware for API key authentication
|
|
5
|
+
#
|
|
6
|
+
# This middleware adds Bearer token authentication to HTTP requests using
|
|
7
|
+
# a pre-configured API key. The API key is added to the Authorization header
|
|
8
|
+
# for each request unless already present.
|
|
9
|
+
#
|
|
10
|
+
# For more info see: https://developers.booqable.com/#authentication-access-token
|
|
11
|
+
#
|
|
12
|
+
# @example Adding to Faraday middleware stack
|
|
13
|
+
# builder.use Booqable::Middleware::Auth::ApiKey, api_key: "your_api_key"
|
|
14
|
+
class ApiKey < Base
|
|
15
|
+
# OAuth token endpoint (legacy reference)
|
|
16
|
+
TOKEN_ENDPOINT = "/api/boomerang/oauth/token"
|
|
17
|
+
|
|
18
|
+
# Initialize the API key authentication middleware
|
|
19
|
+
#
|
|
20
|
+
# @param app [#call] The next middleware in the Faraday stack
|
|
21
|
+
# @param options [Hash] Configuration options
|
|
22
|
+
# @option options [String] :api_key The API key for authentication
|
|
23
|
+
# @raise [KeyError] If :api_key option is not provided
|
|
24
|
+
def initialize(app, options = {})
|
|
25
|
+
super(app)
|
|
26
|
+
|
|
27
|
+
@api_key = options.fetch(:api_key)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Process the HTTP request and add API key authentication
|
|
31
|
+
#
|
|
32
|
+
# Adds the API key as a Bearer token in the Authorization header if
|
|
33
|
+
# no authorization header is already present, then passes the request
|
|
34
|
+
# to the next middleware in the stack.
|
|
35
|
+
#
|
|
36
|
+
# @param env [Faraday::Env] The request environment
|
|
37
|
+
# @return [Faraday::Response] The response from the next middleware
|
|
38
|
+
def call(env)
|
|
39
|
+
env.request_headers["Authorization"] ||= "Bearer #{@api_key}"
|
|
40
|
+
|
|
41
|
+
@app.call(env)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|