Skip to main content

tonic_rest/runtime/
error.rs

1//! REST error wrapper — converts [`tonic::Status`] to HTTP error responses.
2
3use axum::extract::Json;
4use axum::response::IntoResponse;
5
6use super::status_map::{grpc_code_name, grpc_to_http_status};
7
8/// REST error wrapper — converts [`tonic::Status`] to an HTTP error response.
9///
10/// Maps gRPC status codes to HTTP status codes and returns a JSON error body
11/// following the [Google API error model](https://cloud.google.com/apis/design/errors):
12///
13/// ```json
14/// {
15///   "error": { "code": 400, "message": "...", "status": "INVALID_ARGUMENT" }
16/// }
17/// ```
18///
19/// # Response Format
20///
21/// The JSON shape is intentionally fixed to the Google API error convention.
22/// This provides a consistent, well-documented error format across all generated
23/// REST endpoints. The body wraps the error in an `"error"` object:
24///
25/// ```json
26/// { "error": { "code": 404, "message": "...", "status": "NOT_FOUND" } }
27/// ```
28///
29/// Note: SSE error events (via [`sse_error_event`](crate::sse_error_event)) use
30/// the same `{"error": {...}}` format, ensuring a consistent error shape across
31/// both HTTP JSON and SSE transports.
32///
33/// If you need a custom error shape, implement
34/// [`axum::response::IntoResponse`] on your own error type and set the
35/// `runtime_crate` config in `tonic-rest-build` to point to the module
36/// containing your custom types.
37///
38/// # Constructing
39///
40/// Use [`From<tonic::Status>`] or [`RestError::new`]:
41///
42/// ```
43/// # use tonic_rest::RestError;
44/// let err = RestError::new(tonic::Status::not_found("gone"));
45/// let err: RestError = tonic::Status::not_found("gone").into();
46/// ```
47///
48/// # Examples
49///
50/// Convert a tonic status to an Axum-compatible HTTP response:
51///
52/// ```
53/// use tonic_rest::RestError;
54/// use axum::response::IntoResponse;
55///
56/// let err = RestError::new(tonic::Status::not_found("user not found"));
57/// let response = err.into_response();
58/// assert_eq!(response.status(), axum::http::StatusCode::NOT_FOUND);
59/// ```
60#[derive(Debug, Clone)]
61pub struct RestError(tonic::Status);
62
63impl std::fmt::Display for RestError {
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        write!(f, "{}: {}", grpc_code_name(self.0.code()), self.0.message())
66    }
67}
68
69impl std::error::Error for RestError {
70    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
71        Some(&self.0)
72    }
73}
74
75impl RestError {
76    /// Create a new `RestError` from a [`tonic::Status`].
77    #[must_use]
78    pub const fn new(status: tonic::Status) -> Self {
79        Self(status)
80    }
81
82    /// Returns a reference to the underlying [`tonic::Status`].
83    #[must_use]
84    pub const fn status(&self) -> &tonic::Status {
85        &self.0
86    }
87
88    /// Consumes the `RestError` and returns the underlying [`tonic::Status`].
89    #[must_use]
90    pub fn into_status(self) -> tonic::Status {
91        self.0
92    }
93}
94
95impl From<tonic::Status> for RestError {
96    fn from(status: tonic::Status) -> Self {
97        Self(status)
98    }
99}
100
101impl IntoResponse for RestError {
102    fn into_response(self) -> axum::response::Response {
103        let http_status = grpc_to_http_status(self.0.code());
104
105        let body = serde_json::json!({
106            "error": {
107                "code": http_status.as_u16(),
108                "message": self.0.message(),
109                "status": grpc_code_name(self.0.code()),
110            }
111        });
112
113        (http_status, Json(body)).into_response()
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use http_body_util::BodyExt;
121
122    /// Parse the JSON error body from a `RestError` response.
123    async fn error_body(status: tonic::Status) -> (axum::http::StatusCode, serde_json::Value) {
124        let response = RestError::new(status).into_response();
125        let http_status = response.status();
126        let bytes = response.into_body().collect().await.unwrap().to_bytes();
127        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
128        (http_status, json)
129    }
130
131    #[tokio::test]
132    async fn not_found_response() {
133        let (status, json) = error_body(tonic::Status::not_found("user not found")).await;
134        assert_eq!(status, axum::http::StatusCode::NOT_FOUND);
135        assert_eq!(json["error"]["code"], 404);
136        assert_eq!(json["error"]["message"], "user not found");
137        assert_eq!(json["error"]["status"], "NOT_FOUND");
138    }
139
140    #[tokio::test]
141    async fn invalid_argument_response() {
142        let (status, json) = error_body(tonic::Status::invalid_argument("bad email")).await;
143        assert_eq!(status, axum::http::StatusCode::BAD_REQUEST);
144        assert_eq!(json["error"]["code"], 400);
145        assert_eq!(json["error"]["message"], "bad email");
146        assert_eq!(json["error"]["status"], "INVALID_ARGUMENT");
147    }
148
149    #[tokio::test]
150    async fn internal_error_response() {
151        let (status, json) = error_body(tonic::Status::internal("db crashed")).await;
152        assert_eq!(status, axum::http::StatusCode::INTERNAL_SERVER_ERROR);
153        assert_eq!(json["error"]["code"], 500);
154        assert_eq!(json["error"]["message"], "db crashed");
155        assert_eq!(json["error"]["status"], "INTERNAL");
156    }
157
158    #[tokio::test]
159    async fn unauthenticated_response() {
160        let (status, json) = error_body(tonic::Status::unauthenticated("token expired")).await;
161        assert_eq!(status, axum::http::StatusCode::UNAUTHORIZED);
162        assert_eq!(json["error"]["code"], 401);
163        assert_eq!(json["error"]["message"], "token expired");
164        assert_eq!(json["error"]["status"], "UNAUTHENTICATED");
165    }
166
167    #[tokio::test]
168    async fn permission_denied_response() {
169        let (status, json) = error_body(tonic::Status::permission_denied("admin only")).await;
170        assert_eq!(status, axum::http::StatusCode::FORBIDDEN);
171        assert_eq!(json["error"]["code"], 403);
172        assert_eq!(json["error"]["message"], "admin only");
173        assert_eq!(json["error"]["status"], "PERMISSION_DENIED");
174    }
175
176    #[tokio::test]
177    async fn empty_message_response() {
178        let (status, json) = error_body(tonic::Status::internal("")).await;
179        assert_eq!(status, axum::http::StatusCode::INTERNAL_SERVER_ERROR);
180        assert_eq!(json["error"]["message"], "");
181    }
182
183    #[test]
184    fn from_tonic_status() {
185        let status = tonic::Status::not_found("gone");
186        let err = RestError::from(status);
187        assert_eq!(err.status().code(), tonic::Code::NotFound);
188        assert_eq!(err.status().message(), "gone");
189    }
190
191    #[test]
192    fn display_format() {
193        let err = RestError::new(tonic::Status::not_found("user not found"));
194        assert_eq!(err.to_string(), "NOT_FOUND: user not found");
195    }
196
197    #[test]
198    fn display_empty_message() {
199        let err = RestError::new(tonic::Status::internal(""));
200        assert_eq!(err.to_string(), "INTERNAL: ");
201    }
202
203    #[test]
204    fn debug_format() {
205        let err = RestError::new(tonic::Status::not_found("gone"));
206        let debug = format!("{err:?}");
207        assert!(debug.contains("RestError"), "missing type name: {debug}");
208    }
209
210    #[test]
211    fn error_source_is_tonic_status() {
212        use std::error::Error;
213        let err = RestError::new(tonic::Status::internal("boom"));
214        let source = err.source().expect("should have a source");
215        assert!(
216            source.to_string().contains("boom"),
217            "source should contain message: {source}",
218        );
219    }
220
221    #[tokio::test]
222    async fn response_content_type_is_json() {
223        let response = RestError::new(tonic::Status::not_found("x")).into_response();
224        let content_type = response
225            .headers()
226            .get("content-type")
227            .unwrap()
228            .to_str()
229            .unwrap();
230        assert!(
231            content_type.contains("application/json"),
232            "expected JSON content-type, got: {content_type}",
233        );
234    }
235
236    #[test]
237    fn status_accessor_returns_inner() {
238        let err = RestError::new(tonic::Status::not_found("gone"));
239        assert_eq!(err.status().code(), tonic::Code::NotFound);
240        assert_eq!(err.status().message(), "gone");
241    }
242
243    #[test]
244    fn into_status_consumes_and_returns_inner() {
245        let err = RestError::new(tonic::Status::permission_denied("nope"));
246        let status = err.into_status();
247        assert_eq!(status.code(), tonic::Code::PermissionDenied);
248        assert_eq!(status.message(), "nope");
249    }
250}