smithy-client 1.0.0.pre0 → 1.0.0.pre1

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 (101) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +2 -0
  3. data/VERSION +1 -1
  4. data/lib/smithy-client/anonymous_provider.rb +12 -0
  5. data/lib/smithy-client/auth_option.rb +23 -0
  6. data/lib/smithy-client/auth_scheme.rb +25 -0
  7. data/lib/smithy-client/auth_schemes/anonymous.rb +18 -0
  8. data/lib/smithy-client/auth_schemes/http_api_key.rb +18 -0
  9. data/lib/smithy-client/auth_schemes/http_basic.rb +18 -0
  10. data/lib/smithy-client/auth_schemes/http_bearer.rb +18 -0
  11. data/lib/smithy-client/auth_schemes/http_digest.rb +18 -0
  12. data/lib/smithy-client/base.rb +200 -0
  13. data/lib/smithy-client/block_io.rb +36 -0
  14. data/lib/smithy-client/configuration.rb +222 -0
  15. data/lib/smithy-client/default_params.rb +91 -0
  16. data/lib/smithy-client/dynamic_errors.rb +82 -0
  17. data/lib/smithy-client/endpoint_rules.rb +186 -0
  18. data/lib/smithy-client/handler.rb +29 -0
  19. data/lib/smithy-client/handler_builder.rb +33 -0
  20. data/lib/smithy-client/handler_context.rb +67 -0
  21. data/lib/smithy-client/handler_list.rb +197 -0
  22. data/lib/smithy-client/handler_list_entry.rb +102 -0
  23. data/lib/smithy-client/http/error_inspector.rb +87 -0
  24. data/lib/smithy-client/http/headers.rb +122 -0
  25. data/lib/smithy-client/http/request.rb +57 -0
  26. data/lib/smithy-client/http/response.rb +178 -0
  27. data/lib/smithy-client/http_api_key_provider.rb +18 -0
  28. data/lib/smithy-client/http_bearer_provider.rb +18 -0
  29. data/lib/smithy-client/http_login_provider.rb +19 -0
  30. data/lib/smithy-client/identities/anonymous.rb +10 -0
  31. data/lib/smithy-client/identities/http_api_key.rb +18 -0
  32. data/lib/smithy-client/identities/http_bearer.rb +18 -0
  33. data/lib/smithy-client/identities/http_login.rb +22 -0
  34. data/lib/smithy-client/identity.rb +15 -0
  35. data/lib/smithy-client/log_formatter.rb +215 -0
  36. data/lib/smithy-client/log_param_filter.rb +88 -0
  37. data/lib/smithy-client/log_param_formatter.rb +65 -0
  38. data/lib/smithy-client/managed_file.rb +14 -0
  39. data/lib/smithy-client/net_http/connection_pool.rb +297 -0
  40. data/lib/smithy-client/net_http/handler.rb +160 -0
  41. data/lib/smithy-client/net_http/patches.rb +28 -0
  42. data/lib/smithy-client/networking_error.rb +16 -0
  43. data/lib/smithy-client/pageable_response.rb +138 -0
  44. data/lib/smithy-client/param_converter.rb +243 -0
  45. data/lib/smithy-client/param_validator.rb +213 -0
  46. data/lib/smithy-client/plugin.rb +144 -0
  47. data/lib/smithy-client/plugin_list.rb +141 -0
  48. data/lib/smithy-client/plugins/anonymous_auth.rb +23 -0
  49. data/lib/smithy-client/plugins/checksum_required.rb +51 -0
  50. data/lib/smithy-client/plugins/content_length.rb +26 -0
  51. data/lib/smithy-client/plugins/default_params.rb +22 -0
  52. data/lib/smithy-client/plugins/host_prefix.rb +69 -0
  53. data/lib/smithy-client/plugins/http_api_key_auth.rb +37 -0
  54. data/lib/smithy-client/plugins/http_basic_auth.rb +47 -0
  55. data/lib/smithy-client/plugins/http_bearer_auth.rb +37 -0
  56. data/lib/smithy-client/plugins/http_digest_auth.rb +60 -0
  57. data/lib/smithy-client/plugins/idempotency_token.rb +34 -0
  58. data/lib/smithy-client/plugins/logging.rb +56 -0
  59. data/lib/smithy-client/plugins/net_http.rb +163 -0
  60. data/lib/smithy-client/plugins/pageable_response.rb +37 -0
  61. data/lib/smithy-client/plugins/param_converter.rb +32 -0
  62. data/lib/smithy-client/plugins/param_validator.rb +30 -0
  63. data/lib/smithy-client/plugins/protocol.rb +66 -0
  64. data/lib/smithy-client/plugins/raise_response_errors.rb +33 -0
  65. data/lib/smithy-client/plugins/request_compression.rb +200 -0
  66. data/lib/smithy-client/plugins/response_target.rb +71 -0
  67. data/lib/smithy-client/plugins/retry_errors.rb +125 -0
  68. data/lib/smithy-client/plugins/sign_requests.rb +24 -0
  69. data/lib/smithy-client/plugins/stub_responses.rb +102 -0
  70. data/lib/smithy-client/protocol_spec_matcher.rb +60 -0
  71. data/lib/smithy-client/refreshing_identity_provider.rb +65 -0
  72. data/lib/smithy-client/request.rb +76 -0
  73. data/lib/smithy-client/response.rb +48 -0
  74. data/lib/smithy-client/retry/adaptive.rb +66 -0
  75. data/lib/smithy-client/retry/client_rate_limiter.rb +142 -0
  76. data/lib/smithy-client/retry/quota.rb +58 -0
  77. data/lib/smithy-client/retry/standard.rb +52 -0
  78. data/lib/smithy-client/retry.rb +36 -0
  79. data/lib/smithy-client/rpc_v2_cbor/protocol.rb +38 -0
  80. data/lib/smithy-client/rpc_v2_cbor/request_builder.rb +76 -0
  81. data/lib/smithy-client/rpc_v2_cbor/response_parser.rb +86 -0
  82. data/lib/smithy-client/rpc_v2_cbor/response_stubber.rb +34 -0
  83. data/lib/smithy-client/service_error.rb +57 -0
  84. data/lib/smithy-client/signer.rb +16 -0
  85. data/lib/smithy-client/signers/anonymous.rb +13 -0
  86. data/lib/smithy-client/signers/http_api_key.rb +52 -0
  87. data/lib/smithy-client/signers/http_basic.rb +23 -0
  88. data/lib/smithy-client/signers/http_bearer.rb +19 -0
  89. data/lib/smithy-client/signers/http_digest.rb +21 -0
  90. data/lib/smithy-client/stubbing/data_applicator.rb +61 -0
  91. data/lib/smithy-client/stubbing/empty_stub.rb +69 -0
  92. data/lib/smithy-client/stubbing/endpoint_provider.rb +22 -0
  93. data/lib/smithy-client/stubbing/protocol.rb +29 -0
  94. data/lib/smithy-client/stubbing/stub_data.rb +25 -0
  95. data/lib/smithy-client/stubbing.rb +14 -0
  96. data/lib/smithy-client/stubs.rb +212 -0
  97. data/lib/smithy-client/util.rb +15 -0
  98. data/lib/smithy-client/waiters/poller.rb +93 -0
  99. data/lib/smithy-client/waiters/waiter.rb +113 -0
  100. data/lib/smithy-client.rb +66 -1
  101. metadata +163 -9
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smithy
4
+ module Client
5
+ module RPCv2CBOR
6
+ # @api private
7
+ class ResponseStubber
8
+ def initialize(options = {})
9
+ @codec = CBOR::Codec.new(options)
10
+ end
11
+
12
+ def stub_data(_service, operation, data)
13
+ response = HTTP::Response.new
14
+ response.status_code = 200
15
+ response.headers['Smithy-Protocol'] = 'rpc-v2-cbor'
16
+ response.headers['Content-Type'] = 'application/cbor'
17
+ response.body = @codec.serialize(operation.output, data)
18
+ response
19
+ end
20
+
21
+ def stub_error(_service, error_code)
22
+ response = HTTP::Response.new
23
+ response.status_code = 400
24
+ response.headers['Smithy-Protocol'] = 'rpc-v2-cbor'
25
+ response.headers['Content-Type'] = 'application/cbor'
26
+ response.headers['X-Amzn-RequestId'] = 'stubbed-request-id'
27
+ data = { '__type' => error_code, 'message' => 'stubbed-error-message' }
28
+ response.body = CBOR.encode(data)
29
+ response
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smithy
4
+ module Client
5
+ # The base class for all errors returned by a Smithy generated client.
6
+ # All ~400 level client errors and ~500 level server errors are raised
7
+ # as service errors. This indicates it was an error returned from the
8
+ # service and not one generated by the client.
9
+ class ServiceError < RuntimeError
10
+ # @param [Smithy::Client::HandlerContext] context
11
+ # @param [String] message
12
+ # @param [Structure] data
13
+ def initialize(context, message, data)
14
+ @code = self.class.code
15
+ @context = context
16
+ @message = extract_message(message, data)
17
+ @data = data
18
+ super(@message)
19
+ end
20
+
21
+ # @return [String, nil] The error code returned by the service.
22
+ attr_reader :code
23
+
24
+ # @return [Smithy::Client::HandlerContext] The context of the request
25
+ # that triggered the remote service to return this error.
26
+ attr_reader :context
27
+
28
+ # @return [String] The error message returned by the service.
29
+ # Defaults to the class name if no message is provided or can be parsed.
30
+ attr_reader :message
31
+
32
+ # @return [Structure] Additional data returned by the service.
33
+ attr_accessor :data
34
+
35
+ class << self
36
+ # @return [String, nil]
37
+ attr_accessor :code
38
+ end
39
+
40
+ # @return [Boolean] Returns `true` if the error is retryable.
41
+ def retryable?
42
+ false
43
+ end
44
+
45
+ # @return [Boolean] Returns `true` if the error is due to throttling.
46
+ def throttling?
47
+ false
48
+ end
49
+
50
+ private
51
+
52
+ def extract_message(message, data)
53
+ message || (data.message if data.respond_to?(:message)) || self.class.name.to_s
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smithy
4
+ module Client
5
+ # Base class for all Signer classes.
6
+ class Signer
7
+ def sign(_options = {})
8
+ raise NotImplementedError
9
+ end
10
+
11
+ def reset(_options = {})
12
+ raise NotImplementedError
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smithy
4
+ module Client
5
+ module Signers
6
+ # A signer that does not sign requests.
7
+ class Anonymous < Signer
8
+ def sign(**); end
9
+ def reset(**); end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smithy
4
+ module Client
5
+ module Signers
6
+ # A signer that signs requests using the HTTP API Key Auth scheme.
7
+ class HttpApiKey < Signer
8
+ def sign(request:, identity:, properties:)
9
+ case properties['in']
10
+ when 'header'
11
+ value = "#{properties['scheme']} #{identity.key}".strip
12
+ request.headers[properties['name']] = value
13
+ when 'query'
14
+ name = properties['name']
15
+ append_query_param(request, name, identity.key)
16
+ end
17
+ end
18
+
19
+ def reset(request:, properties:)
20
+ case properties['in']
21
+ when 'header'
22
+ request.headers.delete(properties['name'])
23
+ when 'query'
24
+ name = properties['name']
25
+ remove_query_param(request, name)
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def append_query_param(request, name, value)
32
+ if request.endpoint.query
33
+ request.endpoint.query += "&#{name}=#{value}"
34
+ else
35
+ request.endpoint.query = "#{name}=#{value}"
36
+ end
37
+ end
38
+
39
+ def remove_query_param(request, name)
40
+ parsed = CGI.parse(request.endpoint.query)
41
+ parsed.delete(name)
42
+ # encode_www_form ignores query params without values
43
+ # (CGI parses these as empty lists)
44
+ parsed.each do |key, values|
45
+ parsed[key] = values.empty? ? nil : values
46
+ end
47
+ request.endpoint.query = URI.encode_www_form(parsed)
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+
5
+ module Smithy
6
+ module Client
7
+ module Signers
8
+ # A signer that signs requests using the HTTP Basic Auth scheme.
9
+ class HttpBasic < Signer
10
+ def sign(request:, identity:, **_options)
11
+ # TODO: does not handle realm or other properties
12
+ identity_string = "#{identity.username}:#{identity.password}"
13
+ encoded = Base64.strict_encode64(identity_string)
14
+ request.headers['Authorization'] = "Basic #{encoded}"
15
+ end
16
+
17
+ def reset(request:, **_options)
18
+ request.headers.delete('Authorization')
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smithy
4
+ module Client
5
+ module Signers
6
+ # A signer that signs requests using the HTTP Bearer Auth scheme.
7
+ class HttpBearer < Signer
8
+ def sign(request:, identity:, **_options)
9
+ # TODO: does not handle realm or other properties
10
+ request.headers['Authorization'] = "Bearer #{identity.token}"
11
+ end
12
+
13
+ def reset(request:, **_options)
14
+ request.headers.delete('Authorization')
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smithy
4
+ module Client
5
+ module Signers
6
+ # A signer that signs requests using the HTTP Digest Auth scheme.
7
+ class HttpDigest < Signer
8
+ def sign(request:, identity:, **_options)
9
+ # TODO: requires a nonce from the server - this cannot
10
+ # be implemented unless we rescue from a 401 and retry
11
+ # with the nonce
12
+ raise NotImplementedError
13
+ end
14
+
15
+ def reset(request:, **_options)
16
+ raise NotImplementedError
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smithy
4
+ module Client
5
+ module Stubbing
6
+ # Applies data to a stubbed object.
7
+ # @api private
8
+ class DataApplicator
9
+ include Smithy::Schema::Shapes
10
+
11
+ def initialize(ref)
12
+ @ref = ref
13
+ end
14
+
15
+ def apply(data, stub)
16
+ structure(@ref, data, stub)
17
+ end
18
+
19
+ private
20
+
21
+ def shape(ref, value, stub = nil)
22
+ case ref.shape
23
+ when StructureShape then structure(ref, value, stub)
24
+ when ListShape then list(ref, value)
25
+ when MapShape then map(ref, value)
26
+ else value
27
+ end
28
+ end
29
+
30
+ def list(ref, value)
31
+ return if value.nil?
32
+
33
+ shape = ref.shape
34
+ value.each_with_object([]) do |v, list|
35
+ list << shape(shape.member, v)
36
+ end
37
+ end
38
+
39
+ def map(ref, value)
40
+ return if value.nil?
41
+
42
+ shape = ref.shape
43
+ value.each_with_object({}) do |(k, v), map|
44
+ map[k.to_s] = shape(shape.value, v)
45
+ end
46
+ end
47
+
48
+ def structure(ref, data, stub)
49
+ return if data.nil?
50
+
51
+ stub = ref.shape.type.new if stub.nil?
52
+ shape = ref.shape
53
+ data.each_pair do |key, value|
54
+ stub[key] = shape(shape.member(key), value)
55
+ end
56
+ stub
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bigdecimal'
4
+
5
+ module Smithy
6
+ module Client
7
+ module Stubbing
8
+ # Creates an empty default stub.
9
+ # @api private
10
+ class EmptyStub
11
+ include Smithy::Schema::Shapes
12
+
13
+ def initialize(ref)
14
+ @ref = ref
15
+ end
16
+
17
+ # @return [Schema::Structure, Schema::EmptyStructure]
18
+ def stub
19
+ structure(@ref, [])
20
+ end
21
+
22
+ private
23
+
24
+ def shape(ref, visited)
25
+ shape = ref.shape
26
+ return nil if visited.include?(shape)
27
+
28
+ visited += [shape]
29
+
30
+ case shape
31
+ when ListShape then []
32
+ when MapShape then {}
33
+ when StructureShape then structure(ref, visited)
34
+ when UnionShape then union(ref, visited)
35
+ else scalar(ref)
36
+ end
37
+ end
38
+
39
+ def structure(ref, visited)
40
+ shape = ref.shape
41
+ shape.members.each_with_object(shape.type.new) do |(member_name, member_ref), struct|
42
+ struct[member_name] = shape(member_ref, visited)
43
+ end
44
+ end
45
+
46
+ def union(ref, visited)
47
+ shape = ref.shape
48
+ member_name, member_ref = shape.members.first
49
+ return unless member_name
50
+
51
+ value = shape(member_ref, visited)
52
+ klass = shape.member_type(member_name)
53
+ klass.new(member_name => value)
54
+ end
55
+
56
+ def scalar(ref)
57
+ case ref.shape
58
+ when BigDecimalShape then BigDecimal(0)
59
+ when BlobShape, EnumShape, StringShape then ref.member_name
60
+ when BooleanShape then false
61
+ when IntegerShape, IntEnumShape then 0
62
+ when FloatShape then 0.0
63
+ when TimestampShape then Time.now
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smithy
4
+ module Client
5
+ module Stubbing
6
+ # Default endpoint provider when stubbing is configured.
7
+ # @api private
8
+ class EndpointProvider
9
+ def resolve(parameters)
10
+ uri =
11
+ if EndpointRules.set?(parameters.endpoint)
12
+ parameters.endpoint
13
+ else
14
+ 'http://stubbed-endpoint'
15
+ end
16
+
17
+ EndpointRules::Endpoint.new(uri: uri)
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smithy
4
+ module Client
5
+ module Stubbing
6
+ # Default protocol when stubbing is configured.
7
+ # @api private
8
+ class Protocol
9
+ def build_request(_context); end
10
+ def parse_data(_output); end
11
+ def parse_error(_output); end
12
+
13
+ def stub_data(_service, _operation, data)
14
+ response = HTTP::Response.new
15
+ response.status_code = 200
16
+ response.body = StringIO.new(data.to_json)
17
+ response
18
+ end
19
+
20
+ def stub_error(_service, error_code)
21
+ response = HTTP::Response.new
22
+ response.status_code = 500
23
+ response.body = StringIO.new(error_code.to_json)
24
+ response
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smithy
4
+ module Client
5
+ module Stubbing
6
+ # Stubs data for an operation.
7
+ # @api private
8
+ class StubData
9
+ def initialize(operation)
10
+ @output = operation.output
11
+ end
12
+
13
+ # @param [Hash] params
14
+ # @return [Structure]
15
+ def stub(params = {})
16
+ stub = EmptyStub.new(@output).stub
17
+ data = ParamConverter.new(@output).convert(params)
18
+ ParamValidator.new(@output, validate_required: false).validate!(data, context: 'stub')
19
+ DataApplicator.new(@output).apply(data, stub)
20
+ stub
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'stubbing/data_applicator'
4
+ require_relative 'stubbing/empty_stub'
5
+ require_relative 'stubbing/endpoint_provider'
6
+ require_relative 'stubbing/protocol'
7
+ require_relative 'stubbing/stub_data'
8
+
9
+ module Smithy
10
+ module Client
11
+ # @api private
12
+ module Stubbing; end
13
+ end
14
+ end
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smithy
4
+ module Client
5
+ # This module provides the ability to specify the data and/or errors to
6
+ # return when a client is using stubbed responses.
7
+ module Stubs
8
+ # Configures what data / errors should be returned from the named operation
9
+ # when response stubbing is enabled.
10
+ #
11
+ # ## Basic usage
12
+ #
13
+ # When you enable response stubbing, the client will generate fake
14
+ # responses and will not make any HTTP requests:
15
+ #
16
+ # client = Weather::Client.new(stub_responses: true)
17
+ # client.get_current_time
18
+ # #=> #<struct Weather::GetCurrentTimeOutput time=2025-01-20 12:00:00 -0500>
19
+ #
20
+ # You can specify the stub data using {#stub_responses}:
21
+ #
22
+ # time = Time.now #=> 2025-01-20 12:00:00 -0500
23
+ # client.stub_responses(:get_current_time, { time: time })
24
+ # client.get_current_time.time #=> 2025-01-20 12:00:00 -0500
25
+ #
26
+ # ## Dynamic Stubbing
27
+ #
28
+ # In addition to creating static stubs, it's also possible to generate
29
+ # stubs dynamically based on the parameters with which operations were
30
+ # called, by passing a `Proc` object:
31
+ #
32
+ # time = Time.now #=> 2025-01-20 12:00:00 -0500
33
+ # client.stub_responses(:get_current_time, ->(context) do
34
+ # if context.params[:time] == time
35
+ # { time: time + 60 }
36
+ # else
37
+ # { time: time }
38
+ # end
39
+ # end
40
+ # )
41
+ #
42
+ # The yielded object is an instance of {Smithy::Client::HandlerContext}.
43
+ #
44
+ # ## Stubbing Errors
45
+ #
46
+ # When stubbing is enabled, the SDK will default to generate
47
+ # fake responses with placeholder values. You can override the data
48
+ # returned. You can also specify errors it should raise:
49
+ #
50
+ # # To simulate service errors, give the error code:
51
+ # client.stub_responses(:get_city, 'NoSuchResource')
52
+ # client.get_city(city_id: 'Winchester')
53
+ # #=> raises Weather::Errors::NoSuchResource
54
+ #
55
+ # # To simulate other errors, give the error class.
56
+ # # You must be able to construct an instance with `.new`:
57
+ # client.stub_responses(:get_city, Timeout::Error)
58
+ # client.get_city(city_id: 'Winchester')
59
+ # #=> raises new Timeout::Error
60
+ #
61
+ # # Or you can give an instance of an error class:
62
+ # error = RuntimeError.new('oops')
63
+ # client.stub_responses(:get_city, error)
64
+ # client.get_city(city_id: 'Winchester')
65
+ # #=> raises error
66
+ #
67
+ # ## Stubbing HTTP Responses
68
+ #
69
+ # As an alternative to providing the response data, you can provide
70
+ # an HTTP response:
71
+ #
72
+ # client.stub_responses(:get_current_time, {
73
+ # status_code: 200,
74
+ # headers: { 'header-name' => 'header-value' },
75
+ # body: "payload",
76
+ # })
77
+ #
78
+ # To stub an HTTP response, pass a Hash with all three of the following
79
+ # keys set:
80
+ #
81
+ # * **`:status_code`** - `Integer` - The HTTP status code
82
+ # * **`:headers`** - `Hash<String, String>` - A hash of HTTP header keys and values
83
+ # * **`:body`** - `<String, IO>` - The HTTP response body.
84
+ #
85
+ # ## Stubbing Multiple Responses
86
+ #
87
+ # Calling an operation multiple times will return similar responses.
88
+ # You can configure multiple stubs, and they will be returned in sequence:
89
+ #
90
+ # client.stub_responses(:get_city, [
91
+ # 'NoSuchResource',
92
+ # { name: 'Winchester', coordinates: { latitude: 39.1825, longitude: -78.1676 } }
93
+ # ])
94
+ #
95
+ # client.get_city(city_id: "Winchester')
96
+ # #=> raises Weather::Errors::NoSuchResource
97
+ #
98
+ # response = client.get_city(city_id: "Winchester')
99
+ # response.name #=> 'Winchester'
100
+ # response.coordinates.latitude #=> 39.1825
101
+ # response.coordinates.longitude #=> -78.1676
102
+ #
103
+ # @param [Symbol] operation_name
104
+ # @param [Mixed] stubs One or more responses to return from the named operation.
105
+ # @raise [RuntimeError] Raises a runtime error when called on a client
106
+ # that has not enabled response stubbing with `stub_responses: true`.
107
+ def stub_responses(operation_name, *stubs)
108
+ unless @config.stub_responses
109
+ raise 'stubbing is not enabled; enable stubbing in the constructor ' \
110
+ 'with `stub_responses: true`'
111
+ end
112
+ apply_stubs(operation_name, stubs.flatten)
113
+ end
114
+
115
+ # Generates and returns stubbed response data from the named operation.
116
+ #
117
+ # client = Weather::Client.new
118
+ # client.stub_data(:get_current_time)
119
+ # #=> #<struct Weather::Types::GetCurrentTimeOutput time=2025-01-20 12:00:00 -0500>
120
+ #
121
+ # In addition to generating default stubs, you can provide data to apply to the response stub.
122
+ #
123
+ # time = Time.now #=> 2025-01-20 12:00:00 -0500
124
+ # client.stub_data(:get_current_time, { time: time + 60 })
125
+ # #=> #<struct Weather::Types::GetCurrentTimeOutput time=2025-01-20 12:01:00 -0500>
126
+ #
127
+ # @param [Symbol] operation_name
128
+ # @param [Hash] data
129
+ # @return [Structure] Returns a stubbed response data structure.
130
+ def stub_data(operation_name, data = {})
131
+ Stubbing::StubData.new(@config.service.operation(operation_name)).stub(data)
132
+ end
133
+
134
+ # Allows you to access all the requests that the stubbed client has made.
135
+ # @return [Array] Returns an array of the api requests made. Each request
136
+ # object contains keys: :operation_name, :params and :context.
137
+ # @raise [RuntimeError] Raises a runtime error when called on a client
138
+ # that has not enabled response stubbing with `stub_responses: true`.
139
+ def api_requests
140
+ unless @config.stub_responses
141
+ raise 'stubbing is not enabled; enable stubbing in the constructor ' \
142
+ 'with `stub_responses: true`'
143
+ end
144
+ @config.api_requests_mutex.synchronize { @config.api_requests }
145
+ end
146
+
147
+ # @api private
148
+ def next_stub(context)
149
+ operation_name = context.operation_name
150
+ stub = @config.stubs_mutex.synchronize do
151
+ stubs = @config.stubs[operation_name] || []
152
+ case stubs.length
153
+ when 0 then stub_data(operation_name)
154
+ when 1 then stubs.first
155
+ else stubs.shift
156
+ end
157
+ end
158
+ stub = convert_stub(operation_name, stub, context)
159
+ stub[:mutex] = Mutex.new
160
+ stub
161
+ end
162
+
163
+ private
164
+
165
+ def apply_stubs(operation_name, stubs)
166
+ @config.stubs_mutex.synchronize do
167
+ @config.stubs[operation_name] = stubs
168
+ end
169
+ end
170
+
171
+ # This method converts the given stub data and converts it to a
172
+ # HTTP response (when possible). This enables the response stubbing
173
+ # plugin to provide a HTTP response that triggers all normal events
174
+ # during response handling.
175
+ def convert_stub(operation_name, stub, context)
176
+ case stub
177
+ when Proc then convert_stub(operation_name, stub.call(context), context)
178
+ when Exception, Class then { error: stub }
179
+ when String then service_error_stub(stub)
180
+ else http_response_stub(operation_name, stub)
181
+ end
182
+ end
183
+
184
+ def service_error_stub(error_code)
185
+ { http: @config.protocol.stub_error(@config.service, error_code) }
186
+ end
187
+
188
+ def http_response_stub(operation_name, data)
189
+ if data.is_a?(Hash) && data.keys.sort == %i[body headers status_code]
190
+ { http: hash_to_http_response(data) }
191
+ else
192
+ { http: data_to_http_response(operation_name, data) }
193
+ end
194
+ end
195
+
196
+ def hash_to_http_response(data)
197
+ http_response = HTTP::Response.new
198
+ http_response.status_code = data[:status_code]
199
+ http_response.headers.update(data[:headers])
200
+ http_response.body = data[:body]
201
+ http_response
202
+ end
203
+
204
+ def data_to_http_response(operation_name, data)
205
+ operation = @config.service.operation(operation_name)
206
+ data = ParamConverter.new(operation.output).convert(data)
207
+ ParamValidator.new(operation.output, validate_required: false).validate!(data, context: 'stub')
208
+ @config.protocol.stub_data(@config.service, operation, data)
209
+ end
210
+ end
211
+ end
212
+ end