From da264056eb9f5cba1bd5a01d0f81f404d53aeead Mon Sep 17 00:00:00 2001 From: ysaito1001 Date: Wed, 22 Feb 2023 15:04:48 -0600 Subject: [PATCH] Add support for the awsQueryCompatible trait (#2398) * Add support for the awsQueryCompatible trait This commit adds support for the awsQueryCompatible trait. This allows services already supporting custom error codes through the AWS Query protocol with the awsQueryError trait to continue supporting them after the services switch to the AWS JSON 1.0 protocol. * Add copyright header * Fix clippy warning for clippy::manual-map * Update CHANGELOG.next.toml * Update CHANGELOG.next.toml * Update CHANGELOG.next.toml * Remove unused variables from `errorScope` This commit addresses https://github.com/awslabs/smithy-rs/pull/2398#discussion_r1114763528 * Reorder arguments for test verification This commit addresses https://github.com/awslabs/smithy-rs/pull/2398#discussion_r1114766817 --------- Co-authored-by: Yuki Saito --- CHANGELOG.next.toml | 41 +++++ .../smithy/protocols/ClientProtocolLoader.kt | 14 +- .../protocols/AwsQueryCompatibleTest.kt | 152 ++++++++++++++++++ .../codegen/core/rustlang/CargoDependency.kt | 7 + .../rust/codegen/core/smithy/RuntimeType.kt | 1 + .../smithy/protocols/AwsQueryCompatible.kt | 97 +++++++++++ .../src/aws_query_compatible_errors.rs | 103 ++++++++++++ rust-runtime/inlineable/src/lib.rs | 2 + 8 files changed, 416 insertions(+), 1 deletion(-) create mode 100644 codegen-client/src/test/kotlin/software/amazon/smithy/rust/codegen/client/smithy/protocols/AwsQueryCompatibleTest.kt create mode 100644 codegen-core/src/main/kotlin/software/amazon/smithy/rust/codegen/core/smithy/protocols/AwsQueryCompatible.kt create mode 100644 rust-runtime/inlineable/src/aws_query_compatible_errors.rs diff --git a/CHANGELOG.next.toml b/CHANGELOG.next.toml index d776a86f6a..8e2684683b 100644 --- a/CHANGELOG.next.toml +++ b/CHANGELOG.next.toml @@ -235,3 +235,44 @@ message = "Support for constraint traits on member shapes (constraint trait prec references = ["smithy-rs#1969"] meta = { "breaking" = false, "tada" = true, "bug" = false, "target" = "server" } author = "drganjoo" + +[[smithy-rs]] +message = """ +Add support for the `awsQueryCompatible` trait. This allows services to continue supporting a custom error code (via the `awsQueryError` trait) when the services migrate their protocol from `awsQuery` to `awsJson1_0` annotated with `awsQueryCompatible`. +
+Click to expand for more details... + +After the migration, services will include an additional header `x-amzn-query-error` in their responses whose value is in the form of `;`. An example response looks something like +``` +HTTP/1.1 400 +x-amzn-query-error: AWS.SimpleQueueService.NonExistentQueue;Sender +Date: Wed, 08 Sep 2021 23:46:52 GMT +Content-Type: application/x-amz-json-1.0 +Content-Length: 163 + +{ + "__type": "com.amazonaws.sqs#QueueDoesNotExist", + "message": "some user-visible message" +} +``` +`` is `AWS.SimpleQueueService.NonExistentQueue` and `` is `Sender`. + +If an operation results in an error that causes a service to send back the response above, you can access `` and `` as follows: +```rust +match client.some_operation().send().await { + Ok(_) => { /* success */ } + Err(sdk_err) => { + let err = sdk_err.into_service_error(); + assert_eq!( + error.meta().code(), + Some("AWS.SimpleQueueService.NonExistentQueue"), + ); + assert_eq!(error.meta().extra("type"), Some("Sender")); + } +} +
+``` +""" +references = ["smithy-rs#2398"] +meta = { "breaking" = false, "tada" = true, "bug" = false } +author = "ysaito1001" diff --git a/codegen-client/src/main/kotlin/software/amazon/smithy/rust/codegen/client/smithy/protocols/ClientProtocolLoader.kt b/codegen-client/src/main/kotlin/software/amazon/smithy/rust/codegen/client/smithy/protocols/ClientProtocolLoader.kt index ecfea77ff5..a194dc98a2 100644 --- a/codegen-client/src/main/kotlin/software/amazon/smithy/rust/codegen/client/smithy/protocols/ClientProtocolLoader.kt +++ b/codegen-client/src/main/kotlin/software/amazon/smithy/rust/codegen/client/smithy/protocols/ClientProtocolLoader.kt @@ -7,16 +7,19 @@ package software.amazon.smithy.rust.codegen.client.smithy.protocols import software.amazon.smithy.aws.traits.protocols.AwsJson1_0Trait import software.amazon.smithy.aws.traits.protocols.AwsJson1_1Trait +import software.amazon.smithy.aws.traits.protocols.AwsQueryCompatibleTrait import software.amazon.smithy.aws.traits.protocols.AwsQueryTrait import software.amazon.smithy.aws.traits.protocols.Ec2QueryTrait import software.amazon.smithy.aws.traits.protocols.RestJson1Trait import software.amazon.smithy.aws.traits.protocols.RestXmlTrait +import software.amazon.smithy.model.shapes.ServiceShape import software.amazon.smithy.rust.codegen.client.smithy.ClientCodegenContext import software.amazon.smithy.rust.codegen.client.smithy.generators.protocol.ClientProtocolGenerator import software.amazon.smithy.rust.codegen.core.smithy.CodegenContext import software.amazon.smithy.rust.codegen.core.smithy.generators.protocol.ProtocolSupport import software.amazon.smithy.rust.codegen.core.smithy.protocols.AwsJson import software.amazon.smithy.rust.codegen.core.smithy.protocols.AwsJsonVersion +import software.amazon.smithy.rust.codegen.core.smithy.protocols.AwsQueryCompatible import software.amazon.smithy.rust.codegen.core.smithy.protocols.AwsQueryProtocol import software.amazon.smithy.rust.codegen.core.smithy.protocols.Ec2QueryProtocol import software.amazon.smithy.rust.codegen.core.smithy.protocols.Protocol @@ -25,6 +28,7 @@ import software.amazon.smithy.rust.codegen.core.smithy.protocols.ProtocolLoader import software.amazon.smithy.rust.codegen.core.smithy.protocols.ProtocolMap import software.amazon.smithy.rust.codegen.core.smithy.protocols.RestJson import software.amazon.smithy.rust.codegen.core.smithy.protocols.RestXml +import software.amazon.smithy.rust.codegen.core.util.hasTrait class ClientProtocolLoader(supportedProtocols: ProtocolMap) : ProtocolLoader(supportedProtocols) { @@ -57,12 +61,20 @@ private val CLIENT_PROTOCOL_SUPPORT = ProtocolSupport( private class ClientAwsJsonFactory(private val version: AwsJsonVersion) : ProtocolGeneratorFactory { - override fun protocol(codegenContext: ClientCodegenContext): Protocol = AwsJson(codegenContext, version) + override fun protocol(codegenContext: ClientCodegenContext): Protocol = + if (compatibleWithAwsQuery(codegenContext.serviceShape, version)) { + AwsQueryCompatible(codegenContext, AwsJson(codegenContext, version)) + } else { + AwsJson(codegenContext, version) + } override fun buildProtocolGenerator(codegenContext: ClientCodegenContext): HttpBoundProtocolGenerator = HttpBoundProtocolGenerator(codegenContext, protocol(codegenContext)) override fun support(): ProtocolSupport = CLIENT_PROTOCOL_SUPPORT + + private fun compatibleWithAwsQuery(serviceShape: ServiceShape, version: AwsJsonVersion) = + serviceShape.hasTrait() && version == AwsJsonVersion.Json10 } private class ClientAwsQueryFactory : ProtocolGeneratorFactory { diff --git a/codegen-client/src/test/kotlin/software/amazon/smithy/rust/codegen/client/smithy/protocols/AwsQueryCompatibleTest.kt b/codegen-client/src/test/kotlin/software/amazon/smithy/rust/codegen/client/smithy/protocols/AwsQueryCompatibleTest.kt new file mode 100644 index 0000000000..df33d83151 --- /dev/null +++ b/codegen-client/src/test/kotlin/software/amazon/smithy/rust/codegen/client/smithy/protocols/AwsQueryCompatibleTest.kt @@ -0,0 +1,152 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.rust.codegen.client.smithy.protocols + +import org.junit.jupiter.api.Test +import software.amazon.smithy.rust.codegen.client.testutil.clientIntegrationTest +import software.amazon.smithy.rust.codegen.core.rustlang.rust +import software.amazon.smithy.rust.codegen.core.testutil.asSmithyModel +import software.amazon.smithy.rust.codegen.core.testutil.integrationTest + +class AwsQueryCompatibleTest { + @Test + fun `aws-query-compatible json with aws query error should allow for retrieving error code and type from custom header`() { + val model = """ + namespace test + use aws.protocols#awsJson1_0 + use aws.protocols#awsQueryCompatible + use aws.protocols#awsQueryError + + @awsQueryCompatible + @awsJson1_0 + service TestService { + version: "2023-02-20", + operations: [SomeOperation] + } + + operation SomeOperation { + input: SomeOperationInputOutput, + output: SomeOperationInputOutput, + errors: [InvalidThingException], + } + + structure SomeOperationInputOutput { + a: String, + b: Integer + } + + @awsQueryError( + code: "InvalidThing", + httpResponseCode: 400, + ) + @error("client") + structure InvalidThingException { + message: String + } + """.asSmithyModel() + + clientIntegrationTest(model) { clientCodegenContext, rustCrate -> + val moduleName = clientCodegenContext.moduleUseName() + rustCrate.integrationTest("should_parse_code_and_type_fields") { + rust( + """ + ##[test] + fn should_parse_code_and_type_fields() { + use aws_smithy_http::response::ParseStrictResponse; + + let response = http::Response::builder() + .header( + "x-amzn-query-error", + http::HeaderValue::from_static("AWS.SimpleQueueService.NonExistentQueue;Sender"), + ) + .status(400) + .body( + r##"{ + "__type": "com.amazonaws.sqs##QueueDoesNotExist", + "message": "Some user-visible message" + }"##, + ) + .unwrap(); + let some_operation = $moduleName::operation::SomeOperation::new(); + let error = some_operation + .parse(&response.map(bytes::Bytes::from)) + .err() + .unwrap(); + assert_eq!( + Some("AWS.SimpleQueueService.NonExistentQueue"), + error.meta().code(), + ); + assert_eq!(Some("Sender"), error.meta().extra("type")); + } + """, + ) + } + } + } + + @Test + fun `aws-query-compatible json without aws query error should allow for retrieving error code from payload`() { + val model = """ + namespace test + use aws.protocols#awsJson1_0 + use aws.protocols#awsQueryCompatible + + @awsQueryCompatible + @awsJson1_0 + service TestService { + version: "2023-02-20", + operations: [SomeOperation] + } + + operation SomeOperation { + input: SomeOperationInputOutput, + output: SomeOperationInputOutput, + errors: [InvalidThingException], + } + + structure SomeOperationInputOutput { + a: String, + b: Integer + } + + @error("client") + structure InvalidThingException { + message: String + } + """.asSmithyModel() + + clientIntegrationTest(model) { clientCodegenContext, rustCrate -> + val moduleName = clientCodegenContext.moduleUseName() + rustCrate.integrationTest("should_parse_code_from_payload") { + rust( + """ + ##[test] + fn should_parse_code_from_payload() { + use aws_smithy_http::response::ParseStrictResponse; + + let response = http::Response::builder() + .status(400) + .body( + r##"{ + "__type": "com.amazonaws.sqs##QueueDoesNotExist", + "message": "Some user-visible message" + }"##, + ) + .unwrap(); + let some_operation = $moduleName::operation::SomeOperation::new(); + let error = some_operation + .parse(&response.map(bytes::Bytes::from)) + .err() + .unwrap(); + assert_eq!(Some("QueueDoesNotExist"), error.meta().code()); + assert_eq!(None, error.meta().extra("type")); + } + """, + ) + } + } + } +} diff --git a/codegen-core/src/main/kotlin/software/amazon/smithy/rust/codegen/core/rustlang/CargoDependency.kt b/codegen-core/src/main/kotlin/software/amazon/smithy/rust/codegen/core/rustlang/CargoDependency.kt index 8424219a0f..167fd2997f 100644 --- a/codegen-core/src/main/kotlin/software/amazon/smithy/rust/codegen/core/rustlang/CargoDependency.kt +++ b/codegen-core/src/main/kotlin/software/amazon/smithy/rust/codegen/core/rustlang/CargoDependency.kt @@ -95,6 +95,13 @@ class InlineDependency( CargoDependency.Http, ) + fun awsQueryCompatibleErrors(runtimeConfig: RuntimeConfig) = + forInlineableRustFile( + "aws_query_compatible_errors", + CargoDependency.smithyJson(runtimeConfig), + CargoDependency.Http, + ) + fun idempotencyToken() = forInlineableRustFile("idempotency_token", CargoDependency.FastRand) diff --git a/codegen-core/src/main/kotlin/software/amazon/smithy/rust/codegen/core/smithy/RuntimeType.kt b/codegen-core/src/main/kotlin/software/amazon/smithy/rust/codegen/core/smithy/RuntimeType.kt index 5dcb704c83..df1b2cc791 100644 --- a/codegen-core/src/main/kotlin/software/amazon/smithy/rust/codegen/core/smithy/RuntimeType.kt +++ b/codegen-core/src/main/kotlin/software/amazon/smithy/rust/codegen/core/smithy/RuntimeType.kt @@ -279,6 +279,7 @@ data class RuntimeType(val path: String, val dependency: RustDependency? = null) fun provideErrorMetadataTrait(runtimeConfig: RuntimeConfig) = smithyTypes(runtimeConfig).resolve("error::metadata::ProvideErrorMetadata") fun unhandledError(runtimeConfig: RuntimeConfig) = smithyTypes(runtimeConfig).resolve("error::Unhandled") fun jsonErrors(runtimeConfig: RuntimeConfig) = forInlineDependency(InlineDependency.jsonErrors(runtimeConfig)) + fun awsQueryCompatibleErrors(runtimeConfig: RuntimeConfig) = forInlineDependency(InlineDependency.awsQueryCompatibleErrors(runtimeConfig)) fun labelFormat(runtimeConfig: RuntimeConfig, func: String) = smithyHttp(runtimeConfig).resolve("label::$func") fun operation(runtimeConfig: RuntimeConfig) = smithyHttp(runtimeConfig).resolve("operation::Operation") fun operationModule(runtimeConfig: RuntimeConfig) = smithyHttp(runtimeConfig).resolve("operation") diff --git a/codegen-core/src/main/kotlin/software/amazon/smithy/rust/codegen/core/smithy/protocols/AwsQueryCompatible.kt b/codegen-core/src/main/kotlin/software/amazon/smithy/rust/codegen/core/smithy/protocols/AwsQueryCompatible.kt new file mode 100644 index 0000000000..32f9fdbb60 --- /dev/null +++ b/codegen-core/src/main/kotlin/software/amazon/smithy/rust/codegen/core/smithy/protocols/AwsQueryCompatible.kt @@ -0,0 +1,97 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.rust.codegen.core.smithy.protocols + +import software.amazon.smithy.model.shapes.OperationShape +import software.amazon.smithy.model.shapes.ToShapeId +import software.amazon.smithy.model.traits.HttpTrait +import software.amazon.smithy.rust.codegen.core.rustlang.CargoDependency +import software.amazon.smithy.rust.codegen.core.rustlang.RustModule +import software.amazon.smithy.rust.codegen.core.rustlang.rustTemplate +import software.amazon.smithy.rust.codegen.core.smithy.CodegenContext +import software.amazon.smithy.rust.codegen.core.smithy.RuntimeType +import software.amazon.smithy.rust.codegen.core.smithy.protocols.parse.StructuredDataParserGenerator +import software.amazon.smithy.rust.codegen.core.smithy.protocols.serialize.StructuredDataSerializerGenerator + +class AwsQueryCompatibleHttpBindingResolver( + private val awsQueryBindingResolver: AwsQueryBindingResolver, + private val awsJsonHttpBindingResolver: AwsJsonHttpBindingResolver, +) : HttpBindingResolver { + override fun httpTrait(operationShape: OperationShape): HttpTrait = + awsJsonHttpBindingResolver.httpTrait(operationShape) + + override fun requestBindings(operationShape: OperationShape): List = + awsJsonHttpBindingResolver.requestBindings(operationShape) + + override fun responseBindings(operationShape: OperationShape): List = + awsJsonHttpBindingResolver.responseBindings(operationShape) + + override fun errorResponseBindings(errorShape: ToShapeId): List = + awsJsonHttpBindingResolver.errorResponseBindings(errorShape) + + override fun errorCode(errorShape: ToShapeId): String = + awsQueryBindingResolver.errorCode(errorShape) + + override fun requestContentType(operationShape: OperationShape): String = + awsJsonHttpBindingResolver.requestContentType(operationShape) + + override fun responseContentType(operationShape: OperationShape): String = + awsJsonHttpBindingResolver.requestContentType(operationShape) +} + +class AwsQueryCompatible( + val codegenContext: CodegenContext, + private val awsJson: AwsJson, +) : Protocol { + private val runtimeConfig = codegenContext.runtimeConfig + private val errorScope = arrayOf( + "Bytes" to RuntimeType.Bytes, + "ErrorMetadataBuilder" to RuntimeType.errorMetadataBuilder(runtimeConfig), + "JsonError" to CargoDependency.smithyJson(runtimeConfig).toType() + .resolve("deserialize::error::DeserializeError"), + "Response" to RuntimeType.Http.resolve("Response"), + "json_errors" to RuntimeType.jsonErrors(runtimeConfig), + "aws_query_compatible_errors" to RuntimeType.awsQueryCompatibleErrors(runtimeConfig), + ) + private val jsonDeserModule = RustModule.private("json_deser") + + override val httpBindingResolver: HttpBindingResolver = + AwsQueryCompatibleHttpBindingResolver( + AwsQueryBindingResolver(codegenContext.model), + AwsJsonHttpBindingResolver(codegenContext.model, awsJson.version), + ) + + override val defaultTimestampFormat = awsJson.defaultTimestampFormat + + override fun structuredDataParser(operationShape: OperationShape): StructuredDataParserGenerator = + awsJson.structuredDataParser(operationShape) + + override fun structuredDataSerializer(operationShape: OperationShape): StructuredDataSerializerGenerator = + awsJson.structuredDataSerializer(operationShape) + + override fun parseHttpErrorMetadata(operationShape: OperationShape): RuntimeType = + RuntimeType.forInlineFun("parse_http_error_metadata", jsonDeserModule) { + rustTemplate( + """ + pub fn parse_http_error_metadata(response: &#{Response}<#{Bytes}>) -> Result<#{ErrorMetadataBuilder}, #{JsonError}> { + let mut builder = + #{json_errors}::parse_error_metadata(response.body(), response.headers())?; + if let Some((error_code, error_type)) = + #{aws_query_compatible_errors}::parse_aws_query_compatible_error(response.headers()) + { + builder = builder.code(error_code); + builder = builder.custom("type", error_type); + } + Ok(builder) + } + """, + *errorScope, + ) + } + + override fun parseEventStreamErrorMetadata(operationShape: OperationShape): RuntimeType = + awsJson.parseEventStreamErrorMetadata(operationShape) +} diff --git a/rust-runtime/inlineable/src/aws_query_compatible_errors.rs b/rust-runtime/inlineable/src/aws_query_compatible_errors.rs new file mode 100644 index 0000000000..7a94064d71 --- /dev/null +++ b/rust-runtime/inlineable/src/aws_query_compatible_errors.rs @@ -0,0 +1,103 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +use http::header::ToStrError; +use http::{HeaderMap, HeaderValue}; + +const X_AMZN_QUERY_ERROR: &str = "x-amzn-query-error"; +const QUERY_COMPATIBLE_ERRORCODE_DELIMITER: char = ';'; + +fn aws_query_compatible_error_from_header( + headers: &HeaderMap, +) -> Result, ToStrError> { + headers + .get(X_AMZN_QUERY_ERROR) + .map(|v| v.to_str()) + .transpose() +} + +/// Obtains custom error code and error type from the given `headers`. +/// +/// Looks up a value for the `X_AMZN_QUERY_ERROR` header and if found, the value should be in the +/// form of `;`. The function then splits it into two parts and returns +/// a (error code, error type) as a tuple. +/// +/// Any execution path besides the above happy path will yield a `None`. +pub fn parse_aws_query_compatible_error(headers: &HeaderMap) -> Option<(&str, &str)> { + let header_value = match aws_query_compatible_error_from_header(headers) { + Ok(error) => error?, + _ => return None, + }; + + header_value + .find(QUERY_COMPATIBLE_ERRORCODE_DELIMITER) + .map(|idx| (&header_value[..idx], &header_value[idx + 1..])) +} + +#[cfg(test)] +mod test { + use crate::aws_query_compatible_errors::{ + aws_query_compatible_error_from_header, parse_aws_query_compatible_error, + X_AMZN_QUERY_ERROR, + }; + + #[test] + fn aws_query_compatible_error_from_header_should_provide_value_for_custom_header() { + let mut response: http::Response<()> = http::Response::default(); + response.headers_mut().insert( + X_AMZN_QUERY_ERROR, + http::HeaderValue::from_static("AWS.SimpleQueueService.NonExistentQueue;Sender"), + ); + + let actual = aws_query_compatible_error_from_header(response.headers()).unwrap(); + + assert_eq!( + Some("AWS.SimpleQueueService.NonExistentQueue;Sender"), + actual, + ); + } + + #[test] + fn parse_aws_query_compatible_error_should_parse_code_and_type_fields() { + let mut response: http::Response<()> = http::Response::default(); + response.headers_mut().insert( + X_AMZN_QUERY_ERROR, + http::HeaderValue::from_static("AWS.SimpleQueueService.NonExistentQueue;Sender"), + ); + + let actual = parse_aws_query_compatible_error(response.headers()); + + assert_eq!( + Some(("AWS.SimpleQueueService.NonExistentQueue", "Sender")), + actual, + ); + } + + #[test] + fn parse_aws_query_compatible_error_should_return_none_when_header_value_has_no_delimiter() { + let mut response: http::Response<()> = http::Response::default(); + response.headers_mut().insert( + X_AMZN_QUERY_ERROR, + http::HeaderValue::from_static("AWS.SimpleQueueService.NonExistentQueue"), + ); + + let actual = parse_aws_query_compatible_error(response.headers()); + + assert_eq!(None, actual); + } + + #[test] + fn parse_aws_query_compatible_error_should_return_none_when_there_is_no_target_header() { + let mut response: http::Response<()> = http::Response::default(); + response.headers_mut().insert( + "x-amzn-requestid", + http::HeaderValue::from_static("a918fbf2-457a-4fe1-99ba-5685ce220fc1"), + ); + + let actual = parse_aws_query_compatible_error(response.headers()); + + assert_eq!(None, actual); + } +} diff --git a/rust-runtime/inlineable/src/lib.rs b/rust-runtime/inlineable/src/lib.rs index 41af358919..e53b81db7d 100644 --- a/rust-runtime/inlineable/src/lib.rs +++ b/rust-runtime/inlineable/src/lib.rs @@ -3,6 +3,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +#[allow(dead_code)] +mod aws_query_compatible_errors; #[allow(unused)] mod constrained; #[allow(dead_code)]