Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Arc<ServerContext>>,
) -> Result<HttpResponseOk<Option<CoordinatorStatus>>, 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<CoordinatorStatus>,
}
```

Alternatively, if `None` means that the resource doesn't exist, return a 404 Not
Found error.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I might put this case first?

If None is meant to indicate that the resource doesn't exist, consider returning 404 Not Found instead. ...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, reversed the order of recommendations.


The same applies to request bodies: rather than `TypedBody<Option<MyParams>>`,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a little interesting. Option is obviously overloaded in serde/serde_json to mean "maybe present" or "could be null", but it may not be unreasonable to have an optional POST body (even though it's not something dropshot supports well today). That said, I think this is the right restriction given the current capabilities.

Also, this specific phrasing ("the same applies") may make more sense after the "make your Option a struct field" rather than after "None = 404"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Option is obviously overloaded in serde/serde_json to mean "maybe present" or "could be null", but it may not be unreasonable to have an optional POST body (even though it's not something dropshot supports well today).

Hmm, from the openapi-lint perspective (which only works on OpenAPI documents, not Rust code) this would be represented as "required": false and not "nullable": true, right? So I don't think this change makes that worse in any way. Unless I'm missing something?

accept a struct with an `Option<MyParams>` member.

## External Rules

These rules only apply to APIs that are "external".
Expand Down
90 changes: 88 additions & 2 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -97,6 +102,7 @@ impl Validator {
.chain(operations)
.chain(parameters)
.chain(responses)
.chain(bodies)
.chain(named_schemas)
.chain(op_summaries)
.chain(op_docs)
Expand Down Expand Up @@ -346,6 +352,63 @@ impl Validator {
.collect()
}

fn validate_operation_bodies(&self, spec: &OpenAPI, op: &Operation) -> Vec<String> {
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("<unknown>");

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<T> as the body, use a \
struct with an Option<T> member."
}
BodyLocation::DefaultResponse | BodyLocation::Response(_) => {
"Rather than using an Option<T> as the body, use a \
struct with an Option<T> 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<String> {
const INFO: &str = "For more info, see \
https://github.com/oxidecomputer/openapi-lint#naming";
Expand Down Expand Up @@ -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<String> {
const INFO: &str = "For more info, see \
https://github.com/oxidecomputer/openapi-lint#rust-documentation";
Expand Down Expand Up @@ -467,6 +547,12 @@ impl ComponentLookup for Schema {
}
}

impl ComponentLookup for RequestBody {
fn get_components(components: &Components) -> &IndexMap<String, ReferenceOr<Self>> {
&components.request_bodies
}
}

impl ComponentLookup for Response {
fn get_components(components: &Components) -> &IndexMap<String, ReferenceOr<Self>> {
&components.responses
Expand Down
165 changes: 165 additions & 0 deletions src/tests/errors.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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::<MyError>() { 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() ); } } } ```",
Expand Down Expand Up @@ -1544,6 +1701,14 @@
}
}
},
"MaybeProjectView": {
"nullable": true,
"allOf": [
{
"$ref": "#/components/schemas/ProjectView"
}
]
},
"ProjectView": {
"description": "Client view of an [`Project`]",
"type": "object",
Expand Down
Loading
Loading