Skip to content

Commit 14db823

Browse files
authored
Allow handlers to return user-defined error types (#1180)
Currently, all endpoint handler functions must return a `Result<T, HttpError>`, where `T` implements `HttpResponse`. This is unfortunate, as it limits how error return values are represented in the API. There isn't presently a mechanism for an endpoint to return a structured error value of its own which is part of the OpenAPI spec for that endpoint. This is discussed at length in issue #39. This branch relaxes this requirement, and instead allows endpoint handler functions to return `Result<T, E>`, where `E` is any type that implements a new `HttpResponseError` trait. The `HttpResponseError` trait defines how to produce an error response for an error value. This is implemented by `dropshot`'s `HttpError` type, but it may also be implemented by user errors. Types implementing this trait must implement `HttpResponseContent`, to determine how to generate the response body and define its schema, and they must also implement a method `HttpResponseError::status_code` to provide the status code to use for the error response. This is somewhat different from the existing `HttpCodedResponse` trait, which allows successful responses to indicate at compile time that they will always have a particular status code, such as 201 Created. Errors are a bit different: we would like to be able to return any number of different error status codes, but would still like to ensure that they represent *errors*, in order to correctly generate an OpenAPI document where the error schemas are returned only for error responses (see [this comment][1] for details). As discussed [here][2], we ensure this by providing new `ErrorStatusCode` and `ClientErrorStatusCode` types, which are newtypes around `http::StatusCode` that only contain a 4xx or 5xx status (in the case of `ErrorStatusCode`), or only contain a 4xx (in the case of `ClientErrorStatusCode`). These types may be fallibly converted from an `http::StatusCode` at runtime, but we also provide constants for well-known 4xx and 5xx statuses, which can be used infallibly. The `HttpResponseError::status_code` method returns an `ErrorStatusCode` rather than a `http::StatusCode`, allowing us to ensure that error types always have error statuses and generate a correct OpenAPI document. Additionally, while adding `ErrorStatusCode`s, I've gone ahead and changed the `dropshot::HttpError` type to also use it, and changed the `HttpError::for_client_error` and `HttpError::for_status` constructors to take a `ClientErrorStatusCode`. Although this is a breaking change, it resolves a long-standing issue with these APIs: currently, they assert that the provided status code is a 4xx internally, which is often surprising to the user. Thus, this PR fixes #693. Fixes #39 Fixes #693 Fixes #801 This branch is a second attempt at the change originally proposed in PR #1164, so this closes #1164. This design is substantially simpler, and *only* addresses the ability for handler functions to return user-defined types. Other changes made in #1164, such as a way to specify a global handler for dropshot-generated errors, and adding headers to `HttpError` responses, can be addressed separately. For now, all extractors and internal errors still produce `dropshot::HttpError`s. A subsequent change will implement a mechanism for providing alternate presentation for such errors (such as an HTML 404 page). [1]: #39 (comment) [2]: #39 (comment)
1 parent 9c20ce1 commit 14db823

File tree

62 files changed

+3032
-594
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

62 files changed

+3032
-594
lines changed

CHANGELOG.adoc

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,87 @@
1515

1616
https://github.com/oxidecomputer/dropshot/compare/v0.14.0\...HEAD[Full list of commits]
1717

18+
=== Breaking changes
19+
20+
21+
* The `HttpError` type now contains a `dropshot::ErrorStatusCode` rather than an
22+
`http::StatusCode`. An `ErrorStatusCode` is a newtype around `http::StatusCode`
23+
that may only be constructed from 4xx or 5xx status codes.
24+
+
25+
Code which uses `http::StatusCode` constants for well-known status codes can
26+
be updated to the new API by replacing `http::StatusCode::...` with
27+
`dropshot::ErrorStatusCode`. For example:
28+
+
29+
```rust
30+
dropshot::HttpError {
31+
status: http::StatusCode::NOT_FOUND,
32+
// ...
33+
}
34+
```
35+
+
36+
becomes:
37+
+
38+
```rust
39+
dropshot::HttpError {
40+
status: dropshot::ErrorStatusCode::NOT_FOUND,
41+
// ...
42+
}
43+
```
44+
+
45+
To represent extension status codes that lack well-known constants, use
46+
`ErrorStatusCode::from_u16` (or the corresponding `TryFrom` implementation).
47+
This is analogous to the similarly-named method on `http::StatusCode`, so this:
48+
+
49+
```rust
50+
http::StatusCode::from_u16(420).expect("420 is a valid status code")
51+
```
52+
+
53+
becomes this:
54+
+
55+
```rust
56+
dropshot::ErrorStatusCode::from_u16(420).expect("420 is a valid 4xx status code")
57+
```
58+
+
59+
Finally, note that `ErrorStatusCode` implements `TryFrom<http::StatusCode>`, so
60+
`StatusCode`s from external sources may be converted into `ErrorStatusCode`s as
61+
necessary.
62+
63+
* The `HttpError::for_status` constructor, which required that the provided
64+
status code be a 4xx client error and panicked if it was not, has been removed.
65+
It has been replaced with a new `HttpError::for_client_error_with_status`
66+
constructor, which takes a `dropshot::ClientErrorStatusCode` type rather than a
67+
`http::StatusCode`. Ensuring that only client errors are passed to this
68+
constructor at the type level removes the often-surprising panic on non-4xx errors.
69+
+
70+
`ClientErrorStatusCode` provides constants for each well known 4xx status code,
71+
similarly to `ErrorStatusCode`. Uses of `HttpError::for_status`
72+
that use a constant status code, like this:
73+
+
74+
```rust
75+
HttpError::for_status(None, http::StatusCode::GONE)
76+
```
77+
+
78+
becomes this:
79+
+
80+
```rust
81+
HttpError::for_client_error_with_status(None, dropshot::ClientErrorStatusCode::GONE)
82+
```
83+
+
84+
Additionally, `ErrorStatusCode` provides an `as_client_error` method that
85+
returns a `ClientErrorStatusCode` if the status code is a client error, or an
86+
error.
87+
88+
=== Other notable changes
89+
90+
* Endpoint handler functions may now return any error type that implements the
91+
new `dropshot::HttpResponseError` trait. Previously, they could only return
92+
`dropshot::HttpError`. This change permits endpoints to return user-defined
93+
error types, and generate OpenAPI response schemas for those types.
94+
+
95+
For details on how to implement `HttpResponseError` for user-defined types, see
96+
the trait documentation, or
97+
https://github.com/oxidecomputer/dropshot/blob/main/dropshot/examples/custom-error.rs[`examples/custom-error.rs`].
98+
1899
== 0.14.0 (released 2024-12-02)
19100

20101
https://github.com/oxidecomputer/dropshot/compare/v0.13.0\...v0.14.0[Full list of commits]

dropshot/examples/custom-error.rs

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
// Copyright 2024 Oxide Computer Company
2+
3+
//! An example demonstrating how to return user-defined error types from
4+
//! endpoint handlers.
5+
6+
use dropshot::endpoint;
7+
use dropshot::ApiDescription;
8+
use dropshot::ConfigLogging;
9+
use dropshot::ConfigLoggingLevel;
10+
use dropshot::ErrorStatusCode;
11+
use dropshot::HttpError;
12+
use dropshot::HttpResponseError;
13+
use dropshot::HttpResponseOk;
14+
use dropshot::Path;
15+
use dropshot::RequestContext;
16+
use dropshot::ServerBuilder;
17+
use schemars::JsonSchema;
18+
use serde::Deserialize;
19+
use serde::Serialize;
20+
21+
// This is the custom error type returned by our API. A common use case for
22+
// custom error types is to return an `enum` type that represents
23+
// application-specific errors in a way that generated clients can interact with
24+
// programmatically, so the error in this example will be an enum type.
25+
//
26+
// In order to be returned from an endpoint handler, it must implement the
27+
// `HttpResponseError` trait, which requires implementations of:
28+
//
29+
// - `HttpResponseContent`, which determines how to produce a response body
30+
// from the error type,
31+
// - `std::fmt::Display`, which determines how to produce a human-readable
32+
// message for Dropshot to log when returning the error,
33+
// - `From<dropshot::HttpError>`, so that errors returned by request extractors
34+
// and resposne body serialization can be converted to the user-defined error
35+
// type.
36+
#[derive(Debug)]
37+
// Deriving `Serialize` and `JsonSchema` for our error type provides an
38+
// implementation of the `HttpResponseContent` trait, which is required to
39+
// implement `HttpResponseError`:
40+
#[derive(serde::Serialize, schemars::JsonSchema)]
41+
// `HttpResponseError` also requires a `std::fmt::Display` implementation,
42+
// which we'll generate using `thiserror`'s `Error` derive:
43+
#[derive(thiserror::Error)]
44+
enum ThingyError {
45+
// First, define some application-specific error variants that represent
46+
// structured error responses from our API:
47+
/// No thingies are currently available to satisfy this request.
48+
#[error("no thingies are currently available")]
49+
NoThingies,
50+
51+
/// The requested thingy is invalid.
52+
#[error("invalid thingy: {:?}", .name)]
53+
InvalidThingy { name: String },
54+
55+
// Then, we'll define a variant that can be constructed from a
56+
// `dropshot::HttpError`, so that errors returned by Dropshot can also be
57+
// represented in the error schema for our API:
58+
#[error("{internal_message}")]
59+
Other {
60+
message: String,
61+
error_code: Option<String>,
62+
63+
// Skip serializing these fields, as they are used for the
64+
// `fmt::Display` implementation and for determining the status
65+
// code, respectively, rather than included in the response body:
66+
#[serde(skip)]
67+
internal_message: String,
68+
#[serde(skip)]
69+
status: ErrorStatusCode,
70+
},
71+
}
72+
73+
impl HttpResponseError for ThingyError {
74+
// Note that this method returns a `dropshot::ErrorStatusCode`, rather than
75+
// an `http::StatusCode`. This type is a refinement of `http::StatusCode`
76+
// that can only be constructed from status codes in 4xx (client error) or
77+
// 5xx (server error) ranges.
78+
fn status_code(&self) -> dropshot::ErrorStatusCode {
79+
match self {
80+
ThingyError::NoThingies => {
81+
// The `dropshot::ErrorStatusCode` type provides constants for
82+
// all well-known 4xx and 5xx status codes, such as 503 Service
83+
// Unavailable.
84+
dropshot::ErrorStatusCode::SERVICE_UNAVAILABLE
85+
}
86+
ThingyError::InvalidThingy { .. } => {
87+
// Alternatively, an `ErrorStatusCode` can be constructed from a
88+
// u16, but the `ErrorStatusCode::from_u16` constructor
89+
// validates that the status code is a 4xx or 5xx.
90+
//
91+
// This allows using extended status codes, while still
92+
// validating that they are errors.
93+
dropshot::ErrorStatusCode::from_u16(442)
94+
.expect("442 is a 4xx status code")
95+
}
96+
ThingyError::Other { status, .. } => *status,
97+
}
98+
}
99+
}
100+
101+
impl From<HttpError> for ThingyError {
102+
fn from(error: HttpError) -> Self {
103+
ThingyError::Other {
104+
message: error.external_message,
105+
internal_message: error.internal_message,
106+
status: error.status_code,
107+
error_code: error.error_code,
108+
}
109+
}
110+
}
111+
112+
/// Just some kind of thingy returned by the API. This doesn't actually matter.
113+
#[derive(Deserialize, Serialize, JsonSchema)]
114+
struct Thingy {
115+
magic_number: u64,
116+
}
117+
118+
#[derive(Deserialize, JsonSchema)]
119+
struct ThingyPathParams {
120+
name: ThingyName,
121+
}
122+
123+
// Using an enum as a path parameter allows the API to also return extractor
124+
// errors. Try sending a `GET` request for `/thingy/baz` or similar to see how
125+
// the extractor error is converted into our custom error representation.
126+
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
127+
#[serde(rename_all = "lowercase")]
128+
enum ThingyName {
129+
Foo,
130+
Bar,
131+
}
132+
133+
/// Fetch the thingy with the provided name.
134+
#[endpoint {
135+
method = GET,
136+
path = "/thingy/{name}",
137+
}]
138+
async fn get_thingy(
139+
_rqctx: RequestContext<()>,
140+
path_params: Path<ThingyPathParams>,
141+
) -> Result<HttpResponseOk<Thingy>, ThingyError> {
142+
let ThingyPathParams { name } = path_params.into_inner();
143+
Err(ThingyError::InvalidThingy { name: format!("{name:?}") })
144+
}
145+
146+
#[endpoint {
147+
method = GET,
148+
path = "/nothing",
149+
}]
150+
async fn get_nothing(
151+
_rqctx: RequestContext<()>,
152+
) -> Result<HttpResponseOk<Thingy>, ThingyError> {
153+
Err(ThingyError::NoThingies)
154+
}
155+
156+
/// Endpoints which return `Result<_, HttpError>` may be part of the same
157+
/// API as endpoints which return user-defined error types.
158+
#[endpoint {
159+
method = GET,
160+
path = "/something",
161+
}]
162+
async fn get_something(
163+
_rqctx: RequestContext<()>,
164+
) -> Result<HttpResponseOk<Thingy>, dropshot::HttpError> {
165+
Ok(HttpResponseOk(Thingy { magic_number: 42 }))
166+
}
167+
168+
#[tokio::main]
169+
async fn main() -> Result<(), String> {
170+
// See dropshot/examples/basic.rs for more details on most of these pieces.
171+
let config_logging =
172+
ConfigLogging::StderrTerminal { level: ConfigLoggingLevel::Info };
173+
let log = config_logging
174+
.to_logger("example-custom-error")
175+
.map_err(|error| format!("failed to create logger: {}", error))?;
176+
177+
let mut api = ApiDescription::new();
178+
api.register(get_thingy).unwrap();
179+
api.register(get_nothing).unwrap();
180+
api.register(get_something).unwrap();
181+
182+
api.openapi("Custom Error Example", semver::Version::new(0, 0, 0))
183+
.write(&mut std::io::stdout())
184+
.map_err(|e| e.to_string())?;
185+
186+
let server = ServerBuilder::new(api, (), log)
187+
.start()
188+
.map_err(|error| format!("failed to create server: {}", error))?;
189+
190+
server.await
191+
}

0 commit comments

Comments
 (0)