Skip to content

Commit 596fc40

Browse files
authored
fix(core/protocols): $unknown union member support (#7593)
1 parent 2c73829 commit 596fc40

13 files changed

+277
-20
lines changed
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* Helper for identifying unknown union members during deserialization.
3+
*/
4+
export class UnionSerde {
5+
private keys: Set<string>;
6+
7+
public constructor(private from: any, private to: any) {
8+
this.keys = new Set(Object.keys(this.from).filter((k) => k !== "__type"));
9+
}
10+
11+
/**
12+
* Marks the key as being a known member.
13+
* @param key - to mark.
14+
*/
15+
public mark(key: string): void {
16+
this.keys.delete(key);
17+
}
18+
19+
/**
20+
* @returns whether only one key remains unmarked and nothing has been written,
21+
* implying the object is a union.
22+
*/
23+
public hasUnknown(): boolean {
24+
return this.keys.size === 1 && Object.keys(this.to).length === 0;
25+
}
26+
27+
/**
28+
* Writes the unknown key-value pair, if present, into the $unknown property
29+
* of the union object.
30+
*/
31+
public writeUnknown(): void {
32+
if (this.hasUnknown()) {
33+
const k = this.keys.values().next().value as string;
34+
const v = this.from[k];
35+
this.to.$unknown = [k, v];
36+
}
37+
}
38+
}

packages/core/src/submodules/protocols/json/JsonShapeDeserializer.spec.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { NumericValue } from "@smithy/core/serde";
22
import type { TimestampEpochSecondsSchema } from "@smithy/types";
33
import { describe, expect, test as it } from "vitest";
44

5-
import { createNestingWidget, nestingWidget, widget } from "../test-schema.spec";
5+
import { createNestingWidget, nestingWidget, unionStruct, unionStructControl, widget } from "../test-schema.spec";
66
import { JsonShapeDeserializer } from "./JsonShapeDeserializer";
77
import { JsonShapeSerializer } from "./JsonShapeSerializer";
88

@@ -155,6 +155,30 @@ describe(JsonShapeDeserializer.name, () => {
155155
expect(await deserializer.read(widget, JSON.stringify({ scalar: "NaN" }))).toEqual({ scalar: NaN });
156156
});
157157

158+
it("deserializes $unknown union members", async () => {
159+
const json = `{"union":{"unknownKey":{"timestamp":0,"blob":"AAECAw=="}}}`;
160+
{
161+
const deserialization = await deserializer.read(unionStruct, json);
162+
expect(deserialization).toEqual({
163+
union: {
164+
$unknown: [
165+
"unknownKey",
166+
{
167+
blob: "AAECAw==",
168+
timestamp: 0,
169+
},
170+
],
171+
},
172+
});
173+
}
174+
{
175+
const deserialization = await deserializer.read(unionStructControl, json);
176+
expect(deserialization).toEqual({
177+
union: {},
178+
});
179+
}
180+
});
181+
158182
describe("performance baseline indicator", () => {
159183
const serializer = new JsonShapeSerializer({
160184
jsonName: true,

packages/core/src/submodules/protocols/json/JsonShapeDeserializer.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { fromBase64 } from "@smithy/util-base64";
1919

2020
import { SerdeContextConfig } from "../ConfigurableSerdeContext";
2121
import { deserializingStructIterator } from "../structIterator";
22+
import { UnionSerde } from "../UnionSerde";
2223
import { JsonSettings } from "./JsonCodec";
2324
import { jsonReviver } from "./jsonReviver";
2425
import { parseJsonBody } from "./parseJsonBody";
@@ -49,18 +50,28 @@ export class JsonShapeDeserializer extends SerdeContextConfig implements ShapeDe
4950

5051
if (isObject) {
5152
if (ns.isStructSchema()) {
53+
const union = ns.isUnionSchema();
5254
const out = {} as any;
55+
let unionSerde: UnionSerde;
56+
if (union) {
57+
unionSerde = new UnionSerde(value, out);
58+
}
5359
for (const [memberName, memberSchema] of deserializingStructIterator(
5460
ns,
5561
value,
5662
this.settings.jsonName ? "jsonName" : false
5763
)) {
5864
const fromKey = this.settings.jsonName ? memberSchema.getMergedTraits().jsonName ?? memberName : memberName;
59-
const deserializedValue = this._read(memberSchema, (value as any)[fromKey]);
60-
if (deserializedValue != null) {
61-
out[memberName] = deserializedValue;
65+
if (union) {
66+
unionSerde!.mark(fromKey);
67+
}
68+
if ((value as any)[fromKey] != null) {
69+
out[memberName] = this._read(memberSchema, (value as any)[fromKey]);
6270
}
6371
}
72+
if (union) {
73+
unionSerde!.writeUnknown();
74+
}
6475
return out;
6576
}
6677
if (Array.isArray(value) && ns.isListSchema()) {

packages/core/src/submodules/protocols/json/JsonShapeSerializer.spec.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { NumericValue } from "@smithy/core/serde";
22
import type { TimestampEpochSecondsSchema } from "@smithy/types";
33
import { describe, expect, test as it } from "vitest";
44

5-
import { createNestingWidget, nestingWidget, widget } from "../test-schema.spec";
5+
import { createNestingWidget, nestingWidget, unionStruct, widget } from "../test-schema.spec";
66
import { SinglePassJsonShapeSerializer } from "./experimental/SinglePassJsonShapeSerializer";
77
import { JsonShapeSerializer } from "./JsonShapeSerializer";
88

@@ -31,6 +31,22 @@ describe(JsonShapeSerializer.name, () => {
3131
);
3232
});
3333

34+
it("serializes $unknown union members", () => {
35+
serializer1.write(unionStruct, {
36+
union: {
37+
$unknown: [
38+
"unknownKey",
39+
{
40+
timestamp: new Date(0),
41+
blob: new Uint8Array([0, 1, 2, 3]),
42+
},
43+
],
44+
},
45+
});
46+
const serialization = serializer1.flush();
47+
expect(serialization).toEqual(`{"union":{"unknownKey":{"timestamp":0,"blob":"AAECAw=="}}}`);
48+
});
49+
3450
describe("performance baseline indicator", () => {
3551
for (const serializer of [serializer1, serializer2]) {
3652
it("should serialize objects", () => {

packages/core/src/submodules/protocols/json/JsonShapeSerializer.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { determineTimestampFormat } from "@smithy/core/protocols";
22
import { NormalizedSchema } from "@smithy/core/schema";
33
import { dateToUtcString, generateIdempotencyToken, LazyJsonString, NumericValue } from "@smithy/core/serde";
44
import type {
5+
DocumentSchema,
56
Schema,
67
ShapeSerializer,
78
TimestampDateTimeSchema,
@@ -81,6 +82,13 @@ export class JsonShapeSerializer extends SerdeContextConfig implements ShapeSeri
8182
out[targetKey] = serializableValue;
8283
}
8384
}
85+
if (ns.isUnionSchema() && Object.keys(out).length === 0) {
86+
const { $unknown } = value as any;
87+
if (Array.isArray($unknown)) {
88+
const [k, v] = $unknown;
89+
out[k] = this._write(15 satisfies DocumentSchema, v);
90+
}
91+
}
8492
return out;
8593
}
8694

packages/core/src/submodules/protocols/json/experimental/SinglePassJsonShapeSerializer.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { determineTimestampFormat } from "@smithy/core/protocols";
22
import { NormalizedSchema } from "@smithy/core/schema";
33
import { dateToUtcString, generateIdempotencyToken, LazyJsonString, NumericValue } from "@smithy/core/serde";
44
import type {
5+
DocumentSchema,
56
Schema,
67
ShapeSerializer,
78
TimestampDateTimeSchema,
@@ -73,15 +74,24 @@ export class SinglePassJsonShapeSerializer extends SerdeContextConfig implements
7374
}
7475
} else if (ns.isStructSchema()) {
7576
b += "{";
77+
let didWriteMember = false;
7678
for (const [name, member] of serializingStructIterator(ns, value)) {
7779
const item = (value as any)[name];
7880
const targetKey = this.settings.jsonName ? member.getMergedTraits().jsonName ?? name : name;
7981
const serializableValue = this.writeValue(member, item);
8082
if (item != null || member.isIdempotencyToken()) {
83+
didWriteMember = true;
8184
b += `"${targetKey}":${serializableValue}`;
8285
b += ",";
8386
}
8487
}
88+
if (!didWriteMember && ns.isUnionSchema()) {
89+
const { $unknown } = value as any;
90+
if (Array.isArray($unknown)) {
91+
const [k, v] = $unknown;
92+
b += `"${k}":${this.writeValue(15 satisfies DocumentSchema, v)}`;
93+
}
94+
}
8595
} else if (ns.isMapSchema() || ns.isDocumentSchema()) {
8696
b += "{";
8797
for (const [k, v] of Object.entries(value)) {

packages/core/src/submodules/protocols/query/QueryShapeSerializer.spec.ts

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,15 @@ import { NumericValue } from "@smithy/core/serde";
22
import type { TimestampDateTimeSchema } from "@smithy/types";
33
import { describe, expect, test as it } from "vitest";
44

5-
import { widget } from "../test-schema.spec";
5+
import { unionStruct, unionStructControl, widget } from "../test-schema.spec";
66
import { QueryShapeSerializer } from "./QueryShapeSerializer";
77

88
describe(QueryShapeSerializer.name, () => {
9-
it("serializes data to Query", async () => {
10-
const serializer = new QueryShapeSerializer({
11-
timestampFormat: { default: 5 satisfies TimestampDateTimeSchema, useTrait: true },
12-
});
13-
serializer.setSerdeContext({
14-
base64Encoder: (input: Uint8Array) => {
15-
return Buffer.from(input).toString("base64");
16-
},
17-
} as any);
9+
const serializer = new QueryShapeSerializer({
10+
timestampFormat: { default: 5 satisfies TimestampDateTimeSchema, useTrait: true },
11+
});
1812

13+
it("serializes data to Query", async () => {
1914
const data = {
2015
timestamp: new Date(0),
2116
bigint: 10000000000000000000000054321n,
@@ -28,4 +23,41 @@ describe(QueryShapeSerializer.name, () => {
2823
`&blob=AAAAAQ%3D%3D&timestamp=0&bigint=10000000000000000000000054321&bigdecimal=0.10000000000000000000000054321`
2924
);
3025
});
26+
27+
it("serializes $unknown union members", () => {
28+
{
29+
serializer.write(unionStruct, {
30+
union: {
31+
$unknown: [
32+
"unknownKey",
33+
{
34+
timestamp: new Date(0),
35+
blob: new Uint8Array([0, 1, 2, 3]),
36+
},
37+
],
38+
},
39+
});
40+
const serialization = String(serializer.flush()).replaceAll(/&/g, "\n&");
41+
expect(serialization).toEqual(`
42+
&union.unknownKey.entry.1.key=timestamp
43+
&union.unknownKey.entry.1.value=1970-01-01T00%3A00%3A00Z
44+
&union.unknownKey.entry.2.key=blob
45+
&union.unknownKey.entry.2.value=AAECAw%3D%3D`);
46+
}
47+
{
48+
serializer.write(unionStructControl, {
49+
union: {
50+
$unknown: [
51+
"unknownKey",
52+
{
53+
timestamp: new Date(0),
54+
blob: new Uint8Array([0, 1, 2, 3]),
55+
},
56+
],
57+
},
58+
});
59+
const serialization = serializer.flush();
60+
expect(serialization).toEqual(``);
61+
}
62+
});
3163
});

packages/core/src/submodules/protocols/query/QueryShapeSerializer.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,15 @@ import { NormalizedSchema } from "@smithy/core/schema";
33
import { generateIdempotencyToken, NumericValue } from "@smithy/core/serde";
44
import { dateToUtcString } from "@smithy/smithy-client";
55
import type {
6+
BlobSchema,
7+
DocumentSchema,
8+
ListSchemaModifier,
9+
MapSchemaModifier,
610
Schema,
711
ShapeSerializer,
12+
StringSchema,
813
TimestampDateTimeSchema,
14+
TimestampDefaultSchema,
915
TimestampEpochSecondsSchema,
1016
TimestampHttpDateSchema,
1117
} from "@smithy/types";
@@ -74,7 +80,18 @@ export class QueryShapeSerializer extends SerdeContextConfig implements ShapeSer
7480
}
7581
}
7682
} else if (ns.isDocumentSchema()) {
77-
throw new Error(`@aws-sdk/core/protocols - QuerySerializer unsupported document type ${ns.getName(true)}`);
83+
if (Array.isArray(value)) {
84+
this.write((64 satisfies ListSchemaModifier) | (15 satisfies DocumentSchema), value, prefix);
85+
} else if (value instanceof Date) {
86+
this.write(4 satisfies TimestampDefaultSchema, value, prefix);
87+
} else if (value instanceof Uint8Array) {
88+
this.write(21 satisfies BlobSchema, value, prefix);
89+
} else if (value && typeof value === "object") {
90+
this.write((128 satisfies MapSchemaModifier) | (15 satisfies DocumentSchema), value, prefix);
91+
} else {
92+
this.writeKey(prefix);
93+
this.writeValue(String(value));
94+
}
7895
} else if (ns.isListSchema()) {
7996
if (Array.isArray(value)) {
8097
if (value.length === 0) {
@@ -120,13 +137,23 @@ export class QueryShapeSerializer extends SerdeContextConfig implements ShapeSer
120137
}
121138
} else if (ns.isStructSchema()) {
122139
if (value && typeof value === "object") {
140+
let didWriteMember = false;
123141
for (const [memberName, member] of serializingStructIterator(ns, value)) {
124142
if ((value as any)[memberName] == null && !member.isIdempotencyToken()) {
125143
continue;
126144
}
127145
const suffix = this.getKey(memberName, member.getMergedTraits().xmlName);
128146
const key = `${prefix}${suffix}`;
129147
this.write(member, (value as any)[memberName], key);
148+
didWriteMember = true;
149+
}
150+
if (!didWriteMember && ns.isUnionSchema()) {
151+
const { $unknown } = value as any;
152+
if (Array.isArray($unknown)) {
153+
const [k, v] = $unknown;
154+
const key = `${prefix}${k}`;
155+
this.write(15 satisfies DocumentSchema, v, key);
156+
}
130157
}
131158
}
132159
} else if (ns.isUnitSchema()) {

packages/core/src/submodules/protocols/test-schema.spec.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
StaticListSchema,
88
StaticOperationSchema,
99
StaticStructureSchema,
10+
StaticUnionSchema,
1011
StringSchema,
1112
TimestampDefaultSchema,
1213
TimestampEpochSecondsSchema,
@@ -122,3 +123,21 @@ export const context = {
122123
};
123124
},
124125
} as any;
126+
127+
export const unionStruct = [
128+
3,
129+
"ns",
130+
"UnionStruct",
131+
0,
132+
["union"],
133+
[[4, "ns", "Union", 0, ["string", "timestamp", "blob"], [0, 7, 21]] satisfies StaticUnionSchema],
134+
] satisfies StaticStructureSchema;
135+
136+
export const unionStructControl = [
137+
3,
138+
"ns",
139+
"UnionStruct",
140+
0,
141+
["union"],
142+
[[3, "ns", "Union", 0, ["string", "timestamp", "blob"], [0, 7, 21]] satisfies StaticStructureSchema],
143+
] satisfies StaticStructureSchema;

0 commit comments

Comments
 (0)