Skip to main content

lago_api/
error.rs

1use axum::http::StatusCode;
2use axum::response::{IntoResponse, Response};
3use lago_core::LagoError;
4use serde::Serialize;
5
6/// API-level error type that wraps `LagoError` and adds HTTP-specific variants.
7#[derive(Debug)]
8pub enum ApiError {
9    /// Wraps a core `LagoError`.
10    Lago(LagoError),
11    /// 400 Bad Request with a human-readable message.
12    BadRequest(String),
13    /// 404 Not Found with a description of what was missing.
14    NotFound(String),
15    /// 403 Forbidden with a policy explanation.
16    Forbidden(String),
17    /// 409 Conflict with a description of why the operation cannot proceed.
18    Conflict(String),
19    /// 500 Internal Server Error with an opaque message.
20    Internal(String),
21}
22
23/// JSON body returned for error responses.
24#[derive(Serialize)]
25struct ErrorBody {
26    error: String,
27    message: String,
28}
29
30impl IntoResponse for ApiError {
31    fn into_response(self) -> Response {
32        let (status, error_type, message) = match &self {
33            ApiError::Lago(e) => match e {
34                LagoError::SessionNotFound(id) => (
35                    StatusCode::NOT_FOUND,
36                    "session_not_found",
37                    format!("session not found: {id}"),
38                ),
39                LagoError::BranchNotFound(id) => (
40                    StatusCode::NOT_FOUND,
41                    "branch_not_found",
42                    format!("branch not found: {id}"),
43                ),
44                LagoError::EventNotFound(id) => (
45                    StatusCode::NOT_FOUND,
46                    "event_not_found",
47                    format!("event not found: {id}"),
48                ),
49                LagoError::BlobNotFound(hash) => (
50                    StatusCode::NOT_FOUND,
51                    "blob_not_found",
52                    format!("blob not found: {hash}"),
53                ),
54                LagoError::FileNotFound(path) => (
55                    StatusCode::NOT_FOUND,
56                    "file_not_found",
57                    format!("file not found: {path}"),
58                ),
59                LagoError::InvalidArgument(msg) => {
60                    (StatusCode::BAD_REQUEST, "invalid_argument", msg.clone())
61                }
62                LagoError::SequenceConflict { expected, actual } => (
63                    StatusCode::CONFLICT,
64                    "sequence_conflict",
65                    format!("sequence conflict: expected {expected}, got {actual}"),
66                ),
67                LagoError::PolicyDenied(msg) => {
68                    (StatusCode::FORBIDDEN, "policy_denied", msg.clone())
69                }
70                LagoError::Serialization(e) => (
71                    StatusCode::BAD_REQUEST,
72                    "serialization_error",
73                    format!("serialization error: {e}"),
74                ),
75                LagoError::HashLine(e) => (
76                    StatusCode::BAD_REQUEST,
77                    "hashline_error",
78                    format!("hashline error: {e}"),
79                ),
80                LagoError::Sandbox(msg) => (
81                    StatusCode::BAD_REQUEST,
82                    "sandbox_error",
83                    format!("sandbox error: {msg}"),
84                ),
85                _ => (
86                    StatusCode::INTERNAL_SERVER_ERROR,
87                    "internal_error",
88                    format!("internal error: {e}"),
89                ),
90            },
91            ApiError::BadRequest(msg) => (StatusCode::BAD_REQUEST, "bad_request", msg.clone()),
92            ApiError::NotFound(msg) => (StatusCode::NOT_FOUND, "not_found", msg.clone()),
93            ApiError::Forbidden(msg) => (StatusCode::FORBIDDEN, "forbidden", msg.clone()),
94            ApiError::Conflict(msg) => (StatusCode::CONFLICT, "conflict", msg.clone()),
95            ApiError::Internal(msg) => (
96                StatusCode::INTERNAL_SERVER_ERROR,
97                "internal_error",
98                msg.clone(),
99            ),
100        };
101
102        let body = ErrorBody {
103            error: error_type.to_string(),
104            message,
105        };
106
107        (status, axum::Json(body)).into_response()
108    }
109}
110
111impl From<LagoError> for ApiError {
112    fn from(e: LagoError) -> Self {
113        ApiError::Lago(e)
114    }
115}
116
117impl From<serde_json::Error> for ApiError {
118    fn from(e: serde_json::Error) -> Self {
119        ApiError::BadRequest(format!("invalid JSON: {e}"))
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use axum::response::IntoResponse;
127
128    #[test]
129    fn api_error_from_lago_session_not_found() {
130        let e: ApiError = LagoError::SessionNotFound("S1".into()).into();
131        let resp = e.into_response();
132        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
133    }
134
135    #[test]
136    fn api_error_from_lago_branch_not_found() {
137        let e: ApiError = LagoError::BranchNotFound("B1".into()).into();
138        let resp = e.into_response();
139        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
140    }
141
142    #[test]
143    fn api_error_from_lago_policy_denied() {
144        let e: ApiError = LagoError::PolicyDenied("blocked".into()).into();
145        let resp = e.into_response();
146        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
147    }
148
149    #[test]
150    fn api_error_from_lago_sequence_conflict() {
151        let e: ApiError = LagoError::SequenceConflict {
152            expected: 5,
153            actual: 3,
154        }
155        .into();
156        let resp = e.into_response();
157        assert_eq!(resp.status(), StatusCode::CONFLICT);
158    }
159
160    #[test]
161    fn api_error_from_lago_invalid_argument() {
162        let e: ApiError = LagoError::InvalidArgument("bad input".into()).into();
163        let resp = e.into_response();
164        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
165    }
166
167    #[test]
168    fn api_error_from_lago_internal() {
169        let e: ApiError = LagoError::Journal("disk error".into()).into();
170        let resp = e.into_response();
171        assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
172    }
173
174    #[test]
175    fn api_error_bad_request() {
176        let e = ApiError::BadRequest("bad".into());
177        let resp = e.into_response();
178        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
179    }
180
181    #[test]
182    fn api_error_not_found() {
183        let e = ApiError::NotFound("missing".into());
184        let resp = e.into_response();
185        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
186    }
187
188    #[test]
189    fn api_error_forbidden() {
190        let e = ApiError::Forbidden("policy denied".into());
191        let resp = e.into_response();
192        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
193    }
194
195    #[test]
196    fn api_error_conflict() {
197        let e = ApiError::Conflict("merge conflict".into());
198        let resp = e.into_response();
199        assert_eq!(resp.status(), StatusCode::CONFLICT);
200    }
201
202    #[test]
203    fn api_error_internal() {
204        let e = ApiError::Internal("oops".into());
205        let resp = e.into_response();
206        assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
207    }
208
209    #[test]
210    fn api_error_from_serde_json() {
211        let err: Result<serde_json::Value, _> = serde_json::from_str("{bad");
212        let e: ApiError = err.unwrap_err().into();
213        match e {
214            ApiError::BadRequest(msg) => assert!(msg.contains("invalid JSON")),
215            _ => panic!("expected BadRequest"),
216        }
217    }
218
219    #[test]
220    fn api_error_from_lago_hashline() {
221        let hl_err = lago_core::hashline::HashLineError::LineOutOfBounds {
222            line_num: 10,
223            total_lines: 5,
224        };
225        let e: ApiError = LagoError::HashLine(hl_err).into();
226        let resp = e.into_response();
227        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
228    }
229
230    #[test]
231    fn api_error_from_lago_sandbox() {
232        let e: ApiError = LagoError::Sandbox("container failed".into()).into();
233        let resp = e.into_response();
234        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
235    }
236}