From d33d83043d43118315eb929d03366b627dd618f0 Mon Sep 17 00:00:00 2001 From: rays Date: Sat, 24 Oct 2026 01:12:14 +0000 Subject: [PATCH] Doc comments on ipx-feed's and ipx-directory's public items (#354) The style guide asks for doc comments on a library's public API. Every type, function, constant and static the two libraries export now has one, saying what it is for or what it does that its name does not: fetch follows redirects itself so credentials stay on the feed's host, Config::parse refuses repeated token names, Store::due_one is the admin's Refresh. Fields and variants whose names say it all have none, as the guide allows. category_path's comment had slid above categories(), which then had two; each has its own again. Co-Authored-By: Claude Opus 5.5 --- crates/directory/src/config.rs | 6 ++++++ crates/directory/src/lib.rs | 12 ++++++++++++ crates/directory/src/store.rs | 11 +++++++++++ crates/feed/src/guard.rs | 4 ++++ crates/feed/src/lib.rs | 31 +++++++++++++++++++++++-------- 5 files changed, 56 insertions(+), 8 deletions(-) diff --git a/crates/directory/src/config.rs b/crates/directory/src/config.rs index 89124f4..81bc6a8 100644 --- a/crates/directory/src/config.rs +++ b/crates/directory/src/config.rs @@ -6,9 +6,11 @@ use anyhow::{Context, Result, bail}; use serde::Deserialize; use std::path::Path; +/// `directory.toml`. See docs/directory.md. #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] pub struct Config { + /// Where the API and the public pages listen. #[serde(default = "default_bind")] pub bind: String, /// The environment variable holding the database URL, so the file holds no secret. @@ -39,6 +41,7 @@ fn default_database_url_env() -> String { "DIRECTORY_DATABASE_URL".into() } +/// A token as `directory.toml` lists it: a name, what it may do, and only the token's hash. #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] pub struct TokenCfg { @@ -74,11 +77,14 @@ pub struct Token { } impl Config { + /// Reads and checks `path`; see `parse`. pub fn load(path: &Path) -> Result { let text = std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?; Self::parse(&text).with_context(|| format!("in {}", path.display())) } + /// Reads a config, refusing tokens with empty or repeated names, which would file two + /// instances' snapshots together. pub fn parse(text: &str) -> Result { let cfg: Config = toml::from_str(text)?; let mut seen = std::collections::HashSet::new(); diff --git a/crates/directory/src/lib.rs b/crates/directory/src/lib.rs index cc5b532..2670835 100644 --- a/crates/directory/src/lib.rs +++ b/crates/directory/src/lib.rs @@ -63,7 +63,9 @@ pub struct Item { pub guid: String, pub title: Option, pub link: Option, + /// Unix seconds. pub published: Option, + /// Seconds. pub duration: Option, /// As the feed sent it, unsanitized. pub description: Option, @@ -80,6 +82,7 @@ pub struct SnapshotEntry { pub category: Option, } +/// What became of a snapshot's entries. #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] pub struct SnapshotResult { pub accepted: usize, @@ -91,6 +94,7 @@ pub struct SnapshotResult { pub refused_invalid: usize, } +/// Who a token belongs to and what it may do: `/v1/me`'s answer. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Me { pub name: String, @@ -105,11 +109,13 @@ pub struct Instance { pub subscribers: i64, } +/// A feed an admin removed from the directory, which snapshots may not bring back. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Blocked { pub id: String, pub url: Option, pub title: Option, + /// When it was blocked, Unix seconds. pub at: i64, } @@ -121,6 +127,8 @@ pub const MAX_SNAPSHOT: usize = 20_000; /// to a feed fetches it itself anyway: every six hours was 3.9 GB of feeds a day (#243). pub const REFRESH_EVERY: i64 = 12 * 3600; +/// The running directory: its database, the tokens it accepts and the client its refresher +/// fetches feeds with. pub struct Directory { pub store: Store, tokens: RwLock>, @@ -134,9 +142,12 @@ pub struct Directory { /// How long the public pages show the same copy of the list. const PAGES_LIST_FOR: Duration = Duration::from_secs(60); +/// The directory as every handler and task holds it. pub type Shared = Arc; impl Directory { + /// A directory on `store`, accepting `tokens`, whose refresher may reach the internal + /// addresses `fetch_internal` lists and no others. pub fn new( store: Store, tokens: Vec, @@ -288,6 +299,7 @@ pub async fn reloader(dir: Shared, path: std::path::PathBuf) { } } +/// Every route: the API under `/v1`, behind a token, and the public pages. pub fn router(dir: Shared) -> Router { let v1 = Router::new() .route("/v1/me", get(me)) diff --git a/crates/directory/src/store.rs b/crates/directory/src/store.rs index 4579f13..127c313 100644 --- a/crates/directory/src/store.rs +++ b/crates/directory/src/store.rs @@ -16,6 +16,7 @@ const FLAG_AFTER_SECS: i64 = 24 * 3600; /// The most subscribers one instance may say a feed has. pub const MAX_SUBSCRIBERS: i64 = 1_000_000; +/// The directory's database: feeds, snapshots, blocks and when each sync ran. pub struct Store { db: DatabaseConnection, } @@ -30,11 +31,15 @@ pub struct Due { /// What a fetch found, to be stored. pub enum Refreshed { + /// Unchanged since the last fetch. NotModified, + /// Read anew, with the validators to send next time. Feed { parsed: ipx_feed::ParsedFeed, etag: Option, last_modified: Option }, + /// The fetch failed, and why, in words. Failed(String), } +/// Unix seconds now. #[must_use] pub fn now() -> i64 { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs() as i64) @@ -184,6 +189,7 @@ impl Store { }) } + /// Records when the sync `name` (Apple's charts) last ran. pub async fn set_synced(&self, name: &str, at: i64) -> Result<()> { self.exec( "INSERT INTO syncs (name, at) VALUES ($1, $2) ON CONFLICT (name) DO UPDATE SET at = excluded.at", @@ -193,6 +199,7 @@ impl Store { Ok(()) } + /// Whether the database answers, for the healthcheck. pub async fn ping(&self) -> Result<()> { self.rows("SELECT 1", vec![]).await?; Ok(()) @@ -377,6 +384,7 @@ impl Store { Ok(self.exec("DELETE FROM blocked WHERE id = $1", vec![id.into()]).await? > 0) } + /// Each instance that has sent a snapshot, with how many feeds and people it counted. pub async fn instances(&self) -> Result> { // Postgres sums a BIGINT as NUMERIC, which does not read as an i64. self.rows( @@ -396,6 +404,7 @@ impl Store { .collect() } + /// The feeds an admin has blocked. pub async fn blocked(&self) -> Result> { self.rows("SELECT id, url, title, at FROM blocked ORDER BY at DESC, id", vec![]) .await? @@ -432,6 +441,7 @@ impl Store { .collect() } + /// One feed, due or not, for an admin's Refresh. pub async fn due_one(&self, id: &str) -> Result> { self.rows("SELECT id, url, etag, last_modified FROM feeds WHERE id = $1", vec![id.into()]) .await? @@ -447,6 +457,7 @@ impl Store { .transpose() } + /// Stores what a fetch of feed `id` found, and when. pub async fn save(&self, id: &str, got: Refreshed) -> Result<()> { let t = now(); match got { diff --git a/crates/feed/src/guard.rs b/crates/feed/src/guard.rs index 1b273d9..ee57f49 100644 --- a/crates/feed/src/guard.rs +++ b/crates/feed/src/guard.rs @@ -24,10 +24,12 @@ type BoxError = Box; pub struct Allowed(Arc>); impl Allowed { + /// Reads `fetch_internal`'s list, refusing an entry that is neither an address nor a network. pub fn parse(list: &[String]) -> Result { list.iter().map(|s| parse_net(s)).collect::>>().map(|v| Self(Arc::new(v))) } + /// Whether a connection to `ip` may go ahead: any public address, and the internal ones listed. #[must_use] pub fn permits(&self, ip: IpAddr) -> bool { let ip = canonical(ip); @@ -109,6 +111,7 @@ pub fn is_internal(ip: IpAddr) -> bool { pub struct Guard(Allowed); impl Guard { + /// A guard letting through what `allowed` lists, besides the public internet. #[must_use] pub fn new(allowed: Allowed) -> Self { Self(allowed) @@ -123,6 +126,7 @@ impl tower::Layer for Guard { } } +/// The connector `Guard` wraps, checking each connection it makes. #[derive(Clone)] pub struct Guarded { inner: S, diff --git a/crates/feed/src/lib.rs b/crates/feed/src/lib.rs index 0275011..f491927 100644 --- a/crates/feed/src/lib.rs +++ b/crates/feed/src/lib.rs @@ -7,10 +7,13 @@ use anyhow::{Context, Result, anyhow}; use reqwest::StatusCode; use reqwest::header::{ETAG, IF_MODIFIED_SINCE, IF_NONE_MATCH, LAST_MODIFIED}; +/// A feed as `parse` reads it, RSS or Atom alike: the channel's own details and its items. #[derive(Debug, Default)] pub struct ParsedFeed { pub title: Option, + /// RSS's ``: how long the publisher asks to be left alone between reads. pub ttl_mins: Option, + /// The show's artwork, `` before the channel's ``. pub image: Option, /// The site the feed belongs to, where a favicon can stand in for missing artwork. pub site: Option, @@ -22,17 +25,22 @@ pub struct ParsedFeed { pub entries: Vec, } +/// One item of a feed. #[derive(Debug, Default)] pub struct Entry { + /// What tells this item from the feed's others: its ``, or failing that its link, its + /// enclosure's address or its title (`pick_guid`). pub guid: String, pub title: Option, pub link: Option, + /// Unix seconds. pub published: Option, pub description: Option, /// The item's Markdown, when the feed sends it beside HTML of its own (``): /// the page can show either (#285). With no HTML it is the description instead (#284). pub markdown: Option, pub categories: Vec, + /// `` said yes. pub explicit: bool, /// Episode artwork; falls back to the feed's in the UI. pub image: Option, @@ -43,21 +51,22 @@ pub struct Entry { pub enclosures: Vec, } +/// A file an item carries, an episode as a rule. #[derive(Debug, Default, Clone, PartialEq)] pub struct Enclosure { pub url: String, + /// The type the feed declares, which may be missing or wrong. pub mime: Option, + /// The size the feed declares, in bytes. pub length: Option, } +/// What `fetch` got. pub enum Fetched { /// Server said 304, or returned a body we already have. NotModified, - Body { - bytes: Vec, - etag: Option, - last_modified: Option, - }, + /// A body to read, with the validators to send next time. + Body { bytes: Vec, etag: Option, last_modified: Option }, } /// The longest a feed may take, connecting to the last byte: a scan handles feeds in order, and @@ -76,6 +85,7 @@ pub const FEED_SILENCE: std::time::Duration = std::time::Duration::from_secs(15) /// beside memory (#224): a body is read whole, so a night of large feeds is a night of large /// allocations. pub static FETCHES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); +/// See `FETCHES`. pub static FETCHED_BYTES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); /// Conditional GET, following redirects itself: `client` must follow none (`feed_client`), so @@ -135,6 +145,9 @@ async fn read_capped(mut resp: reqwest::Response) -> Result> { Ok(out) } +/// Reads a feed, asking only for what changed since `etag` and `last_modified`. Redirects are +/// followed here, up to ten, so `auth` goes to the feed's own host alone. Also returns the new +/// address when every hop said the feed moved for good, for the caller to keep instead. #[tracing::instrument(skip_all, fields(url = %redact(feed_url), http.response.body.size))] pub async fn fetch( client: &reqwest::Client, @@ -258,7 +271,9 @@ fn from_wp_posts(posts: &[serde_json::Value]) -> ParsedFeed { /// A stored `last_error`, translated into plain words for whoever subscribes: whose problem /// it is, and whether there is a new address to switch to. pub struct Failure { + /// What to tell them, in a sentence. pub reason: &'static str, + /// Where the feed went, when the error says. pub new_url: Option, } @@ -1309,15 +1324,15 @@ const CATEGORIES: &[(&str, &[&str])] = &[ const ALIASES: &[(&str, &str)] = &[("American Football", "Football"), ("Football (Soccer)", "Soccer"), ("Software How-To", "Technology")]; -/// Where a category sits in Apple's list, as (category, subcategory), so the Directory browses a -/// category and then its subcategories as Apple's does (#118). A podcast's own is stored as its -/// subcategory, and one an admin typed that is not Apple's stands as a category of its own. /// Apple's categories as ipx has them, for the directory to check against Apple's own (#252). #[must_use] pub fn categories() -> &'static [(&'static str, &'static [&'static str])] { CATEGORIES } +/// Where a category sits in Apple's list, as (category, subcategory), so the Directory browses a +/// category and then its subcategories as Apple's does (#118). A podcast's own is stored as its +/// subcategory, and one an admin typed that is not Apple's stands as a category of its own. #[must_use] pub fn category_path(name: &str) -> (String, Option) { // A feed's own text, which can end in a stray comma: "Soccer," was a category of its own.