diff --git a/gems/smithy-client/lib/smithy-client.rb b/gems/smithy-client/lib/smithy-client.rb index 254e67b76..2a4f8bb04 100644 --- a/gems/smithy-client/lib/smithy-client.rb +++ b/gems/smithy-client/lib/smithy-client.rb @@ -32,6 +32,8 @@ 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' require_relative 'smithy-client/util' diff --git a/gems/smithy-client/lib/smithy-client/no_op_protocol.rb b/gems/smithy-client/lib/smithy-client/no_op_protocol.rb new file mode 100644 index 000000000..9fc15fc59 --- /dev/null +++ b/gems/smithy-client/lib/smithy-client/no_op_protocol.rb @@ -0,0 +1,19 @@ +# 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. + # @api private + class NoOpProtocol < Protocol + def build_request(_context); end + + def parse_data(_response); end + + def parse_error(_response); 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 new file mode 100644 index 000000000..1eb371380 --- /dev/null +++ b/gems/smithy-client/lib/smithy-client/plugins/protocol.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require_relative '../protocol' +require_relative '../no_op_protocol' + +module Smithy + module Client + module Plugins + # Generic protocol plugin. Resolves the +:protocol+ option and installs + # build/parse handlers that delegate serialization and deserialization to + # the resolved protocol instance. + # @api private + class Protocol < Plugin + option( + :protocol, + doc_type: 'Symbol, Object', + docstring: 'The protocol used for request serialization and response ' \ + 'deserialization. Defaults to the service default protocol. ' \ + 'May be a Symbol naming a supported protocol, or a custom ' \ + 'object implementing the protocol interface.' + ) + + # @api private + class BuildHandler < Handler + def call(context) + context.config.protocol.build_request(context) + @handler.call(context) + end + end + + # @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 + end + end + + def add_handlers(handlers, _config) + handlers.add(BuildHandler) + handlers.add(ParseHandler) + end + + def before_initialize(client_class, options) + case options[:protocol] + when nil + options[:protocol] = client_class.protocols.values.first&.new || NoOpProtocol.new + when Symbol + options[:protocol] = resolve!(client_class, options[:protocol]) + # else: custom protocol instance, used as-is + end + end + + private + + # TODO: client class to have prioritized list + def resolve!(client_class, name) + protocol_class = client_class.protocols[name] + raise ArgumentError, "Unknown protocol: #{name}" unless protocol_class + + protocol_class.new + end + end + end + end +end diff --git a/gems/smithy-client/lib/smithy-client/protocol.rb b/gems/smithy-client/lib/smithy-client/protocol.rb new file mode 100644 index 000000000..5bbedb7a2 --- /dev/null +++ b/gems/smithy-client/lib/smithy-client/protocol.rb @@ -0,0 +1,31 @@ +# 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/spec/smithy-client/plugins/protocol_spec.rb b/gems/smithy-client/spec/smithy-client/plugins/protocol_spec.rb new file mode 100644 index 000000000..34615a6bf --- /dev/null +++ b/gems/smithy-client/spec/smithy-client/plugins/protocol_spec.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +require_relative '../../spec_helper' + +require 'smithy-client/plugins/protocol' + +module Smithy + module Client + module Plugins + describe Protocol do + let(:sample_client) { ClientHelper.sample_client } + let(:client_class) do + client_class = sample_client.const_get(:Client) + client_class.clear_plugins + client_class.add_plugin(sample_client::Plugins::Endpoint) + client_class.add_plugin(Protocol) + client_class + end + let(:client_options) { { endpoint: 'https://example.com' } } + + let(:fake_protocol_class) do + Class.new(Client::Protocol) do + def build_request(_context); end + def parse_data(_response); end + def parse_error(_response); end + end + end + + # TODO: remove this test-only registry once the `protocols` weld is impl + before do + protocols = { rpc_v2_cbor: fake_protocol_class } + client_class.define_singleton_method(:protocols) { protocols } + end + + it 'adds a :protocol option to config' do + client = client_class.new(client_options) + expect(client.config).to respond_to(:protocol) + end + + context 'when :protocol is not provided (default)' do + it 'defaults to an instance of the first registered protocol' do + client = client_class.new(client_options) + expect(client.config.protocol).to be_a(fake_protocol_class) + end + + it 'falls back to NoOpProtocol when no protocol is registered' do + client_class.define_singleton_method(:protocols) { {} } + client = client_class.new(client_options) + expect(client.config.protocol).to be_a(Client::NoOpProtocol) + end + end + + context 'when :protocol is a Symbol' do + it 'resolves to an instance of the named protocol' do + client = client_class.new(client_options.merge(protocol: :rpc_v2_cbor)) + expect(client.config.protocol).to be_a(fake_protocol_class) + end + + it 'raises an ArgumentError for an unknown protocol' do + expect { client_class.new(client_options.merge(protocol: :nope)) } + .to raise_error(ArgumentError, /Unknown protocol: nope/) + end + end + + context 'when :protocol is a custom object' do + it 'uses the provided object as-is' do + custom = fake_protocol_class.new + client = client_class.new(client_options.merge(protocol: custom)) + expect(client.config.protocol).to be(custom) + end + end + + it 'adds the build and parse handlers' do + client = client_class.new(client_options) + expect(client.handlers).to include(Protocol::BuildHandler) + expect(client.handlers).to include(Protocol::ParseHandler) + 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 + end + + it 'returns nil from #parse_error without raising' do + expect(protocol.parse_error(double('response'))).to be_nil + end + end + end + end +end