Skip to main content

maincopy_shared/
posts.rs

1//! Wire contracts for listing posts loaded by the content compiler.
2
3use serde::{Deserialize, Deserializer, Serialize, de::Error as _};
4use time::{OffsetDateTime, UtcOffset};
5use uuid::Uuid;
6
7/// Versioned path for listing loaded post revisions.
8pub const POSTS_PATH: &str = "/api/admin/v1/posts";
9
10/// Canonical publication state of one loaded post revision.
11#[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    /// `revision` is a newer previewable revision while the approved revision stays public.
18    #[serde(rename = "unpublished_change")]
19    UnpublishedChange,
20    Published,
21}
22
23/// Summary of one post revision loaded from the Git-owned content tree.
24#[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/// One cursor-paginated page of posts from an immutable site revision.
43#[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}