Skip to content

Commit 0a610ec

Browse files
author
Zelda Hessler
authored
Always write required query param keys, even if value is unset or empty (#1973)
* fix: issues when sending multipart-upload-related operations with no upload ID by making upload ID required add: Mandatory trait add: S3 customization to make Upload Ids mandatory * add: CHANGELOG.next.toml entry * update: strategy for tagging shapes as mandatory * remove: Mandatory trait undo: S3Decorator changes add: codegen to generate "else" branch after required query string serialization `if let Some` check add: missing tokio feature to `TokioTest` * update: mandatory-query-param.rs tests * format: run cargo fmt * update: return BuildError for missing required query params update: required-query-params.rs tests * formatting: make RustWriter.paramList more readable fix: code broken by merge from `main` * fix: handling of enum strings * add: codegen tests for required query params update: required enum query param codegen add: smithy-rs CHANGELOG.next.toml entry * fix: presigning.rs test * fix: use `toType` instead of `asType` fix: indentation in test
1 parent c370c8d commit 0a610ec

File tree

7 files changed

+192
-22
lines changed

7 files changed

+192
-22
lines changed

CHANGELOG.next.toml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,27 @@ references = ["smithy-rs#1935"]
197197
meta = { "breaking" = true, "tada" = false, "bug" = false }
198198
author = "jdisanti"
199199

200+
[[aws-sdk-rust]]
201+
message = """
202+
It was possible in some cases to send some S3 requests without a required upload ID, causing a risk of unintended data
203+
deletion and modification. Now, when an operation has query parameters that are marked as required, the omission of
204+
those query parameters will cause a BuildError, preventing the invalid operation from being sent.
205+
"""
206+
references = ["smithy-rs#1957"]
207+
meta = { "breaking" = false, "tada" = false, "bug" = true }
208+
author = "Velfi"
209+
210+
[[smithy-rs]]
211+
message = """
212+
It was previously possible to send requests without setting query parameters modeled as required. Doing this may cause a
213+
service to interpret a request incorrectly instead of just sending back a 400 error. Now, when an operation has query
214+
parameters that are marked as required, the omission of those query parameters will cause a BuildError, preventing the
215+
invalid operation from being sent.
216+
"""
217+
references = ["smithy-rs#1957"]
218+
meta = { "breaking" = false, "tada" = false, "bug" = true, "target" = "client" }
219+
author = "Velfi"
220+
200221
[[smithy-rs]]
201222
message = "Upgrade to Smithy 1.26.2"
202223
references = ["smithy-rs#1972"]

aws/sdk-codegen/src/main/kotlin/software/amazon/smithy/rustsdk/customize/s3/S3Decorator.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,8 @@ class S3Decorator : RustCodegenDecorator<ClientProtocolGenerator, ClientCodegenC
6666
return model.letIf(applies(service.id)) {
6767
ModelTransformer.create().mapShapes(model) { shape ->
6868
shape.letIf(isInInvalidXmlRootAllowList(shape)) {
69-
logger.info("Adding AllowInvalidXmlRoot trait to $shape")
70-
(shape as StructureShape).toBuilder().addTrait(AllowInvalidXmlRoot()).build()
69+
logger.info("Adding AllowInvalidXmlRoot trait to $it")
70+
(it as StructureShape).toBuilder().addTrait(AllowInvalidXmlRoot()).build()
7171
}
7272
}
7373
}

aws/sdk/integration-tests/s3/tests/presigning.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ async fn test_presigned_upload_part() -> Result<(), Box<dyn Error>> {
124124
.build()?);
125125
assert_eq!(
126126
presigned.uri().to_string(),
127-
"https://s3.us-east-1.amazonaws.com/bucket/key?x-id=UploadPart&uploadId=upload-id&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=ANOTREAL%2F20090213%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20090213T233131Z&X-Amz-Expires=30&X-Amz-SignedHeaders=content-length%3Bhost&X-Amz-Signature=e50e1a4d1dae7465bb7731863a565bdf4137393e3ab4119b5764fb49f5f60b14&X-Amz-Security-Token=notarealsessiontoken"
127+
"https://s3.us-east-1.amazonaws.com/bucket/key?x-id=UploadPart&partNumber=0&uploadId=upload-id&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=ANOTREAL%2F20090213%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20090213T233131Z&X-Amz-Expires=30&X-Amz-SignedHeaders=content-length%3Bhost&X-Amz-Signature=59777f7ddd2f324dfe0749685e06b978433d03e6f090dceb96eb23cc9540c30c&X-Amz-Security-Token=notarealsessiontoken"
128128
);
129129
Ok(())
130130
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
use aws_sdk_s3::operation::AbortMultipartUpload;
7+
use aws_sdk_s3::Region;
8+
use aws_smithy_http::operation::error::BuildError;
9+
10+
#[tokio::test]
11+
async fn test_error_when_required_query_param_is_unset() {
12+
let conf = aws_sdk_s3::Config::builder()
13+
.region(Region::new("us-east-1"))
14+
.build();
15+
16+
let err = AbortMultipartUpload::builder()
17+
.bucket("test-bucket")
18+
.key("test.txt")
19+
.build()
20+
.unwrap()
21+
.make_operation(&conf)
22+
.await
23+
.unwrap_err();
24+
25+
assert_eq!(
26+
BuildError::missing_field("upload_id", "cannot be empty or unset").to_string(),
27+
err.to_string(),
28+
)
29+
}
30+
31+
#[tokio::test]
32+
async fn test_error_when_required_query_param_is_set_but_empty() {
33+
let conf = aws_sdk_s3::Config::builder()
34+
.region(Region::new("us-east-1"))
35+
.build();
36+
let err = AbortMultipartUpload::builder()
37+
.bucket("test-bucket")
38+
.key("test.txt")
39+
.upload_id("")
40+
.build()
41+
.unwrap()
42+
.make_operation(&conf)
43+
.await
44+
.unwrap_err();
45+
46+
assert_eq!(
47+
BuildError::missing_field("upload_id", "cannot be empty or unset").to_string(),
48+
err.to_string(),
49+
)
50+
}

codegen-core/src/main/kotlin/software/amazon/smithy/rust/codegen/core/smithy/generators/http/RequestBindingGenerator.kt

Lines changed: 54 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import software.amazon.smithy.model.shapes.MemberShape
1414
import software.amazon.smithy.model.shapes.OperationShape
1515
import software.amazon.smithy.model.shapes.Shape
1616
import software.amazon.smithy.model.shapes.StructureShape
17+
import software.amazon.smithy.model.traits.EnumTrait
1718
import software.amazon.smithy.model.traits.HttpTrait
1819
import software.amazon.smithy.rust.codegen.core.rustlang.Attribute
1920
import software.amazon.smithy.rust.codegen.core.rustlang.CargoDependency
@@ -32,6 +33,7 @@ import software.amazon.smithy.rust.codegen.core.smithy.isOptional
3233
import software.amazon.smithy.rust.codegen.core.smithy.protocols.Protocol
3334
import software.amazon.smithy.rust.codegen.core.util.dq
3435
import software.amazon.smithy.rust.codegen.core.util.expectMember
36+
import software.amazon.smithy.rust.codegen.core.util.hasTrait
3537
import software.amazon.smithy.rust.codegen.core.util.inputShape
3638

3739
fun HttpTrait.uriFormatString(): String {
@@ -54,7 +56,7 @@ fun SmithyPattern.rustFormatString(prefix: String, separator: String): String {
5456
* Generates methods to serialize and deserialize requests based on the HTTP trait. Specifically:
5557
* 1. `fn update_http_request(builder: http::request::Builder) -> Builder`
5658
*
57-
* This method takes a builder (perhaps pre configured with some headers) from the caller and sets the HTTP
59+
* This method takes a builder (perhaps pre-configured with some headers) from the caller and sets the HTTP
5860
* headers & URL based on the HTTP trait implementation.
5961
*/
6062
class RequestBindingGenerator(
@@ -71,7 +73,7 @@ class RequestBindingGenerator(
7173
private val httpBindingGenerator =
7274
HttpBindingGenerator(protocol, codegenContext, codegenContext.symbolProvider, operationShape, ::builderSymbol)
7375
private val index = HttpBindingIndex.of(model)
74-
private val Encoder = CargoDependency.smithyTypes(runtimeConfig).toType().member("primitive::Encoder")
76+
private val encoder = CargoDependency.smithyTypes(runtimeConfig).toType().member("primitive::Encoder")
7577

7678
private val codegenScope = arrayOf(
7779
"BuildError" to runtimeConfig.operationBuildError(),
@@ -207,24 +209,58 @@ class RequestBindingGenerator(
207209
val memberShape = param.member
208210
val memberSymbol = symbolProvider.toSymbol(memberShape)
209211
val memberName = symbolProvider.toMemberName(memberShape)
210-
val outerTarget = model.expectShape(memberShape.target)
211-
ifSet(outerTarget, memberSymbol, "&_input.$memberName") { field ->
212-
// if `param` is a list, generate another level of iteration
213-
listForEach(outerTarget, field) { innerField, targetId ->
214-
val target = model.expectShape(targetId)
215-
rust(
216-
"query.push_kv(${param.locationName.dq()}, ${
217-
paramFmtFun(writer, target, memberShape, innerField)
218-
});",
212+
val target = model.expectShape(memberShape.target)
213+
214+
if (memberShape.isRequired) {
215+
val codegenScope = arrayOf(
216+
"BuildError" to OperationBuildError(runtimeConfig).missingField(
217+
memberName,
218+
"cannot be empty or unset",
219+
),
220+
)
221+
val derefName = safeName("inner")
222+
rust("let $derefName = &_input.$memberName;")
223+
if (memberSymbol.isOptional()) {
224+
rustTemplate(
225+
"let $derefName = $derefName.as_ref().ok_or_else(|| #{BuildError:W})?;",
226+
*codegenScope,
219227
)
220228
}
229+
230+
// Strings that aren't enums must be checked to see if they're empty
231+
if (target.isStringShape && !target.hasTrait<EnumTrait>()) {
232+
rustBlock("if $derefName.is_empty()") {
233+
rustTemplate("return Err(#{BuildError:W});", *codegenScope)
234+
}
235+
}
236+
237+
paramList(target, derefName, param, writer, memberShape)
238+
} else {
239+
ifSet(target, memberSymbol, "&_input.$memberName") { field ->
240+
// if `param` is a list, generate another level of iteration
241+
paramList(target, field, param, writer, memberShape)
242+
}
221243
}
222244
}
223245
writer.rust("Ok(())")
224246
}
225247
return true
226248
}
227249

250+
private fun RustWriter.paramList(
251+
outerTarget: Shape,
252+
field: String,
253+
param: HttpBinding,
254+
writer: RustWriter,
255+
memberShape: MemberShape,
256+
) {
257+
listForEach(outerTarget, field) { innerField, targetId ->
258+
val target = model.expectShape(targetId)
259+
val value = paramFmtFun(writer, target, memberShape, innerField)
260+
rust("""query.push_kv("${param.locationName}", $value);""")
261+
}
262+
}
263+
228264
/**
229265
* Format [member] when used as a queryParam
230266
*/
@@ -234,18 +270,21 @@ class RequestBindingGenerator(
234270
val func = writer.format(RuntimeType.QueryFormat(runtimeConfig, "fmt_string"))
235271
"&$func(&$targetName)"
236272
}
273+
237274
target.isTimestampShape -> {
238275
val timestampFormat =
239276
index.determineTimestampFormat(member, HttpBinding.Location.QUERY, protocol.defaultTimestampFormat)
240277
val timestampFormatType = RuntimeType.TimestampFormat(runtimeConfig, timestampFormat)
241278
val func = writer.format(RuntimeType.QueryFormat(runtimeConfig, "fmt_timestamp"))
242279
"&$func($targetName, ${writer.format(timestampFormatType)})?"
243280
}
281+
244282
target.isListShape || target.isMemberShape -> {
245283
throw IllegalArgumentException("lists should be handled at a higher level")
246284
}
285+
247286
else -> {
248-
"${writer.format(Encoder)}::from(${autoDeref(targetName)}).encode()"
287+
"${writer.format(encoder)}::from(${autoDeref(targetName)}).encode()"
249288
}
250289
}
251290
}
@@ -272,17 +311,19 @@ class RequestBindingGenerator(
272311
}
273312
rust("let $outputVar = $func($input, #T);", encodingStrategy)
274313
}
314+
275315
target.isTimestampShape -> {
276316
val timestampFormat =
277317
index.determineTimestampFormat(member, HttpBinding.Location.LABEL, protocol.defaultTimestampFormat)
278318
val timestampFormatType = RuntimeType.TimestampFormat(runtimeConfig, timestampFormat)
279319
val func = format(RuntimeType.LabelFormat(runtimeConfig, "fmt_timestamp"))
280320
rust("let $outputVar = $func($input, ${format(timestampFormatType)})?;")
281321
}
322+
282323
else -> {
283324
rust(
284325
"let mut ${outputVar}_encoder = #T::from(${autoDeref(input)}); let $outputVar = ${outputVar}_encoder.encode();",
285-
Encoder,
326+
encoder,
286327
)
287328
}
288329
}

codegen-core/src/main/kotlin/software/amazon/smithy/rust/codegen/core/testutil/TestHelpers.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ fun StructureShape.renderWithModelBuilder(
117117
val TokioWithTestMacros = CargoDependency(
118118
"tokio",
119119
CratesIo("1"),
120-
features = setOf("macros", "test-util", "rt"),
120+
features = setOf("macros", "test-util", "rt", "rt-multi-thread"),
121121
scope = DependencyScope.Dev,
122122
)
123123

0 commit comments

Comments
 (0)