oxedyne/fe2o3/fe2o3_steel/src/srv/publish/json.rs
10.7 KiB, 75 runs
created by r1870400018:14354, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | //! The posts as JSON, for a page that would rather render them itself. |
| 2 | //! |
| 3 | //! The convenience, not the point. A post's canonical form is a [page](super::page): a URL, HTML in |
| 4 | //! the first response, and the tags a card is built from. This exists so an app that is already a |
| 5 | //! running page can show its posts inline without a navigation, and it hands over prose that was |
| 6 | //! rendered on the way out, so there is one renderer rather than one per client. |
| 7 | //! |
| 8 | //! Served from the same prefix as everything else here, because the prefix is the module's and a post |
| 9 | //! is a post however it is asked for. A slug cannot collide with this: `index.json` is not a name a |
| 10 | //! slug may wear, punctuation not being allowed in one. |
| 11 | //! |
| 12 | //! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\ |
| 13 | //! Anthropic Claude |
| 14 | |
| 15 | use crate::srv::cache; |
| 16 | use crate::srv::publish::{ |
| 17 | Author, |
| 18 | Post, |
| 19 | PublishConfig, |
| 20 | date_text, |
| 21 | declare, |
| 22 | read_mins, |
| 23 | }; |
| 24 | |
| 25 | use oxedyne_fe2o3_core::prelude::*; |
| 26 | use oxedyne_fe2o3_jdat::prelude::*; |
| 27 | use oxedyne_fe2o3_jdat::string::enc::EncoderConfig; |
| 28 | use oxedyne_fe2o3_net::http::{ |
| 29 | fields::{ |
| 30 | HeaderFieldValue, |
| 31 | HeaderName, |
| 32 | }, |
| 33 | msg::HttpMessage, |
| 34 | }; |
| 35 | |
| 36 | |
| 37 | /// Serves the posts as JSON: rendered, newest first. |
| 38 | /// |
| 39 | /// Beside the posts go the two things a page cannot work out from them: the authors they name, |
| 40 | /// resolved to a face, and the categories the site offers. A post carries its author as a login |
| 41 | /// username, which is a hash and shows a reader nothing, and it carries only the categories it wears |
| 42 | /// rather than the ones it could have worn -- so a client drawing its own filter would otherwise |
| 43 | /// offer a taxonomy narrower than the site's and a row of hashes for faces. |
| 44 | pub fn serve( |
| 45 | cfg: &PublishConfig, |
| 46 | posts: &[Post], |
| 47 | authors: &[Author], |
| 48 | id: &str, |
| 49 | ) |
| 50 | -> Outcome<HttpMessage> |
| 51 | { |
| 52 | // A post's author, by the handle the page shows, resolved through the authors this request already |
| 53 | // read. The stored username is the SHA-256 of a passphrase and never leaves the server. |
| 54 | let handle_of = |username: &str| -> String { |
| 55 | authors.iter().find(|a| a.username == username) |
| 56 | .map(|a| a.handle.clone()) |
| 57 | .unwrap_or_default() |
| 58 | }; |
| 59 | let list = posts.iter() |
| 60 | .map(|p| { |
| 61 | let mut fields = vec![ |
| 62 | (dat!("slug"), dat!(p.slug.clone())), |
| 63 | (dat!("title"), dat!(p.title.clone())), |
| 64 | // The author, by the public handle the faces below carry, so a page can group posts |
| 65 | // under one. Empty where none is named, or where the author could not be resolved, |
| 66 | // which draws as no author rather than as a missing one. |
| 67 | (dat!("author"), dat!(handle_of(&p.author))), |
| 68 | (dat!("url"), dat!(cfg.path_of(&p.slug))), |
| 69 | (dat!("excerpt"), dat!(p.excerpt.clone())), |
| 70 | (dat!("html"), dat!(p.html.clone())), |
| 71 | // Reading time in whole minutes, so a filter can offer a min/max slider without recounting |
| 72 | // words in the client. The figure the post's own badge shows, from the one definition. |
| 73 | (dat!("read_mins"), dat!(read_mins(p.words) as u64)), |
| 74 | // The tags and categories, always present as arrays so a page reading this need not ask |
| 75 | // whether the key is there -- an empty post carries the empty list, the same thing said |
| 76 | // once. Each is a plain string the store already normalised. |
| 77 | (dat!("tags"), Dat::List(p.tags.iter().map(|t| dat!(t.clone())).collect())), |
| 78 | (dat!("categories"), Dat::List(p.categories.iter().map(|c| dat!(c.clone())).collect())), |
| 79 | ]; |
| 80 | // What the author declared about writing it, resolved to the words, the artwork and the |
| 81 | // link a page would draw -- so the one rule for building a mark stays here rather than |
| 82 | // being written a second time in every client. Absent where the author declared nothing. |
| 83 | if let Some(level) = p.ai_level { |
| 84 | let d = declare::Declaration::new(level, declare::Medium::Doc); |
| 85 | fields.push((dat!("declare"), create_dat_ordmap(vec![ |
| 86 | (dat!("level"), dat!(d.level.slug().to_string())), |
| 87 | (dat!("words"), dat!(d.level.words().to_string())), |
| 88 | (dat!("mark"), dat!(fmt!("{}/{}", cfg.declare.marks, d.mark_file()))), |
| 89 | (dat!("href"), dat!(fmt!("{}{}", cfg.declare.url, d.path()))), |
| 90 | ]))); |
| 91 | } |
| 92 | // A post without a date carries no date key, rather than a key saying nothing. The reader |
| 93 | // asks whether the post has one; it should not also have to ask what a date of nothing means. |
| 94 | if let Some(d) = &p.date { |
| 95 | fields.push((dat!("date"), dat!(d.clone()))); |
| 96 | // The same instant, said the way a person says it, so a page showing this does not |
| 97 | // have to know that the stored form is ISO. |
| 98 | fields.push((dat!("date_text"), dat!(date_text(d)))); |
| 99 | } |
| 100 | create_dat_ordmap(fields) |
| 101 | }) |
| 102 | .collect::<Vec<_>>(); |
| 103 | |
| 104 | // The authors, each with the name and avatar a reader sees, keyed by the username a post stores. |
| 105 | // A client matches a post to a face on that username, as the server's own filter does. |
| 106 | let faces = authors.iter() |
| 107 | .map(|a| create_dat_ordmap(vec![ |
| 108 | (dat!("handle"), dat!(a.handle.clone())), |
| 109 | (dat!("name"), dat!(a.name.clone())), |
| 110 | (dat!("avatar"), dat!(a.avatar.clone())), |
| 111 | // What the author writes about, which a page drawing its own reader shows above the |
| 112 | // posts the way the index page does. |
| 113 | (dat!("bio"), dat!(a.bio.clone())), |
| 114 | // The letter drawn where there is no avatar, worked out once here rather than in every |
| 115 | // client that would have to know the same fallback rule. |
| 116 | (dat!("initial"), dat!(a.initial())), |
| 117 | ])) |
| 118 | .collect::<Vec<_>>(); |
| 119 | |
| 120 | let mut top = vec![ |
| 121 | (dat!("posts"), Dat::List(list)), |
| 122 | (dat!("authors"), Dat::List(faces)), |
| 123 | // The site's whole category vocabulary, in the order the config gives, so a client's checkboxes |
| 124 | // stand in that order and offer what the composer offers. |
| 125 | (dat!("categories"), Dat::List(cfg.categories.iter().map(|c| dat!(c.clone())).collect())), |
| 126 | ]; |
| 127 | // What the site declares about itself, so a page drawing its own footer needs no second fetch to |
| 128 | // know what to put in it. Absent where the site declares nothing about itself. |
| 129 | if let Some(d) = cfg.declare.site { |
| 130 | top.push((dat!("declare"), create_dat_ordmap(vec![ |
| 131 | (dat!("level"), dat!(d.level.slug().to_string())), |
| 132 | (dat!("words"), dat!(d.level.words().to_string())), |
| 133 | (dat!("mark"), dat!(fmt!("{}/{}", cfg.declare.marks, d.mark_file()))), |
| 134 | (dat!("href"), dat!(fmt!("{}{}", cfg.declare.url, d.path()))), |
| 135 | ]))); |
| 136 | } |
| 137 | let body_dat = create_dat_ordmap(top); |
| 138 | let json_cfg = EncoderConfig::<(), ()>::json(None); |
| 139 | let body_json = res!(body_dat.encode_string_with_config(&json_cfg)); |
| 140 | |
| 141 | info!("{}: publish: json, {} posts", id, posts.len()); |
| 142 | |
| 143 | let mut resp = HttpMessage::ok_respond_with_text(body_json); |
| 144 | resp = resp.with_field( |
| 145 | HeaderName::ContentType, |
| 146 | HeaderFieldValue::Generic(fmt!("application/json")), |
| 147 | ); |
| 148 | // An app drawing its own stream from this must see a publication at once, and will not |
| 149 | // think to force a refresh. |
| 150 | Ok(cache::generated(resp)) |
| 151 | } |
| 152 | |
| 153 | |
| 154 | #[cfg(test)] |
| 155 | mod tests { |
| 156 | use super::*; |
| 157 | |
| 158 | use crate::srv::publish::Source; |
| 159 | |
| 160 | fn cfg() -> PublishConfig { |
| 161 | PublishConfig { |
| 162 | path: fmt!("/asides"), |
| 163 | dir: fmt!("/nonexistent"), |
| 164 | source: Source::Dir, |
| 165 | title: fmt!("Asides"), |
| 166 | site_name: fmt!("Elearnity"), |
| 167 | base_url: fmt!("https://example.com"), |
| 168 | css: vec![], |
| 169 | creds: Default::default(), |
| 170 | comments: true, |
| 171 | comment_rate_secs: 0, |
| 172 | comment_rate_hourly: 0, |
| 173 | subscribe_rate_secs: 0, |
| 174 | subscribe_rate_hourly: 0, |
| 175 | newsletter_from: String::new(), |
| 176 | categories: vec![fmt!("Personal"), fmt!("Big Ideas")], |
| 177 | default_author: String::new(), |
| 178 | logo: String::new(), |
| 179 | home: String::new(), |
| 180 | // A site in a declaration scheme, so a post's declaration has somewhere to point. |
| 181 | declare: declare::DeclareConfig { |
| 182 | url: fmt!("https://example.org"), |
| 183 | marks: fmt!("/assets/marks"), |
| 184 | site: Some(declare::Declaration::new( |
| 185 | declare::Level::With, declare::Medium::Code)), |
| 186 | items: Vec::new(), |
| 187 | }, |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | fn post() -> Post { |
| 192 | Post { |
| 193 | slug: fmt!("on-rent"), |
| 194 | title: fmt!("On rent"), |
| 195 | author: fmt!("9f3ac1"), |
| 196 | categories: vec![fmt!("Big Ideas")], |
| 197 | date: Some(fmt!("2026-07-17")), |
| 198 | words: 420, |
| 199 | excerpt: fmt!("An opening sentence."), |
| 200 | html: fmt!("<p>An opening sentence.</p>\n"), |
| 201 | also_on: Vec::new(), |
| 202 | tags: vec![fmt!("rent")], |
| 203 | ai_level: None, |
| 204 | } |
| 205 | } |
| 206 | |
| 207 | /// The feed a page draws itself from carries the two things it cannot work out from the posts: the |
| 208 | /// faces behind the usernames, and the whole category vocabulary the site offers. |
| 209 | #[test] |
| 210 | fn test_the_json_carries_faces_and_a_taxonomy_00() -> Outcome<()> { |
| 211 | let authors = vec![Author { |
| 212 | username: fmt!("9f3ac1"), |
| 213 | handle: fmt!("qv7m2ab9dz"), |
| 214 | name: fmt!("Jason"), |
| 215 | avatar: String::new(), |
| 216 | bio: fmt!("Notes on rent."), |
| 217 | }]; |
| 218 | let resp = res!(serve(&cfg(), &[post()], &authors, "test")); |
| 219 | let body = String::from_utf8_lossy(&resp.body).to_string(); |
| 220 | // The public handle, and nowhere the login username, which is the SHA-256 of a passphrase. |
| 221 | assert!(body.contains(r#""handle": "qv7m2ab9dz""#), "no author handle: {}", body); |
| 222 | assert!(!body.contains("9f3ac1"), "the login username reached the feed: {}", body); |
| 223 | assert!(body.contains(r#""author": "qv7m2ab9dz""#), "a post is not keyed to its author: {}", body); |
| 224 | assert!(body.contains(r#""name": "Jason""#), "no author name: {}", body); |
| 225 | // The letter a client draws where an author has no picture, settled here. |
| 226 | assert!(body.contains(r#""initial": "J""#), "no drawn initial: {}", body); |
| 227 | // What the author says they write about, which a client shows above the posts. |
| 228 | assert!(body.contains(r#""bio": "Notes on rent.""#), "no description: {}", body); |
| 229 | // Every category the site offers, not only the one the post wears. |
| 230 | assert!(body.contains(r#""Personal""#), "an unworn category is still offered: {}", body); |
| 231 | assert!(body.contains(r#""Big Ideas""#), "no category with a space: {}", body); |
| 232 | assert!(body.contains(r#""read_mins": 3"#), "no reading time: {}", body); |
| 233 | Ok(()) |
| 234 | } |
| 235 | |
| 236 | /// The feed says it may not be served from a store unasked. Without that an app redraws its |
| 237 | /// stream from a copy taken before the post was published, and only a forced refresh gets past it. |
| 238 | #[test] |
| 239 | fn test_the_json_is_never_served_from_a_store_unasked_02() -> Outcome<()> { |
| 240 | let resp = res!(serve(&cfg(), &[post()], &[], "test")); |
| 241 | let held = res!(resp.header.fields.get_one(&HeaderName::CacheControl).ok_or_else(|| |
| 242 | err!("The feed carried no cache directive, so a store is free to guess one."; Missing))); |
| 243 | assert_eq!(fmt!("{}", held), "no-cache"); |
| 244 | Ok(()) |
| 245 | } |
| 246 | |
| 247 | /// A site with no authors and no posts still answers the three keys, each an empty list, so a page |
| 248 | /// reading it never has to ask whether a key is there. |
| 249 | #[test] |
| 250 | fn test_an_empty_site_still_answers_in_shape_01() -> Outcome<()> { |
| 251 | let resp = res!(serve(&cfg(), &[], &[], "test")); |
| 252 | let body = String::from_utf8_lossy(&resp.body).to_string(); |
| 253 | assert!(body.contains(r#""posts": []"#), "no empty post list: {}", body); |
| 254 | assert!(body.contains(r#""authors": []"#), "no empty author list: {}", body); |
| 255 | Ok(()) |
| 256 | } |
| 257 | } |