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/aws/rust-runtime/Cargo.lock b/aws/rust-runtime/Cargo.lock index ad1858d7c4c..308bdf70b3a 100644 --- a/aws/rust-runtime/Cargo.lock +++ b/aws/rust-runtime/Cargo.lock @@ -70,7 +70,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-credential-types" -version = "1.2.8" +version = "1.2.9" dependencies = [ "async-trait", "aws-smithy-async", @@ -113,7 +113,7 @@ dependencies = [ [[package]] name = "aws-runtime" -version = "1.5.12" +version = "1.5.13" dependencies = [ "arbitrary", "aws-credential-types", diff --git a/aws/rust-runtime/aws-credential-types/Cargo.toml b/aws/rust-runtime/aws-credential-types/Cargo.toml index 3a2a221cb55..b9a352d6553 100644 --- a/aws/rust-runtime/aws-credential-types/Cargo.toml +++ b/aws/rust-runtime/aws-credential-types/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "aws-credential-types" -version = "1.2.8" +version = "1.2.9" authors = ["AWS Rust SDK Team "] description = "Types for AWS SDK credentials." edition = "2021" diff --git a/aws/rust-runtime/aws-credential-types/src/credential_feature.rs b/aws/rust-runtime/aws-credential-types/src/credential_feature.rs index afb7ee11afe..8e69a0a8610 100644 --- a/aws/rust-runtime/aws-credential-types/src/credential_feature.rs +++ b/aws/rust-runtime/aws-credential-types/src/credential_feature.rs @@ -49,6 +49,8 @@ pub enum AwsCredentialFeature { CredentialsImds, /// An operation called using a Bearer token resolved from service-specific environment variables BearerServiceEnvVars, + /// An operation called using S3 Express bucket credentials + S3ExpressBucket, } impl Storable for AwsCredentialFeature { diff --git a/aws/rust-runtime/aws-inlineable/src/s3_express.rs b/aws/rust-runtime/aws-inlineable/src/s3_express.rs index 42c6f9daa64..2a95c696435 100644 --- a/aws/rust-runtime/aws-inlineable/src/s3_express.rs +++ b/aws/rust-runtime/aws-inlineable/src/s3_express.rs @@ -445,6 +445,7 @@ pub(crate) mod identity_provider { use crate::s3_express::identity_cache::S3ExpressIdentityCache; use crate::types::SessionCredentials; + use aws_credential_types::credential_feature::AwsCredentialFeature; use aws_credential_types::provider::error::CredentialsError; use aws_credential_types::Credentials; use aws_smithy_async::time::{SharedTimeSource, TimeSource}; @@ -516,7 +517,9 @@ pub(crate) mod identity_provider { let creds = self .express_session_credentials(bucket_name, runtime_components, config_bag) .await?; - let data = Credentials::try_from(creds)?; + let mut data = Credentials::try_from(creds)?; + data.get_property_mut_or_default::>() + .push(AwsCredentialFeature::S3ExpressBucket); Ok(( Identity::new(data.clone(), data.expiry()), data.expiry().unwrap(), @@ -628,6 +631,140 @@ pub(crate) mod identity_provider { IdentityCacheLocation::IdentityResolver } } + + #[cfg(test)] + mod tests { + use super::*; + use aws_credential_types::credential_feature::AwsCredentialFeature; + use aws_credential_types::Credentials; + + // Helper function to create test runtime components with SigV4 identity resolver + fn create_test_runtime_components( + base_credentials: Credentials, + ) -> aws_smithy_runtime_api::client::runtime_components::RuntimeComponents { + use aws_credential_types::provider::SharedCredentialsProvider; + use aws_smithy_runtime::client::http::test_util::infallible_client_fn; + use aws_smithy_runtime::client::orchestrator::endpoints::StaticUriEndpointResolver; + use aws_smithy_runtime::client::retries::strategy::NeverRetryStrategy; + use aws_smithy_runtime_api::client::auth::static_resolver::StaticAuthSchemeOptionResolver; + use aws_smithy_runtime_api::client::identity::SharedIdentityResolver; + use aws_smithy_runtime_api::client::runtime_components::RuntimeComponentsBuilder; + use aws_smithy_types::body::SdkBody; + + let sigv4_resolver = + SharedIdentityResolver::new(SharedCredentialsProvider::new(base_credentials)); + + // Create a simple auth scheme option resolver for testing + let auth_option_resolver = + StaticAuthSchemeOptionResolver::new(vec![aws_runtime::auth::sigv4::SCHEME_ID]); + + let http_client = infallible_client_fn(|_req| { + http::Response::builder() + .status(200) + .body(SdkBody::from( + r#" + + + session_access_key + session_secret_key + session_token + 2025-01-01T00:00:00Z + + "#, + )) + .unwrap() + }); + + RuntimeComponentsBuilder::for_tests() + .with_identity_resolver(aws_runtime::auth::sigv4::SCHEME_ID, sigv4_resolver) + .with_http_client(Some(http_client)) + .with_time_source(Some(aws_smithy_async::time::SystemTimeSource::new())) + .with_retry_strategy(Some(NeverRetryStrategy::new())) + .with_auth_scheme_option_resolver(Some(auth_option_resolver)) + .with_endpoint_resolver(Some(StaticUriEndpointResolver::http_localhost(8080))) + .build() + .unwrap() + } + + // Helper function to create config bag with minimal S3 Express bucket parameters + fn create_test_config_bag(bucket_name: &str) -> aws_smithy_types::config_bag::ConfigBag { + use aws_smithy_runtime_api::client::endpoint::EndpointResolverParams; + use aws_smithy_runtime_api::client::stalled_stream_protection::StalledStreamProtectionConfig; + use aws_smithy_types::config_bag::{ConfigBag, Layer}; + + let mut config_bag = ConfigBag::base(); + let mut layer = Layer::new("test"); + + let endpoint_params = EndpointResolverParams::new( + crate::config::endpoint::Params::builder() + .bucket(bucket_name) + .build() + .unwrap(), + ); + layer.store_put(endpoint_params); + + layer.store_put(StalledStreamProtectionConfig::disabled()); + + layer.store_put(crate::config::Region::new("us-west-2")); + + config_bag.push_layer(layer); + + config_bag + } + + #[test] + fn test_session_credentials_conversion() { + let session_creds = SessionCredentials::builder() + .access_key_id("test_access_key") + .secret_access_key("test_secret_key") + .session_token("test_session_token") + .expiration(aws_smithy_types::DateTime::from_secs(1000)) + .build() + .expect("valid session credentials"); + + let credentials = + Credentials::try_from(session_creds).expect("conversion should succeed"); + + assert_eq!(credentials.access_key_id(), "test_access_key"); + assert_eq!(credentials.secret_access_key(), "test_secret_key"); + assert_eq!(credentials.session_token(), Some("test_session_token")); + } + + #[tokio::test] + async fn test_identity_provider_embeds_s3express_feature() { + let bucket_name = "test-bucket--usw2-az1--x-s3"; + + // Use helper functions to set up test components + let base_credentials = Credentials::for_tests(); + let runtime_components = create_test_runtime_components(base_credentials); + let config_bag = create_test_config_bag(bucket_name); + + // Create the identity provider + let provider = DefaultS3ExpressIdentityProvider::builder() + .behavior_version(crate::config::BehaviorVersion::latest()) + .time_source(aws_smithy_async::time::SystemTimeSource::new()) + .build(); + + // Call identity() and verify the S3ExpressBucket feature is present + let identity = provider + .identity(&runtime_components, &config_bag) + .await + .expect("identity() should succeed"); + + let credentials = identity + .data::() + .expect("Identity should contain Credentials"); + + let features = credentials + .get_property::>() + .expect("Credentials should have features"); + + assert!( + features.contains(&AwsCredentialFeature::S3ExpressBucket), + "S3ExpressBucket feature should be present in credentials returned by identity()" + ); + } + } } /// Supporting code for S3 Express runtime plugin @@ -787,15 +924,15 @@ pub(crate) mod runtime_plugin { // Disable option is set from service client. let disable_s3_express_session_token = crate::config::DisableS3ExpressSessionAuth(true); - // An environment variable says the session auth is _not_ disabled, but it will be - // overruled by what is in `layer`. + // An environment variable says the session auth is _not_ disabled, + // but it will be overruled by what is in `layer`. let actual = config( Some(disable_s3_express_session_token), Env::from_slice(&[(super::env::S3_DISABLE_EXPRESS_SESSION_AUTH, "false")]), ); - // A config layer from this runtime plugin should not provide a new `DisableS3ExpressSessionAuth` - // if the disable option is set from service client. + // A config layer from this runtime plugin should not provide + // a new `DisableS3ExpressSessionAuth` if the disable option is set from service client. assert!(actual .load::() .is_none()); @@ -803,12 +940,13 @@ pub(crate) mod runtime_plugin { #[test] fn disable_option_set_from_env_should_take_the_second_highest_precedence() { - // An environment variable says session auth is disabled + // Disable option is set from environment variable. let actual = config( None, Env::from_slice(&[(super::env::S3_DISABLE_EXPRESS_SESSION_AUTH, "true")]), ); + // The config layer should provide `DisableS3ExpressSessionAuth` from the environment variable. assert!( actual .load::() @@ -820,15 +958,15 @@ pub(crate) mod runtime_plugin { #[should_panic] #[test] fn disable_option_set_from_profile_file_should_take_the_lowest_precedence() { - // TODO(aws-sdk-rust#1073): Implement a test that mimics only setting - // `s3_disable_express_session_auth` in a profile file - todo!() + todo!("TODO(aws-sdk-rust#1073): Implement profile file test") } #[test] fn disable_option_should_be_unspecified_if_unset() { + // Disable option is not set anywhere. let actual = config(None, Env::from_slice(&[])); + // The config layer should not provide `DisableS3ExpressSessionAuth` when it's not configured. assert!(actual .load::() .is_none()); @@ -836,6 +974,7 @@ pub(crate) mod runtime_plugin { #[test] fn s3_express_runtime_plugin_should_set_default_identity_resolver() { + // Config has SigV4 credentials provider, so S3 Express identity resolver should be set. let config = crate::Config::builder() .behavior_version_latest() .time_source(aws_smithy_async::time::SystemTimeSource::new()) @@ -843,6 +982,7 @@ pub(crate) mod runtime_plugin { .build(); let actual = runtime_components_builder(config); + // The runtime plugin should provide a default S3 Express identity resolver. assert!(actual .identity_resolver(&crate::s3_express::auth::SCHEME_ID) .is_some()); @@ -850,12 +990,14 @@ pub(crate) mod runtime_plugin { #[test] fn s3_express_plugin_should_not_set_default_identity_resolver_without_sigv4_counterpart() { + // Config does not have SigV4 credentials provider. let config = crate::Config::builder() .behavior_version_latest() .time_source(aws_smithy_async::time::SystemTimeSource::new()) .build(); let actual = runtime_components_builder(config); + // The runtime plugin should not provide S3 Express identity resolver without SigV4 credentials. assert!(actual .identity_resolver(&crate::s3_express::auth::SCHEME_ID) .is_none()); @@ -863,6 +1005,7 @@ pub(crate) mod runtime_plugin { #[tokio::test] async fn s3_express_plugin_should_not_set_default_identity_resolver_if_user_provided() { + // User provides a custom S3 Express credentials provider. let expected_access_key_id = "expected acccess key ID"; let config = crate::Config::builder() .behavior_version_latest() @@ -877,13 +1020,13 @@ pub(crate) mod runtime_plugin { .time_source(aws_smithy_async::time::SystemTimeSource::new()) .build(); - // `RuntimeComponentsBuilder` from `S3ExpressRuntimePlugin` should not provide an S3Express identity resolver. + // The runtime plugin should not override the user-provided identity resolver. let runtime_components_builder = runtime_components_builder(config.clone()); assert!(runtime_components_builder .identity_resolver(&crate::s3_express::auth::SCHEME_ID) .is_none()); - // Get the S3Express identity resolver from the service config. + // The user-provided identity resolver should be used. let express_identity_resolver = config .runtime_components .identity_resolver(&crate::s3_express::auth::SCHEME_ID) @@ -896,7 +1039,6 @@ pub(crate) mod runtime_plugin { .await .unwrap(); - // Verify credentials are the one generated by the S3Express identity resolver user provided. assert_eq!( expected_access_key_id, creds.data::().unwrap().access_key_id() diff --git a/aws/rust-runtime/aws-runtime/Cargo.toml b/aws/rust-runtime/aws-runtime/Cargo.toml index 2928bb0d6f7..043d3885149 100644 --- a/aws/rust-runtime/aws-runtime/Cargo.toml +++ b/aws/rust-runtime/aws-runtime/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "aws-runtime" -version = "1.5.12" +version = "1.5.13" authors = ["AWS Rust SDK Team "] description = "Runtime support code for the AWS SDK. This crate isn't intended to be used directly." edition = "2021" diff --git a/aws/rust-runtime/aws-runtime/src/user_agent/metrics.rs b/aws/rust-runtime/aws-runtime/src/user_agent/metrics.rs index daf97fa5b52..45470082ba1 100644 --- a/aws/rust-runtime/aws-runtime/src/user_agent/metrics.rs +++ b/aws/rust-runtime/aws-runtime/src/user_agent/metrics.rs @@ -258,6 +258,7 @@ impl ProvideBusinessMetric for AwsCredentialFeature { CredentialsHttp => Some(BusinessMetric::CredentialsHttp), CredentialsImds => Some(BusinessMetric::CredentialsImds), BearerServiceEnvVars => Some(BusinessMetric::BearerServiceEnvVars), + S3ExpressBucket => Some(BusinessMetric::S3ExpressBucket), otherwise => { // This may occur if a customer upgrades only the `aws-smithy-runtime-api` crate // while continuing to use an outdated version of an SDK crate or the `aws-credential-types` diff --git a/aws/sdk/integration-tests/s3/tests/express.rs b/aws/sdk/integration-tests/s3/tests/express.rs index 1b45053681f..0a2ba75428e 100644 --- a/aws/sdk/integration-tests/s3/tests/express.rs +++ b/aws/sdk/integration-tests/s3/tests/express.rs @@ -7,6 +7,7 @@ use std::time::{Duration, SystemTime}; use aws_config::timeout::TimeoutConfig; use aws_config::Region; +use aws_runtime::user_agent::test_util::assert_ua_does_not_contain_metric_values; use aws_sdk_s3::config::endpoint::{EndpointFuture, Params, ResolveEndpoint}; use aws_sdk_s3::config::{Builder, Credentials}; use aws_sdk_s3::presigning::PresigningConfig; @@ -54,6 +55,11 @@ async fn create_session_request_should_not_include_x_amz_s3session_token() { ); assert!(req.headers().get("x-amz-security-token").is_some()); assert!(req.headers().get("x-amz-s3session-token").is_none()); + + // The first request uses regular SigV4 credentials (for CreateSession), not S3 Express credentials, + // so metric "J" should NOT be present yet. It will appear on subsequent requests that use S3 Express credentials. + let user_agent = req.headers().get("x-amz-user-agent").unwrap(); + assert_ua_does_not_contain_metric_values(user_agent, &["J"]); } #[tokio::test] @@ -273,9 +279,9 @@ async fn default_checksum_should_be_crc32_for_operation_requiring_checksum() { .iter() .filter(|(key, _)| key.starts_with("x-amz-checksum")) .collect(); - assert_eq!(1, checksum_headers.len()); assert_eq!("x-amz-checksum-crc32", checksum_headers[0].0); + http_client.assert_requests_match(&[""]); } @@ -304,7 +310,6 @@ async fn default_checksum_should_be_none() { .iter() .map(|checksum| format!("amz-checksum-{}", checksum.to_lowercase())) .chain(std::iter::once("content-md5".to_string())); - assert!(!all_checksums.any(|checksum| http_client .actual_requests() .any(|req| req.headers().iter().any(|(key, _)| key == checksum)))); @@ -332,6 +337,10 @@ async fn disable_s3_express_session_auth_at_service_client_level() { req.headers().get("x-amz-create-session-mode").is_none(), "x-amz-create-session-mode should not appear in headers when S3 Express session auth is disabled" ); + + // Verify that the User-Agent does NOT contain the S3ExpressBucket metric "J" when session auth is disabled + let user_agent = req.headers().get("x-amz-user-agent").unwrap(); + assert_ua_does_not_contain_metric_values(user_agent, &["J"]); } #[tokio::test] @@ -418,6 +427,10 @@ async fn support_customer_overriding_express_credentials_provider() { .expect("x-amz-security-token should be present"); assert_eq!(expected_session_token, actual_session_token); assert!(req.headers().get("x-amz-s3session-token").is_none()); + + // Verify that the User-Agent does NOT contain the S3ExpressBucket metric "J" for regular buckets + let user_agent = req.headers().get("x-amz-user-agent").unwrap(); + assert_ua_does_not_contain_metric_values(user_agent, &["J"]); } #[tokio::test] diff --git a/codegen-core/src/main/kotlin/software/amazon/smithy/rust/codegen/core/util/Smithy.kt b/codegen-core/src/main/kotlin/software/amazon/smithy/rust/codegen/core/util/Smithy.kt index d90e037950e..84bdf8b3b35 100644 --- a/codegen-core/src/main/kotlin/software/amazon/smithy/rust/codegen/core/util/Smithy.kt +++ b/codegen-core/src/main/kotlin/software/amazon/smithy/rust/codegen/core/util/Smithy.kt @@ -132,6 +132,8 @@ fun Shape.targetOrSelf(model: Model): Shape = else -> this } +fun MemberShape.targetShape(model: Model): Shape = model.expectShape(this.target) + /** Kotlin sugar for hasTrait() check. e.g. shape.hasTrait() instead of shape.hasTrait(EnumTrait::class.java) */ inline fun Shape.hasTrait(): Boolean = hasTrait(T::class.java) diff --git a/codegen-server-test/build.gradle.kts b/codegen-server-test/build.gradle.kts index c9815e8623b..dc2b562221b 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", @@ -118,6 +118,18 @@ val allCodegenTests = "../codegen-core/common-test-models".let { commonModels -> // When iterating on protocol tests use this to speed up codegen: // .filter { it.module == "rpcv2Cbor_extras" || it.module == "rpcv2Cbor_extras_no_initial_response" } +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..f1c1384dfa0 --- /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.framework.rust#validationException +use smithy.framework.rust#validationFieldList +use smithy.framework.rust#validationFieldMessage +use smithy.framework.rust#validationFieldName +use smithy.framework.rust#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 6654c012c4c..9cfc3ca1063 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..f6a617bd38a 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 @@ -5,6 +5,7 @@ package software.amazon.smithy.rust.codegen.server.smithy +import software.amazon.smithy.framework.rust.ValidationExceptionTrait import software.amazon.smithy.model.Model import software.amazon.smithy.model.shapes.BlobShape import software.amazon.smithy.model.shapes.ByteShape @@ -145,13 +146,23 @@ private sealed class UnsupportedConstraintMessageKind { } } -private data class OperationWithConstrainedInputWithoutValidationException(val shape: OperationShape) +private data class OperationWithConstrainedInputWithoutValidationException( + val shape: OperationShape, +) -private data class UnsupportedConstraintOnMemberShape(val shape: MemberShape, val constraintTrait: Trait) : - UnsupportedConstraintMessageKind() +private data class OperationWithConstrainedInputWithMultipleValidationExceptions( + val shape: OperationShape, +) -private data class UnsupportedConstraintOnShapeReachableViaAnEventStream(val shape: Shape, val constraintTrait: Trait) : - UnsupportedConstraintMessageKind() +private data class UnsupportedConstraintOnMemberShape( + val shape: MemberShape, + val constraintTrait: Trait, +) : UnsupportedConstraintMessageKind() + +private data class UnsupportedConstraintOnShapeReachableViaAnEventStream( + val shape: Shape, + val constraintTrait: Trait, +) : UnsupportedConstraintMessageKind() private data class UnsupportedLengthTraitOnStreamingBlobShape( val shape: BlobShape, @@ -159,11 +170,15 @@ private data class UnsupportedLengthTraitOnStreamingBlobShape( val streamingTrait: StreamingTrait, ) : UnsupportedConstraintMessageKind() -private data class UnsupportedRangeTraitOnShape(val shape: Shape, val rangeTrait: RangeTrait) : - UnsupportedConstraintMessageKind() +private data class UnsupportedRangeTraitOnShape( + val shape: Shape, + val rangeTrait: RangeTrait, +) : UnsupportedConstraintMessageKind() -private data class UnsupportedUniqueItemsTraitOnShape(val shape: Shape, val uniqueItemsTrait: UniqueItemsTrait) : - UnsupportedConstraintMessageKind() +private data class UnsupportedUniqueItemsTraitOnShape( + val shape: Shape, + val uniqueItemsTrait: UniqueItemsTrait, +) : UnsupportedConstraintMessageKind() private data class UnsupportedMapShapeReachableFromUniqueItemsList( val listShape: ListShape, @@ -171,10 +186,18 @@ private data class UnsupportedMapShapeReachableFromUniqueItemsList( val mapShape: MapShape, ) : UnsupportedConstraintMessageKind() -data class LogMessage(val level: Level, val message: String) +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 }) +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 @@ -185,15 +208,16 @@ fun operationShapesThatMustHaveValidationException( service: ServiceShape, ): Set { val walker = DirectedWalker(model) - return walker.walkShapes(service) + return walker + .walkShapes(service) .filterIsInstance() .asSequence() .filter { operationShape -> // Walk the shapes reachable via this operation input. - walker.walkShapes(operationShape.inputShape(model)) + walker + .walkShapes(operationShape.inputShape(model)) .any { it is SetShape || it is EnumShape || it.hasConstraintTrait() || it.hasEventStreamMember(model) } - } - .toSet() + }.toSet() } /** @@ -205,13 +229,24 @@ 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 defaultValidationExceptionShapeId = ShapeId.from("smithy.framework#ValidationException") val operationsWithConstrainedInputWithoutValidationExceptionSet = operationShapesThatMustHaveValidationException(model, service) - .filter { !it.errors.contains(validationExceptionShapeId) } - .map { OperationWithConstrainedInputWithoutValidationException(it) } + .filter { + val errors = it.getErrors(service) + !errors.contains(defaultValidationExceptionShapeId) && + !errors + .contains(validationExceptionShapeId) && + errors.none { error -> + model + .expectShape(error) + .hasTrait(ValidationExceptionTrait.ID) + } + }.map { OperationWithConstrainedInputWithoutValidationException(it) } .toSet() val messages = @@ -221,16 +256,18 @@ 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", " ") + """ ```smithy - use $validationExceptionShapeId + use $defaultValidationExceptionShapeId operation ${it.shape.id.name} { ... - errors: [..., ${validationExceptionShapeId.name}] // <-- Add this. + errors: [..., ${defaultValidationExceptionShapeId.name}] // <-- Add this. } ``` """.trimIndent(), @@ -240,6 +277,115 @@ 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) +} + +private fun Shape.isReachableFromOperationErrors(model: Model): Boolean = + model.serviceShapes.any { + it.errorsSet.contains(this.id) + } || model.operationShapes.any { it.errorsSet.contains(this.id) } + +/** + * 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 { + // Custom validation exception shapes that are defined AND used in input + val customValidationExceptionShapes = + model + .shapes() + .filter { it.hasTrait(ValidationExceptionTrait.ID) && it.isReachableFromOperationErrors(model) } + .toList() + + val messages = mutableListOf() + + if (customValidationExceptionShapes.isEmpty()) { + return ValidationResult(shouldAbort = false, messages) + } + + if (customValidationExceptionShapes.size > 1) { + messages.add( + LogMessage( + Level.SEVERE, + """ + Using multiple custom validation exceptions is unsupported. + Found ${customValidationExceptionShapes.size} validation exception shapes reachable from operation input: + ${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") + + // This is guaranteed to have a single shape due to the above check + val customValidationExceptionId = customValidationExceptionShapes.single()!!.id + + val operationsWithDefault = + walker + .walkShapes(service) + .asSequence() + .filterIsInstance() + .filter { it.errors.contains(defaultValidationExceptionId) } + + operationsWithDefault.forEach { + // This error will typically not be reached anyways because Smithy will error out from collisions + messages.add( + LogMessage( + Level.SEVERE, + """ + Operation ${it.id} uses the default ValidationException, but $customValidationExceptionId is also + also used in another operation. + Remove ValidationException from the operation's errors and use the custom validation exception, or vice versa. + $validationExceptionDocsErrorMessage + """.trimIndent(), + ), + ) + } + + return ValidationResult(shouldAbort = messages.any { it.level == Level.SEVERE }, messages) +} + fun validateUnsupportedConstraints( model: Model, service: ServiceShape, @@ -273,9 +419,10 @@ fun validateUnsupportedConstraints( .map { (shape, trait) -> UnsupportedConstraintOnShapeReachableViaAnEventStream(shape, trait) } .toSet() val eventStreamErrors = - eventStreamShapes.map { - it.expectTrait() - }.map { it.errorMembers } + eventStreamShapes + .map { + it.expectTrait() + }.map { it.errorMembers } val unsupportedConstraintErrorShapeReachableViaAnEventStreamSet = eventStreamErrors .flatMap { it } @@ -312,8 +459,7 @@ fun validateUnsupportedConstraints( mapShape, ) } - } - .toSet() + }.toSet() val messages = ( @@ -348,5 +494,6 @@ fun validateUnsupportedConstraints( * The returned sequence contains one pair per shape in the input iterable that has attached a trait contained in [traits]. */ private fun Sequence.filterMapShapesToTraits(traits: Set>): Sequence> = - this.map { shape -> shape to traits.mapNotNull { shape.getTrait(it).orNull() } } + this + .map { shape -> shape to traits.mapNotNull { shape.getTrait(it).orNull() } } .flatMap { (shape, traits) -> traits.map { shape to it } } 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..e6b5dbf05eb --- /dev/null +++ b/codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/customizations/UserProvidedValidationExceptionDecorator.kt @@ -0,0 +1,700 @@ +/* + * 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.framework.rust.ValidationExceptionTrait +import software.amazon.smithy.framework.rust.ValidationFieldListTrait +import software.amazon.smithy.framework.rust.ValidationFieldMessageTrait +import software.amazon.smithy.framework.rust.ValidationFieldNameTrait +import software.amazon.smithy.framework.rust.ValidationMessageTrait +import software.amazon.smithy.model.Model +import software.amazon.smithy.model.SourceLocation +import software.amazon.smithy.model.shapes.MapShape +import software.amazon.smithy.model.shapes.MemberShape +import software.amazon.smithy.model.shapes.ServiceShape +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.targetShape +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.ServerRustSettings +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.Length +import software.amazon.smithy.rust.codegen.server.smithy.generators.Pattern +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.isValidationFieldName +import software.amazon.smithy.rust.codegen.server.smithy.util.isValidationMessage +import software.amazon.smithy.rust.codegen.server.smithy.validationErrorMessage + +/** + * Decorator for user provided 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() = "UserProvidedValidationExceptionDecorator" + override val order: Byte + get() = 68 + + override fun validationExceptionConversion( + codegenContext: ServerCodegenContext, + ): ValidationExceptionConversionGenerator? = + firstStructureShapeWithValidationExceptionTrait(codegenContext.model)?.let { + UserProvidedValidationExceptionConversionGenerator( + codegenContext, + it, + validationMessageMember(it), + maybeValidationFieldList(codegenContext.model, it), + additionalFieldMembers(it), + ) + } + + internal fun firstStructureShapeWithValidationExceptionTrait(model: Model): StructureShape? = + model + .shapes(StructureShape::class.java) + .toList() + // Defining multiple validation exceptions is unsupported. See `ValidateUnsupportedConstraints` + .firstOrNull({ it.hasTrait(ValidationExceptionTrait.ID) }) + + internal fun validationMessageMember(validationExceptionStructure: StructureShape): MemberShape = + validationExceptionStructure + .members() + .firstOrNull { it.isValidationMessage() } + ?: throw CodegenException("Expected `$validationExceptionStructure` to contain a member named `message` or annotated with the `@validationMessageTrait`") + + internal fun additionalFieldMembers(validationExceptionStructure: StructureShape): List = + validationExceptionStructure.members().filter { member -> + !member.isValidationMessage() && + !member.hasTrait( + ValidationFieldListTrait.ID, + ) + } + + /** + * Returns a [ValidationFieldList] if the following exist: + * - A structure type representing the field + * - A list type representing the field list with a single member targeting the field type + * - A member in the validation exception structure annotated with `@validationFieldList` targeting the list type + * + * Returns null if there is no member annotated with the `@validationFieldList` trait in the given validation exception structure + * Otherwise, throws a [CodegenException] if it exists, but is misconfigured + */ + internal fun maybeValidationFieldList( + model: Model, + validationExceptionStructure: StructureShape, + ): ValidationFieldList? { + val validationFieldListMember = + validationExceptionStructure + .members() + .firstOrNull { it.hasTrait(ValidationFieldListTrait.ID) } + ?: return null + + val validationFieldListShape = + validationFieldListMember + .targetShape(model) + .asListShape() + .orElseThrow { + CodegenException("Expected `$validationFieldListMember` to target a list type") + } + + val validationFieldListShapeMember = + validationFieldListShape.members().singleOrNull() + ?: throw CodegenException("Expected `$validationFieldListShape` to have a single member") + + val validationFieldStructure = + validationFieldListShapeMember + .targetShape(model) + .asStructureShape() + .orElseThrow { + CodegenException("Expected $validationFieldListShapeMember to target a structure type") + } + + // It is required that a member of the user provided validation field structure has @validationFieldName + val validationFieldNameMember = + validationFieldStructure + .members() + .firstOrNull { it.isValidationFieldName() } + ?: throw CodegenException( + "Expected `$validationFieldStructure` to contain a member explicitly" + + " annotated with the `@validationFieldName` trait, or with the name \"name\"", + ) + + val maybeValidationFieldMessageMember = + validationFieldStructure + .members() + .firstOrNull { it.hasTrait(ValidationFieldMessageTrait.ID) } + + return ValidationFieldList( + validationFieldListMember, + validationFieldStructure, + validationFieldNameMember, + maybeValidationFieldMessageMember, + ) + } + + override fun transformModel( + service: ServiceShape, + model: Model, + settings: ServerRustSettings, + ): Model { + val validationExceptionStructure = firstStructureShapeWithValidationExceptionTrait(model) ?: return model + annotateValidationMessageMember(validationExceptionStructure) + maybeValidationFieldList(model, validationExceptionStructure)?.let { + annotateValidationFieldName(it) + } + + return model + } + + /** + * Annotates the "message" member of the validation exception structure with @validationMessage when there is no + * explicitly annotated member + */ + internal fun annotateValidationMessageMember(validationExceptionStructure: StructureShape) { + val member = validationMessageMember(validationExceptionStructure) + if (!member.hasTrait(ValidationMessageTrait.ID)) { + // When there is no field annotated with the @validationMessage trait, we will annotate the field named "message" + member.toBuilder().addTrait(ValidationMessageTrait(SourceLocation.none())) + } + } + + /** + * Annotates the "name" member of the validation field structure with @validationFieldName when there is no + * explicitly annotated member + */ + internal fun annotateValidationFieldName(validationFieldList: ValidationFieldList) { + val member = validationFieldList.validationFieldNameMember + if (!member.hasTrait(ValidationFieldNameTrait.ID)) { + // When there is no field annotated with the @validationMessage trait, we will annotate the field named "name" + member.toBuilder().addTrait(ValidationFieldNameTrait(SourceLocation.none())) + } + } +} + +class UserProvidedValidationExceptionConversionGenerator( + private val codegenContext: ServerCodegenContext, + private val validationExceptionStructure: StructureShape, + private val validationMessageMember: MemberShape, + private val maybeValidationFieldList: ValidationFieldList?, + private val additionalFieldMembers: List, +) : ValidationExceptionConversionGenerator { + private val codegenScope = + listOfNotNull(maybeValidationFieldList?.validationFieldStructure) + .map { + "ValidationExceptionField" to codegenContext.symbolProvider.toSymbol(it) + }.toTypedArray() + + companion object { + val SHAPE_ID: ShapeId = ShapeId.from("smithy.framework#UserProvidedValidationException") + } + + override val shapeId: ShapeId = SHAPE_ID + + override fun renderImplFromConstraintViolationForRequestRejection(protocol: ServerProtocol): Writable = + writable { + val validationMessageName = codegenContext.symbolProvider.toMemberName(validationMessageMember) + // Generate the correct shape module name for the user provided validation exception + val shapeModuleName = + codegenContext.symbolProvider.shapeModuleName(codegenContext.serviceShape, validationExceptionStructure) + val shapeFunctionName = validationExceptionStructure.id.name.toSnakeCase() + + rustTemplate( + """ + impl #{From} for #{RequestRejection} { + fn from(constraint_violation: ConstraintViolation) -> Self { + #{FieldCreation} + let validation_exception = #{ValidationException} { + $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), + "ValidationException" to codegenContext.symbolProvider.toSymbol(validationExceptionStructure), + "FieldCreation" to + writable { + if (maybeValidationFieldList?.maybeValidationFieldMessageMember != null) { + rust("""let first_validation_exception_field = constraint_violation.as_validation_exception_field("".to_owned());""") + } + }, + "ValidationMessage" to + writable { + val message = + maybeValidationFieldList?.maybeValidationFieldMessageMember?.let { + val validationFieldMessageName = + codegenContext.symbolProvider.toMemberName(it) + if (it.isOptional) { + """format!("validation error detected. {}", &first_validation_exception_field.$validationFieldMessageName.clone().unwrap_or_default())""" + } else { + """format!("validation error detected. {}", &first_validation_exception_field.$validationFieldMessageName)""" + } + } ?: """format!("validation error detected")""" + rust(validationMessageMember.wrapValueIfOptional(message)) + }, + "FieldListAssignment" to + writable { + maybeValidationFieldList?.validationFieldListMember?.let { + val fieldName = + codegenContext.symbolProvider.toMemberName(it) + val value = it.wrapValueIfOptional("vec![first_validation_exception_field]") + rust("$fieldName: $value,") + } + }, + "AdditionalFieldAssignments" to + writable { + rust( + additionalFieldMembers.joinToString { member -> + val memberName = codegenContext.symbolProvider.toMemberName(member)!! + "$memberName: ${defaultFieldAssignment(member)}" + }, + ) + }, + ) + } + + override fun stringShapeConstraintViolationImplBlock(stringConstraintsInfo: Collection): Writable { + val validationFieldList = maybeValidationFieldList ?: return writable { } + + return writable { + val fieldAssignments = generateUserProvidedValidationFieldAssignments(validationFieldList) + + rustTemplate( + """ + pub(crate) fn as_validation_exception_field(self, path: #{String}) -> #{ValidationExceptionField} { + match self { + #{ValidationExceptionFields} + } + } + """, + *preludeScope, + *codegenScope, + "ValidationExceptionFields" to + writable { + stringConstraintsInfo.forEach { stringTraitInfo -> + when (stringTraitInfo) { + is Length -> { + val lengthTrait = + stringTraitInfo::class.java + .getDeclaredField("lengthTrait") + .apply { isAccessible = true } + .get(stringTraitInfo) as LengthTrait + rustTemplate( + """ + Self::Length(length) => #{ValidationExceptionField} { + #{FieldAssignments} + }, + """, + *codegenScope, + "FieldAssignments" to + fieldAssignments( + "path.clone()", + """format!(${ + lengthTrait.validationErrorMessage().dq() + }, length, &path)""", + ), + ) + } + + is Pattern -> { + val patternTrait = + stringTraitInfo::class.java + .getDeclaredField("patternTrait") + .apply { isAccessible = true } + .get(stringTraitInfo) as PatternTrait + rustTemplate( + """ + Self::Pattern(_) => #{ValidationExceptionField} { + #{FieldAssignments} + }, + """, + *codegenScope, + "FieldAssignments" to + fieldAssignments( + "path.clone()", + """format!(${ + patternTrait.validationErrorMessage().dq() + }, &path, ${patternTrait.pattern.toString().dq()})""", + ), + ) + } + } + } + }, + ) + } + } + + override fun blobShapeConstraintViolationImplBlock(blobConstraintsInfo: Collection): Writable { + val validationFieldList = maybeValidationFieldList ?: return writable { } + + return writable { + rustTemplate( + """ + pub(crate) fn as_validation_exception_field(self, path: #{String}) -> #{ValidationExceptionField} { + match self { + #{ValidationExceptionFields} + } + } + """, + *preludeScope, + *codegenScope, + "ValidationExceptionFields" to + writable { + val fieldAssignments = + generateUserProvidedValidationFieldAssignments(validationFieldList) + blobConstraintsInfo.forEach { blobLength -> + rustTemplate( + """ + Self::Length(length) => #{ValidationExceptionField} { + #{FieldAssignments} + }, + """, + *codegenScope, + "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 validationFieldList = maybeValidationFieldList ?: return writable { } + + return writable { + val fieldAssignments = generateUserProvidedValidationFieldAssignments(validationFieldList) + + rustBlockTemplate( + "pub(crate) fn as_validation_exception_field(self, path: #{String}) -> #{ValidationExceptionField}", + *preludeScope, + *codegenScope, + ) { + rustBlock("match self") { + shape.getTrait()?.also { + rustTemplate( + """ + Self::Length(length) => #{ValidationExceptionField} { + #{FieldAssignments} + },""", + *codegenScope, + "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 validationFieldList = maybeValidationFieldList ?: return writable { } + + return writable { + val fieldAssignments = generateUserProvidedValidationFieldAssignments(validationFieldList) + val message = enumTrait.validationErrorMessage() + + rustTemplate( + """ + pub(crate) fn as_validation_exception_field(self, path: #{String}) -> #{ValidationExceptionField} { + #{ValidationExceptionField} { + #{FieldAssignments} + } + } + """, + *preludeScope, + *codegenScope, + "FieldAssignments" to fieldAssignments("path.clone()", """format!(r##"$message"##, &path)"""), + ) + } + } + + override fun numberShapeConstraintViolationImplBlock(rangeInfo: Range): Writable { + val validationFieldList = maybeValidationFieldList ?: return writable { } + + return writable { + val fieldAssignments = generateUserProvidedValidationFieldAssignments(validationFieldList) + + rustTemplate( + """ + pub(crate) fn as_validation_exception_field(self, path: #{String}) -> #{ValidationExceptionField} { + match self { + Self::Range(_) => #{ValidationExceptionField} { + #{FieldAssignments} + }, + } + } + """, + *preludeScope, + *codegenScope, + "FieldAssignments" to + fieldAssignments( + "path.clone()", + """format!(${rangeInfo.rangeTrait.validationErrorMessage().dq()}, &path)""", + ), + ) + } + } + + override fun builderConstraintViolationFn(constraintViolations: Collection): Writable { + val validationFieldList = maybeValidationFieldList ?: return writable { } + + return writable { + val fieldAssignments = generateUserProvidedValidationFieldAssignments(validationFieldList) + + rustBlockTemplate( + "pub(crate) fn as_validation_exception_field(self, path: #{String}) -> #{ValidationExceptionField}", + *preludeScope, + *codegenScope, + ) { + 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()} => #{ValidationExceptionField} { + #{FieldAssignments} + }, + """.trimIndent(), + *codegenScope, + "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 validationFieldList = maybeValidationFieldList ?: return writable { } + + return writable { + val fieldAssignments = generateUserProvidedValidationFieldAssignments(validationFieldList) + + rustTemplate( + """ + pub(crate) fn as_validation_exception_field(self, path: #{String}) -> #{ValidationExceptionField} { + match self { + #{ValidationExceptionFields} + } + } + """, + *preludeScope, + *codegenScope, + "ValidationExceptionFields" to + writable { + collectionConstraintsInfo.forEach { collectionTraitInfo -> + when (collectionTraitInfo) { + is CollectionTraitInfo.Length -> { + rustTemplate( + """ + Self::Length(length) => #{ValidationExceptionField} { + #{FieldAssignments} + }, + """, + *codegenScope, + "FieldAssignments" to + fieldAssignments( + "path.clone()", + """format!(${ + collectionTraitInfo.lengthTrait.validationErrorMessage() + .dq() + }, length, &path)""", + ), + ) + } + + is CollectionTraitInfo.UniqueItems -> { + rustTemplate( + """ + Self::UniqueItems { duplicate_indices, .. } => #{ValidationExceptionField} { + #{FieldAssignments} + }, + """, + *codegenScope, + "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 { + maybeValidationFieldList ?: return writable { } + + return writable { + rustBlockTemplate( + "pub(crate) fn as_validation_exception_field(self, path: #{String}) -> #{ValidationExceptionField}", + *preludeScope, + *codegenScope, + ) { + 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 user provided validation exception fields + */ + private fun generateUserProvidedValidationFieldAssignments( + validationFieldList: ValidationFieldList, + ): (String, String) -> Writable = + { rawPathExpression: String, rawMessageExpression: String -> + writable { + rustTemplate( + validationFieldList.validationFieldStructure.members().joinToString(",") { member -> + val memberName = codegenContext.symbolProvider.toMemberName(member) + val pathExpression = member.wrapValueIfOptional(rawPathExpression) + val messageExpression = member.wrapValueIfOptional(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.targetShape(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()" + } + + private fun MemberShape.wrapValueIfOptional(valueExpression: String): String = + if (this.isOptional) { + "Some($valueExpression)" + } else { + valueExpression + } +} + +/** + * Class for encapsulating data related to validation field list + */ +class ValidationFieldList( + val validationFieldListMember: MemberShape, + val validationFieldStructure: StructureShape, + val validationFieldNameMember: MemberShape, + val maybeValidationFieldMessageMember: MemberShape?, +) 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..a34fd7d8471 --- /dev/null +++ b/codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/util/CustomValidationExceptionUtil.kt @@ -0,0 +1,22 @@ +/* + * 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.framework.rust.ValidationFieldNameTrait +import software.amazon.smithy.framework.rust.ValidationMessageTrait +import software.amazon.smithy.model.shapes.MemberShape + +/** + * 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" +} + +fun MemberShape.isValidationFieldName(): Boolean { + return this.hasTrait(ValidationFieldNameTrait.ID) || this.memberName == "name" +} 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..8f71649df1e --- /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.framework.rust.ValidationExceptionTrait +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 + +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..86b86a944ee 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.framework.rust#validationException + use smithy.framework.rust#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,140 @@ 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.framework.rust#validationException + use smithy.framework.rust#validationMessage + + service TestService { + operations: [TestOperation] + errors: [ + CustomValidationException + AnotherValidationException + ] + } + + 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.framework.rust#validationException + use smithy.framework.rust#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.framework.rust#validationException + use smithy.framework.rust#validationMessage + + service TestService { + operations: [TestOperation] + } + + operation TestOperation { + input: TestInputOutput, + output: TestInputOutput, + errors: [ + ValidationException + CustomValidationException + ] + } + + @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/UserProvidedValidationExceptionDecoratorTest.kt b/codegen-server/src/test/kotlin/software/amazon/smithy/rust/codegen/server/smithy/customizations/UserProvidedValidationExceptionDecoratorTest.kt new file mode 100644 index 00000000000..22a4b37203e --- /dev/null +++ b/codegen-server/src/test/kotlin/software/amazon/smithy/rust/codegen/server/smithy/customizations/UserProvidedValidationExceptionDecoratorTest.kt @@ -0,0 +1,387 @@ +/* + * 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.framework.rust.ValidationExceptionTrait +import software.amazon.smithy.framework.rust.ValidationFieldListTrait +import software.amazon.smithy.framework.rust.ValidationFieldNameTrait +import software.amazon.smithy.framework.rust.ValidationMessageTrait +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.testutil.asSmithyModel +import software.amazon.smithy.rust.codegen.server.smithy.testutil.serverIntegrationTest +import software.amazon.smithy.rust.codegen.server.smithy.testutil.serverTestCodegenContext + +internal class UserProvidedValidationExceptionDecoratorTest { + private val modelWithCustomValidation = + """ + namespace com.example + + use aws.protocols#restJson1 + use smithy.framework.rust#validationException + use smithy.framework.rust#validationMessage + use smithy.framework.rust#validationFieldList + use smithy.framework.rust#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.framework.rust#validationException + use smithy.framework.rust#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.firstStructureShapeWithValidationExceptionTrait(codegenContext.model)!! + } + + @Test + fun `firstStructureShapeWithValidationExceptionTrait 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 `firstStructureShapeWithValidationExceptionTrait 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.firstStructureShapeWithValidationExceptionTrait(codegenContext.model) + + result shouldBe null + } + + @Test + fun `validationMessageMember returns correct member shape`() { + val model = modelWithCustomValidation + val validationExceptionStructure = mockValidationException(model) + + val result = UserProvidedValidationExceptionDecorator().validationMessageMember(validationExceptionStructure) + + result shouldNotBe null + result.memberName shouldBe "customMessage" + result.hasTrait(ValidationMessageTrait.ID) shouldBe true + } + + @Test + fun `validationFieldListMember returns correct member shape`() { + val model = modelWithCustomValidation + val validationExceptionStructure = mockValidationException(model) + val result = + UserProvidedValidationExceptionDecorator().maybeValidationFieldList( + model, + validationExceptionStructure, + )!!.validationFieldListMember + + result shouldNotBe null + result.memberName shouldBe "customFieldList" + result.hasTrait(ValidationFieldListTrait.ID) shouldBe true + } + + @Test + fun `maybeValidationFieldList returns null when no field list exists`() { + val model = modelWithoutFieldList + val validationExceptionStructure = mockValidationException(model) + + val result = + UserProvidedValidationExceptionDecorator().maybeValidationFieldList( + model, + validationExceptionStructure, + ) + + result shouldBe null + } + + @Test + fun `validationFieldStructure returns correct structure shape`() { + val model = modelWithCustomValidation + val validationExceptionStructure = mockValidationException(model) + + val result = + UserProvidedValidationExceptionDecorator().maybeValidationFieldList( + model, + validationExceptionStructure, + )!!.validationFieldStructure + + result shouldNotBe null + result.id shouldBe ShapeId.from("com.example#ValidationExceptionField") + result.members().any { it.hasTrait(ValidationFieldNameTrait.ID) } shouldBe true + } + + @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.framework.rust#validationException + use smithy.framework.rust#validationFieldList + use smithy.framework.rust#validationFieldMessage + use smithy.framework.rust#validationFieldName + use smithy.framework.rust#validationMessage + + @restJson1 + service CustomValidationExample { + version: "1.0.0" + operations: [ + TestOperation + StreamingOperation + PublishMessages + ] + errors: [ + MyCustomValidationException + ] + } + + @http(method: "POST", uri: "/test") + operation TestOperation { + input: TestInput + } + + @http(method: "GET", uri: "/streaming-operation") + @readonly + operation StreamingOperation { + input := {} + output := { + @httpPayload + output: StreamingBlob = "" + } + } + + @streaming + blob StreamingBlob + + @http(method: "POST", uri: "/publish") + operation PublishMessages { + input: PublishMessagesInput + } + + @input + structure PublishMessagesInput { + @httpPayload + messages: PublishEvents + } + + @streaming + union PublishEvents { + message: Message + leave: LeaveEvent + } + + structure Message { + message: String + } + + structure LeaveEvent {} + + 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.framework.rust#validationException + use smithy.framework.rust#validationFieldList + use smithy.framework.rust#validationFieldMessage + use smithy.framework.rust#validationFieldName + use smithy.framework.rust#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..90e889a40cc --- /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.framework.rust#validationException + use smithy.framework.rust#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.framework.rust#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.framework.rust#validationException + use smithy.framework.rust#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.framework.rust#validationException + use smithy.framework.rust#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.framework.rust#validationException + use smithy.framework.rust#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.framework.rust#validationException + use smithy.framework.rust#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.framework.rust#validationException + use smithy.framework.rust#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/codegen-traits/build.gradle.kts b/codegen-traits/build.gradle.kts index 61c721c84c7..80098869f5c 100644 --- a/codegen-traits/build.gradle.kts +++ b/codegen-traits/build.gradle.kts @@ -10,7 +10,7 @@ plugins { description = "Smithy traits for Rust code generation" extra["displayName"] = "Smithy :: Rust :: Codegen :: Traits" -extra["moduleName"] = "software.amazon.smithy.rust.codegen.traits" +extra["moduleName"] = "software.amazon.smithy.framework.rust" dependencies { implementation(libs.smithy.model) diff --git a/codegen-traits/src/main/kotlin/software/amazon/smithy/rust/codegen/traits/ValidationExceptionTrait.kt b/codegen-traits/src/main/kotlin/software/amazon/smithy/framework/rust/ValidationExceptionTrait.kt similarity index 86% rename from codegen-traits/src/main/kotlin/software/amazon/smithy/rust/codegen/traits/ValidationExceptionTrait.kt rename to codegen-traits/src/main/kotlin/software/amazon/smithy/framework/rust/ValidationExceptionTrait.kt index 30e1f51a798..5138a8db609 100644 --- a/codegen-traits/src/main/kotlin/software/amazon/smithy/rust/codegen/traits/ValidationExceptionTrait.kt +++ b/codegen-traits/src/main/kotlin/software/amazon/smithy/framework/rust/ValidationExceptionTrait.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -package software.amazon.smithy.rust.codegen.traits +package software.amazon.smithy.framework.rust import software.amazon.smithy.model.SourceLocation import software.amazon.smithy.model.node.Node @@ -15,7 +15,7 @@ class ValidationExceptionTrait( sourceLocation: SourceLocation, ) : AbstractTrait(ID, sourceLocation) { companion object { - val ID: ShapeId = ShapeId.from("smithy.rust.codegen.traits#validationException") + val ID: ShapeId = ShapeId.from("smithy.framework.rust#validationException") } override fun createNode(): Node = Node.objectNode() diff --git a/codegen-traits/src/main/kotlin/software/amazon/smithy/rust/codegen/traits/ValidationFieldListTrait.kt b/codegen-traits/src/main/kotlin/software/amazon/smithy/framework/rust/ValidationFieldListTrait.kt similarity index 86% rename from codegen-traits/src/main/kotlin/software/amazon/smithy/rust/codegen/traits/ValidationFieldListTrait.kt rename to codegen-traits/src/main/kotlin/software/amazon/smithy/framework/rust/ValidationFieldListTrait.kt index c98d24d6f27..d15e7ef8cff 100644 --- a/codegen-traits/src/main/kotlin/software/amazon/smithy/rust/codegen/traits/ValidationFieldListTrait.kt +++ b/codegen-traits/src/main/kotlin/software/amazon/smithy/framework/rust/ValidationFieldListTrait.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -package software.amazon.smithy.rust.codegen.traits +package software.amazon.smithy.framework.rust import software.amazon.smithy.model.SourceLocation import software.amazon.smithy.model.node.Node @@ -15,7 +15,7 @@ class ValidationFieldListTrait( sourceLocation: SourceLocation, ) : AbstractTrait(ID, sourceLocation) { companion object { - val ID: ShapeId = ShapeId.from("smithy.rust.codegen.traits#validationFieldList") + val ID: ShapeId = ShapeId.from("smithy.framework.rust#validationFieldList") } override fun createNode(): Node = Node.objectNode() diff --git a/codegen-traits/src/main/kotlin/software/amazon/smithy/rust/codegen/traits/ValidationFieldMessageTrait.kt b/codegen-traits/src/main/kotlin/software/amazon/smithy/framework/rust/ValidationFieldMessageTrait.kt similarity index 86% rename from codegen-traits/src/main/kotlin/software/amazon/smithy/rust/codegen/traits/ValidationFieldMessageTrait.kt rename to codegen-traits/src/main/kotlin/software/amazon/smithy/framework/rust/ValidationFieldMessageTrait.kt index 79c5e94f8c5..80f0db3cd38 100644 --- a/codegen-traits/src/main/kotlin/software/amazon/smithy/rust/codegen/traits/ValidationFieldMessageTrait.kt +++ b/codegen-traits/src/main/kotlin/software/amazon/smithy/framework/rust/ValidationFieldMessageTrait.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -package software.amazon.smithy.rust.codegen.traits +package software.amazon.smithy.framework.rust import software.amazon.smithy.model.SourceLocation import software.amazon.smithy.model.node.Node @@ -15,7 +15,7 @@ class ValidationFieldMessageTrait( sourceLocation: SourceLocation, ) : AbstractTrait(ID, sourceLocation) { companion object { - val ID: ShapeId = ShapeId.from("smithy.rust.codegen.traits#validationFieldMessage") + val ID: ShapeId = ShapeId.from("smithy.framework.rust#validationFieldMessage") } override fun createNode(): Node = Node.objectNode() diff --git a/codegen-traits/src/main/kotlin/software/amazon/smithy/rust/codegen/traits/ValidationFieldNameTrait.kt b/codegen-traits/src/main/kotlin/software/amazon/smithy/framework/rust/ValidationFieldNameTrait.kt similarity index 86% rename from codegen-traits/src/main/kotlin/software/amazon/smithy/rust/codegen/traits/ValidationFieldNameTrait.kt rename to codegen-traits/src/main/kotlin/software/amazon/smithy/framework/rust/ValidationFieldNameTrait.kt index 35681497bf3..e07f155ed0f 100644 --- a/codegen-traits/src/main/kotlin/software/amazon/smithy/rust/codegen/traits/ValidationFieldNameTrait.kt +++ b/codegen-traits/src/main/kotlin/software/amazon/smithy/framework/rust/ValidationFieldNameTrait.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -package software.amazon.smithy.rust.codegen.traits +package software.amazon.smithy.framework.rust import software.amazon.smithy.model.SourceLocation import software.amazon.smithy.model.node.Node @@ -15,7 +15,7 @@ class ValidationFieldNameTrait( sourceLocation: SourceLocation, ) : AbstractTrait(ID, sourceLocation) { companion object { - val ID: ShapeId = ShapeId.from("smithy.rust.codegen.traits#validationFieldName") + val ID: ShapeId = ShapeId.from("smithy.framework.rust#validationFieldName") } override fun createNode(): Node = Node.objectNode() diff --git a/codegen-traits/src/main/kotlin/software/amazon/smithy/rust/codegen/traits/ValidationMessageTrait.kt b/codegen-traits/src/main/kotlin/software/amazon/smithy/framework/rust/ValidationMessageTrait.kt similarity index 86% rename from codegen-traits/src/main/kotlin/software/amazon/smithy/rust/codegen/traits/ValidationMessageTrait.kt rename to codegen-traits/src/main/kotlin/software/amazon/smithy/framework/rust/ValidationMessageTrait.kt index 39851823ded..2cb8c9b60ea 100644 --- a/codegen-traits/src/main/kotlin/software/amazon/smithy/rust/codegen/traits/ValidationMessageTrait.kt +++ b/codegen-traits/src/main/kotlin/software/amazon/smithy/framework/rust/ValidationMessageTrait.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -package software.amazon.smithy.rust.codegen.traits +package software.amazon.smithy.framework.rust import software.amazon.smithy.model.SourceLocation import software.amazon.smithy.model.node.Node @@ -15,7 +15,7 @@ class ValidationMessageTrait( sourceLocation: SourceLocation, ) : AbstractTrait(ID, sourceLocation) { companion object { - val ID: ShapeId = ShapeId.from("smithy.rust.codegen.traits#validationMessage") + val ID: ShapeId = ShapeId.from("smithy.framework.rust#validationMessage") } override fun createNode(): Node = Node.objectNode() diff --git a/codegen-traits/src/main/resources/META-INF/smithy/validation-exception.smithy b/codegen-traits/src/main/resources/META-INF/smithy/validation-exception.smithy index 5765d0c3e0c..9a1282edd3f 100644 --- a/codegen-traits/src/main/resources/META-INF/smithy/validation-exception.smithy +++ b/codegen-traits/src/main/resources/META-INF/smithy/validation-exception.smithy @@ -1,6 +1,6 @@ $version: "2.0" -namespace smithy.rust.codegen.traits +namespace smithy.framework.rust /// Marks a structure as a custom validation exception that can replace /// smithy.framework#ValidationException in operation error lists. @@ -9,11 +9,11 @@ structure validationException {} /// Marks a String member as the primary message field for a validation exception. /// Exactly one member in a @validationException structure must have this trait. -@trait(selector: "structure[trait|smithy.rust.codegen.traits#validationException] > member") +@trait(selector: "structure[trait|smithy.framework.rust#validationException] > member") structure validationMessage {} /// Marks a member as containing the list of field-level validation errors. -/// The target shape must be a String, List, or List where +/// The target shape must be a List where /// the structure contains validation field information. @trait(selector: "structure > member") structure validationFieldList {} diff --git a/codegen-traits/src/test/kotlin/software/amazon/smithy/rust/codegen/traits/ValidationExceptionTraitTest.kt b/codegen-traits/src/test/kotlin/software/amazon/smithy/rust/codegen/traits/ValidationExceptionTraitTest.kt index c4bbf105ea5..b72d14417ba 100644 --- a/codegen-traits/src/test/kotlin/software/amazon/smithy/rust/codegen/traits/ValidationExceptionTraitTest.kt +++ b/codegen-traits/src/test/kotlin/software/amazon/smithy/rust/codegen/traits/ValidationExceptionTraitTest.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -package software.amazon.smithy.rust.codegen.traits +package software.amazon.smithy.framework.rust import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test @@ -14,10 +14,10 @@ class ValidationExceptionTraitTest { @Test fun testValidationExceptionTrait() { val trait = ValidationExceptionTrait(SourceLocation.NONE) - assertEquals(ShapeId.from("smithy.rust.codegen.traits#validationException"), trait.toShapeId()) + assertEquals(ShapeId.from("smithy.framework.rust#validationException"), trait.toShapeId()) // Test the Provider val provider = ValidationExceptionTrait.Provider() - assertEquals(ShapeId.from("smithy.rust.codegen.traits#validationException"), provider.shapeId) + assertEquals(ShapeId.from("smithy.framework.rust#validationException"), provider.shapeId) } } diff --git a/codegen-traits/src/test/kotlin/software/amazon/smithy/rust/codegen/traits/ValidationFieldListTraitTest.kt b/codegen-traits/src/test/kotlin/software/amazon/smithy/rust/codegen/traits/ValidationFieldListTraitTest.kt index a57aa6960a2..7ddd9513267 100644 --- a/codegen-traits/src/test/kotlin/software/amazon/smithy/rust/codegen/traits/ValidationFieldListTraitTest.kt +++ b/codegen-traits/src/test/kotlin/software/amazon/smithy/rust/codegen/traits/ValidationFieldListTraitTest.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -package software.amazon.smithy.rust.codegen.traits +package software.amazon.smithy.framework.rust import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test @@ -14,10 +14,10 @@ class ValidationFieldListTraitTest { @Test fun testValidationFieldListTrait() { val trait = ValidationFieldListTrait(SourceLocation.NONE) - assertEquals(ShapeId.from("smithy.rust.codegen.traits#validationFieldList"), trait.toShapeId()) + assertEquals(ShapeId.from("smithy.framework.rust#validationFieldList"), trait.toShapeId()) // Test the Provider val provider = ValidationFieldListTrait.Provider() - assertEquals(ShapeId.from("smithy.rust.codegen.traits#validationFieldList"), provider.shapeId) + assertEquals(ShapeId.from("smithy.framework.rust#validationFieldList"), provider.shapeId) } } diff --git a/codegen-traits/src/test/kotlin/software/amazon/smithy/rust/codegen/traits/ValidationFieldMessageTraitTest.kt b/codegen-traits/src/test/kotlin/software/amazon/smithy/rust/codegen/traits/ValidationFieldMessageTraitTest.kt index ebee879efa4..4d3e75e06e5 100644 --- a/codegen-traits/src/test/kotlin/software/amazon/smithy/rust/codegen/traits/ValidationFieldMessageTraitTest.kt +++ b/codegen-traits/src/test/kotlin/software/amazon/smithy/rust/codegen/traits/ValidationFieldMessageTraitTest.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -package software.amazon.smithy.rust.codegen.traits +package software.amazon.smithy.framework.rust import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test @@ -14,10 +14,10 @@ class ValidationFieldMessageTraitTest { @Test fun testValidationFieldMessageTrait() { val trait = ValidationFieldMessageTrait(SourceLocation.NONE) - assertEquals(ShapeId.from("smithy.rust.codegen.traits#validationFieldMessage"), trait.toShapeId()) + assertEquals(ShapeId.from("smithy.framework.rust#validationFieldMessage"), trait.toShapeId()) // Test the Provider val provider = ValidationFieldMessageTrait.Provider() - assertEquals(ShapeId.from("smithy.rust.codegen.traits#validationFieldMessage"), provider.shapeId) + assertEquals(ShapeId.from("smithy.framework.rust#validationFieldMessage"), provider.shapeId) } } diff --git a/codegen-traits/src/test/kotlin/software/amazon/smithy/rust/codegen/traits/ValidationFieldNameTraitTest.kt b/codegen-traits/src/test/kotlin/software/amazon/smithy/rust/codegen/traits/ValidationFieldNameTraitTest.kt index 499e092676d..1c91a4d8616 100644 --- a/codegen-traits/src/test/kotlin/software/amazon/smithy/rust/codegen/traits/ValidationFieldNameTraitTest.kt +++ b/codegen-traits/src/test/kotlin/software/amazon/smithy/rust/codegen/traits/ValidationFieldNameTraitTest.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -package software.amazon.smithy.rust.codegen.traits +package software.amazon.smithy.framework.rust import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test @@ -14,10 +14,10 @@ class ValidationFieldNameTraitTest { @Test fun testValidationFieldNameTrait() { val trait = ValidationFieldNameTrait(SourceLocation.NONE) - assertEquals(ShapeId.from("smithy.rust.codegen.traits#validationFieldName"), trait.toShapeId()) + assertEquals(ShapeId.from("smithy.framework.rust#validationFieldName"), trait.toShapeId()) // Test the Provider val provider = ValidationFieldNameTrait.Provider() - assertEquals(ShapeId.from("smithy.rust.codegen.traits#validationFieldName"), provider.shapeId) + assertEquals(ShapeId.from("smithy.framework.rust#validationFieldName"), provider.shapeId) } } diff --git a/codegen-traits/src/test/kotlin/software/amazon/smithy/rust/codegen/traits/ValidationMessageTraitTest.kt b/codegen-traits/src/test/kotlin/software/amazon/smithy/rust/codegen/traits/ValidationMessageTraitTest.kt index 50ecda2c660..8eb68e1b1af 100644 --- a/codegen-traits/src/test/kotlin/software/amazon/smithy/rust/codegen/traits/ValidationMessageTraitTest.kt +++ b/codegen-traits/src/test/kotlin/software/amazon/smithy/rust/codegen/traits/ValidationMessageTraitTest.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -package software.amazon.smithy.rust.codegen.traits +package software.amazon.smithy.framework.rust import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test @@ -14,10 +14,10 @@ class ValidationMessageTraitTest { @Test fun testValidationMessageTrait() { val trait = ValidationMessageTrait(SourceLocation.NONE) - assertEquals(ShapeId.from("smithy.rust.codegen.traits#validationMessage"), trait.toShapeId()) + assertEquals(ShapeId.from("smithy.framework.rust#validationMessage"), trait.toShapeId()) // Test the Provider val provider = ValidationMessageTrait.Provider() - assertEquals(ShapeId.from("smithy.rust.codegen.traits#validationMessage"), provider.shapeId) + assertEquals(ShapeId.from("smithy.framework.rust#validationMessage"), provider.shapeId) } } 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..9886c56bc24 --- /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.framework.rust#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