Skip to content

Commit aaf7ea5

Browse files
committed
web/error: add JsonAxumNope for JSON APIs
This aims to satisfy these wishes: - Use the same error enum (`AxumNope`) for both handlers returning HTML or JSON. - Yet allow those handlers to specify whether HTML or JSON should be returned. - Not change the exising code returning `AxumNope`--still convert to HTML by default. Because `AxumNope` also contains a case (`Search`) that is fixed to producing HTML, but shouldn't ever be used by handlers returning JSON, that works with a runtime error in case the latter assumption isn't being followed. The approach is to add a new wrapper around `AxumNope` called `JsonAxumNope` and matching type def `JsonAxumResult`. The wrapper also implements `IntoResponse`, but produces JSON. Endpoints wishing to return JSON errors need to use that wrapper and specify `JsonAxumResult` as their return type.
1 parent bb0212b commit aaf7ea5

File tree

1 file changed

+120
-36
lines changed

1 file changed

+120
-36
lines changed

src/web/error.rs

Lines changed: 120 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
use crate::{
22
db::PoolError,
33
storage::PathNotFoundError,
4-
web::{cache::CachePolicy, encode_url_path, releases::Search, AxumErrorPage},
4+
web::{cache::CachePolicy, encode_url_path, releases::Search},
55
};
66
use anyhow::anyhow;
77
use axum::{
88
http::StatusCode,
99
response::{IntoResponse, Response as AxumResponse},
10+
Json,
1011
};
1112
use std::borrow::Cow;
13+
use tracing::error;
14+
15+
use super::AxumErrorPage;
1216

1317
#[derive(Debug, thiserror::Error)]
1418
pub enum AxumNope {
@@ -32,90 +36,169 @@ pub enum AxumNope {
3236
Redirect(String, CachePolicy),
3337
}
3438

35-
impl IntoResponse for AxumNope {
36-
fn into_response(self) -> AxumResponse {
39+
// FUTURE: Ideally, the split between the 3 kinds of responses would
40+
// be done by having multiple nested enums in the first place instead
41+
// of just `AxumNope`, to keep everything statically type-checked
42+
// throughout instead of having the potential for a runtime error.
43+
44+
impl AxumNope {
45+
fn into_error_response(self) -> ErrorResponse {
3746
match self {
3847
AxumNope::ResourceNotFound => {
3948
// user tried to navigate to a resource (doc page/file) that doesn't exist
40-
AxumErrorPage {
49+
ErrorResponse::ErrorInfo(ErrorInfo {
4150
title: "The requested resource does not exist",
4251
message: "no such resource".into(),
4352
status: StatusCode::NOT_FOUND,
44-
}
45-
.into_response()
53+
})
4654
}
47-
48-
AxumNope::BuildNotFound => AxumErrorPage {
55+
AxumNope::BuildNotFound => ErrorResponse::ErrorInfo(ErrorInfo {
4956
title: "The requested build does not exist",
5057
message: "no such build".into(),
5158
status: StatusCode::NOT_FOUND,
52-
}
53-
.into_response(),
54-
59+
}),
5560
AxumNope::CrateNotFound => {
5661
// user tried to navigate to a crate that doesn't exist
5762
// TODO: Display the attempted crate and a link to a search for said crate
58-
AxumErrorPage {
63+
ErrorResponse::ErrorInfo(ErrorInfo {
5964
title: "The requested crate does not exist",
6065
message: "no such crate".into(),
6166
status: StatusCode::NOT_FOUND,
62-
}
63-
.into_response()
67+
})
6468
}
65-
66-
AxumNope::OwnerNotFound => AxumErrorPage {
69+
AxumNope::OwnerNotFound => ErrorResponse::ErrorInfo(ErrorInfo {
6770
title: "The requested owner does not exist",
6871
message: "no such owner".into(),
6972
status: StatusCode::NOT_FOUND,
70-
}
71-
.into_response(),
72-
73+
}),
7374
AxumNope::VersionNotFound => {
7475
// user tried to navigate to a crate with a version that does not exist
7576
// TODO: Display the attempted crate and version
76-
AxumErrorPage {
77+
ErrorResponse::ErrorInfo(ErrorInfo {
7778
title: "The requested version does not exist",
7879
message: "no such version for this crate".into(),
7980
status: StatusCode::NOT_FOUND,
80-
}
81-
.into_response()
81+
})
8282
}
8383
AxumNope::NoResults => {
8484
// user did a search with no search terms
85-
Search {
85+
ErrorResponse::Search(Search {
8686
title: "No results given for empty search query".to_owned(),
8787
status: StatusCode::NOT_FOUND,
8888
..Default::default()
89-
}
90-
.into_response()
89+
})
9190
}
92-
AxumNope::BadRequest(source) => AxumErrorPage {
91+
AxumNope::BadRequest(source) => ErrorResponse::ErrorInfo(ErrorInfo {
9392
title: "Bad request",
9493
message: Cow::Owned(source.to_string()),
9594
status: StatusCode::BAD_REQUEST,
96-
}
97-
.into_response(),
95+
}),
9896
AxumNope::InternalError(source) => {
99-
let web_error = crate::web::AxumErrorPage {
97+
crate::utils::report_error(&source);
98+
ErrorResponse::ErrorInfo(ErrorInfo {
10099
title: "Internal Server Error",
101100
message: Cow::Owned(source.to_string()),
102101
status: StatusCode::INTERNAL_SERVER_ERROR,
103-
};
104-
105-
crate::utils::report_error(&source);
106-
107-
web_error.into_response()
102+
})
108103
}
109104
AxumNope::Redirect(target, cache_policy) => {
110105
match super::axum_cached_redirect(&encode_url_path(&target), cache_policy) {
111-
Ok(response) => response.into_response(),
112-
Err(err) => AxumNope::InternalError(err).into_response(),
106+
Ok(response) => ErrorResponse::Redirect(response),
107+
// Recurse 1 step:
108+
Err(err) => AxumNope::InternalError(err).into_error_response(),
113109
}
114110
}
115111
}
116112
}
117113
}
118114

115+
// A response representing an outcome from `AxumNope`, usable in both
116+
// HTML or JSON (API) based endpoints.
117+
enum ErrorResponse {
118+
// Info representable both as HTML or as JSON
119+
ErrorInfo(ErrorInfo),
120+
// Redirect,
121+
Redirect(AxumResponse),
122+
// To recreate empty search page; only valid in HTML based
123+
// endpoints.
124+
Search(Search),
125+
}
126+
127+
struct ErrorInfo {
128+
// For the title of the page
129+
pub title: &'static str,
130+
// The error message, displayed as a description
131+
pub message: Cow<'static, str>,
132+
// The status code of the response
133+
pub status: StatusCode,
134+
}
135+
136+
impl ErrorResponse {
137+
fn into_html_response(self) -> AxumResponse {
138+
match self {
139+
ErrorResponse::ErrorInfo(ErrorInfo {
140+
title,
141+
message,
142+
status,
143+
}) => AxumErrorPage {
144+
title,
145+
message,
146+
status,
147+
}
148+
.into_response(),
149+
ErrorResponse::Redirect(response) => response,
150+
ErrorResponse::Search(search) => search.into_response(),
151+
}
152+
}
153+
154+
fn into_json_response(self) -> AxumResponse {
155+
match self {
156+
ErrorResponse::ErrorInfo(ErrorInfo {
157+
title,
158+
message,
159+
status,
160+
}) => (
161+
status,
162+
Json(serde_json::json!({
163+
"title": title,
164+
"message": message,
165+
})),
166+
)
167+
.into_response(),
168+
ErrorResponse::Redirect(response) => response,
169+
ErrorResponse::Search(search) => {
170+
// FUTURE: this runtime error is avoidable by
171+
// splitting `enum AxumNope` into hierarchical parts,
172+
// see above.
173+
error!(
174+
"expecting that handlers that return JSON error responses \
175+
don't return Search, but got: {search:?}"
176+
);
177+
AxumNope::InternalError(anyhow!(
178+
"bug: search HTML page returned from endpoint that returns JSON"
179+
))
180+
.into_error_response()
181+
.into_json_response()
182+
}
183+
}
184+
}
185+
}
186+
187+
impl IntoResponse for AxumNope {
188+
fn into_response(self) -> AxumResponse {
189+
self.into_error_response().into_html_response()
190+
}
191+
}
192+
193+
/// `AxumNope` but generating error responses in JSON (for API).
194+
pub(crate) struct JsonAxumNope(pub AxumNope);
195+
196+
impl IntoResponse for JsonAxumNope {
197+
fn into_response(self) -> AxumResponse {
198+
self.0.into_error_response().into_json_response()
199+
}
200+
}
201+
119202
impl From<anyhow::Error> for AxumNope {
120203
fn from(err: anyhow::Error) -> Self {
121204
match err.downcast::<AxumNope>() {
@@ -141,6 +224,7 @@ impl From<PoolError> for AxumNope {
141224
}
142225

143226
pub(crate) type AxumResult<T> = Result<T, AxumNope>;
227+
pub(crate) type JsonAxumResult<T> = Result<T, JsonAxumNope>;
144228

145229
#[cfg(test)]
146230
mod tests {

0 commit comments

Comments
 (0)