From 57c93067c5b06aa011c3e315663e2c961b49d01e Mon Sep 17 00:00:00 2001 From: Fahad Zubair Date: Wed, 2 Apr 2025 21:17:44 +0100 Subject: [PATCH 1/4] Implement `Display` for shapes reachable from error shapes --- .../client/smithy/ClientCodegenVisitor.kt | 4 + .../protocol/ProtocolParserGenerator.kt | 7 +- .../ClientErrorReachableShapesDisplayTest.kt | 16 ++ .../common-test-models/nested-error.smithy | 70 ++++++++ .../smithy/generators/StructureGenerator.kt | 79 ++++++-- .../error/NestedErrorStructureTest.kt | 168 ++++++++++++++++++ .../server/smithy/ServerCodegenVisitor.kt | 4 + 7 files changed, 334 insertions(+), 14 deletions(-) create mode 100644 codegen-client/src/test/kotlin/software/amazon/smithy/rust/codegen/client/smithy/generators/ClientErrorReachableShapesDisplayTest.kt create mode 100644 codegen-core/common-test-models/nested-error.smithy create mode 100644 codegen-core/src/test/kotlin/software/amazon/smithy/rust/codegen/core/smithy/generators/error/NestedErrorStructureTest.kt diff --git a/codegen-client/src/main/kotlin/software/amazon/smithy/rust/codegen/client/smithy/ClientCodegenVisitor.kt b/codegen-client/src/main/kotlin/software/amazon/smithy/rust/codegen/client/smithy/ClientCodegenVisitor.kt index ae1788d9463..e3caf4ce0fb 100644 --- a/codegen-client/src/main/kotlin/software/amazon/smithy/rust/codegen/client/smithy/ClientCodegenVisitor.kt +++ b/codegen-client/src/main/kotlin/software/amazon/smithy/rust/codegen/client/smithy/ClientCodegenVisitor.kt @@ -42,6 +42,7 @@ import software.amazon.smithy.rust.codegen.core.smithy.generators.BuilderGenerat import software.amazon.smithy.rust.codegen.core.smithy.generators.StructureGenerator import software.amazon.smithy.rust.codegen.core.smithy.generators.UnionGenerator import software.amazon.smithy.rust.codegen.core.smithy.protocols.ProtocolGeneratorFactory +import software.amazon.smithy.rust.codegen.core.smithy.transformers.AddSyntheticTraitForImplDisplay import software.amazon.smithy.rust.codegen.core.smithy.transformers.EventStreamNormalizer import software.amazon.smithy.rust.codegen.core.smithy.transformers.OperationNormalizer import software.amazon.smithy.rust.codegen.core.smithy.transformers.RecursiveShapeBoxer @@ -146,6 +147,9 @@ class ClientCodegenVisitor( .let(EventStreamNormalizer::transform) // Mark operations incompatible with stalled stream protection as such .let(DisableStalledStreamProtection::transformModel) + // Add synthetic trait to shapes referenced by error types to ensure they implement `Display`. + // This ensures error formatting works correctly for nested structures. + .let(AddSyntheticTraitForImplDisplay::transform) /** * Execute code generation diff --git a/codegen-client/src/main/kotlin/software/amazon/smithy/rust/codegen/client/smithy/generators/protocol/ProtocolParserGenerator.kt b/codegen-client/src/main/kotlin/software/amazon/smithy/rust/codegen/client/smithy/generators/protocol/ProtocolParserGenerator.kt index 6bcc00d84a5..0f1ef7800aa 100644 --- a/codegen-client/src/main/kotlin/software/amazon/smithy/rust/codegen/client/smithy/generators/protocol/ProtocolParserGenerator.kt +++ b/codegen-client/src/main/kotlin/software/amazon/smithy/rust/codegen/client/smithy/generators/protocol/ProtocolParserGenerator.kt @@ -14,6 +14,7 @@ import software.amazon.smithy.rust.codegen.client.smithy.generators.OperationCus import software.amazon.smithy.rust.codegen.client.smithy.generators.OperationSection import software.amazon.smithy.rust.codegen.client.smithy.generators.http.ResponseBindingGenerator import software.amazon.smithy.rust.codegen.core.rustlang.Attribute +import software.amazon.smithy.rust.codegen.core.rustlang.RustType import software.amazon.smithy.rust.codegen.core.rustlang.RustWriter import software.amazon.smithy.rust.codegen.core.rustlang.Writable import software.amazon.smithy.rust.codegen.core.rustlang.assignment @@ -33,6 +34,7 @@ import software.amazon.smithy.rust.codegen.core.smithy.protocols.HttpLocation import software.amazon.smithy.rust.codegen.core.smithy.protocols.Protocol import software.amazon.smithy.rust.codegen.core.smithy.protocols.ProtocolFunctions import software.amazon.smithy.rust.codegen.core.smithy.protocols.parse.StructuredDataParserGenerator +import software.amazon.smithy.rust.codegen.core.smithy.rustType import software.amazon.smithy.rust.codegen.core.smithy.transformers.operationErrors import software.amazon.smithy.rust.codegen.core.util.UNREACHABLE import software.amazon.smithy.rust.codegen.core.util.dq @@ -163,10 +165,11 @@ class ProtocolParserGenerator( } } val errorMessageMember = errorShape.errorMessageMember() - // If the message member is optional and wasn't set, we set a generic error message. + // If the message member is optional, is of `String` Rust type and wasn't set, we set a generic error message. if (errorMessageMember != null) { val symbol = symbolProvider.toSymbol(errorMessageMember) - if (symbol.isOptional()) { + val currentRustType = symbol.rustType() + if (symbol.isOptional() && currentRustType == RustType.String) { rust( """ if tmp.message.is_none() { diff --git a/codegen-client/src/test/kotlin/software/amazon/smithy/rust/codegen/client/smithy/generators/ClientErrorReachableShapesDisplayTest.kt b/codegen-client/src/test/kotlin/software/amazon/smithy/rust/codegen/client/smithy/generators/ClientErrorReachableShapesDisplayTest.kt new file mode 100644 index 00000000000..24cf09a6fd5 --- /dev/null +++ b/codegen-client/src/test/kotlin/software/amazon/smithy/rust/codegen/client/smithy/generators/ClientErrorReachableShapesDisplayTest.kt @@ -0,0 +1,16 @@ +package software.amazon.smithy.rust.codegen.client.smithy.generators + +import org.junit.jupiter.api.Test +import software.amazon.smithy.rust.codegen.client.testutil.clientIntegrationTest +import software.amazon.smithy.rust.codegen.core.testutil.asSmithyModel +import java.io.File + +class ClientErrorReachableShapesDisplayTest { + @Test + fun correctMissingFields() { + var sampleModel = File("../codegen-core/common-test-models/nested-error.smithy").readText().asSmithyModel() + clientIntegrationTest(sampleModel) { _, _ -> + // It should compile. + } + } +} diff --git a/codegen-core/common-test-models/nested-error.smithy b/codegen-core/common-test-models/nested-error.smithy new file mode 100644 index 00000000000..9fe85d2e324 --- /dev/null +++ b/codegen-core/common-test-models/nested-error.smithy @@ -0,0 +1,70 @@ +$version: "2" + +namespace sample + +use smithy.framework#ValidationException +use aws.protocols#restJson1 + +@restJson1 +service SampleService { + operations: [SampleOperation] +} + +@http(uri: "/anOperation", method: "POST") +operation SampleOperation { + output:= {} + input:= {} + errors: [ + SimpleError, + ErrorWithCompositeShape, + ErrorWithDeepCompositeShape, + ComposedSensitiveError + ] +} + +@error("client") +structure ErrorWithCompositeShape { + message: ErrorMessage +} + +@error("client") +structure SimpleError { + message: String +} + +structure ErrorMessage { + @required + statusCode: String + @required + errorMessage: String + requestId: String + @required + toolName: String +} + +structure WrappedErrorMessage { + someValue: Integer + contained: ErrorMessage +} + +@error("client") +structure ErrorWithDeepCompositeShape { + message: WrappedErrorMessage +} + +@sensitive +structure SensitiveMessage { + nothing: String + should: String + bePrinted: String +} + +@error("server") +structure ComposedSensitiveError { + message: SensitiveMessage +} + +@error("server") +structure ErrorWithNestedError { + message: ErrorWithDeepCompositeShape +} diff --git a/codegen-core/src/main/kotlin/software/amazon/smithy/rust/codegen/core/smithy/generators/StructureGenerator.kt b/codegen-core/src/main/kotlin/software/amazon/smithy/rust/codegen/core/smithy/generators/StructureGenerator.kt index 17452da38db..934428fced1 100644 --- a/codegen-core/src/main/kotlin/software/amazon/smithy/rust/codegen/core/smithy/generators/StructureGenerator.kt +++ b/codegen-core/src/main/kotlin/software/amazon/smithy/rust/codegen/core/smithy/generators/StructureGenerator.kt @@ -24,15 +24,20 @@ import software.amazon.smithy.rust.codegen.core.rustlang.isDeref import software.amazon.smithy.rust.codegen.core.rustlang.render import software.amazon.smithy.rust.codegen.core.rustlang.rust import software.amazon.smithy.rust.codegen.core.rustlang.rustBlock +import software.amazon.smithy.rust.codegen.core.rustlang.rustBlockTemplate import software.amazon.smithy.rust.codegen.core.rustlang.stripOuter import software.amazon.smithy.rust.codegen.core.smithy.RuntimeType +import software.amazon.smithy.rust.codegen.core.smithy.RuntimeType.Companion.preludeScope import software.amazon.smithy.rust.codegen.core.smithy.RustSymbolProvider import software.amazon.smithy.rust.codegen.core.smithy.customize.NamedCustomization import software.amazon.smithy.rust.codegen.core.smithy.customize.Section import software.amazon.smithy.rust.codegen.core.smithy.customize.writeCustomizations import software.amazon.smithy.rust.codegen.core.smithy.expectRustMetadata +import software.amazon.smithy.rust.codegen.core.smithy.isOptional +import software.amazon.smithy.rust.codegen.core.smithy.protocols.serialize.ValueExpression import software.amazon.smithy.rust.codegen.core.smithy.renamedFrom import software.amazon.smithy.rust.codegen.core.smithy.rustType +import software.amazon.smithy.rust.codegen.core.smithy.traits.SyntheticImplDisplayTrait import software.amazon.smithy.rust.codegen.core.util.REDACTION import software.amazon.smithy.rust.codegen.core.util.dq import software.amazon.smithy.rust.codegen.core.util.getTrait @@ -104,20 +109,9 @@ open class StructureGenerator( ) { writer.rustBlock("fn fmt(&self, f: &mut #1T::Formatter<'_>) -> #1T::Result", RuntimeType.stdFmt) { rust("""let mut formatter = f.debug_struct(${name.dq()});""") - members.forEach { member -> val memberName = symbolProvider.toMemberName(member) - // If the struct is marked sensitive all fields get redacted, otherwise each field is determined on its own - val fieldValue = - if (shape.shouldRedact(model)) { - REDACTION - } else { - member.redactIfNecessary( - model, - "self.$memberName", - ) - } - + val fieldValue = getFieldValue(member, memberName) rust( "formatter.field(${memberName.dq()}, &$fieldValue);", ) @@ -128,6 +122,66 @@ open class StructureGenerator( } } + // If the struct is marked sensitive all fields get redacted, otherwise each field is determined on its own. + private fun getFieldValue( + member: MemberShape, + memberName: String?, + ): String { + val fieldValue = + if (shape.shouldRedact(model)) { + REDACTION + } else { + member.redactIfNecessary( + model, + "self.$memberName", + ) + } + return fieldValue + } + + private fun renderImplDisplayIfSyntheticImplDisplayTraitApplied() { + if (shape.getTrait() == null) { + return + } + + val lifetime = shape.lifetimeDeclaration(symbolProvider) + writer.rustBlock( + "impl ${shape.lifetimeDeclaration(symbolProvider)} #T for $name $lifetime", + RuntimeType.Display, + ) { + writer.rustBlock("fn fmt(&self, f: &mut #1T::Formatter<'_>) -> #1T::Result", RuntimeType.stdFmt) { + write("""::std::write!(f, "$name {{")?;""") + + members.forEachIndexed { index, member -> + val separator = if (index > 0) ", " else "" + val memberName = symbolProvider.toMemberName(member) + val shouldRedact = shape.shouldRedact(model) || member.shouldRedact(model) + + // If the shape is redacted then each member shape will be redacted. + if (shouldRedact) { + write("""::std::write!(f, "$separator$memberName={}", $REDACTION)?;""") + } else { + val variable = ValueExpression.Reference("&self.$memberName") + val memberSymbol = symbolProvider.toSymbol(member) + + if (memberSymbol.isOptional()) { + rustBlockTemplate("if let #{Some}(inner) = ${variable.asRef()}", *preludeScope) { + write("""::std::write!(f, "$separator$memberName=Some({})", inner)?;""") + } + rustBlock("else") { + write("""::std::write!(f, "$separator$memberName=None")?;""") + } + } else { + write("""::std::write!(f, "$separator$memberName={}", ${variable.asRef()})?;""") + } + } + } + + write("""::std::write!(f, "}}")""") + } + } + } + private fun renderStructureImpl() { if (accessorMembers.isEmpty()) { return @@ -209,6 +263,7 @@ open class StructureGenerator( if (!containerMeta.hasDebugDerive()) { renderDebugImpl() } + renderImplDisplayIfSyntheticImplDisplayTraitApplied() writer.writeCustomizations(customizations, StructureSection.AdditionalTraitImpls(shape, name)) } diff --git a/codegen-core/src/test/kotlin/software/amazon/smithy/rust/codegen/core/smithy/generators/error/NestedErrorStructureTest.kt b/codegen-core/src/test/kotlin/software/amazon/smithy/rust/codegen/core/smithy/generators/error/NestedErrorStructureTest.kt new file mode 100644 index 00000000000..00ce65c0fbd --- /dev/null +++ b/codegen-core/src/test/kotlin/software/amazon/smithy/rust/codegen/core/smithy/generators/error/NestedErrorStructureTest.kt @@ -0,0 +1,168 @@ +package software.amazon.smithy.rust.codegen.core.smithy.generators.error + +import org.junit.jupiter.api.Test +import software.amazon.smithy.model.shapes.StructureShape +import software.amazon.smithy.model.traits.ErrorTrait +import software.amazon.smithy.rust.codegen.core.rustlang.RustModule +import software.amazon.smithy.rust.codegen.core.rustlang.RustReservedWordConfig +import software.amazon.smithy.rust.codegen.core.rustlang.RustWriter +import software.amazon.smithy.rust.codegen.core.rustlang.rustTemplate +import software.amazon.smithy.rust.codegen.core.smithy.generators.StructSettings +import software.amazon.smithy.rust.codegen.core.smithy.generators.StructureGenerator +import software.amazon.smithy.rust.codegen.core.smithy.transformers.AddSyntheticTraitForImplDisplay +import software.amazon.smithy.rust.codegen.core.testutil.TestWorkspace +import software.amazon.smithy.rust.codegen.core.testutil.asSmithyModel +import software.amazon.smithy.rust.codegen.core.testutil.compileAndTest +import software.amazon.smithy.rust.codegen.core.testutil.testSymbolProvider +import software.amazon.smithy.rust.codegen.core.testutil.unitTest +import software.amazon.smithy.rust.codegen.core.util.REDACTION +import software.amazon.smithy.rust.codegen.core.util.getTrait +import software.amazon.smithy.rust.codegen.core.util.lookup +import java.io.File + +class NestedErrorStructureTest { + private var sampleModel = File("../codegen-core/common-test-models/nested-error.smithy").readText().asSmithyModel() + private val model = sampleModel.let(AddSyntheticTraitForImplDisplay::transform) + + private val errorWithCompositeShape = model.lookup("sample#ErrorWithCompositeShape") + private val simpleError = model.lookup("sample#SimpleError") + private val errorWithDeepCompositeShape = model.lookup("sample#ErrorWithDeepCompositeShape") + private val composedSensitiveError = model.lookup("sample#ComposedSensitiveError") + private val errorWithNestedError = model.lookup("sample#ErrorWithNestedError") + private val errorMessage = model.lookup("sample#ErrorMessage") + private val wrappedErrorMessage = model.lookup("sample#WrappedErrorMessage") + private val sensitiveMessage = model.lookup("sample#SensitiveMessage") + + private val allStructures = + arrayOf( + errorWithCompositeShape, + simpleError, + errorWithDeepCompositeShape, + composedSensitiveError, + errorWithNestedError, + errorMessage, + wrappedErrorMessage, + sensitiveMessage, + ) + private val errorShapes = + arrayOf( + errorWithCompositeShape, + simpleError, + errorWithDeepCompositeShape, + errorWithNestedError, + composedSensitiveError, + ) + + private val rustReservedWordConfig: RustReservedWordConfig = + RustReservedWordConfig( + structureMemberMap = StructureGenerator.structureMemberNameMap, + enumMemberMap = emptyMap(), + unionMemberMap = emptyMap(), + ) + + private val provider = testSymbolProvider(model, rustReservedWordConfig = rustReservedWordConfig) + + private fun structureGenerator( + writer: RustWriter, + shape: StructureShape, + ) = StructureGenerator(model, provider, writer, shape, emptyList(), StructSettings(flattenVecAccessors = true)) + + private fun errorImplGenerator( + writer: RustWriter, + shape: StructureShape, + ) = ErrorImplGenerator(model, provider, writer, shape, shape.getTrait()!!, emptyList()) + + @Test + fun `generate nested error structure`() { + val project = TestWorkspace.testProject(provider) + // Generate code for each structure. + for (shape in allStructures) { + project.useShapeWriter(shape) { + structureGenerator(this, shape).render() + } + } + // Generate code for each structure marked with an error trait. + for (shape in errorShapes) { + project.useShapeWriter(shape) { + errorImplGenerator(this, shape).render() + } + } + + project.withModule( + RustModule.public("tests"), + ) { + unitTest("optional_field_prints_none") { + rustTemplate( + """ + let message = crate::test_model::ErrorMessage { + status_code: "200".to_owned(), + error_message: "this is an error".to_owned(), + request_id: None, + tool_name : "vscode".to_owned() + }; + let formatted = format!("{message}"); + assert_eq!(formatted, "ErrorMessage {status_code=200, error_message=this is an error, request_id=None, tool_name=vscode}"); + """, + ) + } + unitTest("optional_field_prints_value") { + rustTemplate( + """ + let message = crate::test_model::ErrorMessage { + status_code: "200".to_owned(), + error_message: "this is an error".to_owned(), + request_id: Some("1234".to_owned()), + tool_name : "vscode".to_owned() + }; + let formatted = format!("{message}"); + assert_eq!(formatted, "ErrorMessage {status_code=200, error_message=this is an error, request_id=Some(1234), tool_name=vscode}"); + """, + ) + } + unitTest("sensitive_is_redacted") { + val redacted = REDACTION.removeSurrounding("\"") + rustTemplate( + """ + let message = crate::test_model::SensitiveMessage { + nothing: Some("some value".to_owned()), + should: Some("some other value".to_owned()), + be_printed: Some("another value".to_owned()), + }; + let formatted = format!("{message}"); + assert_eq!(formatted, "SensitiveMessage {nothing=$redacted, should=$redacted, be_printed=$redacted}"); + """, + ) + } + unitTest("nested_error_structure_do_not_implement_display_twice") { + val redacted = REDACTION.removeSurrounding("\"") + rustTemplate( + """ + let message = crate::test_error::ErrorWithNestedError { + message: Some(crate::test_error::ErrorWithDeepCompositeShape { + message: Some(crate::test_model::WrappedErrorMessage { + some_value: Some(123), + contained: Some(crate::test_model::ErrorMessage { + status_code: "200".to_owned(), + error_message: "this is an error".to_owned(), + request_id: Some("1234".to_owned()), + tool_name: "vscode".to_owned(), + }), + }), + }), + }; + let formatted = format!("{message}"); + const EXPECTED: &str = "ErrorWithNestedError: ErrorWithDeepCompositeShape: \ + WrappedErrorMessage {some_value=Some(123), \ + contained=Some(ErrorMessage {status_code=200, \ + error_message=this is an error, \ + request_id=Some(1234), \ + tool_name=vscode})}"; + assert_eq!(formatted, EXPECTED); + """, + ) + } + } + + project.compileAndTest() + } +} diff --git a/codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/ServerCodegenVisitor.kt b/codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/ServerCodegenVisitor.kt index b1fc07a31a1..3b72367d106 100644 --- a/codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/ServerCodegenVisitor.kt +++ b/codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/ServerCodegenVisitor.kt @@ -44,6 +44,7 @@ import software.amazon.smithy.rust.codegen.core.smithy.generators.UnionGenerator import software.amazon.smithy.rust.codegen.core.smithy.generators.error.ErrorImplGenerator import software.amazon.smithy.rust.codegen.core.smithy.generators.lifetimeDeclaration import software.amazon.smithy.rust.codegen.core.smithy.protocols.ProtocolGeneratorFactory +import software.amazon.smithy.rust.codegen.core.smithy.transformers.AddSyntheticTraitForImplDisplay import software.amazon.smithy.rust.codegen.core.smithy.transformers.EventStreamNormalizer import software.amazon.smithy.rust.codegen.core.smithy.transformers.OperationNormalizer import software.amazon.smithy.rust.codegen.core.smithy.transformers.RecursiveShapeBoxer @@ -213,6 +214,9 @@ open class ServerCodegenVisitor( .let { ServerProtocolBasedTransformationFactory.transform(it, settings) } // Normalize event stream operations .let(EventStreamNormalizer::transform) + // Add synthetic trait to shapes referenced by error types to ensure they implement Display. + // This ensures error formatting works correctly for nested structures. + .let(AddSyntheticTraitForImplDisplay::transform) /** * Exposure purely for unit test purposes. From 5fd7eaa97663bc9d1f51e3356fedbb8159129eaa Mon Sep 17 00:00:00 2001 From: Fahad Zubair Date: Thu, 3 Apr 2025 09:27:24 +0100 Subject: [PATCH 2/4] Additional files added to git --- .../traits/SyntheticImplDisplayTrait.kt | 11 +++ .../AddSyntheticTraitForImplDisplay.kt | 73 +++++++++++++++++++ .../generators/PythonServerEnumGenerator.kt | 29 +++++--- .../ServerErrorReachableShapesDisplayTest.kt | 16 ++++ 4 files changed, 118 insertions(+), 11 deletions(-) create mode 100644 codegen-core/src/main/kotlin/software/amazon/smithy/rust/codegen/core/smithy/traits/SyntheticImplDisplayTrait.kt create mode 100644 codegen-core/src/main/kotlin/software/amazon/smithy/rust/codegen/core/smithy/transformers/AddSyntheticTraitForImplDisplay.kt create mode 100644 codegen-server/src/test/kotlin/software/amazon/smithy/rust/codegen/server/smithy/generators/ServerErrorReachableShapesDisplayTest.kt diff --git a/codegen-core/src/main/kotlin/software/amazon/smithy/rust/codegen/core/smithy/traits/SyntheticImplDisplayTrait.kt b/codegen-core/src/main/kotlin/software/amazon/smithy/rust/codegen/core/smithy/traits/SyntheticImplDisplayTrait.kt new file mode 100644 index 00000000000..d1208e26eac --- /dev/null +++ b/codegen-core/src/main/kotlin/software/amazon/smithy/rust/codegen/core/smithy/traits/SyntheticImplDisplayTrait.kt @@ -0,0 +1,11 @@ +package software.amazon.smithy.rust.codegen.core.smithy.traits + +import software.amazon.smithy.model.node.Node +import software.amazon.smithy.model.shapes.ShapeId +import software.amazon.smithy.model.traits.AnnotationTrait + +class SyntheticImplDisplayTrait : AnnotationTrait(ID, Node.objectNode()) { + companion object { + val ID: ShapeId = ShapeId.from("software.amazon.smithy.rust.codegen.core.smithy.traits#syntheticImplDisplayTrait") + } +} diff --git a/codegen-core/src/main/kotlin/software/amazon/smithy/rust/codegen/core/smithy/transformers/AddSyntheticTraitForImplDisplay.kt b/codegen-core/src/main/kotlin/software/amazon/smithy/rust/codegen/core/smithy/transformers/AddSyntheticTraitForImplDisplay.kt new file mode 100644 index 00000000000..4b3f9306a6e --- /dev/null +++ b/codegen-core/src/main/kotlin/software/amazon/smithy/rust/codegen/core/smithy/transformers/AddSyntheticTraitForImplDisplay.kt @@ -0,0 +1,73 @@ +package software.amazon.smithy.rust.codegen.core.smithy.transformers + +import software.amazon.smithy.model.Model +import software.amazon.smithy.model.shapes.AbstractShapeBuilder +import software.amazon.smithy.model.shapes.EnumShape +import software.amazon.smithy.model.shapes.ListShape +import software.amazon.smithy.model.shapes.MapShape +import software.amazon.smithy.model.shapes.StructureShape +import software.amazon.smithy.model.shapes.UnionShape +import software.amazon.smithy.model.traits.ErrorTrait +import software.amazon.smithy.model.transform.ModelTransformer +import software.amazon.smithy.rust.codegen.core.smithy.DirectedWalker +import software.amazon.smithy.rust.codegen.core.smithy.traits.SyntheticImplDisplayTrait +import software.amazon.smithy.rust.codegen.core.util.UNREACHABLE +import software.amazon.smithy.rust.codegen.core.util.getTrait +import software.amazon.smithy.utils.ToSmithyBuilder + +/** + * Adds a synthetic trait to shapes that are reachable from error shapes to ensure they + * implement the `Display` trait in generated code. + * + * When a shape is annotated with `@error`, it needs to implement Rust's `Display` trait. + * If the error shape contains references to other structures, those structures also + * need to implement `Display` for proper error formatting. + */ +object AddSyntheticTraitForImplDisplay { + /** + * Transforms the model by adding [SyntheticImplDisplayTrait] to all shapes that are: + * 1. Reachable from an error shape + * 2. Not already marked with `@error` + * 3. Of a type that can implement `Display` (structure, list, union, or map) + * + * @param model The input model to transform + * @return The transformed model with synthetic traits added + */ + fun transform(model: Model): Model { + val walker = DirectedWalker(model) + + // Find all error shapes from operations. + val errorShapes = + model.operationShapes + .flatMap { it.errors } + .mapNotNull { model.expectShape(it).asStructureShape().orElse(null) } + + // Get shapes reachable from error shapes that need Display impl. + val shapesNeedingDisplay = + errorShapes + .flatMap { walker.walkShapes(it) } + .filter { + (it is StructureShape || it is ListShape || it is UnionShape || it is MapShape || it is EnumShape) && + it.getTrait() == null + } + + // Add synthetic trait to identified shapes. + val transformedShapes = + shapesNeedingDisplay.mapNotNull { shape -> + if (shape !is ToSmithyBuilder<*>) { + UNREACHABLE("Shapes reachable from error shapes should be buildable") + return@mapNotNull null + } + + val builder = shape.toBuilder() + if (builder is AbstractShapeBuilder<*, *>) { + builder.addTrait(SyntheticImplDisplayTrait()).build() + } else { + UNREACHABLE("`impl Display` cannot be generated for ${shape.id}") + null + } + } + + return ModelTransformer.create().replaceShapes(model, transformedShapes) + } +} diff --git a/codegen-server/python/src/main/kotlin/software/amazon/smithy/rust/codegen/server/python/smithy/generators/PythonServerEnumGenerator.kt b/codegen-server/python/src/main/kotlin/software/amazon/smithy/rust/codegen/server/python/smithy/generators/PythonServerEnumGenerator.kt index a7bcdd56d5e..db475cfef7c 100644 --- a/codegen-server/python/src/main/kotlin/software/amazon/smithy/rust/codegen/server/python/smithy/generators/PythonServerEnumGenerator.kt +++ b/codegen-server/python/src/main/kotlin/software/amazon/smithy/rust/codegen/server/python/smithy/generators/PythonServerEnumGenerator.kt @@ -59,20 +59,27 @@ class PythonConstrainedEnum( } private fun pyEnumName(context: EnumGeneratorContext): Writable = - writable { - rustBlock( - """ - ##[getter] - pub fn name(&self) -> &str - """, - ) { - rustBlock("match self") { - context.sortedMembers.forEach { member -> - val memberName = member.name()?.name - rust("""${context.enumName}::$memberName => ${memberName?.dq()},""") + // Only named enums have a `name` property. Do not generate `fn name` for + // unnamed enums. + if (context.enumTrait.hasNames()) { + writable { + rustBlock( + """ + ##[getter] + pub fn name(&self) -> &str + """, + ) { + rustBlock("match self") { + context.sortedMembers.forEach { member -> + val memberName = member.name()?.name + check(memberName != null) { "${context.enumTrait} cannot have null members" } + rust("""${context.enumName}::$memberName => ${memberName?.dq()},""") + } } } } + } else { + writable {} } } diff --git a/codegen-server/src/test/kotlin/software/amazon/smithy/rust/codegen/server/smithy/generators/ServerErrorReachableShapesDisplayTest.kt b/codegen-server/src/test/kotlin/software/amazon/smithy/rust/codegen/server/smithy/generators/ServerErrorReachableShapesDisplayTest.kt new file mode 100644 index 00000000000..b5118eb6e6d --- /dev/null +++ b/codegen-server/src/test/kotlin/software/amazon/smithy/rust/codegen/server/smithy/generators/ServerErrorReachableShapesDisplayTest.kt @@ -0,0 +1,16 @@ +package software.amazon.smithy.rust.codegen.server.smithy.generators + +import org.junit.jupiter.api.Test +import software.amazon.smithy.rust.codegen.core.testutil.asSmithyModel +import software.amazon.smithy.rust.codegen.server.smithy.testutil.serverIntegrationTest +import java.io.File + +class ServerErrorReachableShapesDisplayTest { + @Test + fun `composite error shapes are compilable`() { + var sampleModel = File("../codegen-core/common-test-models/nested-error.smithy").readText().asSmithyModel() + serverIntegrationTest(sampleModel) { _, _ -> + // It should compile. + } + } +} From 024d02a2e8f38a611999fe239a68eb14068662df Mon Sep 17 00:00:00 2001 From: Fahad Zubair Date: Thu, 3 Apr 2025 09:33:45 +0100 Subject: [PATCH 3/4] Remove getFieldValue. --- .../common-test-models/nested-error.smithy | 16 +++++----- .../smithy/generators/StructureGenerator.kt | 30 ++++++++----------- .../error/NestedErrorStructureTest.kt | 27 ++++++++--------- .../generators/PythonServerEnumGenerator.kt | 29 +++++++----------- 4 files changed, 44 insertions(+), 58 deletions(-) diff --git a/codegen-core/common-test-models/nested-error.smithy b/codegen-core/common-test-models/nested-error.smithy index 9fe85d2e324..ee69cfa8df0 100644 --- a/codegen-core/common-test-models/nested-error.smithy +++ b/codegen-core/common-test-models/nested-error.smithy @@ -16,30 +16,30 @@ operation SampleOperation { input:= {} errors: [ SimpleError, - ErrorWithCompositeShape, + ErrorInInput, ErrorWithDeepCompositeShape, - ComposedSensitiveError + ComposedSensitiveError, ] } @error("client") -structure ErrorWithCompositeShape { - message: ErrorMessage +structure SimpleError { + message: String } @error("client") -structure SimpleError { - message: String +structure ErrorInInput { + message: ErrorMessage } structure ErrorMessage { @required - statusCode: String + statusCode: Integer @required errorMessage: String requestId: String @required - toolName: String + isRetryable: Boolean } structure WrappedErrorMessage { diff --git a/codegen-core/src/main/kotlin/software/amazon/smithy/rust/codegen/core/smithy/generators/StructureGenerator.kt b/codegen-core/src/main/kotlin/software/amazon/smithy/rust/codegen/core/smithy/generators/StructureGenerator.kt index 934428fced1..a68981ece82 100644 --- a/codegen-core/src/main/kotlin/software/amazon/smithy/rust/codegen/core/smithy/generators/StructureGenerator.kt +++ b/codegen-core/src/main/kotlin/software/amazon/smithy/rust/codegen/core/smithy/generators/StructureGenerator.kt @@ -109,9 +109,20 @@ open class StructureGenerator( ) { writer.rustBlock("fn fmt(&self, f: &mut #1T::Formatter<'_>) -> #1T::Result", RuntimeType.stdFmt) { rust("""let mut formatter = f.debug_struct(${name.dq()});""") + members.forEach { member -> val memberName = symbolProvider.toMemberName(member) - val fieldValue = getFieldValue(member, memberName) + // If the struct is marked sensitive all fields get redacted, otherwise each field is determined on its own + val fieldValue = + if (shape.shouldRedact(model)) { + REDACTION + } else { + member.redactIfNecessary( + model, + "self.$memberName", + ) + } + rust( "formatter.field(${memberName.dq()}, &$fieldValue);", ) @@ -122,23 +133,6 @@ open class StructureGenerator( } } - // If the struct is marked sensitive all fields get redacted, otherwise each field is determined on its own. - private fun getFieldValue( - member: MemberShape, - memberName: String?, - ): String { - val fieldValue = - if (shape.shouldRedact(model)) { - REDACTION - } else { - member.redactIfNecessary( - model, - "self.$memberName", - ) - } - return fieldValue - } - private fun renderImplDisplayIfSyntheticImplDisplayTraitApplied() { if (shape.getTrait() == null) { return diff --git a/codegen-core/src/test/kotlin/software/amazon/smithy/rust/codegen/core/smithy/generators/error/NestedErrorStructureTest.kt b/codegen-core/src/test/kotlin/software/amazon/smithy/rust/codegen/core/smithy/generators/error/NestedErrorStructureTest.kt index 00ce65c0fbd..7c7ab55da73 100644 --- a/codegen-core/src/test/kotlin/software/amazon/smithy/rust/codegen/core/smithy/generators/error/NestedErrorStructureTest.kt +++ b/codegen-core/src/test/kotlin/software/amazon/smithy/rust/codegen/core/smithy/generators/error/NestedErrorStructureTest.kt @@ -24,7 +24,7 @@ class NestedErrorStructureTest { private var sampleModel = File("../codegen-core/common-test-models/nested-error.smithy").readText().asSmithyModel() private val model = sampleModel.let(AddSyntheticTraitForImplDisplay::transform) - private val errorWithCompositeShape = model.lookup("sample#ErrorWithCompositeShape") + private val errorInInput = model.lookup("sample#ErrorInInput") private val simpleError = model.lookup("sample#SimpleError") private val errorWithDeepCompositeShape = model.lookup("sample#ErrorWithDeepCompositeShape") private val composedSensitiveError = model.lookup("sample#ComposedSensitiveError") @@ -35,7 +35,7 @@ class NestedErrorStructureTest { private val allStructures = arrayOf( - errorWithCompositeShape, + errorInInput, simpleError, errorWithDeepCompositeShape, composedSensitiveError, @@ -46,7 +46,7 @@ class NestedErrorStructureTest { ) private val errorShapes = arrayOf( - errorWithCompositeShape, + errorInInput, simpleError, errorWithDeepCompositeShape, errorWithNestedError, @@ -95,13 +95,13 @@ class NestedErrorStructureTest { rustTemplate( """ let message = crate::test_model::ErrorMessage { - status_code: "200".to_owned(), + status_code: 333, error_message: "this is an error".to_owned(), request_id: None, - tool_name : "vscode".to_owned() + is_retryable: false, }; let formatted = format!("{message}"); - assert_eq!(formatted, "ErrorMessage {status_code=200, error_message=this is an error, request_id=None, tool_name=vscode}"); + assert_eq!(formatted, "ErrorMessage {status_code=333, error_message=this is an error, request_id=None, is_retryable=false}"); """, ) } @@ -109,13 +109,13 @@ class NestedErrorStructureTest { rustTemplate( """ let message = crate::test_model::ErrorMessage { - status_code: "200".to_owned(), + status_code: 419, error_message: "this is an error".to_owned(), request_id: Some("1234".to_owned()), - tool_name : "vscode".to_owned() + is_retryable : true, }; let formatted = format!("{message}"); - assert_eq!(formatted, "ErrorMessage {status_code=200, error_message=this is an error, request_id=Some(1234), tool_name=vscode}"); + assert_eq!(formatted, "ErrorMessage {status_code=419, error_message=this is an error, request_id=Some(1234), is_retryable=true}"); """, ) } @@ -142,10 +142,10 @@ class NestedErrorStructureTest { message: Some(crate::test_model::WrappedErrorMessage { some_value: Some(123), contained: Some(crate::test_model::ErrorMessage { - status_code: "200".to_owned(), + status_code: 509, error_message: "this is an error".to_owned(), request_id: Some("1234".to_owned()), - tool_name: "vscode".to_owned(), + is_retryable: false, }), }), }), @@ -153,10 +153,9 @@ class NestedErrorStructureTest { let formatted = format!("{message}"); const EXPECTED: &str = "ErrorWithNestedError: ErrorWithDeepCompositeShape: \ WrappedErrorMessage {some_value=Some(123), \ - contained=Some(ErrorMessage {status_code=200, \ + contained=Some(ErrorMessage {status_code=509, \ error_message=this is an error, \ - request_id=Some(1234), \ - tool_name=vscode})}"; + request_id=Some(1234), is_retryable=false})}"; assert_eq!(formatted, EXPECTED); """, ) diff --git a/codegen-server/python/src/main/kotlin/software/amazon/smithy/rust/codegen/server/python/smithy/generators/PythonServerEnumGenerator.kt b/codegen-server/python/src/main/kotlin/software/amazon/smithy/rust/codegen/server/python/smithy/generators/PythonServerEnumGenerator.kt index db475cfef7c..a7bcdd56d5e 100644 --- a/codegen-server/python/src/main/kotlin/software/amazon/smithy/rust/codegen/server/python/smithy/generators/PythonServerEnumGenerator.kt +++ b/codegen-server/python/src/main/kotlin/software/amazon/smithy/rust/codegen/server/python/smithy/generators/PythonServerEnumGenerator.kt @@ -59,27 +59,20 @@ class PythonConstrainedEnum( } private fun pyEnumName(context: EnumGeneratorContext): Writable = - // Only named enums have a `name` property. Do not generate `fn name` for - // unnamed enums. - if (context.enumTrait.hasNames()) { - writable { - rustBlock( - """ - ##[getter] - pub fn name(&self) -> &str - """, - ) { - rustBlock("match self") { - context.sortedMembers.forEach { member -> - val memberName = member.name()?.name - check(memberName != null) { "${context.enumTrait} cannot have null members" } - rust("""${context.enumName}::$memberName => ${memberName?.dq()},""") - } + writable { + rustBlock( + """ + ##[getter] + pub fn name(&self) -> &str + """, + ) { + rustBlock("match self") { + context.sortedMembers.forEach { member -> + val memberName = member.name()?.name + rust("""${context.enumName}::$memberName => ${memberName?.dq()},""") } } } - } else { - writable {} } } From 03f9d3736bb06bbe282d7bfe7c28721562f20a05 Mon Sep 17 00:00:00 2001 From: Fahad Zubair Date: Thu, 3 Apr 2025 15:29:08 +0100 Subject: [PATCH 4/4] Use more fields in the model and leave them empty if a value is given --- .../common-test-models/nested-error.smithy | 21 +- .../smithy/generators/StructureGenerator.kt | 51 +++- .../error/NestedErrorStructureTest.kt | 250 +++++++++++++++--- 3 files changed, 280 insertions(+), 42 deletions(-) diff --git a/codegen-core/common-test-models/nested-error.smithy b/codegen-core/common-test-models/nested-error.smithy index ee69cfa8df0..c5d5b1abe36 100644 --- a/codegen-core/common-test-models/nested-error.smithy +++ b/codegen-core/common-test-models/nested-error.smithy @@ -37,9 +37,28 @@ structure ErrorMessage { statusCode: Integer @required errorMessage: String - requestId: String @required isRetryable: Boolean + requestId: String + timeStamp: Timestamp + ratio: Float + precision: Double + dataSize: Long + byteCount: Short + flags: Byte + documentData: Document + blobData: Blob + tags: Map + errorCodes: List +} + +map Map { + key: String, + value: String +} + +list List { + member: Integer } structure WrappedErrorMessage { diff --git a/codegen-core/src/main/kotlin/software/amazon/smithy/rust/codegen/core/smithy/generators/StructureGenerator.kt b/codegen-core/src/main/kotlin/software/amazon/smithy/rust/codegen/core/smithy/generators/StructureGenerator.kt index a68981ece82..8c1e958e453 100644 --- a/codegen-core/src/main/kotlin/software/amazon/smithy/rust/codegen/core/smithy/generators/StructureGenerator.kt +++ b/codegen-core/src/main/kotlin/software/amazon/smithy/rust/codegen/core/smithy/generators/StructureGenerator.kt @@ -7,7 +7,12 @@ package software.amazon.smithy.rust.codegen.core.smithy.generators import software.amazon.smithy.codegen.core.Symbol import software.amazon.smithy.model.Model +import software.amazon.smithy.model.shapes.BlobShape +import software.amazon.smithy.model.shapes.DocumentShape +import software.amazon.smithy.model.shapes.ListShape +import software.amazon.smithy.model.shapes.MapShape import software.amazon.smithy.model.shapes.MemberShape +import software.amazon.smithy.model.shapes.StringShape import software.amazon.smithy.model.shapes.StructureShape import software.amazon.smithy.model.traits.ErrorTrait import software.amazon.smithy.model.traits.SensitiveTrait @@ -37,6 +42,7 @@ import software.amazon.smithy.rust.codegen.core.smithy.isOptional import software.amazon.smithy.rust.codegen.core.smithy.protocols.serialize.ValueExpression import software.amazon.smithy.rust.codegen.core.smithy.renamedFrom import software.amazon.smithy.rust.codegen.core.smithy.rustType +import software.amazon.smithy.rust.codegen.core.smithy.shape import software.amazon.smithy.rust.codegen.core.smithy.traits.SyntheticImplDisplayTrait import software.amazon.smithy.rust.codegen.core.util.REDACTION import software.amazon.smithy.rust.codegen.core.util.dq @@ -146,29 +152,52 @@ open class StructureGenerator( writer.rustBlock("fn fmt(&self, f: &mut #1T::Formatter<'_>) -> #1T::Result", RuntimeType.stdFmt) { write("""::std::write!(f, "$name {{")?;""") - members.forEachIndexed { index, member -> - val separator = if (index > 0) ", " else "" + var separator = "" + for (index in members.indices) { + val member = members[index] val memberName = symbolProvider.toMemberName(member) - val shouldRedact = shape.shouldRedact(model) || member.shouldRedact(model) + val memberSymbol = symbolProvider.toSymbol(member) + val shouldRedact = shape.shouldRedact(model) || member.shouldRedact(model) // If the shape is redacted then each member shape will be redacted. if (shouldRedact) { write("""::std::write!(f, "$separator$memberName={}", $REDACTION)?;""") } else { val variable = ValueExpression.Reference("&self.$memberName") - val memberSymbol = symbolProvider.toSymbol(member) - if (memberSymbol.isOptional()) { - rustBlockTemplate("if let #{Some}(inner) = ${variable.asRef()}", *preludeScope) { - write("""::std::write!(f, "$separator$memberName=Some({})", inner)?;""") + val target = model.expectShape(member.target) + when (target) { + is DocumentShape, is BlobShape, is MapShape, is ListShape -> { + // Just print the member field name but not the value. + if (memberSymbol.isOptional()) { + rustBlockTemplate("if let #{Some}(_) = ${variable.asRef()}", *preludeScope) { + write("""::std::write!(f, "$separator$memberName=Some()")?;""") + } + rustBlock("else") { + write("""::std::write!(f, "$separator$memberName=None")?;""") + } + } else { + write("""::std::write!(f, "$separator$memberName=")?;""") + } } - rustBlock("else") { - write("""::std::write!(f, "$separator$memberName=None")?;""") + else -> { + if (memberSymbol.isOptional()) { + rustBlockTemplate("if let #{Some}(inner) = ${variable.asRef()}", *preludeScope) { + write("""::std::write!(f, "$separator$memberName=Some({})", inner)?;""") + } + rustBlock("else") { + write("""::std::write!(f, "$separator$memberName=None")?;""") + } + } else { + write("""::std::write!(f, "$separator$memberName={}", ${variable.asRef()})?;""") + } } - } else { - write("""::std::write!(f, "$separator$memberName={}", ${variable.asRef()})?;""") } } + + if (separator.isEmpty()) { + separator = ", " + } } write("""::std::write!(f, "}}")""") diff --git a/codegen-core/src/test/kotlin/software/amazon/smithy/rust/codegen/core/smithy/generators/error/NestedErrorStructureTest.kt b/codegen-core/src/test/kotlin/software/amazon/smithy/rust/codegen/core/smithy/generators/error/NestedErrorStructureTest.kt index 7c7ab55da73..2fc058a7435 100644 --- a/codegen-core/src/test/kotlin/software/amazon/smithy/rust/codegen/core/smithy/generators/error/NestedErrorStructureTest.kt +++ b/codegen-core/src/test/kotlin/software/amazon/smithy/rust/codegen/core/smithy/generators/error/NestedErrorStructureTest.kt @@ -72,6 +72,199 @@ class NestedErrorStructureTest { shape: StructureShape, ) = ErrorImplGenerator(model, provider, writer, shape, shape.getTrait()!!, emptyList()) + /** + * Generates Rust code to create an ErrorMessage with specified fields and returns the expected formatting. + * + * @param statusCode The required status code value + * @param errorMessage The required error message string + * @param isRetryable The required boolean flag for retryability + * @param optionalValues Map of optional field names to values (null indicates None) + * @return Pair of (Rust initialization code, assert_eq! statement) + */ + private fun generateErrorMessageWithAssert( + statusCode: Int, + errorMessage: String, + isRetryable: Boolean, + optionalValues: Map = emptyMap() + ): Pair { + // Generate the Rust initialization code + val rustCode = generateErrorMessage(statusCode, errorMessage, isRetryable, optionalValues = optionalValues) + + // Build the expected output string for assert_eq! + val expectedOutput = buildExpectedOutput(statusCode, errorMessage, isRetryable, optionalValues = optionalValues) + + // Generate the assert_eq! statement + val assertStatement = """ + let formatted = format!("{message}"); + assert_eq!(formatted, "$expectedOutput"); + """ + + return Pair(rustCode, assertStatement) + } + + /** + * Builds the expected string representation of the ErrorMessage + */ + private fun buildExpectedOutput( + statusCode: Int, + errorMessage: String, + isRetryable: Boolean, + optionalValues: Map = emptyMap() + ): String { + val parts = mutableListOf( + "status_code=$statusCode", + "error_message=$errorMessage", + "is_retryable=$isRetryable" + ) + val codeBuilder = StringBuilder(parts.joinToString(", ")) + codeBuilder.append(", ") + + // For assertions, just use quoted strings without .to_owned() + fillOptionalValues( + codeBuilder, + assignmentOperator = "=", + lineSeparator = ", ", + optionalValues = optionalValues, + stringFormatter = { "$it" } // Simple quoted string for assertions + ) + + val optional = codeBuilder.toString().dropLast(2) + + // Return the formatted string + return "ErrorMessage {$optional}" + } + + /** + * Generates Rust code to create an ErrorMessage with specified fields. + */ + private fun generateErrorMessage( + statusCode: Int, + errorMessage: String, + isRetryable: Boolean, + prefix: String = "let message = ", + suffix: String = ";", + optionalValues: Map = emptyMap() + ): String { + val codeBuilder = StringBuilder("$prefix crate::test_model::ErrorMessage {\n") + + // Add required fields + codeBuilder.append(" status_code: $statusCode,\n") + codeBuilder.append(" error_message: \"$errorMessage\".to_owned(),\n") + codeBuilder.append(" is_retryable: $isRetryable,\n") + + // Add optional fields with indentation and proper syntax for code generation + codeBuilder.append(" ") + fillOptionalValues( + codeBuilder, + assignmentOperator = ": ", + lineSeparator = ",\n ", + optionalValues = optionalValues, + stringFormatter = { "\"$it\".to_owned()" } // Use .to_owned() for code generation + ) + + codeBuilder.append("\n} $suffix") + return codeBuilder.toString() + } + + /** + * Fills optional values in the provided StringBuilder + * + * @param codeBuilder StringBuilder to append formatted values to + * @param assignmentOperator Operator for assignment (e.g., ":" or "=") + * @param lineSeparator Separator between lines (e.g., ",\n" or ", ") + * @param optionalValues Map of field names to values + * @param stringFormatter Lambda for formatting string values + */ + private fun fillOptionalValues( + codeBuilder: StringBuilder, + assignmentOperator: String = ":", + lineSeparator: String = ",\n", + optionalValues: Map = emptyMap(), + stringFormatter: (String) -> String = { "\"$it\".to_owned()" } // Default uses .to_owned() + ) { + // List of all optional fields + val allOptionalFields = listOf( + "request_id", + "time_stamp", + "ratio", + "precision", + "data_size", + "byte_count", + "flags", + "document_data", + "blob_data", + "tags", + "error_codes", + ) + + // Add all optional fields, using provided values or None + for (field in allOptionalFields) { + val value = optionalValues[field] + val formattedValue = formatOptionalValue(field, value, stringFormatter) + codeBuilder.append("$field$assignmentOperator$formattedValue$lineSeparator") + } + } + + /** + * Formats an optional value as Some(value) or None + * + * @param field Field name + * @param value Field value + * @param stringFormatter Lambda for formatting string values + * @return Formatted value string + */ + private fun formatOptionalValue( + field: String, + value: Any?, + stringFormatter: (String) -> String + ): String { + return if (value == null) { + "None" + } else when (field) { + "request_id" -> "Some(${stringFormatter(value.toString())})" + "time_stamp" -> { + if (value is String) { + "Some(aws_smithy_types::DateTime::from_str(\"$value\", aws_smithy_types::date_time::Format::DateTime).unwrap())" + } else { + "Some(aws_smithy_types::DateTime::from_secs($value))" + } + } + "document_data" -> { + if (value is String) { + "Some(aws_smithy_json::Value::from_str(\"$value\").unwrap())" + } else { + "Some($value)" + } + } + "blob_data" -> "Some(aws_smithy_types::Blob::new($value))" + "tags" -> { + if (value is Map<*, *>) { + val mapEntries = value.entries.joinToString(", ") { (k, v) -> + "${stringFormatter(k.toString())} => ${stringFormatter(v.toString())}" + } + "Some(std::collections::HashMap::from([$mapEntries]))" + } else { + "Some($value)" + } + } + "error_codes" -> { + if (value is List<*>) { + val items = value.joinToString(", ") + "Some(vec![$items])" + } else { + "Some($value)" + } + } + else -> { + if (value is String) { + "Some(${stringFormatter(value)})" + } else { + "Some($value)" + } + } + } + } + @Test fun `generate nested error structure`() { val project = TestWorkspace.testProject(provider) @@ -92,33 +285,35 @@ class NestedErrorStructureTest { RustModule.public("tests"), ) { unitTest("optional_field_prints_none") { - rustTemplate( - """ - let message = crate::test_model::ErrorMessage { - status_code: 333, - error_message: "this is an error".to_owned(), - request_id: None, - is_retryable: false, - }; - let formatted = format!("{message}"); - assert_eq!(formatted, "ErrorMessage {status_code=333, error_message=this is an error, request_id=None, is_retryable=false}"); - """, + val (rustCode, assertStatement) = generateErrorMessageWithAssert( + statusCode = 333, + errorMessage = "this is an error", + isRetryable = false, + optionalValues = mapOf("request_id" to null) ) + + rustTemplate(""" + $rustCode + $assertStatement + """) } + unitTest("optional_field_prints_value") { + val (rustCode, assertStatement) = generateErrorMessageWithAssert( + statusCode = 419, + errorMessage = "this is an error", + isRetryable = true, + optionalValues = mapOf("request_id" to "1234") + ) + rustTemplate( """ - let message = crate::test_model::ErrorMessage { - status_code: 419, - error_message: "this is an error".to_owned(), - request_id: Some("1234".to_owned()), - is_retryable : true, - }; - let formatted = format!("{message}"); - assert_eq!(formatted, "ErrorMessage {status_code=419, error_message=this is an error, request_id=Some(1234), is_retryable=true}"); - """, + $rustCode + $assertStatement + """ ) } + unitTest("sensitive_is_redacted") { val redacted = REDACTION.removeSurrounding("\"") rustTemplate( @@ -134,28 +329,23 @@ class NestedErrorStructureTest { ) } unitTest("nested_error_structure_do_not_implement_display_twice") { - val redacted = REDACTION.removeSurrounding("\"") + val rustCode = generateErrorMessage(509, "this is an error", false, prefix = "", suffix = "") + val expectedOutput = buildExpectedOutput(509, "this is an error", false) + rustTemplate( """ let message = crate::test_error::ErrorWithNestedError { message: Some(crate::test_error::ErrorWithDeepCompositeShape { message: Some(crate::test_model::WrappedErrorMessage { some_value: Some(123), - contained: Some(crate::test_model::ErrorMessage { - status_code: 509, - error_message: "this is an error".to_owned(), - request_id: Some("1234".to_owned()), - is_retryable: false, - }), + contained: Some($rustCode), }), }), }; let formatted = format!("{message}"); const EXPECTED: &str = "ErrorWithNestedError: ErrorWithDeepCompositeShape: \ WrappedErrorMessage {some_value=Some(123), \ - contained=Some(ErrorMessage {status_code=509, \ - error_message=this is an error, \ - request_id=Some(1234), is_retryable=false})}"; + contained=Some($expectedOutput)}"; assert_eq!(formatted, EXPECTED); """, )