Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow handlers to return user-defined error types #1180

Merged
merged 61 commits into from
Dec 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
af226ea
[WIP] custom error responses using `HttpResponse`
hawkw Nov 13, 2024
4d7c3e4
use a new trait, but HttpResponseContent
hawkw Nov 13, 2024
5bd7e3d
hmmm maybe this is good actually
hawkw Nov 14, 2024
2eff88a
wip schema generation
hawkw Nov 18, 2024
f2c7f5f
use schemars existing deduplication
hawkw Nov 18, 2024
8da1c05
use a refined type for error status
hawkw Nov 20, 2024
86b3afb
just have `HttpError` be a normal `HttpResponseError`
hawkw Nov 20, 2024
1f611bf
just rely on `schemars` to disambiguate colliding names
hawkw Nov 20, 2024
90e7247
start documenting stuff
hawkw Nov 20, 2024
06a1af3
TRYBUILD=overwrite
hawkw Nov 20, 2024
6de6de3
docs etc
hawkw Nov 20, 2024
cc4a2b9
remove unneeded `JsonSchema` impl for `HttpError`
hawkw Nov 20, 2024
c513c46
theory of operation comment in error module
hawkw Nov 20, 2024
cfc582b
actually, we can completely insulate the user from `HandlerError`
hawkw Nov 20, 2024
3d0575c
EXPECTORATE=overwrite
hawkw Nov 20, 2024
6b4b6d4
fix wsrong doctest
hawkw Nov 20, 2024
5f374b8
Merge branch 'main' into eliza/custom-error-httpresponse-result
hawkw Nov 21, 2024
53ed323
rustfmt (NEVER use the github web merge editor)
hawkw Nov 21, 2024
ab798a9
update to track merged changes
hawkw Nov 21, 2024
e87ad82
EXPECTORATE=overwrite
hawkw Nov 21, 2024
6c9c824
Apply docs suggestions from @ahl
hawkw Nov 21, 2024
10a4a99
remove local envrc
hawkw Nov 21, 2024
b9f194c
update copyright dates
hawkw Nov 21, 2024
576ba5f
reticulating comments
hawkw Nov 21, 2024
8a4d52f
reticulating comments
hawkw Nov 21, 2024
f9642d1
nicer error for missing `HttpResponse` impls
hawkw Nov 21, 2024
8f6d70e
fix trait-based stub API not knowing about error schemas
hawkw Nov 21, 2024
ccbbbe2
EXPECTORATE=overwrite
hawkw Nov 21, 2024
46b4df1
whoops i forgot to add changes to endpoint tests
hawkw Nov 22, 2024
00bcea7
convert `HttpError`s into endpoint's error type
hawkw Nov 22, 2024
a6c3472
add a note about `HttpError`
hawkw Nov 22, 2024
4c93e2e
reticulating implementation comments
hawkw Nov 23, 2024
a0e71bf
update docs, improve examples
hawkw Nov 25, 2024
9a15443
fix missing request ID header with custom errors
hawkw Nov 25, 2024
9c8d898
add tests and test utils for custom errors
hawkw Nov 25, 2024
2b7cdea
remove unrelated change
hawkw Nov 25, 2024
a3ee555
Update dropshot/src/handler.rs
hawkw Nov 25, 2024
9d99131
just panic
hawkw Nov 25, 2024
5239d17
Update error.rs
hawkw Nov 28, 2024
a3497ea
Update error.rs
hawkw Nov 28, 2024
ab1d903
add test for trait-bassed custom error APIs
hawkw Dec 2, 2024
ea5c9ef
add wrong error type test with trait-based API
hawkw Dec 2, 2024
5bf8aa2
don't have trait-based API errors claim HttpError is required
hawkw Dec 2, 2024
f315724
various comment suggestions from @davepacheco
hawkw Dec 2, 2024
ba5c5b3
use a macro to generate ClientTestContext methods
hawkw Dec 2, 2024
84bca39
rsutfmt
hawkw Dec 2, 2024
b017d57
rename `HttpError::for_status` to `for_client_error_with_status`
hawkw Dec 2, 2024
72d0ced
document why `HandlerError` isnt publicly exported
hawkw Dec 2, 2024
6ad3bb4
rm spurious comma
hawkw Dec 2, 2024
d189d61
fix typo
hawkw Dec 2, 2024
71dc879
move ErrorStatusCode to its own module
hawkw Dec 2, 2024
cab73b6
improve ErrorStatusCode docs
hawkw Dec 2, 2024
c86d368
rustfmt again (oops)
hawkw Dec 2, 2024
de6933c
reuse response schemas as well as body schemas
hawkw Dec 3, 2024
c6f533d
use schema name instead of type name when available
hawkw Dec 3, 2024
6a0e990
don't emit error schema for responses w/o status
hawkw Dec 3, 2024
f0e6a46
fix accidental use of type name instead of name
hawkw Dec 3, 2024
6d6bff3
add changelog entries
hawkw Dec 3, 2024
8b648f1
Merge branch 'main' into eliza/custom-error-httpresponse-result
hawkw Dec 3, 2024
75cbc09
oops i raced with the 0.14.0 release
hawkw Dec 4, 2024
ba0f62a
ugh what
hawkw Dec 4, 2024
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
81 changes: 81 additions & 0 deletions CHANGELOG.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,87 @@

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

=== Breaking changes


* The `HttpError` type now contains a `dropshot::ErrorStatusCode` rather than an
`http::StatusCode`. An `ErrorStatusCode` is a newtype around `http::StatusCode`
that may only be constructed from 4xx or 5xx status codes.
+
Code which uses `http::StatusCode` constants for well-known status codes can
be updated to the new API by replacing `http::StatusCode::...` with
`dropshot::ErrorStatusCode`. For example:
+
```rust
dropshot::HttpError {
status: http::StatusCode::NOT_FOUND,
// ...
}
```
+
becomes:
+
```rust
dropshot::HttpError {
status: dropshot::ErrorStatusCode::NOT_FOUND,
// ...
}
```
+
To represent extension status codes that lack well-known constants, use
`ErrorStatusCode::from_u16` (or the corresponding `TryFrom` implementation).
This is analogous to the similarly-named method on `http::StatusCode`, so this:
+
```rust
http::StatusCode::from_u16(420).expect("420 is a valid status code")
```
+
becomes this:
+
```rust
dropshot::ErrorStatusCode::from_u16(420).expect("420 is a valid 4xx status code")
```
+
Finally, note that `ErrorStatusCode` implements `TryFrom<http::StatusCode>`, so
`StatusCode`s from external sources may be converted into `ErrorStatusCode`s as
necessary.

* The `HttpError::for_status` constructor, which required that the provided
status code be a 4xx client error and panicked if it was not, has been removed.
It has been replaced with a new `HttpError::for_client_error_with_status`
constructor, which takes a `dropshot::ClientErrorStatusCode` type rather than a
`http::StatusCode`. Ensuring that only client errors are passed to this
constructor at the type level removes the often-surprising panic on non-4xx errors.
+
`ClientErrorStatusCode` provides constants for each well known 4xx status code,
similarly to `ErrorStatusCode`. Uses of `HttpError::for_status`
that use a constant status code, like this:
+
```rust
HttpError::for_status(None, http::StatusCode::GONE)
```
+
becomes this:
+
```rust
HttpError::for_client_error_with_status(None, dropshot::ClientErrorStatusCode::GONE)
```
+
Additionally, `ErrorStatusCode` provides an `as_client_error` method that
returns a `ClientErrorStatusCode` if the status code is a client error, or an
error.

=== Other notable changes

* Endpoint handler functions may now return any error type that implements the
new `dropshot::HttpResponseError` trait. Previously, they could only return
`dropshot::HttpError`. This change permits endpoints to return user-defined
error types, and generate OpenAPI response schemas for those types.
+
For details on how to implement `HttpResponseError` for user-defined types, see
the trait documentation, or
https://github.com/oxidecomputer/dropshot/blob/main/dropshot/examples/custom-error.rs[`examples/custom-error.rs`].
Copy link
Member Author

Choose a reason for hiding this comment

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

this link will be broken until this PR has merged --- it still felt like the nicest way to reference the example in the changelog, IMO.


== 0.14.0 (released 2024-12-02)

https://github.com/oxidecomputer/dropshot/compare/v0.13.0\...v0.14.0[Full list of commits]
Expand Down
191 changes: 191 additions & 0 deletions dropshot/examples/custom-error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
// Copyright 2024 Oxide Computer Company

//! An example demonstrating how to return user-defined error types from
//! endpoint handlers.

use dropshot::endpoint;
use dropshot::ApiDescription;
use dropshot::ConfigLogging;
use dropshot::ConfigLoggingLevel;
use dropshot::ErrorStatusCode;
use dropshot::HttpError;
use dropshot::HttpResponseError;
use dropshot::HttpResponseOk;
use dropshot::Path;
use dropshot::RequestContext;
use dropshot::ServerBuilder;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;

// This is the custom error type returned by our API. A common use case for
// custom error types is to return an `enum` type that represents
// application-specific errors in a way that generated clients can interact with
// programmatically, so the error in this example will be an enum type.
//
// In order to be returned from an endpoint handler, it must implement the
// `HttpResponseError` trait, which requires implementations of:
//
// - `HttpResponseContent`, which determines how to produce a response body
// from the error type,
// - `std::fmt::Display`, which determines how to produce a human-readable
// message for Dropshot to log when returning the error,
// - `From<dropshot::HttpError>`, so that errors returned by request extractors
// and resposne body serialization can be converted to the user-defined error
// type.
#[derive(Debug)]
// Deriving `Serialize` and `JsonSchema` for our error type provides an
// implementation of the `HttpResponseContent` trait, which is required to
// implement `HttpResponseError`:
#[derive(serde::Serialize, schemars::JsonSchema)]
// `HttpResponseError` also requires a `std::fmt::Display` implementation,
// which we'll generate using `thiserror`'s `Error` derive:
#[derive(thiserror::Error)]
enum ThingyError {
// First, define some application-specific error variants that represent
// structured error responses from our API:
/// No thingies are currently available to satisfy this request.
#[error("no thingies are currently available")]
NoThingies,

/// The requested thingy is invalid.
#[error("invalid thingy: {:?}", .name)]
InvalidThingy { name: String },

// Then, we'll define a variant that can be constructed from a
// `dropshot::HttpError`, so that errors returned by Dropshot can also be
// represented in the error schema for our API:
#[error("{internal_message}")]
Other {
message: String,
error_code: Option<String>,

// Skip serializing these fields, as they are used for the
// `fmt::Display` implementation and for determining the status
// code, respectively, rather than included in the response body:
#[serde(skip)]
internal_message: String,
#[serde(skip)]
status: ErrorStatusCode,
},
}

impl HttpResponseError for ThingyError {
// Note that this method returns a `dropshot::ErrorStatusCode`, rather than
// an `http::StatusCode`. This type is a refinement of `http::StatusCode`
// that can only be constructed from status codes in 4xx (client error) or
// 5xx (server error) ranges.
fn status_code(&self) -> dropshot::ErrorStatusCode {
match self {
ThingyError::NoThingies => {
// The `dropshot::ErrorStatusCode` type provides constants for
// all well-known 4xx and 5xx status codes, such as 503 Service
// Unavailable.
dropshot::ErrorStatusCode::SERVICE_UNAVAILABLE
}
ThingyError::InvalidThingy { .. } => {
// Alternatively, an `ErrorStatusCode` can be constructed from a
// u16, but the `ErrorStatusCode::from_u16` constructor
// validates that the status code is a 4xx or 5xx.
//
// This allows using extended status codes, while still
// validating that they are errors.
dropshot::ErrorStatusCode::from_u16(442)
.expect("442 is a 4xx status code")
}
ThingyError::Other { status, .. } => *status,
}
}
}

impl From<HttpError> for ThingyError {
fn from(error: HttpError) -> Self {
ThingyError::Other {
message: error.external_message,
internal_message: error.internal_message,
status: error.status_code,
error_code: error.error_code,
}
}
}

/// Just some kind of thingy returned by the API. This doesn't actually matter.
#[derive(Deserialize, Serialize, JsonSchema)]
struct Thingy {
magic_number: u64,
}

#[derive(Deserialize, JsonSchema)]
struct ThingyPathParams {
name: ThingyName,
}

// Using an enum as a path parameter allows the API to also return extractor
// errors. Try sending a `GET` request for `/thingy/baz` or similar to see how
// the extractor error is converted into our custom error representation.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
enum ThingyName {
Foo,
Bar,
}

/// Fetch the thingy with the provided name.
#[endpoint {
method = GET,
path = "/thingy/{name}",
}]
async fn get_thingy(
_rqctx: RequestContext<()>,
path_params: Path<ThingyPathParams>,
) -> Result<HttpResponseOk<Thingy>, ThingyError> {
let ThingyPathParams { name } = path_params.into_inner();
Err(ThingyError::InvalidThingy { name: format!("{name:?}") })
}

#[endpoint {
method = GET,
path = "/nothing",
}]
async fn get_nothing(
_rqctx: RequestContext<()>,
) -> Result<HttpResponseOk<Thingy>, ThingyError> {
Err(ThingyError::NoThingies)
}

/// Endpoints which return `Result<_, HttpError>` may be part of the same
/// API as endpoints which return user-defined error types.
#[endpoint {
method = GET,
path = "/something",
}]
async fn get_something(
_rqctx: RequestContext<()>,
) -> Result<HttpResponseOk<Thingy>, dropshot::HttpError> {
Ok(HttpResponseOk(Thingy { magic_number: 42 }))
}

#[tokio::main]
async fn main() -> Result<(), String> {
// See dropshot/examples/basic.rs for more details on most of these pieces.
let config_logging =
ConfigLogging::StderrTerminal { level: ConfigLoggingLevel::Info };
let log = config_logging
.to_logger("example-custom-error")
.map_err(|error| format!("failed to create logger: {}", error))?;

let mut api = ApiDescription::new();
api.register(get_thingy).unwrap();
api.register(get_nothing).unwrap();
api.register(get_something).unwrap();

api.openapi("Custom Error Example", semver::Version::new(0, 0, 0))
.write(&mut std::io::stdout())
.map_err(|e| e.to_string())?;

let server = ServerBuilder::new(api, (), log)
.start()
.map_err(|error| format!("failed to create server: {}", error))?;

server.await
}
Loading