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