diff --git a/gems/smithy-cbor/lib/smithy-cbor/builder.rb b/gems/smithy-cbor/lib/smithy-cbor/builder.rb index 76443366f..cf6cfb61c 100644 --- a/gems/smithy-cbor/lib/smithy-cbor/builder.rb +++ b/gems/smithy-cbor/lib/smithy-cbor/builder.rb @@ -21,7 +21,8 @@ def build(shape, data) private def build_shape(shape, value) - case shape.target + target = shape.target + case target when BlobShape then blob(value) when ListShape then list(shape, value) when MapShape then map(shape, value) @@ -38,48 +39,54 @@ def blob(value) def list(shape, values) return if values.nil? + member = shape.target.member values.collect do |value| - build_shape(shape.target.member, value) + build_shape(member, value) end end def map(shape, values) return if values.nil? + value_member = shape.target.value values.each.with_object({}) do |(key, value), data| - data[key] = build_shape(shape.target.value, value) + data[key] = build_shape(value_member, value) end end def structure(shape, values) return if values.nil? - members = shape.target.members + index = @extension.member_index(shape.target) values.each_pair.with_object({}) do |(member_name, value), data| next if value.nil? - member_shape = members[member_name] - next unless member_shape + entry = index[member_name] + next unless entry - data[@extension.wire_name(member_shape)] = build_shape(member_shape, value) + wire_name, member_shape = entry + data[wire_name] = build_shape(member_shape, value) end end - def union(shape, values) # rubocop:disable Metrics/AbcSize + def union(shape, values) return if values.nil? - data = {} - if values.is_a?(Schema::Union) - _name, member_shape = shape.target.member_by_type(values.class) - data[@extension.wire_name(member_shape)] = build_shape(member_shape, values.value) - else - key, value = values.first - if shape.target.member?(key) - member_shape = shape.target.member(key) - data[@extension.wire_name(member_shape)] = build_shape(member_shape, value) + target = shape.target + key, value = + if values.is_a?(Schema::Union) + values.active_member_value + else + values.first end - end - data + + return {} unless key + + entry = @extension.member_index(target)[key] + return {} unless entry + + wire_name, member_shape = entry + { wire_name => build_shape(member_shape, value) } end end end diff --git a/gems/smithy-cbor/lib/smithy-cbor/codec.rb b/gems/smithy-cbor/lib/smithy-cbor/codec.rb index 2c95508e0..b1d9b7042 100644 --- a/gems/smithy-cbor/lib/smithy-cbor/codec.rb +++ b/gems/smithy-cbor/lib/smithy-cbor/codec.rb @@ -6,14 +6,15 @@ module Cbor class Codec # @param [Hash] options def initialize(options = {}) - @options = options + @builder = Builder.new(options) + @parser = Parser.new(options) end # @param [Shape] shape # @param [Object] data # @return [String, nil] def build(shape, data) - Builder.new(@options).build(shape, data) + @builder.build(shape, data) end # @param [Shape] shape @@ -21,7 +22,7 @@ def build(shape, data) # @param [Object, nil] result (nil) # @return [Object, nil] def parse(shape, bytes, result = nil) - Parser.new(@options).parse(shape, bytes, result) + @parser.parse(shape, bytes, result) end end end diff --git a/gems/smithy-cbor/lib/smithy-cbor/decoder.rb b/gems/smithy-cbor/lib/smithy-cbor/decoder.rb index 72779550e..09e3630e8 100644 --- a/gems/smithy-cbor/lib/smithy-cbor/decoder.rb +++ b/gems/smithy-cbor/lib/smithy-cbor/decoder.rb @@ -7,6 +7,7 @@ module Cbor # @api private class Decoder # rubocop:disable Metrics/ClassLength FIVE_BIT_MASK = 0x1F + BREAK_STOP_BYTE = 0xFF TAG_TYPE_EPOCH = 1 TAG_TYPE_BIGNUM = 2 TAG_TYPE_NEG_BIGNUM = 3 @@ -47,6 +48,16 @@ def decode_item # rubocop:disable Metrics when :indefinite_string then process_indefinite_string when :tag then process_tag when :break_stop_code then raise ParseError, 'Unexpected break code' + when :integer then read_integer + when :binary_string then read_binary_string + when :string then read_string + when :boolean then read_boolean + when :nil then read_nil + when :undefined then read_undefined + when :reserved_undefined then read_reserved_undefined + when :half then read_half + when :float then read_float + when :double then read_double else send("read_#{next_type}") end ensure @@ -62,7 +73,7 @@ def peek(n_bytes) # low level streaming interface def peek_type # rubocop:disable Metrics - ib = peek(1).ord + ib = peek_byte add_info = ib & FIVE_BIT_MASK major_type = ib >> 5 case major_type @@ -97,7 +108,7 @@ def process_major_type_simple(add_info) # rubocop:disable Metrics def process_indefinite_array read_start_indefinite_array value = [] - value << decode_item until peek_type == :break_stop_code + value << decode_item until peek_byte == BREAK_STOP_BYTE read_end_indefinite_collection value end @@ -105,7 +116,7 @@ def process_indefinite_array def process_indefinite_binary read_info value = String.new - value << read_binary_string until peek_type == :break_stop_code + value << read_binary_string until peek_byte == BREAK_STOP_BYTE read_end_indefinite_collection value end @@ -113,7 +124,7 @@ def process_indefinite_binary def process_indefinite_map read_start_indefinite_map value = {} - value[read_string] = decode_item until peek_type == :break_stop_code + value[read_string] = decode_item until peek_byte == BREAK_STOP_BYTE read_end_indefinite_collection value end @@ -121,7 +132,7 @@ def process_indefinite_map def process_indefinite_string read_info value = String.new - value << read_string until peek_type == :break_stop_code + value << read_string until peek_byte == BREAK_STOP_BYTE read_end_indefinite_collection value.force_encoding(Encoding::UTF_8) end @@ -191,8 +202,8 @@ def read_binary_string def read_count(add_info) case add_info when 0..23 then add_info - when 24 then take(1).ord - when 25 then take(2).unpack1('n') + when 24 then take_byte + when 25 then take_u16 when 26 then take(4).unpack1('N') when 27 then take(8).unpack1('Q>') else raise ParseError, "Unexpected additional information: #{add_info}" @@ -222,7 +233,7 @@ def read_float # precision - 10 bits def read_half read_info - b16 = take(2).unpack1('n') + b16 = take_u16 exp = (b16 >> 10) & 0x1f mant = b16 & 0x3ff val = @@ -245,7 +256,7 @@ def read_half # return a tuple of major_type, add_info def read_info - ib = take(1).ord + ib = take_byte [ib >> 5, ib & FIVE_BIT_MASK] end @@ -313,6 +324,24 @@ def take(n_bytes) left = @buffer.bytesize - @pos raise ParseError, "Out of bytes. Trying to read #{n_bytes} bytes but buffer contains only #{left}" end + + def peek_byte + return @buffer.getbyte(@pos) if @pos < @buffer.bytesize + + left = @buffer.bytesize - @pos + raise ParseError, "Out of bytes. Trying to read 1 bytes but buffer contains only #{left}" + end + + def take_byte + return @buffer.getbyte(@pos).tap { @pos += 1 } if @pos < @buffer.bytesize + + left = @buffer.bytesize - @pos + raise ParseError, "Out of bytes. Trying to read 1 bytes but buffer contains only #{left}" + end + + def take_u16 + (take_byte << 8) | take_byte + end end end end diff --git a/gems/smithy-cbor/lib/smithy-cbor/encoder.rb b/gems/smithy-cbor/lib/smithy-cbor/encoder.rb index 02b0bbceb..d2935410c 100644 --- a/gems/smithy-cbor/lib/smithy-cbor/encoder.rb +++ b/gems/smithy-cbor/lib/smithy-cbor/encoder.rb @@ -115,7 +115,7 @@ def add_big_decimal(value) end def add_boolean(value) - value ? head(MAJOR_TYPE_SIMPLE, 21) : head(MAJOR_TYPE_SIMPLE, 20) + @buffer << (value ? 0xf5 : 0xf4) end # Encoding MUST already be Encoding::BINARY @@ -141,11 +141,16 @@ def add_hash(value) end def add_nil - head(MAJOR_TYPE_SIMPLE, 22) + @buffer << 0xf6 end def add_string(value) - value = value.encode(Encoding::UTF_8).force_encoding(Encoding::BINARY) + value = + if value.encoding == Encoding::UTF_8 + value.b + else + value.encode(Encoding::UTF_8).force_encoding(Encoding::BINARY) + end head(MAJOR_TYPE_STR, value.bytesize) @buffer << value end @@ -170,21 +175,20 @@ def bignum_to_bytes(value) end def head(major_type, value) - @buffer << - case value - when 0...24 - [major_type + value].pack('C') # 8-bit unsigned - when 0...256 - [major_type + 24, value].pack('CC') - when 0...65_536 - [major_type + 25, value].pack('Cn') - when 0...4_294_967_296 - [major_type + 26, value].pack('CN') - when 0...MAX_INTEGER - [major_type + 27, value].pack('CQ>') - else - raise BuildError, "Value is too large to encode: #{value}" - end + case value + when 0...24 + @buffer << (major_type + value) # 8-bit unsigned + when 0...256 + @buffer << (major_type + 24) << value + when 0...65_536 + @buffer << [major_type + 25, value].pack('Cn') + when 0...4_294_967_296 + @buffer << [major_type + 26, value].pack('CN') + when 0...MAX_INTEGER + @buffer << [major_type + 27, value].pack('CQ>') + else + raise BuildError, "Value is too large to encode: #{value}" + end end def process_string(value) diff --git a/gems/smithy-cbor/lib/smithy-cbor/parser.rb b/gems/smithy-cbor/lib/smithy-cbor/parser.rb index 85cf46015..d5d0e3bba 100644 --- a/gems/smithy-cbor/lib/smithy-cbor/parser.rb +++ b/gems/smithy-cbor/lib/smithy-cbor/parser.rb @@ -23,7 +23,8 @@ def parse(shape, bytes, result = nil) def parse_shape(shape, value, result = nil) return nil if value.nil? - case shape.target + target = shape.target + case target when ListShape then list(shape, value, result) when MapShape then map(shape, value, result) when StructureShape then structure(shape, value, result) @@ -33,28 +34,35 @@ def parse_shape(shape, value, result = nil) end def list(shape, values, result = nil) + target = shape.target + sparse = Smithy::Schema::Extension.sparse?(target) + list_member = target.member result = [] if result.nil? values.each do |value| - next if value.nil? && !sparse?(shape.target) + next if value.nil? && !sparse - result << parse_shape(shape.target.member, value) + result << parse_shape(list_member, value) end result end def map(shape, values, result = nil) + target = shape.target + sparse = Smithy::Schema::Extension.sparse?(target) + value_member = target.value result = {} if result.nil? values.each do |key, value| - next if value.nil? && !sparse?(shape.target) + next if value.nil? && !sparse - result[key] = parse_shape(shape.target.value, value) + result[key] = parse_shape(value_member, value) end result end def structure(shape, values, result = nil) - result = shape.target.type.new if result.nil? - index = @extension.member_index(shape.target) + target = shape.target + result = target.type.new if result.nil? + index = @extension.wire_index(target) values.each do |wire_name, value| next if value.nil? @@ -67,8 +75,9 @@ def structure(shape, values, result = nil) result end - def union(shape, values, result = nil) # rubocop:disable Metrics/AbcSize - index = @extension.member_index(shape.target) + def union(shape, values, result = nil) + target = shape.target + index = @extension.wire_index(target) values.each do |wire_name, value| next if value.nil? @@ -76,17 +85,13 @@ def union(shape, values, result = nil) # rubocop:disable Metrics/AbcSize next unless entry member_name, member_shape = entry - result = shape.target.member_type(member_name) if result.nil? + result = target.member_type(member_name) if result.nil? return result.new(member_name => parse_shape(member_shape, value)) end values.delete('__type') key, value = values.first - shape.target.member_type(:unknown).new(unknown: { key => value }) - end - - def sparse?(shape) - @extension.sparse?(shape) + target.member_type(:unknown).new(unknown: { key => value }) end end end diff --git a/gems/smithy-client/lib/smithy-client.rb b/gems/smithy-client/lib/smithy-client.rb index c9c016c0d..a20e07904 100644 --- a/gems/smithy-client/lib/smithy-client.rb +++ b/gems/smithy-client/lib/smithy-client.rb @@ -50,6 +50,7 @@ require_relative 'smithy-client/http/headers' require_relative 'smithy-client/http/response' require_relative 'smithy-client/http/request' +require_relative 'smithy-client/http_binding' require_relative 'smithy-client/stream' require_relative 'smithy-client/transport' require_relative 'smithy-client/net_http/connection_pool' diff --git a/gems/smithy-client/lib/smithy-client/default_params.rb b/gems/smithy-client/lib/smithy-client/default_params.rb index ea4105b32..6ea316bc5 100644 --- a/gems/smithy-client/lib/smithy-client/default_params.rb +++ b/gems/smithy-client/lib/smithy-client/default_params.rb @@ -22,7 +22,8 @@ def apply(params) private def apply_shape(shape, value) - case shape.target + target = shape.target + case target when ListShape then list(shape, value) when MapShape then map(shape, value) when StructureShape then structure(shape, value) @@ -33,7 +34,8 @@ def apply_shape(shape, value) def list(shape, values) return if values.nil? - member = shape.target.member + target = shape.target + member = target.member values.each do |value| apply_shape(member, value) end @@ -43,7 +45,8 @@ def list(shape, values) def map(shape, values) return if values.nil? - value_shape = shape.target.value + target = shape.target + value_shape = target.value values.each_pair do |_key, value| apply_shape(value_shape, value) end @@ -53,26 +56,28 @@ def map(shape, values) def structure(shape, values) return if values.nil? - shape.target.members.each do |member_name, member_shape| - value = values[member_name] - value ||= default(member_shape) if default?(shape, member_shape.traits) - next if value.nil? && !default?(shape, member_shape.traits) # default can have nil values + target = shape.target + unless shape == @shape + Schema::Extension.default_members(target).each do |member_name, member_shape| + next unless values[member_name].nil? - values[member_name] = apply_shape(member_shape, value) + values[member_name] = default(member_shape) + end end - values - end - def default?(shape, traits) - # skip defaults for top level members - return false if shape == @shape + values.each do |member_name, value| + member_shape = target.members[member_name] + next unless member_shape - traits.include?('smithy.api#default') && !traits.include?('smithy.api#clientOptional') + values[member_name] = apply_shape(member_shape, value) + end + values end def default(member_shape) - default = member_shape.traits['smithy.api#default'] - case member_shape.target + default = Schema::Extension.default_trait(member_shape) + target = member_shape.target + case target when BlobShape then Base64.strict_decode64(default) when TimestampShape then timestamp_default(default) else default diff --git a/gems/smithy-client/lib/smithy-client/endpoint_rules.rb b/gems/smithy-client/lib/smithy-client/endpoint_rules.rb index 9800b002b..cb53bed31 100644 --- a/gems/smithy-client/lib/smithy-client/endpoint_rules.rb +++ b/gems/smithy-client/lib/smithy-client/endpoint_rules.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true -require 'cgi' require 'ipaddr' require 'uri' @@ -80,7 +79,7 @@ def self.substring(input, start, stop, reverse) # Performs RFC 3986#section-2.1 defined percent-encoding on the input value. # @api private def self.uri_encode(value) - CGI.escape(value.encode('UTF-8')).gsub('+', '%20').gsub('%7E', '~') + Util.escape(value) end # isSet(value: Option) bool diff --git a/gems/smithy-client/lib/smithy-client/http_binding.rb b/gems/smithy-client/lib/smithy-client/http_binding.rb new file mode 100644 index 000000000..4fc633199 --- /dev/null +++ b/gems/smithy-client/lib/smithy-client/http_binding.rb @@ -0,0 +1,277 @@ +# frozen_string_literal: true + +module Smithy + module Client + # Lookup helpers for HTTP binding metadata derived from Smithy HTTP binding + # traits. + # + # Raw Smithy trait data remains on +shape.traits+ and +member.traits+ with + # string keys. This module resolves HTTP binding routing metadata on demand + # and caches the resolved values directly on reusable operation and + # structure shapes: + # - +operation[:http_operation_index]+ caches HTTP method/path/query metadata + # - +shape[:http_request_index]+ caches request member routing metadata + # - +shape[:http_response_index]+ caches response member routing metadata + # + # Payload-shaping concerns remain in the JSON/XML/CBOR codec extensions. + # This module only caches where members travel in the HTTP message. + # @api private + module HttpBinding # rubocop:disable Metrics/ModuleLength + class << self # rubocop:disable Metrics/ClassLength + def operation_method(operation) + operation_index(operation)[:http_method] + end + + def operation_path(operation) + operation_index(operation)[:http_path] + end + + def operation_static_query(operation) + operation_index(operation)[:http_static_query] + end + + def header_members(shape) + request_index(shape)[:http_header_members] + end + + def prefix_header_members(shape) + request_index(shape)[:http_prefix_header_members] + end + + def query_members(shape) + request_index(shape)[:http_query_members] + end + + def query_name(member_shape) + member_shape[:http_query_name] ||= member_shape.traits['smithy.api#httpQuery'] || member_shape.name + end + + def query_params_member(shape) + request_index(shape)[:http_query_params_member] + end + + def label_members(shape) + request_index(shape)[:http_label_members] + end + + def payload_member(shape) + request_index(shape)[:http_payload_member] + end + + def payload_type(shape) + request_index(shape)[:http_payload_type] + end + + def payload_content_type(shape) + request_index(shape)[:http_payload_content_type] + end + + def raw_payload?(shape) + payload_type(shape) == :raw + end + + def union_payload?(shape) + payload_type(shape) == :union + end + + def special_payload?(shape) + raw_payload?(shape) || union_payload?(shape) + end + + def body_members(shape) + request_index(shape)[:http_body_members] + end + + def response_header_members(shape) + response_index(shape)[:http_header_members] + end + + def response_prefix_header_members(shape) + response_index(shape)[:http_prefix_header_members] + end + + def response_payload_member(shape) + response_index(shape)[:http_payload_member] + end + + def response_payload_type(shape) + response_index(shape)[:http_payload_type] + end + + def response_raw_payload?(shape) + response_payload_type(shape) == :raw + end + + def response_union_payload?(shape) + response_payload_type(shape) == :union + end + + def response_special_payload?(shape) + response_raw_payload?(shape) || response_union_payload?(shape) + end + + def response_code_member(shape) + response_index(shape)[:http_response_code_member] + end + + private + + def operation_index(operation) + operation[:http_operation_index] ||= build_operation_index(operation) + end + + def request_index(shape) + shape[:http_request_index] ||= build_request_index(shape) + end + + def response_index(shape) + shape[:http_response_index] ||= build_response_index(shape) + end + + def build_operation_index(operation) + http = operation.traits['smithy.api#http'] || {} + uri = http['uri'] || '/' + path, static_query = uri.split('?', 2) + + { + http_method: http['method'] || 'POST', + http_path: path, + http_static_query: static_query, + http_response_code: http['code'] + }.freeze + end + + def build_request_index(shape) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength + index = { + http_header_members: [], + http_prefix_header_members: [], + http_query_members: [], + http_query_params_member: nil, + http_label_members: {}, + http_payload_member: nil, + http_payload_type: nil, + http_payload_content_type: nil, + http_body_members: [] + } + + shape.members.each do |ruby_name, member| + kind, value = request_binding(member) + + case kind + when :header + index[:http_header_members] << [ruby_name, member, value].freeze + when :prefix_header + index[:http_prefix_header_members] << [ruby_name, member, value].freeze + when :query + index[:http_query_members] << [ruby_name, member].freeze + when :query_params + index[:http_query_params_member] = [ruby_name, member].freeze + when :label + index[:http_label_members][member.name] = [ruby_name, member].freeze + when :payload + index[:http_payload_member] = [ruby_name, member].freeze + index[:http_payload_type] = resolve_payload_type(member) + index[:http_payload_content_type] = resolve_payload_content_type(member) + when :body + index[:http_body_members] << [ruby_name, member].freeze + end + end + + index[:http_header_members].freeze + index[:http_prefix_header_members].freeze + index[:http_query_members].freeze + index[:http_label_members].freeze + index[:http_body_members].freeze + index.freeze + end + + def build_response_index(shape) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + index = { + http_header_members: [], + http_prefix_header_members: [], + http_payload_member: nil, + http_payload_type: nil, + http_response_code_member: nil + } + + shape.members.each do |ruby_name, member| + kind, value = response_binding(member) + + case kind + when :header + index[:http_header_members] << [ruby_name, member, value].freeze + when :prefix_header + index[:http_prefix_header_members] << [ruby_name, member, value].freeze + when :response_code + index[:http_response_code_member] = [ruby_name, member].freeze + when :payload + index[:http_payload_member] = [ruby_name, member].freeze + index[:http_payload_type] = resolve_payload_type(member) + end + end + + index[:http_header_members].freeze + index[:http_prefix_header_members].freeze + index.freeze + end + + def request_binding(member) # rubocop:disable Metrics/CyclomaticComplexity + traits = member.traits + return [:header, traits['smithy.api#httpHeader']] if traits.key?('smithy.api#httpHeader') + return [:prefix_header, traits['smithy.api#httpPrefixHeaders']] if traits.key?('smithy.api#httpPrefixHeaders') + + if traits.key?('smithy.api#httpQuery') + query_name(member) + return [:query, nil] + end + + return [:query_params, nil] if traits.key?('smithy.api#httpQueryParams') + return [:label, nil] if traits.key?('smithy.api#httpLabel') + return [:payload, nil] if traits.key?('smithy.api#httpPayload') + # Leave response-code members out of the request/body fallback. + # Staging stub generation reuses the request-side serializers against + # output shapes, so these must not be classified as body members. + return [:ignore, nil] if traits.key?('smithy.api#httpResponseCode') + + [:body, nil] + end + + def response_binding(member) + traits = member.traits + return [:header, traits['smithy.api#httpHeader']] if traits.key?('smithy.api#httpHeader') + + return [:prefix_header, traits['smithy.api#httpPrefixHeaders']] if traits.key?('smithy.api#httpPrefixHeaders') + return [:response_code, nil] if traits.key?('smithy.api#httpResponseCode') + return [:payload, nil] if traits.key?('smithy.api#httpPayload') + + [:ignore, nil] + end + + def resolve_payload_type(member_shape) + case member_shape.target + when Smithy::Schema::Shapes::StringShape, + Smithy::Schema::Shapes::BlobShape, + Smithy::Schema::Shapes::EnumShape + :raw + when Smithy::Schema::Shapes::UnionShape then :union + else :default + end + end + + def resolve_payload_content_type(member_shape) + payload = member_shape.target + media_type = Schema::Extension.media_type(payload) + return media_type if media_type + + case payload + when Smithy::Schema::Shapes::BlobShape + 'application/octet-stream' + when Smithy::Schema::Shapes::StringShape, + Smithy::Schema::Shapes::EnumShape + 'text/plain' + end + end + end + 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 ea2d71cb3..9b5152d4b 100644 --- a/gems/smithy-client/lib/smithy-client/param_converter.rb +++ b/gems/smithy-client/lib/smithy-client/param_converter.rb @@ -40,7 +40,8 @@ def c(shape, value) end def convert_shape(shape, value) - case shape.target + target = shape.target + case target when ListShape then list(shape, value) when MapShape then map(shape, value) when StructureShape then structure(shape, value) @@ -53,15 +54,20 @@ def list(shape, values) values = c(shape, values) return values unless values.is_a?(Array) - values.collect { |v| convert_shape(shape.target.member, v) } + target = shape.target + member = target.member + values.collect { |v| convert_shape(member, v) } end def map(shape, values) values = c(shape, values) return values unless values.is_a?(Hash) + target = shape.target + key_shape = target.key + value_shape = target.value values.each.with_object({}) do |(key, value), hash| - hash[convert_shape(shape.target.key, key)] = convert_shape(shape.target.value, value) + hash[convert_shape(key_shape, key)] = convert_shape(value_shape, value) end end @@ -69,24 +75,30 @@ def structure(shape, values) values = c(shape, values) return values unless values.respond_to?(:each_pair) + target = shape.target + index = Schema::Extension.member_index(target) values.each_pair do |k, v| next if v.nil? - next unless shape.target.member?(k) - values[k] = convert_shape(shape.target.member(k), v) + entry = index[k] + next unless entry + + values[k] = convert_shape(entry[1], v) end values end def union(shape, values) values = c(shape, values) + target = shape.target if values.is_a?(Schema::Union) - _name, member_shape = shape.target.member_by_type(values.class) + _name, member_shape = target.member_by_type(values.class) values = convert_shape(member_shape, values) elsif values.is_a?(Hash) key, value = values.first - values[key] = convert_shape(shape.target.member(key), value) + entry = Schema::Extension.member_index(target)[key] + values[key] = convert_shape(entry[1], value) if entry end values end diff --git a/gems/smithy-client/lib/smithy-client/param_validator.rb b/gems/smithy-client/lib/smithy-client/param_validator.rb index 8ade44877..6f454d535 100644 --- a/gems/smithy-client/lib/smithy-client/param_validator.rb +++ b/gems/smithy-client/lib/smithy-client/param_validator.rb @@ -9,6 +9,7 @@ class ParamValidator include Smithy::Schema::Shapes EXPECTED_GOT = 'expected %s to be %s, got class %s instead.' + DOCUMENT_TYPES = [Hash, Array, Numeric, String, TrueClass, FalseClass, NilClass].freeze def initialize(shape, validate_required: true) @shape = shape @@ -29,7 +30,8 @@ def validate!(params, context: 'params') # rubocop:disable-next Metrics def validate_shape(shape, value, errors, context) - case shape.target + target = shape.target + case target when StructureShape then structure(shape, value, errors, context) when ListShape then list(shape, value, errors, context) when MapShape then map(shape, value, errors, context) @@ -69,9 +71,8 @@ def validate_shape(shape, value, errors, context) end def document(shape, value, errors, context) - document_types = [Hash, Array, Numeric, String, TrueClass, FalseClass, NilClass] - unless document_types.any? { |t| value.is_a?(t) } - errors << expected_got(context, "one of #{document_types.join(', ')}", value) + unless DOCUMENT_TYPES.any? { |t| value.is_a?(t) } + errors << expected_got(context, "one of #{DOCUMENT_TYPES.join(', ')}", value) end case value @@ -92,10 +93,12 @@ def list(shape, values, errors, context) return end + target = shape.target + member = target.member values.each.with_index do |value, index| next unless value - validate_shape(shape.target.member, value, errors, context + "[#{index}]") + validate_shape(member, value, errors, context + "[#{index}]") end end @@ -105,17 +108,21 @@ def map(shape, values, errors, context) return end + target = shape.target + key_shape = target.key + value_shape = target.value values.each do |key, value| - validate_shape(shape.target.key, key, errors, "#{context} #{key.inspect} key") + validate_shape(key_shape, key, errors, "#{context} #{key.inspect} key") next unless value - validate_shape(shape.target.value, value, errors, context + "[#{key.inspect}]") + validate_shape(value_shape, value, errors, context + "[#{key.inspect}]") end end def member(shape, name, value, errors, context) - if shape.target.member?(name) - member_shape = shape.target.member(name) + entry = Schema::Extension.member_index(shape.target)[name] + if entry + member_shape = entry[1] validate_shape(member_shape, value, errors, context + "[#{name.inspect}]") else errors << "unexpected value at #{context}[#{name.inspect}]" @@ -123,7 +130,8 @@ def member(shape, name, value, errors, context) end def structure(shape, values, errors, context) - return if shape.target == Prelude::Unit + target = shape.target + return if target == Prelude::Unit return unless valid_structure?(shape, values, errors, context) validate_required_members(shape, values, errors, context) if @validate_required @@ -135,7 +143,8 @@ def structure(shape, values, errors, context) end def valid_structure?(shape, values, errors, context) - if !values.is_a?(Hash) && !values.is_a?(shape.target.type) + target = shape.target + if !values.is_a?(Hash) && !values.is_a?(target.type) errors << expected_got(context, 'a Hash', values) return false end @@ -146,8 +155,9 @@ def valid_structure?(shape, values, errors, context) def union(shape, values, errors, context) return unless valid_union?(shape, values, errors, context) + target = shape.target if values.is_a?(Schema::Union) - _name, member_shape = shape.target.member_by_type(values.class) + _name, member_shape = target.member_by_type(values.class) validate_shape(member_shape, values.value, errors, context) elsif values.is_a?(Hash) values.each_pair do |name, value| @@ -159,7 +169,8 @@ def union(shape, values, errors, context) end def valid_union?(shape, values, errors, context) - return true if values.is_a?(shape.target.type) + target = shape.target + return true if values.is_a?(target.type) unless values.is_a?(Hash) errors << expected_got(context, 'a Hash', values) @@ -167,26 +178,23 @@ def valid_union?(shape, values, errors, context) end return true if values.size <= 1 - union_members = shape.target.members.keys.join(', ') + union_members = target.members.keys.join(', ') error = "expected #{context} to be a Hash with one of #{union_members}, got #{values.size} keys instead." errors << error false end def validate_required_members(shape, values, errors, context) - shape.target.members.each do |name, member_shape| - traits = member_shape.traits - next unless traits.key?('smithy.api#required') && !traits.key?('smithy.api#clientOptional') + target = shape.target + Schema::Extension.required_members(target).each do |name| + next unless values[name].nil? - if values[name].nil? - param = "#{context}[#{name.inspect}]" - errors << "missing required parameter #{param}" - end + errors << "missing required parameter #{context}[#{name.inspect}]" end end def streaming_input?(shape) - shape.target.traits.key?('smithy.api#streaming') + Schema::Extension.streaming?(shape.target) end def io_like?(value, require_size: false) diff --git a/gems/smithy-client/lib/smithy-client/plugins/checksum_required.rb b/gems/smithy-client/lib/smithy-client/plugins/checksum_required.rb index fa16f5473..8685ea411 100644 --- a/gems/smithy-client/lib/smithy-client/plugins/checksum_required.rb +++ b/gems/smithy-client/lib/smithy-client/plugins/checksum_required.rb @@ -26,7 +26,7 @@ def call(context) private def checksum_required_operation?(context) - context.operation.traits.key?('smithy.api#httpChecksumRequired') + Smithy::Schema::Extension.checksum_required?(context.operation) end def md5(value) diff --git a/gems/smithy-client/lib/smithy-client/plugins/host_prefix.rb b/gems/smithy-client/lib/smithy-client/plugins/host_prefix.rb index b87f33304..adc9633e2 100644 --- a/gems/smithy-client/lib/smithy-client/plugins/host_prefix.rb +++ b/gems/smithy-client/lib/smithy-client/plugins/host_prefix.rb @@ -33,34 +33,34 @@ def add_handlers(handlers, config) # @api private class Handler < Smithy::Client::Handler def call(context) - host_prefix = context.operation.traits.dig('smithy.api#endpoint', 'hostPrefix') + host_prefix = Schema::Extension.endpoint_host_prefix(context.operation) apply_host_prefix(context, host_prefix) if host_prefix @handler.call(context) end private - # TODO: optimize this to collect all labels in one pass def apply_host_prefix(context, host_prefix) - input = context.operation.input - prefix = host_prefix.gsub(/\{.+?}/) do |label| - label_value(input, label.delete('{}'), context.params) - end + prefix = + if host_prefix.include?('{') + input = context.operation.input + host_prefix.gsub(/\{.+?}/) do |label| + label_value(input, label.delete('{}'), context.params) + end + else + host_prefix + end context.http_request.endpoint.host = prefix + context.http_request.endpoint.host end def label_value(input, label, params) - name = nil - input.members.each do |member_name, member_shape| - next unless member_shape.traits.key?('smithy.api#hostLabel') - next unless member_shape.name == label - - name = member_name - end + name = Schema::Extension.host_label_index(input)[label] raise ArgumentError, "#{label} is not a valid host label" if name.nil? - raise ArgumentError, "params[:#{name}] must not be nil or blank" if params[name].nil? || params[name].empty? - params[name] + value = params[name] + raise ArgumentError, "params[:#{name}] must not be nil or blank" if value.nil? || value.empty? + + value end end end diff --git a/gems/smithy-client/lib/smithy-client/plugins/idempotency_token.rb b/gems/smithy-client/lib/smithy-client/plugins/idempotency_token.rb index 1fc7989f7..546492aa2 100644 --- a/gems/smithy-client/lib/smithy-client/plugins/idempotency_token.rb +++ b/gems/smithy-client/lib/smithy-client/plugins/idempotency_token.rb @@ -21,11 +21,10 @@ def call(context) private def apply_idempotency_token(input, params) - input.members.each do |member_name, member_shape| - next unless member_shape.traits.key?('smithy.api#idempotencyToken') + member_name = Schema::Extension.idempotency_token_member(input) + return unless member_name - params[member_name] ||= SecureRandom.uuid - end + params[member_name] ||= SecureRandom.uuid end end end diff --git a/gems/smithy-client/lib/smithy-client/plugins/request_compression.rb b/gems/smithy-client/lib/smithy-client/plugins/request_compression.rb index 6e4d23573..9e8b5b913 100644 --- a/gems/smithy-client/lib/smithy-client/plugins/request_compression.rb +++ b/gems/smithy-client/lib/smithy-client/plugins/request_compression.rb @@ -67,14 +67,12 @@ def add_handlers(handlers, config) # @api private class Handler < Client::Handler def call(context) - if request_compression_trait?(context) - selected_encoding = request_encoding_selection(context) - if selected_encoding - if streaming?(context.operation.input) - process_streaming_compression(selected_encoding, context) - elsif context.http_request.body.size >= context.config.request_min_compression_size_bytes - process_compression(selected_encoding, context) - end + selected_encoding = request_encoding_selection(context.operation) + if selected_encoding + if Schema::Extension.streaming_member_without_length(context.operation.input) + process_streaming_compression(selected_encoding, context) + elsif context.http_request.body.size >= context.config.request_min_compression_size_bytes + process_compression(selected_encoding, context) end end track_feature(selected_encoding) { @handler.call(context) } @@ -82,20 +80,9 @@ def call(context) private - def request_compression_trait?(context) - context.operation.traits.key?('smithy.api#requestCompression') - end - - def request_encoding_selection(context) - encodings = context.operation.traits['smithy.api#requestCompression']['encodings'] - encodings.find { |encoding| RequestCompression::SUPPORTED_ENCODINGS.include?(encoding) } - end - - def streaming?(input) - input.members.any? do |_, member_shape| - member_shape.target.traits.key?('smithy.api#streaming') && - !member_shape.target.traits.key?('smithy.api#requiresLength') - end + def request_encoding_selection(operation) + encodings = Schema::Extension.request_compression_encodings(operation) + encodings&.find { |encoding| RequestCompression::SUPPORTED_ENCODINGS.include?(encoding) } end def process_streaming_compression(encoding, context) @@ -167,12 +154,7 @@ def initialize(body) end def read(length, buff = nil) - if @gzip_writer.closed? - # an empty string to signify an end as - # there will be nothing remaining to be read - StringIO.new('').read(length, buff) - return - end + return read_buffer(length, buff, '') if @gzip_writer.closed? chunk = @body.read(length) if !chunk || chunk.empty? @@ -186,7 +168,17 @@ def read(length, buff = nil) @gzip_writer.write(chunk) end - StringIO.new(@buffer.last_chunk).read(length, buff) + read_buffer(length, buff, @buffer.last_chunk) + end + + private + + def read_buffer(length, buff, value) + value ||= '' + value = value.byteslice(0, length) if length + return value unless buff + + buff.replace(value) end end diff --git a/gems/smithy-client/lib/smithy-client/plugins/retry_errors.rb b/gems/smithy-client/lib/smithy-client/plugins/retry_errors.rb index 01730a060..982776acc 100644 --- a/gems/smithy-client/lib/smithy-client/plugins/retry_errors.rb +++ b/gems/smithy-client/lib/smithy-client/plugins/retry_errors.rb @@ -151,9 +151,8 @@ def reset_response(context, response) response.error = nil end - # TODO: Revisit after trait is finalized. def long_polling_operation?(context) - context.operation.traits.key?('smithy.api#longPoll') + Schema::Extension.long_polling?(context.operation) end def track_feature(retry_strategy, &block) diff --git a/gems/smithy-client/lib/smithy-client/plugins/transfer_encoding.rb b/gems/smithy-client/lib/smithy-client/plugins/transfer_encoding.rb index 7c34ba8ee..edf0717de 100644 --- a/gems/smithy-client/lib/smithy-client/plugins/transfer_encoding.rb +++ b/gems/smithy-client/lib/smithy-client/plugins/transfer_encoding.rb @@ -11,8 +11,8 @@ class TransferEncoding < Plugin # @api private class Handler < Client::Handler def call(context) - payload_member = streaming_member(context) - # Proceed with TE header logic IFF there's a streaming payload. + payload_member = Schema::Extension.streaming_member(context.operation.input) + # Proceed with TE header logic when the input models a streaming member. apply_transfer_encoding(context, payload_member.target) if payload_member @handler.call(context) end @@ -21,26 +21,12 @@ def call(context) def apply_transfer_encoding(context, payload_shape) return if context.http_request.body.respond_to?(:size) - if requires_length?(payload_shape) + if Schema::Extension.requires_length?(payload_shape) raise Smithy::Client::Errors::MissingContentLength - elsif unsigned_payload?(context) + elsif Schema::Extension.unsigned_payload?(context.operation) context.http_request.headers['Transfer-Encoding'] = 'chunked' end end - - def streaming_member(context) - context.operation.input.members.detect do |_, member_shape| - member_shape.target.traits.key?('smithy.api#streaming') - end&.last - end - - def requires_length?(shape) - shape.traits.key?('smithy.api#requiresLength') - end - - def unsigned_payload?(context) - context.operation.traits.key?('aws.auth#unsignedPayload') - end end handler(Handler, step: :sign) diff --git a/gems/smithy-client/lib/smithy-client/rpc_v2_cbor.rb b/gems/smithy-client/lib/smithy-client/rpc_v2_cbor.rb index e6a61b0ba..3c7d4e96f 100644 --- a/gems/smithy-client/lib/smithy-client/rpc_v2_cbor.rb +++ b/gems/smithy-client/lib/smithy-client/rpc_v2_cbor.rb @@ -84,7 +84,7 @@ def apply_headers(context) def apply_content_type_header(context) input = context.operation.input content_type = - if event_stream?(input) + if Schema::Extension.event_streaming?(input) 'application/vnd.amazon.eventstream' elsif input != Schema::Shapes::Prelude::Unit 'application/cbor' @@ -95,7 +95,7 @@ def apply_content_type_header(context) def apply_accept_header(context) accept = - if event_stream?(context.operation.output) + if Schema::Extension.event_streaming?(context.operation.output) 'application/vnd.amazon.eventstream' else 'application/cbor' @@ -114,14 +114,6 @@ def apply_url_path(context) 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'] @@ -150,19 +142,21 @@ def extract_error(body, context) 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 + err_shape = Schema::Extension.error_index(context.operation)[code] + return Schema::EmptyStructure.new unless err_shape - data = @codec.parse(err_shape, body, err_shape.type.new) - end - data + @codec.parse(err_shape, body, err_shape.type.new) end def error_code(context, data) code = data['__type'] code ||= http_status_error_code(context) - code.split('#').last.split('$').first + + start = code.rindex('#') + start = start ? start + 1 : 0 + + finish = code.index('$', start) + finish ? code[start...finish] : code[start..] end def build_error(context, code, data) diff --git a/gems/smithy-client/lib/smithy-client/util.rb b/gems/smithy-client/lib/smithy-client/util.rb index b8968b148..ddb81b49e 100644 --- a/gems/smithy-client/lib/smithy-client/util.rb +++ b/gems/smithy-client/lib/smithy-client/util.rb @@ -1,15 +1,28 @@ # frozen_string_literal: true +require 'cgi/escape' +require 'cgi/util' if RUBY_VERSION < '3.5' + module Smithy module Client # @api private module Util def self.str_to_bool(str) - case str + case str.to_s when 'true' then true when 'false' then false end end + + # CGI.escape handles UTF-8 encoding for us, then we normalize to the + # RFC 3986 form expected by Smithy without paying for rewrite passes + # when the escaped value does not contain the affected bytes. + def self.escape(value) + encoded = CGI.escape(value.encode('UTF-8')) + encoded = encoded.gsub('+', '%20') if encoded.include?('+') + encoded = encoded.gsub('%7E', '~') if encoded.include?('%7E') + encoded + end end end end diff --git a/gems/smithy-client/spec/smithy-client/http_binding_spec.rb b/gems/smithy-client/spec/smithy-client/http_binding_spec.rb new file mode 100644 index 000000000..1f44b230a --- /dev/null +++ b/gems/smithy-client/spec/smithy-client/http_binding_spec.rb @@ -0,0 +1,176 @@ +# frozen_string_literal: true + +require_relative '../spec_helper' + +module Smithy + module Client + describe HttpBinding do + let(:string_shape) { Schema::Shapes::StringShape.new } + let(:structure_shape) { Schema::Shapes::StructureShape.new } + let(:union_shape) { Schema::Shapes::UnionShape.new } + + describe 'operation accessors' do + it 'resolves method, path, and static query data from the http trait' do + operation = Schema::Shapes::OperationShape.new( + traits: { + 'smithy.api#http' => { + 'method' => 'PUT', + 'uri' => '/{Bucket}/{Key+}?uploads=true', + 'code' => 204 + } + } + ) + + expect(described_class.operation_method(operation)).to eq('PUT') + expect(described_class.operation_path(operation)).to eq('/{Bucket}/{Key+}') + expect(described_class.operation_static_query(operation)).to eq('uploads=true') + end + end + + describe 'request accessors' do + it 'resolves request-side HTTP routing' do + shape = Schema::Shapes::StructureShape.new + header_member = Schema::Shapes::MemberShape.new( + target: string_shape, + name: 'Checksum', + traits: { 'smithy.api#httpHeader' => 'x-amz-checksum' } + ) + prefix_member = Schema::Shapes::MemberShape.new( + target: Schema::Shapes::MapShape.new, + name: 'Metadata', + traits: { 'smithy.api#httpPrefixHeaders' => 'x-amz-meta-' } + ) + query_member = Schema::Shapes::MemberShape.new( + target: string_shape, + name: 'Mode', + traits: { 'smithy.api#httpQuery' => 'mode' } + ) + label_member = Schema::Shapes::MemberShape.new( + target: string_shape, + name: 'TimestampLabel', + traits: { 'smithy.api#httpLabel' => {} } + ) + payload_member = Schema::Shapes::MemberShape.new( + target: string_shape, + name: 'Body', + traits: { 'smithy.api#httpPayload' => {} } + ) + document_member = Schema::Shapes::MemberShape.new( + target: string_shape, + name: 'Acl' + ) + shape.add_member(:checksum, header_member) + shape.add_member(:metadata, prefix_member) + shape.add_member(:mode, query_member) + shape.add_member(:timestamp_label, label_member) + shape.add_member(:body, payload_member) + shape.add_member(:acl, document_member) + + expect(described_class.header_members(shape)).to eq([[:checksum, header_member, 'x-amz-checksum']]) + expect(described_class.prefix_header_members(shape)).to eq([[:metadata, prefix_member, 'x-amz-meta-']]) + expect(described_class.query_members(shape)).to eq([[:mode, query_member]]) + expect(described_class.query_name(query_member)).to eq('mode') + expect(described_class.label_members(shape)).to eq( + 'TimestampLabel' => [:timestamp_label, label_member] + ) + expect(described_class.payload_member(shape)).to eq([:body, payload_member]) + expect(described_class.payload_type(shape)).to eq(:raw) + expect(described_class.payload_content_type(shape)).to eq('text/plain') + expect(described_class.raw_payload?(shape)).to be(true) + expect(described_class.special_payload?(shape)).to be(true) + expect(described_class.body_members(shape)).to eq([[:acl, document_member]]) + end + + it 'categorizes default and union payload members' do + default_shape = Schema::Shapes::StructureShape.new + default_shape.add_member( + :body, + Schema::Shapes::MemberShape.new( + target: structure_shape, + name: 'Body', + traits: { 'smithy.api#httpPayload' => {} } + ) + ) + + union_payload_shape = Schema::Shapes::StructureShape.new + union_payload_shape.add_member( + :body, + Schema::Shapes::MemberShape.new( + target: union_shape, + name: 'Body', + traits: { 'smithy.api#httpPayload' => {} } + ) + ) + + expect(described_class.payload_type(default_shape)).to eq(:default) + expect(described_class.payload_content_type(default_shape)).to be_nil + expect(described_class.special_payload?(default_shape)).to be(false) + expect(described_class.payload_type(union_payload_shape)).to eq(:union) + expect(described_class.payload_content_type(union_payload_shape)).to be_nil + expect(described_class.union_payload?(union_payload_shape)).to be(true) + expect(described_class.special_payload?(union_payload_shape)).to be(true) + end + + it 'prefers payload media types over inferred scalar content types' do + shape = Schema::Shapes::StructureShape.new + payload_shape = Schema::Shapes::StringShape.new( + traits: { 'smithy.api#mediaType' => 'application/custom' } + ) + shape.add_member( + :body, + Schema::Shapes::MemberShape.new( + target: payload_shape, + name: 'Body', + traits: { 'smithy.api#httpPayload' => {} } + ) + ) + + expect(described_class.payload_content_type(shape)).to eq('application/custom') + end + end + + describe 'response accessors' do + it 'resolves response-side HTTP routing' do + shape = Schema::Shapes::StructureShape.new + header_member = Schema::Shapes::MemberShape.new( + target: string_shape, + name: 'LastModified', + traits: { 'smithy.api#httpHeader' => 'Last-Modified' } + ) + prefix_member = Schema::Shapes::MemberShape.new( + target: Schema::Shapes::MapShape.new, + name: 'Metadata', + traits: { 'smithy.api#httpPrefixHeaders' => 'x-amz-meta-' } + ) + response_code_member = Schema::Shapes::MemberShape.new( + target: Schema::Shapes::IntegerShape.new, + name: 'StatusCode', + traits: { 'smithy.api#httpResponseCode' => {} } + ) + payload_member = Schema::Shapes::MemberShape.new( + target: string_shape, + name: 'Body', + traits: { 'smithy.api#httpPayload' => {} } + ) + + shape.add_member(:last_modified, header_member) + shape.add_member(:metadata, prefix_member) + shape.add_member(:status_code, response_code_member) + shape.add_member(:body, payload_member) + + expect(described_class.response_header_members(shape)).to eq( + [[:last_modified, header_member, 'Last-Modified']] + ) + expect(described_class.response_prefix_header_members(shape)).to eq( + [[:metadata, prefix_member, 'x-amz-meta-']] + ) + expect(described_class.response_code_member(shape)).to eq([:status_code, response_code_member]) + expect(described_class.response_payload_member(shape)).to eq([:body, payload_member]) + expect(described_class.response_payload_type(shape)).to eq(:raw) + expect(described_class.response_raw_payload?(shape)).to be(true) + expect(described_class.response_special_payload?(shape)).to be(true) + end + end + end + end +end diff --git a/gems/smithy-client/spec/smithy-client/plugins/request_compression_spec.rb b/gems/smithy-client/spec/smithy-client/plugins/request_compression_spec.rb index eb16934cd..4c0681efd 100644 --- a/gems/smithy-client/spec/smithy-client/plugins/request_compression_spec.rb +++ b/gems/smithy-client/spec/smithy-client/plugins/request_compression_spec.rb @@ -126,6 +126,7 @@ module Plugins end) client.operation(streaming_blob: large_body) end + 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 d486949f2..bcddaf29f 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 @@ -51,6 +51,7 @@ def call(context) response = client.operation(streaming_blob: StringIO.new('data')) expect(response.context.http_request.headers['Transfer-Encoding']).to be_nil end + end context 'streaming payload with unknown size' do @@ -74,6 +75,7 @@ def call(context) response = client.operation(streaming_blob: unsizable_body) expect(response.context.http_request.headers['Transfer-Encoding']).to eq('chunked') end + end context 'when signed and no requiresLength' do 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 index a0e279134..63361f5ef 100644 --- a/gems/smithy-client/spec/smithy-client/rpc_v2_cbor_spec.rb +++ b/gems/smithy-client/spec/smithy-client/rpc_v2_cbor_spec.rb @@ -41,6 +41,50 @@ def build_context(http_request: Http::Request.new, http_response: Http::Response expect(context.http_request.body.read).not_to be_empty end end + + it 'uses eventstream content-type and accept headers for streaming union payloads' do + stream_target = Schema::Shapes::UnionShape.new( + traits: { 'smithy.api#streaming' => {} } + ) + input_member = Schema::Shapes::MemberShape.new( + target: stream_target, + name: 'events', + traits: { 'smithy.api#httpPayload' => {} } + ) + output_member = Schema::Shapes::MemberShape.new( + target: stream_target, + name: 'events', + traits: { 'smithy.api#httpPayload' => {} } + ) + operation.input.add_member(:events, input_member) + operation.output.add_member(:events, output_member) + + context = build_context + protocol.build_request(context) + + aggregate_failures do + expect(context.http_request.headers['Content-Type']).to eq('application/vnd.amazon.eventstream') + expect(context.http_request.headers['Accept']).to eq('application/vnd.amazon.eventstream') + end + end + + it 'uses eventstream headers for non-payload streaming union members' do + stream_target = Schema::Shapes::UnionShape.new( + traits: { 'smithy.api#streaming' => {} } + ) + input_member = Schema::Shapes::MemberShape.new(target: stream_target, name: 'events') + output_member = Schema::Shapes::MemberShape.new(target: stream_target, name: 'events') + operation.input.add_member(:events, input_member) + operation.output.add_member(:events, output_member) + + context = build_context + protocol.build_request(context) + + aggregate_failures do + expect(context.http_request.headers['Content-Type']).to eq('application/vnd.amazon.eventstream') + expect(context.http_request.headers['Accept']).to eq('application/vnd.amazon.eventstream') + end + end end describe '#parse_data' do @@ -115,6 +159,19 @@ def response(status_code:, body: '', protocol_header: 'rpc-v2-cbor') end end + it 'extracts the modeled error code when __type includes a member suffix' do + body = Smithy::Cbor.encode('__type' => 'smithy.ruby.tests#Error$member', '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) + aggregate_failures do + expect(error.code).to eq('Error') + expect(error.data.message).to eq('boom') + end + 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), diff --git a/gems/smithy-json/lib/smithy-json/builder.rb b/gems/smithy-json/lib/smithy-json/builder.rb index 8bbf36e70..aa5cb1a4f 100644 --- a/gems/smithy-json/lib/smithy-json/builder.rb +++ b/gems/smithy-json/lib/smithy-json/builder.rb @@ -10,6 +10,7 @@ class Builder def initialize(options = {}) @extension = options[:json_name] ? Smithy::Json::Extension : Smithy::Schema::Extension + @default_timestamp = options.fetch(:default_timestamp, 'epoch-seconds') end def build(shape, data) @@ -50,59 +51,61 @@ def float(value) def list(shape, values) return if values.nil? + member = shape.target.member values.collect do |value| - build_shape(shape.target.member, value) + build_shape(member, value) end end def map(shape, values) return if values.nil? + value_member = shape.target.value values.each.with_object({}) do |(key, value), data| - data[key] = build_shape(shape.target.value, value) + data[key] = build_shape(value_member, value) end end def structure(shape, values) return if values.nil? - members = shape.target.members + index = @extension.member_index(shape.target) values.each_pair.with_object({}) do |(member_name, value), data| next if value.nil? - member_shape = members[member_name] - next unless member_shape + entry = index[member_name] + next unless entry - data[@extension.wire_name(member_shape)] = build_shape(member_shape, value) + wire_name, member_shape = entry + data[wire_name] = build_shape(member_shape, value) end end def timestamp(shape, value) - trait = 'smithy.api#timestampFormat' - case shape.traits[trait] || shape.target.traits[trait] + format = Smithy::Schema::Extension.timestamp_format(shape) + format = @default_timestamp if format == :default + + case format when 'date-time' then value.utc.iso8601 when 'http-date' then value.utc.httpdate + when 'epoch-seconds' then value.to_i else - # default to epoch-seconds - value.to_i + raise ArgumentError, "unsupported JSON timestamp format: #{format.inspect}" end end - def union(shape, values) # rubocop:disable Metrics/AbcSize + def union(shape, values) return if values.nil? - data = {} if values.is_a?(Schema::Union) - _name, member_shape = shape.target.member_by_type(values.class) - data[@extension.wire_name(member_shape)] = build_shape(member_shape, values.value) + key, value = values.active_member_value else key, value = values.first - if shape.target.member?(key) - member_shape = shape.target.member(key) - data[@extension.wire_name(member_shape)] = build_shape(member_shape, value) - end end - data + member_shape = shape.target.member(key) + return {} unless member_shape + + { @extension.wire_name(member_shape) => build_shape(member_shape, value) } end end end diff --git a/gems/smithy-json/lib/smithy-json/codec.rb b/gems/smithy-json/lib/smithy-json/codec.rb index 0ad2c6d3c..d67a1ea5f 100644 --- a/gems/smithy-json/lib/smithy-json/codec.rb +++ b/gems/smithy-json/lib/smithy-json/codec.rb @@ -6,14 +6,15 @@ module Json class Codec # @param [Hash] options def initialize(options = {}) - @options = options + @builder = Builder.new(options) + @parser = Parser.new(options) end # @param [Shape] shape # @param [Object] data # @return [String, nil] def build(shape, data) - Builder.new(@options).build(shape, data) + @builder.build(shape, data) end # @param [Shape] shape @@ -21,7 +22,7 @@ def build(shape, data) # @param [Object, nil] result (nil) # @return [Object, nil] def parse(shape, bytes, result = nil) - Parser.new(@options).parse(shape, bytes, result) + @parser.parse(shape, bytes, result) end end end diff --git a/gems/smithy-json/lib/smithy-json/extension.rb b/gems/smithy-json/lib/smithy-json/extension.rb index b2ad6b4c7..2f889a694 100644 --- a/gems/smithy-json/lib/smithy-json/extension.rb +++ b/gems/smithy-json/lib/smithy-json/extension.rb @@ -9,34 +9,40 @@ module Json # module resolves JSON-specific serde behavior on demand and stores the # resolved values in metadata: # - +member[:json_name]+ caches the resolved JSON wire name for a member - # - +shape[:json_index]+ caches the JSON wire-name lookup index for a shape + # - +shape[:json_wire_index]+ caches the JSON wire-name lookup index for a shape + # - +shape[:json_member_index]+ caches the JSON build lookup index for a shape # @api private module Extension - extend Smithy::Schema::ExtensionHelpers - class << self # Returns the JSON member lookup index cached on the shape as - # +shape[:json_index]+. + # +shape[:json_wire_index]+. # # The index maps: # - resolved JSON wire name # - to [ruby_member_name, member_shape] + def wire_index(shape) + shape[:json_wire_index] ||= build_wire_index(shape) + end + + # Returns the JSON build lookup index cached on the shape as + # +shape[:json_member_index]+. + # + # The index maps: + # - ruby member name + # - to [resolved JSON wire name, member_shape] def member_index(shape) - shape[:json_index] ||= build_member_index(shape) + shape[:json_member_index] ||= build_member_index(shape) end # Returns the resolved JSON wire name for the member, cached as # +member[:json_name]+ and preferring the Smithy @jsonName trait. def wire_name(member) - cached = member[:json_name] - return cached unless cached.nil? - - member[:json_name] = member.traits['smithy.api#jsonName'] || member.name + member[:json_name] ||= member.traits['smithy.api#jsonName'] || member.name end private - def build_member_index(shape) + def build_wire_index(shape) index = {} shape.members.each do |name, member| wire_name = wire_name(member) @@ -46,6 +52,17 @@ def build_member_index(shape) end index.freeze end + + def build_member_index(shape) + index = {} + shape.members.each do |name, member| + wire_name = wire_name(member) + next unless wire_name + + index[name] = [wire_name, member] + end + index.freeze + end end end end diff --git a/gems/smithy-json/lib/smithy-json/parser.rb b/gems/smithy-json/lib/smithy-json/parser.rb index 3dc25ada2..1963f6e5f 100644 --- a/gems/smithy-json/lib/smithy-json/parser.rb +++ b/gems/smithy-json/lib/smithy-json/parser.rb @@ -21,6 +21,8 @@ def parse(shape, bytes, result = nil) private def parse_shape(shape, value, result = nil) # rubocop:disable Metrics/CyclomaticComplexity + return if value.nil? + case shape.target when BlobShape then Base64.decode64(value) when FloatShape then float(value) @@ -45,21 +47,27 @@ def float(value) def list(shape, values, result = nil) return if values.nil? + member = shape.target.member + sparse = Smithy::Schema::Extension.sparse?(shape.target) result = [] if result.nil? values.each do |value| - next if value.nil? && !sparse?(shape.target) + next if value.nil? && !sparse - result << parse_shape(shape.target.member, value) + result << parse_shape(member, value) end result end def map(shape, values, result = nil) + return if values.nil? + + value_member = shape.target.value + sparse = Smithy::Schema::Extension.sparse?(shape.target) result = {} if result.nil? values.each do |key, value| - next if value.nil? && !sparse?(shape.target) + next if value.nil? && !sparse - result[key] = parse_shape(shape.target.value, value) + result[key] = parse_shape(value_member, value) end result end @@ -68,7 +76,7 @@ def structure(shape, values, result = nil) return if values.nil? result = shape.target.type.new if result.nil? - index = @extension.member_index(shape.target) + index = @extension.wire_index(shape.target) values.each do |wire_name, value| next if value.nil? @@ -95,7 +103,7 @@ def timestamp(value) end def union(shape, values, result = nil) # rubocop:disable Metrics/AbcSize - index = @extension.member_index(shape.target) + index = @extension.wire_index(shape.target) values.each do |wire_name, value| next if value.nil? @@ -103,7 +111,7 @@ def union(shape, values, result = nil) # rubocop:disable Metrics/AbcSize next unless entry member_name, member_shape = entry - result = shape.target.member_type(member_name) if result.nil? + result = shape.target.member_type(member_name) if result.nil? # cache ahead return result.new(member_name => parse_shape(member_shape, value)) end @@ -111,10 +119,6 @@ def union(shape, values, result = nil) # rubocop:disable Metrics/AbcSize key, value = values.first shape.target.member_type(:unknown).new(unknown: { key => value }) end - - def sparse?(shape) - @extension.sparse?(shape) - end end end end diff --git a/gems/smithy-json/spec/smithy-json/builder_spec.rb b/gems/smithy-json/spec/smithy-json/builder_spec.rb index d6adce827..4bc976225 100644 --- a/gems/smithy-json/spec/smithy-json/builder_spec.rb +++ b/gems/smithy-json/spec/smithy-json/builder_spec.rb @@ -143,6 +143,30 @@ module Json bytes = subject.build(structure_shape, data) expect(Json.load(bytes)).to eq('union' => { 'NewString' => 'string' }) end + + it 'builds typed union members with jsonName' do + subject = described_class.new(json_name: true) + shapes['smithy.ruby.tests#Union']['members']['string'] = { + 'target' => 'smithy.api#String', + 'traits' => { 'smithy.api#jsonName' => 'NewString' } + } + union = structure_shape.member(:union).target.member_type(:string).new(string: 'string') + type = structure_shape.type.new(union: union) + bytes = subject.build(structure_shape, type) + expect(Json.load(bytes)).to eq('union' => { 'NewString' => 'string' }) + end + + it 'builds base union objects with jsonName' do + subject = described_class.new(json_name: true) + shapes['smithy.ruby.tests#Union']['members']['string'] = { + 'target' => 'smithy.api#String', + 'traits' => { 'smithy.api#jsonName' => 'NewString' } + } + union = structure_shape.member(:union).target.type.new(string: 'string') + type = structure_shape.type.new(union: union) + bytes = subject.build(structure_shape, type) + expect(Json.load(bytes)).to eq('union' => { 'NewString' => 'string' }) + end end context 'lists' do @@ -220,6 +244,33 @@ module Json bytes = subject.build(structure_shape, data) expect(Json.load(bytes)).to eq({ 'timestamp' => time.utc.httpdate }) end + + it 'uses the configured default timestamp when the model does not override it' do + subject = described_class.new(default_timestamp: 'date-time') + time = Time.now.utc + data = { timestamp: time } + bytes = subject.build(structure_shape, data) + expect(Json.load(bytes)).to eq({ 'timestamp' => time.utc.iso8601 }) + end + + it 'still prefers the modeled timestamp format over the configured default' do + subject = described_class.new(default_timestamp: 'epoch-seconds') + time = Time.now.utc + shapes['smithy.ruby.tests#Structure']['members']['timestamp']['traits'] = { + 'smithy.api#timestampFormat' => 'http-date' + } + data = { timestamp: time } + bytes = subject.build(structure_shape, data) + expect(Json.load(bytes)).to eq({ 'timestamp' => time.utc.httpdate }) + end + + it 'raises for unsupported timestamp formats' do + subject = described_class.new(default_timestamp: 'bogus-format') + time = Time.now.utc + + expect { subject.build(structure_shape, { timestamp: time }) } + .to raise_error(ArgumentError, /unsupported JSON timestamp format/) + end end end end diff --git a/gems/smithy-json/spec/smithy-json/codec_spec.rb b/gems/smithy-json/spec/smithy-json/codec_spec.rb new file mode 100644 index 000000000..4e9d1b76d --- /dev/null +++ b/gems/smithy-json/spec/smithy-json/codec_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require_relative '../spec_helper' + +module Smithy + module Json + describe Codec do + let(:shapes) { SchemaHelper.sample_shapes } + let(:sample_schema) { SchemaHelper.sample_schema(shapes: shapes) } + let(:structure_shape) { sample_schema.const_get(:Structure) } + + describe '#build' do + it 'reuses the same codec instance across build calls without leaking builder state' do + codec = described_class.new + + first = codec.build(structure_shape, { string: 'first' }) + second = codec.build(structure_shape, { integer: 123 }) + + expect(Smithy::Json.load(first)).to eq('string' => 'first') + expect(Smithy::Json.load(second)).to eq('integer' => 123) + end + end + + describe '#parse' do + it 'reuses the same codec instance across parse calls' do + codec = described_class.new + + first = codec.parse(structure_shape, '{"string":"first"}') + second = codec.parse(structure_shape, '{"integer":123}') + + expect(first.to_h).to eq(string: 'first') + expect(second.to_h).to eq(integer: 123) + end + end + end + end +end diff --git a/gems/smithy-json/spec/smithy-json/extension_spec.rb b/gems/smithy-json/spec/smithy-json/extension_spec.rb index feff27da2..bc61792fb 100644 --- a/gems/smithy-json/spec/smithy-json/extension_spec.rb +++ b/gems/smithy-json/spec/smithy-json/extension_spec.rb @@ -19,19 +19,31 @@ module Json name: 'plainName' ) end - let(:sparse_shape) do - Schema::Shapes::ListShape.new(traits: { 'smithy.api#sparse' => {} }) + describe '.wire_index' do + it 'indexes members by jsonName when present' do + shape = Schema::Shapes::StructureShape.new + shape.add_member(:plain_name, plain_member) + shape.add_member(:json_named, json_named_member) + + expect(described_class.wire_index(shape)).to eq( + 'plainName' => [:plain_name, plain_member], + 'wireName' => [:json_named, json_named_member] + ) + expect(described_class.wire_index(shape)).to be_frozen + expect(plain_member[:json_name]).to eq('plainName') + expect(json_named_member[:json_name]).to eq('wireName') + end end describe '.member_index' do - it 'indexes members by jsonName when present' do + it 'indexes members by Ruby member name with resolved wire names' do shape = Schema::Shapes::StructureShape.new shape.add_member(:plain_name, plain_member) shape.add_member(:json_named, json_named_member) expect(described_class.member_index(shape)).to eq( - 'plainName' => [:plain_name, plain_member], - 'wireName' => [:json_named, json_named_member] + plain_name: ['plainName', plain_member], + json_named: ['wireName', json_named_member] ) expect(described_class.member_index(shape)).to be_frozen expect(plain_member[:json_name]).to eq('plainName') @@ -50,12 +62,6 @@ module Json expect(plain_member[:json_name]).to eq('plainName') end end - - describe '.sparse?' do - it 'uses the shared generic sparse helper' do - expect(described_class.sparse?(sparse_shape)).to be(true) - end - end end end end diff --git a/gems/smithy-json/spec/smithy-json/parser_spec.rb b/gems/smithy-json/spec/smithy-json/parser_spec.rb index ac8ea756d..5fad3aa48 100644 --- a/gems/smithy-json/spec/smithy-json/parser_spec.rb +++ b/gems/smithy-json/spec/smithy-json/parser_spec.rb @@ -17,6 +17,28 @@ module Json expect(subject.parse(Schema::Shapes::Prelude::Unit, '')).to eq({}) end + context 'top-level null' do + it 'parses a scalar null as nil' do + expect(subject.parse(Schema::Shapes::Prelude::String, 'null')).to be_nil + end + + it 'parses a float null as nil' do + expect(subject.parse(Schema::Shapes::Prelude::Float, 'null')).to be_nil + end + + it 'parses a blob null as nil' do + expect(subject.parse(Schema::Shapes::Prelude::Blob, 'null')).to be_nil + end + + it 'parses a timestamp null as nil' do + expect(subject.parse(Schema::Shapes::Prelude::Timestamp, 'null')).to be_nil + end + + it 'parses a map null as nil' do + expect(subject.parse(sample_schema.const_get(:Map), 'null')).to be_nil + end + end + context 'structures' do before { allow(Time).to receive(:at).and_return(time) } let(:time) { Time.now } diff --git a/gems/smithy-schema/lib/smithy-schema.rb b/gems/smithy-schema/lib/smithy-schema.rb index 46c9f635d..29e819c4b 100644 --- a/gems/smithy-schema/lib/smithy-schema.rb +++ b/gems/smithy-schema/lib/smithy-schema.rb @@ -5,7 +5,6 @@ require_relative 'smithy-schema/union' require_relative 'smithy-schema/shapes' -require_relative 'smithy-schema/extension_helpers' require_relative 'smithy-schema/extension' require_relative 'smithy-schema/document' require_relative 'smithy-schema/type_registry' diff --git a/gems/smithy-schema/lib/smithy-schema/extension.rb b/gems/smithy-schema/lib/smithy-schema/extension.rb index c9ba91086..9056279c3 100644 --- a/gems/smithy-schema/lib/smithy-schema/extension.rb +++ b/gems/smithy-schema/lib/smithy-schema/extension.rb @@ -8,29 +8,322 @@ module Schema # string keys. This module only provides generic modeled-name lookup # helpers and memoizes shape-level indexes in metadata when that # meaningfully avoids rebuilding them. + # + # - +shape[:wire_index]+ caches the modeled wire-name lookup index for a shape + # - +shape[:member_index]+ caches the modeled build lookup index for a shape + # - +shape[:host_label_index]+ caches the modeled host-label lookup index for a shape + # - +shape[:idempotency_token_member]+ caches the modeled idempotency-token + # member for a shape, or +false+ when absent + # - +operation[:request_compression_encodings]+ caches the modeled request + # compression encodings for an operation, or +false+ when absent + # - +operation[:error_index]+ caches the modeled error-name lookup index for an operation + # - +operation[:endpoint_host_prefix]+ caches the modeled endpoint host prefix, + # or +false+ when the operation does not model one + # - +shape[:default_members]+ caches modeled members with Smithy @default + # that are not Smithy @clientOptional + # - +shape[:required_members]+ caches modeled members with Smithy @required + # that are not Smithy @clientOptional + # - +shape[:streaming_member]+ caches the modeled member that targets a + # streaming shape, or +false+ when absent + # - +shape[:event_stream_member]+ caches the modeled member that targets a + # streaming union, or +false+ when the shape does not model an event stream + # - +shape[:streaming_member_without_length]+ caches the modeled member that + # targets a streaming shape without Smithy @requiresLength, or +false+ + # when absent + # - +shape[:xml_flattened]+ caches whether the shape has the Smithy + # @xmlFlattened trait + # - +shape[:media_type]+ caches the modeled Smithy @mediaType trait value, + # or +false+ when absent + # - +operation[:unsigned_payload]+ caches whether the operation has the + # AWS @unsignedPayload trait + # - +shape[:timestamp_format]+ caches the resolved explicit timestamp format + # for a member or target shape, or +:default+ when the model does not + # override the protocol default # @api private module Extension - extend ExtensionHelpers - class << self # Returns the modeled member lookup index cached on the shape as + # +shape[:wire_index]+. + # + # Return shape: + # - +Hash{String => [Symbol, MemberShape]}+ + def wire_index(shape) + shape[:wire_index] ||= build_wire_index(shape) + end + + # Returns the modeled build lookup index cached on the shape as # +shape[:member_index]+. # - # The index maps: - # - modeled member name - # - to [ruby_member_name, member_shape] + # Return shape: + # - +Hash{Symbol => [String, MemberShape]}+ def member_index(shape) shape[:member_index] ||= build_member_index(shape) end + # Returns the modeled host-label lookup index cached on the shape as + # +shape[:host_label_index]+. + # + # Return shape: + # - +Hash{String => Symbol}+ + def host_label_index(shape) + shape[:host_label_index] ||= build_host_label_index(shape) + end + + # Returns the modeled error lookup index cached on the operation as + # +operation[:error_index]+. + # + # Return shape: + # - +Hash{String => Shape}+ + def error_index(operation) + operation[:error_index] ||= build_error_index(operation) + end + # Returns the modeled member name for schema lookup. + # + # Return shape: + # - modeled wire name as +String+ + # - +nil+ when absent def wire_name(member) member.name end + # Returns the modeled idempotency-token member cached on the shape as + # +shape[:idempotency_token_member]+. + # + # Return shape: + # - ruby member name as +Symbol+ + # - +nil+ when absent + def idempotency_token_member(shape) + value = shape[:idempotency_token_member] + return value if value + return nil if value == false + + value = build_idempotency_token_member(shape) + shape[:idempotency_token_member] = value || false + value + end + + # Returns the modeled members eligible for client-side default + # application on nested structures. + # + # Return shape: + # - +Array<[Symbol, MemberShape]>+ + def default_members(shape) + shape[:default_members] ||= build_default_members(shape) + end + + # Returns the modeled member names eligible for required-member + # validation. + # + # Return shape: + # - +Array+ + def required_members(shape) + shape[:required_members] ||= build_required_members(shape) + end + + # Returns the cached member that targets a streaming shape, or +nil+ + # when the shape does not model a streaming member. + # + # Return shape: + # - +MemberShape+ + # - +nil+ when absent + def streaming_member(shape) + value = shape[:streaming_member] + return value if value + return nil if value == false + + value = shape.members.each_value.find do |member| + member.target.traits.key?('smithy.api#streaming') + end + + shape[:streaming_member] = value || false + value + end + + # Returns the cached member that targets a streaming union, or +nil+ + # when the shape does not model an event stream member. + # + # Return shape: + # - +MemberShape+ + # - +nil+ when absent + def event_stream_member(shape) + value = shape[:event_stream_member] + return value if value + return nil if value == false + + value = shape.members.each_value.find do |member| + streaming_union_member?(member) + end + + shape[:event_stream_member] = value || false + value + end + + # Returns whether the shape models an event stream member. + # + # Return shape: + # - +true+ or +false+ + def event_streaming?(shape) + !event_stream_member(shape).nil? + end + + # Returns the cached member that targets a streaming shape without the + # Smithy @requiresLength trait, or +nil+ when absent. + # + # Return shape: + # - +MemberShape+ + # - +nil+ when absent + def streaming_member_without_length(shape) + value = shape[:streaming_member_without_length] + return value if value + return nil if value == false + + value = shape.members.each_value.find do |member| + target = member.target + target.traits.key?('smithy.api#streaming') && !requires_length?(target) + end + + shape[:streaming_member_without_length] = value || false + value + end + + # Returns whether the shape has the Smithy @xmlFlattened trait. + # + # Return shape: + # - +true+ or +false+ + def xml_flattened?(shape) + value = shape[:xml_flattened] + return value unless value.nil? + + shape[:xml_flattened] = shape.traits.key?('smithy.api#xmlFlattened') + end + + # Returns the modeled Smithy @mediaType trait value when present. + # + # Return shape: + # - media type as +String+ + # - +nil+ when absent + def media_type(shape) + value = shape[:media_type] + return value if value + return nil if value == false + + value = shape.traits['smithy.api#mediaType'] + shape[:media_type] = value || false + value + end + + # Returns the modeled request-compression encodings when present on the + # operation. + # + # Return shape: + # - +Array+ + # - +nil+ when absent + def request_compression_encodings(operation) + value = operation[:request_compression_encodings] + return value if value + return nil if value == false + + value = operation.traits.dig('smithy.api#requestCompression', 'encodings') + operation[:request_compression_encodings] = value || false + value + end + + # Returns the modeled endpoint host prefix when present on the operation. + # + # Return shape: + # - host prefix as +String+ + # - +nil+ when absent + def endpoint_host_prefix(operation) + value = operation[:endpoint_host_prefix] + return value if value + return nil if value == false + + value = operation.traits.dig('smithy.api#endpoint', 'hostPrefix') + operation[:endpoint_host_prefix] = value || false + value + end + + # Returns whether the operation has the Smithy + # @httpChecksumRequired trait. + # + # Return shape: + # - +true+ or +false+ + def checksum_required?(operation) + operation.traits.key?('smithy.api#httpChecksumRequired') + end + + # TODO: Revisit after trait is finalized. + # Returns whether the operation has the Smithy @longPoll trait. + # + # Return shape: + # - +true+ or +false+ + def long_polling?(operation) + operation.traits.key?('smithy.api#longPoll') + end + + # Returns whether the operation has the AWS @unsignedPayload trait. + # + # Return shape: + # - +true+ or +false+ + def unsigned_payload?(operation) + value = operation[:unsigned_payload] + return value unless value.nil? + + operation[:unsigned_payload] = + operation.traits.key?('aws.auth#unsignedPayload') + end + + # Returns the modeled Smithy @default trait payload when present. + # + # Return shape: + # - raw trait payload + # - +nil+ when absent + def default_trait(shape) + shape.traits['smithy.api#default'] + end + + # Returns whether the shape has the Smithy @sparse trait. + # + # Return shape: + # - +true+ or +false+ + def sparse?(shape) + shape.traits.key?('smithy.api#sparse') + end + + # Returns whether the shape has the Smithy @requiresLength trait. + # + # Return shape: + # - +true+ or +false+ + def requires_length?(shape) + shape.traits.key?('smithy.api#requiresLength') + end + + # Returns whether the shape has the Smithy @streaming trait. + # + # Return shape: + # - +true+ or +false+ + def streaming?(shape) + shape.traits.key?('smithy.api#streaming') + end + + # Returns the resolved explicit timestamp format for a member/target + # shape, or +:default+ when the model does not override the protocol + # default. + # + # Return shape: + # - explicit timestamp format as +String+ + # - +:default+ when the protocol default should be used + def timestamp_format(shape) + shape[:timestamp_format] ||= + shape.traits['smithy.api#timestampFormat'] || + shape.target.traits['smithy.api#timestampFormat'] || + :default + end + private - def build_member_index(shape) + def build_wire_index(shape) index = {} shape.members.each do |name, member| wire_name = wire_name(member) @@ -40,6 +333,80 @@ def build_member_index(shape) end index.freeze end + + def build_member_index(shape) + index = {} + shape.members.each do |name, member| + wire_name = wire_name(member) + next unless wire_name + + index[name] = [wire_name, member] + end + index.freeze + end + + def build_host_label_index(shape) + index = {} + shape.members.each do |member_name, member_shape| + next unless member_shape.traits.key?('smithy.api#hostLabel') + next unless member_shape.name + + index[member_shape.name] = member_name + end + index.freeze + end + + def build_default_members(shape) + members = [] + shape.members.each do |member_name, member_shape| + traits = member_shape.traits + next unless traits.key?('smithy.api#default') + next if traits.key?('smithy.api#clientOptional') + + members << [member_name, member_shape] + end + members.freeze + end + + def build_required_members(shape) + members = [] + shape.members.each do |member_name, member_shape| + traits = member_shape.traits + next unless traits.key?('smithy.api#required') + next if traits.key?('smithy.api#clientOptional') + + members << member_name + end + members.freeze + end + + def build_idempotency_token_member(shape) + shape.members.each do |member_name, member_shape| + next unless member_shape.traits.key?('smithy.api#idempotencyToken') + + return member_name + end + + nil + end + + def build_error_index(operation) + index = {} + operation.errors.each do |error_shape| + next unless error_shape.name + + index[error_shape.name] = error_shape + end + index.freeze + end + + def streaming_union_member?(member) + return false unless member + + target = member.target + target.is_a?(Shapes::UnionShape) && + target.traits.key?('smithy.api#streaming') + end end end end diff --git a/gems/smithy-schema/lib/smithy-schema/extension_helpers.rb b/gems/smithy-schema/lib/smithy-schema/extension_helpers.rb deleted file mode 100644 index 0fbb76056..000000000 --- a/gems/smithy-schema/lib/smithy-schema/extension_helpers.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -module Smithy - module Schema - # Shared generic extension helpers used across protocol-specific extensions. - # @api private - module ExtensionHelpers - def sparse?(shape) - shape.traits.key?('smithy.api#sparse') - end - end - end -end diff --git a/gems/smithy-schema/lib/smithy-schema/union.rb b/gems/smithy-schema/lib/smithy-schema/union.rb index afa1e117b..24a40cf49 100644 --- a/gems/smithy-schema/lib/smithy-schema/union.rb +++ b/gems/smithy-schema/lib/smithy-schema/union.rb @@ -15,6 +15,14 @@ def member def value self[member] if member end + + def active_member_value + each_pair do |member_name, value| + return [member_name, value] unless value.nil? + end + + nil + end end end end diff --git a/gems/smithy-schema/sig/smithy-schema/extension.rbs b/gems/smithy-schema/sig/smithy-schema/extension.rbs index 49f76edcc..ec427fad6 100644 --- a/gems/smithy-schema/sig/smithy-schema/extension.rbs +++ b/gems/smithy-schema/sig/smithy-schema/extension.rbs @@ -1,9 +1,9 @@ module Smithy module Schema module Extension - def self.member_index: (untyped shape) -> Hash[String, [Symbol, Shapes::MemberShape]] + def self.member_index: (untyped shape) -> Hash[Symbol, [String, Shapes::MemberShape]] def self.wire_name: (Shapes::MemberShape member) -> String? - def self.sparse?: (untyped shape) -> bool + def self.timestamp_format: (untyped shape) -> (String | Symbol) end end end diff --git a/gems/smithy-schema/spec/smithy-schema/extension_spec.rb b/gems/smithy-schema/spec/smithy-schema/extension_spec.rb index ea18aa533..fa671009b 100644 --- a/gems/smithy-schema/spec/smithy-schema/extension_spec.rb +++ b/gems/smithy-schema/spec/smithy-schema/extension_spec.rb @@ -5,14 +5,33 @@ module Smithy module Schema describe Extension do - describe '.member_index' do + describe '.wire_index' do let(:shape) { Shapes::StructureShape.new } let(:member) { Shapes::MemberShape.new(target: Shapes::StringShape.new, name: 'wireName') } it 'returns a frozen member index keyed by member name' do shape.add_member(:some_member, member) - expect(described_class.member_index(shape)).to eq('wireName' => [:some_member, member]) + expect(described_class.wire_index(shape)).to eq('wireName' => [:some_member, member]) + expect(described_class.wire_index(shape)).to be_frozen + end + + it 'ignores members that do not have a modeled member name' do + shape.add_member(:missing_name, Shapes::MemberShape.new(target: Shapes::StringShape.new)) + + expect(described_class.wire_index(shape)).to eq({}) + end + + end + + describe '.member_index' do + let(:shape) { Shapes::StructureShape.new } + let(:member) { Shapes::MemberShape.new(target: Shapes::StringShape.new, name: 'wireName') } + + it 'returns a frozen build index keyed by Ruby member name' do + shape.add_member(:some_member, member) + + expect(described_class.member_index(shape)).to eq(some_member: ['wireName', member]) expect(described_class.member_index(shape)).to be_frozen end @@ -22,10 +41,267 @@ module Schema expect(described_class.member_index(shape)).to eq({}) end - it 'memoizes the index on the shape metadata' do - shape.add_member(:some_member, member) + end + + describe '.host_label_index' do + let(:shape) { Shapes::StructureShape.new } + + it 'returns a frozen host-label index keyed by modeled label name' do + shape.add_member( + :account_id, + Shapes::MemberShape.new( + target: Shapes::StringShape.new, + name: 'accountId', + traits: { 'smithy.api#hostLabel' => {} } + ) + ) + + expect(described_class.host_label_index(shape)).to eq('accountId' => :account_id) + expect(described_class.host_label_index(shape)).to be_frozen + end + + it 'ignores non-host-label members' do + shape.add_member(:string, Shapes::MemberShape.new(target: Shapes::StringShape.new, name: 'string')) + + expect(described_class.host_label_index(shape)).to eq({}) + end + + end + + describe '.default_members' do + let(:shape) { Shapes::StructureShape.new } + + it 'returns a frozen list of members with default and without clientOptional' do + default_member = Shapes::MemberShape.new( + target: Shapes::StringShape.new, + name: 'defaulted', + traits: { 'smithy.api#default' => 'value' } + ) + client_optional_member = Shapes::MemberShape.new( + target: Shapes::StringShape.new, + name: 'optional', + traits: { + 'smithy.api#default' => 'value', + 'smithy.api#clientOptional' => {} + } + ) + shape.add_member(:defaulted, default_member) + shape.add_member(:optional, client_optional_member) + + expect(described_class.default_members(shape)).to eq([[:defaulted, default_member]]) + expect(described_class.default_members(shape)).to be_frozen + end + + it 'returns an empty list when no members are eligible' do + shape.add_member(:string, Shapes::MemberShape.new(target: Shapes::StringShape.new, name: 'string')) + + expect(described_class.default_members(shape)).to eq([]) + end + + end + + describe '.required_members' do + let(:shape) { Shapes::StructureShape.new } + + it 'returns a frozen list of member names with required and without clientOptional' do + required_member = Shapes::MemberShape.new( + target: Shapes::StringShape.new, + name: 'required', + traits: { 'smithy.api#required' => {} } + ) + client_optional_member = Shapes::MemberShape.new( + target: Shapes::StringShape.new, + name: 'optional', + traits: { + 'smithy.api#required' => {}, + 'smithy.api#clientOptional' => {} + } + ) + shape.add_member(:required, required_member) + shape.add_member(:optional, client_optional_member) + + expect(described_class.required_members(shape)).to eq([:required]) + expect(described_class.required_members(shape)).to be_frozen + end + + it 'returns an empty list when no members are eligible' do + shape.add_member(:string, Shapes::MemberShape.new(target: Shapes::StringShape.new, name: 'string')) + + expect(described_class.required_members(shape)).to eq([]) + end + + end + + describe '.idempotency_token_member' do + let(:shape) { Shapes::StructureShape.new } + + it 'returns the ruby member name with the trait' do + shape.add_member( + :client_token, + Shapes::MemberShape.new( + target: Shapes::StringShape.new, + name: 'clientToken', + traits: { 'smithy.api#idempotencyToken' => {} } + ) + ) + + expect(described_class.idempotency_token_member(shape)).to eq(:client_token) + end + + end + + describe '.request_compression_encodings' do + it 'returns the modeled request compression encodings' do + operation = Shapes::OperationShape.new( + traits: { 'smithy.api#requestCompression' => { 'encodings' => ['gzip'] } } + ) + + expect(described_class.request_compression_encodings(operation)).to eq(['gzip']) + end + end + + describe '.xml_flattened?' do + it 'returns true when xmlFlattened is present' do + member = Shapes::MemberShape.new( + target: Shapes::ListShape.new, + traits: { 'smithy.api#xmlFlattened' => {} } + ) + + expect(described_class.xml_flattened?(member)).to be(true) + end + + it 'returns false when xmlFlattened is absent' do + member = Shapes::MemberShape.new(target: Shapes::ListShape.new) + + expect(described_class.xml_flattened?(member)).to be(false) + end + end + + describe '.media_type' do + it 'returns the modeled media type trait value' do + shape = Shapes::StringShape.new( + traits: { 'smithy.api#mediaType' => 'application/custom' } + ) + + expect(described_class.media_type(shape)).to eq('application/custom') + end + end + + describe '.streaming_member' do + let(:shape) { Shapes::StructureShape.new } + + it 'returns the member when it targets a streaming shape' do + stream_target = Shapes::BlobShape.new(traits: { 'smithy.api#streaming' => {} }) + stream_member = Shapes::MemberShape.new(target: stream_target, name: 'stream') + shape.add_member(:stream, stream_member) + + expect(described_class.streaming_member(shape)).to be(stream_member) + end + + end + + describe '.default_trait' do + it 'returns the modeled default trait payload' do + member = Shapes::MemberShape.new( + target: Shapes::StringShape.new, + traits: { 'smithy.api#default' => 'value' } + ) + + expect(described_class.default_trait(member)).to eq('value') + end + + it 'returns nil when the trait is absent' do + member = Shapes::MemberShape.new(target: Shapes::StringShape.new) + + expect(described_class.default_trait(member)).to be_nil + end + end + + describe '.error_index' do + let(:operation) { Shapes::OperationShape.new } + let(:error_shape) { Shapes::StructureShape.new(name: 'Error') } + + it 'returns a frozen error index keyed by modeled error name' do + operation.errors = [error_shape] + + expect(described_class.error_index(operation)).to eq('Error' => error_shape) + expect(described_class.error_index(operation)).to be_frozen + end + + it 'ignores error shapes that do not have a modeled name' do + operation.errors = [Shapes::StructureShape.new] + + expect(described_class.error_index(operation)).to eq({}) + end + + end + + describe '.endpoint_host_prefix' do + it 'returns the modeled endpoint host prefix' do + operation = Shapes::OperationShape.new( + traits: { 'smithy.api#endpoint' => { 'hostPrefix' => 'foo.' } } + ) + + expect(described_class.endpoint_host_prefix(operation)).to eq('foo.') + end + + end + + describe '.event_stream_member' do + let(:shape) { Shapes::StructureShape.new } + let(:stream_target) do + Shapes::UnionShape.new(traits: { 'smithy.api#streaming' => {} }) + end + let(:stream_member) { Shapes::MemberShape.new(target: stream_target, name: 'events') } + + it 'returns the member when it targets a streaming union' do + shape.add_member(:events, stream_member) + + expect(described_class.event_stream_member(shape)).to be(stream_member) + end + + it 'returns the streaming union member even when payload metadata is absent' do + shape.add_member(:events, stream_member) + + expect(described_class.event_stream_member(shape)).to be(stream_member) + end + + describe '.event_streaming?' do + it 'returns true when the shape has a streaming union member' do + shape.add_member(:events, stream_member) + + expect(described_class.event_streaming?(shape)).to be(true) + end + + it 'returns false when the shape has no streaming union member' do + shape.add_member(:string, Shapes::MemberShape.new(target: Shapes::StringShape.new, name: 'string')) - expect(described_class.member_index(shape)).to be(described_class.member_index(shape)) + expect(described_class.event_streaming?(shape)).to be(false) + end + end + end + + describe '.streaming_member_without_length' do + let(:shape) { Shapes::StructureShape.new } + + it 'returns the member when it targets a streaming shape without requiresLength' do + stream_target = Shapes::BlobShape.new(traits: { 'smithy.api#streaming' => {} }) + stream_member = Shapes::MemberShape.new(target: stream_target, name: 'stream') + shape.add_member(:stream, stream_member) + + expect(described_class.streaming_member_without_length(shape)).to be(stream_member) + end + + it 'ignores streaming members that require length' do + stream_target = Shapes::BlobShape.new( + traits: { + 'smithy.api#streaming' => {}, + 'smithy.api#requiresLength' => {} + } + ) + shape.add_member(:stream, Shapes::MemberShape.new(target: stream_target, name: 'stream')) + + expect(described_class.streaming_member_without_length(shape)).to be_nil end end @@ -41,15 +317,84 @@ module Schema end end - describe '.sparse?' do - it 'returns true when the sparse trait is present' do - shape = Shapes::ListShape.new(traits: { 'smithy.api#sparse' => {} }) + describe '.checksum_required?' do + it 'returns whether the operation has the checksum-required trait' do + operation = Shapes::OperationShape.new( + traits: { 'smithy.api#httpChecksumRequired' => {} } + ) + + expect(described_class.checksum_required?(operation)).to be(true) + expect(described_class.checksum_required?(Shapes::OperationShape.new)).to be(false) + end + end + + describe '.long_polling?' do + it 'returns whether the operation has the long-poll trait' do + operation = Shapes::OperationShape.new( + traits: { 'smithy.api#longPoll' => {} } + ) + + expect(described_class.long_polling?(operation)).to be(true) + expect(described_class.long_polling?(Shapes::OperationShape.new)).to be(false) + end + end + + describe '.timestamp_format' do + it 'prefers the member trait' do + member = Shapes::MemberShape.new( + target: Shapes::TimestampShape.new( + traits: { 'smithy.api#timestampFormat' => 'http-date' } + ), + traits: { 'smithy.api#timestampFormat' => 'date-time' } + ) + + expect(described_class.timestamp_format(member)).to eq('date-time') + end + + it 'falls back to the target shape trait' do + member = Shapes::MemberShape.new( + target: Shapes::TimestampShape.new( + traits: { 'smithy.api#timestampFormat' => 'http-date' } + ) + ) + + expect(described_class.timestamp_format(member)).to eq('http-date') + end + + it 'returns :default when no explicit format is modeled' do + member = Shapes::MemberShape.new(target: Shapes::TimestampShape.new) + + expect(described_class.timestamp_format(member)).to eq(:default) + end + + end + + describe '.requires_length?' do + it 'returns whether the shape has the requiresLength trait' do + shape = Shapes::BlobShape.new(traits: { 'smithy.api#requiresLength' => {} }) + + expect(described_class.requires_length?(shape)).to be(true) + expect(described_class.requires_length?(Shapes::BlobShape.new)).to be(false) + end + end + + describe '.streaming?' do + it 'returns whether the shape has the streaming trait' do + shape = Shapes::BlobShape.new(traits: { 'smithy.api#streaming' => {} }) - expect(described_class.sparse?(shape)).to be(true) + expect(described_class.streaming?(shape)).to be(true) + expect(described_class.streaming?(Shapes::BlobShape.new)).to be(false) end + end + + describe '.unsigned_payload?' do + it 'returns whether the operation has the unsignedPayload trait' do + operation = Shapes::OperationShape.new( + traits: { 'aws.auth#unsignedPayload' => {} } + ) - it 'returns false when the sparse trait is absent' do - expect(described_class.sparse?(Shapes::ListShape.new)).to be(false) + expect(described_class.unsigned_payload?(operation)).to be(true) + expect(described_class.unsigned_payload?(Shapes::OperationShape.new)).to be(false) end end end diff --git a/gems/smithy-xml/lib/smithy-xml/builder.rb b/gems/smithy-xml/lib/smithy-xml/builder.rb index a0be9d626..807ec6c4d 100644 --- a/gems/smithy-xml/lib/smithy-xml/builder.rb +++ b/gems/smithy-xml/lib/smithy-xml/builder.rb @@ -11,7 +11,9 @@ class Builder def initialize(options = {}) @indent = options.fetch(:indent, '') @pad = options.fetch(:pad, '') + @default_timestamp = options.fetch(:default_timestamp, 'date-time') @extension = Smithy::Xml::Extension + @map_entry_shape = MemberShape.new(target: MapShape.new) end def build(shape, data, output = nil) @@ -41,41 +43,44 @@ def blob(value) def list(name, shape, values) member_shape = shape.target.member - if flat?(shape) + flattened = @extension.flattened?(shape) + if flattened values.each do |value| build_shape(name, member_shape, value) end else + member_name = @extension.wire_name(member_shape) node(name, shape) do values.each do |value| - build_shape(@extension.wire_name(member_shape), shape.target.member, value) + build_shape(member_name, member_shape, value) end end end end def map(name, shape, values) - key_shape = shape.target.key - value_shape = shape.target.value - if flat?(shape) - flat_map_entries(name, shape, values, key_shape, value_shape) + flattened = @extension.flattened?(shape) + if flattened + flat_map_entries(name, shape, values) else + key_name, key_member, value_name, value_member = @extension.map_parts(shape) node(name, shape) do values.each do |key, value| - node('entry', MemberShape.new(target: MapShape.new)) do - build_shape(@extension.wire_name(key_shape), key_shape, key) - build_shape(@extension.wire_name(value_shape), value_shape, value) + node('entry', @map_entry_shape) do + build_shape(key_name, key_member, key) + build_shape(value_name, value_member, value) end end end end end - def flat_map_entries(name, shape, values, key_shape, value_shape) + def flat_map_entries(name, shape, values) + key_name, key_member, value_name, value_member = @extension.map_parts(shape) values.each do |key, value| node(name, shape) do - build_shape(@extension.wire_name(key_shape), key_shape, key) - build_shape(@extension.wire_name(value_shape), value_shape, value) + build_shape(key_name, key_member, key) + build_shape(value_name, value_member, value) end end end @@ -84,58 +89,52 @@ def structure(name, shape, values) return node(name, shape) if values.empty? node(name, shape, structure_attrs(shape, values)) do - @extension.members(shape.target)[:elements].each do |ruby_member_name, member_shape| - next if values[ruby_member_name].nil? - - build_shape( - @extension.wire_name(member_shape), - member_shape, - values[ruby_member_name] - ) + element_members = @extension.element_members(shape.target) + element_members.each do |member_name, xml_name, member_shape| + member_value = values[member_name] + next if member_value.nil? + + build_shape(xml_name, member_shape, member_value) end end end def structure_attrs(shape, values) - @extension.members(shape.target)[:attributes].each_with_object({}) do |(ruby_member_name, member_shape), attrs| - next unless values.key?(ruby_member_name) + attribute_members = @extension.attribute_members(shape.target) + attribute_members.each_with_object({}) do |(name, xml_name, _m_shape), attrs| + value = values[name] + next if value.nil? && !values.key?(name) - attrs[@extension.wire_name(member_shape)] = values[ruby_member_name] + attrs[xml_name] = value end end def timestamp(shape, value) - trait = 'smithy.api#timestampFormat' - case shape.traits[trait] || shape.target.traits[trait] + format = Smithy::Schema::Extension.timestamp_format(shape) + format = @default_timestamp if format == :default + + case format when 'epoch-seconds' then value.to_i.to_s when 'http-date' then value.utc.httpdate - else - # default to date-time - value.utc.iso8601 + when 'date-time' then value.utc.iso8601 + else raise ArgumentError, "unsupported XML timestamp format: #{format.inspect}" end end - def union(name, shape, values) # rubocop:disable Metrics/AbcSize + def union(name, shape, values) return node(name, shape) if values.empty? + if values.is_a?(Schema::Union) + key, value = values.active_member_value + else + key, value = values.first + end node(name, shape, structure_attrs(shape, values)) do - if values.is_a?(Schema::Union) - _name, member_shape = shape.target.member_by_type(values.class) - build_shape(@extension.wire_name(member_shape), member_shape, values.value) - else - key, value = values.first - if shape.target.member?(key) - member_shape = shape.target.member(key) - build_shape(@extension.wire_name(member_shape), member_shape, value) - end - end + member_shape = shape.target.member(key) + build_shape(@extension.wire_name(member_shape), member_shape, value) if member_shape end end - def flat?(shape) - shape.traits.key?('smithy.api#xmlFlattened') - end - # The `args` list may contain: # # * [] - empty, no value or attributes @@ -148,7 +147,12 @@ def flat?(shape) # def node(name, shape, *args, &) attrs = args.last.is_a?(Hash) ? args.pop : {} - attrs = @extension.namespace_attrs(shape).merge(attrs) + namespace_attrs = @extension.namespace_attrs(shape) + if attrs.empty? + attrs = namespace_attrs + elsif !namespace_attrs.empty? + attrs = namespace_attrs.merge(attrs) + end args << attrs @builder.node(name, *args, &) end diff --git a/gems/smithy-xml/lib/smithy-xml/extension.rb b/gems/smithy-xml/lib/smithy-xml/extension.rb index 7a2a8dedc..d1b14b652 100644 --- a/gems/smithy-xml/lib/smithy-xml/extension.rb +++ b/gems/smithy-xml/lib/smithy-xml/extension.rb @@ -2,23 +2,28 @@ module Smithy module Xml - # Lookup helpers for XML serde using Smithy traits that affect XML + # Lookup helpers for XML SERDE using Smithy traits that affect XML # wire names and structure layout. # # Raw Smithy trait data remains on +shape.traits+ and +member.traits+ with - # string keys. This module resolves XML-specific serde behavior on demand + # string keys. This module resolves XML-specific SERDE behavior on demand # and stores resolved values in metadata: # - +shape[:xml_structure_name]+ caches the resolved XML element name for a # structure or top-level structure member + # - +shape[:xml_flattened]+ caches whether +@xmlFlattened+ is set on a + # wrapper member as a boolean + # - +shape[:xml_frame_class]+ caches the XML parser frame class selected for + # a wrapper shape # - +member[:xml_name]+ caches the resolved XML wire name for a member # - +shape[:xml_members]+ partitions members into XML attributes vs elements # - +shape[:xml_member_index]+ caches the XML wire-name lookup index + # - +shape[:xml_map_parts]+ caches resolved XML map key/value members and wire names # - +shape[:xml_namespace_attrs]+ caches resolved xmlns attributes # @api private module Extension - extend Smithy::Schema::ExtensionHelpers - class << self + include Smithy::Schema::Shapes + # Returns the XML element name, preferring the Smithy @xmlName trait. def structure_name(shape) shape[:xml_structure_name] ||= @@ -27,6 +32,25 @@ def structure_name(shape) shape.target.name end + # Returns whether the wrapper shape is marked with @xmlFlattened. + def flattened?(shape) + Smithy::Schema::Extension.xml_flattened?(shape) + end + + # Returns the cached parser frame class for a wrapper shape. + def frame_class(shape) + shape[:xml_frame_class] ||= begin + klass = base_frame_class(shape.target) + if klass == Parser::ListFrame && flattened?(shape) + Parser::FlatListFrame + elsif klass == Parser::MapFrame && flattened?(shape) + Parser::MapEntryFrame + else + klass + end + end + end + # Returns the resolved XML wire name, preferring the Smithy @xmlName # trait and caching the result as +member[:xml_name]+. def wire_name(member) @@ -34,15 +58,34 @@ def wire_name(member) end # Partitioned XML members for the builder => { attributes:, elements: }. + # + # Each entry is: + # - [ruby_member_name, resolved_xml_name, member_shape] def members(shape) shape[:xml_members] ||= build_members(shape) end + # XML members that serialize as attributes. + def attribute_members(shape) + members(shape)[:attributes] + end + + # XML members that serialize as child elements. + def element_members(shape) + members(shape)[:elements] + end + # Resolved XML wire name => [ruby_member_name, member_shape] def member_index(shape) shape[:xml_member_index] ||= build_member_index(shape) end + # Resolved XML map parts as: + # - [key_name, key_member, value_name, value_member] + def map_parts(shape) + shape[:xml_map_parts] ||= build_map_parts(shape) + end + # XML namespace attributes derived from the Smithy @xmlNamespace trait. def namespace_attrs(shape) shape[:xml_namespace_attrs] ||= build_namespace_attrs(shape) @@ -55,7 +98,8 @@ def build_members(shape) elements = [] shape.members.each do |name, member| - entry = [name, member].freeze + xml_name = wire_name(member) + entry = [name, xml_name, member].freeze if xml_attribute?(member) attributes << entry else @@ -79,6 +123,17 @@ def build_member_index(shape) index.freeze end + def build_map_parts(shape) + key_member = shape.target.key + value_member = shape.target.value + [ + wire_name(key_member), + key_member, + wire_name(value_member), + value_member + ].freeze + end + def build_namespace_attrs(shape) xmlns = shape.traits['smithy.api#xmlNamespace'] || shape.target.traits['smithy.api#xmlNamespace'] return {}.freeze unless xmlns @@ -95,6 +150,21 @@ def build_namespace_attrs(shape) def xml_attribute?(shape) shape.traits.key?('smithy.api#xmlAttribute') end + + def base_frame_class(target) # rubocop:disable Metrics/CyclomaticComplexity + case target + when BigDecimalShape then Parser::BigDecimalFrame + when BlobShape then Parser::BlobFrame + when BooleanShape then Parser::BooleanFrame + when EnumShape, StringShape then Parser::StringFrame + when FloatShape then Parser::FloatFrame + when IntegerShape, IntEnumShape then Parser::IntegerFrame + when ListShape then Parser::ListFrame + when MapShape then Parser::MapFrame + when StructureShape, UnionShape then Parser::StructureFrame + when TimestampShape then Parser::TimestampFrame + end + end end end end diff --git a/gems/smithy-xml/lib/smithy-xml/parser/frame.rb b/gems/smithy-xml/lib/smithy-xml/parser/frame.rb index b93c7bbb5..fe06db5ad 100644 --- a/gems/smithy-xml/lib/smithy-xml/parser/frame.rb +++ b/gems/smithy-xml/lib/smithy-xml/parser/frame.rb @@ -25,14 +25,7 @@ def new(path, parent, shape, result = nil) private def frame_class(shape) - klass = FRAME_CLASSES[shape.target.class] - if klass == ListFrame && shape.traits.key?('smithy.api#xmlFlattened') - FlatListFrame - elsif klass == MapFrame && shape.traits.key?('smithy.api#xmlFlattened') - MapEntryFrame - else - klass - end + Extension.frame_class(shape) end end @@ -41,13 +34,20 @@ def initialize(path, parent, shape, result) @parent = parent @shape = shape @result = result - @text = [] + @text = nil end attr_reader :parent, :shape, :result def append_text(value) - @text << value + case @text + when nil + @text = value + when String + @text = [@text, value] + else + @text << value + end end def child_frame(xml_name) @@ -69,33 +69,37 @@ def path def yield_unhandled_value(path, value) parent.yield_unhandled_value(path, value) end + + def text_value + @text.is_a?(Array) ? @text.join : @text + end end # @api private class BigDecimalFrame < Frame def result - @text.empty? ? nil : BigDecimal(@text.join) + @text.nil? ? nil : BigDecimal(text_value) end end # @api private class BlobFrame < Frame def result - @text.empty? ? '' : Base64.decode64(@text.join) + @text.nil? ? '' : Base64.decode64(text_value) end end # @api private class BooleanFrame < Frame def result - @text.empty? ? nil : (@text.join == 'true') + @text.nil? ? nil : (text_value == 'true') end end # @api private class IntegerFrame < Frame def result - @text.empty? ? nil : @text.join.to_i + @text.nil? ? nil : text_value.to_i end end @@ -126,7 +130,7 @@ def consume_child_frame(_child) # @api private class FloatFrame < Frame def result - @text.empty? ? nil : deserialize_number(@text.join) + @text.nil? ? nil : deserialize_number(text_value) end # @param [String] str @@ -147,7 +151,8 @@ class ListFrame < Frame def initialize(*args) super @result = [] - @member_xml_name = Smithy::Xml::Extension.wire_name(@shape.target.member) + @member_shape = @shape.target.member + @member_xml_name = Smithy::Xml::Extension.wire_name(@member_shape) end def child_frame(xml_name) @@ -155,7 +160,7 @@ def child_frame(xml_name) raise NotImplementedError, "Expected XML name '#{@member_xml_name}' for ListFrame, got '#{xml_name}'" end - Frame.new(xml_name, self, @shape.target.member) + Frame.new(xml_name, self, @member_shape) end def consume_child_frame(child) @@ -167,10 +172,10 @@ def consume_child_frame(child) class MapEntryFrame < Frame def initialize(xml_name, *args) super - @key_name = Smithy::Xml::Extension.wire_name(@shape.target.key) - @key = Frame.new(xml_name, self, @shape.target.key) - @value_name = Smithy::Xml::Extension.wire_name(@shape.target.value) - @value = Frame.new(xml_name, self, @shape.target.value) + @key_name, key_member, @value_name, value_member = + Smithy::Xml::Extension.map_parts(@shape) + @key = Frame.new(xml_name, self, key_member) + @value = Frame.new(xml_name, self, value_member) end # @return [StringFrame] @@ -225,7 +230,7 @@ def append_text(value) # @api private class StringFrame < Frame def result - @text.join + text_value || '' end end @@ -268,7 +273,7 @@ def consume_child_frame(child) # rubocop:disable Metrics/AbcSize, Metrics/Cyclom # @api private class TimestampFrame < Frame def result - @text.empty? ? nil : deserialize_time(@text.join) + @text.nil? ? nil : deserialize_time(text_value) end # @param [String] value @@ -291,27 +296,11 @@ def deserialize_time(value) # @api private class UnknownMemberFrame < Frame def result - @text.join + text_value || '' end end include Smithy::Schema::Shapes - - FRAME_CLASSES = { - BigDecimalShape => BigDecimalFrame, - BlobShape => BlobFrame, - BooleanShape => BooleanFrame, - EnumShape => StringFrame, - FloatShape => FloatFrame, - IntegerShape => IntegerFrame, - IntEnumShape => IntegerFrame, - ListShape => ListFrame, - MapShape => MapFrame, - StringShape => StringFrame, - StructureShape => StructureFrame, - TimestampShape => TimestampFrame, - UnionShape => StructureFrame - }.freeze end end end diff --git a/gems/smithy-xml/lib/smithy-xml/parser/libxml_engine.rb b/gems/smithy-xml/lib/smithy-xml/parser/libxml_engine.rb index 144fc2716..ba9786b8a 100644 --- a/gems/smithy-xml/lib/smithy-xml/parser/libxml_engine.rb +++ b/gems/smithy-xml/lib/smithy-xml/parser/libxml_engine.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require 'libxml' +require 'libxml-ruby' module Smithy module Xml diff --git a/gems/smithy-xml/sig/smithy-xml/extension.rbs b/gems/smithy-xml/sig/smithy-xml/extension.rbs new file mode 100644 index 000000000..c1eb0411a --- /dev/null +++ b/gems/smithy-xml/sig/smithy-xml/extension.rbs @@ -0,0 +1,17 @@ +module Smithy + module Xml + module Extension + def self.structure_name: (untyped shape) -> String + def self.flattened: (untyped shape) -> (:set | :unset) + def self.flattened?: (untyped shape) -> bool + def self.frame_class: (untyped shape) -> Class + def self.wire_name: (Schema::Shapes::MemberShape member) -> String? + def self.members: (untyped shape) -> { + attributes: Array[[Symbol, String?, Schema::Shapes::MemberShape]], + elements: Array[[Symbol, String?, Schema::Shapes::MemberShape]] + } + def self.member_index: (untyped shape) -> Hash[String, [Symbol, Schema::Shapes::MemberShape]] + def self.namespace_attrs: (untyped shape) -> Hash[String, String] + end + end +end diff --git a/gems/smithy-xml/spec/smithy-xml/builder_spec.rb b/gems/smithy-xml/spec/smithy-xml/builder_spec.rb index e1f696655..430413395 100644 --- a/gems/smithy-xml/spec/smithy-xml/builder_spec.rb +++ b/gems/smithy-xml/spec/smithy-xml/builder_spec.rb @@ -206,6 +206,17 @@ def inline(xml) bytes = subject.build(structure_shape, data) expect(bytes).to include('string') end + + it 'builds typed union members with xmlName' do + shapes['smithy.ruby.tests#Union']['members']['string'] = { + 'target' => 'smithy.api#String', + 'traits' => { 'smithy.api#xmlName' => 'NewString' } + } + union = structure_shape.member(:union).target.member_type(:string).new(string: 'string') + type = structure_shape.type.new(union: union) + bytes = subject.build(structure_shape, type) + expect(bytes).to include('string') + end end context 'lists' do @@ -263,6 +274,33 @@ def inline(xml) bytes = subject.build(structure_shape, data) expect(bytes).to include("#{time.httpdate}") end + + it 'uses the configured default timestamp when the model does not override it' do + subject = described_class.new(default_timestamp: 'epoch-seconds') + time = Time.now.utc + data = { timestamp: time } + bytes = subject.build(structure_shape, data) + expect(bytes).to include("#{time.to_i}") + end + + it 'still prefers the modeled timestamp format over the configured default' do + subject = described_class.new(default_timestamp: 'epoch-seconds') + time = Time.now.utc + shapes['smithy.ruby.tests#Structure']['members']['timestamp']['traits'] = { + 'smithy.api#timestampFormat' => 'http-date' + } + data = { timestamp: time } + bytes = subject.build(structure_shape, data) + expect(bytes).to include("#{time.httpdate}") + end + + it 'raises for unsupported timestamp formats' do + subject = described_class.new(default_timestamp: 'bogus-format') + time = Time.now.utc + + expect { subject.build(structure_shape, { timestamp: time }) } + .to raise_error(ArgumentError, /unsupported XML timestamp format/) + end end end end diff --git a/gems/smithy-xml/spec/smithy-xml/codec_spec.rb b/gems/smithy-xml/spec/smithy-xml/codec_spec.rb new file mode 100644 index 000000000..f59bdd5c5 --- /dev/null +++ b/gems/smithy-xml/spec/smithy-xml/codec_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require_relative '../spec_helper' + +module Smithy + module Xml + describe Codec do + let(:shapes) { SchemaHelper.sample_shapes } + let(:sample_schema) { SchemaHelper.sample_schema(shapes: shapes) } + let(:structure_shape) { sample_schema.const_get(:Structure) } + + describe '#build' do + it 'passes an explicit default timestamp through to the builder' do + builder = instance_double(Builder, build: '') + allow(Builder).to receive(:new).and_return(builder) + + described_class.new(default_timestamp: 'http-date').build(structure_shape, {}) + + expect(Builder).to have_received(:new).with(default_timestamp: 'http-date') + end + end + + it 'reuses the same codec instance across build calls without leaking builder state' do + codec = described_class.new + + first = codec.build(structure_shape, { string: 'first' }) + second = codec.build(structure_shape, { integer: 123 }) + + expect(first).to eq('first') + expect(second).to eq('123') + end + + it 'reuses the same codec instance across parse calls' do + codec = described_class.new + + first = codec.parse(structure_shape, 'first') + second = codec.parse(structure_shape, '123') + + expect(first.to_h).to eq(string: 'first') + expect(second.to_h).to eq(integer: 123) + end + end + end +end diff --git a/gems/smithy-xml/spec/smithy-xml/extension_spec.rb b/gems/smithy-xml/spec/smithy-xml/extension_spec.rb index 1f3527c4b..711c012a2 100644 --- a/gems/smithy-xml/spec/smithy-xml/extension_spec.rb +++ b/gems/smithy-xml/spec/smithy-xml/extension_spec.rb @@ -28,16 +28,10 @@ module Xml ) expect(described_class.structure_name(member)).to eq('RootElement') - expect(member[:xml_structure_name]).to eq('RootElement') end it 'falls back to the target structure name' do expect(described_class.structure_name(structure)).to eq('Structure') - expect(structure[:xml_structure_name]).to eq('Structure') - end - - it 'memoizes the structure element name on shape metadata' do - expect(described_class.structure_name(structure)).to be(described_class.structure_name(structure)) end end @@ -50,12 +44,27 @@ module Xml ) expect(described_class.wire_name(member)).to eq('NewString') - expect(member[:xml_name]).to eq('NewString') end it 'falls back to the provided default' do expect(described_class.wire_name(element_member)).to eq('String') - expect(element_member[:xml_name]).to eq('String') + end + end + + describe '.flattened?' do + it 'returns true when xmlFlattened is present' do + member = Schema::Shapes::MemberShape.new( + target: Schema::Shapes::ListShape.new, + traits: { 'smithy.api#xmlFlattened' => {} } + ) + + expect(described_class.flattened?(member)).to be(true) + end + + it 'returns false when xmlFlattened is absent' do + member = Schema::Shapes::MemberShape.new(target: Schema::Shapes::ListShape.new) + + expect(described_class.flattened?(member)).to be(false) end end @@ -65,16 +74,11 @@ module Xml structure.add_member(:status, attribute_member) expect(described_class.members(structure)).to eq( - elements: [[:string, element_member]], - attributes: [[:status, attribute_member]] + elements: [[:string, 'String', element_member]], + attributes: [[:status, 'Status', attribute_member]] ) end - it 'memoizes the grouped members on shape metadata' do - structure.add_member(:string, element_member) - - expect(described_class.members(structure)).to be(described_class.members(structure)) - end end describe '.member_index' do @@ -86,15 +90,6 @@ module Xml 'String' => [:string, element_member], 'Status' => [:status, attribute_member] ) - expect(described_class.member_index(structure)).to be_frozen - expect(element_member[:xml_name]).to eq('String') - expect(attribute_member[:xml_name]).to eq('Status') - end - - it 'memoizes the index on the shape metadata' do - structure.add_member(:string, element_member) - - expect(described_class.member_index(structure)).to be(described_class.member_index(structure)) end end @@ -103,7 +98,6 @@ module Xml structure.traits['smithy.api#xmlNamespace'] = { 'uri' => 'https://example.com/ns' } expect(described_class.namespace_attrs(structure)).to eq('xmlns' => 'https://example.com/ns') - expect(described_class.namespace_attrs(structure)).to be_frozen end it 'builds prefixed namespace attrs from xmlNamespace' do @@ -112,18 +106,8 @@ module Xml expect(described_class.namespace_attrs(structure)).to eq('xmlns:smithy' => 'https://example.com/ns') end - it 'returns a memoized empty hash when no namespace is present' do + it 'returns an empty hash when no namespace is present' do expect(described_class.namespace_attrs(structure)).to eq({}) - expect(described_class.namespace_attrs(structure)).to be(described_class.namespace_attrs(structure)) - expect(described_class.namespace_attrs(structure)).to be_frozen - end - end - - describe '.sparse?' do - it 'uses the shared generic sparse helper' do - sparse_shape = Schema::Shapes::ListShape.new(traits: { 'smithy.api#sparse' => {} }) - - expect(described_class.sparse?(sparse_shape)).to be(true) end end end diff --git a/gems/smithy-xml/spec/smithy-xml/parser_spec.rb b/gems/smithy-xml/spec/smithy-xml/parser_spec.rb index c902aa669..a2021926a 100644 --- a/gems/smithy-xml/spec/smithy-xml/parser_spec.rb +++ b/gems/smithy-xml/spec/smithy-xml/parser_spec.rb @@ -9,6 +9,44 @@ module Xml let(:sample_schema) { SchemaHelper.sample_schema(shapes: shapes) } let(:structure_shape) { sample_schema.const_get(:Structure) } + context 'parser engines' do + parser_engines = %i[ox oga libxml nokogiri rexml].freeze + + parser_engines.each do |engine_name| + describe "ENGINE: #{engine_name};" do + let(:engine_class) { Smithy::Xml::Parser.send(:load_engine, engine_name) } + let(:parser) { described_class.new(engine: engine_class) } + + before do + engine_class + rescue LoadError + skip "Skipping #{engine_name} tests because it is not installed" + end + + it 'parses a simple structure' do + bytes = String.new(<<~XML) + + string + 123 + + XML + + expect(parser.parse(structure_shape, bytes).to_h).to eq(string: 'string', integer: 123) + end + + it 'parses large text content correctly' do + bytes = <<~XML + + #{'a' * 200_000} + + XML + + expect(parser.parse(structure_shape, String.new(bytes)).to_h).to eq(string: 'a' * 200_000) + end + end + end + end + it 'returns an empty structure when given a unit shape' do expect(subject.parse(Schema::Shapes::Prelude::Unit, '')).to be_a(Schema::EmptyStructure) end @@ -118,6 +156,20 @@ module Xml XML expect(subject.parse(structure_shape, bytes).to_h).to eq(string: 'string') end + + it 'reuses the cached XML member index across parses' do + bytes = <<~XML + + string + + XML + + expect(Smithy::Xml::Extension).to receive(:build_member_index).once.and_call_original + + 3.times do + expect(subject.parse(structure_shape, bytes).to_h).to eq(string: 'string') + end + end end context 'unions' do diff --git a/gems/smithy-xml/spec/spec_helper.rb b/gems/smithy-xml/spec/spec_helper.rb index 6df750a27..2676b3c55 100644 --- a/gems/smithy-xml/spec/spec_helper.rb +++ b/gems/smithy-xml/spec/spec_helper.rb @@ -2,12 +2,12 @@ require 'simplecov' SimpleCov.start do - add_filter '/spec/' - add_filter 'gems/smithy/' - add_filter 'gems/smithy-cbor/' - add_filter 'gems/smithy-client/' - add_filter 'gems/smithy-json/' - add_filter 'gems/smithy-schema/' + skip '/spec/' + skip 'gems/smithy/' + skip 'gems/smithy-cbor/' + skip 'gems/smithy-client/' + skip 'gems/smithy-json/' + skip 'gems/smithy-schema/' end require 'smithy-xml' diff --git a/gems/smithy/lib/smithy/templates/client/schema.erb b/gems/smithy/lib/smithy/templates/client/schema.erb index 4c4bd1251..ef9979fa2 100644 --- a/gems/smithy/lib/smithy/templates/client/schema.erb +++ b/gems/smithy/lib/smithy/templates/client/schema.erb @@ -24,10 +24,6 @@ module <%= module_name %> <% when 'structure' -%> <% shape.members.each do |member| -%> <%= shape.name %>.add_member(:<%= member.ruby_name %>, <%= member.initializer %>) -<% end -%> -<% if shape.http_payload? -%> - <%= shape.name %>[:http_payload] = :<%= shape.http_payload %> - <%= shape.name %>[:http_payload_member] = <%= shape.name %>.member(:<%= shape.http_payload %>) <% end -%> <%= shape.name %>.type = <%= shape.type_class %> <% when 'union' -%> diff --git a/gems/smithy/lib/smithy/views/client/schema.rb b/gems/smithy/lib/smithy/views/client/schema.rb index 39429fadc..b8f47bf6a 100644 --- a/gems/smithy/lib/smithy/views/client/schema.rb +++ b/gems/smithy/lib/smithy/views/client/schema.rb @@ -247,14 +247,6 @@ def initialize(service, id, shape) def type_class "Types::#{(@service.dig('rename', @id) || Model::Shape.name(@id)).camelize}" end - - def http_payload? - @members.any?(&:http_payload?) - end - - def http_payload - @members.find(&:http_payload).http_payload - end end # @api private @@ -340,16 +332,6 @@ def initializer options_str += ", traits: #{@traits}" unless @traits.empty? "::Smithy::Schema::Shapes::MemberShape.new(#{options_str})" end - - def http_payload? - @traits.key?('smithy.api#httpPayload') - end - - def http_payload - return unless http_payload? - - @ruby_name - end end end end