Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(protocols): fix header serde, handle unset union payloads #1417

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/witty-rings-do.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@smithy/smithy-client": minor
---

add quoteHeader function
2 changes: 2 additions & 0 deletions packages/smithy-client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ export * from "./lazy-json";
export * from "./NoOpLogger";
export * from "./object-mapping";
export * from "./parse-utils";
export * from "./quote-header";
export * from "./resolve-path";
export * from "./ser-utils";
export * from "./serde-json";
export * from "./split-every";
export * from "./split-header";
19 changes: 19 additions & 0 deletions packages/smithy-client/src/quote-header.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { quoteHeader } from "./quote-header";

describe(quoteHeader.name, () => {
it("should not wrap header elements that don't include the delimiter or double quotes", () => {
expect(quoteHeader("bc")).toBe("bc");
});

it("should wrap header elements that include the delimiter", () => {
expect(quoteHeader("b,c")).toBe('"b,c"');
});

it("should wrap header elements that include double quotes", () => {
expect(quoteHeader(`"bc"`)).toBe('"\\"bc\\""');
});

it("should wrap header elements that include the delimiter and double quotes", () => {
expect(quoteHeader(`"b,c"`)).toBe('"\\"b,c\\""');
});
});
11 changes: 11 additions & 0 deletions packages/smithy-client/src/quote-header.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* @public
* @param part - header list element
* @returns quoted string if part contains delimiter.
*/
export function quoteHeader(part: string) {
if (part.includes(",") || part.includes('"')) {
part = `"${part.replace(/"/g, '\\"')}"`;
}
return part;
}
Copy link
Contributor Author

@kuhe kuhe Sep 27, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function filters the input/serialization side of list/set shape headers. A simple .join(",") is not enough, since if the individual elements contain either the delimiter , or ", they must be quoted. The internal quotes must also be escaped.

15 changes: 15 additions & 0 deletions packages/smithy-client/src/split-header.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { splitHeader } from "./split-header";

describe(splitHeader.name, () => {
it("should split a string by commas and trim only the comma delimited outer values", () => {
expect(splitHeader("abc")).toEqual(["abc"]);
expect(splitHeader("a,b,c")).toEqual(["a", "b", "c"]);
expect(splitHeader("a, b, c")).toEqual(["a", "b", "c"]);
expect(splitHeader("a , b , c")).toEqual(["a", "b", "c"]);
expect(splitHeader(`a , b , " c "`)).toEqual(["a", "b", " c "]);
});
it("should split a string by commas that are not in quotes, and remove outer quotes", () => {
expect(splitHeader('"b,c", "\\"def\\"", a')).toEqual(["b,c", '"def"', "a"]);
expect(splitHeader('"a,b,c", ""def"", "a,b ,c"')).toEqual(["a,b,c", '"def"', "a,b ,c"]);
});
});
42 changes: 42 additions & 0 deletions packages/smithy-client/src/split-header.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* @param value - header string value.
* @returns value split by commas that aren't in quotes.
*/
export const splitHeader = (value: string): string[] => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This takes over .split(",") on the deserialization side. Splitting by commas is not enough, because commas within quotes do not count as delimiters, and once split, the items wrapped by double quotes should have those quotes removed.

Behavior specified here: https://github.com/smithy-lang/smithy/blob/main/smithy-aws-protocol-tests/model/restJson1/http-headers.smithy#L229-L232

{
        id: "RestJsonInputAndOutputWithQuotedStringHeaders",
        documentation: "Tests responses with string list header bindings that require quoting",
        protocol: restJson1,
        code: 200,
        headers: {
            "X-StringList": "\"b,c\", \"\\\"def\\\"\", a"
        },
        params: {
            headerStringList: ["b,c", "\"def\"", "a"]
        }
}

const z = value.length;
const values = [];

let withinQuotes = false;
let prevChar = undefined;
let anchor = 0;

for (let i = 0; i < z; ++i) {
const char = value[i];
switch (char) {
case `"`:
if (prevChar !== "\\") {
withinQuotes = !withinQuotes;
}
break;
case ",":
if (!withinQuotes) {
values.push(value.slice(anchor, i));
anchor = i + 1;
}
break;
default:
}
prevChar = char;
}

values.push(value.slice(anchor));

return values.map((v) => {
v = v.trim();
const z = v.length;
if (v[0] === `"` && v[z - 1] === `"`) {
v = v.slice(1, z - 1);
}
return v.replace(/\\"/g, '"');
});
};
Original file line number Diff line number Diff line change
Expand Up @@ -697,7 +697,7 @@ it("no_input:Request", async () => {
expect(r.headers["smithy-protocol"]).toBeDefined();
expect(r.headers["smithy-protocol"]).toBe("rpc-v2-cbor");

expect(r.body).toBeFalsy();
expect(!r.body || r.body === `{}`).toBeTruthy();
}
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
import software.amazon.smithy.utils.IoUtils;
import software.amazon.smithy.utils.MapUtils;
import software.amazon.smithy.utils.Pair;
import software.amazon.smithy.utils.SetUtils;
import software.amazon.smithy.utils.SmithyInternalApi;

/**
Expand All @@ -91,6 +92,9 @@ public final class HttpProtocolTestGenerator implements Runnable {

private static final Logger LOGGER = Logger.getLogger(HttpProtocolTestGenerator.class.getName());
private static final String TEST_CASE_FILE_TEMPLATE = "test/functional/%s.spec.ts";
private static final Set<String> IGNORE_COMMA_SPACING = SetUtils.of(
"content-encoding"
);

private final TypeScriptSettings settings;
private final Model model;
Expand Down Expand Up @@ -517,7 +521,17 @@ private void writeHttpHeaderAssertions(HttpMessageTestCase testCase) {
testCase.getHeaders().forEach((header, value) -> {
header = header.toLowerCase();
writer.write("expect(r.headers[$S]).toBeDefined();", header);
writer.write("expect(r.headers[$S]).toBe($S);", header, value);
if (IGNORE_COMMA_SPACING.contains(header) && value.contains(",")) {
writer.write("""
expect(
r.headers[$S].replaceAll(", ", ",")
).toBe(
$S.replaceAll(", ", ",")
);
""", header, value);
} else {
writer.write("expect(r.headers[$S]).toBe($S);", header, value);
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I propose that we ignore comma delimiter spacing rather than changing the upstream Smithy test assertion or our and other language implementations.

});
writer.write("");
}
Expand All @@ -531,9 +545,11 @@ private void writeHttpHostAssertion(HttpRequestTestCase testCase) {
}

private void writeHttpBodyAssertions(String body, String mediaType, boolean isClientTest) {
// If we expect an empty body, expect it to be falsy.
if (body.isEmpty()) {
writer.write("expect(r.body).toBeFalsy();");
// If we expect an empty body, expect it to be falsy.
// Or, for JSON an empty object represents an empty body.
// mediaType is often UNKNOWN here.
writer.write("expect(!r.body || r.body === `{}`).toBeTruthy();");
return;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1385,6 +1385,11 @@ private String getCollectionInputParam(

switch (bindingType) {
case HEADER:
if (collectionTarget.isStringShape()) {
context.getWriter().addImport(
"quoteHeader", "__quoteHeader", TypeScriptDependency.AWS_SMITHY_CLIENT);
return iteratedParam + ".map(__quoteHeader).join(', ')";
}
return iteratedParam + ".join(', ')";
case QUERY:
case QUERY_PARAMS:
Expand Down Expand Up @@ -2464,18 +2469,31 @@ private HttpBinding readPayload(
+ "= __expectObject(await parseBody(output.body, context));");
} else if (target instanceof UnionShape) {
// If payload is a Union, then we need to parse the string into JavaScript object.
importUnionDeserializer(writer);
writer.write("const data: Record<string, any> | undefined "
+ "= __expectUnion(await parseBody(output.body, context));");
+ "= await parseBody(output.body, context);");
} else if (target instanceof StringShape || target instanceof DocumentShape) {
// If payload is String or Document, we need to collect body and convert binary to string.
writer.write("const data: any = await collectBodyString(output.body, context);");
} else {
throw new CodegenException(String.format("Unexpected shape type bound to payload: `%s`",
target.getType()));
}
writer.write("contents.$L = $L;", binding.getMemberName(), getOutputValue(context,

if (target instanceof UnionShape) {
writer.openBlock(
"if (Object.keys(data ?? {}).length) {",
"}",
() -> {
importUnionDeserializer(writer);
writer.write("contents.$L = __expectUnion($L);", binding.getMemberName(), getOutputValue(context,
Location.PAYLOAD, "data", binding.getMember(), target));
}
);
} else {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

old:

  const data: Record<string, any> | undefined = __expectUnion(await parseBody(output.body, context));
  contents.nested = de_UnionPayload(data, context);

(does not handle empty output correctly)

new:

  const data: Record<string, any> | undefined = await parseBody(output.body, context);
  if (Object.keys(data ?? {}).length) {
    contents.nested = __expectUnion(de_UnionPayload(data, context));
  }

writer.write("contents.$L = $L;", binding.getMemberName(), getOutputValue(context,
Location.PAYLOAD, "data", binding.getMember(), target));
}

return binding;
}

Expand Down Expand Up @@ -2716,7 +2734,8 @@ private String getCollectionOutputParam(
case HEADER:
dataSource = "(" + dataSource + " || \"\")";
// Split these values on commas.
outputParam = dataSource + ".split(',')";
context.getWriter().addImport("splitHeader", "__splitHeader", TypeScriptDependency.AWS_SMITHY_CLIENT);
outputParam = "__splitHeader(" + dataSource + ")";

// Headers that have HTTP_DATE formatted timestamps already contain a ","
// in their formatted entry, so split on every other "," instead.
Expand Down
Loading