From c95d1b5375fe6426ba4eb30bc6e64a00f2bb42ae Mon Sep 17 00:00:00 2001 From: Eliza Weisman Date: Wed, 11 Jun 2025 10:28:58 -0700 Subject: [PATCH] Add Serialize/Deserialize for ErrorStatusCode PR #1180 added `ErrorStatusCode` and `ClientErrorStatusCode` types that represent HTTP status codes that are validated to be in the 400-599 and 400-499 ranges, respectively. These are itnended to be returned by user-defined error types in their `HttpResponseError` implementations. User-defined errors are typically implemented as a type that implements `Serialize` and `JsonSchema` along with `HttpResponseError`, which in turn requires that the type have a `From` conversion, used in the case of extractor and dropshot-internal errors. In order for the user-defined error's `HttpResponseError` implementation to have the same status as the `dropshot::HttpError` from which it was constructed, it must contain that status code within the error type so that it may return it. However, `ErrorStatusCode` does not implement `Serialize`, `Deserialize`, or `JsonSchema`, so holding it in the user-defined error type breaks deriving those traits. One solution is to use `#[serde(skip)]` for the `ErrorStatusCode` field, which I showed in the [`custom-error.rs` example][1]. When the user-defined error type is only used in the server, this is sufficient, and it will simply omit the status when serializing. However, this prevents the user-defined error type from implementing `Deserialize`, since the status code is never serialized. This means that generated client code cannot use the same type for the error response value as the server code, which is desirable in some cases (e.g. to use the same `fmt::Display` implementation on both sides, etc). Also, in some cases, it may be useful to include a status code in the serialized body, such as when embedding an HTTP error returned by an external service which may not actually be the same as the response's status code (e.g. I might return a 500 or a 503 when a request to an upstream service returns a 4xx error). Therefore, this commit adds `Serialize`, `Deserialize`, and `JsonSchema` implementations for the `ErrorStatusCode` and `ClientErrorStatusCode` types. These implementations serialize and deserialize these types as a `u16`, and we generate a JSON Schema that represents them as integers with appropriate minimum and maximum value validation. [1]: https://github.com/oxidecomputer/dropshot/blob/c028c6751771d89457c44286e26b465ed14ef25f/dropshot/examples/custom-error.rs#L63-L69 --- dropshot/src/error_status_code.rs | 82 ++++++++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 2 deletions(-) diff --git a/dropshot/src/error_status_code.rs b/dropshot/src/error_status_code.rs index 52a8bdb9..b5208a78 100644 --- a/dropshot/src/error_status_code.rs +++ b/dropshot/src/error_status_code.rs @@ -4,6 +4,7 @@ //! Newtypes around [`http::StatusCode`] that are limited to status ranges //! representing errors. +use serde::{Deserialize, Serialize}; use std::fmt; /// An HTTP 4xx (client error) or 5xx (server error) status code. @@ -20,7 +21,10 @@ use std::fmt; /// fallible conversion from an [`http::StatusCode`]. /// /// [iana]: https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml -#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive( + Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, +)] +#[serde(try_from = "u16", into = "u16")] pub struct ErrorStatusCode(http::StatusCode); // Generate constants for a `http::StatusCode` wrapper type that re-export the @@ -415,6 +419,44 @@ impl_status_code_wrapper! { } } +impl schemars::JsonSchema for ErrorStatusCode { + fn schema_name() -> String { + "ErrorStatusCode".to_string() + } + fn schema_id() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed("dropshot::ErrorStatusCode") + } + + fn json_schema( + _generator: &mut schemars::gen::SchemaGenerator, + ) -> schemars::schema::Schema { + schemars::schema::SchemaObject { + metadata: Some(Box::new(schemars::schema::Metadata { + title: Some("An HTTP error status code".to_string()), + description: Some( + "An HTTP status code in the error range (4xx or 5xx)" + .to_string(), + ), + examples: vec![ + "400".into(), + "404".into(), + "500".into(), + "503".into(), + ], + ..Default::default() + })), + instance_type: Some(schemars::schema::InstanceType::Integer.into()), + number: Some(Box::new(schemars::schema::NumberValidation { + minimum: Some(400.0), + maximum: Some(599.0), + ..Default::default() + })), + ..Default::default() + } + .into() + } +} + /// An HTTP 4xx client error status code /// /// This is a refinement of the [`http::StatusCode`] type that is limited to the @@ -429,7 +471,10 @@ impl_status_code_wrapper! { /// conversion from an [`http::StatusCode`]. /// /// [iana]: https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml -#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive( + Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, +)] +#[serde(try_from = "u16", into = "u16")] pub struct ClientErrorStatusCode(http::StatusCode); impl ClientErrorStatusCode { @@ -585,6 +630,39 @@ impl_status_code_wrapper! { } } +impl schemars::JsonSchema for ClientErrorStatusCode { + fn schema_name() -> String { + "ClientErrorStatusCode".to_string() + } + fn schema_id() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed("dropshot::ClientErrorStatusCode") + } + + fn json_schema( + _generator: &mut schemars::gen::SchemaGenerator, + ) -> schemars::schema::Schema { + schemars::schema::SchemaObject { + metadata: Some(Box::new(schemars::schema::Metadata { + title: Some("An HTTP client error status code".to_string()), + description: Some( + "An HTTP status code in the client error range (4xx)" + .to_string(), + ), + examples: vec!["400".into(), "404".into(), "451".into()], + ..Default::default() + })), + instance_type: Some(schemars::schema::InstanceType::Integer.into()), + number: Some(Box::new(schemars::schema::NumberValidation { + minimum: Some(400.0), + maximum: Some(499.0), + ..Default::default() + })), + ..Default::default() + } + .into() + } +} + impl TryFrom for ClientErrorStatusCode { type Error = NotAClientError; fn try_from(error: ErrorStatusCode) -> Result {