From 016ca4a8062f438a12e88989a084841afa61d383 Mon Sep 17 00:00:00 2001 From: Juli Tera Date: Fri, 14 Aug 2026 09:43:55 -0700 Subject: [PATCH 1/3] Add load-time wire-name map to StructureShape --- .../smithy-schema/lib/smithy-schema/shapes.rb | 30 ++++++++++ .../sig/smithy-schema/shapes.rbs | 4 ++ .../spec/smithy-schema/shapes_spec.rb | 55 +++++++++++++++++++ 3 files changed, 89 insertions(+) diff --git a/gems/smithy-schema/lib/smithy-schema/shapes.rb b/gems/smithy-schema/lib/smithy-schema/shapes.rb index 271d6d1d2..754b29178 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,10 @@ def initialize(options = {}) # @return [MemberShape] def add_member(name, member_shape) @members[name] = member_shape + register_wire_name(member_shape.location_name, name, member_shape) + json_name = member_shape.traits['smithy.api#jsonName'] + register_wire_name(json_name, name, member_shape) if json_name && json_name != member_shape.location_name + member_shape end # @param [Symbol] name @@ -259,6 +264,31 @@ 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 + + private + + def register_wire_name(wire_name, name, member_shape) + return if wire_name.nil? + + existing = @members_by_wire_name[wire_name] + if existing && existing[0] != name + raise ArgumentError, + "wire name collision on #{@id.inspect}: #{wire_name.inspect} " \ + "maps to both #{existing[0].inspect} and #{name.inspect}" + end + + @members_by_wire_name[wire_name] = [name, member_shape] + 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..48dd4e53c 100644 --- a/gems/smithy-schema/sig/smithy-schema/shapes.rbs +++ b/gems/smithy-schema/sig/smithy-schema/shapes.rbs @@ -89,6 +89,10 @@ module Smithy def add_member: (Symbol, MemberShape) -> MemberShape def member?: (Symbol?) -> bool def member: (Symbol) -> MemberShape? + def member_by_wire_name: (String) -> [Symbol, MemberShape]? + + private + def register_wire_name: (String?, Symbol, MemberShape) -> void 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..27f3bad11 100644 --- a/gems/smithy-schema/spec/smithy-schema/shapes_spec.rb +++ b/gems/smithy-schema/spec/smithy-schema/shapes_spec.rb @@ -424,6 +424,61 @@ 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 + + it 'raises when two members share a location name' do + subject.add_member(:foo, MemberShape.new(target: StringShape.new, location_name: 'shared')) + expect do + subject.add_member(:bar, MemberShape.new(target: StringShape.new, location_name: 'shared')) + end.to raise_error(ArgumentError, /wire name collision/) + end + + it 'raises when a jsonName collides with another member location name' do + subject.add_member(:foo, MemberShape.new(target: StringShape.new, location_name: 'shared')) + expect do + subject.add_member( + :bar, + MemberShape.new( + target: StringShape.new, + location_name: 'bar', + traits: { 'smithy.api#jsonName' => 'shared' } + ) + ) + end.to raise_error(ArgumentError, /wire name collision/) + end + end end describe TimestampShape do From 9f7ac8c6ac8a757eb62dc0ab173509cb950b1b8f Mon Sep 17 00:00:00 2001 From: Juli Tera Date: Fri, 14 Aug 2026 09:45:22 -0700 Subject: [PATCH 2/3] Drive JSON/CBOR deserialization off the wire payload --- gems/smithy-cbor/lib/smithy-cbor/parser.rb | 10 ++++++--- .../spec/smithy-cbor/parser_spec.rb | 12 ++++++++++ gems/smithy-json/lib/smithy-json/parser.rb | 10 ++++++--- .../spec/smithy-json/parser_spec.rb | 22 +++++++++++++++++++ 4 files changed, 48 insertions(+), 6 deletions(-) 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/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/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/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 From bd60a34c16e7178adfebf095af35da7d53c5fecc Mon Sep 17 00:00:00 2001 From: Juli Tera Date: Fri, 14 Aug 2026 09:46:21 -0700 Subject: [PATCH 3/3] Drive JSON/CBOR serialization off the input values --- gems/smithy-cbor/lib/smithy-cbor/builder.rb | 15 ++++++++++++--- gems/smithy-cbor/spec/smithy-cbor/builder_spec.rb | 10 ++++++++++ gems/smithy-json/lib/smithy-json/builder.rb | 12 +++++++++--- gems/smithy-json/spec/smithy-json/builder_spec.rb | 12 ++++++++++++ 4 files changed, 43 insertions(+), 6 deletions(-) diff --git a/gems/smithy-cbor/lib/smithy-cbor/builder.rb b/gems/smithy-cbor/lib/smithy-cbor/builder.rb index 08c427d87..9623c4ede 100644 --- a/gems/smithy-cbor/lib/smithy-cbor/builder.rb +++ b/gems/smithy-cbor/lib/smithy-cbor/builder.rb @@ -53,11 +53,20 @@ 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] + # Mirrors smithy-json's guard for the same reason (see that builder's + # `structure` method): unreachable today since `build` already + # short-circuits `Prelude::Unit` above, but keeps both builders' + # fallback behavior identical if that ever changes. Flagged for + # review, not verified against a real non-hash input reaching here. + return {} unless values.respond_to?(:each_pair) + + 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/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-json/lib/smithy-json/builder.rb b/gems/smithy-json/lib/smithy-json/builder.rb index 3aa7b5bfe..68892fa2b 100644 --- a/gems/smithy-json/lib/smithy-json/builder.rb +++ b/gems/smithy-json/lib/smithy-json/builder.rb @@ -65,10 +65,16 @@ def map(shape, values) def structure(shape, values) return if values.nil? + return {} unless values.respond_to?(:each_pair) - 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/spec/smithy-json/builder_spec.rb b/gems/smithy-json/spec/smithy-json/builder_spec.rb index 08f286eb3..ed6ff8ffe 100644 --- a/gems/smithy-json/spec/smithy-json/builder_spec.rb +++ b/gems/smithy-json/spec/smithy-json/builder_spec.rb @@ -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