diff --git a/.changelog/1759254918.md b/.changelog/1759254918.md new file mode 100644 index 00000000000..876afcc05ee --- /dev/null +++ b/.changelog/1759254918.md @@ -0,0 +1,11 @@ +--- +applies_to: ["server"] +authors: ["jasgin"] +references: ["smithy-rs#4317"] +breaking: false +new_feature: true +bug_fix: false +--- +Adds validators and codegen support for the custom traits custom traits `@validationException`, `@validationMessage`, +`@validationFieldList`, `@validationFieldName`, and `@validationFieldMessage` for defining a custom validation exception +to use instead of `smithy.framework#ValidationException`. diff --git a/codegen-server-test/build.gradle.kts b/codegen-server-test/build.gradle.kts index 871a4c4c826..45e8f9b9660 100644 --- a/codegen-server-test/build.gradle.kts +++ b/codegen-server-test/build.gradle.kts @@ -33,7 +33,7 @@ smithy { format.set(false) } -val allCodegenTests = "../codegen-core/common-test-models".let { commonModels -> +val commonCodegenTests = "../codegen-core/common-test-models".let { commonModels -> listOf( CodegenTest( "crate#Config", @@ -108,6 +108,18 @@ val allCodegenTests = "../codegen-core/common-test-models".let { commonModels -> ) } +val customCodegenTests = "custom-test-models".let { customModels -> + listOf( + CodegenTest( + "com.aws.example#CustomValidationExample", + "custom-validation-exception-example", + imports = listOf("$customModels/custom-validation-exception.smithy"), + ), + ) +} + +val allCodegenTests = commonCodegenTests + customCodegenTests + project.registerGenerateSmithyBuildTask(rootProject, pluginName, allCodegenTests) project.registerGenerateCargoWorkspaceTask(rootProject, pluginName, allCodegenTests, workingDirUnderBuildDir) project.registerGenerateCargoConfigTomlTask(layout.buildDirectory.dir(workingDirUnderBuildDir).get().asFile) diff --git a/codegen-server-test/custom-test-models/custom-validation-exception.smithy b/codegen-server-test/custom-test-models/custom-validation-exception.smithy new file mode 100644 index 00000000000..110f89aad05 --- /dev/null +++ b/codegen-server-test/custom-test-models/custom-validation-exception.smithy @@ -0,0 +1,70 @@ +$version: "2.0" + +namespace com.aws.example + +use aws.protocols#restJson1 +use smithy.rust.codegen.traits#validationException +use smithy.rust.codegen.traits#validationFieldList +use smithy.rust.codegen.traits#validationFieldMessage +use smithy.rust.codegen.traits#validationFieldName +use smithy.rust.codegen.traits#validationMessage + +@restJson1 +service CustomValidationExample { + version: "1.0.0" + operations: [ + TestOperation + ] + errors: [ + MyCustomValidationException + ] +} + +@http(method: "POST", uri: "/test") +operation TestOperation { + input: TestInput +} + +structure TestInput { + @required + @length(min: 1, max: 10) + name: String + + @range(min: 1, max: 100) + age: Integer +} + +@error("client") +@httpError(400) +@validationException +structure MyCustomValidationException { + @required + @validationMessage + customMessage: String + + @required + @default("testReason1") + reason: ValidationExceptionReason + + @validationFieldList + customFieldList: CustomValidationFieldList +} + +enum ValidationExceptionReason { + TEST_REASON_0 = "testReason0" + TEST_REASON_1 = "testReason1" +} + +structure CustomValidationField { + @required + @validationFieldName + customFieldName: String + + @required + @validationFieldMessage + customFieldMessage: String +} + +list CustomValidationFieldList { + member: CustomValidationField +} diff --git a/codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/Constraints.kt b/codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/Constraints.kt index f6dc9eac664..165c3dba025 100644 --- a/codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/Constraints.kt +++ b/codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/Constraints.kt @@ -10,6 +10,8 @@ import software.amazon.smithy.model.Model import software.amazon.smithy.model.shapes.BlobShape import software.amazon.smithy.model.shapes.ByteShape import software.amazon.smithy.model.shapes.CollectionShape +import software.amazon.smithy.model.shapes.EnumShape +import software.amazon.smithy.model.shapes.IntEnumShape import software.amazon.smithy.model.shapes.IntegerShape import software.amazon.smithy.model.shapes.LongShape import software.amazon.smithy.model.shapes.MapShape @@ -97,6 +99,39 @@ fun Shape.isDirectlyConstrained(symbolProvider: SymbolProvider): Boolean = this.members().any { !symbolProvider.toSymbol(it).isOptional() && !it.hasNonNullDefault() } } + else -> this.isDirectlyConstrainedHelper() + } + +/** + * Finds shapes that are directly constrained in validation phase, which means the shape is a: + * - [StructureShape] with a required member that does not have a non-null default + * - [EnumShape] + * - [IntEnumShape] + * - [MemberShape] that is required and does not have a non-null default + * + * We use this rather than [Shape.isDirectlyConstrained] to check for constrained shapes in validation phase because + * the [SymbolProvider] has not yet been created + */ +fun Shape.isDirectlyConstrainedForValidation(): Boolean = + when (this) { + is StructureShape -> { + // we use `member.isOptional` here because the issue outlined in (https://github.com/smithy-lang/smithy-rs/issues/1302) + // should not be relevant in validation phase + this.members().any { !it.isOptional && !it.hasNonNullDefault() } + } + + // For alignment with + // (https://github.com/smithy-lang/smithy-rs/blob/custom-validation-rfc/design/src/rfcs/rfc0047_custom_validation.md#terminology) + // TODO(move to [isDirectlyConstrainerHelper] if they can be safely applied to [isDirectlyConstrained] without breaking implications) + is EnumShape -> true + is IntEnumShape -> true + is MemberShape -> !this.isOptional && !this.hasNonNullDefault() + + else -> this.isDirectlyConstrainedHelper() + } + +private fun Shape.isDirectlyConstrainedHelper(): Boolean = + when (this) { is MapShape -> this.hasTrait() is StringShape -> this.hasTrait() || supportedStringConstraintTraits.any { this.hasTrait(it) } is CollectionShape -> supportedCollectionConstraintTraits.any { this.hasTrait(it) } @@ -129,11 +164,27 @@ fun Shape.canReachConstrainedShape( DirectedWalker(model).walkShapes(this).toSet().any { it.isDirectlyConstrained(symbolProvider) } } +/** + * Whether this shape (or the shape's target for [MemberShape]s) can reach constrained shapes for validations. + * + * We use this rather than [Shape.canReachConstrainedShape] to check for constrained shapes in validation phase because + * the [SymbolProvider] has not yet been created + */ +fun Shape.canReachConstrainedShapeForValidation(model: Model): Boolean = + if (this is MemberShape) { + this.targetCanReachConstrainedShapeForValidation(model) + } else { + DirectedWalker(model).walkShapes(this).toSet().any { it.isDirectlyConstrainedForValidation() } + } + fun MemberShape.targetCanReachConstrainedShape( model: Model, symbolProvider: SymbolProvider, ): Boolean = model.expectShape(this.target).canReachConstrainedShape(model, symbolProvider) +fun MemberShape.targetCanReachConstrainedShapeForValidation(model: Model): Boolean = + model.expectShape(this.target).canReachConstrainedShapeForValidation(model) + fun Shape.hasPublicConstrainedWrapperTupleType( model: Model, publicConstrainedTypes: Boolean, diff --git a/codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/RustServerCodegenPlugin.kt b/codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/RustServerCodegenPlugin.kt index e68fa26a33c..e6ab96bd869 100644 --- a/codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/RustServerCodegenPlugin.kt +++ b/codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/RustServerCodegenPlugin.kt @@ -20,6 +20,7 @@ import software.amazon.smithy.rust.codegen.core.smithy.SymbolVisitor import software.amazon.smithy.rust.codegen.server.smithy.customizations.CustomValidationExceptionWithReasonDecorator import software.amazon.smithy.rust.codegen.server.smithy.customizations.ServerRequiredCustomizations import software.amazon.smithy.rust.codegen.server.smithy.customizations.SmithyValidationExceptionDecorator +import software.amazon.smithy.rust.codegen.server.smithy.customizations.UserProvidedValidationExceptionDecorator import software.amazon.smithy.rust.codegen.server.smithy.customize.CombinedServerCodegenDecorator import software.amazon.smithy.rust.codegen.server.smithy.customize.ServerCodegenDecorator import software.amazon.smithy.rust.codegen.server.smithy.testutil.ServerDecoratableBuildPlugin @@ -50,6 +51,7 @@ class RustServerCodegenPlugin : ServerDecoratableBuildPlugin() { CombinedServerCodegenDecorator.fromClasspath( context, ServerRequiredCustomizations(), + UserProvidedValidationExceptionDecorator(), SmithyValidationExceptionDecorator(), CustomValidationExceptionWithReasonDecorator(), *decorator, 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 a4a8b5affff..86e59f57214 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 @@ -238,6 +238,7 @@ open class ServerCodegenVisitor( val validationExceptionShapeId = validationExceptionConversionGenerator.shapeId for (validationResult in listOf( + validateModelHasAtMostOneValidationException(model, service), codegenDecorator.postprocessValidationExceptionNotAttachedErrorMessage( validateOperationsWithConstrainedInputHaveValidationExceptionAttached( model, @@ -246,6 +247,13 @@ open class ServerCodegenVisitor( ), ), validateUnsupportedConstraints(model, service, codegenContext.settings.codegenConfig), + codegenDecorator.postprocessMultipleValidationExceptionsErrorMessage( + validateOperationsWithConstrainedInputHaveOneValidationExceptionAttached( + model, + service, + validationExceptionShapeId, + ), + ), )) { for (logMessage in validationResult.messages) { // TODO(https://github.com/smithy-lang/smithy-rs/issues/1756): These are getting duplicated. diff --git a/codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/ValidateUnsupportedConstraints.kt b/codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/ValidateUnsupportedConstraints.kt index 4d17fa6d2e1..ff4ee46563a 100644 --- a/codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/ValidateUnsupportedConstraints.kt +++ b/codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/ValidateUnsupportedConstraints.kt @@ -32,6 +32,7 @@ import software.amazon.smithy.rust.codegen.core.util.hasEventStreamMember import software.amazon.smithy.rust.codegen.core.util.hasTrait import software.amazon.smithy.rust.codegen.core.util.inputShape import software.amazon.smithy.rust.codegen.core.util.orNull +import software.amazon.smithy.rust.codegen.traits.ValidationExceptionTrait import java.util.logging.Level private sealed class UnsupportedConstraintMessageKind { @@ -147,6 +148,8 @@ private sealed class UnsupportedConstraintMessageKind { private data class OperationWithConstrainedInputWithoutValidationException(val shape: OperationShape) +private data class OperationWithConstrainedInputWithMultipleValidationExceptions(val shape: OperationShape) + private data class UnsupportedConstraintOnMemberShape(val shape: MemberShape, val constraintTrait: Trait) : UnsupportedConstraintMessageKind() @@ -176,6 +179,9 @@ data class LogMessage(val level: Level, val message: String) data class ValidationResult(val shouldAbort: Boolean, val messages: List) : Throwable(message = messages.joinToString("\n") { it.message }) +private const val validationExceptionDocsErrorMessage = + "For documentation, see https://smithy-lang.github.io/smithy-rs/design/server/validation_exceptions.html" + /* * Returns the set of operation shapes that must have a supported validation exception shape * in their associated errors list. @@ -205,12 +211,20 @@ fun validateOperationsWithConstrainedInputHaveValidationExceptionAttached( validationExceptionShapeId: ShapeId, ): ValidationResult { // Traverse the model and error out if an operation uses constrained input, but it does not have - // `ValidationException` attached in `errors`. https://github.com/smithy-lang/smithy-rs/pull/1199#discussion_r809424783 + // `ValidationException` or a structure with the @validationException trait attached in `errors`. + // https://github.com/smithy-lang/smithy-rs/pull/1199#discussion_r809424783 // TODO(https://github.com/smithy-lang/smithy-rs/issues/1401): This check will go away once we add support for // `disableDefaultValidation` set to `true`, allowing service owners to map from constraint violations to operation errors. val operationsWithConstrainedInputWithoutValidationExceptionSet = operationShapesThatMustHaveValidationException(model, service) - .filter { !it.errors.contains(validationExceptionShapeId) } + .filter { + !it.errors.contains(validationExceptionShapeId) && + it.errors.none { error -> + model + .expectShape(error) + .hasTrait(ValidationExceptionTrait.ID) + } + } .map { OperationWithConstrainedInputWithoutValidationException(it) } .toSet() @@ -221,7 +235,9 @@ fun validateOperationsWithConstrainedInputHaveValidationExceptionAttached( """ Operation ${it.shape.id} takes in input that is constrained (https://awslabs.github.io/smithy/2.0/spec/constraint-traits.html), and as such can fail with a - validation exception. You must model this behavior in the operation shape in your model file. + validation exception. You must model this behavior in the operation shape in your model file using + the default validation exception shown below, or by defining a custom validation exception. + $validationExceptionDocsErrorMessage """.trimIndent().replace("\n", " ") + """ @@ -240,6 +256,104 @@ fun validateOperationsWithConstrainedInputHaveValidationExceptionAttached( return ValidationResult(shouldAbort = messages.any { it.level == Level.SEVERE }, messages) } +/** + * Validate that all constrained operations have exactly one of: the default smithy.framework#ValidationException or a + * custom validation exception (shape with @validationException) attached to their errors. + */ +fun validateOperationsWithConstrainedInputHaveOneValidationExceptionAttached( + model: Model, + service: ServiceShape, + validationExceptionShapeId: ShapeId, +): ValidationResult { + val operationsWithConstrainedInputWithMultipleValidationExceptionSet = + operationShapesThatMustHaveValidationException(model, service) + .filter { + it.errors.count { error -> + val errorShape = model.expectShape(error) + errorShape.hasTrait(ValidationExceptionTrait.ID) || errorShape.id == validationExceptionShapeId + } > 1 + } + .map { OperationWithConstrainedInputWithMultipleValidationExceptions(it) } + .toSet() + + val messages = + operationsWithConstrainedInputWithMultipleValidationExceptionSet.map { + LogMessage( + Level.SEVERE, + """ + Cannot have multiple validation exceptions defined for a constrained operation. + Operation ${it.shape.id} takes in input that is constrained (https://awslabs.github.io/smithy/2.0/spec/constraint-traits.html), + and as such can fail with a validation exception. This must be modeled with a single validation exception. + $validationExceptionDocsErrorMessage + """.trimIndent(), + ) + } + + return ValidationResult(shouldAbort = messages.any { it.level == Level.SEVERE }, messages) +} + +/** + * Restrict custom validation exceptions to just one and ensure default validation exception is not used if a custom + * validation exception is defined + */ +fun validateModelHasAtMostOneValidationException( + model: Model, + service: ServiceShape, +): ValidationResult { + val customValidationExceptionShapes = + model.shapes() + .filter { it.hasTrait(ValidationExceptionTrait.ID) } + .toList() + + val messages = mutableListOf() + + if (customValidationExceptionShapes.isEmpty()) { + return ValidationResult(shouldAbort = false, messages) + } + + if (customValidationExceptionShapes.size > 1) { + messages.add( + LogMessage( + Level.SEVERE, + """ + Defining multiple custom validation exceptions is unsupported. + Found ${customValidationExceptionShapes.size} validation exception shapes: + ${customValidationExceptionShapes.joinToString(", ") { it.id.toString() }} + $validationExceptionDocsErrorMessage + """.trimIndent(), + ), + ) + return ValidationResult(shouldAbort = true, messages) + } + + // Traverse the model and error out if the default ValidationException exists in an error closure of a service or operation: + val walker = DirectedWalker(model) + + val defaultValidationExceptionId = ShapeId.from("smithy.framework#ValidationException") + + val operationsWithDefault = + walker + .walkShapes(service) + .asSequence() + .filterIsInstance() + .filter { it.errors.contains(defaultValidationExceptionId) } + + operationsWithDefault.forEach { + messages.add( + LogMessage( + Level.SEVERE, + """ + Operation ${it.id} uses the default ValidationException, but a custom validation exception is defined. + Remove ValidationException from the operation's errors and use the custom validation exception instead. + $validationExceptionDocsErrorMessage + """.trimIndent(), + ), + ) + } + + return ValidationResult(shouldAbort = messages.any { it.level == Level.SEVERE }, messages) +} + fun validateUnsupportedConstraints( model: Model, service: ServiceShape, diff --git a/codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/customizations/UserProvidedValidationExceptionDecorator.kt b/codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/customizations/UserProvidedValidationExceptionDecorator.kt new file mode 100644 index 00000000000..297760fc3bf --- /dev/null +++ b/codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/customizations/UserProvidedValidationExceptionDecorator.kt @@ -0,0 +1,620 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.rust.codegen.server.smithy.customizations + +import software.amazon.smithy.codegen.core.CodegenException +import software.amazon.smithy.model.Model +import software.amazon.smithy.model.shapes.MapShape +import software.amazon.smithy.model.shapes.MemberShape +import software.amazon.smithy.model.shapes.Shape +import software.amazon.smithy.model.shapes.ShapeId +import software.amazon.smithy.model.shapes.StringShape +import software.amazon.smithy.model.shapes.StructureShape +import software.amazon.smithy.model.traits.EnumTrait +import software.amazon.smithy.model.traits.LengthTrait +import software.amazon.smithy.model.traits.PatternTrait +import software.amazon.smithy.rust.codegen.core.rustlang.Writable +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.rustTemplate +import software.amazon.smithy.rust.codegen.core.rustlang.withBlock +import software.amazon.smithy.rust.codegen.core.rustlang.writable +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.protocols.shapeModuleName +import software.amazon.smithy.rust.codegen.core.util.dq +import software.amazon.smithy.rust.codegen.core.util.getTrait +import software.amazon.smithy.rust.codegen.core.util.targetOrSelf +import software.amazon.smithy.rust.codegen.core.util.toSnakeCase +import software.amazon.smithy.rust.codegen.server.smithy.ServerCodegenContext +import software.amazon.smithy.rust.codegen.server.smithy.customize.ServerCodegenDecorator +import software.amazon.smithy.rust.codegen.server.smithy.generators.BlobLength +import software.amazon.smithy.rust.codegen.server.smithy.generators.CollectionTraitInfo +import software.amazon.smithy.rust.codegen.server.smithy.generators.ConstraintViolation +import software.amazon.smithy.rust.codegen.server.smithy.generators.Range +import software.amazon.smithy.rust.codegen.server.smithy.generators.StringTraitInfo +import software.amazon.smithy.rust.codegen.server.smithy.generators.UnionConstraintTraitInfo +import software.amazon.smithy.rust.codegen.server.smithy.generators.ValidationExceptionConversionGenerator +import software.amazon.smithy.rust.codegen.server.smithy.generators.isKeyConstrained +import software.amazon.smithy.rust.codegen.server.smithy.generators.isValueConstrained +import software.amazon.smithy.rust.codegen.server.smithy.generators.protocol.ServerProtocol +import software.amazon.smithy.rust.codegen.server.smithy.util.isValidationMessage +import software.amazon.smithy.rust.codegen.server.smithy.validationErrorMessage +import software.amazon.smithy.rust.codegen.traits.ValidationExceptionTrait +import software.amazon.smithy.rust.codegen.traits.ValidationFieldListTrait +import software.amazon.smithy.rust.codegen.traits.ValidationFieldMessageTrait +import software.amazon.smithy.rust.codegen.traits.ValidationFieldNameTrait + +/** + * Decorator for custom validation exception codegen + * + * The order of this is less than that of [SmithyValidationExceptionDecorator] so it takes precedence regardless of the + * order decorators are passed into the plugin + */ +class UserProvidedValidationExceptionDecorator : ServerCodegenDecorator { + override val name: String + get() = "CustomValidationExceptionDecorator" + override val order: Byte + get() = 68 + + internal fun customValidationException(codegenContext: ServerCodegenContext): StructureShape? = + codegenContext.model + .shapes(StructureShape::class.java) + .filter { it.hasTrait(ValidationExceptionTrait.ID) } + .findFirst() + .orElse(null) + + override fun validationExceptionConversion( + codegenContext: ServerCodegenContext, + ): ValidationExceptionConversionGenerator? { + val validationException = customValidationException(codegenContext) ?: return null + + return UserProvidedValidationExceptionConversionGenerator(codegenContext, validationException) + } +} + +class UserProvidedValidationExceptionConversionGenerator( + private val codegenContext: ServerCodegenContext, + private val validationException: StructureShape, +) : ValidationExceptionConversionGenerator { + private val maybeValidationField = customValidationField() + + private val codegenCtx = + listOfNotNull(maybeValidationField) + .map { + "CustomValidationExceptionField" to codegenContext.symbolProvider.toSymbol(it) + }.toTypedArray() + + companion object { + val SHAPE_ID: ShapeId = ShapeId.from("smithy.framework#CustomValidationException") + } + + override val shapeId: ShapeId = SHAPE_ID + + internal fun customValidationMessage(): MemberShape = + validationException + .members() + .firstOrNull { it.isValidationMessage() } + ?: throw CodegenException("Expected $validationException to contain a member with ValidationMessageTrait") + + internal fun customValidationFieldList(): MemberShape? = + validationException + .members() + .firstOrNull { it.hasTrait(ValidationFieldListTrait.ID) } + + internal fun customValidationField(): StructureShape? { + val validationFieldListMember = customValidationFieldList() ?: return null + + // get target of member of the custom validation field list that represents the structure for the + // validation field shape, otherwise, we return null as field list is optional + val validationFieldShape = + validationFieldListMember + .targetOrSelf(codegenContext.model) + .members() + .firstOrNull { it.targetOrSelf(codegenContext.model).isStructureShape } + ?.targetOrSelf(codegenContext.model) + ?.asStructureShape() + ?.orElse(null) + ?: return null + + // It is required that a member of the custom validation field structure has @validationFieldName + if (validationFieldShape + .members() + .none { it.hasTrait(ValidationFieldNameTrait.ID) } + ) { + throw CodegenException("Expected $validationFieldShape to contain a member with ValidationFieldNameTrait") + } + + return validationFieldShape + } + + internal fun customValidationFieldMessage(): MemberShape? { + val validationField = customValidationField() ?: return null + + return validationField.members().firstOrNull { it.hasTrait(ValidationFieldMessageTrait.ID) } + } + + internal fun customValidationAdditionalFields(): List = + validationException.members().filter { member -> + !member.isValidationMessage() && !member.hasTrait(ValidationFieldListTrait.ID) + } + + override fun renderImplFromConstraintViolationForRequestRejection(protocol: ServerProtocol): Writable { + val validationMessage = customValidationMessage() + val validationFieldList = customValidationFieldList() + val validationFieldMessage = customValidationFieldMessage() + val additionalFields = customValidationAdditionalFields() + + return writable { + val validationMessageName = codegenContext.symbolProvider.toMemberName(validationMessage)!! + // Generate the correct shape module name for the custom validation exception + val shapeModuleName = + codegenContext.symbolProvider.shapeModuleName(codegenContext.serviceShape, validationException) + val shapeFunctionName = validationException.id.name.toSnakeCase() + + rustTemplate( + """ + impl #{From} for #{RequestRejection} { + fn from(constraint_violation: ConstraintViolation) -> Self { + #{FieldCreation} + let validation_exception = #{CustomValidationException} { + $validationMessageName: #{ValidationMessage}, + #{FieldListAssignment} + #{AdditionalFieldAssignments} + }; + Self::ConstraintViolation( + crate::protocol_serde::$shapeModuleName::ser_${shapeFunctionName}_error(&validation_exception) + .expect("validation exceptions should never fail to serialize; please file a bug report under https://github.com/smithy-lang/smithy-rs/issues") + ) + } + } + """, + *preludeScope, + "RequestRejection" to protocol.requestRejection(codegenContext.runtimeConfig), + "CustomValidationException" to codegenContext.symbolProvider.toSymbol(validationException), + "FieldCreation" to + writable { + if (validationFieldList != null) { + rust("""let first_validation_exception_field = constraint_violation.as_validation_exception_field("".to_owned());""") + } + }, + "ValidationMessage" to + writable { + val message = + if (validationFieldList != null && validationFieldMessage != null) { + val validationFieldMessageName = + codegenContext.symbolProvider.toMemberName(validationFieldMessage)!! + if (validationFieldMessage.isOptional) { + """format!("validation error detected. {}", &first_validation_exception_field.$validationFieldMessageName.clone().unwrap_or_default())""" + } else { + """format!("validation error detected. {}", &first_validation_exception_field.$validationFieldMessageName)""" + } + } else { + """format!("validation error detected")""" + } + if (validationMessage.isOptional) { + rust("Some($message)") + } else { + rust(message) + } + }, + "FieldListAssignment" to + writable { + if (validationFieldList != null) { + val fieldName = codegenContext.symbolProvider.toMemberName(validationFieldList)!! + val value = "vec![first_validation_exception_field]" + if (validationFieldList.isOptional) { + rust("$fieldName: Some($value),") + } else { + rust("$fieldName: $value,") + } + } + }, + "AdditionalFieldAssignments" to + writable { + rust( + additionalFields.joinToString { member -> + val memberName = codegenContext.symbolProvider.toMemberName(member)!! + "$memberName: ${defaultFieldAssignment(member)}" + }, + ) + }, + ) + } + } + + override fun stringShapeConstraintViolationImplBlock(stringConstraintsInfo: Collection): Writable { + val validationField = maybeValidationField ?: return writable { } + + return writable { + val fieldAssignments = generateCustomValidationFieldAssignments(validationField) + + rustTemplate( + """ + pub(crate) fn as_validation_exception_field(self, path: #{String}) -> #{CustomValidationExceptionField} { + match self { + #{ValidationExceptionFields} + } + } + """, + *preludeScope, + *codegenCtx, + "ValidationExceptionFields" to + writable { + stringConstraintsInfo.forEach { stringTraitInfo -> + when (stringTraitInfo::class.simpleName) { + "Length" -> { + val lengthTrait = + stringTraitInfo::class.java + .getDeclaredField("lengthTrait") + .apply { isAccessible = true } + .get(stringTraitInfo) as LengthTrait + rustTemplate( + """ + Self::Length(length) => #{CustomValidationExceptionField} { + #{FieldAssignments} + }, + """, + *codegenCtx, + "FieldAssignments" to + fieldAssignments( + "path.clone()", + """format!(${ + lengthTrait.validationErrorMessage().dq() + }, length, &path)""", + ), + ) + } + + "Pattern" -> { + val patternTrait = + stringTraitInfo::class.java + .getDeclaredField("patternTrait") + .apply { isAccessible = true } + .get(stringTraitInfo) as PatternTrait + rustTemplate( + """ + Self::Pattern(_) => #{CustomValidationExceptionField} { + #{FieldAssignments} + }, + """, + *codegenCtx, + "FieldAssignments" to + fieldAssignments( + "path.clone()", + """format!(${ + patternTrait.validationErrorMessage().dq() + }, &path, ${patternTrait.pattern.toString().dq()})""", + ), + ) + } + } + } + }, + ) + } + } + + override fun blobShapeConstraintViolationImplBlock(blobConstraintsInfo: Collection): Writable { + val validationField = maybeValidationField ?: return writable { } + + return writable { + rustTemplate( + """ + pub(crate) fn as_validation_exception_field(self, path: #{String}) -> #{CustomValidationExceptionField} { + match self { + #{ValidationExceptionFields} + } + } + """, + *preludeScope, + *codegenCtx, + "ValidationExceptionFields" to + writable { + val fieldAssignments = generateCustomValidationFieldAssignments(validationField) + blobConstraintsInfo.forEach { blobLength -> + rustTemplate( + """ + Self::Length(length) => #{CustomValidationExceptionField} { + #{FieldAssignments} + }, + """, + *codegenCtx, + "FieldAssignments" to + fieldAssignments( + "path.clone()", + """format!(${ + blobLength.lengthTrait.validationErrorMessage().dq() + }, length, &path)""", + ), + ) + } + }, + ) + } + } + + override fun mapShapeConstraintViolationImplBlock( + shape: MapShape, + keyShape: StringShape, + valueShape: Shape, + symbolProvider: RustSymbolProvider, + model: Model, + ): Writable { + val validationField = maybeValidationField ?: return writable { } + + return writable { + val fieldAssignments = generateCustomValidationFieldAssignments(validationField) + + rustBlockTemplate( + "pub(crate) fn as_validation_exception_field(self, path: #{String}) -> #{CustomValidationExceptionField}", + *preludeScope, + *codegenCtx, + ) { + rustBlock("match self") { + shape.getTrait()?.also { + rustTemplate( + """ + Self::Length(length) => #{CustomValidationExceptionField} { + #{FieldAssignments} + },""", + *codegenCtx, + "FieldAssignments" to + fieldAssignments( + "path.clone()", + """format!(${it.validationErrorMessage().dq()}, length, &path)""", + ), + ) + } + if (isKeyConstrained(keyShape, symbolProvider)) { + rust("""Self::Key(key_constraint_violation) => key_constraint_violation.as_validation_exception_field(path),""") + } + if (isValueConstrained(valueShape, model, symbolProvider)) { + rust("""Self::Value(key, value_constraint_violation) => value_constraint_violation.as_validation_exception_field(path + "/" + key.as_str()),""") + } + } + } + } + } + + override fun enumShapeConstraintViolationImplBlock(enumTrait: EnumTrait): Writable { + val validationField = maybeValidationField ?: return writable { } + + return writable { + val fieldAssignments = generateCustomValidationFieldAssignments(validationField) + val message = enumTrait.validationErrorMessage() + + rustTemplate( + """ + pub(crate) fn as_validation_exception_field(self, path: #{String}) -> #{CustomValidationExceptionField} { + #{CustomValidationExceptionField} { + #{FieldAssignments} + } + } + """, + *preludeScope, + *codegenCtx, + "FieldAssignments" to fieldAssignments("path.clone()", """format!(r##"$message"##, &path)"""), + ) + } + } + + override fun numberShapeConstraintViolationImplBlock(rangeInfo: Range): Writable { + val validationField = maybeValidationField ?: return writable { } + + return writable { + val fieldAssignments = generateCustomValidationFieldAssignments(validationField) + + rustTemplate( + """ + pub(crate) fn as_validation_exception_field(self, path: #{String}) -> #{CustomValidationExceptionField} { + match self { + Self::Range(_) => #{CustomValidationExceptionField} { + #{FieldAssignments} + }, + } + } + """, + *preludeScope, + *codegenCtx, + "FieldAssignments" to + fieldAssignments( + "path.clone()", + """format!(${rangeInfo.rangeTrait.validationErrorMessage().dq()}, &path)""", + ), + ) + } + } + + override fun builderConstraintViolationFn(constraintViolations: Collection): Writable { + val validationField = maybeValidationField ?: return writable { } + + return writable { + val fieldAssignments = generateCustomValidationFieldAssignments(validationField) + + rustBlockTemplate( + "pub(crate) fn as_validation_exception_field(self, path: #{String}) -> #{CustomValidationExceptionField}", + *preludeScope, + *codegenCtx, + ) { + rustBlock("match self") { + constraintViolations.forEach { + if (it.hasInner()) { + rust("""ConstraintViolation::${it.name()}(inner) => inner.as_validation_exception_field(path + "/${it.forMember.memberName}"),""") + } else { + rustTemplate( + """ + ConstraintViolation::${it.name()} => #{CustomValidationExceptionField} { + #{FieldAssignments} + }, + """.trimIndent(), + *codegenCtx, + "FieldAssignments" to + fieldAssignments( + """path.clone() + "/${it.forMember.memberName}"""", + """format!("Value at '{}/${it.forMember.memberName}' failed to satisfy constraint: Member must not be null", path)""", + ), + ) + } + } + } + } + } + } + + override fun collectionShapeConstraintViolationImplBlock( + collectionConstraintsInfo: Collection, + isMemberConstrained: Boolean, + ): Writable { + val validationField = maybeValidationField ?: return writable { } + + return writable { + val fieldAssignments = generateCustomValidationFieldAssignments(validationField) + + rustTemplate( + """ + pub(crate) fn as_validation_exception_field(self, path: #{String}) -> #{CustomValidationExceptionField} { + match self { + #{ValidationExceptionFields} + } + } + """, + *preludeScope, + *codegenCtx, + "ValidationExceptionFields" to + writable { + collectionConstraintsInfo.forEach { collectionTraitInfo -> + when (collectionTraitInfo) { + is CollectionTraitInfo.Length -> { + rustTemplate( + """ + Self::Length(length) => #{CustomValidationExceptionField} { + #{FieldAssignments} + }, + """, + *codegenCtx, + "FieldAssignments" to + fieldAssignments( + "path.clone()", + """format!(${ + collectionTraitInfo.lengthTrait.validationErrorMessage() + .dq() + }, length, &path)""", + ), + ) + } + + is CollectionTraitInfo.UniqueItems -> { + rustTemplate( + """ + Self::UniqueItems { duplicate_indices, .. } => #{CustomValidationExceptionField} { + #{FieldAssignments} + }, + """, + *codegenCtx, + "FieldAssignments" to + fieldAssignments( + "path.clone()", + """format!(${ + collectionTraitInfo.uniqueItemsTrait.validationErrorMessage() + .dq() + }, &duplicate_indices, &path)""", + ), + ) + } + } + } + + if (isMemberConstrained) { + rust( + """Self::Member(index, member_constraint_violation) => + member_constraint_violation.as_validation_exception_field(path + "/" + &index.to_string()) + """, + ) + } + }, + ) + } + } + + override fun unionShapeConstraintViolationImplBlock( + unionConstraintTraitInfo: Collection, + ): Writable { + val validationField = maybeValidationField ?: return writable { } + + return writable { + rustBlockTemplate( + "pub(crate) fn as_validation_exception_field(self, path: #{String}) -> #{CustomValidationExceptionField}", + *preludeScope, + *codegenCtx, + ) { + withBlock("match self {", "}") { + for (constraintViolation in unionConstraintTraitInfo) { + rust("""Self::${constraintViolation.name()}(inner) => inner.as_validation_exception_field(path + "/${constraintViolation.forMember.memberName}"),""") + } + } + } + } + } + + /** + * Helper function to generate field assignments for custom validation exception fields + */ + private fun generateCustomValidationFieldAssignments( + customValidationExceptionField: StructureShape, + ): (String, String) -> Writable = + { rawPathExpression: String, rawMessageExpression: String -> + writable { + rustTemplate( + customValidationExceptionField.members().joinToString(",") { member -> + val memberName = codegenContext.symbolProvider.toMemberName(member) + val pathExpression = + if (member.isOptional) "Some($rawPathExpression)" else rawPathExpression + val messageExpression = + if (member.isOptional) "Some($rawMessageExpression)" else rawMessageExpression + when { + member.hasTrait(ValidationFieldNameTrait.ID) -> + "$memberName: $pathExpression" + + member.hasTrait(ValidationFieldMessageTrait.ID) -> + "$memberName: $messageExpression" + + else -> { + "$memberName: ${defaultFieldAssignment(member)}" + } + } + }, + ) + } + } + + private fun defaultFieldAssignment(member: MemberShape): String { + val targetShape = member.targetOrSelf(codegenContext.model) + return member.getTrait()?.toNode()?.let { node -> + when { + targetShape.isEnumShape && node.isStringNode -> { + val enumShape = targetShape.asEnumShape().get() + val enumSymbol = codegenContext.symbolProvider.toSymbol(targetShape) + val enumValue = node.expectStringNode().value + val enumMember = + enumShape.members().find { enumMember -> + enumMember.getTrait()?.stringValue?.orElse( + enumMember.memberName, + ) == enumValue + } + val variantName = enumMember?.let { codegenContext.symbolProvider.toMemberName(it) } ?: enumValue + "$enumSymbol::$variantName" + } + + node.isStringNode -> """"${node.expectStringNode().value}".to_string()""" + node.isBooleanNode -> node.expectBooleanNode().value.toString() + node.isNumberNode -> node.expectNumberNode().value.toString() + else -> "Default::default()" + } + } ?: "Default::default()" + } +} diff --git a/codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/customize/ServerCodegenDecorator.kt b/codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/customize/ServerCodegenDecorator.kt index 5bd79ed7a06..6e23bbd6787 100644 --- a/codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/customize/ServerCodegenDecorator.kt +++ b/codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/customize/ServerCodegenDecorator.kt @@ -41,6 +41,13 @@ interface ServerCodegenDecorator : CoreCodegenDecorator) : decorator.postprocessValidationExceptionNotAttachedErrorMessage(accumulated) } + override fun postprocessMultipleValidationExceptionsErrorMessage( + validationResult: ValidationResult, + ): ValidationResult = + orderedDecorators.foldRight(validationResult) { decorator, accumulated -> + decorator.postprocessMultipleValidationExceptionsErrorMessage(accumulated) + } + override fun postprocessOperationGenerateAdditionalStructures( operationShape: OperationShape, ): List = diff --git a/codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/util/CustomValidationExceptionUtil.kt b/codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/util/CustomValidationExceptionUtil.kt new file mode 100644 index 00000000000..2ab79d96f6f --- /dev/null +++ b/codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/util/CustomValidationExceptionUtil.kt @@ -0,0 +1,17 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.rust.codegen.server.smithy.util + +import software.amazon.smithy.model.shapes.MemberShape +import software.amazon.smithy.rust.codegen.traits.ValidationMessageTrait + +/** + * Helper function to determine if this [MemberShape] is a validation message either explicitly with the + * @validationMessage trait or implicitly because it is named "message" + */ +fun MemberShape.isValidationMessage(): Boolean { + return this.hasTrait(ValidationMessageTrait.ID) || this.memberName == "message" +} diff --git a/codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/validators/CustomValidationExceptionValidator.kt b/codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/validators/CustomValidationExceptionValidator.kt new file mode 100644 index 00000000000..4a5bb07ed75 --- /dev/null +++ b/codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/validators/CustomValidationExceptionValidator.kt @@ -0,0 +1,118 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.rust.codegen.server.smithy.validators + +import software.amazon.smithy.model.Model +import software.amazon.smithy.model.shapes.Shape +import software.amazon.smithy.model.shapes.ShapeType +import software.amazon.smithy.model.shapes.StructureShape +import software.amazon.smithy.model.traits.DefaultTrait +import software.amazon.smithy.model.traits.ErrorTrait +import software.amazon.smithy.model.validation.AbstractValidator +import software.amazon.smithy.model.validation.Severity +import software.amazon.smithy.model.validation.ValidationEvent +import software.amazon.smithy.rust.codegen.core.util.hasTrait +import software.amazon.smithy.rust.codegen.core.util.targetOrSelf +import software.amazon.smithy.rust.codegen.server.smithy.canReachConstrainedShapeForValidation +import software.amazon.smithy.rust.codegen.server.smithy.isDirectlyConstrainedForValidation +import software.amazon.smithy.rust.codegen.server.smithy.util.isValidationMessage +import software.amazon.smithy.rust.codegen.traits.ValidationExceptionTrait + +class CustomValidationExceptionValidator : AbstractValidator() { + override fun validate(model: Model): List { + val events = mutableListOf() + + model.shapes(StructureShape::class.java).filter { it.hasTrait(ValidationExceptionTrait.ID) } + .forEach { shape -> + // Validate that the shape also has @error trait + if (!shape.hasTrait(ErrorTrait::class.java)) { + events.add( + ValidationEvent.builder().id("CustomValidationException.MissingErrorTrait") + .severity(Severity.ERROR).shape(shape) + .message("@validationException requires @error trait") + .build(), + ) + } + + // Validate exactly one member with @validationMessage trait (explicit) or named "message" (implicit) + val messageFields = + shape.members().filter { it.isValidationMessage() } + + when (messageFields.size) { + 0 -> + events.add( + ValidationEvent.builder().id("CustomValidationException.MissingMessageField") + .severity(Severity.ERROR).shape(shape) + .message( + "@validationException requires exactly one String member named " + + "\"message\" or with the @validationMessage trait", + ).build(), + ) + + 1 -> { + val validationMessageField = messageFields.first() + if (!model.expectShape(validationMessageField.target).isStringShape) { + events.add( + ValidationEvent.builder().id("CustomValidationException.NonStringMessageField") + .severity(Severity.ERROR).shape(shape) + .message("@validationMessage field must be a String").build(), + ) + } + } + + else -> + events.add( + ValidationEvent.builder().id("CustomValidationException.MultipleMessageFields") + .severity(Severity.ERROR).shape(shape) + .message( + "@validationException can have only one member explicitly marked with the" + + "@validationMessage trait or implicitly selected via the \"message\" field name convention.", + ).build(), + ) + } + + // Validate default constructibility if it contains constrained shapes + if (shape.canReachConstrainedShapeForValidation(model)) { + shape.members().forEach { member -> member.validateDefaultConstructibility(model, events) } + } + } + + return events + } + + /** Validate default constructibility of the shape + * When a validation exception occurs, the framework has to create a Rust type that represents + * the ValidationException structure, but if that structure has fields other than 'message' and + * 'field list', then it can't instantiate them if they don't have defaults. Later on, we will introduce + * a mechanism for service code to be able to participate in construction of a validation exception type. + * Until that time, we need to restrict this to default constructibility. + */ + private fun Shape.validateDefaultConstructibility( + model: Model, + events: MutableList, + ) { + when (this.type) { + ShapeType.STRUCTURE -> { + this.members().forEach { member -> member.validateDefaultConstructibility(model, events) } + } + + ShapeType.MEMBER -> { + // We want to check if the member's target is constrained. If so, we want the default trait to be on the + // member. + if (this.targetOrSelf(model).isDirectlyConstrainedForValidation() && !this.hasTrait()) { + events.add( + ValidationEvent.builder().id("CustomValidationException.MissingDefault") + .severity(Severity.ERROR) + .message("$this must be default constructible") + .build(), + ) + } + } + + else -> return + } + } +} diff --git a/codegen-server/src/main/resources/META-INF/services/software.amazon.smithy.model.validation.Validator b/codegen-server/src/main/resources/META-INF/services/software.amazon.smithy.model.validation.Validator index 753c72d1cec..7addb248931 100644 --- a/codegen-server/src/main/resources/META-INF/services/software.amazon.smithy.model.validation.Validator +++ b/codegen-server/src/main/resources/META-INF/services/software.amazon.smithy.model.validation.Validator @@ -3,3 +3,4 @@ # SPDX-License-Identifier: Apache-2.0 # software.amazon.smithy.rust.codegen.server.smithy.PatternTraitEscapedSpecialCharsValidator +software.amazon.smithy.rust.codegen.server.smithy.validators.CustomValidationExceptionValidator diff --git a/codegen-server/src/test/kotlin/software/amazon/smithy/rust/codegen/server/smithy/ValidateUnsupportedConstraintsAreNotUsedTest.kt b/codegen-server/src/test/kotlin/software/amazon/smithy/rust/codegen/server/smithy/ValidateUnsupportedConstraintsAreNotUsedTest.kt index 15d093ff21e..b7ae6a0e3a8 100644 --- a/codegen-server/src/test/kotlin/software/amazon/smithy/rust/codegen/server/smithy/ValidateUnsupportedConstraintsAreNotUsedTest.kt +++ b/codegen-server/src/test/kotlin/software/amazon/smithy/rust/codegen/server/smithy/ValidateUnsupportedConstraintsAreNotUsedTest.kt @@ -69,7 +69,7 @@ internal class ValidateUnsupportedConstraintsAreNotUsedTest { // Asserts the exact message, to ensure the formatting is appropriate. validationResult.messages[0].message shouldBe """ - Operation test#TestOperation takes in input that is constrained (https://awslabs.github.io/smithy/2.0/spec/constraint-traits.html), and as such can fail with a validation exception. You must model this behavior in the operation shape in your model file. + Operation test#TestOperation takes in input that is constrained (https://awslabs.github.io/smithy/2.0/spec/constraint-traits.html), and as such can fail with a validation exception. You must model this behavior in the operation shape in your model file using the default validation exception shown below, or by defining a custom validation exception. For documentation, see https://smithy-lang.github.io/smithy-rs/design/server/validation_exceptions.html ```smithy use smithy.framework#ValidationException @@ -81,6 +81,56 @@ internal class ValidateUnsupportedConstraintsAreNotUsedTest { """.trimIndent() } + @Test + fun `it should work when an operation with constrained input has a custom validation exception attached in errors`() { + val version = "\$version: \"2\"" + val model = + """ + $version + namespace test + + use smithy.rust.codegen.traits#validationException + use smithy.rust.codegen.traits#validationMessage + + service TestService { + operations: [TestOperation] + } + + operation TestOperation { + input: TestInputOutput, + output: TestInputOutput, + errors: [ + CustomValidationException + ] + } + + structure TestInputOutput { + @required + requiredString: String + } + + @validationException + @error("client") + structure CustomValidationException { + @validationMessage + @default("Validation Failed") + @required + message: String, + + errorCode: String, + } + """.asSmithyModel() + val service = model.lookup("test#TestService") + val validationResult = + validateOperationsWithConstrainedInputHaveValidationExceptionAttached( + model, + service, + SmithyValidationExceptionConversionGenerator.SHAPE_ID, + ) + + validationResult.messages shouldHaveSize 0 + } + @Test fun `should detect when event streams are used, even without constraints, as the event member is required`() { val model = @@ -114,7 +164,7 @@ internal class ValidateUnsupportedConstraintsAreNotUsedTest { // Asserts the exact message, to ensure the formatting is appropriate. validationResult.messages[0].message shouldBe """ - Operation test#TestOperation takes in input that is constrained (https://awslabs.github.io/smithy/2.0/spec/constraint-traits.html), and as such can fail with a validation exception. You must model this behavior in the operation shape in your model file. + Operation test#TestOperation takes in input that is constrained (https://awslabs.github.io/smithy/2.0/spec/constraint-traits.html), and as such can fail with a validation exception. You must model this behavior in the operation shape in your model file using the default validation exception shown below, or by defining a custom validation exception. For documentation, see https://smithy-lang.github.io/smithy-rs/design/server/validation_exceptions.html ```smithy use smithy.framework#ValidationException @@ -229,7 +279,7 @@ internal class ValidateUnsupportedConstraintsAreNotUsedTest { validationResult.messages shouldHaveSize 1 validationResult.shouldAbort shouldBe true - validationResult.messages[0].message shouldContain( + validationResult.messages[0].message shouldContain ( """ The map shape `test#Map` is reachable from the list shape `test#UniqueItemsList`, which has the `@uniqueItems` trait attached. @@ -314,11 +364,135 @@ internal class ValidateUnsupportedConstraintsAreNotUsedTest { validationResult.messages shouldHaveSize 1 validationResult.shouldAbort shouldBe true - validationResult.messages[0].message shouldContain( + validationResult.messages[0].message shouldContain ( """ The `ignoreUnsupportedConstraints` flag in the `codegen` configuration is set to `true`, but it has no effect. All the constraint traits used in the model are well-supported, please remove this flag. """.trimIndent().replace("\n", " ") ) } + + @Test + fun `it should detect multiple validation exceptions in model`() { + val model = + """ + namespace test + + use smithy.rust.codegen.traits#validationException + use smithy.rust.codegen.traits#validationMessage + + service TestService { + operations: [TestOperation] + } + + operation TestOperation { + input: TestInputOutput, + output: TestInputOutput, + } + + @validationException + @error("client") + structure CustomValidationException { + @validationMessage + message: String, + } + + @validationException + @error("client") + structure AnotherValidationException { + @validationMessage + message: String, + } + + structure TestInputOutput { + @length(min: 1, max: 69) + lengthString: String, + } + """.asSmithyModel() + val service = model.serviceShapes.first() + val validationResult = validateModelHasAtMostOneValidationException(model, service) + + validationResult.shouldAbort shouldBe true + validationResult.messages shouldHaveSize 1 + validationResult.messages[0].level shouldBe Level.SEVERE + } + + @Test + fun `it should allow single validation exception in model`() { + val model = + """ + namespace test + + use smithy.rust.codegen.traits#validationException + use smithy.rust.codegen.traits#validationMessage + + service TestService { + operations: [TestOperation] + } + + operation TestOperation { + input: TestInputOutput, + output: TestInputOutput, + } + + @validationException + @error("client") + structure CustomValidationException { + @validationMessage + message: String, + } + + structure TestInputOutput { + @length(min: 1, max: 69) + lengthString: String, + } + """.asSmithyModel() + val service = model.serviceShapes.first() + val validationResult = validateModelHasAtMostOneValidationException(model, service) + + validationResult.shouldAbort shouldBe false + validationResult.messages shouldHaveSize 0 + } + + @Test + fun `it should detect default validation exception in operation when custom validation exception is defined`() { + val model = + """ + namespace test + + use smithy.framework#ValidationException + use smithy.rust.codegen.traits#validationException + use smithy.rust.codegen.traits#validationMessage + + service TestService { + operations: [TestOperation] + } + + operation TestOperation { + input: TestInputOutput, + output: TestInputOutput, + errors: [ + ValidationException + ] + } + + @validationException + @error("client") + structure CustomValidationException { + @validationMessage + message: String, + } + + structure TestInputOutput { + @length(min: 1, max: 69) + lengthString: String, + } + """.asSmithyModel() + val service = model.serviceShapes.first() + val validationResult = validateModelHasAtMostOneValidationException(model, service) + + validationResult.shouldAbort shouldBe true + validationResult.messages shouldHaveSize 1 + validationResult.messages[0].level shouldBe Level.SEVERE + } } diff --git a/codegen-server/src/test/kotlin/software/amazon/smithy/rust/codegen/server/smithy/customizations/CustomValidationExceptionDecoratorTest.kt b/codegen-server/src/test/kotlin/software/amazon/smithy/rust/codegen/server/smithy/customizations/CustomValidationExceptionDecoratorTest.kt new file mode 100644 index 00000000000..2e2d18be72b --- /dev/null +++ b/codegen-server/src/test/kotlin/software/amazon/smithy/rust/codegen/server/smithy/customizations/CustomValidationExceptionDecoratorTest.kt @@ -0,0 +1,359 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.rust.codegen.server.smithy.customizations + +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import org.junit.jupiter.api.Test +import software.amazon.smithy.model.Model +import software.amazon.smithy.model.shapes.ShapeId +import software.amazon.smithy.model.shapes.StructureShape +import software.amazon.smithy.rust.codegen.core.rustlang.rust +import software.amazon.smithy.rust.codegen.core.testutil.asSmithyModel +import software.amazon.smithy.rust.codegen.server.smithy.testutil.serverIntegrationTest +import software.amazon.smithy.rust.codegen.server.smithy.testutil.serverTestCodegenContext +import software.amazon.smithy.rust.codegen.traits.ValidationExceptionTrait +import software.amazon.smithy.rust.codegen.traits.ValidationFieldListTrait +import software.amazon.smithy.rust.codegen.traits.ValidationFieldNameTrait +import software.amazon.smithy.rust.codegen.traits.ValidationMessageTrait + +internal class CustomValidationExceptionDecoratorTest { + private val modelWithCustomValidation = + """ + namespace com.example + + use aws.protocols#restJson1 + use smithy.rust.codegen.traits#validationException + use smithy.rust.codegen.traits#validationMessage + use smithy.rust.codegen.traits#validationFieldList + use smithy.rust.codegen.traits#validationFieldName + + @restJson1 + service TestService { + version: "1.0.0" + } + + @validationException + @error("client") + structure MyValidationException { + @validationMessage + customMessage: String + + @validationFieldList + customFieldList: ValidationExceptionFieldList + } + + structure ValidationExceptionField { + @validationFieldName + path: String + message: String + } + + list ValidationExceptionFieldList { + member: ValidationExceptionField + } + """.asSmithyModel(smithyVersion = "2.0") + + private val modelWithoutFieldList = + """ + namespace com.example + + use aws.protocols#restJson1 + use smithy.rust.codegen.traits#validationException + use smithy.rust.codegen.traits#validationMessage + + @restJson1 + service TestService { + version: "1.0.0" + } + + @validationException + @error("client") + structure MyValidationException { + @validationMessage + message: String + } + """.asSmithyModel(smithyVersion = "2.0") + + private fun mockValidationException(model: Model): StructureShape { + val codegenContext = serverTestCodegenContext(model) + val decorator = UserProvidedValidationExceptionDecorator() + return decorator.customValidationException(codegenContext)!! + } + + @Test + fun `customValidationException returns correct shape`() { + val result = mockValidationException(modelWithCustomValidation) + + result shouldNotBe null + result.id shouldBe ShapeId.from("com.example#MyValidationException") + result.hasTrait(ValidationExceptionTrait.ID) shouldBe true + } + + @Test + fun `customValidationException returns null when no validation exception exists`() { + val model = + """ + namespace com.example + + use aws.protocols#restJson1 + + @restJson1 + service TestService { + version: "1.0.0" + } + + structure RegularException { message: String } + """.asSmithyModel(smithyVersion = "2.0") + + val codegenContext = serverTestCodegenContext(model) + val decorator = UserProvidedValidationExceptionDecorator() + + val result = decorator.customValidationException(codegenContext) + + result shouldBe null + } + + @Test + fun `customValidationMessage returns correct member shape`() { + val model = modelWithCustomValidation + val codegenContext = serverTestCodegenContext(model) + val generator = + UserProvidedValidationExceptionConversionGenerator(codegenContext, mockValidationException(model)) + + val result = generator.customValidationMessage() + + result shouldNotBe null + result.memberName shouldBe "customMessage" + result.hasTrait(ValidationMessageTrait.ID) shouldBe true + } + + @Test + fun `customValidationFieldList returns correct member shape`() { + val model = modelWithCustomValidation + val codegenContext = serverTestCodegenContext(model) + val generator = + UserProvidedValidationExceptionConversionGenerator(codegenContext, mockValidationException(model)) + + val result = generator.customValidationFieldList() + + result shouldNotBe null + result!!.memberName shouldBe "customFieldList" + result.hasTrait(ValidationFieldListTrait.ID) shouldBe true + } + + @Test + fun `customValidationFieldList returns null when no field list exists`() { + val model = modelWithoutFieldList + val codegenContext = serverTestCodegenContext(model) + val generator = + UserProvidedValidationExceptionConversionGenerator(codegenContext, mockValidationException(model)) + + val result = generator.customValidationFieldList() + + result shouldBe null + } + + @Test + fun `customValidationExceptionField returns correct structure shape`() { + val model = modelWithCustomValidation + val codegenContext = serverTestCodegenContext(model) + val generator = + UserProvidedValidationExceptionConversionGenerator(codegenContext, mockValidationException(model)) + + val result = generator.customValidationField() + + result shouldNotBe null + result!!.id shouldBe ShapeId.from("com.example#ValidationExceptionField") + result.members().any { it.hasTrait(ValidationFieldNameTrait.ID) } shouldBe true + } + + @Test + fun `customValidationExceptionField returns null when no field list exists`() { + val model = modelWithoutFieldList + val codegenContext = serverTestCodegenContext(model) + val generator = + UserProvidedValidationExceptionConversionGenerator(codegenContext, mockValidationException(model)) + + val result = generator.customValidationField() + + result shouldBe null + } + + @Test + fun `decorator returns null when no custom validation exception exists`() { + val model = + """ + namespace com.example + + use aws.protocols#restJson1 + + @restJson1 + service TestService { + version: "1.0.0" + } + + structure RegularException { message: String } + """.asSmithyModel(smithyVersion = "2.0") + + val codegenContext = serverTestCodegenContext(model) + val decorator = UserProvidedValidationExceptionDecorator() + + val generator = decorator.validationExceptionConversion(codegenContext) + + generator shouldBe null + } + + private val completeTestModel = + """ + namespace com.aws.example + + use aws.protocols#restJson1 + use smithy.rust.codegen.traits#validationException + use smithy.rust.codegen.traits#validationFieldList + use smithy.rust.codegen.traits#validationFieldMessage + use smithy.rust.codegen.traits#validationFieldName + use smithy.rust.codegen.traits#validationMessage + + @restJson1 + service CustomValidationExample { + version: "1.0.0" + operations: [ + TestOperation + ] + errors: [ + MyCustomValidationException + ] + } + + @http(method: "POST", uri: "/test") + operation TestOperation { + input: TestInput + } + + structure TestInput { + @required + @length(min: 1, max: 10) + name: String + + @range(min: 1, max: 100) + age: Integer + } + + @error("client") + @httpError(400) + @validationException + structure MyCustomValidationException { + @required + @validationMessage + customMessage: String + + @required + @default("testReason1") + reason: ValidationExceptionReason + + @validationFieldList + customFieldList: CustomValidationFieldList + } + + enum ValidationExceptionReason { + TEST_REASON_0 = "testReason0" + TEST_REASON_1 = "testReason1" + } + + structure CustomValidationField { + @required + @validationFieldName + customFieldName: String + + @required + @validationFieldMessage + customFieldMessage: String + } + + list CustomValidationFieldList { + member: CustomValidationField + } + """.asSmithyModel(smithyVersion = "2.0") + + @Test + fun `code compiles with custom validation exception`() { + serverIntegrationTest(completeTestModel) + } + + private val completeTestModelWithOptionals = + """ + namespace com.aws.example + + use aws.protocols#restJson1 + use smithy.rust.codegen.traits#validationException + use smithy.rust.codegen.traits#validationFieldList + use smithy.rust.codegen.traits#validationFieldMessage + use smithy.rust.codegen.traits#validationFieldName + use smithy.rust.codegen.traits#validationMessage + + @restJson1 + service CustomValidationExample { + version: "1.0.0" + operations: [ + TestOperation + ] + errors: [ + MyCustomValidationException + ] + } + + @http(method: "POST", uri: "/test") + operation TestOperation { + input: TestInput + } + + structure TestInput { + @required + @length(min: 1, max: 10) + name: String + + @range(min: 1, max: 100) + age: Integer + } + + @error("client") + @httpError(400) + @validationException + structure MyCustomValidationException { + @validationMessage + customMessage: String + + @default("testReason1") + reason: ValidationExceptionReason + + @validationFieldList + customFieldList: CustomValidationFieldList + } + + enum ValidationExceptionReason { + TEST_REASON_0 = "testReason0" + TEST_REASON_1 = "testReason1" + } + + structure CustomValidationField { + @validationFieldName + customFieldName: String + + @validationFieldMessage + customFieldMessage: String + } + + list CustomValidationFieldList { + member: CustomValidationField + } + """.asSmithyModel(smithyVersion = "2.0") + + @Test + fun `code compiles with custom validation exception using optionals`() { + serverIntegrationTest(completeTestModelWithOptionals) + } +} diff --git a/codegen-server/src/test/kotlin/software/amazon/smithy/rust/codegen/server/smithy/validators/CustomValidationExceptionValidatorTest.kt b/codegen-server/src/test/kotlin/software/amazon/smithy/rust/codegen/server/smithy/validators/CustomValidationExceptionValidatorTest.kt new file mode 100644 index 00000000000..a42e7532296 --- /dev/null +++ b/codegen-server/src/test/kotlin/software/amazon/smithy/rust/codegen/server/smithy/validators/CustomValidationExceptionValidatorTest.kt @@ -0,0 +1,178 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.rust.codegen.server.smithy.validators + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test +import software.amazon.smithy.model.shapes.ShapeId +import software.amazon.smithy.model.validation.Severity +import software.amazon.smithy.model.validation.ValidatedResultException +import software.amazon.smithy.rust.codegen.core.testutil.asSmithyModel + +class CustomValidationExceptionValidatorTest { + @Test + fun `should error when validationException lacks error trait`() { + val exception = + shouldThrow { + """ + namespace test + use smithy.rust.codegen.traits#validationException + use smithy.rust.codegen.traits#validationMessage + + @validationException + structure ValidationError { + @validationMessage + message: String + } + """.asSmithyModel(smithyVersion = "2") + } + val events = exception.validationEvents.filter { it.severity == Severity.ERROR } + + events shouldHaveSize 1 + events[0].shapeId.get() shouldBe ShapeId.from("test#ValidationError") + events[0].id shouldBe "CustomValidationException.MissingErrorTrait" + } + + @Test + fun `should error when validationException has no validationMessage field`() { + val exception = + shouldThrow { + """ + namespace test + use smithy.rust.codegen.traits#validationException + + @validationException + @error("client") + structure ValidationError { + code: String + } + """.asSmithyModel(smithyVersion = "2") + } + val events = exception.validationEvents.filter { it.severity == Severity.ERROR } + + events shouldHaveSize 1 + events[0].shapeId.get() shouldBe ShapeId.from("test#ValidationError") + events[0].id shouldBe "CustomValidationException.MissingMessageField" + } + + @Test + fun `should error when validationException has multiple explicit validationMessage fields`() { + val exception = + shouldThrow { + """ + namespace test + use smithy.rust.codegen.traits#validationException + use smithy.rust.codegen.traits#validationMessage + + @validationException + @error("client") + structure ValidationError { + @validationMessage + message: String, + @validationMessage + details: String + } + """.asSmithyModel(smithyVersion = "2") + } + val events = exception.validationEvents.filter { it.severity == Severity.ERROR } + + events shouldHaveSize 1 + events[0].shapeId.get() shouldBe ShapeId.from("test#ValidationError") + events[0].id shouldBe "CustomValidationException.MultipleMessageFields" + } + + @Test + fun `should error when validationException has explicit validationMessage and implicit message fields`() { + val exception = + shouldThrow { + """ + namespace test + use smithy.rust.codegen.traits#validationException + use smithy.rust.codegen.traits#validationMessage + + @validationException + @error("client") + structure ValidationError { + message: String, + @validationMessage + details: String, + } + """.asSmithyModel(smithyVersion = "2") + } + val events = exception.validationEvents.filter { it.severity == Severity.ERROR } + + events shouldHaveSize 1 + events[0].shapeId.get() shouldBe ShapeId.from("test#ValidationError") + events[0].id shouldBe "CustomValidationException.MultipleMessageFields" + } + + @Test + fun `should error when constrained shape lacks default trait`() { + val exception = + shouldThrow { + """ + namespace test + use smithy.rust.codegen.traits#validationException + use smithy.rust.codegen.traits#validationMessage + + @validationException + @error("client") + structure ValidationError { + @validationMessage + message: String, + constrainedField: ConstrainedString + } + + @length(min: 1, max: 10) + string ConstrainedString + """.asSmithyModel(smithyVersion = "2") + } + val events = exception.validationEvents.filter { it.severity == Severity.ERROR } + + events shouldHaveSize 1 + events[0].id shouldBe "CustomValidationException.MissingDefault" + } + + @Test + fun `should pass validation for properly configured validationException`() { + """ + namespace test + use smithy.rust.codegen.traits#validationException + use smithy.rust.codegen.traits#validationMessage + + @validationException + @error("client") + structure ValidationError { + @validationMessage + message: String + } + """.asSmithyModel(smithyVersion = "2") + } + + @Test + fun `should pass validation for validationException with constrained shape having default`() { + """ + namespace test + use smithy.rust.codegen.traits#validationException + use smithy.rust.codegen.traits#validationMessage + + @validationException + @error("client") + structure ValidationError { + @validationMessage + message: String, + @default("default") + constrainedField: ConstrainedString + } + + @length(min: 1, max: 10) + @default("default") + string ConstrainedString + """.asSmithyModel(smithyVersion = "2") + } +} diff --git a/design/src/server/overview.md b/design/src/server/overview.md index 7592d7278c9..a9659ae79f2 100644 --- a/design/src/server/overview.md +++ b/design/src/server/overview.md @@ -7,3 +7,4 @@ Smithy Rust provides the ability to generate a server whose operations are provi - [Accessing Un-modelled Data](./from_parts.md) - [The Anatomy of a Service](./anatomy.md) - [Generating Common Service Code](./code_generation.md) +- [Validation Exceptions](./validation_exceptions.md) diff --git a/design/src/server/validation_exceptions.md b/design/src/server/validation_exceptions.md new file mode 100644 index 00000000000..cf9b1a9ba9f --- /dev/null +++ b/design/src/server/validation_exceptions.md @@ -0,0 +1,244 @@ +# Validation Exceptions + +## Terminology + +- **Constrained shape**: a shape that is either: + - a shape with a [constraint trait](https://smithy.io/2.0/spec/constraint-traits.html) attached + - a (member) shape with a [`required` trait](https://smithy.io/2.0/spec/type-refinement-traits.html#required-trait) attached + - an [`enum`](https://smithy.io/2.0/spec/simple-types.html#enum) shape + - an [`intEnum`](https://smithy.io/2.0/spec/simple-types.html#intenum) shape + - a [`structure shape`](https://smithy.io/2.0/spec/aggregate-types.html#structure) with at least one required member shape; or + - a shape whose closure includes any of the above. +- **ValidationException**: A Smithy error shape that is serialized in the response when constraint validation fails during request processing. +- **Shape closure**: the set of shapes a shape can "reach", including itself. +- **Custom validation exception**: A user-defined error shape marked with validation-specific traits that replaces the standard smithy.framework#ValidationException. + +If an operation takes an input that is constrained, it can fail with a validation exception. +In these cases, you must model this behavior in the operation shape in your model file. + +In the example below, the `GetCity` operation takes a required `cityId`. This means it is a constrained shape, so the validation exception behavior must be modeled. +As such, attempting to build this model will result in a codegen exception explaining this because. + +```smithy +$version: "2" + +namespace example.citylocator + +use aws.protocols#awsJson1_0 + +@awsJson1_0 +service CityLocator { + version: "2006-03-01" + resources: [ + City + ] +} + +resource City { + identifiers: { + cityId: CityId + } + properties: { + coordinates: CityCoordinates + } + read: GetCity +} + +@pattern("^[A-Za-z0-9 ]+$") +string CityId + +structure CityCoordinates { + @required + latitude: Float + + @required + longitude: Float +} + +@readonly +operation GetCity { + input := for City { + // "cityId" provides the identifier for the resource and + // has to be marked as required. + @required + $cityId + } + + output := for City { + // "required" is used on output to indicate if the service + // will always provide a value for the member. + // "notProperty" indicates that top-level input member "name" + // is not bound to any resource property. + @required + @notProperty + name: String + + @required + $coordinates + } + + errors: [ + NoSuchResource + ] +} + +// "error" is a trait that is used to specialize +// a structure as an error. +@error("client") +structure NoSuchResource { + @required + resourceType: String +} +``` + +## Default validation exception + +The typical way forward is to use Smithy's default validation exception. + +This can go per operation error closure, or in the service's error closure to apply to all operations. + +e.g. + +```smithy +use smithy.framework#ValidationException + +... +operation GetCity { + ... + errors: [ + ... + ValidationException + ] +} +``` + +## Custom validation exception + +In certain cases, you may want to define a custom validation exception. Some reasons for this could be: + +- **Backward compatibility**: Migrating existing APIs to Smithy with a requirement of maintaining the existing validation exception format +- **Published APIs**: Already published a Smithy model with validation exception schemas to external consumers and cannot change the response format without breaking clients +- **Custom error handling**: General needs for additional fields or different field names for validation errors + +The following five traits are provided for defining custom validation exceptions. + +- @validationException +- @validationMessage +- @validationFieldList +- @validationFieldName +- @validationFieldMessage + +### User guide + +#### Requirements + +**1. Define a custom validation exception shape** + +Define a custom validation exception by applying the `@validationException` trait to any structure shape that is also marked with the `@error` trait. +```smithy +@validationException +@error("client") +structure CustomValidationException { + // Structure members defined below +} +``` + +**2. Specify the message field (required)** + +The custom validation exception **must** have **exactly one** String member marked with the `@validationMessage` trait to serve as the primary error message. +```smithy +use smithy.rust.codegen.traits#validationException + +@validationException +@error("client") +structure CustomValidationException { + @validationMessage + @required + message: String + + // <... other fields ...> +} +``` + +**3. Default constructibility requirement** +The custom validation exception structure **must** be default constructible. This means the shape either: + +1. **Must not** contain any constrained shapes that the framework cannot construct; or +1. Any constrained shapes **must** have default values specified + +For example, if we have `errorKind` enum member, we must specify the default with `@default()`. Otherwise, the +model will fail to build. +```smithy +@validationException +@error("client") +structure CustomValidationException { + @validationMessage + @required + message: String, + + @default("errorInValidation") <------- must be specified + errorKind: ErrorKind +} + +enum ErrorKind { + ERROR_IN_VALIDATION = "errorInValidation", + SOME_OTHER_ERROR = "someOtherError", +} +``` + +**4. Optional Field List Support** + +Optionally, the custom validation exception **may** include a field marked with `@validationFieldList` to provide detailed information about which fields failed validation. +This **must** be a list shape where the member is a structure shape with detailed field information: + +- **Must** have a String member marked with `@validationFieldName` +- **May** have a String member marked with `@validationFieldMessage` +- Regarding additional fields: + - The structure may have no additional fields beyond those specified above, or + - If additional fields are present, each must be default constructible + +```smithy +@validationException +@error("client") +structure CustomValidationException { + @validationMessage + @required + message: String, + + @validationFieldList + fieldErrors: ValidationFieldList +} + +list ValidationFieldList { + member: ValidationField +} + +structure ValidationField { + @validationFieldName + @required + fieldName: String, + + @validationFieldMessage + @required + errorMessage: String +} +``` + +**5. Using the custom validation exception in operations** + +```smithy +operation GetCity { + ... + errors: [ + ... + CustomValidationException + ] +} +``` + +### Limitations + +It is unsupported to do the following and will result in an error if modeled: + +- Defining multiple custom validation exceptions +- Including the default Smithy validation exception in an error closure if a custom validation exception is defined