diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/RustServerCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/RustServerCodegen.java index 29dd89d8c148..8e0add99b269 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/RustServerCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/RustServerCodegen.java @@ -1213,6 +1213,7 @@ public CodegenModel fromModel(String name, Schema model) { String dataType = toModelName(ref); mdl.dataType = dataType; mdl.isAlias = false; + mdl.vendorExtensions.put("x-needs-nested-validation", true); LOGGER.debug("Schema for: " + name + " is wrapper for: " + dataType); } @@ -1331,6 +1332,19 @@ public CodegenModel fromModel(String name, Schema model) { } } + // Match the property validation rule for model-valued newtype fields. + boolean needsNestedValidation = (mdl.dataType != null && mdl.dataType.contains("models::")) + || (mdl.getItems() != null && mdl.getItems().dataType != null + && mdl.getItems().dataType.contains("models::")); + if (!mdl.oneOf.isEmpty() || !mdl.anyOf.isEmpty()) { + // Primitive alternatives do not implement Validate. + needsNestedValidation = ModelUtils.getInterfaces(model).stream() + .allMatch(member -> getTypeDeclaration(member).contains("models::")); + } + if (needsNestedValidation) { + mdl.vendorExtensions.put("x-needs-nested-validation", true); + } + mdl.vendorExtensions.put("x-upper-case-name", name.toUpperCase(Locale.ROOT)); mdl.vendorExtensions.put("x-is-string", isString); mdl.vendorExtensions.put("x-to-string-support", toStringSupport); @@ -1644,6 +1658,10 @@ public void postProcessModelProperty(CodegenModel model, CodegenProperty propert property.vendorExtensions.put("x-needs-nested-validation", true); } + if (property.isArray && property.isNullable) { + property.vendorExtensions.put("x-nullable-array", true); + } + // TODO: We should avoid reverse engineering primitive type status from the data type String strippedType = stripNullable(property.dataType); if (!languageSpecificPrimitives.contains(strippedType)) { diff --git a/modules/openapi-generator/src/main/resources/rust-server/models.mustache b/modules/openapi-generator/src/main/resources/rust-server/models.mustache index 3073e56feaf5..9fa269905f57 100644 --- a/modules/openapi-generator/src/main/resources/rust-server/models.mustache +++ b/modules/openapi-generator/src/main/resources/rust-server/models.mustache @@ -108,7 +108,8 @@ impl std::str::FromStr for {{{classname}}} { #[serde(rename = "{{{.}}}")] {{/xmlName}} pub struct {{{classname}}}( -{{>validate}} {{{dataType}}} +{{>validate}}{{^hasConflictingModelNames}}{{#exts.x-needs-nested-validation}} #[cfg_attr(feature = "validate", validate)] +{{/exts.x-needs-nested-validation}}{{/hasConflictingModelNames}} {{{dataType}}} ); impl std::convert::From<{{{dataType}}}> for {{{classname}}} { @@ -250,7 +251,8 @@ pub struct {{{classname}}}( #[serde(serialize_with = "wrap_in_{{{x-item-xml-name}}}")] {{/x-item-xml-name}} {{/exts}} - Vec<{{{arrayModelType}}}> +{{^hasConflictingModelNames}}{{>validate}}{{#exts.x-needs-nested-validation}} #[cfg_attr(feature = "validate", validate)] +{{/exts.x-needs-nested-validation}}{{/hasConflictingModelNames}} Vec<{{{arrayModelType}}}> ); impl std::convert::From> for {{{classname}}} { @@ -419,7 +421,7 @@ pub struct {{{classname}}} { {{^hasConflictingModelNames}}{{>validate}}{{/hasConflictingModelNames}} {{^hasConflictingModelNames}} {{#exts.x-needs-nested-validation}} - {{^minLength}}{{^maxLength}}{{^minItems}}{{^maxItems}}#[cfg_attr(feature = "validate", validate)]{{/maxItems}}{{/minItems}}{{/maxLength}}{{/minLength}} + #[cfg_attr(feature = "validate", validate)] {{/exts.x-needs-nested-validation}} {{/hasConflictingModelNames}} pub {{{name}}}: {{{dataType}}}, @@ -432,7 +434,7 @@ pub struct {{{classname}}} { {{^hasConflictingModelNames}}{{>validate}}{{/hasConflictingModelNames}} {{^hasConflictingModelNames}} {{#exts.x-needs-nested-validation}} - {{^minLength}}{{^maxLength}}{{^minItems}}{{^maxItems}}#[cfg_attr(feature = "validate", validate)]{{/maxItems}}{{/minItems}}{{/maxLength}}{{/minLength}} + #[cfg_attr(feature = "validate", validate)] {{/exts.x-needs-nested-validation}} {{/hasConflictingModelNames}} #[serde(skip_serializing_if="Option::is_none")] diff --git a/modules/openapi-generator/src/main/resources/rust-server/validate.mustache b/modules/openapi-generator/src/main/resources/rust-server/validate.mustache index 97d9551df6b7..1a8856fe57b3 100644 --- a/modules/openapi-generator/src/main/resources/rust-server/validate.mustache +++ b/modules/openapi-generator/src/main/resources/rust-server/validate.mustache @@ -45,11 +45,12 @@ #[cfg_attr(feature = "validate", validate(pattern = r"{{{pattern}}}"))] {{/isByteArray}} {{/pattern}} +{{! Nullable arrays retain their existing bounds: the runtime has no nullable item-count traits. }} {{#minItems}} - #[cfg_attr(feature = "validate", validate(min_length = {{{minItems}}}))] + #[cfg_attr(feature = "validate", validate({{#exts.x-nullable-array}}min_length{{/exts.x-nullable-array}}{{^exts.x-nullable-array}}min_items{{/exts.x-nullable-array}} = {{{minItems}}}))] {{/minItems}} {{#maxItems}} - #[cfg_attr(feature = "validate", validate(max_length = {{{maxItems}}}))] + #[cfg_attr(feature = "validate", validate({{#exts.x-nullable-array}}max_length{{/exts.x-nullable-array}}{{^exts.x-nullable-array}}max_items{{/exts.x-nullable-array}} = {{{maxItems}}}))] {{/maxItems}} {{#isEnum}} {{#allowableValues}} diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/rust/RustServerCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/rust/RustServerCodegenTest.java index ff3bf2c9b829..3d92cdfdbe39 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/rust/RustServerCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/rust/RustServerCodegenTest.java @@ -1,5 +1,6 @@ package org.openapitools.codegen.rust; +import org.apache.commons.io.FileUtils; import org.openapitools.codegen.DefaultGenerator; import org.openapitools.codegen.TestUtils; import org.openapitools.codegen.config.CodegenConfigurator; @@ -18,6 +19,36 @@ */ public class RustServerCodegenTest { + @Test + public void testRecursiveModelValidation() throws IOException { + Path target = Files.createTempDirectory("recursive-validation"); + try { + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("rust-server") + .setInputSpec("src/test/resources/3_0/rust-server/recursive-validation.yaml") + .setGenerateAliasAsModel(true) + .setOutputDir(target.toAbsolutePath().toString().replace("\\", "/")); + new DefaultGenerator().opts(configurator.toClientOptInput()).generate(); + + Path modelsPath = target.resolve("src/models.rs"); + TestUtils.assertFileContains(modelsPath, + "validate(min_items = 1)", + "validate(max_items = 2)", + "#[cfg_attr(feature = \"validate\", validate)]\n Vec", + "#[cfg_attr(feature = \"validate\", validate)]\n std::collections::HashMap", + "#[cfg_attr(feature = \"validate\", validate)]\n StringValue", + "#[cfg_attr(feature = \"validate\", validate)]\n swagger::OneOf2", + "#[cfg_attr(feature = \"validate\", validate)]\n swagger::AnyOf2", + "#[cfg_attr(feature = \"validate\", validate)]\n swagger::OneOf2", + "pub struct MixedChoice(\n swagger::OneOf2", + "pub struct PrimitiveChoice(\n swagger::AnyOf2", + "#[cfg_attr(feature = \"validate\", validate)]\n pub children: Vec", + "#[cfg_attr(feature = \"validate\", validate)]\n #[serde(skip_serializing_if=\"Option::is_none\")]\n pub optional_children: Option>"); + } finally { + FileUtils.deleteDirectory(target.toFile()); + } + } + /** * Test that integer parameters with minimum/maximum constraints are assigned appropriate Rust types. * This tests that integer parameter type fitting logic is applied to CodegenParameter objects. diff --git a/modules/openapi-generator/src/test/resources/3_0/rust-server/recursive-validation.rs b/modules/openapi-generator/src/test/resources/3_0/rust-server/recursive-validation.rs new file mode 100644 index 000000000000..88eb14970883 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/rust-server/recursive-validation.rs @@ -0,0 +1,124 @@ +// Generate recursive-validation.yaml with --generate-alias-as-model and +// --additional-properties packageName=recursive_validation. Copy this file to +// tests/recursive_validation.rs in the output, then run +// cargo test --no-default-features --features validate --test recursive_validation. +#![cfg(feature = "validate")] +use recursive_validation::models::{ + BoundedChildren, MixedChoice, ModelAnyOf, ModelChoice, NamedModelChoice, NullableChildren, + NullableStringItems, Parent, Positive, PositiveMap, Positives, PrimitiveChoice, StringAlias, + StringItems, StringValue, +}; +use serde_valid::Validate; + +#[test] +fn root_validation_reaches_array_wrapper_items_and_bounds() { + for values in [vec![], vec![1], vec![1, 1], vec![1, 1, 1], vec![0]] { + let expected = (1..=2).contains(&values.len()) && values.iter().all(|value| *value >= 1); + let root = Parent { + children: Positives::from( + values + .into_iter() + .map(|value| Positive { value }) + .collect::>(), + ), + }; + assert_eq!(root.validate().is_ok(), expected); + } +} + +#[test] +fn bounded_fields_validate_their_size_and_children() { + for values in [vec![], vec![1], vec![1, 1], vec![1, 1, 1], vec![0]] { + for optional in [false, true] { + let expected = + (1..=2).contains(&values.len()) && values.iter().all(|value| *value >= 1); + let children = values + .iter() + .map(|value| Positive { value: *value }) + .collect(); + let root = if optional { + BoundedChildren { + children: vec![Positive { value: 1 }], + optional_children: Some(children), + } + } else { + BoundedChildren { + children, + optional_children: None, + } + }; + assert_eq!(root.validate().is_ok(), expected); + } + } +} + +#[test] +fn primitive_array_wrappers_validate_their_size() { + for size in 0..=3 { + let value = StringItems::from(vec![String::new(); size]); + assert_eq!(value.validate().is_ok(), (1..=2).contains(&size)); + } +} + +#[test] +fn map_wrappers_validate_their_values() { + for value in [0, 1] { + let root = PositiveMap::from(std::collections::HashMap::from([( + "key".to_owned(), + Positive { value }, + )])); + assert_eq!(root.validate().is_ok(), value == 1); + } +} + +#[test] +fn reference_wrappers_validate_their_inner_model() { + for value in ["", "x"] { + let root = StringAlias::from(StringValue::from(value.to_owned())); + assert_eq!(root.validate().is_ok(), !value.is_empty()); + } +} + +#[test] +fn union_wrappers_compile_with_model_and_primitive_members() { + let model = ModelChoice::from(swagger::OneOf2::A(Positive { value: 1 })); + let named = NamedModelChoice::from(swagger::OneOf2::A(Positive { value: 1 })); + let any = ModelAnyOf::from(swagger::AnyOf2::B(Positives::from(vec![Positive { + value: 1, + }]))); + let mixed = MixedChoice::from(swagger::OneOf2::B("future".to_owned())); + let primitive: PrimitiveChoice = serde_json::from_str("42").unwrap(); + assert!(model.validate().is_ok()); + assert!(named.validate().is_ok()); + assert!(any.validate().is_ok()); + assert!(mixed.validate().is_ok()); + assert!(primitive.validate().is_ok()); +} + +#[test] +fn bounded_nullable_fields_keep_compiling_and_validate_present_models() { + let value = NullableChildren { + children: swagger::Nullable::Null, + optional_children: None, + }; + assert!(value.validate().is_ok()); + for optional in [false, true] { + for value in [0, 1] { + let children = swagger::Nullable::Present(vec![Positive { value }]); + let root = if optional { + NullableChildren { + children: swagger::Nullable::Null, + optional_children: Some(children), + } + } else { + NullableChildren { + children, + optional_children: Some(swagger::Nullable::Null), + } + }; + assert_eq!(root.validate().is_ok(), value == 1); + } + } + let items = NullableStringItems::from(vec![String::new()]); + assert!(items.validate().is_ok()); +} diff --git a/modules/openapi-generator/src/test/resources/3_0/rust-server/recursive-validation.yaml b/modules/openapi-generator/src/test/resources/3_0/rust-server/recursive-validation.yaml new file mode 100644 index 000000000000..b6ec2848d79c --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/rust-server/recursive-validation.yaml @@ -0,0 +1,106 @@ +openapi: 3.0.3 +info: {title: Recursive validation, version: 1.0.0} +paths: + /parents/{name}: + get: + operationId: getParent + parameters: + - name: name + in: path + required: true + schema: {type: string, pattern: '^x+$'} + responses: + '200': + description: A parent + content: + application/json: + schema: {$ref: '#/components/schemas/Parent'} +components: + schemas: + Positive: + type: object + required: [value] + properties: + value: {type: integer, minimum: 1} + Positives: + type: array + minItems: 1 + maxItems: 2 + items: {$ref: '#/components/schemas/Positive'} + Parent: + type: object + required: [children] + properties: + children: {$ref: '#/components/schemas/Positives'} + ModelChoice: + oneOf: + - $ref: '#/components/schemas/Positive' + - $ref: '#/components/schemas/Parent' + NamedModelChoice: + x-one-of-name: 'swagger::OneOf2' + oneOf: + - $ref: '#/components/schemas/Positive' + - $ref: '#/components/schemas/Parent' + MixedChoice: + oneOf: + - $ref: '#/components/schemas/Positive' + - type: string + PrimitiveChoice: + anyOf: + - type: integer + - type: string + ModelAnyOf: + anyOf: + - $ref: '#/components/schemas/Positive' + - $ref: '#/components/schemas/Positives' + BoundedChildren: + type: object + required: [children] + properties: + children: + type: array + minItems: 1 + maxItems: 2 + items: + $ref: '#/components/schemas/Positive' + optionalChildren: + type: array + minItems: 1 + maxItems: 2 + items: + $ref: '#/components/schemas/Positive' + StringItems: + type: array + minItems: 1 + maxItems: 2 + items: {type: string} + PositiveMap: + type: object + additionalProperties: {$ref: '#/components/schemas/Positive'} + StringValue: + type: string + minLength: 1 + StringAlias: + $ref: '#/components/schemas/StringValue' + NullableChildren: + type: object + required: [children] + properties: + children: + type: array + nullable: true + minItems: 1 + maxItems: 2 + items: {$ref: '#/components/schemas/Positive'} + optionalChildren: + type: array + nullable: true + minItems: 1 + maxItems: 2 + items: {$ref: '#/components/schemas/Positive'} + NullableStringItems: + type: array + nullable: true + minItems: 1 + maxItems: 2 + items: {type: string} diff --git a/samples/server/petstore/rust-server/output/petstore-with-fake-endpoints-models-for-testing/src/models.rs b/samples/server/petstore/rust-server/output/petstore-with-fake-endpoints-models-for-testing/src/models.rs index c724b2becd44..a4e07b567a0e 100644 --- a/samples/server/petstore/rust-server/output/petstore-with-fake-endpoints-models-for-testing/src/models.rs +++ b/samples/server/petstore/rust-server/output/petstore-with-fake-endpoints-models-for-testing/src/models.rs @@ -409,6 +409,7 @@ impl Animal { #[cfg_attr(feature = "validate", derive(Validate))] #[cfg_attr(feature = "conversion", derive(frunk::LabelledGeneric))] pub struct AnimalFarm( + #[cfg_attr(feature = "validate", validate)] Vec );