Skip to content

Commit bae4704

Browse files
authored
Strip null from query parameter schemas (#275)
Schemars generates null types for Option<T> fields (either `type: ["string", "null"]` or `anyOf: [{...}, {type: "null"}]`), but query strings cannot express null values - a parameter is either present with a value or absent. This change automatically strips null types from query parameter schemas during finalization. The behavior is enabled by default and can be disabled with `strip_query_null_types(false)`.
1 parent e522e6a commit bae4704

File tree

3 files changed

+264
-2
lines changed

3 files changed

+264
-2
lines changed

crates/aide/src/axum/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,11 @@ where
443443
let _ = transform(TransformOpenApi::new(api));
444444

445445
let needs_reset = in_context(|ctx| {
446+
// Strip null types from query parameters if enabled
447+
if ctx.strip_query_null_types {
448+
crate::transform::strip_null_from_query_params_impl(api);
449+
}
450+
446451
if !ctx.extract_schemas {
447452
return false;
448453
}

crates/aide/src/generate.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,19 @@ pub fn all_error_responses(infer: bool) {
8484
});
8585
}
8686

87+
/// Automatically strip null types from query parameter schemas.
88+
///
89+
/// Query strings cannot express null values - a parameter is either
90+
/// present with a value or absent. When enabled, null types are
91+
/// automatically removed from query parameter schemas during finalization.
92+
///
93+
/// This is enabled by default.
94+
pub fn strip_query_null_types(strip: bool) {
95+
in_context(|ctx| {
96+
ctx.strip_query_null_types = strip;
97+
});
98+
}
99+
87100
/// Reset the state of the thread-local context.
88101
///
89102
/// Currently clears:
@@ -110,6 +123,9 @@ pub struct GenContext {
110123
/// Extract schemas.
111124
pub(crate) extract_schemas: bool,
112125

126+
/// Strip null types from query parameter schemas.
127+
pub(crate) strip_query_null_types: bool,
128+
113129
/// Status code for no content.
114130
pub(crate) no_content_status: u16,
115131

@@ -135,6 +151,7 @@ impl GenContext {
135151
infer_responses: true,
136152
all_error_responses: false,
137153
extract_schemas: true,
154+
strip_query_null_types: true,
138155
show_error: default_error_filter,
139156
error_handler: None,
140157
no_content_status,

crates/aide/src/transform.rs

Lines changed: 242 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,15 @@ use std::{any::type_name, marker::PhantomData};
5151
use crate::{
5252
generate::GenContext,
5353
openapi::{
54-
Components, Contact, Info, License, OpenApi, Operation, Parameter, PathItem, ReferenceOr,
55-
Response, SecurityScheme, Server, StatusCode, Tag,
54+
Components, Contact, Info, License, OpenApi, Operation, Parameter,
55+
ParameterSchemaOrContent, PathItem, ReferenceOr, Response, SecurityScheme, Server,
56+
StatusCode, Tag,
5657
},
5758
OperationInput,
5859
};
5960
use indexmap::IndexMap;
6061
use serde::Serialize;
62+
use serde_json::Value;
6163

6264
use crate::{
6365
error::Error, generate::in_context, operation::OperationOutput, util::iter_operations_mut,
@@ -194,6 +196,23 @@ impl<'t> TransformOpenApi<'t> {
194196
self
195197
}
196198

199+
/// Strip null types from query parameter schemas.
200+
///
201+
/// Query strings cannot express null values - a parameter is either
202+
/// present with a value or absent. This method removes `null` from
203+
/// type arrays (e.g., `["string", "null"]` becomes `"string"`) and
204+
/// unwraps `anyOf` variants containing null types.
205+
///
206+
/// Note: This is called automatically when the transform is finalized
207+
/// (unless disabled via [`aide::generate::strip_query_null_types`]).
208+
/// You only need to call this explicitly if you want to apply it
209+
/// at a specific point in your transform chain.
210+
#[tracing::instrument(skip_all)]
211+
pub fn strip_null_from_query_params(self) -> Self {
212+
strip_null_from_query_params_impl(self.api);
213+
self
214+
}
215+
197216
/// Add a security scheme.
198217
#[allow(clippy::missing_panics_doc)]
199218
pub fn security_scheme(mut self, name: &str, scheme: SecurityScheme) -> Self {
@@ -1320,3 +1339,224 @@ impl<'t> TransformCallback<'t> {
13201339
fn filter_no_duplicate_response(err: &Error) -> bool {
13211340
!matches!(err, Error::DefaultResponseExists | Error::ResponseExists(_))
13221341
}
1342+
1343+
pub(crate) fn strip_null_from_query_params_impl(api: &mut OpenApi) {
1344+
let Some(paths) = &mut api.paths else { return };
1345+
1346+
for (_, path_item) in &mut paths.paths {
1347+
let ReferenceOr::Item(path_item) = path_item else {
1348+
continue;
1349+
};
1350+
1351+
for (_, op) in iter_operations_mut(path_item) {
1352+
for param in &mut op.parameters {
1353+
let ReferenceOr::Item(Parameter::Query { parameter_data, .. }) = param else {
1354+
continue;
1355+
};
1356+
1357+
let ParameterSchemaOrContent::Schema(schema_obj) = &mut parameter_data.format
1358+
else {
1359+
continue;
1360+
};
1361+
1362+
strip_null_from_type(&mut schema_obj.json_schema);
1363+
}
1364+
}
1365+
}
1366+
}
1367+
1368+
fn strip_null_from_type(schema: &mut schemars::Schema) {
1369+
// Handle type: ["string", "null"] -> type: "string"
1370+
if let Some(Value::Array(types)) = schema.get_mut("type") {
1371+
let null_count = types.iter().filter(|t| *t == "null").count();
1372+
if null_count == 0 || null_count == types.len() {
1373+
return; // No nulls, or all nulls - don't modify
1374+
}
1375+
types.retain(|t| t != "null");
1376+
if types.len() == 1 {
1377+
*schema.get_mut("type").unwrap() = types.remove(0);
1378+
}
1379+
return;
1380+
}
1381+
1382+
// Handle anyOf: [..., {type: "null"}] -> remove null variants
1383+
let Some(Value::Array(items)) = schema.get_mut("anyOf") else {
1384+
return;
1385+
};
1386+
1387+
let is_null = |v: &Value| matches!(v.get("type"), Some(Value::String(s)) if s == "null");
1388+
let null_count = items.iter().filter(|item| is_null(item)).count();
1389+
if null_count == 0 || null_count == items.len() {
1390+
return; // No nulls, or all nulls - don't modify
1391+
}
1392+
1393+
items.retain(|item| !is_null(item));
1394+
1395+
// Single item remains: unwrap the anyOf
1396+
if items.len() == 1 {
1397+
if let Some(Value::Object(obj)) = items.pop() {
1398+
if let Some(schema_obj) = schema.as_object_mut() {
1399+
schema_obj.remove("anyOf");
1400+
for (key, value) in obj {
1401+
schema_obj.insert(key, value);
1402+
}
1403+
}
1404+
}
1405+
}
1406+
}
1407+
1408+
#[cfg(test)]
1409+
mod tests {
1410+
use crate::openapi::{
1411+
MediaType, OpenApi, Operation, Parameter, ParameterData, ParameterSchemaOrContent, Paths,
1412+
ReferenceOr, RequestBody, SchemaObject,
1413+
};
1414+
use indexmap::IndexMap;
1415+
use schemars::JsonSchema;
1416+
use serde_json::json;
1417+
1418+
use super::TransformOpenApi;
1419+
1420+
fn inline_schema_for<T: JsonSchema>() -> schemars::Schema {
1421+
let settings = schemars::generate::SchemaSettings::draft07().with(|s| {
1422+
s.inline_subschemas = true;
1423+
});
1424+
let mut gen = settings.into_generator();
1425+
gen.subschema_for::<T>()
1426+
}
1427+
1428+
fn property_schema(struct_schema: &schemars::Schema, name: &str) -> schemars::Schema {
1429+
struct_schema
1430+
.get("properties")
1431+
.and_then(|p| p.get(name))
1432+
.unwrap_or_else(|| panic!("property {name:?} not found"))
1433+
.clone()
1434+
.try_into()
1435+
.unwrap()
1436+
}
1437+
1438+
fn build_api(params: Vec<Parameter>, body_schema: Option<schemars::Schema>) -> OpenApi {
1439+
let mut op = Operation::default();
1440+
op.parameters = params.into_iter().map(ReferenceOr::Item).collect();
1441+
if let Some(schema) = body_schema {
1442+
op.request_body = Some(ReferenceOr::Item(RequestBody {
1443+
content: IndexMap::from_iter([(
1444+
"application/json".into(),
1445+
MediaType {
1446+
schema: Some(SchemaObject {
1447+
json_schema: schema,
1448+
external_docs: None,
1449+
example: None,
1450+
}),
1451+
..Default::default()
1452+
},
1453+
)]),
1454+
..Default::default()
1455+
}));
1456+
}
1457+
1458+
let mut path_item = crate::openapi::PathItem::default();
1459+
path_item.get = Some(op);
1460+
1461+
OpenApi {
1462+
paths: Some(Paths {
1463+
paths: IndexMap::from([("/test".to_string(), ReferenceOr::Item(path_item))]),
1464+
extensions: IndexMap::new(),
1465+
}),
1466+
..OpenApi::default()
1467+
}
1468+
}
1469+
1470+
fn query_param(name: &str, schema: schemars::Schema) -> Parameter {
1471+
Parameter::Query {
1472+
parameter_data: ParameterData {
1473+
name: name.to_string(),
1474+
description: None,
1475+
required: false,
1476+
deprecated: None,
1477+
format: ParameterSchemaOrContent::Schema(SchemaObject {
1478+
json_schema: schema,
1479+
external_docs: None,
1480+
example: None,
1481+
}),
1482+
example: None,
1483+
examples: IndexMap::new(),
1484+
explode: None,
1485+
extensions: IndexMap::new(),
1486+
},
1487+
allow_reserved: false,
1488+
style: Default::default(),
1489+
allow_empty_value: None,
1490+
}
1491+
}
1492+
1493+
fn get_param_schema(api: &OpenApi, param_index: usize) -> &schemars::Schema {
1494+
let paths = api.paths.as_ref().unwrap();
1495+
let ReferenceOr::Item(path_item) = &paths.paths["/test"] else {
1496+
panic!("expected item");
1497+
};
1498+
let op = path_item.get.as_ref().unwrap();
1499+
let ReferenceOr::Item(param) = &op.parameters[param_index] else {
1500+
panic!("expected parameter item");
1501+
};
1502+
let ParameterSchemaOrContent::Schema(schema_obj) = &param.parameter_data_ref().format
1503+
else {
1504+
panic!("expected schema");
1505+
};
1506+
&schema_obj.json_schema
1507+
}
1508+
1509+
fn get_body_schema(api: &OpenApi) -> &schemars::Schema {
1510+
let paths = api.paths.as_ref().unwrap();
1511+
let ReferenceOr::Item(path_item) = &paths.paths["/test"] else {
1512+
panic!("expected item");
1513+
};
1514+
let op = path_item.get.as_ref().unwrap();
1515+
let Some(ReferenceOr::Item(body)) = &op.request_body else {
1516+
panic!("expected request body");
1517+
};
1518+
&body.content["application/json"]
1519+
.schema
1520+
.as_ref()
1521+
.unwrap()
1522+
.json_schema
1523+
}
1524+
1525+
#[test]
1526+
fn strip_null_from_query_params() {
1527+
#[derive(JsonSchema)]
1528+
#[allow(dead_code)]
1529+
struct QueryParams {
1530+
optional: Option<String>,
1531+
}
1532+
1533+
#[derive(JsonSchema)]
1534+
#[allow(dead_code)]
1535+
struct Body {
1536+
optional: Option<String>,
1537+
}
1538+
1539+
let query_field = property_schema(&inline_schema_for::<QueryParams>(), "optional");
1540+
let body_field = property_schema(&inline_schema_for::<Body>(), "optional");
1541+
1542+
// Both should start out nullable
1543+
assert_eq!(query_field.get("type"), Some(&json!(["string", "null"])));
1544+
assert_eq!(body_field.get("type"), Some(&json!(["string", "null"])));
1545+
1546+
let mut api = build_api(vec![query_param("optional", query_field)], Some(body_field));
1547+
let _ = TransformOpenApi::new(&mut api).strip_null_from_query_params();
1548+
1549+
// Query param should have null stripped
1550+
assert_eq!(
1551+
get_param_schema(&api, 0).get("type"),
1552+
Some(&json!("string"))
1553+
);
1554+
1555+
// Request body schema should be unchanged
1556+
assert_eq!(
1557+
get_body_schema(&api).get("type"),
1558+
Some(&json!(["string", "null"])),
1559+
"request body schema should retain its nullable type"
1560+
);
1561+
}
1562+
}

0 commit comments

Comments
 (0)