1use axum::http::StatusCode;
8use axum::response::{IntoResponse, Response};
9use axum::Json;
10use serde_json::json;
11
12#[derive(Debug, thiserror::Error)]
37pub enum ApiError {
38 #[error("unauthenticated")]
40 Unauthorized,
41 #[error("{message}")]
43 NotFound {
44 code: &'static str,
46 message: String,
48 },
49 #[error("{message}")]
51 BadRequest {
52 code: &'static str,
54 message: String,
56 },
57 #[error("{0}")]
59 Internal(String),
60}
61
62impl ApiError {
63 pub fn unknown_index(id: &str) -> Self {
65 ApiError::NotFound {
66 code: "unknown_index",
67 message: format!("unknown index id '{id}'"),
68 }
69 }
70
71 pub fn unknown_node(id: &str) -> Self {
73 ApiError::NotFound {
74 code: "unknown_node",
75 message: format!("unknown node '{id}'"),
76 }
77 }
78
79 pub fn no_path(from: &str, to: &str) -> Self {
87 ApiError::NotFound {
88 code: "no_path",
89 message: format!("no typed path between '{from}' and '{to}'"),
90 }
91 }
92
93 pub fn missing_param(name: &str) -> Self {
95 ApiError::BadRequest {
96 code: "missing_param",
97 message: format!("missing required query parameter '{name}'"),
98 }
99 }
100
101 pub fn invalid_param(name: &str, reason: &str) -> Self {
103 ApiError::BadRequest {
104 code: "invalid_param",
105 message: format!("invalid query parameter '{name}': {reason}"),
106 }
107 }
108
109 pub fn invalid_body(reason: &str) -> Self {
111 ApiError::BadRequest {
112 code: "invalid_body",
113 message: format!("invalid request body: {reason}"),
114 }
115 }
116
117 pub fn invalid_repo_path(reason: &str) -> Self {
119 ApiError::BadRequest {
120 code: "invalid_repo_path",
121 message: format!("invalid repo_path: {reason}"),
122 }
123 }
124
125 pub fn internal(reason: impl Into<String>) -> Self {
127 ApiError::Internal(reason.into())
128 }
129
130 fn status(&self) -> StatusCode {
131 match self {
132 ApiError::Unauthorized => StatusCode::UNAUTHORIZED,
133 ApiError::NotFound { .. } => StatusCode::NOT_FOUND,
134 ApiError::BadRequest { .. } => StatusCode::BAD_REQUEST,
135 ApiError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
136 }
137 }
138
139 pub fn code(&self) -> &'static str {
141 match self {
142 ApiError::Unauthorized => "unauthenticated",
143 ApiError::NotFound { code, .. } | ApiError::BadRequest { code, .. } => code,
144 ApiError::Internal(_) => "internal",
145 }
146 }
147
148 pub fn body(&self) -> serde_json::Value {
150 json!({ "error": self.code(), "message": self.to_string() })
151 }
152}
153
154impl IntoResponse for ApiError {
155 fn into_response(self) -> Response {
156 (self.status(), Json(self.body())).into_response()
157 }
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163
164 #[test]
165 fn status_matches_the_kind_of_failure() {
166 assert_eq!(ApiError::Unauthorized.status(), StatusCode::UNAUTHORIZED);
167 assert_eq!(ApiError::unknown_index("x").status(), StatusCode::NOT_FOUND);
168 assert_eq!(ApiError::unknown_node("x").status(), StatusCode::NOT_FOUND);
169 assert_eq!(ApiError::no_path("a", "b").status(), StatusCode::NOT_FOUND);
170 assert_eq!(
171 ApiError::missing_param("node").status(),
172 StatusCode::BAD_REQUEST
173 );
174 assert_eq!(
175 ApiError::invalid_param("limit", "not an integer").status(),
176 StatusCode::BAD_REQUEST
177 );
178 assert_eq!(
179 ApiError::invalid_body("bad json").status(),
180 StatusCode::BAD_REQUEST
181 );
182 assert_eq!(
183 ApiError::invalid_repo_path("missing").status(),
184 StatusCode::BAD_REQUEST
185 );
186 assert_eq!(
187 ApiError::internal("boom").status(),
188 StatusCode::INTERNAL_SERVER_ERROR
189 );
190 }
191
192 #[test]
195 fn design_codes_are_all_reachable() {
196 for code in [
197 "unauthenticated",
198 "unknown_index",
199 "unknown_node",
200 "missing_param",
201 "invalid_param",
202 "invalid_body",
203 "invalid_repo_path",
204 "internal",
205 ] {
206 let err = match code {
207 "unauthenticated" => ApiError::Unauthorized,
208 "unknown_index" => ApiError::unknown_index("id"),
209 "unknown_node" => ApiError::unknown_node("id"),
210 "missing_param" => ApiError::missing_param("node"),
211 "invalid_param" => ApiError::invalid_param("limit", "out of range"),
212 "invalid_body" => ApiError::invalid_body("not json"),
213 "invalid_repo_path" => ApiError::invalid_repo_path("not a directory"),
214 _ => ApiError::internal("boom"),
215 };
216 assert_eq!(err.code(), code);
217 }
218 }
219
220 #[test]
221 fn body_is_flat_with_code_and_message() {
222 let body = ApiError::unknown_index("abc").body();
223 let obj = body.as_object().expect("body is an object");
224 assert_eq!(obj.len(), 2, "flat body has exactly error+message: {body}");
225 assert_eq!(obj["error"], "unknown_index");
226 assert!(obj["message"].as_str().unwrap().contains("abc"));
227 assert!(body.get("status").is_none());
229 }
230
231 #[test]
234 fn nonexistent_is_404_and_malformed_is_400() {
235 assert_eq!(ApiError::unknown_index("i").code(), "unknown_index");
236 assert_eq!(ApiError::unknown_node("n").code(), "unknown_node");
237 assert_eq!(ApiError::unknown_index("i").status(), StatusCode::NOT_FOUND);
238 assert_eq!(ApiError::missing_param("node").code(), "missing_param");
239 assert_eq!(
240 ApiError::missing_param("node").status(),
241 StatusCode::BAD_REQUEST
242 );
243 }
244
245 #[test]
248 fn message_carries_detail_not_internals() {
249 assert!(ApiError::missing_param("node").to_string().contains("node"));
250 assert!(ApiError::invalid_param("limit", "must be 1..=1000")
251 .to_string()
252 .contains("1..=1000"));
253 assert_eq!(ApiError::Unauthorized.to_string(), "unauthenticated");
254 }
255}