From 4a69d1ac7e8ce6c3ab10cdfcd70f84552e4334b9 Mon Sep 17 00:00:00 2001 From: Juli Tera Date: Fri, 24 Jul 2026 11:21:07 -0700 Subject: [PATCH 1/3] Add a protocols registry to generated clients --- gems/smithy/lib/smithy/templates/client/client.erb | 7 +++++++ gems/smithy/lib/smithy/views/client/client.rb | 13 +++++++++++++ gems/smithy/lib/smithy/weld.rb | 8 ++++++++ 3 files changed, 28 insertions(+) diff --git a/gems/smithy/lib/smithy/templates/client/client.erb b/gems/smithy/lib/smithy/templates/client/client.erb index 27fb67497..b1c7bb53c 100644 --- a/gems/smithy/lib/smithy/templates/client/client.erb +++ b/gems/smithy/lib/smithy/templates/client/client.erb @@ -143,6 +143,13 @@ module <%= module_name %> def errors_module Errors end + + # @api private + def protocols +<% protocols.each do |line| -%> + <%= line %> +<% end -%> + end end end end diff --git a/gems/smithy/lib/smithy/views/client/client.rb b/gems/smithy/lib/smithy/views/client/client.rb index b401f1aef..838fcdc6b 100644 --- a/gems/smithy/lib/smithy/views/client/client.rb +++ b/gems/smithy/lib/smithy/views/client/client.rb @@ -39,6 +39,19 @@ def add_plugins @plugins.map(&:class_name) end + def protocols + weld_protocols = @plan.welds.map(&:add_protocols).reduce({}, :merge) + return ['{}'] if weld_protocols.empty? + + lines = ['{'] + weld_protocols.each do |name, protocol_class| + lines << " #{name}: #{protocol_class}," + end + lines.last.chomp!(',') if lines.last.end_with?(',') + lines << '}' + lines + end + def docstrings options = @plugins.map(&:options).flatten.sort_by(&:name) documentation = {} diff --git a/gems/smithy/lib/smithy/weld.rb b/gems/smithy/lib/smithy/weld.rb index 3f2779104..10ded5121 100644 --- a/gems/smithy/lib/smithy/weld.rb +++ b/gems/smithy/lib/smithy/weld.rb @@ -95,6 +95,14 @@ def remove_plugins [] end + # Called when constructing the client. Any protocols defined here will be + # merged into the client's protocol registry. The key is the protocol name + # (a Symbol) and the value is the fully qualified protocol class. + # @return [Hash] a mapping of protocol names to protocol classes. + def add_protocols + {} + end + # Called when creating the auth resolver and auth schemes. The value is the # absolute shape id of the auth scheme trait. def add_auth_schemes From b9292c82edba0cda753cf24705bdb52c7b7dde59 Mon Sep 17 00:00:00 2001 From: Juli Tera Date: Fri, 24 Jul 2026 11:23:59 -0700 Subject: [PATCH 2/3] Fold the protocol interface into NoOpProtocol Drop the base class in favor of a duck-typed interface documented on the standalone NoOpProtocol. Parse handlers now get context, so a protocol can reach request headers and operation errors. --- gems/smithy-client/lib/smithy-client.rb | 1 - .../lib/smithy-client/no_op_protocol.rb | 45 +++++++++++++---- .../lib/smithy-client/plugins/protocol.rb | 8 ++-- .../lib/smithy-client/protocol.rb | 31 ------------ .../sig/smithy-client/interfaces.rbs | 8 ++++ .../smithy-client/plugins/protocol_spec.rb | 48 ++++++++----------- 6 files changed, 69 insertions(+), 72 deletions(-) delete mode 100644 gems/smithy-client/lib/smithy-client/protocol.rb diff --git a/gems/smithy-client/lib/smithy-client.rb b/gems/smithy-client/lib/smithy-client.rb index 2a4f8bb04..b18f918ec 100644 --- a/gems/smithy-client/lib/smithy-client.rb +++ b/gems/smithy-client/lib/smithy-client.rb @@ -32,7 +32,6 @@ require_relative 'smithy-client/param_validator' require_relative 'smithy-client/plugin' require_relative 'smithy-client/plugin_list' -require_relative 'smithy-client/protocol' require_relative 'smithy-client/no_op_protocol' require_relative 'smithy-client/retry' require_relative 'smithy-client/service_error' diff --git a/gems/smithy-client/lib/smithy-client/no_op_protocol.rb b/gems/smithy-client/lib/smithy-client/no_op_protocol.rb index 9fc15fc59..8c127f945 100644 --- a/gems/smithy-client/lib/smithy-client/no_op_protocol.rb +++ b/gems/smithy-client/lib/smithy-client/no_op_protocol.rb @@ -1,19 +1,48 @@ # frozen_string_literal: true -require_relative 'protocol' - module Smithy module Client - # Default protocol used when no protocol is registered for a client. All - # methods are no-ops, so build/parse handlers can delegate safely without - # a nil check. + # Default protocol used when no protocol is registered for a client. + # Also documents the protocol interface: a protocol serializes requests, + # deserializes responses, and builds stubbed responses for a specific + # wire format. A custom protocol passed via +Client.new(protocol:)+ is + # any object that responds to these methods. + # + # All methods are no-ops, so handlers and stubbing can delegate safely + # without a nil check. # @api private - class NoOpProtocol < Protocol + class NoOpProtocol + # Serialize the request into the wire format. + # @param [HandlerContext] _context def build_request(_context); end - def parse_data(_response); end + # Deserialize a successful response body. + # @param [HandlerContext] _context + # @return [Object, nil] the response data + def parse_data(_context); end + + # Deserialize an error response into the modeled error. Called on + # every response; must return nil when the response is not an error. + # @param [HandlerContext] _context + # @return [StandardError, nil] + def parse_error(_context); end + + # Build a stubbed HTTP response for the given output data. + # @param [Configuration] _config + # @param [Schema::OperationShape] _operation + # @param [Object] _data + # @return [Http::Response] + def stub_data(_config, _operation, _data) + Http::Response.new + end - def parse_error(_response); end + # Build a stubbed HTTP error response for the given error code. + # @param [Configuration] _config + # @param [String] _error_code + # @return [Http::Response] + def stub_error(_config, _error_code) + Http::Response.new + end end end end diff --git a/gems/smithy-client/lib/smithy-client/plugins/protocol.rb b/gems/smithy-client/lib/smithy-client/plugins/protocol.rb index 1eb371380..ff353b3a0 100644 --- a/gems/smithy-client/lib/smithy-client/plugins/protocol.rb +++ b/gems/smithy-client/lib/smithy-client/plugins/protocol.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true -require_relative '../protocol' require_relative '../no_op_protocol' module Smithy @@ -28,12 +27,15 @@ def call(context) end end + # Parses the response after the send handler returns. Send handlers + # must fully signal the response body (signal_done) before returning, + # since parsing happens inline here rather than in a body callback. # @api private class ParseHandler < Handler def call(context) response = @handler.call(context) - response.error = context.config.protocol.parse_error(response) unless response.error - response.data = context.config.protocol.parse_data(response) unless response.error + response.error = context.config.protocol.parse_error(context) unless response.error + response.data = context.config.protocol.parse_data(context) unless response.error response end end diff --git a/gems/smithy-client/lib/smithy-client/protocol.rb b/gems/smithy-client/lib/smithy-client/protocol.rb deleted file mode 100644 index 5bbedb7a2..000000000 --- a/gems/smithy-client/lib/smithy-client/protocol.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true - -module Smithy - module Client - # Base class documenting the protocol interface. A protocol serializes - # requests and deserializes responses for a specific wire format. - # - # A custom protocol passed via +Client.new(protocol:)+ is any object that - # responds to these methods; it need not inherit from this class. - # @api private - class Protocol - # Serialize the request into the wire format. - # @param [Interceptor::Context] _context - def build_request(_context) - raise NotImplementedError - end - - # Deserialize a successful response body. - # @param [Response] _response - def parse_data(_response) - raise NotImplementedError - end - - # Deserialize an error response into the modeled error. - # @param [Response] _response - def parse_error(_response) - raise NotImplementedError - end - end - end -end diff --git a/gems/smithy-client/sig/smithy-client/interfaces.rbs b/gems/smithy-client/sig/smithy-client/interfaces.rbs index 9c76ca51a..a92444b14 100644 --- a/gems/smithy-client/sig/smithy-client/interfaces.rbs +++ b/gems/smithy-client/sig/smithy-client/interfaces.rbs @@ -10,5 +10,13 @@ module Smithy interface _ReadableIO def read: (int length, ?string outbuf) -> String end + + interface _Protocol + def build_request: (HandlerContext context) -> void + def parse_data: (HandlerContext context) -> untyped + def parse_error: (HandlerContext context) -> StandardError? + def stub_data: (untyped config, untyped operation, untyped data) -> Http::Response + def stub_error: (untyped config, String error_code) -> Http::Response + end end end \ No newline at end of file diff --git a/gems/smithy-client/spec/smithy-client/plugins/protocol_spec.rb b/gems/smithy-client/spec/smithy-client/plugins/protocol_spec.rb index 34615a6bf..1056e87fb 100644 --- a/gems/smithy-client/spec/smithy-client/plugins/protocol_spec.rb +++ b/gems/smithy-client/spec/smithy-client/plugins/protocol_spec.rb @@ -19,14 +19,17 @@ module Plugins let(:client_options) { { endpoint: 'https://example.com' } } let(:fake_protocol_class) do - Class.new(Client::Protocol) do + Class.new do def build_request(_context); end - def parse_data(_response); end - def parse_error(_response); end + def parse_data(_context); end + def parse_error(_context); end + def stub_data(_config, _operation, _data); end + def stub_error(_config, _error_code); end end end - # TODO: remove this test-only registry once the `protocols` weld is impl + # Override the generated registry with a controlled double so the + # plugin's resolution logic is tested in isolation from any real protocol. before do protocols = { rpc_v2_cbor: fake_protocol_class } client_class.define_singleton_method(:protocols) { protocols } @@ -77,42 +80,29 @@ def parse_error(_response); end end end - describe Client::Protocol do - subject(:protocol) { described_class.new } - - it 'raises NotImplementedError for #build_request' do - expect { protocol.build_request(double('context')) } - .to raise_error(NotImplementedError) - end - - it 'raises NotImplementedError for #parse_data' do - expect { protocol.parse_data(double('response')) } - .to raise_error(NotImplementedError) - end - - it 'raises NotImplementedError for #parse_error' do - expect { protocol.parse_error(double('response')) } - .to raise_error(NotImplementedError) - end - end - describe Client::NoOpProtocol do subject(:protocol) { described_class.new } - it 'is a Protocol' do - expect(protocol).to be_a(Client::Protocol) - end - it 'returns nil from #build_request without raising' do expect(protocol.build_request(double('context'))).to be_nil end it 'returns nil from #parse_data without raising' do - expect(protocol.parse_data(double('response'))).to be_nil + expect(protocol.parse_data(double('context'))).to be_nil end it 'returns nil from #parse_error without raising' do - expect(protocol.parse_error(double('response'))).to be_nil + expect(protocol.parse_error(double('context'))).to be_nil + end + + it 'returns an empty response from #stub_data' do + response = protocol.stub_data(double('config'), double('operation'), {}) + expect(response).to be_a(Http::Response) + end + + it 'returns an empty response from #stub_error' do + response = protocol.stub_error(double('config'), 'ErrorCode') + expect(response).to be_a(Http::Response) end end end From bddc3d25f793645c547841de9b7513ccbd9f5906 Mon Sep 17 00:00:00 2001 From: Juli Tera Date: Fri, 24 Jul 2026 11:24:55 -0700 Subject: [PATCH 3/3] Make CBOR the first consumer of the generic Protocol plugin One RpcV2Cbor object replaces the per-service plugin, its two handlers, and the separate stubber. Stubbing runs through config.protocol (the object doubles as its own stubber). AWS protocols follow in a separate PR, so their stubbing stays red on version-4 until then. --- gems/smithy-client/lib/smithy-client.rb | 1 + .../lib/smithy-client/plugins/rpc_v2_cbor.rb | 21 -- .../smithy-client/plugins/stub_responses.rb | 6 +- .../lib/smithy-client/rpc_v2_cbor.rb | 185 ++++++++++++++++++ .../rpc_v2_cbor/error_handler.rb | 79 -------- .../lib/smithy-client/rpc_v2_cbor/handler.rb | 77 -------- .../lib/smithy-client/stubbing.rb | 1 - .../smithy-client/stubbing/null_protocol.rb | 18 -- .../lib/smithy-client/stubbing/rpc_v2_cbor.rb | 29 --- gems/smithy-client/lib/smithy-client/stubs.rb | 4 +- .../plugins/transfer_encoding_spec.rb | 2 +- .../spec/smithy-client/rpc_v2_cbor_spec.rb | 163 +++++++++++++++ gems/smithy/lib/smithy/welds/protocols.rb | 12 +- .../interfaces/client/stub_responses_spec.rb | 7 +- 14 files changed, 373 insertions(+), 232 deletions(-) delete mode 100644 gems/smithy-client/lib/smithy-client/plugins/rpc_v2_cbor.rb create mode 100644 gems/smithy-client/lib/smithy-client/rpc_v2_cbor.rb delete mode 100644 gems/smithy-client/lib/smithy-client/rpc_v2_cbor/error_handler.rb delete mode 100644 gems/smithy-client/lib/smithy-client/rpc_v2_cbor/handler.rb delete mode 100644 gems/smithy-client/lib/smithy-client/stubbing/null_protocol.rb delete mode 100644 gems/smithy-client/lib/smithy-client/stubbing/rpc_v2_cbor.rb create mode 100644 gems/smithy-client/spec/smithy-client/rpc_v2_cbor_spec.rb diff --git a/gems/smithy-client/lib/smithy-client.rb b/gems/smithy-client/lib/smithy-client.rb index b18f918ec..ae8dc683b 100644 --- a/gems/smithy-client/lib/smithy-client.rb +++ b/gems/smithy-client/lib/smithy-client.rb @@ -33,6 +33,7 @@ require_relative 'smithy-client/plugin' require_relative 'smithy-client/plugin_list' require_relative 'smithy-client/no_op_protocol' +require_relative 'smithy-client/rpc_v2_cbor' require_relative 'smithy-client/retry' require_relative 'smithy-client/service_error' require_relative 'smithy-client/util' diff --git a/gems/smithy-client/lib/smithy-client/plugins/rpc_v2_cbor.rb b/gems/smithy-client/lib/smithy-client/plugins/rpc_v2_cbor.rb deleted file mode 100644 index 05d7b2562..000000000 --- a/gems/smithy-client/lib/smithy-client/plugins/rpc_v2_cbor.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -require_relative '../rpc_v2_cbor/error_handler' -require_relative '../rpc_v2_cbor/handler' -require_relative '../stubbing/rpc_v2_cbor' - -module Smithy - module Client - module Plugins - # @api private - class RpcV2Cbor < Plugin - option(:protocol, default: 'smithy.protocols#rpcv2Cbor') - option(:cbor_codec) { Smithy::Cbor::Codec.new } - option(:stubber) { Stubbing::RpcV2Cbor.new } - - handler(Client::RpcV2Cbor::Handler) - handler(Client::RpcV2Cbor::ErrorHandler, step: :sign) - end - end - end -end diff --git a/gems/smithy-client/lib/smithy-client/plugins/stub_responses.rb b/gems/smithy-client/lib/smithy-client/plugins/stub_responses.rb index 9bd98b4eb..af46159d7 100644 --- a/gems/smithy-client/lib/smithy-client/plugins/stub_responses.rb +++ b/gems/smithy-client/lib/smithy-client/plugins/stub_responses.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require_relative '../no_op_protocol' + module Smithy module Client module Plugins @@ -19,7 +21,9 @@ class StubResponses < Plugin option(:stubs_mutex) { Mutex.new } option(:api_requests) { [] } option(:api_requests_mutex) { Mutex.new } - option(:stubber) { Stubbing::NullProtocol.new } + # Fallback so stubbing works on clients without the Protocol plugin. + # The Protocol plugin's before_initialize overrides this when present. + option(:protocol) { NoOpProtocol.new } def add_handlers(handlers, config) return unless config.stub_responses diff --git a/gems/smithy-client/lib/smithy-client/rpc_v2_cbor.rb b/gems/smithy-client/lib/smithy-client/rpc_v2_cbor.rb new file mode 100644 index 000000000..16b0b0ba9 --- /dev/null +++ b/gems/smithy-client/lib/smithy-client/rpc_v2_cbor.rb @@ -0,0 +1,185 @@ +# frozen_string_literal: true + +module Smithy + module Client + # Protocol implementation for Smithy RPC v2 CBOR. Serializes requests and + # deserializes responses (and errors) for the +smithy.protocols#rpcv2Cbor+ + # wire format, and builds stubbed responses for testing. + # @api private + class RpcV2Cbor + def initialize + @codec = Smithy::Cbor::Codec.new + end + + # Serialize the request into the RPC v2 CBOR wire format. + # @param [HandlerContext] context + def build_request(context) + context.http_request.http_method = 'POST' + apply_headers(context) + apply_body(context) + apply_url_path(context) + end + + # Deserialize a successful response body. + # @param [HandlerContext] context + # @return [Object] the parsed output data + def parse_data(context) + @codec.parse(context.operation.output, context.http_response.body.read) + end + + # Deserialize an error response into the modeled error. Called on every + # response; returns nil when the response is not an error. + # @param [HandlerContext] context + # @return [StandardError, nil] + def parse_error(context) + # Only inspect responses in the HTTP status range. Outside it (e.g. a + # status 0 from a signaled transport error), leave the response alone + # so the transport error propagates untouched. Mirrors the old + # ErrorHandler's on_done(200..599) gate. + return unless (200..599).cover?(context.http_response.status_code) + + # Malformed responses should raise an http-based error, so we validate + # the protocol header across the full 200..599 range. + unless valid_response?(context) + code, data = http_status_error(context) + return build_error(context, code, data) + end + return unless (400..599).cover?(context.http_response.status_code) + + error(context) + end + + # Build a stubbed HTTP response for the given output data. + # @param [Configuration] _config + # @param [Schema::OperationShape] operation + # @param [Object] data + # @return [Http::Response] + def stub_data(_config, operation, data) + response = Http::Response.new + response.status_code = 200 + response.headers['Smithy-Protocol'] = 'rpc-v2-cbor' + response.headers['Content-Type'] = 'application/cbor' + response.body = @codec.build(operation.output, data) + response + end + + # Build a stubbed HTTP error response for the given error code. + # @param [Configuration] _config + # @param [String] error_code + # @return [Http::Response] + def stub_error(_config, error_code) + response = Http::Response.new + response.status_code = 400 + response.headers['Smithy-Protocol'] = 'rpc-v2-cbor' + response.headers['Content-Type'] = 'application/cbor' + data = { '__type' => "smithy.ruby.tests##{error_code}", 'message' => 'stubbed-error-message' } + response.body = Cbor.encode(data) + response + end + + private + + def apply_headers(context) + context.http_request.headers['Smithy-Protocol'] = 'rpc-v2-cbor' + apply_content_type_header(context) + apply_accept_header(context) + end + + def apply_content_type_header(context) + input = context.operation.input + content_type = + if event_stream?(input) + 'application/vnd.amazon.eventstream' + elsif input != Schema::Shapes::Prelude::Unit + 'application/cbor' + end + + context.http_request.headers['Content-Type'] ||= content_type if content_type + end + + def apply_accept_header(context) + accept = + if event_stream?(context.operation.output) + 'application/vnd.amazon.eventstream' + else + 'application/cbor' + end + + context.http_request.headers['Accept'] ||= accept + end + + def apply_body(context) + context.http_request.body = @codec.build(context.operation.input, context.params) + end + + def apply_url_path(context) + base = context.http_request.endpoint + service_name = context.config.service.name + base.path += "/service/#{service_name}/operation/#{context.operation.name}" + end + + def event_stream?(input_shape) + input_shape.members.each_value do |member_shape| + shape = member_shape.target + return true if shape.traits.key?('smithy.api#streaming') && shape.is_a?(Schema::Shapes::UnionShape) + end + false + end + + def valid_response?(context) + req_header = context.http_request.headers['smithy-protocol'] + resp_header = context.http_response.headers['smithy-protocol'] + req_header == resp_header + end + + def error(context) + body = context.http_response.body.read + code, data = + if body.empty? + http_status_error(context) + else + extract_error(body, context) + end + build_error(context, code, data) + end + + def extract_error(body, context) + data = Cbor.decode(body) + code = error_code(context, data) + data = parse_error_data(context, body, code) + [code, data] + rescue Cbor::ParseError + [http_status_error_code(context), Schema::EmptyStructure.new] + end + + def parse_error_data(context, body, code) + data = Schema::EmptyStructure.new + context.operation.errors.each do |err_shape| + next unless err_shape.name == code + + data = Cbor::Parser.new.parse(err_shape, body, err_shape.type.new) + end + data + end + + def error_code(context, data) + code = data['__type'] + code ||= http_status_error_code(context) + code.split('#').last.split('$').first + end + + def build_error(context, code, data) + errors_module = context.client.class.errors_module + errors_module.error_class(code).new(context, data) + end + + def http_status_error(context) + [http_status_error_code(context), Schema::EmptyStructure.new] + end + + def http_status_error_code(context) + "HTTP#{context.http_response.status_code}Error" + end + end + end +end diff --git a/gems/smithy-client/lib/smithy-client/rpc_v2_cbor/error_handler.rb b/gems/smithy-client/lib/smithy-client/rpc_v2_cbor/error_handler.rb deleted file mode 100644 index af1ceda03..000000000 --- a/gems/smithy-client/lib/smithy-client/rpc_v2_cbor/error_handler.rb +++ /dev/null @@ -1,79 +0,0 @@ -# frozen_string_literal: true - -module Smithy - module Client - module RpcV2Cbor - # @api private - class ErrorHandler < Client::Handler - def call(context) - # Malformed responses should throw an http based error, so we check - # 200 range for error handling only for this case. - @handler.call(context).on_done(200..599) do |response| - if !valid_response?(context) - code, data = http_status_error(context) - response.error = build_error(context, code, data) - elsif (400..599).cover?(context.http_response.status_code) - response.error = error(context) - end - end - end - - private - - def valid_response?(context) - req_header = context.http_request.headers['smithy-protocol'] - resp_header = context.http_response.headers['smithy-protocol'] - req_header == resp_header - end - - def error(context) - body = context.http_response.body.read - if body.empty? - code, data = http_status_error(context) - else - code, data = extract_error(body, context) - end - build_error(context, code, data) - end - - def extract_error(body, context) - data = Cbor.decode(body) - code = error_code(context, data) - data = parse_error_data(context, body, code) - [code, data] - rescue Cbor::ParseError - [http_status_error_code(context), Schema::EmptyStructure.new] - end - - def parse_error_data(context, body, code) - data = Schema::EmptyStructure.new - context.operation.errors.each do |err_shape| - next unless err_shape.name == code - - data = Cbor::Parser.new.parse(err_shape, body, err_shape.type.new) - end - data - end - - def error_code(context, data) - code = data['__type'] - code ||= http_status_error_code(context) - code.split('#').last.split('$').first - end - - def build_error(context, code, data) - errors_module = context.client.class.errors_module - errors_module.error_class(code).new(context, data) - end - - def http_status_error(context) - [http_status_error_code(context), Schema::EmptyStructure.new] - end - - def http_status_error_code(context) - "HTTP#{context.http_response.status_code}Error" - end - end - end - end -end diff --git a/gems/smithy-client/lib/smithy-client/rpc_v2_cbor/handler.rb b/gems/smithy-client/lib/smithy-client/rpc_v2_cbor/handler.rb deleted file mode 100644 index 8fc724807..000000000 --- a/gems/smithy-client/lib/smithy-client/rpc_v2_cbor/handler.rb +++ /dev/null @@ -1,77 +0,0 @@ -# frozen_string_literal: true - -module Smithy - module Client - module RpcV2Cbor - # @api private - class Handler < Client::Handler - def call(context) - build_request(context) - response = @handler.call(context) - response.on_done(200..299) { |resp| resp.data = parse_body(context) } - response - end - - private - - def build_request(context) - context.http_request.http_method = 'POST' - apply_headers(context) - apply_body(context) - apply_url_path(context) - end - - def parse_body(context) - context.config.cbor_codec.parse(context.operation.output, context.http_response.body.read) - end - - def apply_headers(context) - context.http_request.headers['Smithy-Protocol'] = 'rpc-v2-cbor' - apply_content_type_header(context) - apply_accept_header(context) - end - - def apply_content_type_header(context) - input = context.operation.input - content_type = - if event_stream?(input) - 'application/vnd.amazon.eventstream' - elsif input != Schema::Shapes::Prelude::Unit - 'application/cbor' - end - - context.http_request.headers['Content-Type'] ||= content_type if content_type - end - - def apply_accept_header(context) - accept = - if event_stream?(context.operation.output) - 'application/vnd.amazon.eventstream' - else - 'application/cbor' - end - - context.http_request.headers['Accept'] ||= accept - end - - def apply_body(context) - context.http_request.body = context.config.cbor_codec.build(context.operation.input, context.params) - end - - def apply_url_path(context) - base = context.http_request.endpoint - service_name = context.config.service.name - base.path += "/service/#{service_name}/operation/#{context.operation.name}" - end - - def event_stream?(input_shape) - input_shape.members.each_value do |member_shape| - shape = member_shape.target - return true if shape.traits.key?('smithy.api#streaming') && shape.is_a?(Schema::Shapes::UnionShape) - end - false - end - end - end - end -end diff --git a/gems/smithy-client/lib/smithy-client/stubbing.rb b/gems/smithy-client/lib/smithy-client/stubbing.rb index 84a3f47b2..c61e001e2 100644 --- a/gems/smithy-client/lib/smithy-client/stubbing.rb +++ b/gems/smithy-client/lib/smithy-client/stubbing.rb @@ -2,7 +2,6 @@ require_relative 'stubbing/data_applicator' require_relative 'stubbing/empty_stub' -require_relative 'stubbing/null_protocol' require_relative 'stubbing/stub_data' module Smithy diff --git a/gems/smithy-client/lib/smithy-client/stubbing/null_protocol.rb b/gems/smithy-client/lib/smithy-client/stubbing/null_protocol.rb deleted file mode 100644 index 34107e78b..000000000 --- a/gems/smithy-client/lib/smithy-client/stubbing/null_protocol.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -module Smithy - module Client - module Stubbing - # @api private - class NullProtocol - def stub_data(_config, _operation, _data) - Http::Response.new - end - - def stub_error(_config, _error_code) - Http::Response.new - end - end - end - end -end diff --git a/gems/smithy-client/lib/smithy-client/stubbing/rpc_v2_cbor.rb b/gems/smithy-client/lib/smithy-client/stubbing/rpc_v2_cbor.rb deleted file mode 100644 index 5a3b6737d..000000000 --- a/gems/smithy-client/lib/smithy-client/stubbing/rpc_v2_cbor.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -module Smithy - module Client - module Stubbing - # @api private - class RpcV2Cbor - def stub_data(config, operation, data) - response = Http::Response.new - response.status_code = 200 - response.headers['Smithy-Protocol'] = 'rpc-v2-cbor' - response.headers['Content-Type'] = 'application/cbor' - response.body = config.cbor_codec.build(operation.output, data) - response - end - - def stub_error(_config, error_code) - response = Http::Response.new - response.status_code = 400 - response.headers['Smithy-Protocol'] = 'rpc-v2-cbor' - response.headers['Content-Type'] = 'application/cbor' - data = { '__type' => "smithy.ruby.tests##{error_code}", 'message' => 'stubbed-error-message' } - response.body = Cbor.encode(data) - response - end - end - end - end -end diff --git a/gems/smithy-client/lib/smithy-client/stubs.rb b/gems/smithy-client/lib/smithy-client/stubs.rb index d398aec46..da186cf9d 100644 --- a/gems/smithy-client/lib/smithy-client/stubs.rb +++ b/gems/smithy-client/lib/smithy-client/stubs.rb @@ -182,7 +182,7 @@ def convert_stub(operation_name, stub, context) end def service_error_stub(error_code) - { http: @config.stubber.stub_error(@config, error_code) } + { http: @config.protocol.stub_error(@config, error_code) } end def http_response_stub(operation_name, data) @@ -205,7 +205,7 @@ def data_to_http_response(operation_name, data) operation = @config.service.operation(operation_name) data = ParamConverter.new(operation.output).convert(data) ParamValidator.new(operation.output, validate_required: false).validate!(data, context: 'stub') - @config.stubber.stub_data(@config, operation, data) + @config.protocol.stub_data(@config, operation, data) end end end diff --git a/gems/smithy-client/spec/smithy-client/plugins/transfer_encoding_spec.rb b/gems/smithy-client/spec/smithy-client/plugins/transfer_encoding_spec.rb index 15570a688..bb8316012 100644 --- a/gems/smithy-client/spec/smithy-client/plugins/transfer_encoding_spec.rb +++ b/gems/smithy-client/spec/smithy-client/plugins/transfer_encoding_spec.rb @@ -13,7 +13,7 @@ module Plugins # Replace the RPC protocol plugin (which serializes all params into the body) # with a passthrough that pipes the streaming member directly to the # HTTP body, mimicking REST protocol behavior. - klass.remove_plugin(Plugins::RpcV2Cbor) + klass.remove_plugin(Plugins::Protocol) klass.add_plugin(streaming_body_plugin) klass end diff --git a/gems/smithy-client/spec/smithy-client/rpc_v2_cbor_spec.rb b/gems/smithy-client/spec/smithy-client/rpc_v2_cbor_spec.rb new file mode 100644 index 000000000..37426e1ec --- /dev/null +++ b/gems/smithy-client/spec/smithy-client/rpc_v2_cbor_spec.rb @@ -0,0 +1,163 @@ +# frozen_string_literal: true + +require_relative '../spec_helper' + +module Smithy + module Client + describe RpcV2Cbor do + subject(:protocol) { described_class.new } + + let(:sample_client) { ClientHelper.sample_client } + let(:client) { sample_client.const_get(:Client).new(endpoint: 'https://example.com', stub_responses: true) } + let(:operation) { client.config.service.operation(:operation) } + let(:config) { client.config } + + def build_context(http_request: Http::Request.new, http_response: Http::Response.new, params: {}) + http_request.endpoint = 'https://example.com' + HandlerContext.new( + operation_name: :operation, + operation: operation, + client: client, + params: params, + config: config, + http_request: http_request, + http_response: http_response + ) + end + + describe '#build_request' do + it 'sets the POST method' do + context = build_context + protocol.build_request(context) + expect(context.http_request.http_method).to eq('POST') + end + + it 'sets the Smithy-Protocol header' do + context = build_context + protocol.build_request(context) + expect(context.http_request.headers['Smithy-Protocol']).to eq('rpc-v2-cbor') + end + + it 'sets the Content-Type and Accept headers' do + context = build_context + protocol.build_request(context) + expect(context.http_request.headers['Content-Type']).to eq('application/cbor') + expect(context.http_request.headers['Accept']).to eq('application/cbor') + end + + it 'appends the rpc v2 service/operation url path' do + context = build_context + protocol.build_request(context) + service_name = config.service.name + expect(context.http_request.endpoint.path) + .to end_with("/service/#{service_name}/operation/#{operation.name}") + end + + it 'serializes the params into the body' do + context = build_context(params: { string: 'hello' }) + protocol.build_request(context) + expect(context.http_request.body.read).not_to be_empty + end + end + + describe '#parse_data' do + it 'parses the response body via the codec' do + data = { string: 'hello' } + body = Smithy::Cbor::Codec.new.build(operation.output, data) + context = build_context(http_response: Http::Response.new(status_code: 200, body: body)) + result = protocol.parse_data(context) + expect(result[:string]).to eq('hello') + end + end + + describe '#parse_error' do + let(:request_headers) { { 'smithy-protocol' => 'rpc-v2-cbor' } } + + def response(status_code:, body: '', protocol_header: 'rpc-v2-cbor') + headers = protocol_header ? { 'smithy-protocol' => protocol_header } : {} + Http::Response.new(status_code: status_code, headers: headers, body: body) + end + + it 'returns nil for a successful response' do + context = build_context( + http_request: Http::Request.new(headers: request_headers), + http_response: response(status_code: 200) + ) + expect(protocol.parse_error(context)).to be_nil + end + + it 'returns nil for a response outside the HTTP status range (e.g. a signaled transport error)' do + context = build_context( + http_request: Http::Request.new(headers: request_headers), + http_response: response(status_code: 0, protocol_header: nil) + ) + expect(protocol.parse_error(context)).to be_nil + end + + it 'returns an error when the protocol header does not match (even on 2xx)' do + context = build_context( + http_request: Http::Request.new(headers: request_headers), + http_response: response(status_code: 200, protocol_header: 'not-cbor') + ) + expect(protocol.parse_error(context)).to be_a(StandardError) + end + + it 'returns an error when the response is missing the protocol header' do + context = build_context( + http_request: Http::Request.new(headers: request_headers), + http_response: response(status_code: 200, protocol_header: nil) + ) + expect(protocol.parse_error(context)).to be_a(StandardError) + end + + it 'returns an HTTP status error for an error response with an empty body' do + context = build_context( + http_request: Http::Request.new(headers: request_headers), + http_response: response(status_code: 500) + ) + error = protocol.parse_error(context) + expect(error).to be_a(StandardError) + end + + it 'extracts the modeled error from the __type in the body' do + body = Cbor.encode('__type' => 'smithy.ruby.tests#Error', 'message' => 'boom') + context = build_context( + http_request: Http::Request.new(headers: request_headers), + http_response: response(status_code: 400, body: body) + ) + error = protocol.parse_error(context) + expect(error).to be_a(StandardError) + end + + it 'falls back to an HTTP status error when the body is not valid CBOR' do + context = build_context( + http_request: Http::Request.new(headers: request_headers), + http_response: response(status_code: 400, body: 'not-cbor') + ) + error = protocol.parse_error(context) + expect(error).to be_a(StandardError) + end + end + + describe '#stub_data' do + it 'builds a 200 CBOR response with protocol headers' do + response = protocol.stub_data(config, operation, { string: 'hello' }) + expect(response.status_code).to eq(200) + expect(response.headers['Smithy-Protocol']).to eq('rpc-v2-cbor') + expect(response.headers['Content-Type']).to eq('application/cbor') + expect(response.body.read).not_to be_empty + end + end + + describe '#stub_error' do + it 'builds a 400 CBOR error response with protocol headers' do + response = protocol.stub_error(config, 'Error') + expect(response.status_code).to eq(400) + expect(response.headers['Smithy-Protocol']).to eq('rpc-v2-cbor') + decoded = Cbor.decode(response.body.read) + expect(decoded['__type']).to eq('smithy.ruby.tests#Error') + end + end + end + end +end diff --git a/gems/smithy/lib/smithy/welds/protocols.rb b/gems/smithy/lib/smithy/welds/protocols.rb index c0cd68dc9..9e41ed640 100644 --- a/gems/smithy/lib/smithy/welds/protocols.rb +++ b/gems/smithy/lib/smithy/welds/protocols.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true -require 'smithy-client/plugins/rpc_v2_cbor' +require 'smithy-client/plugins/protocol' +require 'smithy-client/rpc_v2_cbor' module Smithy module Welds @@ -23,7 +24,14 @@ def for?(service) def add_plugins case @protocol when 'smithy.protocols#rpcv2Cbor' - { Smithy::Client::Plugins::RpcV2Cbor => { require_path: 'smithy-client/plugins/rpc_v2_cbor' } } + { Smithy::Client::Plugins::Protocol => { require_path: 'smithy-client/plugins/protocol' } } + end + end + + def add_protocols + case @protocol + when 'smithy.protocols#rpcv2Cbor' + { rpc_v2_cbor: Smithy::Client::RpcV2Cbor } end end diff --git a/gems/smithy/spec/interfaces/client/stub_responses_spec.rb b/gems/smithy/spec/interfaces/client/stub_responses_spec.rb index f893bbad8..74543433a 100644 --- a/gems/smithy/spec/interfaces/client/stub_responses_spec.rb +++ b/gems/smithy/spec/interfaces/client/stub_responses_spec.rb @@ -35,7 +35,12 @@ } end - before(:all) { Shapes::Client.add_plugin(Smithy::Client::Plugins::RpcV2Cbor) } + before(:all) do + Shapes::Client.add_plugin(Smithy::Client::Plugins::Protocol) + Shapes::Client.define_singleton_method(:protocols) do + { rpc_v2_cbor: Smithy::Client::RpcV2Cbor } + end + end before do allow(Time).to receive(:now).and_return(now)