1use axum::Json;
2use axum::http::{HeaderValue, StatusCode};
3use axum::response::{IntoResponse, Response};
4use fslite_core::{ErrorCode, FsError};
5use serde_json::json;
6
7#[derive(Debug)]
9pub enum ApiError {
10 Domain(FsError),
12 Unauthenticated(String),
14 WorkspaceMismatch,
16 MalformedBody(String),
18 RouteNotFound,
20 MethodNotAllowed,
22 PayloadTooLarge,
24 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 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 #[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}