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 ae1788d946..e3caf4ce0f 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 6bcc00d84a..0f1ef7800a 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 0000000000..24cf09a6fd --- /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 0000000000..c5d5b1abe3 --- /dev/null +++ b/codegen-core/common-test-models/nested-error.smithy @@ -0,0 +1,89 @@ +$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, + ErrorInInput, + ErrorWithDeepCompositeShape, + ComposedSensitiveError, + ] +} + +@error("client") +structure SimpleError { + message: String +} + +@error("client") +structure ErrorInInput { + message: ErrorMessage +} + +structure ErrorMessage { + @required + statusCode: Integer + @required + errorMessage: 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 { + 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 17452da38d..8c1e958e45 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 @@ -24,15 +29,21 @@ 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.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 import software.amazon.smithy.rust.codegen.core.util.getTrait @@ -128,6 +139,72 @@ open class StructureGenerator( } } + 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 {{")?;""") + + var separator = "" + for (index in members.indices) { + val member = members[index] + val memberName = symbolProvider.toMemberName(member) + 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 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=")?;""") + } + } + 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()})?;""") + } + } + } + } + + if (separator.isEmpty()) { + separator = ", " + } + } + + write("""::std::write!(f, "}}")""") + } + } + } + private fun renderStructureImpl() { if (accessorMembers.isEmpty()) { return @@ -209,6 +286,7 @@ open class StructureGenerator( if (!containerMeta.hasDebugDerive()) { renderDebugImpl() } + renderImplDisplayIfSyntheticImplDisplayTraitApplied() writer.writeCustomizations(customizations, StructureSection.AdditionalTraitImpls(shape, name)) } 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 0000000000..d1208e26ea --- /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 0000000000..4b3f9306a6 --- /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-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 0000000000..2fc058a743 --- /dev/null +++ b/codegen-core/src/test/kotlin/software/amazon/smithy/rust/codegen/core/smithy/generators/error/NestedErrorStructureTest.kt @@ -0,0 +1,357 @@ +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 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") + 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( + errorInInput, + simpleError, + errorWithDeepCompositeShape, + composedSensitiveError, + errorWithNestedError, + errorMessage, + wrappedErrorMessage, + sensitiveMessage, + ) + private val errorShapes = + arrayOf( + errorInInput, + 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()) + + /** + * 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) + // 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") { + 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( + """ + $rustCode + $assertStatement + """ + ) + } + + 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 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($rustCode), + }), + }), + }; + let formatted = format!("{message}"); + const EXPECTED: &str = "ErrorWithNestedError: ErrorWithDeepCompositeShape: \ + WrappedErrorMessage {some_value=Some(123), \ + contained=Some($expectedOutput)}"; + 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 b1fc07a31a..3b72367d10 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. 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 0000000000..b5118eb6e6 --- /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. + } + } +}