From f3765903ddb5a95f5dcfed0a29442448583aba05 Mon Sep 17 00:00:00 2001 From: Rain Date: Fri, 30 Oct 2026 12:32:39 -0700 Subject: [PATCH 1/2] Add lint for nullable request and response bodies --- README.md | 49 +++++++++++++ src/lib.rs | 90 ++++++++++++++++++++++- src/tests/errors.json | 165 ++++++++++++++++++++++++++++++++++++++++++ src/tests/errors.out | 18 +++++ 4 files changed, 320 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c59dee0..9039202 100644 --- a/README.md +++ b/README.md @@ -248,6 +248,55 @@ pub async fn device_auth_confirm( } ``` +### Nullable Bodies + +Request and response bodies should not be nullable. Consider this handler: + +```rust +#[endpoint { + method = GET, + path = "/coordinator-status", +}] +pub async fn coordinator_status( + rqctx: RequestContext>, +) -> Result>, HttpError> { + // ... +} +``` + +The corresponding OpenAPI response schema will be: + +```json +{ + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/CoordinatorStatus" + } + ] +} +``` + +A bare `null` response is valid JSON, but it is not idiomatic. Also, older +Dropshot versions did not generate correct OpenAPI documents for nullable +request and response types; see +[dropshot#1751](https://github.com/oxidecomputer/dropshot/pull/1751). + +Instead, wrap the optional value in a struct: + +```rust +#[derive(Deserialize, JsonSchema, Serialize)] +pub struct CoordinatorStatusResponse { + pub status: Option, +} +``` + +Alternatively, if `None` means that the resource doesn't exist, return a 404 Not +Found error. + +The same applies to request bodies: rather than `TypedBody>`, +accept a struct with an `Option` member. + ## External Rules These rules only apply to APIs that are "external". diff --git a/src/lib.rs b/src/lib.rs index e453e36..c6972e1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,11 +6,13 @@ //! generators would have a hard time turning into easy-to-use native //! constructs. +use std::fmt; + use heck::{ToKebabCase, ToPascalCase, ToShoutySnakeCase, ToSnakeCase}; use indexmap::IndexMap; use openapiv3::{ - AnySchema, Components, OpenAPI, Operation, Parameter, ReferenceOr, Response, Schema, - SchemaKind, StringType, Type, VariantOrUnknownOrEmpty, + AnySchema, Components, OpenAPI, Operation, Parameter, ReferenceOr, RequestBody, Response, + Schema, SchemaKind, StatusCode, StringType, Type, VariantOrUnknownOrEmpty, }; mod walker; @@ -70,6 +72,9 @@ impl Validator { let responses = spec .operations() .flat_map(|(_, _, op)| self.validate_operation_response(spec, op)); + let bodies = spec + .operations() + .flat_map(|(_, _, op)| self.validate_operation_bodies(spec, op)); let op_summaries = if external { spec.operations() .filter_map(|path_method_op| self.validate_operation_summary(path_method_op)) @@ -97,6 +102,7 @@ impl Validator { .chain(operations) .chain(parameters) .chain(responses) + .chain(bodies) .chain(named_schemas) .chain(op_summaries) .chain(op_docs) @@ -346,6 +352,63 @@ impl Validator { .collect() } + fn validate_operation_bodies(&self, spec: &OpenAPI, op: &Operation) -> Vec { + const INFO: &str = "For more info, see \ + https://github.com/oxidecomputer/openapi-lint#nullable-bodies"; + + let operation_id = op.operation_id.as_deref().unwrap_or(""); + + let request = op.request_body.iter().filter_map(|body| { + Some((BodyLocation::Request, &body.item(&spec.components)?.content)) + }); + let default_response = op.responses.default.iter().filter_map(|response| { + Some(( + BodyLocation::DefaultResponse, + &response.item(&spec.components)?.content, + )) + }); + let code_responses = op + .responses + .responses + .iter() + .filter_map(|(code, response)| { + Some(( + BodyLocation::Response(code), + &response.item(&spec.components)?.content, + )) + }); + + request + .chain(default_response) + .chain(code_responses) + .flat_map(|(location, content)| { + content + .iter() + .map(move |(content_type, media_type)| (location, content_type, media_type)) + }) + .filter_map(|(location, content_type, media_type)| { + let schema = media_type.schema.as_ref()?.item(&spec.components)?; + schema.schema_data.nullable.then(|| { + let remedy = match location { + BodyLocation::Request => { + "Rather than using an Option as the body, use a \ + struct with an Option member." + } + BodyLocation::DefaultResponse | BodyLocation::Response(_) => { + "Rather than using an Option as the body, use a \ + struct with an Option member, or return a 404 \ + Not Found error if the resource does not exist." + } + }; + format!( + "The {} ({}) for {} is nullable. {}\n{}", + location, content_type, operation_id, remedy, INFO, + ) + }) + }) + .collect() + } + fn validate_named_schema(&self, type_name: &str) -> Option { const INFO: &str = "For more info, see \ https://github.com/oxidecomputer/openapi-lint#naming"; @@ -377,6 +440,23 @@ impl Validator { } } +#[derive(Clone, Copy)] +enum BodyLocation<'a> { + Request, + DefaultResponse, + Response(&'a StatusCode), +} + +impl fmt::Display for BodyLocation<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + BodyLocation::Request => write!(f, "request body"), + BodyLocation::DefaultResponse => write!(f, "default response body"), + BodyLocation::Response(code) => write!(f, "response body for status {}", code), + } + } +} + fn check_doc_string(s: &str) -> Option { const INFO: &str = "For more info, see \ https://github.com/oxidecomputer/openapi-lint#rust-documentation"; @@ -467,6 +547,12 @@ impl ComponentLookup for Schema { } } +impl ComponentLookup for RequestBody { + fn get_components(components: &Components) -> &IndexMap> { + &components.request_bodies + } +} + impl ComponentLookup for Response { fn get_components(components: &Components) -> &IndexMap> { &components.responses diff --git a/src/tests/errors.json b/src/tests/errors.json index 0d13661..f6b4bf0 100644 --- a/src/tests/errors.json +++ b/src/tests/errors.json @@ -46,6 +46,129 @@ } } }, + "/nullable/request": { + "put": { + "operationId": "nullable_request", + "requestBody": { + "content": { + "application/json": { + "schema": { + "nullable": true, + "type": "array", + "items": { + "type": "string" + } + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "nullable": true, + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "default": { + "description": "unexpected response", + "content": { + "application/json": { + "schema": { + "nullable": true, + "type": "string" + } + } + } + } + } + } + }, + "/nullable/response": { + "get": { + "operationId": "nullable_response", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/ProjectView" + } + ] + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/nullable/request-ref": { + "post": { + "operationId": "nullable_request_ref", + "requestBody": { + "$ref": "#/components/requestBodies/MaybeProjectCreate" + }, + "responses": { + "204": { + "description": "resource updated" + } + } + } + }, + "/nullable/named": { + "get": { + "operationId": "nullable_named", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MaybeProjectView" + } + } + } + } + } + } + }, + "/wrapped/response": { + "get": { + "operationId": "wrapped_response", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "project": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/ProjectView" + } + ] + } + } + } + } + } + } + } + } + }, "/hardware/racks": { "get": { "summary": "List racks in the system.", @@ -1059,6 +1182,40 @@ } }, "components": { + "requestBodies": { + "MaybeProjectCreate": { + "content": { + "application/json": { + "schema": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/ProjectCreateParams" + } + ] + } + } + }, + "required": true + } + }, + "responses": { + "Error": { + "description": "Error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + }, "schemas": { "ActionError": { "description": "An error produced by a saga action\n\nOn failure, actions always return an `ActionError`. This type can represent a failure from Steno itself or a failure produced by the consumer (e.g., an action whose body fails for some reason). The various specific errors are documented below.\n\nYou can use your own error type with [`ActionError`]. As long as it meets the requirements of [`ActionData`], you can wrap your error in an [`ActionError::ActionFailed`] variant using [`ActionError::action_failed()`]. Given an [`ActionError::ActionFailed`] variant, you can get your specific type back out again using [`ActionError::convert()`].\n\nNote that the conversion back to your specific error type can fail! This looks like a downcast, but it's not. `ActionError`s are typically recorded in the saga log and interpreted later, possibly after a crash and recovery. Whether there was an intervening crash or not, the conversion here deserializes the error from the log into your custom error type. This won't work if your error type is incompatible with the one that was used to serialize the error in the first place.\n\n# Example\n\n```rust use serde::Deserialize; use serde::Serialize; use steno::ActionError;\n\n#[derive(Debug, Deserialize, Serialize)] struct MyError { message: String }\n\nfn my_func_that_fails() -> Result<(), ActionError> { Err(ActionError::action_failed(MyError { message: \"boom!\".to_owned() })) }\n\nfn handle_error(error: ActionError) { match error.convert::() { Ok(my_error) => { eprintln!(\"my action failed because: {}\", my_error.message); } Err(other_error) => { eprintln!( \"my action failed because the framework had a problem: {}\", other_error.to_string() ); } } } ```", @@ -1544,6 +1701,14 @@ } } }, + "MaybeProjectView": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/ProjectView" + } + ] + }, "ProjectView": { "description": "Client view of an [`Project`]", "type": "object", diff --git a/src/tests/errors.out b/src/tests/errors.out index 4e1338e..42417a6 100644 --- a/src/tests/errors.out +++ b/src/tests/errors.out @@ -5680,6 +5680,24 @@ For more info, see https://github.com/oxidecomputer/openapi-lint#naming The return type for unit_return was a trivial null. For more info, see https://github.com/oxidecomputer/openapi-lint#trivial-null-response +The request body (application/json) for nullable_request is nullable. Rather than using an Option as the body, use a struct with an Option member. +For more info, see https://github.com/oxidecomputer/openapi-lint#nullable-bodies + +The request body (application/x-www-form-urlencoded) for nullable_request is nullable. Rather than using an Option as the body, use a struct with an Option member. +For more info, see https://github.com/oxidecomputer/openapi-lint#nullable-bodies + +The default response body (application/json) for nullable_request is nullable. Rather than using an Option as the body, use a struct with an Option member, or return a 404 Not Found error if the resource does not exist. +For more info, see https://github.com/oxidecomputer/openapi-lint#nullable-bodies + +The response body for status 200 (application/json) for nullable_response is nullable. Rather than using an Option as the body, use a struct with an Option member, or return a 404 Not Found error if the resource does not exist. +For more info, see https://github.com/oxidecomputer/openapi-lint#nullable-bodies + +The request body (application/json) for nullable_request_ref is nullable. Rather than using an Option as the body, use a struct with an Option member. +For more info, see https://github.com/oxidecomputer/openapi-lint#nullable-bodies + +The response body for status 200 (application/json) for nullable_named is nullable. Rather than using an Option as the body, use a struct with an Option member, or return a 404 Not Found error if the resource does not exist. +For more info, see https://github.com/oxidecomputer/openapi-lint#nullable-bodies + The type "fake_id_sort_mode" has a name that is not PascalCase; to rename it add #[serde(rename = "FakeIdSortMode")] For more info, see https://github.com/oxidecomputer/openapi-lint#naming From 26d82af8fa895797aa8d4176a6ab99671f502fe7 Mon Sep 17 00:00:00 2001 From: Rain Date: Sat, 31 Oct 2026 17:06:42 -0700 Subject: [PATCH 2/2] review feedback --- README.md | 24 +++++++++++++----------- src/lib.rs | 6 +++--- src/tests/errors.out | 6 +++--- 3 files changed, 19 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 9039202..68e4a9e 100644 --- a/README.md +++ b/README.md @@ -282,20 +282,22 @@ Dropshot versions did not generate correct OpenAPI documents for nullable request and response types; see [dropshot#1751](https://github.com/oxidecomputer/dropshot/pull/1751). -Instead, wrap the optional value in a struct: +For response bodies: -```rust -#[derive(Deserialize, JsonSchema, Serialize)] -pub struct CoordinatorStatusResponse { - pub status: Option, -} -``` +* If `None` means that the resource doesn't exist, return a 404 Not Found error. -Alternatively, if `None` means that the resource doesn't exist, return a 404 Not -Found error. +* Alternatively, wrap the optional value in a struct: + + ```rust + #[derive(Deserialize, JsonSchema, Serialize)] + pub struct CoordinatorStatusResponse { + pub status: Option, + } + ``` -The same applies to request bodies: rather than `TypedBody>`, -accept a struct with an `Option` member. +For request bodies, follow the wrapper struct pattern: rather than +`TypedBody>`, accept a struct with an `Option` +member. ## External Rules diff --git a/src/lib.rs b/src/lib.rs index c6972e1..8524825 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -395,9 +395,9 @@ impl Validator { struct with an Option member." } BodyLocation::DefaultResponse | BodyLocation::Response(_) => { - "Rather than using an Option as the body, use a \ - struct with an Option member, or return a 404 \ - Not Found error if the resource does not exist." + "Rather than using an Option as the body, return \ + a 404 Not Found error if the resource does not exist, \ + or use a struct with an Option member." } }; format!( diff --git a/src/tests/errors.out b/src/tests/errors.out index 42417a6..4945a32 100644 --- a/src/tests/errors.out +++ b/src/tests/errors.out @@ -5686,16 +5686,16 @@ For more info, see https://github.com/oxidecomputer/openapi-lint#nullable-bodies The request body (application/x-www-form-urlencoded) for nullable_request is nullable. Rather than using an Option as the body, use a struct with an Option member. For more info, see https://github.com/oxidecomputer/openapi-lint#nullable-bodies -The default response body (application/json) for nullable_request is nullable. Rather than using an Option as the body, use a struct with an Option member, or return a 404 Not Found error if the resource does not exist. +The default response body (application/json) for nullable_request is nullable. Rather than using an Option as the body, return a 404 Not Found error if the resource does not exist, or use a struct with an Option member. For more info, see https://github.com/oxidecomputer/openapi-lint#nullable-bodies -The response body for status 200 (application/json) for nullable_response is nullable. Rather than using an Option as the body, use a struct with an Option member, or return a 404 Not Found error if the resource does not exist. +The response body for status 200 (application/json) for nullable_response is nullable. Rather than using an Option as the body, return a 404 Not Found error if the resource does not exist, or use a struct with an Option member. For more info, see https://github.com/oxidecomputer/openapi-lint#nullable-bodies The request body (application/json) for nullable_request_ref is nullable. Rather than using an Option as the body, use a struct with an Option member. For more info, see https://github.com/oxidecomputer/openapi-lint#nullable-bodies -The response body for status 200 (application/json) for nullable_named is nullable. Rather than using an Option as the body, use a struct with an Option member, or return a 404 Not Found error if the resource does not exist. +The response body for status 200 (application/json) for nullable_named is nullable. Rather than using an Option as the body, return a 404 Not Found error if the resource does not exist, or use a struct with an Option member. For more info, see https://github.com/oxidecomputer/openapi-lint#nullable-bodies The type "fake_id_sort_mode" has a name that is not PascalCase; to rename it add #[serde(rename = "FakeIdSortMode")]