tonic_rest/runtime/
error.rs1use axum::extract::Json;
4use axum::response::IntoResponse;
5
6use super::status_map::{grpc_code_name, grpc_to_http_status};
7
8#[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 #[must_use]
78 pub const fn new(status: tonic::Status) -> Self {
79 Self(status)
80 }
81
82 #[must_use]
84 pub const fn status(&self) -> &tonic::Status {
85 &self.0
86 }
87
88 #[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 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}