ruby_llm 1.0.1 → 1.1.0rc1

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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +28 -12
  3. data/lib/ruby_llm/active_record/acts_as.rb +46 -7
  4. data/lib/ruby_llm/aliases.json +65 -0
  5. data/lib/ruby_llm/aliases.rb +56 -0
  6. data/lib/ruby_llm/chat.rb +10 -9
  7. data/lib/ruby_llm/configuration.rb +4 -0
  8. data/lib/ruby_llm/error.rb +15 -4
  9. data/lib/ruby_llm/models.json +1163 -303
  10. data/lib/ruby_llm/models.rb +40 -11
  11. data/lib/ruby_llm/provider.rb +32 -39
  12. data/lib/ruby_llm/providers/anthropic/capabilities.rb +8 -9
  13. data/lib/ruby_llm/providers/anthropic/chat.rb +31 -4
  14. data/lib/ruby_llm/providers/anthropic/streaming.rb +12 -6
  15. data/lib/ruby_llm/providers/anthropic.rb +4 -0
  16. data/lib/ruby_llm/providers/bedrock/capabilities.rb +168 -0
  17. data/lib/ruby_llm/providers/bedrock/chat.rb +108 -0
  18. data/lib/ruby_llm/providers/bedrock/models.rb +84 -0
  19. data/lib/ruby_llm/providers/bedrock/signing.rb +831 -0
  20. data/lib/ruby_llm/providers/bedrock/streaming/base.rb +46 -0
  21. data/lib/ruby_llm/providers/bedrock/streaming/content_extraction.rb +63 -0
  22. data/lib/ruby_llm/providers/bedrock/streaming/message_processing.rb +79 -0
  23. data/lib/ruby_llm/providers/bedrock/streaming/payload_processing.rb +90 -0
  24. data/lib/ruby_llm/providers/bedrock/streaming/prelude_handling.rb +91 -0
  25. data/lib/ruby_llm/providers/bedrock/streaming.rb +36 -0
  26. data/lib/ruby_llm/providers/bedrock.rb +83 -0
  27. data/lib/ruby_llm/providers/deepseek/chat.rb +17 -0
  28. data/lib/ruby_llm/providers/deepseek.rb +5 -0
  29. data/lib/ruby_llm/providers/gemini/capabilities.rb +50 -34
  30. data/lib/ruby_llm/providers/gemini/chat.rb +8 -15
  31. data/lib/ruby_llm/providers/gemini/images.rb +5 -10
  32. data/lib/ruby_llm/providers/gemini/streaming.rb +35 -76
  33. data/lib/ruby_llm/providers/gemini/tools.rb +12 -12
  34. data/lib/ruby_llm/providers/gemini.rb +4 -0
  35. data/lib/ruby_llm/providers/openai/capabilities.rb +146 -206
  36. data/lib/ruby_llm/providers/openai/streaming.rb +9 -13
  37. data/lib/ruby_llm/providers/openai.rb +4 -0
  38. data/lib/ruby_llm/streaming.rb +96 -0
  39. data/lib/ruby_llm/version.rb +1 -1
  40. data/lib/ruby_llm.rb +6 -3
  41. data/lib/tasks/browser_helper.rb +97 -0
  42. data/lib/tasks/capability_generator.rb +123 -0
  43. data/lib/tasks/capability_scraper.rb +224 -0
  44. data/lib/tasks/cli_helper.rb +22 -0
  45. data/lib/tasks/code_validator.rb +29 -0
  46. data/lib/tasks/model_updater.rb +66 -0
  47. data/lib/tasks/models.rake +28 -193
  48. data/lib/tasks/vcr.rake +13 -30
  49. metadata +27 -19
  50. data/.github/workflows/cicd.yml +0 -158
  51. data/.github/workflows/docs.yml +0 -53
  52. data/.gitignore +0 -59
  53. data/.overcommit.yml +0 -26
  54. data/.rspec +0 -3
  55. data/.rubocop.yml +0 -10
  56. data/.yardopts +0 -12
  57. data/CONTRIBUTING.md +0 -207
  58. data/Gemfile +0 -33
  59. data/Rakefile +0 -9
  60. data/bin/console +0 -17
  61. data/bin/setup +0 -6
  62. data/ruby_llm.gemspec +0 -44
@@ -0,0 +1,831 @@
1
+ # Portions of this file are derived from AWS SDK for Ruby (Apache License 2.0)
2
+ # Modifications made by RubyLLM in 2025
3
+ # See THIRD_PARTY_LICENSES/AWS-SDK-NOTICE.txt for details
4
+
5
+ # frozen_string_literal: true
6
+
7
+ require 'openssl'
8
+ require 'tempfile'
9
+ require 'time'
10
+ require 'uri'
11
+ require 'set'
12
+ require 'cgi'
13
+ require 'pathname'
14
+
15
+ module RubyLLM
16
+ module Providers
17
+ module Bedrock
18
+ module Signing
19
+ # Utility class for creating AWS signature version 4 signature. This class
20
+ # provides a method for generating signatures:
21
+ #
22
+ # * {#sign_request} - Computes a signature of the given request, returning
23
+ # the hash of headers that should be applied to the request.
24
+ #
25
+ # ## Configuration
26
+ #
27
+ # To use the signer, you need to specify the service, region, and credentials.
28
+ # The service name is normally the endpoint prefix to an AWS service. For
29
+ # example:
30
+ #
31
+ # ec2.us-west-1.amazonaws.com => ec2
32
+ #
33
+ # The region is normally the second portion of the endpoint, following
34
+ # the service name.
35
+ #
36
+ # ec2.us-west-1.amazonaws.com => us-west-1
37
+ #
38
+ # It is important to have the correct service and region name, or the
39
+ # signature will be invalid.
40
+ #
41
+ # ## Credentials
42
+ #
43
+ # The signer requires credentials. You can configure the signer
44
+ # with static credentials:
45
+ #
46
+ # signer = Aws::Sigv4::Signer.new(
47
+ # service: 's3',
48
+ # region: 'us-east-1',
49
+ # # static credentials
50
+ # access_key_id: 'akid',
51
+ # secret_access_key: 'secret'
52
+ # )
53
+ #
54
+ # You can also provide refreshing credentials via the `:credentials_provider`.
55
+ # If you are using the AWS SDK for Ruby, you can use any of the credential
56
+ # classes:
57
+ #
58
+ # signer = Aws::Sigv4::Signer.new(
59
+ # service: 's3',
60
+ # region: 'us-east-1',
61
+ # credentials_provider: Aws::InstanceProfileCredentials.new
62
+ # )
63
+ #
64
+ # Other AWS SDK for Ruby classes that can be provided via `:credentials_provider`:
65
+ #
66
+ # * `Aws::Credentials`
67
+ # * `Aws::SharedCredentials`
68
+ # * `Aws::InstanceProfileCredentials`
69
+ # * `Aws::AssumeRoleCredentials`
70
+ # * `Aws::ECSCredentials`
71
+ #
72
+ # A credential provider is any object that responds to `#credentials`
73
+ # returning another object that responds to `#access_key_id`, `#secret_access_key`,
74
+ # and `#session_token`.
75
+ module Errors
76
+ # Error raised when AWS credentials are missing or incomplete
77
+ class MissingCredentialsError < ArgumentError
78
+ def initialize(msg = nil)
79
+ super(msg || <<~MSG.strip)
80
+ missing credentials, provide credentials with one of the following options:
81
+ - :access_key_id and :secret_access_key
82
+ - :credentials
83
+ - :credentials_provider
84
+ MSG
85
+ end
86
+ end
87
+
88
+ # Error raised when AWS region is not specified
89
+ class MissingRegionError < ArgumentError
90
+ def initialize(*_args)
91
+ super('missing required option :region')
92
+ end
93
+ end
94
+ end
95
+
96
+ # Represents a signature for AWS request signing
97
+ class Signature
98
+ # @api private
99
+ def initialize(options)
100
+ options.each_pair do |attr_name, attr_value|
101
+ send("#{attr_name}=", attr_value)
102
+ end
103
+ end
104
+
105
+ # @return [Hash<String,String>] A hash of headers that should
106
+ # be applied to the HTTP request. Header keys are lower
107
+ # cased strings and may include the following:
108
+ #
109
+ # * 'host'
110
+ # * 'x-amz-date'
111
+ # * 'x-amz-security-token'
112
+ # * 'x-amz-content-sha256'
113
+ # * 'authorization'
114
+ #
115
+ attr_accessor :headers
116
+
117
+ # @return [String] For debugging purposes.
118
+ attr_accessor :canonical_request
119
+
120
+ # @return [String] For debugging purposes.
121
+ attr_accessor :string_to_sign
122
+
123
+ # @return [String] For debugging purposes.
124
+ attr_accessor :content_sha256
125
+
126
+ # @return [String] For debugging purposes.
127
+ attr_accessor :signature
128
+
129
+ # @return [Hash] Internal data for debugging purposes.
130
+ attr_accessor :extra
131
+ end
132
+
133
+ # Manages AWS credentials for authentication
134
+ class Credentials
135
+ # @option options [required, String] :access_key_id
136
+ # @option options [required, String] :secret_access_key
137
+ # @option options [String, nil] :session_token (nil)
138
+ def initialize(options = {})
139
+ if options[:access_key_id] && options[:secret_access_key]
140
+ @access_key_id = options[:access_key_id]
141
+ @secret_access_key = options[:secret_access_key]
142
+ @session_token = options[:session_token]
143
+ else
144
+ msg = 'expected both :access_key_id and :secret_access_key options'
145
+ raise ArgumentError, msg
146
+ end
147
+ end
148
+
149
+ # @return [String]
150
+ attr_reader :access_key_id
151
+
152
+ # @return [String]
153
+ attr_reader :secret_access_key
154
+
155
+ # @return [String, nil]
156
+ attr_reader :session_token
157
+
158
+ # @return [Boolean] Returns `true` if the access key id and secret
159
+ # access key are both set.
160
+ def set?
161
+ !access_key_id.nil? &&
162
+ !access_key_id.empty? &&
163
+ !secret_access_key.nil? &&
164
+ !secret_access_key.empty?
165
+ end
166
+ end
167
+
168
+ # Users that wish to configure static credentials can use the
169
+ # `:access_key_id` and `:secret_access_key` constructor options.
170
+ # @api private
171
+ class StaticCredentialsProvider
172
+ # @option options [Credentials] :credentials
173
+ # @option options [String] :access_key_id
174
+ # @option options [String] :secret_access_key
175
+ # @option options [String] :session_token (nil)
176
+ def initialize(options = {})
177
+ @credentials = options[:credentials] || Credentials.new(options)
178
+ end
179
+
180
+ # @return [Credentials]
181
+ attr_reader :credentials
182
+
183
+ # @return [Boolean]
184
+ def set?
185
+ !!credentials && credentials.set?
186
+ end
187
+ end
188
+
189
+ ParamComponent = Struct.new(:name, :value, :offset)
190
+
191
+ # Utility methods for URI manipulation and hashing
192
+ module UriUtils
193
+ module_function
194
+
195
+ def uri_escape_path(path)
196
+ path.gsub(%r{[^/]+}) { |part| uri_escape(part) }
197
+ end
198
+
199
+ def uri_escape(string)
200
+ if string.nil?
201
+ nil
202
+ else
203
+ CGI.escape(string.encode('UTF-8')).gsub('+', '%20').gsub('%7E', '~')
204
+ end
205
+ end
206
+
207
+ def normalize_path(uri)
208
+ normalized_path = Pathname.new(uri.path).cleanpath.to_s
209
+ # Pathname is probably not correct to use. Empty paths will
210
+ # resolve to "." and should be disregarded
211
+ normalized_path = '' if normalized_path == '.'
212
+ # Ensure trailing slashes are correctly preserved
213
+ normalized_path << '/' if uri.path.end_with?('/') && !normalized_path.end_with?('/')
214
+ uri.path = normalized_path
215
+ end
216
+
217
+ def host(uri)
218
+ # Handles known and unknown URI schemes; default_port nil when unknown.
219
+ if uri.default_port == uri.port
220
+ uri.host
221
+ else
222
+ "#{uri.host}:#{uri.port}"
223
+ end
224
+ end
225
+ end
226
+
227
+ # Cryptographic hash and digest utilities
228
+ module CryptoUtils
229
+ module_function
230
+
231
+ # @param [String] value
232
+ # @return [String<SHA256 Hexdigest>]
233
+ def sha256_hexdigest(value)
234
+ OpenSSL::Digest::SHA256.hexdigest(value)
235
+ end
236
+
237
+ def hmac(key, value)
238
+ OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha256'), key, value)
239
+ end
240
+
241
+ def hexhmac(key, value)
242
+ OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), key, value)
243
+ end
244
+ end
245
+
246
+ # Configuration for canonical request creation
247
+ class CanonicalRequestConfig
248
+ attr_reader :uri_escape_path, :unsigned_headers
249
+
250
+ def initialize(options = {})
251
+ @uri_escape_path = options[:uri_escape_path] || true
252
+ @unsigned_headers = options[:unsigned_headers] || Set.new
253
+ end
254
+ end
255
+
256
+ # Handles canonical requests for AWS signature
257
+ class CanonicalRequest
258
+ # Builds a canonical request for AWS signature
259
+ # @param [Hash] params Parameters for the canonical request
260
+ def initialize(params = {})
261
+ @http_method = params[:http_method]
262
+ @url = params[:url]
263
+ @headers = params[:headers]
264
+ @content_sha256 = params[:content_sha256]
265
+ @config = params[:config] || CanonicalRequestConfig.new
266
+ end
267
+
268
+ def to_s
269
+ [
270
+ @http_method,
271
+ path,
272
+ '', # Empty string instead of normalized_querystring since we don't use query params
273
+ "#{canonical_headers}\n",
274
+ signed_headers,
275
+ @content_sha256
276
+ ].join("\n")
277
+ end
278
+
279
+ # Returns the list of signed headers for authorization
280
+ def signed_headers
281
+ @headers.inject([]) do |signed_headers, (header, _)|
282
+ if @config.unsigned_headers.include?(header)
283
+ signed_headers
284
+ else
285
+ signed_headers << header
286
+ end
287
+ end.sort.join(';')
288
+ end
289
+
290
+ private
291
+
292
+ def path
293
+ path = @url.path
294
+ path = '/' if path == ''
295
+ if @config.uri_escape_path
296
+ UriUtils.uri_escape_path(path)
297
+ else
298
+ path
299
+ end
300
+ end
301
+
302
+ def canonical_headers
303
+ headers = @headers.inject([]) do |hdrs, (k, v)|
304
+ if @config.unsigned_headers.include?(k)
305
+ hdrs
306
+ else
307
+ hdrs << [k, v]
308
+ end
309
+ end
310
+ headers = headers.sort_by(&:first)
311
+ headers.map { |k, v| "#{k}:#{canonical_header_value(v.to_s)}" }.join("\n")
312
+ end
313
+
314
+ def canonical_header_value(value)
315
+ value.gsub(/\s+/, ' ').strip
316
+ end
317
+ end
318
+
319
+ # Handles signature computation
320
+ class SignatureComputation
321
+ def initialize(service, region, signing_algorithm)
322
+ @service = service
323
+ @region = region
324
+ @signing_algorithm = signing_algorithm
325
+ end
326
+
327
+ def string_to_sign(datetime, canonical_request, algorithm)
328
+ [
329
+ algorithm,
330
+ datetime,
331
+ credential_scope(datetime[0, 8]),
332
+ CryptoUtils.sha256_hexdigest(canonical_request)
333
+ ].join("\n")
334
+ end
335
+
336
+ def credential_scope(date)
337
+ [
338
+ date,
339
+ @region,
340
+ @service,
341
+ 'aws4_request'
342
+ ].join('/')
343
+ end
344
+
345
+ def credential(credentials, date)
346
+ "#{credentials.access_key_id}/#{credential_scope(date)}"
347
+ end
348
+
349
+ def signature(secret_access_key, date, string_to_sign)
350
+ k_date = CryptoUtils.hmac("AWS4#{secret_access_key}", date)
351
+ k_region = CryptoUtils.hmac(k_date, @region)
352
+ k_service = CryptoUtils.hmac(k_region, @service)
353
+ k_credentials = CryptoUtils.hmac(k_service, 'aws4_request')
354
+ CryptoUtils.hexhmac(k_credentials, string_to_sign)
355
+ end
356
+ end
357
+
358
+ # Extracts and validates request components
359
+ class RequestExtractor
360
+ # Extract and process request components
361
+ # @param [Hash] request The request to process
362
+ # @param [Hash] options Options for extraction
363
+ # @return [Hash] Processed request components
364
+ def self.extract_components(request, options = {})
365
+ normalize_path = options.fetch(:normalize_path, true)
366
+
367
+ # Extract base components
368
+ http_method, url, headers = extract_base_components(request)
369
+ UriUtils.normalize_path(url) if normalize_path
370
+
371
+ # Process headers and compute content SHA256
372
+ datetime = headers['x-amz-date'] || Time.now.utc.strftime('%Y%m%dT%H%M%SZ')
373
+ content_sha256 = extract_content_sha256(headers, request[:body])
374
+
375
+ build_component_hash(http_method, url, headers, datetime, content_sha256)
376
+ end
377
+
378
+ def self.build_component_hash(http_method, url, headers, datetime, content_sha256)
379
+ {
380
+ http_method: http_method,
381
+ url: url,
382
+ headers: headers,
383
+ datetime: datetime,
384
+ date: datetime[0, 8],
385
+ content_sha256: content_sha256
386
+ }
387
+ end
388
+
389
+ def self.extract_base_components(request)
390
+ http_method = extract_http_method(request)
391
+ url = extract_url(request)
392
+ headers = downcase_headers(request[:headers])
393
+ [http_method, url, headers]
394
+ end
395
+
396
+ def self.extract_content_sha256(headers, body)
397
+ headers['x-amz-content-sha256'] || CryptoUtils.sha256_hexdigest(body || '')
398
+ end
399
+
400
+ def self.extract_http_method(request)
401
+ if request[:http_method]
402
+ request[:http_method].upcase
403
+ else
404
+ msg = 'missing required option :http_method'
405
+ raise ArgumentError, msg
406
+ end
407
+ end
408
+
409
+ def self.extract_url(request)
410
+ if request[:url]
411
+ URI.parse(request[:url].to_s)
412
+ else
413
+ msg = 'missing required option :url'
414
+ raise ArgumentError, msg
415
+ end
416
+ end
417
+
418
+ def self.downcase_headers(headers)
419
+ (headers || {}).to_hash.transform_keys(&:downcase)
420
+ end
421
+ end
422
+
423
+ # Handles generating headers for AWS request signing
424
+ class HeaderBuilder
425
+ def initialize(options = {})
426
+ @signing_algorithm = options[:signing_algorithm]
427
+ @apply_checksum_header = options[:apply_checksum_header]
428
+ @omit_session_token = options[:omit_session_token]
429
+ @region = options[:region]
430
+ end
431
+
432
+ # Build headers for a signed request
433
+ # @param [Hash] components Request components
434
+ # @param [Credentials] creds AWS credentials
435
+ # @return [Hash] Generated headers
436
+ def build_sigv4_headers(components, creds)
437
+ headers = {
438
+ 'host' => components[:headers]['host'] || UriUtils.host(components[:url]),
439
+ 'x-amz-date' => components[:datetime]
440
+ }
441
+
442
+ add_session_token_header(headers, creds)
443
+ add_content_sha256_header(headers, components[:content_sha256])
444
+
445
+ headers
446
+ end
447
+
448
+ # Build authorization headers for a signature
449
+ # @param [Hash] sigv4_headers Headers for the signature
450
+ # @param [Hash] signature The computed signature
451
+ # @param [Hash] components Request components
452
+ # @return [Hash] Headers with authorization
453
+ def build_headers(sigv4_headers, signature, components)
454
+ headers = sigv4_headers.merge(
455
+ 'authorization' => build_authorization_header(signature)
456
+ )
457
+
458
+ add_omitted_session_token(headers, components[:creds]) if @omit_session_token
459
+ headers
460
+ end
461
+
462
+ def build_authorization_header(signature)
463
+ [
464
+ "#{signature[:algorithm]} Credential=#{signature[:credential]}",
465
+ "SignedHeaders=#{signature[:signed_headers]}",
466
+ "Signature=#{signature[:signature]}"
467
+ ].join(', ')
468
+ end
469
+
470
+ private
471
+
472
+ def add_session_token_header(headers, creds)
473
+ return unless creds.session_token && !@omit_session_token
474
+
475
+ headers['x-amz-security-token'] = creds.session_token
476
+ end
477
+
478
+ def add_content_sha256_header(headers, content_sha256)
479
+ headers['x-amz-content-sha256'] = content_sha256 if @apply_checksum_header
480
+ end
481
+
482
+ def add_omitted_session_token(headers, creds)
483
+ return unless creds&.session_token
484
+
485
+ headers['x-amz-security-token'] = creds.session_token
486
+ end
487
+ end
488
+
489
+ # Credential management and fetching
490
+ class CredentialManager
491
+ def initialize(credentials_provider)
492
+ @credentials_provider = credentials_provider
493
+ end
494
+
495
+ def fetch_credentials
496
+ credentials = @credentials_provider.credentials
497
+ if credentials_set?(credentials)
498
+ expiration = nil
499
+ expiration = @credentials_provider.expiration if @credentials_provider.respond_to?(:expiration)
500
+ [credentials, expiration]
501
+ else
502
+ raise Errors::MissingCredentialsError,
503
+ 'unable to sign request without credentials set'
504
+ end
505
+ end
506
+
507
+ def credentials_set?(credentials)
508
+ !credentials.access_key_id.nil? &&
509
+ !credentials.access_key_id.empty? &&
510
+ !credentials.secret_access_key.nil? &&
511
+ !credentials.secret_access_key.empty?
512
+ end
513
+ end
514
+
515
+ # Result builder for signature computation
516
+ class SignatureResultBuilder
517
+ def initialize(signature_computation)
518
+ @signature_computation = signature_computation
519
+ end
520
+
521
+ def build_result(request_data)
522
+ result_hash(request_data)
523
+ end
524
+
525
+ private
526
+
527
+ def result_hash(request_data)
528
+ {
529
+ algorithm: request_data[:algorithm],
530
+ credential: credential_from_request(request_data),
531
+ signed_headers: request_data[:canonical_request].signed_headers,
532
+ signature: request_data[:signature],
533
+ canonical_request: request_data[:creq],
534
+ string_to_sign: request_data[:sts]
535
+ }
536
+ end
537
+
538
+ def credential_from_request(request_data)
539
+ @signature_computation.credential(
540
+ request_data[:credentials],
541
+ request_data[:date]
542
+ )
543
+ end
544
+ end
545
+
546
+ # Core functionality for computing signatures
547
+ class SignatureGenerator
548
+ def initialize(options = {})
549
+ @signing_algorithm = :sigv4 # Always use sigv4
550
+ @uri_escape_path = options[:uri_escape_path] || true
551
+ @unsigned_headers = options[:unsigned_headers] || Set.new
552
+ @service = options[:service]
553
+ @region = options[:region]
554
+
555
+ @signature_computation = SignatureComputation.new(@service, @region, @signing_algorithm)
556
+ @result_builder = SignatureResultBuilder.new(@signature_computation)
557
+ end
558
+
559
+ def sts_algorithm
560
+ 'AWS4-HMAC-SHA256' # Always use HMAC-SHA256
561
+ end
562
+
563
+ def compute_signature(components, creds, sigv4_headers)
564
+ algorithm = sts_algorithm
565
+ headers = components[:headers].merge(sigv4_headers)
566
+
567
+ # Process request and generate signature
568
+ canonical_request = create_canonical_request(components, headers)
569
+ sig = compute_signature_from_request(canonical_request, components, creds, algorithm)
570
+
571
+ # Build and return the final result
572
+ build_signature_result(components, creds, canonical_request, sig, algorithm)
573
+ end
574
+
575
+ private
576
+
577
+ def compute_signature_from_request(canonical_request, components, creds, algorithm)
578
+ creq = canonical_request.to_s
579
+ sts = generate_string_to_sign(components, creq, algorithm)
580
+ generate_signature(creds, components[:date], sts)
581
+ end
582
+
583
+ def generate_string_to_sign(components, creq, algorithm)
584
+ @signature_computation.string_to_sign(
585
+ components[:datetime],
586
+ creq,
587
+ algorithm
588
+ )
589
+ end
590
+
591
+ def build_signature_result(components, creds, canonical_request, sig, algorithm)
592
+ @result_builder.build_result(
593
+ algorithm: algorithm,
594
+ credentials: creds,
595
+ date: components[:date],
596
+ signature: sig,
597
+ creq: canonical_request.to_s,
598
+ sts: generate_string_to_sign(components, canonical_request.to_s, algorithm),
599
+ canonical_request: canonical_request
600
+ )
601
+ end
602
+
603
+ def create_canonical_request(components, headers)
604
+ canon_req_config = create_canonical_request_config
605
+
606
+ CanonicalRequest.new(
607
+ http_method: components[:http_method],
608
+ url: components[:url],
609
+ headers: headers,
610
+ content_sha256: components[:content_sha256],
611
+ config: canon_req_config
612
+ )
613
+ end
614
+
615
+ def create_canonical_request_config
616
+ CanonicalRequestConfig.new(
617
+ uri_escape_path: @uri_escape_path,
618
+ unsigned_headers: @unsigned_headers
619
+ )
620
+ end
621
+
622
+ def generate_signature(creds, date, string_to_sign)
623
+ @signature_computation.signature(creds.secret_access_key, date, string_to_sign)
624
+ end
625
+ end
626
+
627
+ # Utility for extracting options and config
628
+ class SignerOptionExtractor
629
+ def self.extract_service(options)
630
+ if options[:service]
631
+ options[:service]
632
+ else
633
+ msg = 'missing required option :service'
634
+ raise ArgumentError, msg
635
+ end
636
+ end
637
+
638
+ def self.extract_region(options)
639
+ raise Errors::MissingRegionError unless options[:region]
640
+
641
+ options[:region]
642
+ end
643
+
644
+ def self.extract_credentials_provider(options)
645
+ if options[:credentials_provider]
646
+ options[:credentials_provider]
647
+ elsif options.key?(:credentials) || options.key?(:access_key_id)
648
+ StaticCredentialsProvider.new(options)
649
+ else
650
+ raise Errors::MissingCredentialsError
651
+ end
652
+ end
653
+
654
+ def self.initialize_unsigned_headers(options)
655
+ headers = Set.new(options.fetch(:unsigned_headers, []).map(&:downcase))
656
+ headers.merge(%w[authorization x-amzn-trace-id expect])
657
+ end
658
+ end
659
+
660
+ # Handles initialization of Signer dependencies
661
+ class SignerInitializer
662
+ def self.create_components(options = {})
663
+ service, region, credentials_provider = extract_core_components(options)
664
+ unsigned_headers = SignerOptionExtractor.initialize_unsigned_headers(options)
665
+
666
+ config_options = extract_config_options(options)
667
+
668
+ components = {
669
+ service: service,
670
+ region: region,
671
+ credentials_provider: credentials_provider,
672
+ unsigned_headers: unsigned_headers
673
+ }.merge(config_options)
674
+
675
+ create_service_components(components)
676
+ end
677
+
678
+ def self.extract_core_components(options)
679
+ service = SignerOptionExtractor.extract_service(options)
680
+ region = SignerOptionExtractor.extract_region(options)
681
+ credentials_provider = SignerOptionExtractor.extract_credentials_provider(options)
682
+
683
+ [service, region, credentials_provider]
684
+ end
685
+
686
+ def self.extract_config_options(options)
687
+ {
688
+ uri_escape_path: options.fetch(:uri_escape_path, true),
689
+ apply_checksum_header: options.fetch(:apply_checksum_header, true),
690
+ normalize_path: options.fetch(:normalize_path, true),
691
+ omit_session_token: options.fetch(:omit_session_token, false)
692
+ }
693
+ end
694
+
695
+ def self.create_service_components(components)
696
+ signature_generator = create_signature_generator(components)
697
+ header_builder = create_header_builder(components)
698
+ credential_manager = CredentialManager.new(components[:credentials_provider])
699
+
700
+ components.merge(
701
+ signature_generator: signature_generator,
702
+ header_builder: header_builder,
703
+ credential_manager: credential_manager
704
+ )
705
+ end
706
+
707
+ def self.create_signature_generator(components)
708
+ SignatureGenerator.new(
709
+ signing_algorithm: components[:signing_algorithm],
710
+ uri_escape_path: components[:uri_escape_path],
711
+ unsigned_headers: components[:unsigned_headers],
712
+ service: components[:service],
713
+ region: components[:region]
714
+ )
715
+ end
716
+
717
+ def self.create_header_builder(components)
718
+ HeaderBuilder.new(
719
+ signing_algorithm: components[:signing_algorithm],
720
+ apply_checksum_header: components[:apply_checksum_header],
721
+ omit_session_token: components[:omit_session_token],
722
+ region: components[:region]
723
+ )
724
+ end
725
+ end
726
+
727
+ # Handles AWS request signing using SigV4 or SigV4a
728
+ class Signer
729
+ # Initialize a new signer with the provided options
730
+ # @param [Hash] options Configuration options for the signer
731
+ def initialize(options = {})
732
+ components = SignerInitializer.create_components(options)
733
+ setup_configuration(components)
734
+ setup_service_components(components)
735
+ end
736
+
737
+ # @return [String]
738
+ attr_reader :service
739
+
740
+ # @return [String]
741
+ attr_reader :region
742
+
743
+ # @return [#credentials]
744
+ attr_reader :credentials_provider
745
+
746
+ # @return [Set<String>]
747
+ attr_reader :unsigned_headers
748
+
749
+ # @return [Boolean]
750
+ attr_reader :apply_checksum_header
751
+
752
+ # Sign an AWS request with SigV4 or SigV4a
753
+ # @param [Hash] request The request to sign
754
+ # @return [Signature] The signature with headers to apply
755
+ def sign_request(request)
756
+ creds = @credential_manager.fetch_credentials.first
757
+ request_components = extract_request_components(request)
758
+ sigv4_headers = build_sigv4_headers(request_components, creds)
759
+ signature = compute_signature(request_components, creds, sigv4_headers)
760
+ build_signature_response(request_components, sigv4_headers, signature)
761
+ end
762
+
763
+ private
764
+
765
+ def setup_configuration(components)
766
+ @service = components[:service]
767
+ @region = components[:region]
768
+ @credentials_provider = components[:credentials_provider]
769
+ @unsigned_headers = components[:unsigned_headers]
770
+ @uri_escape_path = components[:uri_escape_path]
771
+ @apply_checksum_header = components[:apply_checksum_header]
772
+ @signing_algorithm = components[:signing_algorithm]
773
+ @normalize_path = components[:normalize_path]
774
+ @omit_session_token = components[:omit_session_token]
775
+ end
776
+
777
+ def setup_service_components(components)
778
+ @signature_generator = components[:signature_generator]
779
+ @header_builder = components[:header_builder]
780
+ @credential_manager = components[:credential_manager]
781
+ end
782
+
783
+ def extract_request_components(request)
784
+ RequestExtractor.extract_components(
785
+ request,
786
+ normalize_path: @normalize_path
787
+ )
788
+ end
789
+
790
+ def build_sigv4_headers(request_components, creds)
791
+ @header_builder.build_sigv4_headers(request_components, creds)
792
+ end
793
+
794
+ def compute_signature(request_components, creds, sigv4_headers)
795
+ @signature_generator.compute_signature(
796
+ request_components,
797
+ creds,
798
+ sigv4_headers
799
+ )
800
+ end
801
+
802
+ def build_signature_response(components, sigv4_headers, signature)
803
+ headers = @header_builder.build_headers(sigv4_headers, signature, components)
804
+
805
+ Signature.new(
806
+ headers: headers,
807
+ string_to_sign: signature[:string_to_sign],
808
+ canonical_request: signature[:canonical_request],
809
+ content_sha256: components[:content_sha256],
810
+ signature: signature[:signature]
811
+ )
812
+ end
813
+
814
+ class << self
815
+ def uri_escape_path(path)
816
+ UriUtils.uri_escape_path(path)
817
+ end
818
+
819
+ def uri_escape(string)
820
+ UriUtils.uri_escape(string)
821
+ end
822
+
823
+ def normalize_path(uri)
824
+ UriUtils.normalize_path(uri)
825
+ end
826
+ end
827
+ end
828
+ end
829
+ end
830
+ end
831
+ end