Repository: juhaku/utoipa (Rust)

Issue:
Query params should not be nullable
Consider a query params struct like this:
```rust
#[derive(Debug, serde::Deserialize, utoipa::IntoParams)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[into_params(parameter_in = Query)]
pub struct QueryParams {
    pub schema_format: Option<SchemaFormat>,
}

#[derive(
    Debug,
    Default,
    Clone,
    Copy,
    PartialEq,
    Eq,
    serde::Serialize,
    serde::Deserialize,
    utoipa::ToSchema,
)]
pub enum SchemaFormat {
    #[default]
    ArrowJson,
    Parquet,
    ParquetJson,
}
```

Utoipa generates a spec like this:

```json
{
  "openapi": "3.1.0",
  "paths": {
    "/query": {
      "get": {
        "operationId": "query_handler",
        "parameters": [
          {
            "description": "How to encode the schema of the result",
            "in": "query",
            "name": "schemaFormat",
            "required": false,
            "schema": {
              "oneOf": [
                {
                  "type": "null"
                },
                {
                  "$ref": "#/components/schemas/SchemaFormat"
                }
              ]
            }
          }
       ]
      }
    }
  },
  "components": {
    "schemas": {
      "SchemaFormat": {
        "enum": [
          "ArrowJson",
          "Parquet",
          "ParquetJson"
        ],
        "type": "string"
      }
    }
  }
}
```

When we run [IBM openapi-validator](https://github.com/IBM/openapi-validator) on this spec it gives an error:
```
Errors:

  Message :   Invalid type; valid types are: array, boolean, integer, number, object, string
  Rule    :   ibm-schema-type-format
  Path    :   paths./query.get.parameters.5.schema.oneOf.0
```
I think the validator is correct here, as in case of a query parameter there cannot be `null` value, as it would be non-distinguishable from a `"null"` string.

My expectation is that for an optional query parameter utoipa should only specify `"required": false` but not make value nullable.



Fix (patch):
diff --git a/utoipa-gen/CHANGELOG.md b/utoipa-gen/CHANGELOG.md
index bbcdac92..ca870952 100644
--- a/utoipa-gen/CHANGELOG.md
+++ b/utoipa-gen/CHANGELOG.md
@@ -13,6 +13,7 @@
 
 ### Changed
 
+* Adjust params code to not set `nullable` on `Option` for `Query` params (https://github.com/juhaku/utoipa/pull/1248)
 * Use `insta` for snapshot testing (https://github.com/juhaku/utoipa/pull/1247)
 * Make `parse_named_attributes` a method of `MediaTypeAttr` (https://github.com/juhaku/utoipa/pull/1236)
 * Use a re-exported `serde_json` dependency in macros instead of implicitly requiring it as dependency in end projects (https://github.com/juhaku/utoipa/pull/1243)
diff --git a/utoipa-gen/src/component.rs b/utoipa-gen/src/component.rs
index 45808ad9..90bce51d 100644
--- a/utoipa-gen/src/component.rs
+++ b/utoipa-gen/src/component.rs
@@ -694,6 +694,18 @@ pub struct ComponentSchemaProps<'c> {
     pub description: Option<&'c ComponentDescription<'c>>,
 }
 
+impl ComponentSchemaProps<'_> {
+    fn set_nullable(&mut self) {
+        if !self
+            .features
+            .iter()
+            .any(|feature| matches!(feature, Feature::Nullable(_)))
+        {
+            self.features.push(Nullable::new().into());
+        }
+    }
+}
+
 #[cfg_attr(feature = "debug", derive(Debug))]
 pub enum ComponentDescription<'c> {
     CommentAttributes(&'c CommentAttributes),
@@ -750,11 +762,33 @@ pub struct ComponentSchema {
 }
 
 impl ComponentSchema {
-    pub fn new(
+    pub fn for_params(
+        mut schema_props: ComponentSchemaProps,
+        option_is_nullable: bool,
+    ) -> Result<Self, Diagnostics> {
+        // Add nullable feature if not already exists.
+        // Option is always nullable, except when used in query parameters.
+        if schema_props.type_tree.is_option() && option_is_nullable {
+            schema_props.set_nullable()
+        }
+
+        Self::new_inner(schema_props)
+    }
+
+    pub fn new(mut schema_props: ComponentSchemaProps) -> Result<Self, Diagnostics> {
+        // Add nullable feature if not already exists. Option is always nullable
+        if schema_props.type_tree.is_option() {
+            schema_props.set_nullable();
+        }
+
+        Self::new_inner(schema_props)
+    }
+
+    fn new_inner(
         ComponentSchemaProps {
             container,
             type_tree,
-            mut features,
+            features,
             description,
         }: ComponentSchemaProps,
     ) -> Result<Self, Diagnostics> {
@@ -791,13 +825,6 @@ impl ComponentSchema {
                 description,
             )?,
             Some(GenericType::Option) => {
-                // Add nullable feature if not already exists. Option is always nullable
-                if !features
-                    .iter()
-                    .any(|feature| matches!(feature, Feature::Nullable(_)))
-                {
-                    features.push(Nullable::new().into());
-                }
                 let child = type_tree
                     .children
                     .as_ref()
diff --git a/utoipa-gen/src/component/features/attributes.rs b/utoipa-gen/src/component/features/attributes.rs
index 872b0083..e0306d0b 100644
--- a/utoipa-gen/src/component/features/attributes.rs
+++ b/utoipa-gen/src/component/features/attributes.rs
@@ -414,6 +414,12 @@ impl_feature! {
     pub struct ParameterIn(parameter::ParameterIn);
 }
 
+impl ParameterIn {
+    pub fn is_query(&self) -> bool {
+        matches!(self.0, parameter::ParameterIn::Query)
+    }
+}
+
 impl Parse for ParameterIn {
     fn parse(input: syn::parse::ParseStream, _: Ident) -> syn::Result<Self> {
         parse_utils::parse_next(input, || input.parse::<parameter::ParameterIn>().map(Self))
diff --git a/utoipa-gen/src/component/into_params.rs b/utoipa-gen/src/component/into_params.rs
index 7f3868db..3871cd80 100644
--- a/utoipa-gen/src/component/into_params.rs
+++ b/utoipa-gen/src/component/into_params.rs
@@ -407,12 +407,18 @@ impl Param {
             });
             tokens.extend(param_features.to_token_stream()?);
 
-            let schema = ComponentSchema::new(component::ComponentSchemaProps {
-                type_tree: component,
-                features: schema_features,
-                description: None,
-                container: &Container { generics },
-            })?;
+            let is_query = matches!(container_attributes.parameter_in, Some(Feature::ParameterIn(p)) if p.is_query());
+            let option_is_nullable = !is_query;
+
+            let schema = ComponentSchema::for_params(
+                component::ComponentSchemaProps {
+                    type_tree: component,
+                    features: schema_features,
+                    description: None,
+                    container: &Container { generics },
+                },
+                option_is_nullable,
+            )?;
             let schema_tokens = schema.to_token_stream();
 
             tokens.extend(quote! { .schema(Some(#schema_tokens)).build() });
diff --git a/utoipa-gen/src/path/parameter.rs b/utoipa-gen/src/path/parameter.rs
index 7bb3ede1..1f37a9e5 100644
--- a/utoipa-gen/src/path/parameter.rs
+++ b/utoipa-gen/src/path/parameter.rs
@@ -126,16 +126,21 @@ impl ToTokensDiagnostics for Parameter<'_> {
 ))]
 impl<'a> From<crate::ext::ValueArgument<'a>> for Parameter<'a> {
     fn from(argument: crate::ext::ValueArgument<'a>) -> Self {
+        let parameter_in = if argument.argument_in == crate::ext::ArgumentIn::Path {
+            ParameterIn::Path
+        } else {
+            ParameterIn::Query
+        };
+
+        let option_is_nullable = parameter_in != ParameterIn::Query;
+
         Self::Value(ValueParameter {
             name: argument.name.unwrap_or_else(|| Cow::Owned(String::new())),
-            parameter_in: if argument.argument_in == crate::ext::ArgumentIn::Path {
-                ParameterIn::Path
-            } else {
-                ParameterIn::Query
-            },
+            parameter_in,
             parameter_schema: argument.type_tree.map(|type_tree| ParameterSchema {
                 parameter_type: ParameterType::External(type_tree),
                 features: Vec::new(),
+                option_is_nullable,
             }),
             ..Default::default()
         })
@@ -160,6 +165,7 @@ impl<'a> From<crate::ext::IntoParamsType<'a>> for Parameter<'a> {
 struct ParameterSchema<'p> {
     parameter_type: ParameterType<'p>,
     features: Vec<Feature>,
+    option_is_nullable: bool,
 }
 
 impl ToTokensDiagnostics for ParameterSchema<'_> {
@@ -178,14 +184,17 @@ impl ToTokensDiagnostics for ParameterSchema<'_> {
                 let required: Required = (!type_tree.is_option()).into();
 
                 to_tokens(
-                    ComponentSchema::new(component::ComponentSchemaProps {
-                        type_tree,
-                        features: self.features.clone(),
-                        description: None,
-                        container: &Container {
-                            generics: &Generics::default(),
+                    ComponentSchema::for_params(
+                        component::ComponentSchemaProps {
+                            type_tree,
+                            features: self.features.clone(),
+                            description: None,
+                            container: &Container {
+                                generics: &Generics::default(),
+                            },
                         },
-                    })?
+                        self.option_is_nullable,
+                    )?
                     .to_token_stream(),
                     required,
                 );
@@ -199,14 +208,17 @@ impl ToTokensDiagnostics for ParameterSchema<'_> {
                 schema_features.push(Feature::Inline(inline_type.is_inline.into()));
 
                 to_tokens(
-                    ComponentSchema::new(component::ComponentSchemaProps {
-                        type_tree: &type_tree,
-                        features: schema_features,
-                        description: None,
-                        container: &Container {
-                            generics: &Generics::default(),
+                    ComponentSchema::for_params(
+                        component::ComponentSchemaProps {
+                            type_tree: &type_tree,
+                            features: schema_features,
+                            description: None,
+                            container: &Container {
+                                generics: &Generics::default(),
+                            },
                         },
-                    })?
+                        self.option_is_nullable,
+                    )?
                     .to_token_stream(),
                     required,
                 );
@@ -267,6 +279,7 @@ impl Parse for ValueParameter<'_> {
                         })
                     })?),
                     features: Vec::new(),
+                    option_is_nullable: true,
                 });
             }
         } else {
@@ -289,6 +302,10 @@ impl Parse for ValueParameter<'_> {
         parameter.features = (schema_features.clone(), parameter_features);
         if let Some(parameter_schema) = &mut parameter.parameter_schema {
             parameter_schema.features = schema_features;
+
+            if parameter.parameter_in == ParameterIn::Query {
+                parameter_schema.option_is_nullable = false;
+            }
         }
 
         Ok(parameter)