diff --git a/README.md b/README.md index 72b21c7a4..081fa0f6b 100644 --- a/README.md +++ b/README.md @@ -10,19 +10,9 @@ For previous pre-release, Java based Smithy-Ruby, see: [smithy-ruby/main](https: [apache-badge]: https://img.shields.io/badge/License-Apache%202.0-blue.svg - ## Helpful Commands -Run `smithy` gem tests: -``` -bundle exec rake smithy:spec -``` - -Run `smithy-client` gem tests: -``` -bundle exec rake smithy-client:spec -``` - +### Smithy Build local build using smithy cli ``` bundle exec smithy build --debug model/weather.smithy @@ -34,25 +24,62 @@ export SMITHY_PLUGIN_DIR=build/smithy/source/smithy-ruby bundle exec smithy-ruby smith client --gem-name weather --gem-version 1.0.0 --destination-root projections/weather <<< $(smithy ast model/weather.smithy) ``` +### IRB IRB on `weather` gem: ``` -irb -I projections/weather/lib -I gems/smithy-client/lib -r weather +irb -I projections/weather/lib -I gems/smithy-client/lib -I gems/smithy-model/lib -r weather ``` Create a Weather client: ``` -client = Weather::Client.new(endpoint: 'https://example.com') +protocol = Smithy::Client::Protocols::RPCv2.new +client = Weather::Client.new(endpoint: 'https://example.com', protocol: protocol) +client.get_city(city_id: '1') client.get_current_time ``` +### Fixtures + Build a fixture ``` export SMITHY_PLUGIN_DIR=build/smithy/source/smithy-ruby bundle exec smithy-ruby smith client --gem-name fixture --gem-version 1.0.0 <<< $(cat gems/smithy/spec/fixtures/endpoints/default-values/model.json) ``` -Running RBS validations and tests: +Sync and validate fixtures on smithy: +``` +bundle exec rake smithy:sync-fixtures +bundle exec rake smithy:validate-fixtures +``` + +### Running tests on gems + +To run tests on smithy gem: +``` +bundle exec rake smithy:spec +``` + +To run tests on smithy-model gem: +``` +bundle exec rake smithy-model:spec +``` + +To run tests on smithy-client gem: +``` +bundle exec rake smithy-client:spec +``` + +To run RBS validation/tests on smithy gem: ``` -bundle exec rake smithy-client:rbs bundle exec rake smithy:rbs ``` + +To run RBS validation/tests on smithy-model gem: +``` +bundle exec rake smithy-model:rbs +``` + +To run RBS validation/tests on smithy-client gem: +``` +bundle exec rake smithy-client:rbs +``` diff --git a/Rakefile b/Rakefile index 562d51c3b..fd7019a76 100644 --- a/Rakefile +++ b/Rakefile @@ -63,9 +63,12 @@ namespace :smithy do Dir.glob('gems/smithy/spec/fixtures/**/model.smithy') do |model_path| out_path = model_path.sub('.smithy', '.json') config_files = smithy_build_files.map do |file| - " --config #{file}" if model_path.include?(File.dirname(file)) + if model_path.include?(File.dirname(file)) + FileUtils.touch(file) # https://github.com/smithy-lang/smithy/issues/2537 + " --config #{file}" + end end - sh("smithy ast#{config_files.join(' ')} #{model_path} > #{out_path}") + sh("smithy ast#{config_files.join} #{model_path} > #{out_path}") end end @@ -77,9 +80,12 @@ namespace :smithy do Dir.glob('gems/smithy/spec/fixtures/**/model.smithy') do |model_path| old = JSON.load_file(model_path.sub('.smithy', '.json')) config_files = smithy_build_files.map do |file| - " --config #{file}" if model_path.include?(File.dirname(file)) + if model_path.include?(File.dirname(file)) + FileUtils.touch(file) # https://github.com/smithy-lang/smithy/issues/2537 + " --config #{file}" + end end - new = JSON.parse(`smithy ast#{config_files.join(' ')} #{model_path}`) + new = JSON.parse(`smithy ast#{config_files.join} #{model_path}`) failures << model_path if old != new end if failures.any? diff --git a/gems/smithy-client/lib/smithy-client.rb b/gems/smithy-client/lib/smithy-client.rb index 07394acd2..77dbc9c53 100644 --- a/gems/smithy-client/lib/smithy-client.rb +++ b/gems/smithy-client/lib/smithy-client.rb @@ -33,10 +33,11 @@ require_relative 'smithy-client/net_http/connection_pool' require_relative 'smithy-client/net_http/handler' -# codecs +# serde require_relative 'smithy-client/cbor' -require_relative 'smithy-client/codecs/cbor' +require_relative 'smithy-client/codecs' +require_relative 'smithy-client/protocols' module Smithy # Base module for a generated Smithy gem. diff --git a/gems/smithy-client/lib/smithy-client/codecs/cbor.rb b/gems/smithy-client/lib/smithy-client/codecs/cbor.rb index b38dc2a58..ea4e8b281 100644 --- a/gems/smithy-client/lib/smithy-client/codecs/cbor.rb +++ b/gems/smithy-client/lib/smithy-client/codecs/cbor.rb @@ -30,15 +30,19 @@ def serialize(data, shape) # @param [Struct] type # @return [Object, Hash] def deserialize(bytes, shape, type = nil) - return {} if bytes.empty? + return {} if bytes.empty? || shape == Prelude::Unit parse_data(Client::CBOR.decode(bytes), shape, type) end private + def sparse?(shape) + shape.traits.include?('smithy.api#sparse') + end + def format_blob(value) - (value.is_a?(::String) ? value : value.read).force_encoding(Encoding::BINARY) + (value.is_a?(String) ? value : value.read).force_encoding(Encoding::BINARY) end def format_data(value, shape) @@ -52,12 +56,27 @@ def format_data(value, shape) end def format_list(values, shape) - values.collect { |value| format_data(value, shape.member.shape) } + values.collect do |value| + next if value.nil? && !sparse?(shape) + + if value.nil? && sparse?(shape) + nil + else + format_data(value, shape.member.shape) + end + end end def format_map(values, shape) values.each.with_object({}) do |(key, value), data| - data[key] = format_data(value, shape.value.shape) + next if value.nil? && !sparse?(shape) + + data[key] = + if value.nil? && sparse?(shape) + nil + else + format_data(value, shape.value.shape) + end end end @@ -65,7 +84,7 @@ def format_structure(values, shape) values.each_pair.with_object({}) do |(key, value), data| if shape.member?(key) && !value.nil? member = shape.member(key) - data[key] = format_data(value, member.shape) + data[member.name] = format_data(value, member.shape) end end end @@ -81,30 +100,45 @@ def parse_data(value, shape, type = nil) end end - def parse_list(values, shape, target = nil) - target = [] if target.nil? + def parse_list(values, shape, type = nil) + type = [] if type.nil? values.each do |value| - target << parse_data(value, shape.member.shape) + next if value.nil? && !sparse?(shape) + + type << + if value.nil? + nil + else + parse_data(value, shape.member.shape) + end end - target + type end - def parse_map(values, shape, target = nil) - target = {} if target.nil? + def parse_map(values, shape, type = nil) + type = {} if type.nil? values.each do |key, value| - target[key] = parse_data(value, shape.value.shape) unless value.nil? + next if value.nil? && !sparse?(shape) + + type[key] = + if value.nil? + nil + else + parse_data(value, shape.value.shape) + end end - target + type end - def parse_structure(values, shape, target = nil) - target = shape.type.new if target.nil? + def parse_structure(values, shape, type = nil) + type = shape.type.new if type.nil? values.each do |key, value| - if (member = shape.member(key.to_sym)) - target[key] = parse_data(value, member.shape) + if (member = shape.member(key)) + member_name = shape.members_by_name[member.name] + type[member_name] = parse_data(value, member.shape) end end - target + type end end end diff --git a/gems/smithy-client/lib/smithy-client/param_converter.rb b/gems/smithy-client/lib/smithy-client/param_converter.rb index 414a5fee7..c73a68f16 100644 --- a/gems/smithy-client/lib/smithy-client/param_converter.rb +++ b/gems/smithy-client/lib/smithy-client/param_converter.rb @@ -40,6 +40,7 @@ def structure(shape, values) if values.is_a?(::Struct) || values.is_a?(Hash) values.each_pair do |k, v| next if v.nil? + next unless shape.member?(k) values[k] = member(shape.member(k), v) diff --git a/gems/smithy-client/lib/smithy-client/protocols.rb b/gems/smithy-client/lib/smithy-client/protocols.rb new file mode 100644 index 000000000..ccd19b464 --- /dev/null +++ b/gems/smithy-client/lib/smithy-client/protocols.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require_relative 'protocols/rpc_v2' + +module Smithy + module Client + # In Smithy, a protocol is a named set of rules that defines the + # syntax and semantics of how a client and server communicate. To + # use a {Smithy::Client}, a protocol is required to be set on + # {Configuration}. + # + # We currently support the following protocols: + # + # - {RPCv2}, a RPC-based protocol over HTTP that sends requests + # and responses with CBOR payloads. + # + # You could also create a custom protocol to pass into the client + # configuration. The given protocol must provide the following + # functionalities: + # + # - `build` - builds the request + # - `parse` - parse the response + # - `error` - extracts the error from response + # + # See {RPCv2} as an example implementation. + module Protocols; end + end +end diff --git a/gems/smithy-client/lib/smithy-client/protocols/rpc_v2.rb b/gems/smithy-client/lib/smithy-client/protocols/rpc_v2.rb new file mode 100644 index 000000000..12ce72174 --- /dev/null +++ b/gems/smithy-client/lib/smithy-client/protocols/rpc_v2.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +module Smithy + module Client + module Protocols + # A RPC-based protocol over HTTP that sends requests + # and responses with CBOR payloads. + # + # TODO: Refactor methods to handle eventstreams + class RPCv2 + # @api private + SHAPE_ID = 'smithy.protocols#rpcv2Cbor' + + # @param options [Hash] Protocol options + # @option options [Boolean] :query_compatible (nil) + def initialize(options = {}) + @query_compatible = options[:query_compatible] + end + + # @api private + def build(context) + codec = Client::Codecs::CBOR.new(setting(context)) + context.request.body = codec.serialize(context.params, context.operation.input) + context.request.http_method = 'POST' + apply_headers(context) + build_url(context) + end + + # @api private + def parse(context) + output_shape = context.operation.output + codec = Client::Codecs::CBOR.new(setting(context)) + codec.deserialize(context.response.body.read, output_shape) + end + + # @api private + # TODO: To implement after error handling + def error(_context, _response); end + + private + + def apply_headers(context) + context.request.headers['X-Amzn-Query-Mode'] = 'true' if query_compatible?(context) + context.request.headers['Smithy-Protocol'] = 'rpc-v2-cbor' + apple_content_type(context) + apply_accept_header(context) + # TODO: Implement Content-Length Plugin/Handler + context.request.headers['Content-Length'] = context.request.body.size + end + + def apply_accept_header(context) + # TODO: Needs an update when streaming is handled + context.request.headers['Accept'] = 'application/cbor' + end + + def apple_content_type(context) + return if context.operation.input == Model::Shapes::Prelude::Unit + + # TODO: Needs an update when streaming is handled + context.request.headers['Content-Type'] = 'application/cbor' + end + + def build_url(context) + base = context.request.endpoint + base.path += + "/service/#{context.config.service.name}/operation/#{context.operation.name}" + end + + def setting(context) + {}.tap do |h| + h[:query_compatible] = true if query_compatible?(context) + end + end + + def query_compatible?(context) + @query_compatible || + context.config.service.traits.one? { |k, _v| k == 'aws.protocols#awsQuery' } + end + end + end + end +end diff --git a/gems/smithy-client/sig/interfaces.rbs b/gems/smithy-client/sig/interfaces.rbs index a339cf369..a2870ff31 100644 --- a/gems/smithy-client/sig/interfaces.rbs +++ b/gems/smithy-client/sig/interfaces.rbs @@ -10,5 +10,11 @@ module Smithy end type endpoint_url = String | URI::HTTP | URI::HTTPS + + interface _Protocol + def build: (HandlerContext context) -> void + def parse: (HandlerContext context) -> void + def error: (HandlerContext context, HTTP::Response response) -> void + end end end \ No newline at end of file diff --git a/gems/smithy-client/sig/smithy-client/protocols/rpc_v2.rbs b/gems/smithy-client/sig/smithy-client/protocols/rpc_v2.rbs new file mode 100644 index 000000000..c914411c1 --- /dev/null +++ b/gems/smithy-client/sig/smithy-client/protocols/rpc_v2.rbs @@ -0,0 +1,11 @@ +module Smithy + module Client + module Protocols + # A RPC-based protocol over HTTP that sends requests + # and responses with CBOR payloads. + class RPCv2 + def initialize: (?::Hash[untyped, untyped] options) -> void + end + end + end +end \ No newline at end of file diff --git a/gems/smithy-client/spec/smithy-client/codecs/cbor_spec.rb b/gems/smithy-client/spec/smithy-client/codecs/cbor_spec.rb index b48cbc4bb..e40de106d 100644 --- a/gems/smithy-client/spec/smithy-client/codecs/cbor_spec.rb +++ b/gems/smithy-client/spec/smithy-client/codecs/cbor_spec.rb @@ -27,9 +27,9 @@ module Codecs let(:structure_shape) do struct = Model::Shapes::StructureShape.new(id: 'structure') - struct.add_member(:s, string_shape) - struct.add_member(:l, list_shape) - struct.add_member(:m, map_shape) + struct.add_member(:s, 's', string_shape) + struct.add_member(:l, 'l', list_shape) + struct.add_member(:m, 'm', map_shape) struct.type = typed_struct struct end diff --git a/gems/smithy-client/spec/smithy-client/handler_builder_spec.rb b/gems/smithy-client/spec/smithy-client/handler_builder_spec.rb index dcf22b7b0..f44fdf8be 100644 --- a/gems/smithy-client/spec/smithy-client/handler_builder_spec.rb +++ b/gems/smithy-client/spec/smithy-client/handler_builder_spec.rb @@ -58,8 +58,8 @@ def initialize context << :send context end - resp = subject.handlers.to_stack.call([]) - expect(resp).to eq(%i[validate build sign send]) + output = subject.handlers.to_stack.call([]) + expect(output).to eq(%i[validate build sign send]) end it 'returns the handler class' do diff --git a/gems/smithy-model/lib/smithy-model/shapes.rb b/gems/smithy-model/lib/smithy-model/shapes.rb index de6cbe7fd..65cf77781 100644 --- a/gems/smithy-model/lib/smithy-model/shapes.rb +++ b/gems/smithy-model/lib/smithy-model/shapes.rb @@ -11,7 +11,7 @@ def initialize(options = {}) @traits = options[:traits] || {} end - # @return [String, nil] + # @return [String, nil] Absolute shape ID from model attr_accessor :id # @return [Hash] @@ -24,11 +24,15 @@ class ServiceShape < Shape def initialize(options = {}) super + @name = options[:name] @version = options[:version] @operations = {} yield self if block_given? end + # @return [String, nil] Service name + attr_accessor :name + # @return [String, nil] attr_accessor :version @@ -63,12 +67,16 @@ def operation_names class OperationShape < Shape def initialize(options = {}) super + @name = options[:name] @input = options[:input] @output = options[:output] @errors = options[:errors] || [] yield self if block_given? end + # @return [String, nil] Operation name + attr_accessor :name + # @return [StructureShape, nil] attr_accessor :input @@ -96,24 +104,31 @@ class EnumShape < Shape def initialize(options = {}) super @members = {} + @members_by_name = {} end # @return [Hash] attr_accessor :members - # @return [MemberShape] - def add_member(name, shape, traits: {}) - @members[name] = MemberShape.new(shape, traits: traits) + # @return [Hash] + attr_accessor :members_by_name + + def add_member(name, member_name, shape, traits: {}) + @members_by_name[member_name] = name + @members[name] = MemberShape.new(member_name, shape, traits: traits) end + # @param [Symbol, String] name # @return [Boolean] def member?(name) - @members.key?(name) + @members.key?(name) || @members_by_name.key?(name) end + # @param [Symbol, String] name # @return [MemberShape, nil] def member(name) - @members[name] + key = @members_by_name[name] || name + @members[key] end end @@ -125,24 +140,31 @@ class IntEnumShape < Shape def initialize(options = {}) super @members = {} + @members_by_name = {} end # @return [Hash] attr_accessor :members - # @return [MemberShape] - def add_member(name, shape, traits: {}) - @members[name] = MemberShape.new(shape, traits: traits) + # @return [Hash] + attr_accessor :members_by_name + + def add_member(name, member_name, shape, traits: {}) + @members_by_name[member_name] = name + @members[name] = MemberShape.new(member_name, shape, traits: traits) end + # @param [Symbol, String] name # @return [Boolean] def member?(name) - @members.key?(name) + @members.key?(name) || @members_by_name.key?(name) end + # @param [Symbol, String] name # @return [MemberShape, nil] def member(name) - @members[name] + key = @members_by_name[name] || name + @members[key] end end @@ -159,9 +181,8 @@ def initialize(options = {}) # @return [MemberShape, nil] attr_accessor :member - # @return [MemberShape] def set_member(shape, traits: {}) - @member = MemberShape.new(shape, traits: traits) + @member = MemberShape.new('member', shape, traits: traits) end end @@ -179,14 +200,12 @@ def initialize(options = {}) # @return [MemberShape, nil] attr_accessor :value - # @return [MemberShape] def set_key(shape, traits: {}) - @key = MemberShape.new(shape, traits: traits) + @key = MemberShape.new('key', shape, traits: traits) end - # @return [MemberShape] def set_value(shape, traits: {}) - @value = MemberShape.new(shape, traits: traits) + @value = MemberShape.new('value', shape, traits: traits) end end @@ -198,28 +217,35 @@ class StructureShape < Shape def initialize(options = {}) super @members = {} + @members_by_name = {} @type = nil end # @return [Hash] attr_accessor :members + # @return [Hash] + attr_accessor :members_by_name + # @return [Class] attr_accessor :type - # @return [MemberShape] - def add_member(name, shape, traits: {}) - @members[name] = MemberShape.new(shape, traits: traits) + def add_member(name, member_name, shape, traits: {}) + @members_by_name[member_name] = name + @members[name] = MemberShape.new(member_name, shape, traits: traits) end + # @param [Symbol, String] name # @return [Boolean] def member?(name) - @members.key?(name) + @members.key?(name) || @members_by_name.key?(name) end + # @param [Symbol, String] name # @return [MemberShape, nil] def member(name) - @members[name] + key = @members_by_name[name] || name + @members[key] end end @@ -231,6 +257,7 @@ class UnionShape < Shape def initialize(options = {}) super @members = {} + @members_by_name = {} @type = nil @member_types = {} end @@ -238,26 +265,32 @@ def initialize(options = {}) # @return [Hash] attr_accessor :members + # @return [Hash] + attr_accessor :members_by_name + # @return [Class] attr_accessor :type # @return [Symbol, Class] attr_accessor :member_types - # @return [MemberShape] - def add_member(name, shape, type, traits: {}) + def add_member(name, member_name, shape, type, traits: {}) @member_types[name] = type - @members[name] = MemberShape.new(shape, traits: traits) + @members_by_name[member_name] = name + @members[name] = MemberShape.new(member_name, shape, traits: traits) end + # @param [Symbol, String] name # @return [Boolean] def member?(name) - @members.key?(name) + @members.key?(name) || @members_by_name.key?(name) end + # @param [Symbol, String] name # @return [MemberShape, nil] def member(name) - @members[name] + key = @members_by_name[name] || name + @members[key] end # @return [Class, nil] @@ -268,12 +301,16 @@ def member_type(name) # Represents a member shape. class MemberShape - def initialize(shape, traits: {}) + def initialize(name, shape, traits: {}) + @name = name @shape = shape @traits = traits end - # @return [Shape] + # @return [String] Member name + attr_accessor :name + + # @return [Shape] Referenced shape attr_accessor :shape # @return [Hash] diff --git a/gems/smithy-model/sig/smithy-model/shapes.rbs b/gems/smithy-model/sig/smithy-model/shapes.rbs index 04e96048d..2e710da4a 100644 --- a/gems/smithy-model/sig/smithy-model/shapes.rbs +++ b/gems/smithy-model/sig/smithy-model/shapes.rbs @@ -10,6 +10,7 @@ module Smithy class ServiceShape < Shape include Enumerable[Shapes::OperationShape] + attr_accessor name: String? attr_accessor version: String? attr_accessor operations: Hash[Symbol, Shapes::OperationShape] def add_operation: (Symbol, Shapes::OperationShape) -> void @@ -18,6 +19,7 @@ module Smithy end class OperationShape < Shape + attr_accessor name: String? attr_accessor input: StructureShape? attr_accessor output: StructureShape? attr_accessor errors: Array[StructureShape] @@ -37,9 +39,10 @@ module Smithy class EnumShape < Shape attr_accessor members: Hash[Symbol, MemberShape] - def add_member: (Symbol, Shape, ?traits: Hash[String, untyped]) -> void - def member?: (Symbol) -> bool - def member: (Symbol) -> MemberShape? + attr_accessor members_by_name: Hash[String, Symbol] + def add_member: (Symbol, String, Shape, ?traits: Hash[String, untyped]) -> void + def member?: (Symbol|String) -> bool + def member: (Symbol|String) -> MemberShape? end class IntegerShape < Shape @@ -47,9 +50,10 @@ module Smithy class IntEnumShape < Shape attr_accessor members: Hash[Symbol, MemberShape] - def add_member: (Symbol, Shape, ?traits: Hash[String, untyped]) -> void - def member?: (Symbol) -> bool - def member: (Symbol) -> MemberShape? + attr_accessor members_by_name: Hash[String, Symbol] + def add_member: (Symbol, String, Shape, ?traits: Hash[String, untyped]) -> void + def member?: (Symbol|String) -> bool + def member: (Symbol|String) -> MemberShape? end class FloatShape < Shape @@ -72,10 +76,11 @@ module Smithy class StructureShape < Shape attr_accessor members: Hash[Symbol, MemberShape] + attr_accessor members_by_name: Hash[String, Symbol] attr_accessor type: Class? - def add_member: (Symbol, Shape, ?traits: Hash[String, untyped]) -> void - def member?: (Symbol) -> bool - def member: (Symbol) -> MemberShape? + def add_member: (Symbol, String, Shape, ?traits: Hash[String, untyped]) -> void + def member?: (Symbol|String) -> bool + def member: (Symbol|String) -> MemberShape? end class TimestampShape < Shape @@ -83,18 +88,20 @@ module Smithy class UnionShape < Shape attr_accessor members: Hash[Symbol, MemberShape] + attr_accessor members_by_name: Hash[String, Symbol] attr_accessor type: Class? attr_accessor member_types: Hash[Symbol, Class] - def add_member: (Symbol, Shape, Class, ?traits: Hash[String, untyped]) -> void - def member?: (Symbol) -> bool - def member: (Symbol) -> MemberShape? + def add_member: (Symbol, String, Shape, Class, ?traits: Hash[String, untyped]) -> void + def member?: (Symbol|String) -> bool + def member: (Symbol|String) -> MemberShape? def member_type: (Symbol) -> Class? end class MemberShape - def initialize: (Shape, ?traits: Hash[String, untyped]) -> void + def initialize: (String, Shape, ?traits: Hash[String, untyped]) -> void - attr_accessor shape: Shape? + attr_accessor shape: Shape + attr_accessor name: String attr_accessor traits: Hash[String, untyped] end diff --git a/gems/smithy-model/spec/smithy-model/shapes_spec.rb b/gems/smithy-model/spec/smithy-model/shapes_spec.rb index 103ab6d30..dc74c3f37 100644 --- a/gems/smithy-model/spec/smithy-model/shapes_spec.rb +++ b/gems/smithy-model/spec/smithy-model/shapes_spec.rb @@ -45,6 +45,11 @@ module Shapes expect(yielded).to be(subject) end + it 'can set a name' do + subject = ServiceShape.new(name: 'ServiceName') + expect(subject.name).to eq('ServiceName') + end + it 'can set a version' do subject = ServiceShape.new(version: '2015-01-01') expect(subject.version).to eq('2015-01-01') @@ -108,6 +113,11 @@ module Shapes describe '#initialize' do let(:structure_shape) { StructureShape.new } + it 'can set a name' do + subject = OperationShape.new(name: 'OperationName') + expect(subject.name).to eq('OperationName') + end + context 'error attribute' do it 'defaults to empty array' do expect(subject.errors).to be_empty @@ -192,27 +202,29 @@ module Shapes describe '#add_member' do it 'adds a member' do - subject.add_member(:foo, StringShape.new) + subject.add_member(:foo, 'foo', StringShape.new) expect(subject.members[:foo]).to be_kind_of(MemberShape) end it 'can set traits on the member' do - subject.add_member(:foo, StringShape.new, traits: { 'trait' => 'value' }) + subject.add_member(:foo, 'foo', StringShape.new, traits: { 'trait' => 'value' }) expect(subject.members[:foo].traits).to eq({ 'trait' => 'value' }) end end describe '#member?' do it 'returns true if member exists' do - subject.add_member(:foo, StringShape.new) + subject.add_member(:foo, 'foo', StringShape.new) expect(subject.member?(:foo)).to be(true) + expect(subject.member?('foo')).to be(true) end end describe '#member' do it 'returns the member' do - subject.add_member(:foo, StringShape.new) + subject.add_member(:foo, 'foo', StringShape.new) expect(subject.member(:foo)).to be_kind_of(MemberShape) + expect(subject.member('foo')).to be_kind_of(MemberShape) end end end @@ -242,27 +254,29 @@ module Shapes describe '#add_member' do it 'adds a member' do - subject.add_member(:foo, StringShape.new) + subject.add_member(:foo, 'foo', StringShape.new) expect(subject.members[:foo]).to be_kind_of(MemberShape) end it 'can set traits on the member' do - subject.add_member(:foo, StringShape.new, traits: { 'trait' => 'value' }) + subject.add_member(:foo, 'foo', StringShape.new, traits: { 'trait' => 'value' }) expect(subject.members[:foo].traits).to eq({ 'trait' => 'value' }) end end describe '#member?' do it 'returns true if member exists' do - subject.add_member(:foo, StringShape.new) + subject.add_member(:foo, 'foo', StringShape.new) expect(subject.member?(:foo)).to be(true) + expect(subject.member?('foo')).to be(true) end end describe '#member' do it 'returns the member' do - subject.add_member(:foo, StringShape.new) + subject.add_member(:foo, 'foo', StringShape.new) expect(subject.member(:foo)).to be_kind_of(MemberShape) + expect(subject.member('foo')).to be_kind_of(MemberShape) end end end @@ -372,27 +386,29 @@ module Shapes describe '#add_member' do it 'adds a member' do - subject.add_member(:foo, StringShape.new) + subject.add_member(:foo, 'foo', StringShape.new) expect(subject.members[:foo]).to be_kind_of(MemberShape) end it 'can set traits on the member' do - subject.add_member(:foo, StringShape.new, traits: { 'trait' => 'value' }) + subject.add_member(:foo, 'foo', StringShape.new, traits: { 'trait' => 'value' }) expect(subject.members[:foo].traits).to eq({ 'trait' => 'value' }) end end describe '#member?' do it 'returns true if member exists' do - subject.add_member(:foo, StringShape.new) + subject.add_member(:foo, 'foo', StringShape.new) expect(subject.member?(:foo)).to be(true) + expect(subject.member?('foo')).to be(true) end end describe '#member' do it 'returns the member' do - subject.add_member(:foo, StringShape.new) + subject.add_member(:foo, 'foo', StringShape.new) expect(subject.member(:foo)).to be_kind_of(MemberShape) + expect(subject.member('foo')).to be_kind_of(MemberShape) end end end @@ -406,15 +422,19 @@ module Shapes end describe MemberShape do - subject { MemberShape.new(Shape.new) } + subject { MemberShape.new('memberName', Shape.new) } describe '#initialize' do it 'sets the shape' do expect(subject.shape).to be_kind_of(Shape) end + it 'sets the name' do + expect(subject.name).to eq('memberName') + end + it 'can set traits' do - subject = MemberShape.new(Shape.new, traits: { 'trait' => 'value' }) + subject = MemberShape.new('memberName', Shape.new, traits: { 'trait' => 'value' }) expect(subject.traits).to eq({ 'trait' => 'value' }) end end @@ -441,7 +461,7 @@ module Shapes let(:member_type) { Class.new } it 'adds a member with its type' do - subject.add_member(:foo, StringShape.new, member_type) + subject.add_member(:foo, 'foo', StringShape.new, member_type) expect(subject.members[:foo]).to be_kind_of(MemberShape) expect(subject.member_types[:foo]).to be(member_type) end @@ -449,22 +469,24 @@ module Shapes describe '#member?' do it 'returns true if member exists' do - subject.add_member(:foo, StringShape.new, Class.new) + subject.add_member(:foo, 'foo', StringShape.new, Class.new) expect(subject.member?(:foo)).to be(true) + expect(subject.member?('foo')).to be(true) end end describe '#member' do it 'returns the member' do - subject.add_member(:foo, StringShape.new, Class.new) + subject.add_member(:foo, 'foo', StringShape.new, Class.new) expect(subject.member(:foo)).to be_kind_of(MemberShape) + expect(subject.member('foo')).to be_kind_of(MemberShape) end end describe '#member_type' do it 'returns the member type' do member_type = Class.new - subject.add_member(:foo, StringShape.new, member_type) + subject.add_member(:foo, 'foo', StringShape.new, member_type) expect(subject.member_type(:foo)).to be(member_type) end end diff --git a/gems/smithy/lib/smithy/generators/client.rb b/gems/smithy/lib/smithy/generators/client.rb index 6399cbf9c..bf1890a22 100644 --- a/gems/smithy/lib/smithy/generators/client.rb +++ b/gems/smithy/lib/smithy/generators/client.rb @@ -82,6 +82,12 @@ def code_generated_plugins require_relative: true, source: Views::Client::EndpointPlugin.new(@plan).render ) + e.yield "lib/#{@gem_name}/plugins/protocol.rb", Views::Client::Plugin.new( + class_name: "#{@plan.module_name}::Plugins::Protocol", + require_path: 'plugins/protocol', + require_relative: true, + source: Views::Client::ProtocolPlugin.new(@plan).render + ) end end diff --git a/gems/smithy/lib/smithy/templates/client/endpoint_provider_spec.erb b/gems/smithy/lib/smithy/templates/client/endpoint_provider_spec.erb index 0863009ba..008adfdbb 100644 --- a/gems/smithy/lib/smithy/templates/client/endpoint_provider_spec.erb +++ b/gems/smithy/lib/smithy/templates/client/endpoint_provider_spec.erb @@ -40,6 +40,7 @@ module <%= module_name %> <% test_case.operation_inputs.each do |operation_input| -%> it 'produces the correct output from the client when calling <%= operation_input.operation_name %>' do Client.add_plugin(stub_send) + Client.remove_plugin(Plugins::Protocol) client = Client.new( <% operation_input.client_params.each do |p| -%> <%= p.param %>: <%= p.value %>, @@ -54,20 +55,21 @@ module <%= module_name %> ) end.to raise_error(ArgumentError, expected['error']) <% else -%> - resp = client.<%= operation_input.operation_name %>( + output = client.<%= operation_input.operation_name %>( <% operation_input.operation_params.each do |p| -%> <%= p.param %>: <%= p.value %>, <% end -%> ) expected_uri = URI.parse(expected['endpoint']['url']) - expect(resp.context.request.endpoint.to_s).to include(expected_uri.host) - expect(resp.context.request.endpoint.to_s).to include(expected_uri.scheme) - expect(resp.context.request.endpoint.to_s).to include(expected_uri.path) + expect(output.context.request.endpoint.to_s).to include(expected_uri.host) + expect(output.context.request.endpoint.to_s).to include(expected_uri.scheme) + expect(output.context.request.endpoint.to_s).to include(expected_uri.path) expected['endpoint'].fetch('headers', {}).each do |k,v| expect(resp.context.request.headers[k]).to eq(v) end Client.remove_plugin(stub_send) + Client.add_plugin(Plugins::Protocol) # TODO: expect auth <% end -%> diff --git a/gems/smithy/lib/smithy/templates/client/protocol_plugin.erb b/gems/smithy/lib/smithy/templates/client/protocol_plugin.erb new file mode 100644 index 000000000..f33cab163 --- /dev/null +++ b/gems/smithy/lib/smithy/templates/client/protocol_plugin.erb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +# This is generated code! + +module <%= module_name %> + module Plugins + # Protocol plugin - allows user to configure protocol on client. + # TODO: Add convenience mapping - see https://github.com/smithy-lang/smithy-ruby/pull/264/files#r1946524403 + # @api private + class Protocol < Smithy::Client::Plugin + option( + :protocol, +<% if protocol -%> + doc_default: '<%= protocol %>', + doc_type: '#build, #parse, #error', + rbs_type: 'Smithy::Client::_Protocol', + docstring: <<~DOCS) do |_cfg| + Allows you to overwrite default protocol. The given protocol + must provide the following functionalities: + - `build` + - `parse` + - `error` + See existing protocols within Smithy::Client::Protocols for examples. + DOCS + <%= protocol %>.new + end +<% else -%> + default: nil, + doc_type: '#build, #parse, #error', + rbs_type: 'Smithy::Client::_Protocol', + docstring: <<~DOCS) + This configuration is required to build requests and parse responses. + In Smithy, a protocol is a named set of rules that defines the syntax + and semantics of how a client and server communicate. The given protocol + must provide the following functionalities: `build`, `parse` and `error`. + See existing protocols within Smithy::Client::Protocols for examples. + DOCS +<% end -%> + + # @api private + class Build < Smithy::Client::Handler + def call(context) + context.config.protocol.build(context) + @handler.call(context) + end + end + + # @api private + class Parse < Smithy::Client::Handler + def call(context) + output = @handler.call(context) + output.data = context.config.protocol.parse(context) + output + end + end + + # @api private + class Error < Smithy::Client::Handler + def call(context) + @handler.call(context).on(300..599) do |response| + context.config.protocol.error(context, response) + end + end + end + + def add_handlers(handlers, config) + handlers.add(Build) + handlers.add(Parse) + # TODO: Requires error handling to be implemented + # handlers.add(Error, step: :sign) + end + end + end +end diff --git a/gems/smithy/lib/smithy/templates/client/schema.erb b/gems/smithy/lib/smithy/templates/client/schema.erb index bc320a049..599469840 100644 --- a/gems/smithy/lib/smithy/templates/client/schema.erb +++ b/gems/smithy/lib/smithy/templates/client/schema.erb @@ -22,11 +22,13 @@ module <%= module_name %> SERVICE = ServiceShape.new do |service| service.id = "<%= service_shape.id %>" + service.name = "<%= service_shape.name %>" service.version = "<%= service_shape.version %>" service.traits = <%= service_shape.traits %> <% operation_shapes.each do |shape| -%> - service.add_operation(:<%= shape.name %>, OperationShape.new do |operation| + service.add_operation(:<%= shape.to_underscore %>, OperationShape.new do |operation| operation.id = "<%= shape.id %>" + operation.name = "<%= shape.name %>" operation.input = <%= shape.input %> operation.output = <%= shape.output %> operation.traits = <%= shape.traits %> diff --git a/gems/smithy/lib/smithy/views/client.rb b/gems/smithy/lib/smithy/views/client.rb index b6959dd45..8c4168373 100644 --- a/gems/smithy/lib/smithy/views/client.rb +++ b/gems/smithy/lib/smithy/views/client.rb @@ -29,6 +29,7 @@ module Client; end require_relative 'client/gemspec' require_relative 'client/module' require_relative 'client/module_rbs' +require_relative 'client/protocol_plugin' require_relative 'client/rubocop_yml' require_relative 'client/schema' require_relative 'client/schema_rbs' diff --git a/gems/smithy/lib/smithy/views/client/protocol_plugin.rb b/gems/smithy/lib/smithy/views/client/protocol_plugin.rb new file mode 100644 index 000000000..d6f1742c4 --- /dev/null +++ b/gems/smithy/lib/smithy/views/client/protocol_plugin.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Smithy + module Views + module Client + # @api private + class ProtocolPlugin < View + def initialize(plan) + @plan = plan + @model = plan.model + super() + end + + attr_reader :plan + + def module_name + @plan.module_name + end + + def protocol + return if default_protocol.nil? + + weld_protocols[default_protocol] + end + + private + + def service_traits + @service_traits ||= @plan.service.values.first.fetch('traits', {}) + end + + # TODO: Weld ordering - see Smithy::Welds::Protocols for more info + def weld_protocols + @weld_protocols ||= @plan.welds.map(&:protocols).reduce({}, :merge) + end + + def default_protocol + return if weld_protocols.empty? + + weld_protocols.keys.find { |k| service_traits.key?(k) } + end + end + end + end +end diff --git a/gems/smithy/lib/smithy/views/client/schema.rb b/gems/smithy/lib/smithy/views/client/schema.rb index 136e98d35..71443da68 100644 --- a/gems/smithy/lib/smithy/views/client/schema.rb +++ b/gems/smithy/lib/smithy/views/client/schema.rb @@ -95,6 +95,7 @@ def operation_shapes def service_shape ServiceShape.new( id: @service_shape.keys.first, + name: Model::Shape.name(@service_shape.keys.first), version: @service_shape.values.first['version'], traits: filter_traits(@service_shape.values.first['traits']) ) @@ -105,7 +106,7 @@ def service_shape def build_operation_shape(id, shape) OperationShape.new( id: id, - name: Model::Shape.name(id).underscore, + name: Model::Shape.name(id), input: shape_name_from_id(shape['input']['target']), output: shape_name_from_id(shape['output']['target']), errors: build_error_shapes(shape['errors']), @@ -161,7 +162,7 @@ def build_map_members(id, shape) def build_member_shape(parent_id, name, id, traits) MemberShape.new( parent_id: parent_id, - name: name.underscore, + name: name, shape: shape_name_from_id(id), traits: filter_traits(traits) ) @@ -216,6 +217,10 @@ def initialize(options = {}) @traits = options[:traits] end + def union_type + "Types::#{Model::Shape.name(@parent_id).camelize}::#{@name.camelize}" + end + def add_member_method(shape) traits_str = ", traits: #{@traits}" unless @traits.empty? case shape @@ -224,10 +229,9 @@ def add_member_method(shape) when 'MapShape' "set_#{@name}(#{@shape}#{traits_str})" when 'UnionShape' - member_type = "Types::#{Model::Shape.name(@parent_id).camelize}::#{@name.camelize}" - "add_member(:#{@name}, #{@shape}, #{member_type}#{traits_str})" + "add_member(:#{@name.underscore}, '#{@name}', #{@shape}, #{union_type}#{traits_str})" else - "add_member(:#{@name}, #{@shape}#{traits_str})" + "add_member(:#{@name.underscore}, '#{@name}', #{@shape}#{traits_str})" end end end @@ -244,17 +248,22 @@ def initialize(options = {}) end attr_reader :id, :name, :input, :output, :errors, :traits + + def to_underscore + @name.underscore + end end # @api private class ServiceShape def initialize(options = {}) @id = options[:id] + @name = options[:name] @version = options[:version] @traits = options[:traits] end - attr_reader :id, :version, :traits + attr_reader :id, :name, :version, :traits end end end diff --git a/gems/smithy/lib/smithy/weld.rb b/gems/smithy/lib/smithy/weld.rb index 0b37be2e6..d6e1a056f 100644 --- a/gems/smithy/lib/smithy/weld.rb +++ b/gems/smithy/lib/smithy/weld.rb @@ -62,5 +62,13 @@ def endpoint_function_bindings def plugins {} end + + # Called to construct the protocol plugin. Any protocols defined here will be + # merged with other protocols. The key is the protocol trait ID and the value + # is the fully qualified class name of the protocol. + # @return [Hash] protocols for use in protocol plugin. + def protocols + {} + end end end diff --git a/gems/smithy/lib/smithy/welds.rb b/gems/smithy/lib/smithy/welds.rb index 5fa28cfb0..b6f7eb1fc 100644 --- a/gems/smithy/lib/smithy/welds.rb +++ b/gems/smithy/lib/smithy/welds.rb @@ -2,6 +2,7 @@ require_relative 'welds/endpoints' require_relative 'welds/plugins' +require_relative 'welds/protocols' require_relative 'welds/rubocop' module Smithy diff --git a/gems/smithy/lib/smithy/welds/protocols.rb b/gems/smithy/lib/smithy/welds/protocols.rb new file mode 100644 index 000000000..17f1828ab --- /dev/null +++ b/gems/smithy/lib/smithy/welds/protocols.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'smithy-client/protocols/rpc_v2' + +module Smithy + module Welds + # Provides map of protocol trait id and its Ruby class name. + # TODO: Update Welds to have a functionality to control ordering since there + # is a priority ordered list of protocols and a requirement that SDK MUST + # select the first entry in their priority ordered list that is also supported + # by the service. Generic code generation MUST accept configuration of this priority + # priority ordered list for use. + # The priority order is as follows (within AWS context): + # - Smithy RPCv2 CBOR + # - AWS JSON 1.0 + # - AWS JSON 1.1 + # - REST JSON + # - REST XML + # - AWS/Query + # - EC2/Query + # Possible solution: priority system similar to the handler registration + class Protocols < Weld + def protocols + { 'smithy.protocols#rpcv2Cbor' => Smithy::Client::Protocols::RPCv2 } + end + end + end +end diff --git a/gems/smithy/spec/fixtures/endpoints/smithy-build.json b/gems/smithy/spec/fixtures/endpoints/smithy-build.json index 9452afc54..3b142de67 100644 --- a/gems/smithy/spec/fixtures/endpoints/smithy-build.json +++ b/gems/smithy/spec/fixtures/endpoints/smithy-build.json @@ -5,4 +5,4 @@ "software.amazon.smithy:smithy-rules-engine:[1.0,2.0)" ] } -} +} \ No newline at end of file diff --git a/gems/smithy/spec/fixtures/no_protocol/model.json b/gems/smithy/spec/fixtures/no_protocol/model.json new file mode 100644 index 000000000..6803eb6e8 --- /dev/null +++ b/gems/smithy/spec/fixtures/no_protocol/model.json @@ -0,0 +1,8 @@ +{ + "smithy": "2.0", + "shapes": { + "smithy.ruby.tests#NoProtocolService": { + "type": "service" + } + } +} diff --git a/gems/smithy/spec/fixtures/no_protocol/model.smithy b/gems/smithy/spec/fixtures/no_protocol/model.smithy new file mode 100644 index 000000000..54afe9079 --- /dev/null +++ b/gems/smithy/spec/fixtures/no_protocol/model.smithy @@ -0,0 +1,5 @@ +$version: "2" + +namespace smithy.ruby.tests + +service NoProtocolService {} diff --git a/gems/smithy/spec/fixtures/rpcv2_protocol/model.json b/gems/smithy/spec/fixtures/rpcv2_protocol/model.json new file mode 100644 index 000000000..238b79596 --- /dev/null +++ b/gems/smithy/spec/fixtures/rpcv2_protocol/model.json @@ -0,0 +1,58 @@ +{ + "smithy": "2.0", + "shapes": { + "smithy.protocols#StringList": { + "type": "list", + "member": { + "target": "smithy.api#String" + }, + "traits": { + "smithy.api#documentation": "A list of String shapes.", + "smithy.api#private": {} + } + }, + "smithy.protocols#rpcv2Cbor": { + "type": "structure", + "members": { + "http": { + "target": "smithy.protocols#StringList", + "traits": { + "smithy.api#documentation": "Priority ordered list of supported HTTP protocol versions." + } + }, + "eventStreamHttp": { + "target": "smithy.protocols#StringList", + "traits": { + "smithy.api#documentation": "Priority ordered list of supported HTTP protocol versions\nthat are required when using event streams." + } + } + }, + "traits": { + "smithy.api#documentation": "An RPC-based protocol that serializes CBOR payloads.", + "smithy.api#protocolDefinition": { + "traits": [ + "smithy.api#cors", + "smithy.api#endpoint", + "smithy.api#hostLabel", + "smithy.api#httpError" + ] + }, + "smithy.api#trait": { + "selector": "service" + }, + "smithy.api#traitValidators": { + "rpcv2Cbor.NoDocuments": { + "selector": "service ~> member :test(> document)", + "message": "This protocol does not support document types" + } + } + } + }, + "smithy.ruby.tests#Rpcv2CborService": { + "type": "service", + "traits": { + "smithy.protocols#rpcv2Cbor": {} + } + } + } +} diff --git a/gems/smithy/spec/fixtures/rpcv2_protocol/model.smithy b/gems/smithy/spec/fixtures/rpcv2_protocol/model.smithy new file mode 100644 index 000000000..bbd935aa7 --- /dev/null +++ b/gems/smithy/spec/fixtures/rpcv2_protocol/model.smithy @@ -0,0 +1,8 @@ +$version: "2" + +namespace smithy.ruby.tests + +use smithy.protocols#rpcv2Cbor + +@rpcv2Cbor +service Rpcv2CborService {} diff --git a/gems/smithy/spec/fixtures/rpcv2_protocol/smithy-build.json b/gems/smithy/spec/fixtures/rpcv2_protocol/smithy-build.json new file mode 100644 index 000000000..df0ac2b32 --- /dev/null +++ b/gems/smithy/spec/fixtures/rpcv2_protocol/smithy-build.json @@ -0,0 +1,8 @@ +{ + "version": "1.0", + "maven": { + "dependencies": [ + "software.amazon.smithy:smithy-protocol-traits:[1.0,2.0)" + ] + } +} \ No newline at end of file diff --git a/gems/smithy/spec/fixtures/weather/model.json b/gems/smithy/spec/fixtures/weather/model.json index 23e98c57f..adb421790 100644 --- a/gems/smithy/spec/fixtures/weather/model.json +++ b/gems/smithy/spec/fixtures/weather/model.json @@ -1,31 +1,31 @@ { "smithy": "2.0", "shapes": { - "smithy.ruby.tests.weather#City": { + "smithy.ruby.tests#City": { "type": "resource", "identifiers": { "cityId": { - "target": "smithy.ruby.tests.weather#CityId" + "target": "smithy.ruby.tests#CityId" } }, "properties": { "coordinates": { - "target": "smithy.ruby.tests.weather#CityCoordinates" + "target": "smithy.ruby.tests#CityCoordinates" } }, "read": { - "target": "smithy.ruby.tests.weather#GetCity" + "target": "smithy.ruby.tests#GetCity" }, "list": { - "target": "smithy.ruby.tests.weather#ListCities" + "target": "smithy.ruby.tests#ListCities" }, "resources": [ { - "target": "smithy.ruby.tests.weather#Forecast" + "target": "smithy.ruby.tests#Forecast" } ] }, - "smithy.ruby.tests.weather#CityCoordinates": { + "smithy.ruby.tests#CityCoordinates": { "type": "structure", "members": { "latitude": { @@ -42,23 +42,23 @@ } } }, - "smithy.ruby.tests.weather#CityId": { + "smithy.ruby.tests#CityId": { "type": "string", "traits": { "smithy.api#pattern": "^[A-Za-z0-9 ]+$" } }, - "smithy.ruby.tests.weather#CitySummaries": { + "smithy.ruby.tests#CitySummaries": { "type": "list", "member": { - "target": "smithy.ruby.tests.weather#CitySummary" + "target": "smithy.ruby.tests#CitySummary" } }, - "smithy.ruby.tests.weather#CitySummary": { + "smithy.ruby.tests#CitySummary": { "type": "structure", "members": { "cityId": { - "target": "smithy.ruby.tests.weather#CityId", + "target": "smithy.ruby.tests#CityId", "traits": { "smithy.api#required": {} } @@ -73,16 +73,16 @@ "traits": { "smithy.api#references": [ { - "resource": "smithy.ruby.tests.weather#City" + "resource": "smithy.ruby.tests#City" } ] } }, - "smithy.ruby.tests.weather#Forecast": { + "smithy.ruby.tests#Forecast": { "type": "resource", "identifiers": { "cityId": { - "target": "smithy.ruby.tests.weather#CityId" + "target": "smithy.ruby.tests#CityId" } }, "properties": { @@ -91,31 +91,31 @@ } }, "read": { - "target": "smithy.ruby.tests.weather#GetForecast" + "target": "smithy.ruby.tests#GetForecast" } }, - "smithy.ruby.tests.weather#GetCity": { + "smithy.ruby.tests#GetCity": { "type": "operation", "input": { - "target": "smithy.ruby.tests.weather#GetCityInput" + "target": "smithy.ruby.tests#GetCityInput" }, "output": { - "target": "smithy.ruby.tests.weather#GetCityOutput" + "target": "smithy.ruby.tests#GetCityOutput" }, "errors": [ { - "target": "smithy.ruby.tests.weather#NoSuchResource" + "target": "smithy.ruby.tests#NoSuchResource" } ], "traits": { "smithy.api#readonly": {} } }, - "smithy.ruby.tests.weather#GetCityInput": { + "smithy.ruby.tests#GetCityInput": { "type": "structure", "members": { "cityId": { - "target": "smithy.ruby.tests.weather#CityId", + "target": "smithy.ruby.tests#CityId", "traits": { "smithy.api#required": {} } @@ -125,7 +125,7 @@ "smithy.api#input": {} } }, - "smithy.ruby.tests.weather#GetCityOutput": { + "smithy.ruby.tests#GetCityOutput": { "type": "structure", "members": { "name": { @@ -136,7 +136,7 @@ } }, "coordinates": { - "target": "smithy.ruby.tests.weather#CityCoordinates", + "target": "smithy.ruby.tests#CityCoordinates", "traits": { "smithy.api#required": {} } @@ -146,19 +146,19 @@ "smithy.api#output": {} } }, - "smithy.ruby.tests.weather#GetCurrentTime": { + "smithy.ruby.tests#GetCurrentTime": { "type": "operation", "input": { "target": "smithy.api#Unit" }, "output": { - "target": "smithy.ruby.tests.weather#GetCurrentTimeOutput" + "target": "smithy.ruby.tests#GetCurrentTimeOutput" }, "traits": { "smithy.api#readonly": {} } }, - "smithy.ruby.tests.weather#GetCurrentTimeOutput": { + "smithy.ruby.tests#GetCurrentTimeOutput": { "type": "structure", "members": { "time": { @@ -172,23 +172,23 @@ "smithy.api#output": {} } }, - "smithy.ruby.tests.weather#GetForecast": { + "smithy.ruby.tests#GetForecast": { "type": "operation", "input": { - "target": "smithy.ruby.tests.weather#GetForecastInput" + "target": "smithy.ruby.tests#GetForecastInput" }, "output": { - "target": "smithy.ruby.tests.weather#GetForecastOutput" + "target": "smithy.ruby.tests#GetForecastOutput" }, "traits": { "smithy.api#readonly": {} } }, - "smithy.ruby.tests.weather#GetForecastInput": { + "smithy.ruby.tests#GetForecastInput": { "type": "structure", "members": { "cityId": { - "target": "smithy.ruby.tests.weather#CityId", + "target": "smithy.ruby.tests#CityId", "traits": { "smithy.api#required": {} } @@ -198,7 +198,7 @@ "smithy.api#input": {} } }, - "smithy.ruby.tests.weather#GetForecastOutput": { + "smithy.ruby.tests#GetForecastOutput": { "type": "structure", "members": { "chanceOfRain": { @@ -209,13 +209,13 @@ "smithy.api#output": {} } }, - "smithy.ruby.tests.weather#ListCities": { + "smithy.ruby.tests#ListCities": { "type": "operation", "input": { - "target": "smithy.ruby.tests.weather#ListCitiesInput" + "target": "smithy.ruby.tests#ListCitiesInput" }, "output": { - "target": "smithy.ruby.tests.weather#ListCitiesOutput" + "target": "smithy.ruby.tests#ListCitiesOutput" }, "traits": { "smithy.api#paginated": { @@ -224,7 +224,7 @@ "smithy.api#readonly": {} } }, - "smithy.ruby.tests.weather#ListCitiesInput": { + "smithy.ruby.tests#ListCitiesInput": { "type": "structure", "members": { "nextToken": { @@ -238,14 +238,14 @@ "smithy.api#input": {} } }, - "smithy.ruby.tests.weather#ListCitiesOutput": { + "smithy.ruby.tests#ListCitiesOutput": { "type": "structure", "members": { "nextToken": { "target": "smithy.api#String" }, "items": { - "target": "smithy.ruby.tests.weather#CitySummaries", + "target": "smithy.ruby.tests#CitySummaries", "traits": { "smithy.api#required": {} } @@ -255,7 +255,7 @@ "smithy.api#output": {} } }, - "smithy.ruby.tests.weather#NoSuchResource": { + "smithy.ruby.tests#NoSuchResource": { "type": "structure", "members": { "resourceType": { @@ -269,17 +269,17 @@ "smithy.api#error": "client" } }, - "smithy.ruby.tests.weather#Weather": { + "smithy.ruby.tests#Weather": { "type": "service", "version": "2006-03-01", "operations": [ { - "target": "smithy.ruby.tests.weather#GetCurrentTime" + "target": "smithy.ruby.tests#GetCurrentTime" } ], "resources": [ { - "target": "smithy.ruby.tests.weather#City" + "target": "smithy.ruby.tests#City" } ], "traits": { diff --git a/gems/smithy/spec/fixtures/weather/model.smithy b/gems/smithy/spec/fixtures/weather/model.smithy index e2c20e7cb..f3f560b7b 100644 --- a/gems/smithy/spec/fixtures/weather/model.smithy +++ b/gems/smithy/spec/fixtures/weather/model.smithy @@ -1,6 +1,6 @@ $version: "2" -namespace smithy.ruby.tests.weather +namespace smithy.ruby.tests /// Provides weather forecasts. @paginated(inputToken: "nextToken", outputToken: "nextToken", pageSize: "pageSize") diff --git a/gems/smithy/spec/fixtures/weld_protocol/model.json b/gems/smithy/spec/fixtures/weld_protocol/model.json new file mode 100644 index 000000000..faf1eb9a0 --- /dev/null +++ b/gems/smithy/spec/fixtures/weld_protocol/model.json @@ -0,0 +1,19 @@ +{ + "smithy": "2.0", + "shapes": { + "smithy.ruby.tests#WeldProtocolService": { + "type": "service", + "traits": { + "smithy.ruby.tests#weldProtocol": {} + } + }, + "smithy.ruby.tests#weldProtocol": { + "type": "structure", + "members": {}, + "traits": { + "smithy.api#protocolDefinition": {}, + "smithy.api#trait": {} + } + } + } +} diff --git a/gems/smithy/spec/fixtures/weld_protocol/model.smithy b/gems/smithy/spec/fixtures/weld_protocol/model.smithy new file mode 100644 index 000000000..ed89390c3 --- /dev/null +++ b/gems/smithy/spec/fixtures/weld_protocol/model.smithy @@ -0,0 +1,10 @@ +$version: "2" + +namespace smithy.ruby.tests + +@trait +@protocolDefinition +structure weldProtocol {} + +@weldProtocol +service WeldProtocolService {} diff --git a/gems/smithy/spec/interfaces/weld_spec.rb b/gems/smithy/spec/interfaces/weld_spec.rb deleted file mode 100644 index ef77d08b1..000000000 --- a/gems/smithy/spec/interfaces/weld_spec.rb +++ /dev/null @@ -1,59 +0,0 @@ -# frozen_string_literal: true - -describe 'Integration: Welds' do - # rubocop:disable Lint/UselessAssignment - before(:all) do - # Define Weld classes (scoped to this block only) - used = Class.new(Smithy::Weld) do - def for?(service) - service.keys.first == 'smithy.ruby.tests.weather#Weather' - end - - def pre_process(model) - model['shapes']['smithy.ruby.tests.weather#Weld'] = { 'type' => 'structure', 'members' => {} } - model['shapes']['smithy.ruby.tests.weather#GetForecastOutput']['members']['chanceOfWelds'] = - { 'target' => 'smithy.ruby.tests.weather#Weld' } - end - - def post_process(artifacts) - file = artifacts.find { |f| f.include?('/types.rb') } - inject_into_module(file, 'Types') do - " OtherWeld = Struct.new(keyword_init: true)\n" - end - end - end - - unused = Class.new(Smithy::Weld) do - def for?(_service) - false - end - - def pre_process(model) - model['shapes']['smithy.ruby.tests.weather#WeldShouldNotExist'] = { 'type' => 'structure', 'members' => {} } - model['shapes']['smithy.ruby.tests.weather#GetForecastOutput']['members']['chanceOfWelds'] = - { 'target' => 'smithy.ruby.tests.weather#WeldShouldNotExist' } - end - - def post_process(artifacts) - file = artifacts.find { |f| f.include?('/types.rb') } - inject_into_module(file, 'Types') do - " OtherWeldShouldNotExist = Struct.new(keyword_init: true)\n" - end - end - end - end - # rubocop:enable Lint/UselessAssignment - - it 'includes Thor::Actions' do - expect(Class.new(Smithy::Weld).ancestors).to include(Thor::Actions) - end - - ['generated client gem', - 'generated schema gem', - 'generated client from source code', - 'generated schema from source code'].each do |context| - context context do - include_examples 'welds', context - end - end -end diff --git a/gems/smithy/spec/interfaces/welds/post_process_spec.rb b/gems/smithy/spec/interfaces/welds/post_process_spec.rb new file mode 100644 index 000000000..34015e935 --- /dev/null +++ b/gems/smithy/spec/interfaces/welds/post_process_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +describe 'Welds: Post Process' do + before(:all) do + Class.new(Smithy::Weld) do + def for?(service) + service.keys.first == 'smithy.ruby.tests#Weather' + end + + def post_process(artifacts) + file = artifacts.find { |f| f.include?('/types.rb') } + inject_into_module(file, 'Types') do + " OtherWeld = Struct.new(keyword_init: true)\n" + end + end + end + end + + ['generated client gem', 'generated schema gem'].each do |context| + context context do + include_context context, 'Weather' + + it 'can post process files' do + other_weld = Weather::Types::OtherWeld.new + expect(other_weld).to be_a(Struct) + end + end + end +end diff --git a/gems/smithy/spec/interfaces/welds/pre_process_spec.rb b/gems/smithy/spec/interfaces/welds/pre_process_spec.rb new file mode 100644 index 000000000..678b07c4e --- /dev/null +++ b/gems/smithy/spec/interfaces/welds/pre_process_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +describe 'Welds: Pre Process' do + before(:all) do + Class.new(Smithy::Weld) do + def for?(service) + service.keys.first == 'smithy.ruby.tests#Weather' + end + + def pre_process(model) + model['shapes']['smithy.ruby.tests#Weld'] = { 'type' => 'structure', 'members' => {} } + model['shapes']['smithy.ruby.tests#GetForecastOutput']['members']['chanceOfWelds'] = + { 'target' => 'smithy.ruby.tests#Weld' } + end + end + end + + ['generated client gem', + 'generated schema gem', + 'generated client from source code', + 'generated schema from source code'].each do |context| + context context do + include_context context, 'Weather' + + it 'can pre process the model' do + weld = Weather::Types::Weld.new + expect(weld).to be_a(Struct) + expect(weld.members).to be_empty + get_forecast_output = Weather::Types::GetForecastOutput.new + expect(get_forecast_output.members).to include(:chance_of_welds) + end + end + end +end diff --git a/gems/smithy/spec/interfaces/welds/protocols_spec.rb b/gems/smithy/spec/interfaces/welds/protocols_spec.rb new file mode 100644 index 000000000..2d74b97a7 --- /dev/null +++ b/gems/smithy/spec/interfaces/welds/protocols_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +describe 'Welds: Protocols' do + ['generated client gem', 'generated client from source code'].each do |context| + before(:all) do + Class.new(Smithy::Weld) do + def for?(service) + service.keys.first == 'smithy.ruby.tests#WeldProtocolService' + end + + def protocols + { 'smithy.ruby.tests#weldProtocol' => 'FakeProtocol' } + end + end + end + + context 'no protocol' do + include_context context, 'NoProtocol' + + it 'defaults protocol config to nil' do + client = NoProtocol::Client.new(endpoint: 'https://example.com') + expect(client.config.protocol).to be_nil + end + end + + context 'registered protocol' do + include_context context, 'WeldProtocol' + + it 'defaults to the protocol' do + fake_protocol = Class.new + stub_const('FakeProtocol', fake_protocol) + client = WeldProtocol::Client.new(endpoint: 'https://example.com') + expect(client.config.protocol).to be_a(fake_protocol) + end + end + + context 'default cbor protocol' do + include_context context, 'Rpcv2Protocol' + + it 'defaults to the rpcv2Cbor protocol' do + client = Rpcv2Protocol::Client.new(endpoint: 'https://example.com') + expect(client.config.protocol).to be_a(Smithy::Client::Protocols::RPCv2) + end + end + end +end diff --git a/gems/smithy/spec/smithy/weld_spec.rb b/gems/smithy/spec/smithy/weld_spec.rb new file mode 100644 index 000000000..32d1dbc3b --- /dev/null +++ b/gems/smithy/spec/smithy/weld_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Smithy + describe Weld do + it 'includes Thor::Actions' do + expect(Class.new(Smithy::Weld).ancestors).to include(Thor::Actions) + end + end +end diff --git a/gems/smithy/spec/spec_helper.rb b/gems/smithy/spec/spec_helper.rb index 2cca5443f..f817e677c 100644 --- a/gems/smithy/spec/spec_helper.rb +++ b/gems/smithy/spec/spec_helper.rb @@ -16,7 +16,6 @@ require_relative 'support/examples/module_examples' require_relative 'support/examples/schema_examples' require_relative 'support/examples/types_examples' -require_relative 'support/examples/weld_examples' require_relative 'support/matchers/be_in_documentation_matcher' diff --git a/gems/smithy/spec/support/examples/weld_examples.rb b/gems/smithy/spec/support/examples/weld_examples.rb deleted file mode 100644 index 7a0ab94e4..000000000 --- a/gems/smithy/spec/support/examples/weld_examples.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -RSpec.shared_examples 'welds' do |context| - include_context context, 'Weather' - - it 'can pre process the model' do - weld = Weather::Types::Weld.new - expect(weld).to be_a(Struct) - expect(weld.members).to be_empty - get_forecast_output = Weather::Types::GetForecastOutput.new - expect(get_forecast_output.members).to include(:chance_of_welds) - end - - if context.include?('source code') - it 'cannot post process files' do - expect(defined?(Weather::Types::OtherWeld)).to be nil - end - else - it 'can post process files' do - other_weld = Weather::Types::OtherWeld.new - expect(other_weld).to be_a(Struct) - end - end - - it 'does not apply welds that return false in #for?' do - expect(defined?(Weather::Types::WeldShouldNotExist)).to be nil - expect(defined?(Weather::Types::OtherWeldShouldNotExist)).to be nil - end -end diff --git a/projections/weather/lib/weather/client.rb b/projections/weather/lib/weather/client.rb index 7a5f9638c..f1bd0095a 100644 --- a/projections/weather/lib/weather/client.rb +++ b/projections/weather/lib/weather/client.rb @@ -3,6 +3,7 @@ # This is generated code! require_relative 'plugins/endpoint' +require_relative 'plugins/protocol' require 'smithy-client/plugins/logging' require 'smithy-client/plugins/net_http' require 'smithy-client/plugins/param_converter' @@ -17,6 +18,7 @@ class Client < Smithy::Client::Base self.service = Schema::SERVICE add_plugin(Weather::Plugins::Endpoint) + add_plugin(Weather::Plugins::Protocol) add_plugin(Smithy::Client::Plugins::Logging) add_plugin(Smithy::Client::Plugins::NetHTTP) add_plugin(Smithy::Client::Plugins::ParamConverter) @@ -93,6 +95,12 @@ class Client < Smithy::Client::Base # @option options [Logger] :logger # The Logger instance to send log messages to. If this option is not set, # logging is disabled. + # @option options [#build, #parse, #error] :protocol + # This configuration is required to build requests and parse responses. + # In Smithy, a protocol is a named set of rules that defines the syntax + # and semantics of how a client and server communicate. The given protocol + # must provide the following functionalities: `build`, `parse` and `error`. + # See existing protocols within Smithy::Client::Protocols for examples. # @option options [Boolean] :raise_response_errors (true) # When `true`, response errors are raised. When `false`, the error is placed on the # output in the {Smithy::Client::Output#error error accessor}. diff --git a/projections/weather/lib/weather/plugins/protocol.rb b/projections/weather/lib/weather/plugins/protocol.rb new file mode 100644 index 000000000..9d0483626 --- /dev/null +++ b/projections/weather/lib/weather/plugins/protocol.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +# This is generated code! + +module Weather + module Plugins + # Protocol plugin - allows user to configure protocol on client. + # TODO: Add convenience mapping - see https://github.com/smithy-lang/smithy-ruby/pull/264/files#r1946524403 + # @api private + class Protocol < Smithy::Client::Plugin + option( + :protocol, + default: nil, + doc_type: '#build, #parse, #error', + rbs_type: 'Smithy::Client::_Protocol', + docstring: <<~DOCS) + This configuration is required to build requests and parse responses. + In Smithy, a protocol is a named set of rules that defines the syntax + and semantics of how a client and server communicate. The given protocol + must provide the following functionalities: `build`, `parse` and `error`. + See existing protocols within Smithy::Client::Protocols for examples. + DOCS + + # @api private + class Build < Smithy::Client::Handler + def call(context) + context.config.protocol.build(context) + @handler.call(context) + end + end + + # @api private + class Parse < Smithy::Client::Handler + def call(context) + output = @handler.call(context) + output.data = context.config.protocol.parse(context) + output + end + end + + # @api private + class Error < Smithy::Client::Handler + def call(context) + @handler.call(context).on(300..599) do |response| + context.config.protocol.error(context, response) + end + end + end + + def add_handlers(handlers, _config) + handlers.add(Build) + handlers.add(Parse) + # TODO: Requires error handling to be implemented + # handlers.add(Error, step: :sign) + end + end + end +end diff --git a/projections/weather/lib/weather/schema.rb b/projections/weather/lib/weather/schema.rb index 734f2253f..05bc3c3c0 100644 --- a/projections/weather/lib/weather/schema.rb +++ b/projections/weather/lib/weather/schema.rb @@ -20,39 +20,41 @@ module Schema ListCitiesOutput = StructureShape.new(id: 'example.weather#ListCitiesOutput', traits: { 'smithy.api#output' => {} }) NoSuchResource = StructureShape.new(id: 'example.weather#NoSuchResource', traits: { 'smithy.api#error' => 'client' }) - CityCoordinates.add_member(:latitude, Prelude::Float, traits: { 'smithy.api#required' => {} }) - CityCoordinates.add_member(:longitude, Prelude::Float, traits: { 'smithy.api#required' => {} }) + CityCoordinates.add_member(:latitude, 'latitude', Prelude::Float, traits: { 'smithy.api#required' => {} }) + CityCoordinates.add_member(:longitude, 'longitude', Prelude::Float, traits: { 'smithy.api#required' => {} }) CityCoordinates.type = Types::CityCoordinates CitySummaries.set_member(CitySummary) - CitySummary.add_member(:city_id, CityId, traits: { 'smithy.api#required' => {} }) - CitySummary.add_member(:name, Prelude::String, traits: { 'smithy.api#required' => {} }) + CitySummary.add_member(:city_id, 'cityId', CityId, traits: { 'smithy.api#required' => {} }) + CitySummary.add_member(:name, 'name', Prelude::String, traits: { 'smithy.api#required' => {} }) CitySummary.type = Types::CitySummary - GetCityInput.add_member(:city_id, CityId, traits: { 'smithy.api#required' => {} }) + GetCityInput.add_member(:city_id, 'cityId', CityId, traits: { 'smithy.api#required' => {} }) GetCityInput.type = Types::GetCityInput - GetCityOutput.add_member(:name, Prelude::String, traits: { 'smithy.api#notProperty' => {}, 'smithy.api#required' => {} }) - GetCityOutput.add_member(:coordinates, CityCoordinates, traits: { 'smithy.api#required' => {} }) + GetCityOutput.add_member(:name, 'name', Prelude::String, traits: { 'smithy.api#notProperty' => {}, 'smithy.api#required' => {} }) + GetCityOutput.add_member(:coordinates, 'coordinates', CityCoordinates, traits: { 'smithy.api#required' => {} }) GetCityOutput.type = Types::GetCityOutput - GetCurrentTimeOutput.add_member(:time, Prelude::Timestamp, traits: { 'smithy.api#required' => {} }) + GetCurrentTimeOutput.add_member(:time, 'time', Prelude::Timestamp, traits: { 'smithy.api#required' => {} }) GetCurrentTimeOutput.type = Types::GetCurrentTimeOutput - GetForecastInput.add_member(:city_id, CityId, traits: { 'smithy.api#required' => {} }) + GetForecastInput.add_member(:city_id, 'cityId', CityId, traits: { 'smithy.api#required' => {} }) GetForecastInput.type = Types::GetForecastInput - GetForecastOutput.add_member(:chance_of_rain, Prelude::Float) + GetForecastOutput.add_member(:chance_of_rain, 'chanceOfRain', Prelude::Float) GetForecastOutput.type = Types::GetForecastOutput - ListCitiesInput.add_member(:next_token, Prelude::String) - ListCitiesInput.add_member(:page_size, Prelude::Integer) + ListCitiesInput.add_member(:next_token, 'nextToken', Prelude::String) + ListCitiesInput.add_member(:page_size, 'pageSize', Prelude::Integer) ListCitiesInput.type = Types::ListCitiesInput - ListCitiesOutput.add_member(:next_token, Prelude::String) - ListCitiesOutput.add_member(:items, CitySummaries, traits: { 'smithy.api#required' => {} }) + ListCitiesOutput.add_member(:next_token, 'nextToken', Prelude::String) + ListCitiesOutput.add_member(:items, 'items', CitySummaries, traits: { 'smithy.api#required' => {} }) ListCitiesOutput.type = Types::ListCitiesOutput - NoSuchResource.add_member(:resource_type, Prelude::String, traits: { 'smithy.api#required' => {} }) + NoSuchResource.add_member(:resource_type, 'resourceType', Prelude::String, traits: { 'smithy.api#required' => {} }) NoSuchResource.type = Types::NoSuchResource SERVICE = ServiceShape.new do |service| service.id = 'example.weather#Weather' + service.name = 'Weather' service.version = '2006-03-01' service.traits = { 'smithy.api#paginated' => { 'inputToken' => 'nextToken', 'outputToken' => 'nextToken', 'pageSize' => 'pageSize' } } service.add_operation(:get_city, OperationShape.new do |operation| operation.id = 'example.weather#GetCity' + operation.name = 'GetCity' operation.input = GetCityInput operation.output = GetCityOutput operation.traits = { 'smithy.api#readonly' => {} } @@ -60,18 +62,21 @@ module Schema end) service.add_operation(:get_current_time, OperationShape.new do |operation| operation.id = 'example.weather#GetCurrentTime' + operation.name = 'GetCurrentTime' operation.input = Prelude::Unit operation.output = GetCurrentTimeOutput operation.traits = { 'smithy.api#readonly' => {} } end) service.add_operation(:get_forecast, OperationShape.new do |operation| operation.id = 'example.weather#GetForecast' + operation.name = 'GetForecast' operation.input = GetForecastInput operation.output = GetForecastOutput operation.traits = { 'smithy.api#readonly' => {} } end) service.add_operation(:list_cities, OperationShape.new do |operation| operation.id = 'example.weather#ListCities' + operation.name = 'ListCities' operation.input = ListCitiesInput operation.output = ListCitiesOutput operation.traits = { 'smithy.api#paginated' => { 'items' => 'items' }, 'smithy.api#readonly' => {} } diff --git a/projections/weather/sig/client.rbs b/projections/weather/sig/client.rbs index 47e288a4e..9c918cef0 100644 --- a/projections/weather/sig/client.rbs +++ b/projections/weather/sig/client.rbs @@ -20,6 +20,7 @@ module Weather ?http_write_timeout: Numeric, ?log_level: Symbol, ?logger: Logger, + ?protocol: Smithy::Client::_Protocol, ?raise_response_errors: bool, ?validate_params: bool, ) -> void