Skip to content
Open
Show file tree
Hide file tree
Changes from 52 commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
6baed18
Add `ValidationException` and `ValidationMessage` codegen server traits
jasgin Sep 17, 2025
dedfb07
Add `CustomValidationExceptionValidator`
jasgin Sep 17, 2025
1bec408
Add tests for `CustomValidationExceptionValidator`
jasgin Sep 17, 2025
d11f574
Add remaining custom validation exception traits
jasgin Sep 17, 2025
a625749
Add custom validation exception to constrained input validation
jasgin Sep 18, 2025
33e618a
Add tests for custom validation exception in constrained input valida…
jasgin Sep 18, 2025
7e45bd9
Add parts of codegen for custom validation exception
jasgin Sep 23, 2025
2704437
Figure out custom or default in shared generator
jasgin Sep 24, 2025
b56cdce
Add validation to ensure member with @validationMessage targets String
jasgin Sep 24, 2025
011135a
Add validation for ensuring multiple ValidationExceptions are not inc…
jasgin Sep 24, 2025
7410844
Add validation for ensuring multiple custom ValidationExceptions cann…
jasgin Sep 24, 2025
4867eaf
Fix default constructibility validation
jasgin Sep 25, 2025
3d365d9
Fix custom validation exception codegen
jasgin Sep 28, 2025
8e5928d
Add customValidationFieldMessage handling
jasgin Sep 28, 2025
4d7f2db
Reduce code duplication
jasgin Sep 29, 2025
db56b5c
Add additional field handling
jasgin Sep 29, 2025
06106e4
Fix module to use custom shape serialization
jasgin Sep 29, 2025
3b540c2
Add field handling for custom validation field
jasgin Sep 29, 2025
03c6b3c
Clean up gradle for codegen server traits
jasgin Sep 29, 2025
b2dac07
Add copyright label to top of files
jasgin Sep 29, 2025
52360aa
Remove unrelated formatting
jasgin Sep 29, 2025
fc282f5
Clean up validation function
jasgin Sep 29, 2025
ba25b69
Add validation to detect default ValidationException when custom one …
jasgin Sep 29, 2025
e284704
Factor out default field assignment logic
jasgin Sep 29, 2025
cf07b8c
Add handling for fields being required/optional
jasgin Sep 29, 2025
ca64ff6
Add e2e integration test for custom validation exceptions
jasgin Sep 29, 2025
d6080d7
Fix ValidationExceptionReason usage
jasgin Sep 30, 2025
392da87
Consolidate codegen-server-traits into codegen-server
jasgin Sep 30, 2025
831fda7
Add docs on why we need default constructibility requirement
jasgin Sep 30, 2025
fed9ceb
Update visibility modifiers of custom validation finder methods to be…
jasgin Sep 30, 2025
35cb846
Refactor names to avoid variable shadowing
jasgin Sep 30, 2025
5abc4ae
Remove custom formatting
jasgin Sep 30, 2025
a46e2b3
Update validationException custom trait documentation
jasgin Sep 30, 2025
1e5d794
Update validation error detected message to avoid confusion
jasgin Sep 30, 2025
e1a05e6
Add implicit validation exception message field support
jasgin Sep 30, 2025
9aaad1f
Remove redundant service errors check
jasgin Sep 30, 2025
fbe764a
Remove comment documenting completed work
jasgin Sep 30, 2025
72f6cb1
Elaborate on documentation
jasgin Sep 30, 2025
c4e722c
Fix comments for updated ValidationException constraint
jasgin Sep 30, 2025
4278d54
Add changelog
jasgin Sep 30, 2025
124a53c
Fix order of CustomValidationExceptionDecorator to take precendence o…
jasgin Sep 30, 2025
7584771
Apply idiomatic kotlin suggestions
jasgin Sep 30, 2025
4646e8f
Refactor decorator code
jasgin Sep 30, 2025
97ac178
update structure for existing traits
jasgin Oct 6, 2025
b3d7306
update changelog
jasgin Oct 6, 2025
be18581
Fix minor issues
jasgin Oct 9, 2025
ccf52ee
Rename to UserProvided instead of Custom
jasgin Oct 10, 2025
abd7355
Add validation exception docs and incorporate into error messages
jasgin Oct 10, 2025
0b2ecaf
Remove redundant :W's
jasgin Oct 10, 2025
ef0413a
Refactor templating to be easier to follow
jasgin Oct 13, 2025
a79e7d5
Add prelude scope and codegen ctx for runtime and common templating
jasgin Oct 13, 2025
de18e9b
Add compilation tests for reqiured and optional fields
jasgin Oct 13, 2025
e9481e1
Use listOfNotNull to exclude validationField when not present
jasgin Oct 14, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changelog/1759254918.md
Original file line number Diff line number Diff line change
@@ -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`.
14 changes: 13 additions & 1 deletion codegen-server-test/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<LengthTrait>()
is StringShape -> this.hasTrait<EnumTrait>() || supportedStringConstraintTraits.any { this.hasTrait(it) }
is CollectionShape -> supportedCollectionConstraintTraits.any { this.hasTrait(it) }
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -50,6 +51,7 @@ class RustServerCodegenPlugin : ServerDecoratableBuildPlugin() {
CombinedServerCodegenDecorator.fromClasspath(
context,
ServerRequiredCustomizations(),
UserProvidedValidationExceptionDecorator(),
SmithyValidationExceptionDecorator(),
CustomValidationExceptionWithReasonDecorator(),
*decorator,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ open class ServerCodegenVisitor(

val validationExceptionShapeId = validationExceptionConversionGenerator.shapeId
for (validationResult in listOf(
validateModelHasAtMostOneValidationException(model, service),
codegenDecorator.postprocessValidationExceptionNotAttachedErrorMessage(
validateOperationsWithConstrainedInputHaveValidationExceptionAttached(
model,
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -176,6 +179,9 @@ data class LogMessage(val level: Level, val message: String)
data class ValidationResult(val shouldAbort: Boolean, val messages: List<LogMessage>) :
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.
Expand Down Expand Up @@ -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()

Expand All @@ -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", " ") +
"""

Expand All @@ -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<LogMessage>()

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<OperationShape>()
.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,
Expand Down
Loading
Loading