Skip to main content

codetopo_server/
error.rs

1// Copyright (C) 2026 Nrupal Akolkar
2// SPDX-License-Identifier: AGPL-3.0-or-later
3// Part of codetopo — Owned by Nrupal Akolkar · Built with Muse by Meta.
4
5//! JSON error type shared by every handler and middleware.
6
7use axum::http::StatusCode;
8use axum::response::{IntoResponse, Response};
9use axum::Json;
10use serde_json::json;
11
12/// Every failure the API can report, with the JSON body clients receive.
13///
14/// ## Deviation from the design
15///
16/// Phase 2 design §7 specified a nested body carrying the status twice
17/// (`{"error": {"code", "message", "status"}}`). The body here is flat —
18/// `{"error": "<code>", "message": "<text>"}` — and the status is only in the
19/// HTTP status line. Rationale: the status is already authoritative on the wire
20/// and repeating it in the body is a second source of truth that can disagree
21/// with the status line. Flattening also means an agent parses one object with
22/// no branch before it can read the machine code.
23///
24/// `error` is a stable machine code drawn from a closed set
25/// ([`ApiError::code`]) and is what a client should branch on. The codes are the
26/// ones §7 enumerates; `message` is prose for humans and is not guaranteed to
27/// be stable across releases.
28///
29/// ## Why `NotFound` and `BadRequest` carry their own code
30///
31/// Both map to a single HTTP status, so the status alone cannot tell an agent
32/// what went wrong: `404` covers both "no such index" and "no such node", and
33/// `400` covers a missing param, an unparseable param, and a bad body. The
34/// status says *what kind* of failure; the code says *which*, which is what
35/// lets a client retry sensibly without matching on prose.
36#[derive(Debug, thiserror::Error)]
37pub enum ApiError {
38    /// No `Authorization` header, wrong scheme, unknown key, or wrong secret.
39    #[error("unauthenticated")]
40    Unauthorized,
41    /// The request was well-formed but names something that does not exist.
42    #[error("{message}")]
43    NotFound {
44        /// Stable machine code, e.g. `unknown_index`.
45        code: &'static str,
46        /// Human-readable detail.
47        message: String,
48    },
49    /// The request itself is malformed (missing field, bad value).
50    #[error("{message}")]
51    BadRequest {
52        /// Stable machine code, e.g. `missing_param`.
53        code: &'static str,
54        /// Human-readable detail.
55        message: String,
56    },
57    /// Storage, indexing, or an unexpected internal failure.
58    #[error("{0}")]
59    Internal(String),
60}
61
62impl ApiError {
63    /// 404 `unknown_index`: `{id}` does not name a database in the data dir.
64    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    /// 404 `unknown_node`: the index loaded, but the node is not in the graph.
72    pub fn unknown_node(id: &str) -> Self {
73        ApiError::NotFound {
74            code: "unknown_node",
75            message: format!("unknown node '{id}'"),
76        }
77    }
78
79    /// 404 `no_path`: no typed path connects the two nodes.
80    ///
81    /// Not in the §7 table, which does not cover a well-formed `path` query
82    /// whose answer is "these two symbols are unrelated". The CLI rejects the
83    /// same case rather than returning an empty path, so `404` is preserved and
84    /// given its own code instead of being folded into `unknown_index` or
85    /// reported as an empty `200`.
86    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    /// 400 `missing_param`: a required query parameter is absent or empty.
94    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    /// 400 `invalid_param`: a query parameter is present but unusable.
102    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    /// 400 `invalid_body`: the request body is not the expected JSON shape.
110    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    /// 400 `invalid_repo_path`: `repo_path` is missing or is not a directory.
118    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    /// 500 from any internal failure, carrying `reason`.
126    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    /// The stable machine code clients branch on.
140    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    /// Body as a JSON value; exposed for tests and for asserting shape.
149    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    /// Every code §7 enumerates must be reachable, so the closed set clients
193    /// branch on is actually the set the server can emit.
194    #[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        // The status is not duplicated into the body (see module docs).
228        assert!(body.get("status").is_none());
229    }
230
231    /// §7's 404-vs-400 split: naming a nonexistent thing is 404, being
232    /// malformed is 400, and the code keeps them apart within a status.
233    #[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    /// The rendered message must not leak a secret, and must include the detail
246    /// the client needs to fix its own request.
247    #[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}