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)]