Impl std::error::Error for a shape's ConstraintViolation struct (#3431)

## Motivation and Context
Each constrained shape has an associated `ConstraintViolation`
structure, which at the moment does not implement `std::error::Error`.

## Description

All of the `ConstraintViolation` structures now implements
`std::fmt::Display` and the `std::error::Error` marker trait. There is a
difference between the message used for `pub(crate) fn
as_validation_exception_field` and for `std::fmt::Display` as the latter
does not know of the particular field which is being set in the
structure. Hence, it uses the shape ID instead.

For example, for a constrained `int`, the following is the message used
in `Display`:

```
write!(f, "Value for `com.aws.example#MyNumberSensitive`failed to satisfy constraint: Member must be greater than or equal to 100")

```
and the following is used for `as_validation_exception_field`:

```
format!("Value at '{}' failed to satisfy constraint: Member must be greater than or equal to 100", &path),
```

## Testing

Tests have been added to ensure:

1. `std::fmt::Display` has all of the variants for the enum that
represents the associated `Error` type in the `ConstraintViolation`
struct.
2. `ConstraintViolation` implements `std::error::Error`.

---------

Co-authored-by: Fahad Zubair <fahadzub@amazon.com>
This commit is contained in:
Fahad Zubair 2024-04-19 17:11:43 +01:00 committed by GitHub
parent 1f5cb697d1
commit 42701d5b22
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 691 additions and 50 deletions

View File

@ -41,3 +41,9 @@ message = "Fix an S3 crate's dependency on `ahash` so the crate can be compiled
references = ["smithy-rs#3590", "aws-sdk-rust#1131"]
meta = { "breaking" = false, "tada" = false, "bug" = true }
author = "ysaito1001"
[[smithy-rs]]
message = "Implement `std::error::Error` for `ConstraintViolation`"
references = ["smithy-rs#3430"]
meta = { "breaking" = false, "tada" = true, "bug" = false, "target" = "server" }
author = "drganjoo"

View File

@ -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
import software.amazon.smithy.model.shapes.Shape
import software.amazon.smithy.model.traits.EnumTrait
fun EnumTrait.validationErrorMessage() =
"Value at '{}' failed to satisfy constraint: Member must satisfy enum value set: [${enumValueSet()}]"
fun EnumTrait.shapeConstraintViolationDisplayMessage(shape: Shape) =
"Value provided for '${shape.id}' failed to satisfy constraint: Member must satisfy enum value set: [${enumValueSet()}]"
fun EnumTrait.enumValueSet() = this.enumDefinitionValues.joinToString(", ")

View File

@ -5,20 +5,21 @@
package software.amazon.smithy.rust.codegen.server.smithy
import software.amazon.smithy.model.shapes.Shape
import software.amazon.smithy.model.traits.LengthTrait
fun LengthTrait.validationErrorMessage(): String {
val beginning = "Value with length {} at '{}' failed to satisfy constraint: Member must have length "
val ending =
if (this.min.isPresent && this.max.isPresent) {
"between ${this.min.get()} and ${this.max.get()}, inclusive"
} else if (this.min.isPresent) {
(
"greater than or equal to ${this.min.get()}"
)
} else {
check(this.max.isPresent)
"less than or equal to ${this.max.get()}"
}
return "$beginning$ending"
}
fun LengthTrait.validationErrorMessage() =
"Value with length {} at '{}' failed to satisfy constraint: Member must have length ${this.lengthDescription()}"
fun LengthTrait.shapeConstraintViolationDisplayMessage(shape: Shape) =
"Value with length {} provided for '${shape.id}' failed to satisfy constraint: Member must have length ${this.lengthDescription()}"
fun LengthTrait.lengthDescription() =
if (this.min.isPresent && this.max.isPresent) {
"between ${this.min.get()} and ${this.max.get()}, inclusive"
} else if (this.min.isPresent) {
"greater than or equal to ${this.min.get()}"
} else {
check(this.max.isPresent)
"less than or equal to ${this.max.get()}"
}

View File

@ -0,0 +1,15 @@
/*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/
package software.amazon.smithy.rust.codegen.server.smithy
import software.amazon.smithy.model.shapes.Shape
import software.amazon.smithy.model.traits.PatternTrait
fun PatternTrait.validationErrorMessage() =
"Value at '{}' failed to satisfy constraint: Member must satisfy regular expression pattern: {}"
fun PatternTrait.shapeConstraintViolationDisplayMessage(shape: Shape) =
"Value provided for `${shape.id}` failed to satisfy the constraint: Member must match the regular expression pattern: {}"

View File

@ -5,20 +5,21 @@
package software.amazon.smithy.rust.codegen.server.smithy
import software.amazon.smithy.model.shapes.Shape
import software.amazon.smithy.model.traits.RangeTrait
fun RangeTrait.validationErrorMessage(): String {
val beginning = "Value at '{}' failed to satisfy constraint: Member must be "
val ending =
if (this.min.isPresent && this.max.isPresent) {
"between ${this.min.get()} and ${this.max.get()}, inclusive"
} else if (this.min.isPresent) {
(
"greater than or equal to ${this.min.get()}"
)
} else {
check(this.max.isPresent)
"less than or equal to ${this.max.get()}"
}
return "$beginning$ending"
}
fun RangeTrait.validationErrorMessage() =
"Value at '{}' failed to satisfy constraint: Member must be ${this.rangeDescription()}"
fun RangeTrait.shapeConstraintViolationDisplayMessage(shape: Shape) =
"Value for `${shape.id}`failed to satisfy constraint: Member must be ${this.rangeDescription()}"
fun RangeTrait.rangeDescription() =
if (this.min.isPresent && this.max.isPresent) {
"between ${this.min.get()} and ${this.max.get()}, inclusive"
} else if (this.min.isPresent) {
"greater than or equal to ${this.min.get()}"
} else {
check(this.max.isPresent)
"less than or equal to ${this.max.get()}"
}

View File

@ -5,9 +5,16 @@
package software.amazon.smithy.rust.codegen.server.smithy
import software.amazon.smithy.model.shapes.Shape
import software.amazon.smithy.model.traits.UniqueItemsTrait
fun UniqueItemsTrait.validationErrorMessage() =
// We're using the `Debug` representation of `Vec<usize>` here e.g. `[0, 2, 3]`, which is the exact format we need
// to match the expected format of the error message in the protocol tests.
"Value with repeated values at indices {:?} at '{}' failed to satisfy constraint: Member must have unique values"
fun UniqueItemsTrait.shapeConstraintViolationDisplayMessage(shape: Shape) =
"""
Value with repeated values at indices {:?} provided for '${shape.id}'
failed to satisfy constraint: Member must have unique values
""".trimIndent().replace("\n", "")

View File

@ -161,8 +161,7 @@ class SmithyValidationExceptionConversionGenerator(private val codegenContext: S
override fun enumShapeConstraintViolationImplBlock(enumTrait: EnumTrait) =
writable {
val enumValueSet = enumTrait.enumDefinitionValues.joinToString(", ")
val message = "Value at '{}' failed to satisfy constraint: Member must satisfy enum value set: [$enumValueSet]"
val message = enumTrait.validationErrorMessage()
rustTemplate(
"""
pub(crate) fn as_validation_exception_field(self, path: #{String}) -> crate::model::ValidationExceptionField {

View File

@ -9,6 +9,8 @@ import software.amazon.smithy.model.shapes.CollectionShape
import software.amazon.smithy.rust.codegen.core.rustlang.Visibility
import software.amazon.smithy.rust.codegen.core.rustlang.join
import software.amazon.smithy.rust.codegen.core.rustlang.rustTemplate
import software.amazon.smithy.rust.codegen.core.rustlang.writable
import software.amazon.smithy.rust.codegen.core.smithy.RuntimeType
import software.amazon.smithy.rust.codegen.core.smithy.makeRustBoxed
import software.amazon.smithy.rust.codegen.core.util.hasTrait
import software.amazon.smithy.rust.codegen.core.util.letIf
@ -80,8 +82,22 @@ class CollectionConstraintViolationGenerator(
${constraintViolationVisibility.toRustQualifier()} enum $constraintViolationName {
#{ConstraintViolationVariants:W}
}
impl #{Display} for $constraintViolationName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let message = match self {
#{VariantDisplayMessages:W}
};
write!(f, "{message}")
}
}
impl #{Error} for $constraintViolationName {}
""",
"ConstraintViolationVariants" to constraintViolationVariants.join(",\n"),
"Error" to RuntimeType.StdError,
"Display" to RuntimeType.Display,
"VariantDisplayMessages" to generateDisplayMessageForEachVariant(shape.isReachableFromOperationInput() && isMemberConstrained),
)
if (shape.isReachableFromOperationInput()) {
@ -96,4 +112,20 @@ class CollectionConstraintViolationGenerator(
}
}
}
private fun generateDisplayMessageForEachVariant(memberConstraintVariantPresent: Boolean) =
writable {
for (constraintsInfo in collectionConstraintsInfo) {
constraintsInfo.shapeConstraintViolationDisplayMessage(shape).invoke(this)
}
if (memberConstraintVariantPresent) {
rustTemplate(
"""
Self::Member(index, failing_member) => format!("Value at index {index} failed to satisfy constraint. {}",
failing_member)
""",
)
}
}
}

View File

@ -7,6 +7,7 @@ package software.amazon.smithy.rust.codegen.server.smithy.generators
import software.amazon.smithy.codegen.core.Symbol
import software.amazon.smithy.model.shapes.BlobShape
import software.amazon.smithy.model.shapes.Shape
import software.amazon.smithy.model.traits.LengthTrait
import software.amazon.smithy.rust.codegen.core.rustlang.RustWriter
import software.amazon.smithy.rust.codegen.core.rustlang.Visibility
@ -18,6 +19,7 @@ import software.amazon.smithy.rust.codegen.core.rustlang.render
import software.amazon.smithy.rust.codegen.core.rustlang.rust
import software.amazon.smithy.rust.codegen.core.rustlang.rustBlock
import software.amazon.smithy.rust.codegen.core.rustlang.rustTemplate
import software.amazon.smithy.rust.codegen.core.rustlang.writable
import software.amazon.smithy.rust.codegen.core.smithy.RuntimeType
import software.amazon.smithy.rust.codegen.core.smithy.expectRustMetadata
import software.amazon.smithy.rust.codegen.core.smithy.makeMaybeConstrained
@ -26,6 +28,7 @@ import software.amazon.smithy.rust.codegen.core.util.orNull
import software.amazon.smithy.rust.codegen.server.smithy.InlineModuleCreator
import software.amazon.smithy.rust.codegen.server.smithy.PubCrateConstraintViolationSymbolProvider
import software.amazon.smithy.rust.codegen.server.smithy.ServerCodegenContext
import software.amazon.smithy.rust.codegen.server.smithy.shapeConstraintViolationDisplayMessage
import software.amazon.smithy.rust.codegen.server.smithy.traits.isReachableFromOperationInput
import software.amazon.smithy.rust.codegen.server.smithy.validationErrorMessage
@ -128,8 +131,22 @@ class ConstrainedBlobGenerator(
pub enum ${constraintViolation.name} {
#{Variants:W}
}
impl #{Display} for ${constraintViolation.name} {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let message = match self {
#{VariantDisplayMessages:W}
};
write!(f, "{message}")
}
}
impl #{Error} for ${constraintViolation.name} {}
""",
"Variants" to constraintsInfo.map { it.constraintViolationVariant }.join(",\n"),
"Error" to RuntimeType.StdError,
"Display" to RuntimeType.Display,
"VariantDisplayMessages" to generateDisplayMessageForEachVariant(),
)
if (shape.isReachableFromOperationInput()) {
@ -143,9 +160,22 @@ class ConstrainedBlobGenerator(
)
}
}
private fun generateDisplayMessageForEachVariant() =
writable {
blobConstraintsInfo.forEach {
it.shapeConstraintViolationDisplayMessage(shape).invoke(this)
}
}
}
data class BlobLength(val lengthTrait: LengthTrait) {
// Each type of constraint that can be put on a Blob must implement the BlobConstraintGenerator
// interface. This allows the
interface BlobConstraintGenerator {
fun shapeConstraintViolationDisplayMessage(shape: Shape): Writable
}
data class BlobLength(val lengthTrait: LengthTrait) : BlobConstraintGenerator {
fun toTraitInfo(): TraitInfo =
TraitInfo(
{ rust("Self::check_length(&value)?;") },
@ -159,7 +189,8 @@ data class BlobLength(val lengthTrait: LengthTrait) {
Self::Length(length) => crate::model::ValidationExceptionField {
message: format!("${lengthTrait.validationErrorMessage()}", length, &path),
path,
},""",
},
""",
)
},
this::renderValidationFunction,
@ -188,4 +219,15 @@ data class BlobLength(val lengthTrait: LengthTrait) {
""",
)
}
override fun shapeConstraintViolationDisplayMessage(shape: Shape) =
writable {
rustTemplate(
"""
Self::Length(length) => {
format!("${lengthTrait.shapeConstraintViolationDisplayMessage(shape).replace("#", "##")}", length)
},
""",
)
}
}

View File

@ -9,6 +9,7 @@ import software.amazon.smithy.codegen.core.Symbol
import software.amazon.smithy.codegen.core.SymbolProvider
import software.amazon.smithy.model.shapes.CollectionShape
import software.amazon.smithy.model.shapes.EnumShape
import software.amazon.smithy.model.shapes.Shape
import software.amazon.smithy.model.shapes.StructureShape
import software.amazon.smithy.model.shapes.UnionShape
import software.amazon.smithy.model.traits.LengthTrait
@ -16,19 +17,23 @@ import software.amazon.smithy.model.traits.Trait
import software.amazon.smithy.model.traits.UniqueItemsTrait
import software.amazon.smithy.rust.codegen.core.rustlang.RustWriter
import software.amazon.smithy.rust.codegen.core.rustlang.Visibility
import software.amazon.smithy.rust.codegen.core.rustlang.Writable
import software.amazon.smithy.rust.codegen.core.rustlang.docs
import software.amazon.smithy.rust.codegen.core.rustlang.documentShape
import software.amazon.smithy.rust.codegen.core.rustlang.join
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.rustTemplate
import software.amazon.smithy.rust.codegen.core.rustlang.writable
import software.amazon.smithy.rust.codegen.core.smithy.RuntimeType
import software.amazon.smithy.rust.codegen.core.smithy.expectRustMetadata
import software.amazon.smithy.rust.codegen.core.util.PANIC
import software.amazon.smithy.rust.codegen.core.util.hasTrait
import software.amazon.smithy.rust.codegen.core.util.orNull
import software.amazon.smithy.rust.codegen.server.smithy.PubCrateConstraintViolationSymbolProvider
import software.amazon.smithy.rust.codegen.server.smithy.ServerCodegenContext
import software.amazon.smithy.rust.codegen.server.smithy.canReachConstrainedShape
import software.amazon.smithy.rust.codegen.server.smithy.shapeConstraintViolationDisplayMessage
import software.amazon.smithy.rust.codegen.server.smithy.supportedCollectionConstraintTraits
import software.amazon.smithy.rust.codegen.server.smithy.validationErrorMessage
@ -315,6 +320,16 @@ sealed class CollectionTraitInfo {
}
},
)
override fun shapeConstraintViolationDisplayMessage(shape: Shape) =
writable {
rustTemplate(
"""
Self::UniqueItems { duplicate_indices, .. } =>
format!("${uniqueItemsTrait.shapeConstraintViolationDisplayMessage(shape).replace("#", "##")}", &duplicate_indices),
""",
)
}
}
data class Length(val lengthTrait: LengthTrait) : CollectionTraitInfo() {
@ -354,6 +369,17 @@ sealed class CollectionTraitInfo {
}
},
)
override fun shapeConstraintViolationDisplayMessage(shape: Shape) =
writable {
rustTemplate(
"""
Self::Length(length) => {
format!("${lengthTrait.shapeConstraintViolationDisplayMessage(shape).replace("#", "##")}", length)
},
""",
)
}
}
companion object {
@ -386,4 +412,6 @@ sealed class CollectionTraitInfo {
}
abstract fun toTraitInfo(): TraitInfo
abstract fun shapeConstraintViolationDisplayMessage(shape: Shape): Writable
}

View File

@ -31,6 +31,7 @@ import software.amazon.smithy.rust.codegen.core.util.redactIfNecessary
import software.amazon.smithy.rust.codegen.server.smithy.InlineModuleCreator
import software.amazon.smithy.rust.codegen.server.smithy.PubCrateConstraintViolationSymbolProvider
import software.amazon.smithy.rust.codegen.server.smithy.ServerCodegenContext
import software.amazon.smithy.rust.codegen.server.smithy.shapeConstraintViolationDisplayMessage
import software.amazon.smithy.rust.codegen.server.smithy.traits.isReachableFromOperationInput
import software.amazon.smithy.rust.codegen.server.smithy.validationErrorMessage
@ -135,13 +136,23 @@ class ConstrainedNumberGenerator(
writer.renderTryFrom(unconstrainedTypeName, name, constraintViolation, constraintsInfo)
inlineModuleCreator(constraintViolation) {
rust(
rustTemplate(
"""
##[derive(Debug, PartialEq)]
pub enum ${constraintViolation.name} {
Range($unconstrainedTypeName),
}
impl #{Display} for ${constraintViolation.name} {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "${rangeInfo.rangeTrait.shapeConstraintViolationDisplayMessage(shape).replace("#", "##")}")
}
}
impl #{Error} for ${constraintViolation.name} {}
""",
"Error" to RuntimeType.StdError,
"Display" to RuntimeType.Display,
)
if (shape.isReachableFromOperationInput()) {

View File

@ -37,6 +37,7 @@ import software.amazon.smithy.rust.codegen.server.smithy.InlineModuleCreator
import software.amazon.smithy.rust.codegen.server.smithy.PubCrateConstraintViolationSymbolProvider
import software.amazon.smithy.rust.codegen.server.smithy.ServerCargoDependency
import software.amazon.smithy.rust.codegen.server.smithy.ServerCodegenContext
import software.amazon.smithy.rust.codegen.server.smithy.shapeConstraintViolationDisplayMessage
import software.amazon.smithy.rust.codegen.server.smithy.supportedStringConstraintTraits
import software.amazon.smithy.rust.codegen.server.smithy.traits.isReachableFromOperationInput
import software.amazon.smithy.rust.codegen.server.smithy.validationErrorMessage
@ -126,7 +127,6 @@ class ConstrainedStringGenerator(
}
}
impl #{From}<$name> for $inner {
fn from(value: $name) -> Self {
value.into_inner()
@ -158,8 +158,22 @@ class ConstrainedStringGenerator(
pub enum ${constraintViolation.name} {
#{Variants:W}
}
impl #{Display} for ${constraintViolation.name} {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let message = match self {
#{VariantDisplayMessages:W}
};
write!(f, "{message}")
}
}
impl #{Error} for ${constraintViolation.name} {}
""",
"Variants" to constraintsInfo.map { it.constraintViolationVariant }.join(",\n"),
"Error" to RuntimeType.StdError,
"Display" to RuntimeType.Display,
"VariantDisplayMessages" to generateDisplayMessageForEachVariant(),
)
if (shape.isReachableFromOperationInput()) {
@ -174,6 +188,13 @@ class ConstrainedStringGenerator(
}
}
private fun generateDisplayMessageForEachVariant() =
writable {
stringConstraintsInfo.forEach {
it.shapeConstraintViolationDisplayMessage(shape).invoke(this)
}
}
private fun renderTests(shape: Shape) {
val testCases = TraitInfo.testCases(constraintsInfo)
@ -239,6 +260,17 @@ data class Length(val lengthTrait: LengthTrait) : StringTraitInfo() {
""",
)
}
override fun shapeConstraintViolationDisplayMessage(shape: Shape) =
writable {
rustTemplate(
"""
Self::Length(length) => {
format!("${lengthTrait.shapeConstraintViolationDisplayMessage(shape).replace("#", "##")}", length)
},
""",
)
}
}
data class Pattern(val symbol: Symbol, val patternTrait: PatternTrait, val isSensitive: Boolean) : StringTraitInfo() {
@ -278,12 +310,11 @@ data class Pattern(val symbol: Symbol, val patternTrait: PatternTrait, val isSen
}
fun errorMessage(): Writable {
val pattern = patternTrait.pattern
return writable {
val pattern = patternTrait.pattern.toString().replace("#", "##")
rust(
"""
format!("Value at '{}' failed to satisfy constraint: Member must satisfy regular expression pattern: {}", &path, r##"$pattern"##)
format!("${patternTrait.validationErrorMessage()}", &path, r##"$pattern"##)
""",
)
}
@ -297,7 +328,7 @@ data class Pattern(val symbol: Symbol, val patternTrait: PatternTrait, val isSen
constraintViolation: Symbol,
unconstrainedTypeName: String,
): Writable {
val pattern = patternTrait.pattern
val pattern = patternTrait.pattern.toString().replace("#", "##")
val errorMessageForUnsupportedRegex =
"""The regular expression $pattern is not supported by the `regex` crate; feel free to file an issue under https://github.com/smithy-lang/smithy-rs/issues for support"""
@ -327,6 +358,19 @@ data class Pattern(val symbol: Symbol, val patternTrait: PatternTrait, val isSen
)
}
}
override fun shapeConstraintViolationDisplayMessage(shape: Shape) =
writable {
val errorMessage = patternTrait.shapeConstraintViolationDisplayMessage(shape).replace("#", "##")
val pattern = patternTrait.pattern.toString().replace("#", "##")
rustTemplate(
"""
Self::Pattern(_) => {
format!(r##"$errorMessage"##, r##"$pattern"##)
},
""",
)
}
}
sealed class StringTraitInfo {
@ -349,4 +393,6 @@ sealed class StringTraitInfo {
}
abstract fun toTraitInfo(): TraitInfo
abstract fun shapeConstraintViolationDisplayMessage(shape: Shape): Writable
}

View File

@ -10,12 +10,16 @@ import software.amazon.smithy.model.shapes.StringShape
import software.amazon.smithy.model.traits.LengthTrait
import software.amazon.smithy.rust.codegen.core.rustlang.Visibility
import software.amazon.smithy.rust.codegen.core.rustlang.rustTemplate
import software.amazon.smithy.rust.codegen.core.rustlang.writable
import software.amazon.smithy.rust.codegen.core.smithy.RuntimeType
import software.amazon.smithy.rust.codegen.core.smithy.makeRustBoxed
import software.amazon.smithy.rust.codegen.core.util.getTrait
import software.amazon.smithy.rust.codegen.core.util.hasTrait
import software.amazon.smithy.rust.codegen.core.util.letIf
import software.amazon.smithy.rust.codegen.server.smithy.InlineModuleCreator
import software.amazon.smithy.rust.codegen.server.smithy.PubCrateConstraintViolationSymbolProvider
import software.amazon.smithy.rust.codegen.server.smithy.ServerCodegenContext
import software.amazon.smithy.rust.codegen.server.smithy.shapeConstraintViolationDisplayMessage
import software.amazon.smithy.rust.codegen.server.smithy.traits.ConstraintViolationRustBoxTrait
import software.amazon.smithy.rust.codegen.server.smithy.traits.isReachableFromOperationInput
@ -84,8 +88,23 @@ class MapConstraintViolationGenerator(
${if (keyConstraintViolationExists) "##[doc(hidden)] Key(#{KeyConstraintViolationSymbol})," else ""}
${if (valueConstraintViolationExists) "##[doc(hidden)] Value(#{KeySymbol}, #{ValueConstraintViolationSymbol})," else ""}
}
impl #{Display} for $constraintViolationName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
${if (shape.hasTrait<LengthTrait>()) "#{LengthMatchingArm}" else ""}
${if (keyConstraintViolationExists) """Self::Key(key_constraint_violation) => write!(f, "{}", key_constraint_violation),""" else ""}
${if (valueConstraintViolationExists) """Self::Value(_, value_constraint_violation) => write!(f, "{}", value_constraint_violation),""" else ""}
}
}
}
impl #{Error} for $constraintViolationName {}
""",
*constraintViolationCodegenScope,
"LengthMatchingArm" to lengthMatchingArm(),
"Error" to RuntimeType.StdError,
"Display" to RuntimeType.Display,
)
if (shape.isReachableFromOperationInput()) {
@ -107,4 +126,17 @@ class MapConstraintViolationGenerator(
}
}
}
private fun lengthMatchingArm() =
writable {
shape.getTrait<LengthTrait>()?.let {
rustTemplate(
"""
Self::Length(length) => {
write!(f, "${it.shapeConstraintViolationDisplayMessage(shape).replace("#", "##")}", length)
},
""",
)
}
}
}

View File

@ -20,6 +20,7 @@ import software.amazon.smithy.rust.codegen.core.smithy.module
import software.amazon.smithy.rust.codegen.core.util.dq
import software.amazon.smithy.rust.codegen.server.smithy.PubCrateConstraintViolationSymbolProvider
import software.amazon.smithy.rust.codegen.server.smithy.ServerCodegenContext
import software.amazon.smithy.rust.codegen.server.smithy.shapeConstraintViolationDisplayMessage
import software.amazon.smithy.rust.codegen.server.smithy.traits.isReachableFromOperationInput
open class ConstrainedEnum(
@ -50,8 +51,18 @@ open class ConstrainedEnum(
"""
##[derive(Debug, PartialEq)]
pub struct $constraintViolationName(pub(crate) #{String});
impl #{Display} for $constraintViolationName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, r##"${context.enumTrait.shapeConstraintViolationDisplayMessage(shape).replace("#", "##")}"##)
}
}
impl #{Error} for $constraintViolationName {}
""",
*codegenScope,
"Error" to RuntimeType.StdError,
"Display" to RuntimeType.Display,
)
if (shape.isReachableFromOperationInput()) {

View File

@ -154,6 +154,23 @@ class UnconstrainedUnionGenerator(
constraintViolations().forEach { renderConstraintViolation(this, it) }
}
rustTemplate(
"""
impl #{Display} for $constraintViolationName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
#{ConstraintVariants:W}
}
}
}
impl #{Error} for $constraintViolationName {}
""",
"Error" to RuntimeType.StdError,
"Display" to RuntimeType.Display,
"ConstraintVariants" to generateDisplayMessageForEachVariant(),
)
if (shape.isReachableFromOperationInput()) {
rustBlock("impl $constraintViolationName") {
rustBlockTemplate(
@ -171,6 +188,17 @@ class UnconstrainedUnionGenerator(
}
}
private fun generateDisplayMessageForEachVariant() =
writable {
constraintViolations().forEach {
rustTemplate(
"""
Self::${it.name()}(inner) => write!(f, "{inner}"),
""",
)
}
}
data class ConstraintViolation(val forMember: MemberShape) {
fun name() = forMember.memberName.toPascalCase()
}

View File

@ -18,6 +18,7 @@ import software.amazon.smithy.model.node.ArrayNode
import software.amazon.smithy.model.shapes.CollectionShape
import software.amazon.smithy.model.shapes.ListShape
import software.amazon.smithy.model.shapes.SetShape
import software.amazon.smithy.model.shapes.StringShape
import software.amazon.smithy.rust.codegen.core.rustlang.RustWriter
import software.amazon.smithy.rust.codegen.core.rustlang.Writable
import software.amazon.smithy.rust.codegen.core.rustlang.rust
@ -25,6 +26,7 @@ import software.amazon.smithy.rust.codegen.core.rustlang.rustBlock
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
import software.amazon.smithy.rust.codegen.core.testutil.TestWorkspace
import software.amazon.smithy.rust.codegen.core.testutil.asSmithyModel
import software.amazon.smithy.rust.codegen.core.testutil.compileAndTest
@ -177,6 +179,10 @@ class ConstrainedCollectionGeneratorTest {
val codegenContext = serverTestCodegenContext(testCase.model)
val project = TestWorkspace.testProject(codegenContext.symbolProvider)
project.withModule(ServerRustModule.Model) {
TestUtility.generateIsDisplay().invoke(this)
TestUtility.generateIsError().invoke(this)
}
for (shape in listOf(constrainedListShape, constrainedSetShape)) {
val shapeName =
@ -256,7 +262,15 @@ class ConstrainedCollectionGeneratorTest {
withBlock("let expected_err = ", ";") {
rustTemplate("#{ExpectedError:W}", "ExpectedError" to expectedErrorWritable)
}
rust("assert_eq!(err, expected_err);")
rust(
"""
assert_eq!(err, expected_err);
is_error(&err);
is_display(&err);
// Ensure that the `std::fmt::Display` implementation for `ConstraintViolation` error works.
assert_eq!(err.to_string(), expected_err.to_string());
""".trimMargin(),
)
} ?: run {
rust("constrained_res.unwrap_err();")
}
@ -291,6 +305,110 @@ class ConstrainedCollectionGeneratorTest {
writer.toString() shouldContain "pub struct ConstrainedList(pub(crate) ::std::vec::Vec<::std::string::String>);"
}
@Test
fun `error trait implemented for ConstraintViolation should work for constrained member`() {
val model =
"""
${'$'}version: "1.0"
namespace test
use aws.protocols#restJson1
use smithy.framework#ValidationException
// The `ConstraintViolation` code generated for a constrained map that is not reachable from an
// operation does not have the `Key`, or `Value` variants. Hence, we need to define a service
// and an operation that uses the constrained map.
@restJson1
service MyService {
version: "2023-04-01",
operations: [
MyOperation,
]
}
@http(method: "POST", uri: "/echo")
operation MyOperation {
input: MyOperationInput
errors : [ValidationException]
}
@input
structure MyOperationInput {
member1: ConstrainedList,
member2: ConstrainedSet,
}
@length(min: 2, max: 69)
list ConstrainedList {
member: ConstrainedString
}
@length(min: 2, max: 69)
set ConstrainedSet {
member: ConstrainedString
}
@pattern("#\\d+")
string ConstrainedString
""".asSmithyModel().let(ShapesReachableFromOperationInputTagger::transform)
val codegenContext = serverTestCodegenContext(model)
val symbolProvider = codegenContext.symbolProvider
val project = TestWorkspace.testProject(symbolProvider)
project.withModule(ServerRustModule.Model) {
TestUtility.generateIsDisplay().invoke(this)
TestUtility.generateIsError().invoke(this)
TestUtility.renderConstrainedString(
codegenContext, this,
model.lookup<StringShape>("test#ConstrainedString"),
)
rustTemplate(
"""
// Define `ValidationExceptionField` since it is required by the `ConstraintViolation` code for constrained maps,
// and the complete SDK generation process, which would generate it, is not invoked as part of the test.
pub struct ValidationExceptionField {
pub message: String,
pub path: String
}
""",
"Result" to RuntimeType.std.resolve("Result"),
)
val constrainedListShape = model.lookup<ListShape>("test#ConstrainedList")
val constrainedSetShape = model.lookup<ListShape>("test#ConstrainedSet")
render(codegenContext, this, constrainedListShape)
render(codegenContext, this, constrainedSetShape)
unitTest(
name = "try_from_fail_invalid_constrained_list",
test = """
let constrained_error = ConstrainedString::try_from("one".to_string()).unwrap_err();
let error = crate::model::constrained_list::ConstraintViolation::Member(0, constrained_error);
is_error(&error);
is_display(&error);
assert_eq!("Value at index 0 failed to satisfy constraint. Value provided for `test#ConstrainedString` failed to satisfy the constraint: Member must match the regular expression pattern: #\\d+",
error.to_string());
""",
)
unitTest(
name = "try_from_fail_invalid_constrained_set",
test = """
let constrained_error = ConstrainedString::try_from("one".to_string()).unwrap_err();
let error = crate::model::constrained_set::ConstraintViolation::Member(0, constrained_error);
is_error(&error);
is_display(&error);
assert_eq!("Value at index 0 failed to satisfy constraint. Value provided for `test#ConstrainedString` failed to satisfy the constraint: Member must match the regular expression pattern: #\\d+",
error.to_string());
""",
)
}
project.compileAndTest()
}
private fun render(
codegenContext: ServerCodegenContext,
writer: RustWriter,

View File

@ -15,13 +15,17 @@ import org.junit.jupiter.params.provider.ArgumentsSource
import software.amazon.smithy.model.Model
import software.amazon.smithy.model.node.ObjectNode
import software.amazon.smithy.model.shapes.MapShape
import software.amazon.smithy.model.shapes.StringShape
import software.amazon.smithy.rust.codegen.core.rustlang.RustWriter
import software.amazon.smithy.rust.codegen.core.rustlang.rustBlock
import software.amazon.smithy.rust.codegen.core.rustlang.rustTemplate
import software.amazon.smithy.rust.codegen.core.smithy.RuntimeType
import software.amazon.smithy.rust.codegen.core.testutil.TestWorkspace
import software.amazon.smithy.rust.codegen.core.testutil.asSmithyModel
import software.amazon.smithy.rust.codegen.core.testutil.compileAndTest
import software.amazon.smithy.rust.codegen.core.testutil.unitTest
import software.amazon.smithy.rust.codegen.core.util.lookup
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.ServerRustModule
import software.amazon.smithy.rust.codegen.server.smithy.createTestInlineModuleCreator
@ -79,6 +83,9 @@ class ConstrainedMapGeneratorTest {
val project = TestWorkspace.testProject(symbolProvider)
project.withModule(ServerRustModule.Model) {
TestUtility.generateIsDisplay().invoke(this)
TestUtility.generateIsError().invoke(this)
render(codegenContext, this, constrainedMapShape)
val instantiator = ServerInstantiator(codegenContext)
@ -101,7 +108,10 @@ class ConstrainedMapGeneratorTest {
test = """
let map = build_invalid_map();
let constrained_res: Result<ConstrainedMap, _> = map.try_into();
constrained_res.unwrap_err();
let error = constrained_res.unwrap_err();
is_display(&error);
is_error(&error);
""",
)
unitTest(
@ -150,6 +160,161 @@ class ConstrainedMapGeneratorTest {
writer.toString() shouldContain "pub struct ConstrainedMap(pub(crate) ::std::collections::HashMap<::std::string::String, ::std::string::String>);"
}
@Test
fun `error trait implemented for ConstraintViolation should work for constrained key and value`() {
val model =
"""
${'$'}version: "2"
namespace test
use aws.protocols#restJson1
use smithy.framework#ValidationException
// The `ConstraintViolation` code generated for a constrained map that is not reachable from an
// operation does not have the `Key`, or `Value` variants. Hence, we need to define a service
// and an operation that uses the constrained map.
@restJson1
service MyService {
version: "2023-04-01",
operations: [
MyOperation,
]
}
@http(method: "POST", uri: "/echo")
operation MyOperation {
input:= {
member1: ConstrainedMapWithConstrainedKey,
member2: ConstrainedMapWithConstrainedKeyAndValue,
member3: ConstrainedMapWithConstrainedValue,
},
output:= {},
errors : [ValidationException]
}
@length(min: 2, max: 69)
map ConstrainedMapWithConstrainedKey {
key: ConstrainedKey,
value: String
}
@length(min: 2, max: 69)
map ConstrainedMapWithConstrainedValue {
key: String,
value: ConstrainedValue
}
@length(min: 2, max: 69)
map ConstrainedMapWithConstrainedKeyAndValue {
key: ConstrainedKey,
value: ConstrainedValue,
}
@pattern("#\\d+")
string ConstrainedKey
@pattern("A-Z")
string ConstrainedValue
""".asSmithyModel().let(ShapesReachableFromOperationInputTagger::transform)
val constrainedKeyShape = model.lookup<StringShape>("test#ConstrainedKey")
val constrainedValueShape = model.lookup<StringShape>("test#ConstrainedValue")
val codegenContext = serverTestCodegenContext(model)
val symbolProvider = codegenContext.symbolProvider
val project = TestWorkspace.testProject(symbolProvider)
project.withModule(ServerRustModule.Model) {
TestUtility.generateIsDisplay().invoke(this)
TestUtility.generateIsError().invoke(this)
TestUtility.renderConstrainedString(codegenContext, this, constrainedKeyShape)
TestUtility.renderConstrainedString(codegenContext, this, constrainedValueShape)
val mapsToVerify =
listOf(
model.lookup<MapShape>("test#ConstrainedMapWithConstrainedKey"),
model.lookup<MapShape>("test#ConstrainedMapWithConstrainedKeyAndValue"),
model.lookup<MapShape>("test#ConstrainedMapWithConstrainedValue"),
)
rustTemplate(
"""
fn build_invalid_constrained_map_with_constrained_key() -> #{HashMap}<ConstrainedKey, String> {
let mut m = ::std::collections::HashMap::new();
m.insert(ConstrainedKey("1".to_string()), "Y".to_string());
m
}
fn build_invalid_constrained_map_with_constrained_key_and_value() -> std::collections::HashMap<ConstrainedKey, ConstrainedValue> {
let mut m = ::std::collections::HashMap::new();
m.insert(ConstrainedKey("1".to_string()), ConstrainedValue("Y".to_string()));
m
}
fn build_invalid_constrained_map_with_constrained_value() -> std::collections::HashMap<String, ConstrainedValue> {
let mut m = ::std::collections::HashMap::new();
m.insert("1".to_string(), ConstrainedValue("Y".to_string()));
m
}
// Define `ValidationExceptionField` since it is required by the `ConstraintViolation` code for constrained maps,
// and the complete SDK generation process, which would generate it, is not invoked as part of the test.
pub struct ValidationExceptionField {
pub message: String,
pub path: String
}
""",
"HashMap" to RuntimeType.HashMap,
)
for (mapToVerify in mapsToVerify) {
val rustShapeName = mapToVerify.toShapeId().name
val rustShapeSnakeCaseName = rustShapeName.toSnakeCase()
render(codegenContext, this, mapToVerify)
unitTest(
name = "try_from_fail_$rustShapeSnakeCaseName",
test = """
let map = build_invalid_$rustShapeSnakeCaseName();
let constrained_res: Result<$rustShapeName, $rustShapeSnakeCaseName::ConstraintViolation> = map.try_into();
let error = constrained_res.unwrap_err();
is_error(&error);
is_display(&error);
assert_eq!("Value with length 1 provided for 'test#$rustShapeName' failed to satisfy constraint: Member must have length between 2 and 69, inclusive", error.to_string());
""",
)
}
unitTest(
name = "try_constrained_key",
test =
"""
let error = constrained_map_with_constrained_key::ConstraintViolation::Key(constrained_key::ConstraintViolation::Pattern("some error".to_string()));
assert_eq!(error.to_string(), "Value provided for `test#ConstrainedKey` failed to satisfy the constraint: Member must match the regular expression pattern: #\\d+");
""",
)
unitTest(
name = "try_constrained_value",
test =
"""
let error = constrained_map_with_constrained_value::ConstraintViolation::Value("some_key".to_string(), constrained_value::ConstraintViolation::Pattern("some error".to_string()));
assert_eq!(error.to_string(), "Value provided for `test#ConstrainedValue` failed to satisfy the constraint: Member must match the regular expression pattern: A-Z");
""",
)
unitTest(
name = "try_constrained_key_and_value",
test =
"""
let error = constrained_map_with_constrained_key_and_value::ConstraintViolation::Key(constrained_key::ConstraintViolation::Pattern("some error".to_string()));
assert_eq!(error.to_string(), "Value provided for `test#ConstrainedKey` failed to satisfy the constraint: Member must match the regular expression pattern: #\\d+");
let error = constrained_map_with_constrained_key_and_value::ConstraintViolation::Value(ConstrainedKey("1".to_string()), constrained_value::ConstraintViolation::Pattern("some error".to_string()));
assert_eq!(error.to_string(), "Value provided for `test#ConstrainedValue` failed to satisfy the constraint: Member must match the regular expression pattern: A-Z");
""",
)
}
project.compileAndTest()
}
private fun render(
codegenContext: ServerCodegenContext,
writer: RustWriter,

View File

@ -73,6 +73,9 @@ class ConstrainedNumberGeneratorTest {
val project = TestWorkspace.testProject(symbolProvider)
project.withModule(ServerRustModule.Model) {
TestUtility.generateIsDisplay().invoke(this)
TestUtility.generateIsError().invoke(this)
ConstrainedNumberGenerator(
codegenContext,
this.createTestInlineModuleCreator(),
@ -91,7 +94,9 @@ class ConstrainedNumberGeneratorTest {
name = "try_from_fail",
test = """
let constrained_res: Result<${testCase.shapeName}, _> = ${testCase.invalidValue}.try_into();
constrained_res.unwrap_err();
let error = constrained_res.unwrap_err();
is_error(&error);
is_display(&error);
""",
)
unitTest(

View File

@ -53,9 +53,9 @@ class ConstrainedStringGeneratorTest {
Triple(
"""
@length(min: 3, max: 10)
@pattern("^a string$")
@pattern("^a # string$")
""",
"a string", "an invalid string",
"a # string", "an invalid string",
),
Triple("@pattern(\"123\")", "some pattern 123 in the middle", "no pattern at all"),
).map {
@ -86,6 +86,9 @@ class ConstrainedStringGeneratorTest {
val project = TestWorkspace.testProject(symbolProvider)
project.withModule(ServerRustModule.Model) {
TestUtility.generateIsDisplay().invoke(this)
TestUtility.generateIsError().invoke(this)
ConstrainedStringGenerator(
codegenContext,
this.createTestInlineModuleCreator(),
@ -106,7 +109,9 @@ class ConstrainedStringGeneratorTest {
test = """
let string = "${testCase.invalidString}".to_owned();
let constrained_res: Result<ConstrainedString, _> = string.try_into();
constrained_res.unwrap_err();
let error = constrained_res.unwrap_err();
is_error(&error);
is_display(&error);
""",
)
unitTest(

View File

@ -0,0 +1,52 @@
/*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/
package software.amazon.smithy.rust.codegen.server.smithy.generators
import software.amazon.smithy.model.shapes.StringShape
import software.amazon.smithy.rust.codegen.core.rustlang.RustWriter
import software.amazon.smithy.rust.codegen.core.rustlang.rustTemplate
import software.amazon.smithy.rust.codegen.core.rustlang.writable
import software.amazon.smithy.rust.codegen.core.smithy.RuntimeType
import software.amazon.smithy.rust.codegen.server.smithy.ServerCodegenContext
import software.amazon.smithy.rust.codegen.server.smithy.createTestInlineModuleCreator
import software.amazon.smithy.rust.codegen.server.smithy.customizations.SmithyValidationExceptionConversionGenerator
object TestUtility {
fun generateIsDisplay() =
writable {
rustTemplate(
"""
pub fn is_display<T : #{Display}>(_t: &T) { }
""",
"Display" to RuntimeType.Display,
)
}
fun generateIsError() =
writable {
rustTemplate(
"""
pub fn is_error<T : #{Error}>(_t: &T) { }
""",
"Error" to RuntimeType.StdError,
)
}
fun renderConstrainedString(
codegenContext: ServerCodegenContext,
writer: RustWriter,
constrainedStringShape: StringShape,
) {
val validationExceptionConversionGenerator = SmithyValidationExceptionConversionGenerator(codegenContext)
ConstrainedStringGenerator(
codegenContext,
writer.createTestInlineModuleCreator(),
writer,
constrainedStringShape,
validationExceptionConversionGenerator,
).render()
}
}

View File

@ -11,6 +11,7 @@ import software.amazon.smithy.rust.codegen.core.rustlang.rust
import software.amazon.smithy.rust.codegen.core.smithy.CoreCodegenConfig
import software.amazon.smithy.rust.codegen.core.testutil.TestWorkspace
import software.amazon.smithy.rust.codegen.core.testutil.asSmithyModel
import software.amazon.smithy.rust.codegen.core.testutil.compileAndTest
import software.amazon.smithy.rust.codegen.core.testutil.testModule
import software.amazon.smithy.rust.codegen.core.testutil.unitTest
import software.amazon.smithy.rust.codegen.core.util.lookup
@ -71,6 +72,9 @@ class UnconstrainedMapGeneratorTest {
serverIntegrationTest(model) { _, rustCrate ->
rustCrate.testModule {
TestUtility.generateIsDisplay().invoke(this)
TestUtility.generateIsError().invoke(this)
unitTest("map_a_unconstrained_fail_to_constrain_with_some_error") {
rust(
"""
@ -105,8 +109,17 @@ class UnconstrainedMapGeneratorTest {
);
let actual_err = crate::constrained::map_a_constrained::MapAConstrained::try_from(map_a_unconstrained).unwrap_err();
assert!(actual_err == missing_string_expected_err || actual_err == missing_int_expected_err);
is_display(&actual_err);
is_error(&actual_err);
let error_str = actual_err.to_string();
assert!(
error_str == "`string` was not provided but it is required when building `StructureC`"
|| error_str
== "`int` was not provided but it is required when building `StructureC`"
);
""",
)
}
@ -162,5 +175,7 @@ class UnconstrainedMapGeneratorTest {
}
}
}
project.compileAndTest()
}
}

View File

@ -59,6 +59,9 @@ class UnconstrainedUnionGeneratorTest {
}
project.withModule(ServerRustModule.UnconstrainedModule) unconstrainedModuleWriter@{
TestUtility.generateIsDisplay().invoke(this)
TestUtility.generateIsError().invoke(this)
project.withModule(ServerRustModule.Model) modelsModuleWriter@{
UnconstrainedUnionGenerator(codegenContext, project.createInlineModuleCreator(), this@modelsModuleWriter, unionShape).render()
@ -71,11 +74,13 @@ class UnconstrainedUnionGeneratorTest {
let expected_err = crate::model::union::ConstraintViolation::Structure(
crate::model::structure::ConstraintViolation::MissingRequiredMember,
);
let err = crate::model::Union::try_from(union_unconstrained).unwrap_err();
assert_eq!(
expected_err,
crate::model::Union::try_from(union_unconstrained).unwrap_err()
expected_err, err
);
is_display(&err);
is_error(&err);
assert_eq!(err.to_string(), "`required_member` was not provided but it is required when building `Structure`");
""",
)