yggdrasil/api/posts/
validation.rs1use super::service::WriteError;
4
5const MAX_TITLE_CHARS: usize = 200;
7const MAX_SUMMARY_CHARS: usize = 500;
8const MAX_COVER_CHARS: usize = 500;
9const MAX_TAGS: usize = 16;
11const MAX_TAG_CHARS: usize = 50;
12
13pub(super) fn title(value: &str) -> Result<(), WriteError> {
14 if value.trim().is_empty() {
15 return Err(WriteError::Invalid("标题不能为空".into()));
16 }
17 if value.trim().chars().count() > MAX_TITLE_CHARS {
18 return Err(WriteError::Invalid("标题过长(上限 200 字符)".into()));
19 }
20 Ok(())
21}
22
23pub(super) fn content(value: &str) -> Result<(), WriteError> {
24 if value.trim().is_empty() {
25 return Err(WriteError::Invalid("内容不能为空".into()));
26 }
27 Ok(())
28}
29
30pub(super) fn summary(value: &str) -> Result<(), WriteError> {
31 if value.trim().chars().count() > MAX_SUMMARY_CHARS {
32 return Err(WriteError::Invalid("摘要过长(上限 500 字符)".into()));
33 }
34 Ok(())
35}
36
37pub(super) fn cover(value: &str) -> Result<(), WriteError> {
38 if value.trim().chars().count() > MAX_COVER_CHARS {
39 return Err(WriteError::Invalid("封面地址过长(上限 500 字符)".into()));
40 }
41 Ok(())
42}
43
44pub(super) fn tags(raw: &[String]) -> Result<(), WriteError> {
46 let tags = super::helpers::clean_tags(raw);
47 if tags.len() > MAX_TAGS {
48 return Err(WriteError::Invalid("标签最多 16 个".into()));
49 }
50 if tags.iter().any(|t| t.chars().count() > MAX_TAG_CHARS) {
51 return Err(WriteError::Invalid("单个标签过长(上限 50 字符)".into()));
52 }
53 Ok(())
54}
55
56pub(super) fn slug(value: &str) -> Result<(), WriteError> {
57 if !crate::api::slug::is_valid_slug(value) {
58 return Err(WriteError::Invalid(
59 "slug 格式无效,只能包含字母、数字、连字符和下划线".into(),
60 ));
61 }
62 Ok(())
63}
64
65pub(super) fn status(value: &str) -> Result<(), WriteError> {
67 if crate::models::post::PostStatus::from_str(value).is_none() {
68 return Err(WriteError::Invalid(
69 "status 无效,只能是 draft 或 published".into(),
70 ));
71 }
72 Ok(())
73}
74
75pub(super) fn base_slug(title_value: &str, supplied: Option<&str>) -> Result<String, WriteError> {
76 match supplied.map(str::trim).filter(|s| !s.is_empty()) {
77 Some(s) => {
78 slug(s)?;
79 Ok(s.into())
80 }
81 None => Ok(crate::api::slug::slugify(title_value)),
82 }
83}
84
85#[cfg(test)]
86mod tests {
87 use super::*;
88
89 #[test]
90 fn rejects_blank_title_and_content_before_database_access() {
91 assert!(title(" \t\n").is_err());
92 assert!(content(" \t\n").is_err());
93 assert!(title("文章").is_ok());
94 assert!(content("正文").is_ok());
95 }
96
97 #[test]
98 fn slug_is_validated_or_generated_from_title() {
99 assert_eq!(
100 base_slug("标题", Some(" custom-slug ")).unwrap(),
101 "custom-slug"
102 );
103 assert_eq!(base_slug("标题", None).unwrap(), "biao-ti");
104 assert!(base_slug("标题", Some("bad/slug")).is_err());
105 }
106
107 #[test]
108 fn rejects_overlong_fields_and_too_many_tags() {
109 assert!(title(&"字".repeat(200)).is_ok());
110 assert!(title(&"字".repeat(201)).is_err());
111 assert!(summary(&"字".repeat(500)).is_ok());
112 assert!(summary(&"字".repeat(501)).is_err());
113 assert!(cover(&"a".repeat(500)).is_ok());
114 assert!(cover(&"a".repeat(501)).is_err());
115
116 let sixteen: Vec<String> = (0..16).map(|i| format!("t{i}")).collect();
117 let seventeen: Vec<String> = (0..17).map(|i| format!("t{i}")).collect();
118 assert!(tags(&sixteen).is_ok());
119 assert!(tags(&seventeen).is_err());
120
121 assert!(tags(&["字".repeat(50)]).is_ok());
122 assert!(tags(&["字".repeat(51)]).is_err());
123
124 let mut dup: Vec<String> = (0..15).map(|i| format!("t{i}")).collect();
126 dup.extend([" a ".to_owned(), "A".to_owned()]);
127 assert!(tags(&dup).is_ok());
128 }
129
130 #[test]
131 fn status_rejects_unknown_values_instead_of_defaulting_to_draft() {
132 assert!(status("draft").is_ok());
133 assert!(status("published").is_ok());
134 for bad in ["Published", "PUBLISHED", " published", "public", ""] {
135 assert!(status(bad).is_err(), "{bad:?}");
136 }
137 }
138}