diff --git a/gems/smithy-cbor/lib/smithy-cbor/builder.rb b/gems/smithy-cbor/lib/smithy-cbor/builder.rb index 08c427d87..027ef9569 100644 --- a/gems/smithy-cbor/lib/smithy-cbor/builder.rb +++ b/gems/smithy-cbor/lib/smithy-cbor/builder.rb @@ -54,10 +54,13 @@ def map(shape, values) def structure(shape, values) return if values.nil? - shape.target.members.each_with_object({}) do |(member_name, member_shape), data| - value = values[member_name] + members = shape.target.members + values.each_pair.with_object({}) do |(member_name, value), data| next if value.nil? + member_shape = members[member_name] + next unless member_shape + data[member_shape.location_name] = build_shape(member_shape, value) end end diff --git a/gems/smithy-cbor/lib/smithy-cbor/parser.rb b/gems/smithy-cbor/lib/smithy-cbor/parser.rb index 35d4fc78e..fb4156c3c 100644 --- a/gems/smithy-cbor/lib/smithy-cbor/parser.rb +++ b/gems/smithy-cbor/lib/smithy-cbor/parser.rb @@ -54,9 +54,13 @@ def map(shape, values, result = nil) def structure(shape, values, result = nil) result = shape.target.type.new if result.nil? - shape.target.members.each do |member_name, member_shape| - value = values[member_shape.location_name] - result[member_name] = parse_shape(member_shape, value) unless value.nil? + values.each do |wire_name, value| + next if value.nil? + + member_name, member_shape = shape.target.member_by_wire_name(wire_name) + next unless member_shape + + result[member_name] = parse_shape(member_shape, value) end result end diff --git a/gems/smithy-cbor/spec/smithy-cbor/builder_spec.rb b/gems/smithy-cbor/spec/smithy-cbor/builder_spec.rb index 4ebead47d..62185b9d3 100644 --- a/gems/smithy-cbor/spec/smithy-cbor/builder_spec.rb +++ b/gems/smithy-cbor/spec/smithy-cbor/builder_spec.rb @@ -73,6 +73,16 @@ module Cbor bytes = subject.build(structure_shape, data.merge(structure: data)) expect(Cbor.decode(bytes)).to eq(expected.merge('structure' => expected)) end + + it 'builds only the members present on a sparse input, iterating values not declared members' do + bytes = subject.build(structure_shape, { string: 'string', integer: 1 }) + expect(Cbor.decode(bytes)).to eq('string' => 'string', 'integer' => 1) + end + + it 'skips keys in the input that are not declared members of the shape' do + bytes = subject.build(structure_shape, { string: 'string', not_a_real_member: 'ignored' }) + expect(Cbor.decode(bytes)).to eq('string' => 'string') + end end context 'unions' do diff --git a/gems/smithy-cbor/spec/smithy-cbor/parser_spec.rb b/gems/smithy-cbor/spec/smithy-cbor/parser_spec.rb index ca8928c63..ed8aafbbf 100644 --- a/gems/smithy-cbor/spec/smithy-cbor/parser_spec.rb +++ b/gems/smithy-cbor/spec/smithy-cbor/parser_spec.rb @@ -73,6 +73,18 @@ module Cbor bytes = Cbor.encode(data) expect(subject.parse(structure_shape, bytes).to_h).to eq(expected) end + + it 'ignores wire keys that are not members' do + data = { 'string' => 'string', 'notAMember' => 'ignored' } + bytes = Cbor.encode(data) + expect(subject.parse(structure_shape, bytes).to_h).to eq(string: 'string') + end + + it 'skips members whose wire value is nil' do + data = { 'string' => nil } + bytes = Cbor.encode(data) + expect(subject.parse(structure_shape, bytes).to_h).to eq({}) + end end context 'unions' do diff --git a/gems/smithy-json/lib/smithy-json/builder.rb b/gems/smithy-json/lib/smithy-json/builder.rb index 3aa7b5bfe..5f5f5be95 100644 --- a/gems/smithy-json/lib/smithy-json/builder.rb +++ b/gems/smithy-json/lib/smithy-json/builder.rb @@ -66,9 +66,14 @@ def map(shape, values) def structure(shape, values) return if values.nil? - shape.target.members.each_with_object({}) do |(member_name, member_shape), data| - value = values[member_name] - data[location_name(member_shape)] = build_shape(member_shape, value) unless value.nil? + members = shape.target.members + values.each_pair.with_object({}) do |(member_name, value), data| + next if value.nil? + + member_shape = members[member_name] + next unless member_shape + + data[location_name(member_shape)] = build_shape(member_shape, value) end end diff --git a/gems/smithy-json/lib/smithy-json/parser.rb b/gems/smithy-json/lib/smithy-json/parser.rb index e2a251610..a1c90f0f2 100644 --- a/gems/smithy-json/lib/smithy-json/parser.rb +++ b/gems/smithy-json/lib/smithy-json/parser.rb @@ -68,9 +68,13 @@ def structure(shape, values, result = nil) return if values.nil? result = shape.target.type.new if result.nil? - shape.target.members.each do |member_name, member_shape| - value = values[location_name(member_shape)] - result[member_name] = parse_shape(member_shape, value) unless value.nil? + values.each do |wire_name, value| + next if value.nil? + + member_name, member_shape = shape.target.member_by_wire_name(wire_name) + next unless member_shape + + result[member_name] = parse_shape(member_shape, value) end result end diff --git a/gems/smithy-json/spec/smithy-json/builder_spec.rb b/gems/smithy-json/spec/smithy-json/builder_spec.rb index 08f286eb3..d6adce827 100644 --- a/gems/smithy-json/spec/smithy-json/builder_spec.rb +++ b/gems/smithy-json/spec/smithy-json/builder_spec.rb @@ -9,8 +9,8 @@ module Json let(:sample_schema) { SchemaHelper.sample_schema(shapes: shapes) } let(:structure_shape) { sample_schema.const_get(:Structure) } - it 'returns an empty hash when given a unit shape' do - expect(subject.build(Schema::Shapes::Prelude::Unit, '')).to eq('{}') + it 'returns an empty JSON object for a unit shape' do + expect(subject.build(Schema::Shapes::Prelude::Unit, {})).to eq('{}') end context 'structures' do @@ -86,6 +86,18 @@ module Json bytes = subject.build(structure_shape, data) expect(Json.load(bytes)).to eq('NewString' => 'string') end + + it 'builds only the members present on a sparse input, iterating values not declared members' do + data = { string: 'string', integer: 1 } + bytes = subject.build(structure_shape, data) + expect(Json.load(bytes)).to eq('string' => 'string', 'integer' => 1) + end + + it 'skips keys in the input that are not declared members of the shape' do + data = { string: 'string', not_a_real_member: 'ignored' } + bytes = subject.build(structure_shape, data) + expect(Json.load(bytes)).to eq('string' => 'string') + end end context 'unions' do diff --git a/gems/smithy-json/spec/smithy-json/parser_spec.rb b/gems/smithy-json/spec/smithy-json/parser_spec.rb index 13fb46127..fc945934c 100644 --- a/gems/smithy-json/spec/smithy-json/parser_spec.rb +++ b/gems/smithy-json/spec/smithy-json/parser_spec.rb @@ -84,6 +84,28 @@ module Json bytes = Json.dump(data) expect(subject.parse(structure_shape, bytes).to_h).to eq(string: 'string') end + + it 'resolves members keyed by jsonName even without the json_name option' do + shapes['smithy.ruby.tests#Structure']['members']['string'] = { + 'target' => 'smithy.api#String', + 'traits' => { 'smithy.api#jsonName' => 'NewString' } + } + data = { 'NewString' => 'string' } + bytes = Json.dump(data) + expect(subject.parse(structure_shape, bytes).to_h).to eq(string: 'string') + end + + it 'ignores wire keys that are not members' do + data = { 'string' => 'string', 'notAMember' => 'ignored' } + bytes = Json.dump(data) + expect(subject.parse(structure_shape, bytes).to_h).to eq(string: 'string') + end + + it 'skips members whose wire value is nil' do + data = { 'string' => nil } + bytes = Json.dump(data) + expect(subject.parse(structure_shape, bytes).to_h).to eq({}) + end end context 'unions' do diff --git a/gems/smithy-schema/lib/smithy-schema/shapes.rb b/gems/smithy-schema/lib/smithy-schema/shapes.rb index 271d6d1d2..3b11be4c5 100644 --- a/gems/smithy-schema/lib/smithy-schema/shapes.rb +++ b/gems/smithy-schema/lib/smithy-schema/shapes.rb @@ -235,6 +235,7 @@ class StructureShape < Shape def initialize(options = {}) super @members = {} + @members_by_wire_name = {} end # @return [Hash] @@ -246,6 +247,17 @@ def initialize(options = {}) # @return [MemberShape] def add_member(name, member_shape) @members[name] = member_shape + # TODO: (schema-caching): temporary reverse map for data-driven JSON/CBOR + # deserialization, mirroring V3's members_by_location_name (last write + # wins). Registered under location_name and jsonName; remove once the + # schema-caching rework provides per-protocol wire-name resolution. XML + # resolves xmlName through its own per-frame parser path, not this map. + @members_by_wire_name[member_shape.location_name] = [name, member_shape] + json_name = member_shape.traits['smithy.api#jsonName'] + if json_name && json_name != member_shape.location_name + @members_by_wire_name[json_name] = [name, member_shape] + end + member_shape end # @param [Symbol] name @@ -259,6 +271,16 @@ def member?(name) def member(name) @members[name] end + + # Resolves a member from its on-the-wire name. Members are registered + # under both their +location_name+ and their +jsonName+ (when present), + # so a response keyed by either flavor resolves without the shape + # carrying a protocol-specific winner. Built once at load time. + # @param [String] wire_name + # @return [[Symbol, MemberShape], nil] +[member_name, member_shape]+ or nil + def member_by_wire_name(wire_name) + @members_by_wire_name[wire_name] + end end # Represents a Timestamp shape. diff --git a/gems/smithy-schema/sig/smithy-schema/shapes.rbs b/gems/smithy-schema/sig/smithy-schema/shapes.rbs index ea7a332a4..9e3fa9066 100644 --- a/gems/smithy-schema/sig/smithy-schema/shapes.rbs +++ b/gems/smithy-schema/sig/smithy-schema/shapes.rbs @@ -89,6 +89,7 @@ module Smithy def add_member: (Symbol, MemberShape) -> MemberShape def member?: (Symbol?) -> bool def member: (Symbol) -> MemberShape? + def member_by_wire_name: (String) -> [Symbol, MemberShape]? end class TimestampShape < Shape diff --git a/gems/smithy-schema/spec/smithy-schema/shapes_spec.rb b/gems/smithy-schema/spec/smithy-schema/shapes_spec.rb index 72a036cb0..11d32ce4c 100644 --- a/gems/smithy-schema/spec/smithy-schema/shapes_spec.rb +++ b/gems/smithy-schema/spec/smithy-schema/shapes_spec.rb @@ -424,6 +424,40 @@ module Shapes expect(subject.member(:foo)).to be_kind_of(MemberShape) end end + + describe '#member_by_wire_name' do + it 'resolves a member by its location name' do + member_shape = MemberShape.new(target: StringShape.new, location_name: 'foo') + subject.add_member(:foo, member_shape) + expect(subject.member_by_wire_name('foo')).to eq([:foo, member_shape]) + end + + it 'also resolves a member by its jsonName when present' do + member_shape = MemberShape.new( + target: StringShape.new, + location_name: 'foo', + traits: { 'smithy.api#jsonName' => 'Foo' } + ) + subject.add_member(:foo, member_shape) + expect(subject.member_by_wire_name('foo')).to eq([:foo, member_shape]) + expect(subject.member_by_wire_name('Foo')).to eq([:foo, member_shape]) + end + + it 'registers a single entry when jsonName equals the location name' do + member_shape = MemberShape.new( + target: StringShape.new, + location_name: 'foo', + traits: { 'smithy.api#jsonName' => 'foo' } + ) + subject.add_member(:foo, member_shape) + expect(subject.member_by_wire_name('foo')).to eq([:foo, member_shape]) + end + + it 'returns nil for an unknown wire name' do + subject.add_member(:foo, MemberShape.new(target: StringShape.new, location_name: 'foo')) + expect(subject.member_by_wire_name('unknown')).to be_nil + end + end end describe TimestampShape do