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