1use serde::{Deserialize, Deserializer, Serialize, de::Error as _};
4use time::{OffsetDateTime, UtcOffset};
5use uuid::Uuid;
6
7pub const POSTS_PATH: &str = "/api/admin/v1/posts";
9
10#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
12#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
13#[serde(rename_all = "lowercase")]
14pub enum PostPublicationState {
15 Draft,
16 Unpublished,
17 #[serde(rename = "unpublished_change")]
19 UnpublishedChange,
20 Published,
21}
22
23#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
25#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
26pub struct PostSummary {
27 pub post_id: Uuid,
28 pub source_path: Box<str>,
29 pub title: Box<str>,
30 pub slug: Box<str>,
31 #[serde(deserialize_with = "deserialize_post_revision")]
32 #[cfg_attr(feature = "schema", schema(pattern = r"^post-b3-v1-[0-9a-f]{64}$"))]
33 pub revision: Box<str>,
34 pub publication_state: PostPublicationState,
35 #[serde(
36 serialize_with = "time::serde::rfc3339::option::serialize",
37 deserialize_with = "deserialize_optional_utc_timestamp"
38 )]
39 pub published_at: Option<OffsetDateTime>,
40}
41
42#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
44#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
45pub struct ListPostsResponse {
46 #[serde(deserialize_with = "deserialize_content_digest")]
47 #[cfg_attr(feature = "schema", schema(pattern = r"^content-b3-v1-[0-9a-f]{64}$"))]
48 pub content_digest: Box<str>,
49 #[serde(deserialize_with = "deserialize_site_digest")]
50 #[cfg_attr(feature = "schema", schema(pattern = r"^site-b3-v1-[0-9a-f]{64}$"))]
51 pub site_digest: Box<str>,
52 #[serde(deserialize_with = "deserialize_site_version")]
53 #[cfg_attr(feature = "schema", schema(minimum = 1))]
54 pub site_version: u64,
55 pub posts: Vec<PostSummary>,
56 pub next_cursor: Option<Uuid>,
57}
58
59fn deserialize_post_revision<'de, D>(deserializer: D) -> Result<Box<str>, D::Error>
60where
61 D: Deserializer<'de>,
62{
63 deserialize_digest(deserializer, "post-b3-v1-", "revision")
64}
65
66fn deserialize_site_digest<'de, D>(deserializer: D) -> Result<Box<str>, D::Error>
67where
68 D: Deserializer<'de>,
69{
70 deserialize_digest(deserializer, "site-b3-v1-", "site_digest")
71}
72
73fn deserialize_content_digest<'de, D>(deserializer: D) -> Result<Box<str>, D::Error>
74where
75 D: Deserializer<'de>,
76{
77 deserialize_digest(deserializer, "content-b3-v1-", "content_digest")
78}
79
80fn deserialize_digest<'de, D>(
81 deserializer: D,
82 prefix: &str,
83 field: &str,
84) -> Result<Box<str>, D::Error>
85where
86 D: Deserializer<'de>,
87{
88 let value = Box::<str>::deserialize(deserializer)?;
89 let valid = value.strip_prefix(prefix).is_some_and(|encoded| {
90 encoded.len() == 64
91 && encoded
92 .bytes()
93 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
94 });
95 if valid {
96 Ok(value)
97 } else {
98 Err(D::Error::custom(format_args!(
99 "{field} must be {prefix} followed by 64 lowercase hexadecimal characters"
100 )))
101 }
102}
103
104fn deserialize_optional_utc_timestamp<'de, D>(
105 deserializer: D,
106) -> Result<Option<OffsetDateTime>, D::Error>
107where
108 D: Deserializer<'de>,
109{
110 let timestamp = time::serde::rfc3339::option::deserialize(deserializer)?;
111 match timestamp {
112 Some(timestamp) if timestamp.offset() != UtcOffset::UTC => {
113 Err(D::Error::custom("published_at must use the UTC offset"))
114 }
115 timestamp => Ok(timestamp),
116 }
117}
118
119fn deserialize_site_version<'de, D>(deserializer: D) -> Result<u64, D::Error>
120where
121 D: Deserializer<'de>,
122{
123 let version = u64::deserialize(deserializer)?;
124 if version > 0 {
125 Ok(version)
126 } else {
127 Err(D::Error::custom("site_version must be greater than zero"))
128 }
129}
130
131#[cfg(test)]
132mod tests {
133 use serde_json::json;
134
135 use super::*;
136
137 const REVISION: &str =
138 "post-b3-v1-0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
139 const SITE_DIGEST: &str =
140 "site-b3-v1-abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
141 const CONTENT_DIGEST: &str =
142 "content-b3-v1-abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
143
144 fn response_value() -> serde_json::Value {
145 json!({
146 "content_digest": CONTENT_DIGEST,
147 "site_digest": SITE_DIGEST,
148 "site_version": 7,
149 "posts": [
150 {
151 "post_id": "123e4567-e89b-12d3-a456-426614174000",
152 "source_path": "posts/sqlite.md",
153 "title": "SQLite Does Not Need a Network",
154 "slug": "sqlite-does-not-need-a-network",
155 "revision": REVISION,
156 "publication_state": "published",
157 "published_at": "1970-01-01T00:00:00Z"
158 },
159 {
160 "post_id": "018f2046-49b2-7c2a-9226-f81c87ab721d",
161 "source_path": "drafts/next.md",
162 "title": "Next post",
163 "slug": "next-post",
164 "revision": REVISION,
165 "publication_state": "draft",
166 "published_at": null
167 }
168 ],
169 "next_cursor": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
170 })
171 }
172
173 #[test]
174 fn posts_path_is_the_versioned_admin_resource() {
175 assert_eq!(POSTS_PATH, "/api/admin/v1/posts");
176 }
177
178 #[test]
179 fn list_posts_response_has_a_stable_bidirectional_wire_contract() {
180 let response = ListPostsResponse {
181 content_digest: CONTENT_DIGEST.into(),
182 site_digest: SITE_DIGEST.into(),
183 site_version: 7,
184 posts: vec![
185 PostSummary {
186 post_id: Uuid::from_u128(0x123e_4567_e89b_12d3_a456_4266_1417_4000),
187 source_path: "posts/sqlite.md".into(),
188 title: "SQLite Does Not Need a Network".into(),
189 slug: "sqlite-does-not-need-a-network".into(),
190 revision: REVISION.into(),
191 publication_state: PostPublicationState::Published,
192 published_at: Some(OffsetDateTime::UNIX_EPOCH),
193 },
194 PostSummary {
195 post_id: Uuid::from_u128(0x018f_2046_49b2_7c2a_9226_f81c_87ab_721d),
196 source_path: "drafts/next.md".into(),
197 title: "Next post".into(),
198 slug: "next-post".into(),
199 revision: REVISION.into(),
200 publication_state: PostPublicationState::Draft,
201 published_at: None,
202 },
203 ],
204 next_cursor: Some(Uuid::from_u128(0xaaaa_aaaa_aaaa_4aaa_8aaa_aaaa_aaaa_aaaa)),
205 };
206
207 let value = serde_json::to_value(&response).unwrap();
208 assert_eq!(value, response_value());
209 assert_eq!(
210 serde_json::from_value::<ListPostsResponse>(value).unwrap(),
211 response
212 );
213 }
214
215 #[test]
216 fn publication_states_have_stable_wire_names() {
217 for (state, name) in [
218 (PostPublicationState::Draft, "draft"),
219 (PostPublicationState::Unpublished, "unpublished"),
220 (
221 PostPublicationState::UnpublishedChange,
222 "unpublished_change",
223 ),
224 (PostPublicationState::Published, "published"),
225 ] {
226 assert_eq!(serde_json::to_value(state).unwrap(), json!(name));
227 assert_eq!(
228 serde_json::from_value::<PostPublicationState>(json!(name)).unwrap(),
229 state
230 );
231 }
232
233 assert!(serde_json::from_value::<PostPublicationState>(json!("unknown")).is_err());
234 }
235
236 #[test]
237 fn unpublished_change_marks_the_loaded_revision_as_preview_only() {
238 let summary = PostSummary {
239 post_id: Uuid::from_u128(0x123e_4567_e89b_12d3_a456_4266_1417_4000),
240 source_path: "posts/sqlite.md".into(),
241 title: "SQLite Does Not Need a Network".into(),
242 slug: "sqlite-does-not-need-a-network".into(),
243 revision: REVISION.into(),
244 publication_state: PostPublicationState::UnpublishedChange,
245 published_at: Some(OffsetDateTime::UNIX_EPOCH),
246 };
247
248 let value = serde_json::to_value(&summary).unwrap();
249 assert_eq!(value["revision"], REVISION);
250 assert_eq!(value["publication_state"], "unpublished_change");
251 assert_eq!(value["published_at"], "1970-01-01T00:00:00Z");
252 assert_eq!(
253 serde_json::from_value::<PostSummary>(value).unwrap(),
254 summary
255 );
256 }
257
258 #[test]
259 fn list_posts_response_rejects_malformed_typed_fields() {
260 let cases = [
261 ("revision", json!(&REVISION[..REVISION.len() - 1])),
262 ("revision", json!(REVISION.replacen("abcdef", "ABCDEF", 1))),
263 ("content_digest", json!(SITE_DIGEST)),
264 ("site_digest", json!(REVISION)),
265 ("published_at", json!("1970-01-01T01:00:00+01:00")),
266 ("site_version", json!(0)),
267 ];
268
269 for (field, malformed) in cases {
270 let mut value = response_value();
271 match field {
272 "revision" | "published_at" => value["posts"][0][field] = malformed,
273 _ => value[field] = malformed,
274 }
275
276 let error = serde_json::from_value::<ListPostsResponse>(value).unwrap_err();
277 assert!(error.to_string().contains(field), "{field}: {error}");
278 }
279 }
280
281 #[test]
282 fn list_posts_response_rejects_invalid_identifiers_and_states() {
283 for (path, malformed) in [
284 (&["posts", "0", "post_id"][..], json!("not-a-uuid")),
285 (&["posts", "0", "publication_state"][..], json!("unknown")),
286 (&["next_cursor"][..], json!("not-a-uuid")),
287 ] {
288 let mut value = response_value();
289 let mut target = &mut value;
290 for segment in path.iter().take(path.len() - 1) {
291 target = if let Ok(index) = segment.parse::<usize>() {
292 &mut target[index]
293 } else {
294 &mut target[*segment]
295 };
296 }
297 target[path[path.len() - 1]] = malformed;
298
299 assert!(serde_json::from_value::<ListPostsResponse>(value).is_err());
300 }
301 }
302}