Skip to main content

yggdrasil/api/
error.rs

1//! 应用内部错误与 server function 的公开错误契约。
2//!
3//! [`AppError`](crate::api::error::AppError) 保留服务端上下文,只有脱敏后的
4//! [`ApiError`](crate::api::error::ApiError) 可以跨越网络或 SSR 资源边界。
5//! 业务类别由枚举标签传输,消息只用于展示,不能参与错误分类。
6
7use std::fmt;
8
9use leptos::prelude::ServerFnError;
10use leptos::server_fn::{
11    codec::JsonEncoding,
12    error::{FromServerFnError, ServerFnErrorErr},
13};
14use serde::de::{self, Deserializer, IgnoredAny, MapAccess, Visitor};
15use serde::{Deserialize, Serialize};
16
17/// server function、SSR 资源与界面共用的公开错误。
18///
19/// `Framework` 保留网络、编解码等基础设施错误;其原始文本不应直接显示给用户。
20///
21/// 线格式是相邻标签 `{"code": "...", "message": ...}`,序列化用派生实现;
22/// 反序列化手写(见下方 `impl Deserialize`),避免派生代码在 WASM 里引入
23/// `Content` 缓冲和 `ServerFnError` 的整套派生解码。
24#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
25#[serde(tag = "code", content = "message", rename_all = "snake_case")]
26pub enum ApiError {
27    Unauthorized(String),
28    Forbidden(String),
29    NotFound(String),
30    BadRequest(String),
31    Unavailable,
32    Internal(String),
33    Framework(ServerFnError),
34}
35
36impl std::fmt::Display for ApiError {
37    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        match self {
39            Self::Unauthorized(message)
40            | Self::Forbidden(message)
41            | Self::NotFound(message)
42            | Self::BadRequest(message)
43            | Self::Internal(message) => f.write_str(message),
44            Self::Unavailable | Self::Framework(_) => f.write_str("服务暂时不可用"),
45        }
46    }
47}
48
49impl std::error::Error for ApiError {}
50
51impl FromServerFnError for ApiError {
52    type Encoder = JsonEncoding;
53
54    fn from_server_fn_error(value: ServerFnErrorErr) -> Self {
55        Self::Framework(ServerFnError::from_server_fn_error(value))
56    }
57}
58
59const CODES: &[&str] = &[
60    "unauthorized",
61    "forbidden",
62    "not_found",
63    "bad_request",
64    "unavailable",
65    "internal",
66    "framework",
67];
68
69const FRAMEWORK_VARIANTS: &[&str] = &[
70    "Registration",
71    "Request",
72    "Response",
73    "ServerError",
74    "MiddlewareError",
75    "Deserialization",
76    "Serialization",
77    "Args",
78    "MissingArg",
79];
80
81/// `message` 字段的三种线形态:业务文本、`Framework` 的 `{变体: 文本}`、`Unavailable` 的 null。
82enum Message {
83    Text(String),
84    Tagged(String, String),
85    Null,
86}
87
88impl<'de> Deserialize<'de> for Message {
89    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
90        struct MessageVisitor;
91
92        impl<'de> Visitor<'de> for MessageVisitor {
93            type Value = Message;
94
95            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96                f.write_str("错误消息文本或 {变体: 文本}")
97            }
98
99            fn visit_str<E: de::Error>(self, v: &str) -> Result<Message, E> {
100                Ok(Message::Text(v.to_owned()))
101            }
102
103            fn visit_string<E: de::Error>(self, v: String) -> Result<Message, E> {
104                Ok(Message::Text(v))
105            }
106
107            fn visit_unit<E: de::Error>(self) -> Result<Message, E> {
108                Ok(Message::Null)
109            }
110
111            fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Message, A::Error> {
112                let Some((name, text)) = map.next_entry::<String, String>()? else {
113                    return Err(de::Error::invalid_length(0, &self));
114                };
115                if map.next_key::<IgnoredAny>()?.is_some() {
116                    return Err(de::Error::invalid_length(2, &self));
117                }
118                Ok(Message::Tagged(name, text))
119            }
120        }
121
122        d.deserialize_any(MessageVisitor)
123    }
124}
125
126impl<'de> Deserialize<'de> for ApiError {
127    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
128        struct ApiErrorVisitor;
129
130        impl<'de> Visitor<'de> for ApiErrorVisitor {
131            type Value = ApiError;
132
133            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134                f.write_str("带 code 与 message 的错误对象")
135            }
136
137            fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<ApiError, A::Error> {
138                let mut code: Option<String> = None;
139                let mut message: Option<Message> = None;
140                while let Some(key) = map.next_key::<String>()? {
141                    match key.as_str() {
142                        "code" => {
143                            if code.is_some() {
144                                return Err(de::Error::duplicate_field("code"));
145                            }
146                            code = Some(map.next_value()?);
147                        }
148                        "message" => {
149                            if message.is_some() {
150                                return Err(de::Error::duplicate_field("message"));
151                            }
152                            message = Some(map.next_value()?);
153                        }
154                        _ => {
155                            map.next_value::<IgnoredAny>()?;
156                        }
157                    }
158                }
159                let code = code.ok_or_else(|| de::Error::missing_field("code"))?;
160                api_error_from_parts(&code, message)
161            }
162        }
163
164        d.deserialize_struct("ApiError", &["code", "message"], ApiErrorVisitor)
165    }
166}
167
168/// 按 `code` 组装 [`ApiError`];`message` 形态与派生实现的要求一致。
169fn api_error_from_parts<E: de::Error>(code: &str, message: Option<Message>) -> Result<ApiError, E> {
170    let text = |message: Option<Message>| match message {
171        Some(Message::Text(text)) => Ok(text),
172        None => Err(E::missing_field("message")),
173        Some(_) => Err(E::custom("message 应为字符串")),
174    };
175    Ok(match code {
176        "unauthorized" => ApiError::Unauthorized(text(message)?),
177        "forbidden" => ApiError::Forbidden(text(message)?),
178        "not_found" => ApiError::NotFound(text(message)?),
179        "bad_request" => ApiError::BadRequest(text(message)?),
180        "internal" => ApiError::Internal(text(message)?),
181        "unavailable" => match message {
182            None | Some(Message::Null) => ApiError::Unavailable,
183            Some(_) => return Err(E::custom("unavailable 不带 message")),
184        },
185        "framework" => match message {
186            Some(Message::Tagged(name, text)) => ApiError::Framework(framework_error(&name, text)?),
187            None => return Err(E::missing_field("message")),
188            Some(_) => return Err(E::custom("framework 的 message 应为 {变体: 文本}")),
189        },
190        _ => return Err(E::unknown_variant(code, CODES)),
191    })
192}
193
194/// `{变体: 文本}` → 同名 [`ServerFnError`] 变体;已废弃的 `WrappedServerError` 按未知变体处理。
195fn framework_error<E: de::Error>(name: &str, text: String) -> Result<ServerFnError, E> {
196    Ok(match name {
197        "Registration" => ServerFnError::Registration(text),
198        "Request" => ServerFnError::Request(text),
199        "Response" => ServerFnError::Response(text),
200        "ServerError" => ServerFnError::ServerError(text),
201        "MiddlewareError" => ServerFnError::MiddlewareError(text),
202        "Deserialization" => ServerFnError::Deserialization(text),
203        "Serialization" => ServerFnError::Serialization(text),
204        "Args" => ServerFnError::Args(text),
205        "MissingArg" => ServerFnError::MissingArg(text),
206        _ => return Err(E::unknown_variant(name, FRAMEWORK_VARIANTS)),
207    })
208}
209
210/// 应用层内部错误;数据库细节只写日志,不进入公开契约。
211#[derive(Debug)]
212pub enum AppError {
213    /// 未认证(401)。
214    Unauthorized(&'static str),
215    /// 无权限(403)。
216    Forbidden(&'static str),
217    /// 资源不存在(404)。
218    NotFound(&'static str),
219    /// 业务规则拒绝,消息原样透传给用户。
220    BadRequest(String),
221    DbConn(String),
222    Query(String),
223    Transaction(String),
224    /// 已脱敏、可展示的内部错误提示。
225    Internal(&'static str),
226}
227
228#[cfg(feature = "server")]
229impl AppError {
230    /// 记录完整 source 链,对外仅保留错误类别。
231    pub fn db_conn(e: impl std::error::Error) -> Self {
232        tracing::error!(
233            "DB connection failed: {}",
234            crate::db::format_with_sources(&e)
235        );
236        AppError::DbConn("connection error".to_string())
237    }
238
239    /// 记录 SQL 错误,查询细节不得传给客户端。
240    pub fn query(e: impl std::error::Error) -> Self {
241        tracing::error!("Query failed: {}", crate::db::format_with_sources(&e));
242        AppError::Query("query error".to_string())
243    }
244
245    /// 记录事务错误,事务细节不得传给客户端。
246    pub fn tx(e: impl std::error::Error) -> Self {
247        tracing::error!("Transaction failed: {}", crate::db::format_with_sources(&e));
248        AppError::Transaction("transaction error".to_string())
249    }
250}
251
252impl From<AppError> for ApiError {
253    fn from(err: AppError) -> Self {
254        match err {
255            AppError::Unauthorized(message) => Self::Unauthorized(message.into()),
256            AppError::Forbidden(message) => Self::Forbidden(message.into()),
257            AppError::NotFound(message) => Self::NotFound(message.into()),
258            AppError::BadRequest(message) => Self::BadRequest(message),
259            AppError::DbConn(_) => Self::Unavailable,
260            AppError::Query(_) | AppError::Transaction(_) => Self::Internal("操作失败".into()),
261            AppError::Internal(message) => Self::Internal(message.into()),
262        }
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn business_categories_survive_server_function_encoding() {
272        // 消息刻意相同:分类不能依赖展示文本。
273        for error in [
274            ApiError::from(AppError::Unauthorized("not_found:相同消息")),
275            ApiError::from(AppError::Forbidden("not_found:相同消息")),
276            ApiError::from(AppError::NotFound("not_found:相同消息")),
277            ApiError::from(AppError::BadRequest("not_found:相同消息".into())),
278            ApiError::from(AppError::Internal("not_found:相同消息")),
279            ApiError::Unavailable,
280        ] {
281            assert_eq!(ApiError::de(error.ser()), error);
282        }
283    }
284
285    #[test]
286    fn database_details_never_cross_the_wire() {
287        for (internal, expected) in [
288            (
289                AppError::DbConn("postgres://admin:secret@localhost:5432/private".into()),
290                ApiError::Unavailable,
291            ),
292            (
293                AppError::Query("SELECT password FROM users".into()),
294                ApiError::Internal("操作失败".into()),
295            ),
296            (
297                AppError::Transaction("deadlock detected on UPDATE posts".into()),
298                ApiError::Internal("操作失败".into()),
299            ),
300        ] {
301            let error = ApiError::from(internal);
302            assert_eq!(error, expected);
303            assert_eq!(ApiError::de(error.ser()), expected);
304        }
305    }
306
307    #[test]
308    fn malformed_responses_remain_framework_errors() {
309        assert!(matches!(
310            ApiError::de("not_found:代理错误页".into()),
311            ApiError::Framework(ServerFnError::Deserialization(_))
312        ));
313        let error = ApiError::from_server_fn_error(ServerFnErrorErr::Request("not_found".into()));
314        assert!(matches!(
315            ApiError::de(error.ser()),
316            ApiError::Framework(ServerFnError::Request(_))
317        ));
318    }
319
320    #[test]
321    fn wire_format_decodes_independent_of_field_order() {
322        let not_found = ApiError::NotFound("文章不存在".into());
323        assert_eq!(
324            ApiError::de(r#"{"code":"not_found","message":"文章不存在"}"#.into()),
325            not_found
326        );
327        assert_eq!(
328            ApiError::de(r#"{"message":"文章不存在","code":"not_found"}"#.into()),
329            not_found
330        );
331        assert_eq!(
332            ApiError::de(r#"{"code":"unavailable"}"#.into()),
333            ApiError::Unavailable
334        );
335        assert_eq!(
336            ApiError::de(r#"{"code":"unavailable","message":null}"#.into()),
337            ApiError::Unavailable
338        );
339        assert_eq!(
340            ApiError::de(r#"{"code":"framework","message":{"Request":"x"}}"#.into()),
341            ApiError::Framework(ServerFnError::Request("x".into()))
342        );
343        assert!(matches!(
344            ApiError::de(r#"{"code":"teapot","message":"x"}"#.into()),
345            ApiError::Framework(ServerFnError::Deserialization(_))
346        ));
347    }
348
349    #[test]
350    fn framework_variants_round_trip() {
351        let variants: [fn(String) -> ServerFnError; 9] = [
352            ServerFnError::Registration,
353            ServerFnError::Request,
354            ServerFnError::Response,
355            ServerFnError::ServerError,
356            ServerFnError::MiddlewareError,
357            ServerFnError::Deserialization,
358            ServerFnError::Serialization,
359            ServerFnError::Args,
360            ServerFnError::MissingArg,
361        ];
362        for variant in variants {
363            let error = ApiError::Framework(variant("m".into()));
364            assert_eq!(ApiError::de(error.ser()), error);
365        }
366    }
367}