Skip to main content

fslite_server/
error.rs

1use axum::Json;
2use axum::http::{HeaderValue, StatusCode};
3use axum::response::{IntoResponse, Response};
4use fslite_core::{ErrorCode, FsError};
5use serde_json::json;
6
7/// The uniform error type every `fslite-server` handler returns.
8#[derive(Debug)]
9pub enum ApiError {
10    /// A domain error surfaced by `fslite-core`.
11    Domain(FsError),
12    /// The request carried no, or an unrecognized, credential.
13    Unauthenticated(String),
14    /// The authenticated actor's workspace does not match the URL.
15    WorkspaceMismatch,
16    /// The request body was not valid JSON, or failed local validation.
17    MalformedBody(String),
18    /// No route matched the request.
19    RouteNotFound,
20    /// The route exists but not for this HTTP method.
21    MethodNotAllowed,
22    /// The request body exceeded a transport-level size limit.
23    PayloadTooLarge,
24    /// An unexpected server-side failure outside the `FsError` domain.
25    Internal(String),
26}
27
28impl From<FsError> for ApiError {
29    fn from(err: FsError) -> Self {
30        ApiError::Domain(err)
31    }
32}
33
34fn domain_status(code: ErrorCode) -> StatusCode {
35    match code {
36        ErrorCode::InvalidPathOrName
37        | ErrorCode::WorkspaceBoundaryViolation
38        | ErrorCode::InvalidCursor => StatusCode::BAD_REQUEST,
39        ErrorCode::PermissionDenied => StatusCode::FORBIDDEN,
40        ErrorCode::NotFound => StatusCode::NOT_FOUND,
41        ErrorCode::AlreadyExists
42        | ErrorCode::WrongNodeType
43        | ErrorCode::DirectoryNotEmpty
44        | ErrorCode::LinkLoop
45        | ErrorCode::BrokenLink
46        | ErrorCode::QuotaExceeded => StatusCode::CONFLICT,
47        ErrorCode::RevisionConflict => StatusCode::PRECONDITION_FAILED,
48        ErrorCode::InvalidRange => StatusCode::RANGE_NOT_SATISFIABLE,
49        ErrorCode::StorageBusy => StatusCode::SERVICE_UNAVAILABLE,
50        ErrorCode::InternalStorageFailure => StatusCode::INTERNAL_SERVER_ERROR,
51    }
52}
53
54fn code_str(code: ErrorCode) -> &'static str {
55    // Hand-maintained to match `ErrorCode`'s `#[serde(rename_all =
56    // "snake_case")]` output exactly, without leaking a new heap allocation
57    // per error response the way `Box::leak` would.
58    match code {
59        ErrorCode::InvalidPathOrName => "invalid_path_or_name",
60        ErrorCode::NotFound => "not_found",
61        ErrorCode::AlreadyExists => "already_exists",
62        ErrorCode::WrongNodeType => "wrong_node_type",
63        ErrorCode::DirectoryNotEmpty => "directory_not_empty",
64        ErrorCode::LinkLoop => "link_loop",
65        ErrorCode::BrokenLink => "broken_link",
66        ErrorCode::WorkspaceBoundaryViolation => "workspace_boundary_violation",
67        ErrorCode::PermissionDenied => "permission_denied",
68        ErrorCode::RevisionConflict => "revision_conflict",
69        ErrorCode::QuotaExceeded => "quota_exceeded",
70        ErrorCode::InvalidRange => "invalid_range",
71        ErrorCode::InvalidCursor => "invalid_cursor",
72        ErrorCode::StorageBusy => "storage_busy",
73        ErrorCode::InternalStorageFailure => "internal_storage_failure",
74    }
75}
76
77impl IntoResponse for ApiError {
78    fn into_response(self) -> Response {
79        let (status, code, message, details) = match self {
80            ApiError::Domain(err) => (
81                domain_status(err.code()),
82                code_str(err.code()),
83                err.message().to_string(),
84                err.details().clone(),
85            ),
86            ApiError::Unauthenticated(message) => (
87                StatusCode::UNAUTHORIZED,
88                "unauthenticated",
89                message,
90                json!({}),
91            ),
92            ApiError::WorkspaceMismatch => (
93                StatusCode::FORBIDDEN,
94                "workspace_mismatch",
95                "credential does not authorize this workspace".to_string(),
96                json!({}),
97            ),
98            ApiError::MalformedBody(message) => (
99                StatusCode::BAD_REQUEST,
100                "malformed_body",
101                message,
102                json!({}),
103            ),
104            ApiError::RouteNotFound => (
105                StatusCode::NOT_FOUND,
106                "route_not_found",
107                "no route matched this request".to_string(),
108                json!({}),
109            ),
110            ApiError::MethodNotAllowed => (
111                StatusCode::METHOD_NOT_ALLOWED,
112                "method_not_allowed",
113                "this route does not support this method".to_string(),
114                json!({}),
115            ),
116            ApiError::PayloadTooLarge => (
117                StatusCode::PAYLOAD_TOO_LARGE,
118                "payload_too_large",
119                "request body exceeded the configured limit".to_string(),
120                json!({}),
121            ),
122            ApiError::Internal(message) => (
123                StatusCode::INTERNAL_SERVER_ERROR,
124                "internal",
125                message,
126                json!({}),
127            ),
128        };
129
130        let mut response = (
131            status,
132            Json(json!({ "error": { "code": code, "message": message, "details": details } })),
133        )
134            .into_response();
135
136        if status == StatusCode::SERVICE_UNAVAILABLE {
137            response
138                .headers_mut()
139                .insert("retry-after", HeaderValue::from_static("1"));
140        }
141
142        response
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    /// `code_str` hand-maintains the same strings `ErrorCode`'s derived
151    /// `#[serde(rename_all = "snake_case")] Serialize` impl already produces
152    /// (it used to just reuse that impl via `Box::leak`, which leaked memory
153    /// per error response). This guards the two from drifting apart.
154    #[test]
155    fn code_str_matches_error_code_serde_serialization_for_every_variant() {
156        let all = [
157            ErrorCode::InvalidPathOrName,
158            ErrorCode::NotFound,
159            ErrorCode::AlreadyExists,
160            ErrorCode::WrongNodeType,
161            ErrorCode::DirectoryNotEmpty,
162            ErrorCode::LinkLoop,
163            ErrorCode::BrokenLink,
164            ErrorCode::WorkspaceBoundaryViolation,
165            ErrorCode::PermissionDenied,
166            ErrorCode::RevisionConflict,
167            ErrorCode::QuotaExceeded,
168            ErrorCode::InvalidRange,
169            ErrorCode::InvalidCursor,
170            ErrorCode::StorageBusy,
171            ErrorCode::InternalStorageFailure,
172        ];
173        for code in all {
174            let serde_str = match serde_json::to_value(code).unwrap() {
175                serde_json::Value::String(s) => s,
176                _ => panic!("ErrorCode serializes as a string"),
177            };
178            assert_eq!(
179                code_str(code),
180                serde_str,
181                "code_str drifted from serde for {code:?}"
182            );
183        }
184    }
185}