diff --git a/gems/smithy-cbor/lib/smithy-cbor/builder.rb b/gems/smithy-cbor/lib/smithy-cbor/builder.rb index d8de7d0e8..8bd951417 100644 --- a/gems/smithy-cbor/lib/smithy-cbor/builder.rb +++ b/gems/smithy-cbor/lib/smithy-cbor/builder.rb @@ -17,12 +17,13 @@ def build(shape, data) private def build_shape(shape, value) - case Schema::Extension.target_shape(shape) - when Schema::Extension::SHAPE_BLOB then blob(value) - when Schema::Extension::SHAPE_LIST then list(shape, value) - when Schema::Extension::SHAPE_MAP then map(shape, value) - when Schema::Extension::SHAPE_STRUCTURE then structure(shape, value) - when Schema::Extension::SHAPE_UNION then union(shape, value) + target = shape.target + case target + when Schema::Shapes::BlobShape then blob(value) + when Schema::Shapes::ListShape then list(shape, value) + when Schema::Shapes::MapShape then map(shape, value) + when Schema::Shapes::StructureShape then structure(shape, value) + when Schema::Shapes::UnionShape then union(shape, value) else value end end @@ -34,7 +35,7 @@ def blob(value) def list(shape, values) return if values.nil? - member, _target_shape, _sparse = Schema::Extension.list_member(shape.target) + member = shape.target.member values.collect do |value| build_shape(member, value) end @@ -43,23 +44,26 @@ def list(shape, values) def map(shape, values) return if values.nil? - value_member, _target_shape, _sparse = Schema::Extension.map_value_member(shape.target) - values.each.with_object({}) do |(key, value), data| + value_member = shape.target.value + data = {} + values.each do |key, value| data[key] = build_shape(value_member, value) end + data end def structure(shape, values) return if values.nil? - index = Schema::Extension.member_index(shape.target) - values.each_pair.with_object({}) do |(member_name, value), data| + target = shape.target + data = {} + values.each_pair do |member_name, value| next if value.nil? - next unless (entry = index[member_name]) + next unless (member_shape = target.member(member_name)) - wire_name, member_shape, _target_shape = entry - data[wire_name] = build_shape(member_shape, value) + data[member_shape.name] = build_shape(member_shape, value) end + data end def union(shape, values) @@ -67,16 +71,14 @@ def union(shape, values) key, value = if values.is_a?(Schema::Union) - member_name, _member_shape = shape.target.member_by_type(values.class) - [member_name, values.value] + [values.member, values.value] else values.first end - entry = Schema::Extension.member_index(shape.target)[key] - return {} unless entry + member_shape = shape.target.member(key) + return {} unless member_shape - wire_name, member_shape, _target_shape = entry - { wire_name => build_shape(member_shape, value) } + { member_shape.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..e17957bda 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).freeze + @parser = Parser.new(options).freeze 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..017a2cc2e 100644 --- a/gems/smithy-cbor/lib/smithy-cbor/decoder.rb +++ b/gems/smithy-cbor/lib/smithy-cbor/decoder.rb @@ -36,98 +36,88 @@ def decode_item # rubocop:disable Metrics @depth += 1 raise ParseError, "Maximum nesting depth (#{MAX_DEPTH}) exceeded" if @depth > MAX_DEPTH - case (next_type = peek_type) - when :array - read_array.times.map { decode_item } - when :map - read_map.times.to_h { [read_string, decode_item] } - when :indefinite_array then process_indefinite_array - when :indefinite_map then process_indefinite_map - when :indefinite_binary_string then process_indefinite_binary - when :indefinite_string then process_indefinite_string - when :tag then process_tag - when :break_stop_code then raise ParseError, 'Unexpected break code' - else send("read_#{next_type}") - end + ib = read_byte + add_info = ib & FIVE_BIT_MASK + process_item(ib >> 5, add_info) ensure @depth -= 1 end - def peek(n_bytes) - return @buffer[@pos, n_bytes] if (@pos + n_bytes) <= @buffer.bytesize - - left = @buffer.bytesize - @pos - raise ParseError, "Out of bytes. Trying to read #{n_bytes} bytes but buffer contains only #{left}" - end - - # low level streaming interface - def peek_type # rubocop:disable Metrics - ib = peek(1).ord - add_info = ib & FIVE_BIT_MASK - major_type = ib >> 5 + def process_item(major_type, add_info) # rubocop:disable Metrics case major_type - when 0, 1 then :integer + when 0 then read_count(add_info) + when 1 then -1 - read_count(add_info) when 2 - add_info == 31 ? :indefinite_binary_string : :binary_string + add_info == 31 ? process_indefinite_binary : read_binary_string(add_info) when 3 - add_info == 31 ? :indefinite_string : :string + add_info == 31 ? process_indefinite_string : read_string(add_info) when 4 - add_info == 31 ? :indefinite_array : :array + add_info == 31 ? process_indefinite_array : process_array(add_info) when 5 - add_info == 31 ? :indefinite_map : :map - when 6 then :tag + add_info == 31 ? process_indefinite_map : process_map(add_info) + when 6 then process_tag(add_info) when 7 then process_major_type_simple(add_info) end end + def process_array(add_info) + count = read_array(add_info) + value = Array.new(count) + count.times { |index| value[index] = decode_item } + value + end + + def process_map(add_info) + value = {} + read_map(add_info).times { value[read_string] = decode_item } + value + end + # simple or float def process_major_type_simple(add_info) # rubocop:disable Metrics case add_info - when 20, 21 then :boolean - when 22 then :nil - when 23 then :undefined # for smithy, this should be parsed as nil - when 25 then :half - when 26 then :float - when 27 then :double - when 31 then :break_stop_code - else :reserved_undefined + when 20 then false + when 21 then true + when 22 then nil + when 23 then :undefined + when 25 then read_half + when 26 then unpack1('g', 4) + when 27 then unpack1('G', 8) + when 31 then raise ParseError, 'Unexpected break code' + else raise ParseError, "Undefined reserved additional information: #{add_info}" end end def process_indefinite_array - read_start_indefinite_array value = [] - value << decode_item until peek_type == :break_stop_code + value << decode_item until break_stop_code? read_end_indefinite_collection value end def process_indefinite_binary - read_info value = String.new - value << read_binary_string until peek_type == :break_stop_code + value << read_binary_string until break_stop_code? read_end_indefinite_collection value end 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 break_stop_code? read_end_indefinite_collection value end def process_indefinite_string - read_info value = String.new - value << read_string until peek_type == :break_stop_code + value << read_string until break_stop_code? read_end_indefinite_collection value.force_encoding(Encoding::UTF_8) end - def process_tag - case (tag = read_tag) + def process_tag(add_info) + case (tag = read_count(add_info)) when TAG_TYPE_EPOCH item = decode_item Time.at(item) @@ -142,8 +132,8 @@ def process_tag # returns only the length of the array, caller must read the correct # number of values after this - def read_array - _major_type, add_info = read_info + def read_array(add_info = nil) + add_info = read_byte & FIVE_BIT_MASK if add_info.nil? read_count(add_info) end @@ -163,11 +153,12 @@ def read_big_decimal # tag type 2 or 3 def read_bignum(tag_value) - _major_type, add_info = read_info + add_info = read_byte & FIVE_BIT_MASK bstr = take(read_count(add_info)) - v = bstr.bytes.inject(0) do |sum, b| - sum <<= 8 - sum + b + v = 0 + bstr.each_byte do |b| + v <<= 8 + v += b end case tag_value when 2 then v @@ -175,43 +166,25 @@ def read_bignum(tag_value) end end - def read_boolean - _major_type, add_info = read_info - case add_info - when 20 then false - when 21 then true - end - end - - def read_binary_string - _major_type, add_info = read_info + def read_binary_string(add_info = nil) + add_info = read_byte & FIVE_BIT_MASK if add_info.nil? take(read_count(add_info)).force_encoding(Encoding::BINARY) end 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 26 then take(4).unpack1('N') - when 27 then take(8).unpack1('Q>') + when 24 then read_byte + when 25 then unpack1('n', 2) + when 26 then unpack1('N', 4) + when 27 then unpack1('Q>', 8) else raise ParseError, "Unexpected additional information: #{add_info}" end end - def read_double - read_info - take(8).unpack1('G') - end - # returns nothing but consumes and checks the type/info. def read_end_indefinite_collection - read_info - end - - def read_float - read_info - take(4).unpack1('g') + read_byte end # 16 bit IEEE 754 half-precision floats @@ -221,8 +194,7 @@ def read_float # exponent - 5 bits # precision - 10 bits def read_half - read_info - b16 = take(2).unpack1('n') + b16 = unpack1('n', 2) exp = (b16 >> 10) & 0x1f mant = b16 & 0x3ff val = @@ -243,66 +215,51 @@ def read_half end end - # return a tuple of major_type, add_info - def read_info - ib = take(1).ord - [ib >> 5, ib & FIVE_BIT_MASK] - end - def read_integer - major_type, add_info = read_info - - val = read_count(add_info) - case major_type - when 0 then val - when 1 then -1 - val - end - end - - def read_nil - read_info - nil + ib = read_byte + val = read_count(ib & FIVE_BIT_MASK) + (ib >> 5).zero? ? val : -1 - val end # returns only the length of the array, caller must read the correct # number of key value pairs after this - def read_map - _major_type, add_info = read_info + def read_map(add_info = nil) + add_info = read_byte & FIVE_BIT_MASK if add_info.nil? read_count(add_info) end - # returns nothing but consumes and checks the type/info. - # Caller must keep reading until encountering the stop sequence - def read_start_indefinite_array - read_info + def read_string(add_info = nil) + add_info = read_byte & FIVE_BIT_MASK if add_info.nil? + take(read_count(add_info)).force_encoding(Encoding::UTF_8) end - # returns nothing but consumes and checks the type/info. - # Caller must keep reading until encountering the stop sequence - def read_start_indefinite_map - read_info + def break_stop_code? + peek_byte == 0xFF end - def read_string - _major_type, add_info = read_info - take(read_count(add_info)).force_encoding(Encoding::UTF_8) - end + def peek_byte + byte = @buffer.getbyte(@pos) + return byte unless byte.nil? - # returns only the tag, caller must interpret the tag and read another - # value as appropriate - def read_tag - _major_type, add_info = read_info - read_count(add_info) + left = @buffer.bytesize - @pos + raise ParseError, "Out of bytes. Trying to read 1 bytes but buffer contains only #{left}" end - def read_reserved_undefined - _major_type, add_info = read_info - raise ParseError, "Undefined reserved additional information: #{add_info}" + def read_byte + byte = peek_byte + @pos += 1 + byte end - def read_undefined - read_info - :undefined + def unpack1(format, n_bytes) + if (@pos + n_bytes) > @buffer.bytesize + left = @buffer.bytesize - @pos + raise ParseError, "Out of bytes. Trying to read #{n_bytes} bytes but buffer contains only #{left}" + end + + value = @buffer.unpack1(format, offset: @pos) + @pos += n_bytes + value end def take(n_bytes) diff --git a/gems/smithy-cbor/lib/smithy-cbor/encoder.rb b/gems/smithy-cbor/lib/smithy-cbor/encoder.rb index 02b0bbceb..e6b7ee56c 100644 --- a/gems/smithy-cbor/lib/smithy-cbor/encoder.rb +++ b/gems/smithy-cbor/lib/smithy-cbor/encoder.rb @@ -24,6 +24,7 @@ class Encoder TAG_TYPE_BIGDEC = 4 MAX_INTEGER = 18_446_744_073_709_551_616 # 2^64 + BYTE_HEADERS = Array.new(256) { |byte| [byte].pack('C').freeze }.freeze def initialize @buffer = String.new @@ -37,17 +38,17 @@ def bytes # generic method for adding generic Ruby data based on its type def add(value) # rubocop:disable Metrics case value - when BigDecimal then add_big_decimal(value) + when String then process_string(value) when Integer then add_auto_integer(value) + when BigDecimal then add_big_decimal(value) when Numeric then add_auto_float(value) - when Symbol then add_string(value.to_s) when true, false then add_boolean(value) when nil then add_nil - when Tagged then process_tag(value) - when String then process_string(value) - when Array then add_array(value) when Hash then add_hash(value) + when Array then add_array(value) + when Symbol then add_string(value.to_s) when Time then add_time(value) + when Tagged then process_tag(value) else raise BuildError, "Unable to encode #{value}" end self @@ -173,7 +174,7 @@ def head(major_type, value) @buffer << case value when 0...24 - [major_type + value].pack('C') # 8-bit unsigned + BYTE_HEADERS[major_type + value] when 0...256 [major_type + 24, value].pack('CC') when 0...65_536 diff --git a/gems/smithy-cbor/lib/smithy-cbor/parser.rb b/gems/smithy-cbor/lib/smithy-cbor/parser.rb index 9c70e1e0c..d59889e6a 100644 --- a/gems/smithy-cbor/lib/smithy-cbor/parser.rb +++ b/gems/smithy-cbor/lib/smithy-cbor/parser.rb @@ -19,17 +19,20 @@ def parse(shape, bytes, result = nil) def parse_shape(shape, value, result = nil) return nil if value.nil? - case Schema::Extension.target_shape(shape) - when Schema::Extension::SHAPE_LIST then list(shape, value, result) - when Schema::Extension::SHAPE_MAP then map(shape, value, result) - when Schema::Extension::SHAPE_STRUCTURE then structure(shape, value, result) - when Schema::Extension::SHAPE_UNION then union(shape, value, result) + target = shape.target + case target + when Schema::Shapes::ListShape then list(shape, value, result) + when Schema::Shapes::MapShape then map(shape, value, result) + when Schema::Shapes::StructureShape then structure(shape, value, result) + when Schema::Shapes::UnionShape then union(shape, value, result) else value end end def list(shape, values, result = nil) - list_member, _target_shape, sparse = Schema::Extension.list_member(shape.target) + target = shape.target + list_member = target.member + sparse = target.traits.key?('smithy.api#sparse') result = [] if result.nil? values.each do |value| next if value.nil? && !sparse @@ -40,7 +43,9 @@ def list(shape, values, result = nil) end def map(shape, values, result = nil) - value_member, _target_shape, sparse = Schema::Extension.map_value_member(shape.target) + target = shape.target + value_member = target.value + sparse = target.traits.key?('smithy.api#sparse') result = {} if result.nil? values.each do |key, value| next if value.nil? && !sparse @@ -51,39 +56,43 @@ def map(shape, values, result = nil) end def structure(shape, values, result = nil) - result = shape.target.type.new if result.nil? - index = Schema::Extension.wire_index(shape.target) + target = shape.target + result = target.type.new if result.nil? + index = Schema::Extension.wire_index(target) values.each do |wire_name, value| next if value.nil? entry = index[wire_name] next unless entry - member_name, member_shape, _target_shape = entry + member_name, member_shape = entry result[member_name] = parse_shape(member_shape, value) end result end - def union(shape, values, result = nil) # rubocop:disable Metrics/AbcSize - index = Schema::Extension.wire_index(shape.target) + def union(shape, values, result = nil) + target = shape.target + index = Schema::Extension.wire_index(target) values.each do |wire_name, value| next if value.nil? entry = index[wire_name] next unless entry - member_name, member_shape, _target_shape = entry - result = shape.target.member_type(member_name) if result.nil? + member_name, member_shape = entry + 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 - unknown_member_type = - Schema::Extension.unknown_member_type(shape.target) || - shape.target.member_type(:unknown) - unknown_member_type.new(unknown: { key => value }) + unknown_union(target, values) + end + + def unknown_union(target, values) + values.each do |key, value| + return target.member_type(:unknown).new(unknown: { key => value }) unless key == '__type' + end + target.member_type(:unknown).new(unknown: { nil => nil }) end end end diff --git a/gems/smithy-cbor/spec/smithy-cbor/codec_spec.rb b/gems/smithy-cbor/spec/smithy-cbor/codec_spec.rb new file mode 100644 index 000000000..dc53acef9 --- /dev/null +++ b/gems/smithy-cbor/spec/smithy-cbor/codec_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require_relative '../spec_helper' + +module Smithy + module Cbor + describe Codec do + let(:shapes) { SchemaHelper.sample_shapes } + let(:sample_schema) { SchemaHelper.sample_schema(shapes: shapes) } + let(:structure_shape) { sample_schema.const_get(:Structure) } + + it 'freezes its reusable workers' do + codec = described_class.new + + expect(codec.instance_variable_get(:@builder)).to be_frozen + expect(codec.instance_variable_get(:@parser)).to be_frozen + 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(Cbor.decode(first)).to eq('string' => 'first') + expect(Cbor.decode(second)).to eq('integer' => 123) + end + + it 'reuses the same codec instance across parse calls' do + codec = described_class.new + + first = codec.parse(structure_shape, Cbor.encode('string' => 'first')) + second = codec.parse(structure_shape, Cbor.encode('integer' => 123)) + + expect(first.to_h).to eq(string: 'first') + expect(second.to_h).to eq(integer: 123) + end + + it 'supports concurrent builds on the same codec instance' do + codec = described_class.new + builds = 20.times.map do |i| + Thread.new { codec.build(structure_shape, string: "value-#{i}") } + end + + expect(builds.map { |build| Cbor.decode(build.value) }).to eq( + 20.times.map { |i| { 'string' => "value-#{i}" } } + ) + end + end + end +end diff --git a/gems/smithy-client/lib/smithy-client/default_params.rb b/gems/smithy-client/lib/smithy-client/default_params.rb index 00170731f..21bb9405e 100644 --- a/gems/smithy-client/lib/smithy-client/default_params.rb +++ b/gems/smithy-client/lib/smithy-client/default_params.rb @@ -20,10 +20,10 @@ def apply(params) private def apply_shape(shape, value) - case Schema::Extension.target_shape(shape) - when Schema::Extension::SHAPE_LIST then list(shape, value) - when Schema::Extension::SHAPE_MAP then map(shape, value) - when Schema::Extension::SHAPE_STRUCTURE then structure(shape, value) + case shape.target + when Schema::Shapes::ListShape then list(shape, value) + when Schema::Shapes::MapShape then map(shape, value) + when Schema::Shapes::StructureShape then structure(shape, value) else value end end @@ -31,7 +31,7 @@ def apply_shape(shape, value) def list(shape, values) return if values.nil? - member, = Schema::Extension.list_member(shape.target) + member = shape.target.member values.each do |value| apply_shape(member, value) end @@ -41,7 +41,7 @@ def list(shape, values) def map(shape, values) return if values.nil? - value_shape, = Schema::Extension.map_value_member(shape.target) + value_shape = shape.target.value values.each_pair do |_key, value| apply_shape(value_shape, value) end @@ -53,8 +53,9 @@ def structure(shape, values) 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 + has_default = default?(shape, member_shape.traits) + value ||= default(member_shape) if has_default + next if value.nil? && !has_default # default can have nil values values[member_name] = apply_shape(member_shape, value) end @@ -70,9 +71,9 @@ def default?(shape, traits) def default(member_shape) default = member_shape.traits['smithy.api#default'] - case Schema::Extension.target_shape(member_shape) - when Schema::Extension::SHAPE_BLOB then Base64.strict_decode64(default) - when Schema::Extension::SHAPE_TIMESTAMP then timestamp_default(default) + case member_shape.target + when Schema::Shapes::BlobShape then Base64.strict_decode64(default) + when Schema::Shapes::TimestampShape then timestamp_default(default) else default end end diff --git a/gems/smithy-client/lib/smithy-client/endpoint_rules.rb b/gems/smithy-client/lib/smithy-client/endpoint_rules.rb index 9800b002b..7b6f3bf1a 100644 --- a/gems/smithy-client/lib/smithy-client/endpoint_rules.rb +++ b/gems/smithy-client/lib/smithy-client/endpoint_rules.rb @@ -11,6 +11,8 @@ module Client # The rules engine has a set of included functions that can be # invoked without additional dependencies, called the standard library. module EndpointRules + HOST_LABEL = /\A(?!-)[a-zA-Z0-9-]{1,63}(?= stop || input.size < stop - return nil if input.chars.any? { |c| c.ord > 127 } + return nil if input.each_byte.any? { |byte| byte > 127 } return input[start...stop] unless reverse @@ -80,7 +82,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', '~') + CGI.escapeURIComponent(value.encode('UTF-8')) end # isSet(value: Option) bool diff --git a/gems/smithy-client/lib/smithy-client/http/error_inspector.rb b/gems/smithy-client/lib/smithy-client/http/error_inspector.rb index feeeb7ffc..924f51679 100644 --- a/gems/smithy-client/lib/smithy-client/http/error_inspector.rb +++ b/gems/smithy-client/lib/smithy-client/http/error_inspector.rb @@ -54,11 +54,13 @@ def throttling? end def server? - (500..599).cover?(@http_response.status_code) + status_code = @http_response.status_code + status_code >= 500 && status_code <= 599 # rubocop:disable Style/ComparableBetween end def client? - (400..499).cover?(@http_response.status_code) + status_code = @http_response.status_code + status_code >= 400 && status_code <= 499 # rubocop:disable Style/ComparableBetween end def modeled_retryable? diff --git a/gems/smithy-client/lib/smithy-client/http_extension.rb b/gems/smithy-client/lib/smithy-client/http_extension.rb index ec28b2d12..66b2bf9aa 100644 --- a/gems/smithy-client/lib/smithy-client/http_extension.rb +++ b/gems/smithy-client/lib/smithy-client/http_extension.rb @@ -4,9 +4,9 @@ module Smithy module Client # Cached Smithy HTTP binding metadata. # - # Metadata is stored under +shape[:http]+. Operation metadata contains - # +:method+, +:path+, +:static_query+, and +:response_code+. Structure - # metadata contains ordered binding entries: + # Resolved values are cached as flat, HTTP-prefixed keys on their owning + # operation or structure. Structure metadata contains ordered binding + # entries: # - headers and queries: +[ruby_name, member_shape, wire_name]+ # - prefix headers: +[ruby_name, member_shape, prefix]+ # - query params: +[ruby_name, member_shape]+ @@ -18,9 +18,6 @@ module Client # +Schema::Extension.media_type+. # @api private module HttpExtension - KEY = :http - EMPTY_ARRAY = [].freeze - EMPTY_HASH = {}.freeze BINDING_WRITERS = { 'smithy.api#httpHeader' => :add_header, 'smithy.api#httpPrefixHeaders' => :add_prefix_header, @@ -32,17 +29,42 @@ module HttpExtension }.freeze class << self - def fetch(shape) - return shape[KEY] if shape.key?(KEY) - - shape[KEY] = - if shape.is_a?(Schema::Shapes::OperationShape) - operation_metadata(shape) - elsif shape.respond_to?(:members) - shape_metadata(shape) - else - EMPTY_HASH - end + # Returns the HTTP method. + # + # Example: + # HttpExtension.http_method(operation) + # # => 'GET' + def http_method(operation) + operation[:http_method] || resolve_operation(operation, :http_method) + end + + # Returns the HTTP path without its static query string. + # + # Example: + # HttpExtension.path(operation) + # # => '/items/{id}' + def path(operation) + operation[:http_path] || resolve_operation(operation, :http_path) + end + + # Returns the static query string from the HTTP URI. + # + # Example: + # HttpExtension.static_query(operation) + # # => 'version=1' + def static_query(operation) + operation.fetch_metadata(:http_static_query) do + resolve_operation(operation, :http_static_query) + end + end + + # Returns the modeled HTTP response code. + # + # Example: + # HttpExtension.response_code(operation) + # # => 200 + def response_code(operation) + operation[:http_response_code] || resolve_operation(operation, :http_response_code) end # Returns header bindings as: @@ -52,7 +74,7 @@ def fetch(shape) # HttpExtension.header_members(shape) # # => [[:request_id, member, 'X-Request-Id']] def header_members(shape) - fetch(shape).fetch(:header_members, EMPTY_ARRAY) + shape[:http_header_members] || resolve_bindings(shape, :http_header_members) end # Returns the prefix-header binding as: @@ -62,7 +84,9 @@ def header_members(shape) # HttpExtension.prefix_header_member(shape) # # => [:metadata, member, 'x-amz-meta-'] def prefix_header_member(shape) - fetch(shape)[:prefix_header_member] + shape.fetch_metadata(:http_prefix_header_member) do + resolve_bindings(shape, :http_prefix_header_member) + end end # Returns query bindings as: @@ -72,7 +96,7 @@ def prefix_header_member(shape) # HttpExtension.query_members(shape) # # => [[:page_size, member, 'pageSize']] def query_members(shape) - fetch(shape).fetch(:query_members, EMPTY_ARRAY) + shape[:http_query_members] || resolve_bindings(shape, :http_query_members) end # Returns the query-params binding as: @@ -82,7 +106,9 @@ def query_members(shape) # HttpExtension.query_params_member(shape) # # => [:filters, member] def query_params_member(shape) - fetch(shape)[:query_params_member] + shape.fetch_metadata(:http_query_params_member) do + resolve_bindings(shape, :http_query_params_member) + end end # Returns labels indexed by modeled member name. @@ -91,7 +117,7 @@ def query_params_member(shape) # HttpExtension.label_index(shape) # # => { 'bucket' => [:bucket, member] } def label_index(shape) - fetch(shape).fetch(:label_index, EMPTY_HASH) + shape[:http_label_index] || resolve_bindings(shape, :http_label_index) end # Returns members serialized in the document body. @@ -100,7 +126,7 @@ def label_index(shape) # HttpExtension.body_members(shape) # # => [[:name, member]] def body_members(shape) - fetch(shape).fetch(:body_members, EMPTY_ARRAY) + shape[:http_body_members] || resolve_bindings(shape, :http_body_members) end # Returns the payload binding as: @@ -110,7 +136,9 @@ def body_members(shape) # HttpExtension.payload_member(shape) # # => [:body, member, :raw, 'application/octet-stream'] def payload_member(shape) - fetch(shape)[:payload_member] + shape.fetch_metadata(:http_payload_member) do + resolve_bindings(shape, :http_payload_member) + end end # Returns the response-code binding as: @@ -120,25 +148,31 @@ def payload_member(shape) # HttpExtension.response_code_member(shape) # # => [:status_code, member] def response_code_member(shape) - fetch(shape)[:response_code_member] + shape.fetch_metadata(:http_response_code_member) do + resolve_bindings(shape, :http_response_code_member) + end end private - def operation_metadata(operation) + def resolve_operation(operation, result) http = operation.traits['smithy.api#http'] || {} path, static_query = (http['uri'] || '/').split('?', 2) - { method: http['method'] || 'POST', path: path, static_query: static_query, - response_code: http.fetch('code', 200) }.compact.freeze + operation[:http_method] = http['method'] || 'POST' + operation[:http_path] = path + operation[:http_static_query] = static_query + operation[:http_response_code] = http.fetch('code', 200) + operation[result] end - def shape_metadata(shape) + def resolve_bindings(shape, result) metadata = { header_members: [], query_members: [], label_index: {}, body_members: [] } shape.members.each do |name, member| add_member_binding(metadata, name, member) end metadata.each_value { |value| value.freeze if value.respond_to?(:freeze) } - metadata.freeze + metadata.each { |key, value| shape[:"http_#{key}"] = value } + shape[result] end def add_member_binding(metadata, name, member) @@ -186,8 +220,9 @@ def payload_type(member) end def content_type(member) - Schema::Extension.media_type(member.target) || - case member.target + target = member.target + Schema::Extension.media_type(target) || + case target when Schema::Shapes::BlobShape then 'application/octet-stream' when Schema::Shapes::StringShape, Schema::Shapes::EnumShape then 'text/plain' end diff --git a/gems/smithy-client/lib/smithy-client/log_param_filter.rb b/gems/smithy-client/lib/smithy-client/log_param_filter.rb index d5b0697dc..4816242bf 100644 --- a/gems/smithy-client/lib/smithy-client/log_param_filter.rb +++ b/gems/smithy-client/lib/smithy-client/log_param_filter.rb @@ -4,18 +4,16 @@ module Smithy module Client # @api private class LogParamFilter - include Schema::Shapes - def initialize(options = {}) @filter_sensitive_params = options.fetch(:filter_sensitive_params, true) end def filter(shape, values) case shape.target - when ListShape then list(shape, values) - when MapShape then map(shape, values) - when StructureShape then structure(shape, values) - when UnionShape then union(shape, values) + when Schema::Shapes::ListShape then list(shape, values) + when Schema::Shapes::MapShape then map(shape, values) + when Schema::Shapes::StructureShape then structure(shape, values) + when Schema::Shapes::UnionShape then union(shape, values) else scalar(shape, values) end end @@ -81,7 +79,7 @@ def union(shape, values) end def sensitive?(shape) - @filter_sensitive_params && shape.traits.key?('smithy.api#sensitive') + @filter_sensitive_params && Schema::Extension.sensitive?(shape) 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 6cca6ac3d..baa7963e2 100644 --- a/gems/smithy-client/lib/smithy-client/param_converter.rb +++ b/gems/smithy-client/lib/smithy-client/param_converter.rb @@ -40,11 +40,11 @@ def c(shape, value) end def convert_shape(shape, value) - case Schema::Extension.target_shape(shape) - when Schema::Extension::SHAPE_LIST then list(shape, value) - when Schema::Extension::SHAPE_MAP then map(shape, value) - when Schema::Extension::SHAPE_STRUCTURE then structure(shape, value) - when Schema::Extension::SHAPE_UNION then union(shape, value) + case shape.target + when Schema::Shapes::ListShape then list(shape, value) + when Schema::Shapes::MapShape then map(shape, value) + when Schema::Shapes::StructureShape then structure(shape, value) + when Schema::Shapes::UnionShape then union(shape, value) else c(shape, value) end end @@ -53,7 +53,7 @@ def list(shape, values) values = c(shape, values) return values unless values.is_a?(Array) - member, = Schema::Extension.list_member(shape.target) + member = shape.target.member values.collect { |v| convert_shape(member, v) } end @@ -61,22 +61,25 @@ def map(shape, values) values = c(shape, values) return values unless values.is_a?(Hash) - key_member, = Schema::Extension.map_key_member(shape.target) - value_member, = Schema::Extension.map_value_member(shape.target) - values.each.with_object({}) do |(key, value), hash| + key_member = shape.target.key + value_member = shape.target.value + hash = {} + values.each do |key, value| hash[convert_shape(key_member, key)] = convert_shape(value_member, value) end + hash end def structure(shape, values) values = c(shape, values) return values unless values.respond_to?(:each_pair) + target = shape.target values.each_pair do |k, v| next if v.nil? - next unless shape.target.member?(k) + next unless target.member?(k) - values[k] = convert_shape(shape.target.member(k), v) + values[k] = convert_shape(target.member(k), v) end values end @@ -219,7 +222,9 @@ def each_base_class(shape_class, &) add(MapShape, Hash) { |h, _| h.dup } add(MapShape, ::Struct) do |s| - s.members.each.with_object({}) { |k, h| h[k] = s[k] } + hash = {} + s.members.each { |member| hash[member] = s[member] } + hash end add(StringShape, String) diff --git a/gems/smithy-client/lib/smithy-client/param_validator.rb b/gems/smithy-client/lib/smithy-client/param_validator.rb index 1c1427e0e..a6f2a76cd 100644 --- a/gems/smithy-client/lib/smithy-client/param_validator.rb +++ b/gems/smithy-client/lib/smithy-client/param_validator.rb @@ -29,25 +29,25 @@ def validate!(params, context: 'params') # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity def validate_shape(shape, value, errors, context) - case Schema::Extension.target_shape(shape) - when Schema::Extension::SHAPE_STRUCTURE then structure(shape, value, errors, context) - when Schema::Extension::SHAPE_LIST then list(shape, value, errors, context) - when Schema::Extension::SHAPE_MAP then map(shape, value, errors, context) - when Schema::Extension::SHAPE_DOCUMENT then document(shape, value, errors, context) - when Schema::Extension::SHAPE_UNION then union(shape, value, errors, context) - when Schema::Extension::SHAPE_STRING, Schema::Extension::SHAPE_ENUM + case shape.target + when Schema::Shapes::StructureShape then structure(shape, value, errors, context) + when Schema::Shapes::ListShape then list(shape, value, errors, context) + when Schema::Shapes::MapShape then map(shape, value, errors, context) + when Schema::Shapes::DocumentShape then document(shape, value, errors, context) + when Schema::Shapes::UnionShape then union(shape, value, errors, context) + when Schema::Shapes::StringShape, Schema::Shapes::EnumShape errors << expected_got(context, 'a String', value) unless value.is_a?(String) - when Schema::Extension::SHAPE_INTEGER, Schema::Extension::SHAPE_INT_ENUM + when Schema::Shapes::IntegerShape, Schema::Shapes::IntEnumShape errors << expected_got(context, 'an Integer', value) unless value.is_a?(Integer) - when Schema::Extension::SHAPE_BIG_DECIMAL + when Schema::Shapes::BigDecimalShape errors << expected_got(context, 'a BigDecimal', value) unless value.is_a?(BigDecimal) - when Schema::Extension::SHAPE_FLOAT + when Schema::Shapes::FloatShape errors << expected_got(context, 'a Float', value) unless value.is_a?(Float) - when Schema::Extension::SHAPE_TIMESTAMP + when Schema::Shapes::TimestampShape errors << expected_got(context, 'a Time object', value) unless value.is_a?(Time) - when Schema::Extension::SHAPE_BOOLEAN + when Schema::Shapes::BooleanShape errors << expected_got(context, 'true or false', value) unless [true, false].include?(value) - when Schema::Extension::SHAPE_BLOB + when Schema::Shapes::BlobShape blob(shape, value, errors, context) end end @@ -91,7 +91,7 @@ def list(shape, values, errors, context) return end - member, = Schema::Extension.list_member(shape.target) + member = shape.target.member values.each.with_index do |value, index| next unless value @@ -105,8 +105,8 @@ def map(shape, values, errors, context) return end - key_member, = Schema::Extension.map_key_member(shape.target) - value_member, = Schema::Extension.map_value_member(shape.target) + key_member = shape.target.key + value_member = shape.target.value values.each do |key, value| validate_shape(key_member, key, errors, "#{context} #{key.inspect} key") next unless value @@ -116,8 +116,9 @@ def map(shape, values, errors, context) end def member(shape, name, value, errors, context) - if shape.target.member?(name) - member_shape = shape.target.member(name) + target = shape.target + if target.member?(name) + member_shape = target.member(name) validate_shape(member_shape, value, errors, context + "[#{name.inspect}]") else errors << "unexpected value at #{context}[#{name.inspect}]" 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 8b3bad6a9..4ca094c2d 100644 --- a/gems/smithy-client/lib/smithy-client/plugins/host_prefix.rb +++ b/gems/smithy-client/lib/smithy-client/plugins/host_prefix.rb @@ -33,25 +33,23 @@ def add_handlers(handlers, config) # @api private class Handler < Smithy::Client::Handler def call(context) - host_prefix = Schema::Extension.endpoint_host_prefix(context.operation) - apply_host_prefix(context, host_prefix) if host_prefix + plan = Schema::Extension.endpoint_host_prefix_plan(context.operation) + apply_host_prefix(context, plan) if plan @handler.call(context) end private - # TODO: optimize this to collect all labels in one pass - def apply_host_prefix(context, host_prefix) - host_labels = Schema::Extension.host_label_index(context.operation.input) - prefix = host_prefix.gsub(/\{.+?}/) do |label| - label_value(host_labels, label.delete('{}'), context.params) + def apply_host_prefix(context, plan) + prefix = +'' + plan.each do |part| + value = part.is_a?(Symbol) ? label_value(part, context.params) : part + prefix << value end - context.http_request.endpoint.host = prefix + context.http_request.endpoint.host + context.http_request.endpoint.host = prefix << context.http_request.endpoint.host end - def label_value(host_labels, label, params) - name = host_labels[label] - raise ArgumentError, "#{label} is not a valid host label" if name.nil? + def label_value(name, params) raise ArgumentError, "params[:#{name}] must not be nil or blank" if params[name].nil? || params[name].empty? params[name] 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 343f5910a..70076c5dc 100644 --- a/gems/smithy-client/lib/smithy-client/rpc_v2_cbor.rb +++ b/gems/smithy-client/lib/smithy-client/rpc_v2_cbor.rb @@ -32,7 +32,8 @@ def parse_data(context) # @param [HandlerContext] context # @return [StandardError, nil] def parse_error(context) - return unless (200..599).cover?(context.http_response.status_code) + status_code = context.http_response.status_code + return unless status_code >= 200 && status_code <= 599 # rubocop:disable Style/ComparableBetween # Malformed responses should raise an http-based error, so we validate # the protocol header across the full 200..599 range. @@ -40,7 +41,7 @@ def parse_error(context) code, data = http_status_error(context) return build_error(context, code, data) end - return unless (400..599).cover?(context.http_response.status_code) + return unless status_code >= 400 && status_code <= 599 # rubocop:disable Style/ComparableBetween error(context) end diff --git a/gems/smithy-client/sig/smithy-client/http_extension.rbs b/gems/smithy-client/sig/smithy-client/http_extension.rbs index 4acd8e63d..52791f146 100644 --- a/gems/smithy-client/sig/smithy-client/http_extension.rbs +++ b/gems/smithy-client/sig/smithy-client/http_extension.rbs @@ -1,7 +1,10 @@ module Smithy module Client module HttpExtension - def self.fetch: (Schema::Shapes::Shape shape) -> Hash[Symbol, untyped] + def self.http_method: (Schema::Shapes::OperationShape operation) -> String + def self.path: (Schema::Shapes::OperationShape operation) -> String + def self.static_query: (Schema::Shapes::OperationShape operation) -> String? + def self.response_code: (Schema::Shapes::OperationShape operation) -> Integer def self.header_members: (Schema::Shapes::Shape shape) -> Array[untyped] def self.prefix_header_member: (Schema::Shapes::Shape shape) -> untyped def self.query_members: (Schema::Shapes::Shape shape) -> Array[untyped] diff --git a/gems/smithy-client/spec/smithy-client/http_extension_spec.rb b/gems/smithy-client/spec/smithy-client/http_extension_spec.rb index 6f21332eb..61162de30 100644 --- a/gems/smithy-client/spec/smithy-client/http_extension_spec.rb +++ b/gems/smithy-client/spec/smithy-client/http_extension_spec.rb @@ -5,15 +5,19 @@ module Smithy module Client describe HttpExtension do - it 'caches HTTP operation metadata' do + it 'caches flat HTTP operation metadata' do operation = Schema::Shapes::OperationShape.new( traits: { 'smithy.api#http' => { 'method' => 'GET', 'uri' => '/things?x=1', 'code' => 204 } } ) - expect(described_class.fetch(operation)).to include( - method: 'GET', path: '/things', static_query: 'x=1', response_code: 204 - ) - expect(described_class.fetch(operation)).to be(described_class.fetch(operation)) + expect(described_class.http_method(operation)).to eq('GET') + expect(described_class.path(operation)).to eq('/things') + expect(described_class.static_query(operation)).to eq('x=1') + expect(described_class.response_code(operation)).to eq(204) + expect(operation[:http_method]).to eq('GET') + expect(operation[:http_path]).to eq('/things') + expect(operation[:http_static_query]).to eq('x=1') + expect(operation[:http_response_code]).to eq(204) end it 'indexes member bindings and payload media types' do @@ -59,6 +63,9 @@ module Client expect(described_class.query_params_member(shape)).to eq([:query_params, query_params]) expect(described_class.payload_member(shape).last).to eq('application/custom') expect(described_class.response_code_member(shape)).to eq([:response_code, response_code]) + expect(shape[:http_header_members]).to be(described_class.header_members(shape)) + expect(shape[:http_query_members]).to be(described_class.query_members(shape)) + expect(shape[:http_body_members]).to be(described_class.body_members(shape)) end end end diff --git a/gems/smithy-json/lib/smithy-json/builder.rb b/gems/smithy-json/lib/smithy-json/builder.rb index 0458c3453..d2bea7cf4 100644 --- a/gems/smithy-json/lib/smithy-json/builder.rb +++ b/gems/smithy-json/lib/smithy-json/builder.rb @@ -8,6 +8,7 @@ module Json class Builder def initialize(options = {}) @json_name = options[:json_name] || false + @extension = @json_name ? Extension : Schema::Extension @default_timestamp = options.fetch(:default_timestamp, 'epoch-seconds') end @@ -18,14 +19,15 @@ def build(shape, data) private def build_shape(shape, value) # rubocop:disable Metrics/CyclomaticComplexity - case Schema::Extension.target_shape(shape) - when Schema::Extension::SHAPE_BLOB then blob(value) - when Schema::Extension::SHAPE_FLOAT then float(value) - when Schema::Extension::SHAPE_LIST then list(shape, value) - when Schema::Extension::SHAPE_MAP then map(shape, value) - when Schema::Extension::SHAPE_STRUCTURE then structure(shape, value) - when Schema::Extension::SHAPE_TIMESTAMP then timestamp(shape, value) - when Schema::Extension::SHAPE_UNION then union(shape, value) + target = shape.target + case target + when Schema::Shapes::BlobShape then blob(value) + when Schema::Shapes::FloatShape then float(value) + when Schema::Shapes::ListShape then list(shape, value) + when Schema::Shapes::MapShape then map(shape, value) + when Schema::Shapes::StructureShape then structure(shape, value) + when Schema::Shapes::TimestampShape then timestamp(shape, value) + when Schema::Shapes::UnionShape then union(shape, value) else value end end @@ -49,7 +51,7 @@ def float(value) def list(shape, values) return if values.nil? - member, _target_shape, _sparse = Schema::Extension.list_member(shape.target) + member = shape.target.member values.collect do |value| build_shape(member, value) end @@ -58,23 +60,27 @@ def list(shape, values) def map(shape, values) return if values.nil? - value_member, _target_shape, _sparse = Schema::Extension.map_value_member(shape.target) - values.each.with_object({}) do |(key, value), data| + value_member = shape.target.value + data = {} + values.each do |key, value| data[key] = build_shape(value_member, value) end + data end def structure(shape, values) return if values.nil? - index = member_index(shape.target) - values.each_pair.with_object({}) do |(member_name, value), data| + index = @extension.member_index(shape.target) + data = {} + values.each_pair do |member_name, value| next if value.nil? next unless (entry = index[member_name]) - wire_name, member_shape, _target_shape = entry + wire_name, member_shape = entry data[wire_name] = build_shape(member_shape, value) end + data end def timestamp(shape, value) @@ -89,25 +95,16 @@ def union(shape, values) key, value = if values.is_a?(Schema::Union) - member_name, _member_shape = shape.target.member_by_type(values.class) - [member_name, values.value] + [values.member, values.value] else values.first end - entry = member_index(shape.target)[key] + entry = @extension.member_index(shape.target)[key] return {} unless entry - wire_name, member_shape, _target_shape = entry + wire_name, member_shape = entry { wire_name => build_shape(member_shape, value) } end - - def member_index(shape) - if @json_name - Extension.member_index(shape) - else - Schema::Extension.member_index(shape) - end - end 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..ddcda15dd 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).freeze + @parser = Parser.new(options).freeze 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 726985ea3..96e48c694 100644 --- a/gems/smithy-json/lib/smithy-json/extension.rb +++ b/gems/smithy-json/lib/smithy-json/extension.rb @@ -4,100 +4,66 @@ module Smithy module Json # JSON-specific lookup helpers and cached serde metadata. # - # Raw Smithy trait data remains on +member.traits+ with string keys. This - # extension resolves JSON wire names and member indexes on demand, then - # caches them under +object[KEY]+. Generic shape and trait metadata remains - # owned by Schema::Extension. + # Raw Smithy trait data remains on +member.traits+ with string keys. + # Resolved JSON values are cached as flat, JSON-prefixed keys on their + # owning shape or member. # @api private module Extension - KEY = :json - class << self - # Resolves and returns JSON metadata for a structure, union, or member. - # - # Example: - # Extension.fetch(member) - # # => { json_name: 'wireName' } - def fetch(shape) - return shape[KEY] if shape.key?(KEY) - - shape[KEY] = - case shape - when Schema::Shapes::StructureShape, Schema::Shapes::UnionShape - build_structure_metadata(shape) - when Schema::Shapes::MemberShape - build_member_metadata(shape) - end - end - - # Returns the JSON parse lookup index cached in structure or union - # metadata. - # - # The index maps: - # - resolved JSON wire name - # - to [ruby_member_name, member_shape, target_shape_ref] + # Returns the JSON parse lookup index cached on a structure or union. # # Example: - # Extension.wire_index(shape) - # # => { 'wireName' => [:ruby_name, member, Schema::Extension::SHAPE_STRING] } + # Extension.wire_index(shape)['wireName'] + # # => [:ruby_name, member] def wire_index(shape) - fetch(shape)[:json_wire_index] + shape[:json_wire_index] || resolve_indexes(shape, :json_wire_index) end - # Returns the JSON build lookup index cached in structure or union - # metadata. - # - # The index maps: - # - Ruby member name - # - to [resolved JSON wire name, member_shape, target_shape_ref] + # Returns the JSON build lookup index cached on a structure or union. # # Example: - # Extension.member_index(shape) - # # => { ruby_name: ['wireName', member, Schema::Extension::SHAPE_STRING] } + # Extension.member_index(shape)[:ruby_name] + # # => ['wireName', member] def member_index(shape) - fetch(shape)[:json_member_index] + shape[:json_member_index] || resolve_indexes(shape, :json_member_index) end - # Returns the effective JSON member name: +smithy.api#jsonName+ when - # present, otherwise the modeled member name. + # Returns the effective JSON member name. # # Example: # Extension.wire_name(member) # # => 'wireName' def wire_name(member) - fetch(member)[:json_name] + member.fetch_metadata(:json_name) do + member.traits['smithy.api#jsonName'] || member.name + end end # Returns the resolved timestamp format for JSON serialization. # # Example: # Extension.timestamp_format(member) - # # => 'date-time' + # # => 'epoch-seconds' def timestamp_format(shape) Schema::Extension.timestamp_format(shape) end private - def build_structure_metadata(shape) - json_wire_index = {} - json_member_index = {} - + def resolve_indexes(shape, result) + wire_index = {} + member_index = {} Schema::Extension.each_member(shape) do |member_name, member_shape| json_name = wire_name(member_shape) - target_shape = Schema::Extension.target_shape(member_shape) - json_wire_index[json_name] = [member_name, member_shape, target_shape].freeze - json_member_index[member_name] = [json_name, member_shape, target_shape].freeze + wire_index[json_name] = [member_name, member_shape].freeze + member_index[member_name] = [json_name, member_shape].freeze end - { - json_wire_index: json_wire_index.freeze, - json_member_index: json_member_index.freeze - }.freeze - end - - def build_member_metadata(member) - { json_name: member.traits['smithy.api#jsonName'] || member.name }.freeze + wire_index.freeze + member_index.freeze + shape[:json_wire_index] = wire_index + shape[:json_member_index] = member_index + result == :json_wire_index ? wire_index : member_index end end end diff --git a/gems/smithy-json/lib/smithy-json/parser.rb b/gems/smithy-json/lib/smithy-json/parser.rb index 80fb50cc5..706d1b9b0 100644 --- a/gems/smithy-json/lib/smithy-json/parser.rb +++ b/gems/smithy-json/lib/smithy-json/parser.rb @@ -8,6 +8,7 @@ module Json class Parser def initialize(options = {}) @json_name = options[:json_name] || false + @extension = @json_name ? Extension : Schema::Extension end def parse(shape, bytes, result = nil) @@ -19,14 +20,15 @@ def parse(shape, bytes, result = nil) private def parse_shape(shape, value, result = nil) # rubocop:disable Metrics/CyclomaticComplexity - case Schema::Extension.target_shape(shape) - when Schema::Extension::SHAPE_BLOB then Base64.decode64(value) - when Schema::Extension::SHAPE_FLOAT then float(value) - when Schema::Extension::SHAPE_LIST then list(shape, value, result) - when Schema::Extension::SHAPE_MAP then map(shape, value, result) - when Schema::Extension::SHAPE_STRUCTURE then structure(shape, value, result) - when Schema::Extension::SHAPE_TIMESTAMP then timestamp(shape, value) - when Schema::Extension::SHAPE_UNION then union(shape, value, result) + target = shape.target + case target + when Schema::Shapes::BlobShape then Base64.decode64(value) + when Schema::Shapes::FloatShape then float(value) + when Schema::Shapes::ListShape then list(shape, value, result) + when Schema::Shapes::MapShape then map(shape, value, result) + when Schema::Shapes::StructureShape then structure(shape, value, result) + when Schema::Shapes::TimestampShape then timestamp(shape, value) + when Schema::Shapes::UnionShape then union(shape, value, result) else value end end @@ -43,7 +45,9 @@ def float(value) def list(shape, values, result = nil) return if values.nil? - member, _target_shape, sparse = Schema::Extension.list_member(shape.target) + target = shape.target + member = target.member + sparse = target.traits.key?('smithy.api#sparse') result = [] if result.nil? values.each do |value| next if value.nil? && !sparse @@ -54,7 +58,9 @@ def list(shape, values, result = nil) end def map(shape, values, result = nil) - value_member, _target_shape, sparse = Schema::Extension.map_value_member(shape.target) + target = shape.target + value_member = target.value + sparse = target.traits.key?('smithy.api#sparse') result = {} if result.nil? values.each do |key, value| next if value.nil? && !sparse @@ -67,15 +73,16 @@ def map(shape, values, result = nil) def structure(shape, values, result = nil) return if values.nil? - result = shape.target.type.new if result.nil? - index = wire_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? entry = index[wire_name] next unless entry - member_name, member_shape, _target_shape = entry + member_name, member_shape = entry result[member_name] = parse_shape(member_shape, value) end result @@ -87,33 +94,28 @@ def timestamp(shape, value) Schema::Utils.deserialize_timestamp(value, format) end - def union(shape, values, result = nil) # rubocop:disable Metrics/AbcSize - index = wire_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? entry = index[wire_name] next unless entry - member_name, member_shape, _target_shape = entry - result = shape.target.member_type(member_name) if result.nil? + member_name, member_shape = entry + 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 - unknown_member_type = - Schema::Extension.unknown_member_type(shape.target) || - shape.target.member_type(:unknown) - unknown_member_type.new(unknown: { key => value }) + unknown_union(target, values) end - def wire_index(shape) - if @json_name - Extension.wire_index(shape) - else - Schema::Extension.wire_index(shape) + def unknown_union(target, values) + values.each do |key, value| + return target.member_type(:unknown).new(unknown: { key => value }) unless key == '__type' end + target.member_type(:unknown).new(unknown: { nil => nil }) end end end diff --git a/gems/smithy-json/sig/smithy-json/extension.rbs b/gems/smithy-json/sig/smithy-json/extension.rbs index 7d7225836..d0374543f 100644 --- a/gems/smithy-json/sig/smithy-json/extension.rbs +++ b/gems/smithy-json/sig/smithy-json/extension.rbs @@ -2,11 +2,8 @@ module Smithy module Json module Extension type aggregate_shape = Schema::Shapes::StructureShape | Schema::Shapes::UnionShape - type serde_shape = aggregate_shape | Schema::Shapes::MemberShape - - def self.fetch: (serde_shape shape) -> Hash[Symbol, untyped] - def self.wire_index: (aggregate_shape shape) -> Hash[String?, [Symbol, Schema::Shapes::MemberShape, Integer?]] - def self.member_index: (aggregate_shape shape) -> Hash[Symbol, [String?, Schema::Shapes::MemberShape, Integer?]] + def self.wire_index: (aggregate_shape shape) -> Hash[String?, [Symbol, Schema::Shapes::MemberShape]] + def self.member_index: (aggregate_shape shape) -> Hash[Symbol, [String?, Schema::Shapes::MemberShape]] def self.timestamp_format: ((Schema::Shapes::Shape | Schema::Shapes::MemberShape) shape) -> (String | Symbol) def self.wire_name: (Schema::Shapes::MemberShape member) -> String? 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..033f278fc --- /dev/null +++ b/gems/smithy-json/spec/smithy-json/codec_spec.rb @@ -0,0 +1,40 @@ +# 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) } + + it 'freezes its reusable workers' do + codec = described_class.new + + expect(codec.instance_variable_get(:@builder)).to be_frozen + expect(codec.instance_variable_get(:@parser)).to be_frozen + 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(Smithy::Json.load(first)).to eq('string' => 'first') + expect(Smithy::Json.load(second)).to eq('integer' => 123) + end + + 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 diff --git a/gems/smithy-json/spec/smithy-json/extension_spec.rb b/gems/smithy-json/spec/smithy-json/extension_spec.rb index a3cebd727..f73848f6a 100644 --- a/gems/smithy-json/spec/smithy-json/extension_spec.rb +++ b/gems/smithy-json/spec/smithy-json/extension_spec.rb @@ -26,12 +26,13 @@ module Json shape.add_member(:json_named, json_named_member) expect(described_class.wire_index(shape)).to eq( - 'plainName' => [:plain_name, plain_member, Schema::Extension::SHAPE_STRING], - 'wireName' => [:json_named, json_named_member, Schema::Extension::SHAPE_STRING] + 'plainName' => [:plain_name, plain_member], + 'wireName' => [:json_named, json_named_member] ) expect(described_class.wire_index(shape)).to be_frozen - expect(plain_member[:json][:json_name]).to eq('plainName') - expect(json_named_member[:json][:json_name]).to eq('wireName') + expect(shape[:json_wire_index]).to be(described_class.wire_index(shape)) + expect(plain_member[:json_name]).to eq('plainName') + expect(json_named_member[:json_name]).to eq('wireName') end end @@ -42,8 +43,8 @@ module Json shape.add_member(:json_named, json_named_member) expect(described_class.member_index(shape)).to eq( - plain_name: ['plainName', plain_member, Schema::Extension::SHAPE_STRING], - json_named: ['wireName', json_named_member, Schema::Extension::SHAPE_STRING] + plain_name: ['plainName', plain_member], + json_named: ['wireName', json_named_member] ) expect(described_class.member_index(shape)).to be_frozen end @@ -52,12 +53,12 @@ module Json describe '.wire_name' do it 'returns jsonName when present' do expect(described_class.wire_name(json_named_member)).to eq('wireName') - expect(json_named_member[:json][:json_name]).to eq('wireName') + expect(json_named_member[:json_name]).to eq('wireName') end it 'falls back to the member name' do expect(described_class.wire_name(plain_member)).to eq('plainName') - expect(plain_member[:json][:json_name]).to eq('plainName') + expect(plain_member[:json_name]).to eq('plainName') end end end diff --git a/gems/smithy-schema/lib/smithy-schema/document_utils/deserializer.rb b/gems/smithy-schema/lib/smithy-schema/document_utils/deserializer.rb index 6a4ead535..a811676f7 100644 --- a/gems/smithy-schema/lib/smithy-schema/document_utils/deserializer.rb +++ b/gems/smithy-schema/lib/smithy-schema/document_utils/deserializer.rb @@ -54,9 +54,10 @@ def float(value) def list(shape, values, result = nil) return if values.nil? + member = shape.target.member result = [] if result.nil? values.each do |value| - result << deserialize_shape(shape.target.member, value) unless value.nil? + result << deserialize_shape(member, value) unless value.nil? end result end @@ -64,9 +65,10 @@ def list(shape, values, result = nil) def map(shape, values, result = nil) return if values.nil? + value_member = shape.target.value result = {} if result.nil? values.each do |key, value| - result[key] = deserialize_shape(shape.target.value, value) unless value.nil? + result[key] = deserialize_shape(value_member, value) unless value.nil? end result end @@ -74,10 +76,10 @@ def map(shape, values, result = nil) def structure(shape, values, result = nil) return if values.nil? - result = shape.target.type.new if result.nil? - Smithy::Schema::Extension.wire_index(shape.target).each do |wire_name, entry| - member_name, member_shape, _target_shape = entry - value = values[wire_name] + target = shape.target + result = target.type.new if result.nil? + target.members.each do |member_name, member_shape| + value = values[member_shape.name] result[member_name] = deserialize_shape(member_shape, value) unless value.nil? end result @@ -89,20 +91,24 @@ def timestamp(shape, value) Utils.deserialize_timestamp(value, format) end - def union(shape, values, result = nil) # rubocop:disable Metrics/AbcSize - index = Smithy::Schema::Extension.wire_index(shape.target) - values.each do |wire_name, value| + def union(shape, values, result = nil) + target = shape.target + target.members.each do |member_name, member_shape| + value = values[member_shape.name] next if value.nil? - next unless (entry = index[wire_name]) - 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 => deserialize_shape(member_shape, value)) end - values.delete('__type') - key, value = values.first - shape.target.member_type(:unknown).new(key, value) + unknown_union(target, values) + end + + def unknown_union(target, values) + values.each do |key, value| + return target.member_type(:unknown).new(key, value) unless key == '__type' + end + target.member_type(:unknown).new(nil, nil) end end end diff --git a/gems/smithy-schema/lib/smithy-schema/document_utils/serializer.rb b/gems/smithy-schema/lib/smithy-schema/document_utils/serializer.rb index 4ae7eeb28..09b410f83 100644 --- a/gems/smithy-schema/lib/smithy-schema/document_utils/serializer.rb +++ b/gems/smithy-schema/lib/smithy-schema/document_utils/serializer.rb @@ -30,9 +30,11 @@ def serialize_untyped(values) case values when Time then Utils.serialize_timestamp(values, 'epoch-seconds') when Hash - values.each_with_object({}) do |(k, v), h| - h[k.to_s] = serialize_untyped(v) + data = {} + values.each do |k, v| + data[k.to_s] = serialize_untyped(v) end + data when Array then values.map { |d| serialize_untyped(d) } else values end @@ -101,18 +103,23 @@ def map(shape, values) return if values.nil? value_shape = shape.target.value - values.each.with_object({}) do |(key, value), data| + data = {} + values.each do |key, value| data[key.to_s] = serialize_shape(value_shape, value) end + data end def structure(shape, values) return if values.nil? - shape.target.members.each_with_object({}) do |(member_name, member_shape), data| - value = resolve_value(member_name, member_shape, values.to_h) + values = values.to_h + data = {} + shape.target.members.each do |member_name, member_shape| + value = resolve_value(member_name, member_shape, values) data[wire_name(member_shape)] = serialize_shape(member_shape, value) unless value.nil? end + data end def timestamp(shape, value) @@ -156,9 +163,10 @@ def normalize_timestamp_value(value) end def resolve_member_shape(shape, name) - return shape.target.member(name) if shape.target.member?(name) + target = shape.target + return target.member(name) if target.member?(name) - shape.target.members.values.find do |member_shape| + target.members.values.find do |member_shape| member_shape.traits['smithy.api#jsonName'] == name || member_shape.name == name end end diff --git a/gems/smithy-schema/lib/smithy-schema/extension.rb b/gems/smithy-schema/lib/smithy-schema/extension.rb index 83300646c..acae91995 100644 --- a/gems/smithy-schema/lib/smithy-schema/extension.rb +++ b/gems/smithy-schema/lib/smithy-schema/extension.rb @@ -5,227 +5,228 @@ module Schema # Cached schema metadata shared by Smithy protocol codecs. # # Raw Smithy trait data remains on +shape.traits+ and +member.traits+ with - # string keys. This module resolves generic shape classification and - # modeled-member indexes. Protocol extensions own wire-specific metadata. + # string keys. Resolved values are cached as flat, schema-prefixed keys on + # their owning shape, member, or operation. # @api private - # rubocop:disable-next Metrics/ModuleLength module Extension - KEY = :schema - - SHAPE_LIST = 1 - SHAPE_MAP = 2 - SHAPE_STRUCTURE = 3 - SHAPE_UNION = 4 - SHAPE_BLOB = 5 - SHAPE_FLOAT = 6 - SHAPE_TIMESTAMP = 7 - SHAPE_BIG_DECIMAL = 8 - SHAPE_BOOLEAN = 9 - SHAPE_DOCUMENT = 10 - SHAPE_ENUM = 11 - SHAPE_INTEGER = 12 - SHAPE_INT_ENUM = 13 - SHAPE_STRING = 14 - - SHAPE_REF_BY_CLASS = { - Shapes::ListShape => SHAPE_LIST, - Shapes::MapShape => SHAPE_MAP, - Shapes::StructureShape => SHAPE_STRUCTURE, - Shapes::UnionShape => SHAPE_UNION, - Shapes::BlobShape => SHAPE_BLOB, - Shapes::FloatShape => SHAPE_FLOAT, - Shapes::TimestampShape => SHAPE_TIMESTAMP, - Shapes::BigDecimalShape => SHAPE_BIG_DECIMAL, - Shapes::BooleanShape => SHAPE_BOOLEAN, - Shapes::DocumentShape => SHAPE_DOCUMENT, - Shapes::EnumShape => SHAPE_ENUM, - Shapes::IntegerShape => SHAPE_INTEGER, - Shapes::IntEnumShape => SHAPE_INT_ENUM, - Shapes::StringShape => SHAPE_STRING - }.freeze - class << self - # Returns the complete cached Schema metadata payload. - # - # Example: - # Extension.fetch(shape) - # # => { target_shape: Extension::SHAPE_STRUCTURE, ... } - def fetch(shape) - return shape[KEY] if shape.key?(KEY) - - shape[KEY] = - case shape - when Shapes::OperationShape - build_operation_metadata(shape) - when Shapes::StructureShape, Shapes::UnionShape - build_aggregate_metadata(shape) - when Shapes::MemberShape - build_member_metadata(shape) - else - build_shape_metadata(shape) - end - end - # Returns the modeled wire-name lookup used by existing serde # consumers. The index maps modeled member name to - # [ruby_member_name, member_shape, target_shape_ref]. + # [ruby_member_name, member_shape]. # # Example: - # Extension.wire_index(shape) - # # => { 'wireName' => [:ruby_name, member, Extension::SHAPE_STRING] } + # Extension.wire_index(shape)['ModeledName'] + # # => [:ruby_name, member] def wire_index(shape) - fetch(shape)[:wire_index] + shape[:schema_wire_index] || resolve_aggregate(shape, :schema_wire_index) end # Returns the canonical build lookup index. The index maps Ruby member - # name to [modeled_member_name, member_shape, target_shape_ref]. + # name to [modeled_member_name, member_shape]. # # Example: - # Extension.member_index(shape) - # # => { ruby_name: ['wireName', member, Extension::SHAPE_STRING] } + # Extension.member_index(shape)[:ruby_name] + # # => ['ModeledName', member] def member_index(shape) - fetch(shape)[:member_index] - end - - # Returns a normalized reference for the target shape of +shape+. - # Bare shapes reference themselves, while member shapes reference - # their modeled target. - # - # Example: - # Extension.target_shape(member) - # # => Extension::SHAPE_STRING - def target_shape(shape) - fetch(shape)[:target_shape] - end - - # Returns [member_shape, target_shape_ref, sparse] for a list. - # - # Example: - # Extension.list_member(list) - # # => [member, Extension::SHAPE_STRING, true] - def list_member(shape) - fetch(shape)[:list_member] - end - - # Returns [member_shape, target_shape_ref] for a map key. - # - # Example: - # Extension.map_key_member(map) - # # => [member, Extension::SHAPE_STRING] - def map_key_member(shape) - fetch(shape)[:map_key_member] - end - - # Returns [member_shape, target_shape_ref, sparse] for a map value. - # - # Example: - # Extension.map_value_member(map) - # # => [member, Extension::SHAPE_STRING, false] - def map_value_member(shape) - fetch(shape)[:map_value_member] + shape[:schema_member_index] || resolve_aggregate(shape, :schema_member_index) end # Returns the modeled media type, when present. # # Example: # Extension.media_type(shape) - # # => 'application/octet-stream' + # # => 'application/json' def media_type(shape) - fetch(shape)[:media_type] + shape.fetch_metadata(:schema_media_type) do + shape.traits['smithy.api#mediaType'] + end end # Returns whether the sensitive trait is present. + # + # Example: + # Extension.sensitive?(shape) + # # => true def sensitive?(shape) - fetch(shape)[:sensitive] + shape.fetch_metadata(:schema_sensitive) do + shape.traits.key?('smithy.api#sensitive') + end end # Returns whether the streaming trait is present. + # + # Example: + # Extension.streaming?(shape) + # # => true def streaming?(shape) - fetch(shape)[:streaming] + shape.fetch_metadata(:schema_streaming) do + shape.traits.key?('smithy.api#streaming') + end end # Returns whether the requires-length trait is present. + # + # Example: + # Extension.requires_length?(shape) + # # => true def requires_length?(shape) - fetch(shape)[:requires_length] + shape.fetch_metadata(:schema_requires_length) do + shape.traits.key?('smithy.api#requiresLength') + end end + # Returns the endpoint host prefix. + # + # Example: + # Extension.endpoint_host_prefix(operation) + # # => '{account_id}.example.com' def endpoint_host_prefix(operation) - fetch(operation)[:endpoint_host_prefix] + operation.fetch_metadata(:schema_endpoint_host_prefix) do + resolve_endpoint(operation, :schema_endpoint_host_prefix) + end end + # Returns the compiled endpoint host-prefix plan. + # + # Example: + # Extension.endpoint_host_prefix_plan(operation) + # # => [:account_id, '.example.com'] + def endpoint_host_prefix_plan(operation) + operation.fetch_metadata(:schema_endpoint_host_prefix_plan) do + resolve_endpoint(operation, :schema_endpoint_host_prefix_plan) + end + end + + # Returns the supported request-compression encodings. + # + # Example: + # Extension.request_compression_encodings(operation) + # # => ['gzip'] def request_compression_encodings(operation) - fetch(operation)[:request_compression_encodings] + operation.fetch_metadata(:schema_request_compression_encodings) do + operation.traits.dig('smithy.api#requestCompression', 'encodings') + end end + # Returns whether an HTTP checksum is required. + # + # Example: + # Extension.checksum_required?(operation) + # # => true def checksum_required?(operation) - fetch(operation)[:checksum_required] + operation.fetch_metadata(:schema_checksum_required) do + operation.traits.key?('smithy.api#httpChecksumRequired') + end end + # Returns whether an operation uses long polling. + # + # Example: + # Extension.long_polling?(operation) + # # => true def long_polling?(operation) - fetch(operation)[:long_polling] + operation.fetch_metadata(:schema_long_polling) do + operation.traits.key?('smithy.api#longPoll') + end end + # Returns whether an operation uses an unsigned payload. + # + # Example: + # Extension.unsigned_payload?(operation) + # # => true def unsigned_payload?(operation) - fetch(operation)[:unsigned_payload] + operation.fetch_metadata(:schema_unsigned_payload) do + operation.traits.key?('aws.auth#unsignedPayload') + end end # Returns operation errors indexed by target shape name. # # Example: - # Extension.error_index(operation)['ResourceNotFound'] + # Extension.error_index(operation)['ExampleError'] # # => error_member def error_index(operation) - fetch(operation).fetch(:error_index, {}.freeze) + operation[:schema_error_index] ||= build_error_index(operation) end + # Returns required members by Ruby member name. + # + # Example: + # Extension.required_members(shape) + # # => [:name] def required_members(shape) - fetch(shape).fetch(:required_members, [].freeze) + shape[:schema_required_members] || resolve_aggregate(shape, :schema_required_members) end + # Returns host labels indexed by modeled member name. + # + # Example: + # Extension.host_label_index(shape) + # # => { 'AccountId' => :account_id } def host_label_index(shape) - fetch(shape).fetch(:host_label_index, {}.freeze) + shape[:schema_host_label_index] || resolve_aggregate(shape, :schema_host_label_index) end + # Returns the idempotency-token member name. + # + # Example: + # Extension.idempotency_token_member(shape) + # # => :client_token def idempotency_token_member(shape) - fetch(shape)[:idempotency_token_member] + shape.fetch_metadata(:schema_idempotency_token_member) do + resolve_aggregate(shape, :schema_idempotency_token_member) + end end + # Returns the streaming member. + # + # Example: + # Extension.streaming_member(shape) + # # => member def streaming_member(shape) - fetch(shape)[:streaming_member] + shape.fetch_metadata(:schema_streaming_member) do + resolve_aggregate(shape, :schema_streaming_member) + end end + # Returns the streaming member when its length is unknown. + # + # Example: + # Extension.streaming_member_unknown_length(shape) + # # => member def streaming_member_unknown_length(shape) - fetch(shape)[:streaming_member_unknown_length] + shape.fetch_metadata(:schema_streaming_member_unknown_length) do + resolve_aggregate(shape, :schema_streaming_member_unknown_length) + end end + # Returns the event-stream member. + # + # Example: + # Extension.event_stream_member(shape) + # # => member def event_stream_member(shape) - fetch(shape)[:event_stream_member] + shape.fetch_metadata(:schema_event_stream_member) do + resolve_aggregate(shape, :schema_event_stream_member) + end end - # Returns the effective timestamp format, or +:default+ when the - # model does not select one. + # Returns the effective timestamp format, or +:default+ when the model + # does not select one. # # Example: # Extension.timestamp_format(member) # # => 'date-time' def timestamp_format(shape) - fetch(shape).fetch(:timestamp_format, :default) - end - - # Returns a modeled union's unknown-member type when present. - # - # Example: - # Extension.unknown_member_type(union) - # # => Types::Unknown - def unknown_member_type(shape) - fetch(shape)[:unknown_member_type] + shape[:schema_timestamp_format] ||= resolve_timestamp_format(shape) end # Iterates modeled members with separate Ruby name and member-shape # arguments. With no block, returns the underlying enumerator. # # Example: - # Extension.each_member(shape) { |name, member| ... } + # Extension.each_member(shape) { |name, member| } def each_member(shape, &block) return shape.members.each unless block @@ -238,21 +239,44 @@ def each_member(shape, &block) # Extension.sparse?(list) # # => true def sparse?(shape) - shape.traits.key?('smithy.api#sparse') + shape.fetch_metadata(:schema_sparse) do + shape.traits.key?('smithy.api#sparse') + end end private - def build_operation_metadata(operation) - traits = operation.traits - { - endpoint_host_prefix: traits.dig('smithy.api#endpoint', 'hostPrefix'), - request_compression_encodings: traits.dig('smithy.api#requestCompression', 'encodings'), - checksum_required: traits.key?('smithy.api#httpChecksumRequired') || nil, - long_polling: traits.key?('smithy.api#longPoll') || nil, - unsigned_payload: traits.key?('aws.auth#unsignedPayload') || nil, - error_index: build_error_index(operation) - }.compact.freeze + def resolve_endpoint(operation, result) + endpoint_host_prefix = operation.traits.dig('smithy.api#endpoint', 'hostPrefix') + endpoint_host_prefix_plan = build_endpoint_host_prefix_plan(operation, endpoint_host_prefix) + + operation[:schema_endpoint_host_prefix] = endpoint_host_prefix + operation[:schema_endpoint_host_prefix_plan] = endpoint_host_prefix_plan + operation[result] + end + + def build_endpoint_host_prefix_plan(operation, host_prefix) + return unless host_prefix + + host_labels = host_label_index(operation.input) + plan = [] + offset = 0 + host_prefix.to_enum(:scan, /\{(.+?)}/).each do + match = Regexp.last_match + offset = append_host_prefix_match(plan, host_prefix, host_labels, match, offset) + end + plan << host_prefix[offset..].freeze if offset < host_prefix.length + plan.freeze + end + + def append_host_prefix_match(plan, host_prefix, host_labels, match, offset) + plan << host_prefix[offset...match.begin(0)].freeze if match.begin(0) > offset + label = match[1] + name = host_labels[label] + raise ArgumentError, "#{label} is not a valid host label" unless name + + plan << name + match.end(0) end def build_error_index(operation) @@ -261,104 +285,77 @@ def build_error_index(operation) end.freeze end - def build_shape_metadata(shape) - target = shape.target - target_shape = SHAPE_REF_BY_CLASS[target.class] - metadata = { target_shape: target_shape }.compact - add_collection_metadata(metadata, shape) if target.equal?(shape) - add_media_type_metadata(metadata, shape) - add_boolean_trait_metadata(metadata, shape) - add_timestamp_metadata(metadata, shape) - metadata[:unknown_member_type] = shape.member_type(:unknown) if - target_shape == SHAPE_UNION && shape.member_type?(:unknown) - metadata.freeze - end - - def build_member_metadata(member) - target_shape = SHAPE_REF_BY_CLASS[member.target.class] if member.target - metadata = { target_shape: target_shape }.compact - add_media_type_metadata(metadata, member) - add_boolean_trait_metadata(metadata, member) - add_timestamp_metadata(metadata, member) - metadata.freeze - end - - # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity - def build_aggregate_metadata(shape) - metadata = build_shape_metadata(shape).dup - wire_index = {} - member_index = {} - required_members = [] - host_label_index = {} - + def resolve_aggregate(shape, result) + metadata = empty_aggregate_metadata shape.members.each do |ruby_name, member| - modeled_name = member.name - next unless modeled_name - - target_shape = fetch(member)[:target_shape] - wire_index[modeled_name] = [ruby_name, member, target_shape].freeze - member_index[ruby_name] = [modeled_name, member, target_shape].freeze - if member.traits.key?('smithy.api#required') && - !member.traits.key?('smithy.api#clientOptional') - required_members << ruby_name - end - host_label_index[modeled_name] = ruby_name if member.traits.key?('smithy.api#hostLabel') - metadata[:idempotency_token_member] ||= ruby_name if member.traits.key?('smithy.api#idempotencyToken') - next unless streaming_trait?(member.target) - - metadata[:streaming_member] ||= member - metadata[:event_stream_member] ||= member if target_shape == SHAPE_UNION - metadata[:streaming_member_unknown_length] ||= member unless requires_length_trait?(member.target) + next unless member.name + + index_aggregate_member(metadata, ruby_name, member) + index_streaming_member(metadata, member) end - metadata[:wire_index] = wire_index.freeze - metadata[:member_index] = member_index.freeze - metadata[:required_members] = required_members.freeze - metadata[:host_label_index] = host_label_index.freeze - metadata.freeze + freeze_aggregate_metadata(metadata) + metadata.each { |key, value| shape[key] = value } + metadata.fetch(result) end - def add_collection_metadata(metadata, shape) - case shape - when Shapes::ListShape - metadata[:list_member] = member_metadata(shape.member, sparse?(shape)) - when Shapes::MapShape - metadata[:map_key_member] = member_metadata(shape.key) - metadata[:map_value_member] = member_metadata(shape.value, sparse?(shape)) - end + def empty_aggregate_metadata + { + schema_wire_index: {}, + schema_member_index: {}, + schema_required_members: [], + schema_host_label_index: {}, + schema_idempotency_token_member: nil, + schema_streaming_member: nil, + schema_event_stream_member: nil, + schema_streaming_member_unknown_length: nil + } end - def add_timestamp_metadata(metadata, shape) - return unless metadata[:target_shape] == SHAPE_TIMESTAMP - - metadata[:timestamp_format] = - shape.traits['smithy.api#timestampFormat'] || - shape.target.traits['smithy.api#timestampFormat'] || - :default + def index_aggregate_member(metadata, ruby_name, member) + modeled_name = member.name + metadata[:schema_wire_index][modeled_name] = [ruby_name, member].freeze + metadata[:schema_member_index][ruby_name] = [modeled_name, member].freeze + metadata[:schema_required_members] << ruby_name if required_member?(member) + metadata[:schema_host_label_index][modeled_name] = ruby_name if member.traits.key?('smithy.api#hostLabel') + index_idempotency_token_member(metadata, ruby_name, member) end - def add_media_type_metadata(metadata, shape) - media_type = shape.traits['smithy.api#mediaType'] - metadata[:media_type] = media_type if media_type + def index_idempotency_token_member(metadata, ruby_name, member) + return unless member.traits.key?('smithy.api#idempotencyToken') + + metadata[:schema_idempotency_token_member] ||= ruby_name end - def add_boolean_trait_metadata(metadata, shape) - metadata[:sensitive] = true if shape.traits.key?('smithy.api#sensitive') - metadata[:streaming] = true if streaming_trait?(shape) - metadata[:requires_length] = true if requires_length_trait?(shape) + def required_member?(member) + member.traits.key?('smithy.api#required') && + !member.traits.key?('smithy.api#clientOptional') end - def streaming_trait?(shape) - shape.traits.key?('smithy.api#streaming') + def index_streaming_member(metadata, member) + target = member.target + return unless target.traits.key?('smithy.api#streaming') + + metadata[:schema_streaming_member] ||= member + metadata[:schema_event_stream_member] ||= member if target.instance_of?(Shapes::UnionShape) + return if target.traits.key?('smithy.api#requiresLength') + + metadata[:schema_streaming_member_unknown_length] ||= member end - def requires_length_trait?(shape) - shape.traits.key?('smithy.api#requiresLength') + def freeze_aggregate_metadata(metadata) + metadata.values_at( + :schema_wire_index, :schema_member_index, :schema_required_members, :schema_host_label_index + ).each(&:freeze) end - def member_metadata(member, sparse = nil) - target_shape = target_shape(member) if member - [member, target_shape, sparse].compact.freeze + def resolve_timestamp_format(shape) + target = shape.target + return :default unless target.is_a?(Shapes::TimestampShape) + + shape.traits['smithy.api#timestampFormat'] || + target.traits['smithy.api#timestampFormat'] || + :default end end end diff --git a/gems/smithy-schema/lib/smithy-schema/shapes.rb b/gems/smithy-schema/lib/smithy-schema/shapes.rb index ece5abeee..7eb231722 100644 --- a/gems/smithy-schema/lib/smithy-schema/shapes.rb +++ b/gems/smithy-schema/lib/smithy-schema/shapes.rb @@ -38,6 +38,17 @@ def key?(key) @metadata.key?(key) end + # Fetches a cached metadata value, resolving and storing it when absent. + # Unlike +||=+, this preserves cached +nil+ and +false+ values. + # + # @param [Symbol] key + # @return [Object] + def fetch_metadata(key) + @metadata.fetch(key) do + @metadata[key] = yield + end + end + # @param [Symbol] key # @param [Object] value def []=(key, value) @@ -74,6 +85,17 @@ def key?(key) @metadata.key?(key) end + # Fetches a cached metadata value, resolving and storing it when absent. + # Unlike +||=+, this preserves cached +nil+ and +false+ values. + # + # @param [Symbol] key + # @return [Object] + def fetch_metadata(key) + @metadata.fetch(key) do + @metadata[key] = yield + end + end + # @param [Symbol] key # @param [Object] value def []=(key, value) diff --git a/gems/smithy-schema/lib/smithy-schema/structure.rb b/gems/smithy-schema/lib/smithy-schema/structure.rb index 7683b374c..3275dcc83 100644 --- a/gems/smithy-schema/lib/smithy-schema/structure.rb +++ b/gems/smithy-schema/lib/smithy-schema/structure.rb @@ -41,10 +41,12 @@ def key?(member_name) private def _to_h_structure(obj) - obj.members.each_with_object({}) do |member, hash| - value = obj.send(member) + hash = {} + obj.members.each do |member| + value = obj[member] hash[member] = to_hash(value) unless value.nil? end + hash end def _to_h_hash(obj) diff --git a/gems/smithy-schema/sig/smithy-schema/extension.rbs b/gems/smithy-schema/sig/smithy-schema/extension.rbs index 65d7899a4..acc7340ac 100644 --- a/gems/smithy-schema/sig/smithy-schema/extension.rbs +++ b/gems/smithy-schema/sig/smithy-schema/extension.rbs @@ -1,22 +1,19 @@ module Smithy module Schema module Extension - def self.fetch: ((Shapes::Shape | Shapes::MemberShape) shape) -> Hash[Symbol, untyped] - def self.wire_index: ((Shapes::StructureShape | Shapes::UnionShape) shape) -> Hash[String, [Symbol, Shapes::MemberShape, Integer?]] - def self.member_index: ((Shapes::StructureShape | Shapes::UnionShape) shape) -> Hash[Symbol, [String, Shapes::MemberShape, Integer?]] - def self.target_shape: ((Shapes::Shape | Shapes::MemberShape) shape) -> Integer? - def self.list_member: (Shapes::ListShape shape) -> Array[untyped] - def self.map_key_member: (Shapes::MapShape shape) -> Array[untyped] - def self.map_value_member: (Shapes::MapShape shape) -> Array[untyped] + def self.wire_index: ((Shapes::StructureShape | Shapes::UnionShape) shape) -> Hash[String, [Symbol, Shapes::MemberShape]] + def self.member_index: ((Shapes::StructureShape | Shapes::UnionShape) shape) -> Hash[Symbol, [String, Shapes::MemberShape]] def self.media_type: ((Shapes::Shape | Shapes::MemberShape) shape) -> String? - def self.sensitive?: ((Shapes::Shape | Shapes::MemberShape) shape) -> bool? - def self.streaming?: ((Shapes::Shape | Shapes::MemberShape) shape) -> bool? - def self.requires_length?: ((Shapes::Shape | Shapes::MemberShape) shape) -> bool? + def self.sensitive?: ((Shapes::Shape | Shapes::MemberShape) shape) -> bool + def self.streaming?: ((Shapes::Shape | Shapes::MemberShape) shape) -> bool + def self.requires_length?: ((Shapes::Shape | Shapes::MemberShape) shape) -> bool def self.endpoint_host_prefix: (Shapes::OperationShape operation) -> String? + def self.endpoint_host_prefix_plan: (Shapes::OperationShape operation) -> Array[String | Symbol]? def self.request_compression_encodings: (Shapes::OperationShape operation) -> Array[String]? - def self.checksum_required?: (Shapes::OperationShape operation) -> bool? - def self.long_polling?: (Shapes::OperationShape operation) -> bool? - def self.unsigned_payload?: (Shapes::OperationShape operation) -> bool? + def self.checksum_required?: (Shapes::OperationShape operation) -> bool + def self.long_polling?: (Shapes::OperationShape operation) -> bool + def self.unsigned_payload?: (Shapes::OperationShape operation) -> bool + def self.error_index: (Shapes::OperationShape operation) -> Hash[String, Shapes::MemberShape] def self.required_members: ((Shapes::StructureShape | Shapes::UnionShape) shape) -> Array[Symbol] def self.host_label_index: ((Shapes::StructureShape | Shapes::UnionShape) shape) -> Hash[String, Symbol] def self.idempotency_token_member: ((Shapes::StructureShape | Shapes::UnionShape) shape) -> Symbol? @@ -24,7 +21,6 @@ module Smithy def self.streaming_member_unknown_length: ((Shapes::StructureShape | Shapes::UnionShape) shape) -> Shapes::MemberShape? def self.event_stream_member: ((Shapes::StructureShape | Shapes::UnionShape) shape) -> Shapes::MemberShape? def self.timestamp_format: ((Shapes::Shape | Shapes::MemberShape) shape) -> (String | Symbol) - def self.unknown_member_type: (Shapes::UnionShape shape) -> untyped def self.each_member: ((Shapes::StructureShape | Shapes::UnionShape) shape) ?{ (Symbol, Shapes::MemberShape) -> void } -> untyped def self.sparse?: ((Shapes::Shape | Shapes::MemberShape) shape) -> bool end diff --git a/gems/smithy-schema/sig/smithy-schema/shapes.rbs b/gems/smithy-schema/sig/smithy-schema/shapes.rbs index 38ed6e212..86e209510 100644 --- a/gems/smithy-schema/sig/smithy-schema/shapes.rbs +++ b/gems/smithy-schema/sig/smithy-schema/shapes.rbs @@ -6,10 +6,11 @@ module Smithy attr_reader target: Shape attr_accessor id: String - attr_accessor name: String + attr_accessor name: String? attr_accessor traits: Hash[String, untyped] def []: (Symbol) -> Object def key?: (Symbol) -> bool + def fetch_metadata: (Symbol) { () -> Object } -> Object def []=: (Symbol, Object) -> void end @@ -21,6 +22,7 @@ module Smithy attr_accessor traits: Hash[String, untyped] def []: (Symbol) -> Object def key?: (Symbol) -> bool + def fetch_metadata: (Symbol) { () -> Object } -> Object def []=: (Symbol, Object) -> void end diff --git a/gems/smithy-schema/spec/smithy-schema/extension_spec.rb b/gems/smithy-schema/spec/smithy-schema/extension_spec.rb index d620a4fee..171c262fa 100644 --- a/gems/smithy-schema/spec/smithy-schema/extension_spec.rb +++ b/gems/smithy-schema/spec/smithy-schema/extension_spec.rb @@ -12,9 +12,10 @@ module Schema it 'returns a frozen member index keyed by member name' do shape.add_member(:some_member, member) - expected_values = [:some_member, member, described_class::SHAPE_STRING] + expected_values = [:some_member, member] expect(described_class.wire_index(shape)).to eq('wireName' => expected_values) expect(described_class.wire_index(shape)).to be_frozen + expect(shape[:schema_wire_index]).to be(described_class.wire_index(shape)) end it 'ignores members that do not have a modeled member name' do @@ -36,14 +37,17 @@ module Schema shape = Shapes::StructureShape.new shape.add_member(:some_member, member) - expected_values = ['wireName', member, described_class::SHAPE_STRING] + expected_values = ['wireName', member] expect(described_class.member_index(shape)).to eq(some_member: expected_values) end end describe '.sparse?' do it 'returns whether the sparse trait is present' do - expect(described_class.sparse?(Shapes::ListShape.new)).to be(false) + shape = Shapes::ListShape.new + + expect(described_class.sparse?(shape)).to be(false) + expect(shape[:schema_sparse]).to be(false) expect(described_class.sparse?(Shapes::ListShape.new(traits: { 'smithy.api#sparse' => {} }))).to be(true) end end @@ -55,24 +59,11 @@ module Schema operation = Shapes::OperationShape.new(errors: [error_member]) expect(described_class.error_index(operation)).to eq('ExampleError' => error_member) + expect(operation[:schema_error_index]).to be(described_class.error_index(operation)) end end describe 'generic shape metadata' do - it 'classifies target shapes' do - expect(described_class.target_shape(Shapes::BlobShape.new)).to eq(described_class::SHAPE_BLOB) - expect(described_class.target_shape(Shapes::FloatShape.new)).to eq(described_class::SHAPE_FLOAT) - expect(described_class.target_shape(Shapes::ListShape.new)).to eq(described_class::SHAPE_LIST) - end - - it 'caches collection members and sparse metadata' do - list = Shapes::ListShape.new(traits: { 'smithy.api#sparse' => {} }) - member = Shapes::MemberShape.new(target: Shapes::StringShape.new) - list.add_member(:member, member) - - expect(described_class.list_member(list)).to eq([member, described_class::SHAPE_STRING, true]) - end - it 'resolves a member timestamp format before its target format' do timestamp = Shapes::TimestampShape.new( traits: { 'smithy.api#timestampFormat' => 'date-time' } @@ -92,14 +83,18 @@ module Schema ) expect(described_class.media_type(shape)).to eq('application/custom') + expect(shape[:schema_media_type]).to eq('application/custom') end - it 'caches an unknown union member type when present' do - union = Shapes::UnionShape.new - unknown_type = Class.new - union.add_member(:unknown, unknown_type, Shapes::MemberShape.new) + it 'caches absent boolean traits as false' do + shape = Shapes::BlobShape.new - expect(described_class.unknown_member_type(union)).to be(unknown_type) + expect(described_class.sensitive?(shape)).to be(false) + expect(described_class.streaming?(shape)).to be(false) + expect(described_class.requires_length?(shape)).to be(false) + expect(shape[:schema_sensitive]).to be(false) + expect(shape[:schema_streaming]).to be(false) + expect(shape[:schema_requires_length]).to be(false) end end end diff --git a/gems/smithy-schema/spec/smithy-schema/shapes_spec.rb b/gems/smithy-schema/spec/smithy-schema/shapes_spec.rb index 0c74c5a25..4050fea5c 100644 --- a/gems/smithy-schema/spec/smithy-schema/shapes_spec.rb +++ b/gems/smithy-schema/spec/smithy-schema/shapes_spec.rb @@ -37,6 +37,34 @@ module Shapes subject[:foo] = 'bar' expect(subject[:foo]).to eq('bar') end + + it 'fetches metadata once when the resolved value is false' do + calls = 0 + + 2.times do + subject.fetch_metadata(:foo) do + calls += 1 + false + end + end + + expect(calls).to eq(1) + expect(subject[:foo]).to be(false) + end + + it 'fetches metadata once when the resolved value is nil' do + calls = 0 + + 2.times do + subject.fetch_metadata(:foo) do + calls += 1 + nil + end + end + + expect(calls).to eq(1) + expect(subject).to be_key(:foo) + end end end @@ -67,6 +95,13 @@ module Shapes subject[:foo] = 'bar' expect(subject[:foo]).to eq('bar') end + + it 'fetches and memoizes metadata' do + value = Object.new + + expect(subject.fetch_metadata(:foo) { value }).to be(value) + expect(subject.fetch_metadata(:foo) { raise 'resolved twice' }).to be(value) + end end describe ServiceShape do diff --git a/gems/smithy-xml/lib/smithy-xml/builder.rb b/gems/smithy-xml/lib/smithy-xml/builder.rb index d99d54054..bc04fb204 100644 --- a/gems/smithy-xml/lib/smithy-xml/builder.rb +++ b/gems/smithy-xml/lib/smithy-xml/builder.rb @@ -6,31 +6,35 @@ module Smithy module Xml # @api private class Builder + MAP_ENTRY_SHAPE = Schema::Shapes::MemberShape.new( + target: Schema::Shapes::MapShape.new + ).freeze + def initialize(options = {}) @indent = options.fetch(:indent, '') @pad = options.fetch(:pad, '') @default_timestamp = options.fetch(:default_timestamp, 'date-time') - @map_entry_shape = Schema::Shapes::MemberShape.new(target: Schema::Shapes::MapShape.new) end def build(shape, data, output = nil) output ||= [] - @builder = DocBuilder.new(output: output, indent: @indent, pad: @pad) - structure(Extension.structure_name(shape), shape, data) + builder = DocBuilder.new(output: output, indent: @indent, pad: @pad) + structure(builder, Extension.structure_name(shape), shape, data) output.join end private - def build_shape(name, shape, value) - case Schema::Extension.target_shape(shape) - when Schema::Extension::SHAPE_BLOB then node(name, shape, blob(value)) - when Schema::Extension::SHAPE_LIST then list(name, shape, value) - when Schema::Extension::SHAPE_MAP then map(name, shape, value) - when Schema::Extension::SHAPE_STRUCTURE then structure(name, shape, value) - when Schema::Extension::SHAPE_TIMESTAMP then node(name, shape, timestamp(shape, value)) - when Schema::Extension::SHAPE_UNION then union(name, shape, value) - else node(name, shape, value.to_s) + def build_shape(builder, name, shape, value) + target_shape = shape.target + case target_shape + when Schema::Shapes::BlobShape then node(builder, name, shape, blob(value)) + when Schema::Shapes::ListShape then list(builder, name, shape, value) + when Schema::Shapes::MapShape then map(builder, name, shape, value) + when Schema::Shapes::StructureShape then structure(builder, name, shape, value) + when Schema::Shapes::TimestampShape then node(builder, name, shape, timestamp(shape, value)) + when Schema::Shapes::UnionShape then union(builder, name, shape, value) + else node(builder, name, shape, value.to_s) end end @@ -38,66 +42,74 @@ def blob(value) Base64.strict_encode64(value.respond_to?(:read) ? value.read : value) end - def list(name, shape, values) - member_shape, = Schema::Extension.list_member(shape.target) - if Extension.flattened?(shape) + def list(builder, name, shape, values) + member_shape = shape.target.member + flattened = Extension.flattened?(shape) + if flattened values.each do |value| - build_shape(name, member_shape, value) + build_shape(builder, name, member_shape, value) end else - node(name, shape) do + member_name = Extension.wire_name(member_shape) + node(builder, name, shape) do values.each do |value| - build_shape(Extension.wire_name(member_shape), member_shape, value) + build_shape(builder, member_name, member_shape, value) end end end end - def map(name, shape, values) - if Extension.flattened?(shape) - flat_map_entries(name, shape, values) + def map(builder, name, shape, values) + flattened = Extension.flattened?(shape) + if flattened + flat_map_entries(builder, name, shape, values) else - key_name, key_shape, value_name, value_shape = Extension.map_parts(shape) - node(name, shape) do + key_name, key_member, value_name, value_member = Extension.map_parts(shape) + node(builder, name, shape) do values.each do |key, value| - node('entry', @map_entry_shape) do - build_shape(key_name, key_shape, key) - build_shape(value_name, value_shape, value) + node(builder, 'entry', MAP_ENTRY_SHAPE) do + build_shape(builder, key_name, key_member, key) + build_shape(builder, value_name, value_member, value) end end end end end - def flat_map_entries(name, shape, values) - key_name, key_shape, value_name, value_shape = Extension.map_parts(shape) + def flat_map_entries(builder, 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(key_name, key_shape, key) - build_shape(value_name, value_shape, value) + node(builder, name, shape) do + build_shape(builder, key_name, key_member, key) + build_shape(builder, value_name, value_member, value) end end end - def structure(name, shape, values) - return node(name, shape) if values.empty? + def structure(builder, name, shape, values) + return node(builder, name, shape) if values.empty? - node(name, shape, structure_attrs(shape, values)) do - Extension.element_members(shape.target).each do |ruby_member_name, xml_name, member_shape| - next if values[ruby_member_name].nil? + node(builder, name, shape, structure_attrs(shape, values)) do + 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, values[ruby_member_name]) + build_shape(builder, xml_name, member_shape, member_value) end end end def structure_attrs(shape, values) - members = Extension.attribute_members(shape.target) - members.each_with_object({}) do |(ruby_member_name, xml_name, _member_shape), attrs| - next unless values.key?(ruby_member_name) + attribute_members = Extension.attribute_members(shape.target) + attrs = {} + attribute_members.each do |name, xml_name, _member_shape| + value = values[name] + next if value.nil? && !values.key?(name) - attrs[xml_name] = values[ruby_member_name] + attrs[xml_name] = value end + attrs end def timestamp(shape, value) @@ -106,21 +118,18 @@ def timestamp(shape, value) Schema::Utils.serialize_timestamp(value, format).to_s end - def union(name, shape, values) # rubocop:disable Metrics/AbcSize - return node(name, shape) if values.empty? - - node(name, shape, structure_attrs(shape, values)) do - if values.is_a?(Schema::Union) - member_name, _member_shape = shape.target.member_by_type(values.class) - member_shape = shape.target.member(member_name) - 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 + def union(builder, name, shape, values) + return node(builder, name, shape) if values.empty? + + if values.is_a?(Schema::Union) + key = values.member + value = values.value + else + key, value = values.first + end + node(builder, name, shape, structure_attrs(shape, values)) do + member_shape = shape.target.member(key) + build_shape(builder, Extension.wire_name(member_shape), member_shape, value) if member_shape end end @@ -134,12 +143,14 @@ def union(name, shape, values) # rubocop:disable Metrics/AbcSize # Pass a block if you want to nest XML nodes inside. When doing this, # you may *not* pass a value to the `args` list. # - def node(name, shape, *args, &) + def node(builder, name, shape, *args, &) attrs = args.last.is_a?(Hash) ? args.pop : {} namespace_attrs = Extension.namespace_attrs(shape) - attrs = attrs.empty? ? namespace_attrs : namespace_attrs.merge(attrs) if namespace_attrs + if namespace_attrs + attrs = attrs.empty? ? namespace_attrs : namespace_attrs.merge(attrs) + end args << attrs - @builder.node(name, *args, &) + builder.node(name, *args, &) end end end diff --git a/gems/smithy-xml/lib/smithy-xml/codec.rb b/gems/smithy-xml/lib/smithy-xml/codec.rb index 23ae8d86e..190eea888 100644 --- a/gems/smithy-xml/lib/smithy-xml/codec.rb +++ b/gems/smithy-xml/lib/smithy-xml/codec.rb @@ -6,7 +6,8 @@ module Xml class Codec # @param [Hash] options def initialize(options = {}) - @options = options + @builder = Builder.new(options).freeze + @parser = Parser.new(options).freeze end # @param [Shape] shape @@ -14,7 +15,7 @@ def initialize(options = {}) # @param [Array, nil] output (nil) # @return [String, nil] def build(shape, data, output = nil) - Builder.new(@options).build(shape, data, output) + @builder.build(shape, data, output) end # @param [Shape] shape @@ -22,7 +23,7 @@ def build(shape, data, output = nil) # @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-xml/lib/smithy-xml/extension.rb b/gems/smithy-xml/lib/smithy-xml/extension.rb index caee37a96..f86a548c3 100644 --- a/gems/smithy-xml/lib/smithy-xml/extension.rb +++ b/gems/smithy-xml/lib/smithy-xml/extension.rb @@ -5,64 +5,51 @@ module Xml # XML-specific lookup helpers and cached serde metadata. # # Raw Smithy trait data remains on +shape.traits+ and +member.traits+ with - # string keys. This extension caches XML-specific values under - # +object[KEY]+; generic target metadata remains owned by - # +Schema::Extension+. + # string keys. Resolved XML values are cached as flat, XML-prefixed keys + # on their owning shape or member. # @api private module Extension - KEY = :xml - class << self - # Returns cached XML metadata for a shape or member. - # - # Example: - # Extension.fetch(member) - # # => { xml_wire_name: 'Item', ... } - def fetch(shape) - return shape[KEY] if shape.key?(KEY) - - shape[KEY] = - if shape.is_a?(Schema::Shapes::MemberShape) - build_member_metadata(shape) - else - build_shape_metadata(shape) - end - end - # Returns the XML wrapper or structure name. # # Example: # Extension.structure_name(shape) - # # => 'Result' + # # => 'Example' def structure_name(shape) - fetch(shape)[:xml_structure_name] + shape.fetch_metadata(:xml_structure_name) do + resolve_structure_name(shape) + end end - # Preserves the existing true-or-nil return contract. + # Returns whether the XML value is flattened. # # Example: # Extension.flattened?(member) # # => true def flattened?(shape) - shape.traits.key?('smithy.api#xmlFlattened') || nil + shape.fetch_metadata(:xml_flattened) do + shape.traits.key?('smithy.api#xmlFlattened') + end end # Returns the parser frame class for the shape. # # Example: - # Extension.frame_class(shape) - # # => Parser::ListFrame + # Extension.frame_class(member) + # # => Parser::StructureFrame def frame_class(shape) - fetch(shape)[:xml_frame_class] + shape.fetch_metadata(:xml_frame_class) do + frame_class_for(shape.target, flattened?(shape)) + end end # Returns the resolved XML member name. # # Example: # Extension.wire_name(member) - # # => 'Item' + # # => 'ExampleName' def wire_name(member) - fetch(member)[:xml_wire_name] + member[:xml_wire_name] ||= member.traits['smithy.api#xmlName'] || member.name end # Returns XML members partitioned into attributes and elements. @@ -71,96 +58,131 @@ def wire_name(member) # Extension.members(shape) # # => { attributes: [...], elements: [...] } def members(shape) - fetch(shape)[:xml_members] + shape[:xml_members] || resolve_members(shape, :members) end + # Returns XML attribute members. + # + # Example: + # Extension.attribute_members(shape) + # # => [[:id, 'id', member]] def attribute_members(shape) - members(shape)[:attributes] + shape[:xml_attribute_members] || resolve_members(shape, :attributes) end + # Returns XML element members. + # + # Example: + # Extension.element_members(shape) + # # => [[:name, 'Name', member]] def element_members(shape) - members(shape)[:elements] + shape[:xml_element_members] || resolve_members(shape, :elements) end + # Returns XML members indexed by wire name. + # + # Example: + # Extension.member_index(shape)['Name'] + # # => [:name, member] def member_index(shape) - fetch(shape)[:xml_member_index] + shape[:xml_member_index] || resolve_members(shape, :index) end + # Returns XML namespace attributes. + # + # Example: + # Extension.namespace_attrs(shape) + # # => { 'xmlns' => 'https://example.com' } def namespace_attrs(shape) - fetch(shape)[:xml_namespace_attrs] + shape[:xml_namespace_attrs] ||= build_namespace_attrs(shape, shape.target) end + # Returns the resolved key and value parts for an XML map. + # + # Example: + # Extension.map_parts(member) + # # => ['key', key_member, 'value', value_member] def map_parts(shape) - fetch(shape)[:xml_map_parts] + shape.fetch_metadata(:xml_map_parts) do + build_map_parts(shape.target) + end end + # Returns the resolved timestamp format. + # + # Example: + # Extension.timestamp_format(member) + # # => 'date-time' def timestamp_format(shape) Schema::Extension.timestamp_format(shape) end + # Returns whether a collection may include nil values. + # + # Example: + # Extension.sparse?(list) + # # => true def sparse?(shape) Schema::Extension.sparse?(shape) end private - def build_shape_metadata(shape) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + def resolve_structure_name(shape) # rubocop:disable Metrics/CyclomaticComplexity target = shape.target - target_shape = Schema::Extension.target_shape(shape) - metadata = { - xml_structure_name: shape.traits['smithy.api#xmlName'] || target.name, - xml_namespace_attrs: build_namespace_attrs(shape, target), - xml_frame_class: frame_class_for(target_shape, flattened?(shape)) - } - if [Schema::Extension::SHAPE_STRUCTURE, Schema::Extension::SHAPE_UNION].include?(target_shape) - members = { attributes: [], elements: [] } - index = {} - Schema::Extension.each_member(shape) do |ruby_name, member| - member_metadata = fetch(member) - xml_name = member_metadata[:xml_wire_name] - entry = [ruby_name, xml_name, member].freeze - index[xml_name] = [ruby_name, member].freeze - members[member_metadata[:xml_attribute] ? :attributes : :elements] << entry - end - metadata[:xml_members] = { - attributes: members[:attributes].freeze, - elements: members[:elements].freeze - }.freeze - metadata[:xml_member_index] = index.freeze - else - add_map_parts(metadata, target) - end - metadata.freeze - end + return shape.traits['smithy.api#xmlName'] || target.name unless shape.is_a?(Schema::Shapes::MemberShape) - def build_member_metadata(member) # rubocop:disable Metrics/AbcSize - target = member.target - target_shape = Schema::Extension.target_shape(member) - xml_name = member.traits['smithy.api#xmlName'] + xml_name = shape.traits['smithy.api#xmlName'] structure_name = xml_name || target.traits['smithy.api#xmlName'] if structure_name.nil? && - [Schema::Extension::SHAPE_STRUCTURE, Schema::Extension::SHAPE_UNION].include?(target_shape) + (target.is_a?(Schema::Shapes::StructureShape) || target.is_a?(Schema::Shapes::UnionShape)) structure_name = target.name end - metadata = { - xml_structure_name: structure_name || member.name, - xml_wire_name: xml_name || member.name, - xml_namespace_attrs: build_namespace_attrs(member, target), - xml_attribute: member.traits.key?('smithy.api#xmlAttribute'), - xml_frame_class: frame_class_for(target_shape, flattened?(member)) - } - add_map_parts(metadata, target) - metadata.freeze - end - - def add_map_parts(metadata, target) - return unless Schema::Extension.target_shape(target) == Schema::Extension::SHAPE_MAP - - key_member, = Schema::Extension.map_key_member(target) - value_member, = Schema::Extension.map_value_member(target) + structure_name || shape.name + end + + def resolve_members(shape, result) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + attributes = [] + elements = [] + index = {} + Schema::Extension.each_member(shape) do |ruby_name, member| + xml_name = wire_name(member) + entry = [ruby_name, xml_name, member].freeze + index[xml_name] = [ruby_name, member].freeze + (attribute?(member) ? attributes : elements) << entry + end + + attributes.freeze + elements.freeze + members = { attributes: attributes, elements: elements }.freeze + index.freeze + shape[:xml_attribute_members] = attributes + shape[:xml_element_members] = elements + shape[:xml_members] = members + shape[:xml_member_index] = index + + case result + when :members then members + when :attributes then attributes + when :elements then elements + when :index then index + end + end + + def attribute?(member) + member.fetch_metadata(:xml_attribute) do + member.traits.key?('smithy.api#xmlAttribute') + end + end + + def build_map_parts(target) + return unless target.is_a?(Schema::Shapes::MapShape) + + key_member = target.key + value_member = target.value return unless key_member && value_member - metadata[:xml_map_parts] = [ + [ wire_name(key_member), key_member, wire_name(value_member), value_member ].freeze end @@ -177,26 +199,26 @@ def build_namespace_attrs(shape, target) end end - def frame_class_for(target_shape, flattened) - klass = base_frame_class(target_shape) + def frame_class_for(target, flattened) + klass = base_frame_class(target) return Parser::FlatListFrame if klass == Parser::ListFrame && flattened return Parser::MapEntryFrame if klass == Parser::MapFrame && flattened klass end - def base_frame_class(target_shape) # rubocop:disable Metrics/CyclomaticComplexity - case target_shape - when Schema::Extension::SHAPE_BIG_DECIMAL then Parser::BigDecimalFrame - when Schema::Extension::SHAPE_BLOB then Parser::BlobFrame - when Schema::Extension::SHAPE_BOOLEAN then Parser::BooleanFrame - when Schema::Extension::SHAPE_ENUM, Schema::Extension::SHAPE_STRING then Parser::StringFrame - when Schema::Extension::SHAPE_FLOAT then Parser::FloatFrame - when Schema::Extension::SHAPE_INTEGER, Schema::Extension::SHAPE_INT_ENUM then Parser::IntegerFrame - when Schema::Extension::SHAPE_LIST then Parser::ListFrame - when Schema::Extension::SHAPE_MAP then Parser::MapFrame - when Schema::Extension::SHAPE_STRUCTURE, Schema::Extension::SHAPE_UNION then Parser::StructureFrame - when Schema::Extension::SHAPE_TIMESTAMP then Parser::TimestampFrame + def base_frame_class(target) # rubocop:disable Metrics/CyclomaticComplexity + case target + when Schema::Shapes::BigDecimalShape then Parser::BigDecimalFrame + when Schema::Shapes::BlobShape then Parser::BlobFrame + when Schema::Shapes::BooleanShape then Parser::BooleanFrame + when Schema::Shapes::EnumShape, Schema::Shapes::StringShape then Parser::StringFrame + when Schema::Shapes::FloatShape then Parser::FloatFrame + when Schema::Shapes::IntegerShape, Schema::Shapes::IntEnumShape then Parser::IntegerFrame + when Schema::Shapes::ListShape then Parser::ListFrame + when Schema::Shapes::MapShape then Parser::MapFrame + when Schema::Shapes::StructureShape, Schema::Shapes::UnionShape then Parser::StructureFrame + when Schema::Shapes::TimestampShape then Parser::TimestampFrame 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 b7a633e13..ae433794c 100644 --- a/gems/smithy-xml/lib/smithy-xml/parser/frame.rb +++ b/gems/smithy-xml/lib/smithy-xml/parser/frame.rb @@ -94,7 +94,7 @@ def result class FlatListFrame < Frame def initialize(xml_name, *args) super - @member, _target_shape, _sparse = Schema::Extension.list_member(@shape.target) + @member = @shape.target.member @member = Frame.new(xml_name, self, @member) end @@ -139,7 +139,7 @@ class ListFrame < Frame def initialize(*args) super @result = [] - @member, _target_shape, _sparse = Schema::Extension.list_member(@shape.target) + @member = @shape.target.member @member_xml_name = Smithy::Xml::Extension.wire_name(@member) end @@ -160,10 +160,10 @@ def consume_child_frame(child) class MapEntryFrame < Frame def initialize(xml_name, *args) super - @key, _key_target_shape = Schema::Extension.map_key_member(@shape.target) + @key = @shape.target.key @key_name = Smithy::Xml::Extension.wire_name(@key) @key = Frame.new(xml_name, self, @key) - @value, _value_target_shape, _sparse = Schema::Extension.map_value_member(@shape.target) + @value = @shape.target.value @value_name = Smithy::Xml::Extension.wire_name(@value) @value = Frame.new(xml_name, self, @value) end @@ -236,7 +236,7 @@ def child_frame(xml_name) if (@member = @members[xml_name]) _member_name, member_shape = @member Frame.new(xml_name, self, member_shape) - elsif Schema::Extension.target_shape(@shape) == Schema::Extension::SHAPE_UNION + elsif @shape.target.is_a?(Schema::Shapes::UnionShape) UnknownMemberFrame.new(xml_name, self, nil, @result) else NullFrame.new(xml_name, self) diff --git a/gems/smithy-xml/spec/smithy-xml/builder_spec.rb b/gems/smithy-xml/spec/smithy-xml/builder_spec.rb index 9ffd713a1..1eacd7b38 100644 --- a/gems/smithy-xml/spec/smithy-xml/builder_spec.rb +++ b/gems/smithy-xml/spec/smithy-xml/builder_spec.rb @@ -215,6 +215,30 @@ def inline(xml) expect(bytes).to include('string') end + it 'builds flattened lists without a wrapper element' do + list = Schema::Shapes::ListShape.new + list.add_member( + :member, + Schema::Shapes::MemberShape.new( + target: Schema::Shapes::StringShape.new, + name: 'member' + ) + ) + shape = Schema::Shapes::StructureShape.new(name: 'Root') + shape.add_member( + :items, + Schema::Shapes::MemberShape.new( + target: list, + name: 'items', + traits: { 'smithy.api#xmlFlattened' => {} } + ) + ) + + expect(subject.build(shape, items: %w[one two])).to eq( + 'onetwo' + ) + end + it 'builds lists with nil values' do data = { list: [nil] } bytes = subject.build(structure_shape, data) @@ -229,6 +253,38 @@ def inline(xml) expect(bytes).to include('keyvalue') end + it 'builds flattened maps without an entry wrapper' do + map = Schema::Shapes::MapShape.new + map.add_member( + :key, + Schema::Shapes::MemberShape.new( + target: Schema::Shapes::StringShape.new, + name: 'key' + ) + ) + map.add_member( + :value, + Schema::Shapes::MemberShape.new( + target: Schema::Shapes::StringShape.new, + name: 'value' + ) + ) + shape = Schema::Shapes::StructureShape.new(name: 'Root') + shape.add_member( + :entries, + Schema::Shapes::MemberShape.new( + target: map, + name: 'entries', + traits: { 'smithy.api#xmlFlattened' => {} } + ) + ) + + expect(subject.build(shape, entries: { 'one' => 'first', 'two' => 'second' })).to eq( + 'onefirst' \ + 'twosecond' + ) + end + it 'builds maps with nil values' do data = { map: { 'key' => nil } } bytes = subject.build(structure_shape, data) 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..cfece3877 --- /dev/null +++ b/gems/smithy-xml/spec/smithy-xml/codec_spec.rb @@ -0,0 +1,54 @@ +# 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) } + + it 'freezes its reusable workers' do + codec = described_class.new + + expect(codec.instance_variable_get(:@builder)).to be_frozen + expect(codec.instance_variable_get(:@parser)).to be_frozen + 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 + + it 'supports concurrent builds on the same codec instance' do + codec = described_class.new + builds = 20.times.map do |i| + Thread.new do + value = "value-#{i}" + codec.build(structure_shape, string: value) + end + end + + expect(builds.map(&:value)).to eq( + 20.times.map { |i| "value-#{i}" } + ) + 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 3e5742c3c..1079651bf 100644 --- a/gems/smithy-xml/spec/smithy-xml/extension_spec.rb +++ b/gems/smithy-xml/spec/smithy-xml/extension_spec.rb @@ -28,12 +28,12 @@ module Xml ) expect(described_class.structure_name(member)).to eq('RootElement') - expect(member[:xml][:xml_structure_name]).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][:xml_structure_name]).to eq('Structure') + expect(structure[:xml_structure_name]).to eq('Structure') end it 'memoizes the structure element name on shape metadata' do @@ -50,12 +50,19 @@ module Xml ) expect(described_class.wire_name(member)).to eq('NewString') - expect(member[:xml][:xml_wire_name]).to eq('NewString') + expect(member[:xml_wire_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][:xml_wire_name]).to eq('String') + expect(element_member[:xml_wire_name]).to eq('String') + end + end + + describe '.flattened?' do + it 'caches false when the trait is absent' do + expect(described_class.flattened?(element_member)).to be(false) + expect(element_member[:xml_flattened]).to be(false) end end @@ -87,8 +94,8 @@ module Xml 'Status' => [:status, attribute_member] ) expect(described_class.member_index(structure)).to be_frozen - expect(element_member[:xml][:xml_wire_name]).to eq('String') - expect(attribute_member[:xml][:xml_wire_name]).to eq('Status') + expect(element_member[:xml_wire_name]).to eq('String') + expect(attribute_member[:xml_wire_name]).to eq('Status') end it 'memoizes the index on the shape metadata' do