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 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 01:12:14 +00:00
parent 41affc8d8e
commit d33d83043d
5 changed files with 56 additions and 8 deletions

View File

@@ -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<Self> {
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<Self> {
let cfg: Config = toml::from_str(text)?;
let mut seen = std::collections::HashSet::new();

View File

@@ -63,7 +63,9 @@ pub struct Item {
pub guid: String,
pub title: Option<String>,
pub link: Option<String>,
/// Unix seconds.
pub published: Option<i64>,
/// Seconds.
pub duration: Option<i64>,
/// As the feed sent it, unsanitized.
pub description: Option<String>,
@@ -80,6 +82,7 @@ pub struct SnapshotEntry {
pub category: Option<String>,
}
/// 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<String>,
pub title: Option<String>,
/// 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<Vec<Token>>,
@@ -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<Directory>;
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<Token>,
@@ -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))

View File

@@ -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<String>, last_modified: Option<String> },
/// 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<Vec<Instance>> {
// 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<Vec<Blocked>> {
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<Option<Due>> {
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 {

View File

@@ -24,10 +24,12 @@ type BoxError = Box<dyn std::error::Error + Send + Sync>;
pub struct Allowed(Arc<Vec<(IpAddr, u8)>>);
impl Allowed {
/// Reads `fetch_internal`'s list, refusing an entry that is neither an address nor a network.
pub fn parse(list: &[String]) -> Result<Self> {
list.iter().map(|s| parse_net(s)).collect::<Result<Vec<_>>>().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<S> tower::Layer<S> for Guard {
}
}
/// The connector `Guard` wraps, checking each connection it makes.
#[derive(Clone)]
pub struct Guarded<S> {
inner: S,

View File

@@ -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<String>,
/// RSS's `<ttl>`: how long the publisher asks to be left alone between reads.
pub ttl_mins: Option<u64>,
/// The show's artwork, `<itunes:image>` before the channel's `<image>`.
pub image: Option<String>,
/// The site the feed belongs to, where a favicon can stand in for missing artwork.
pub site: Option<String>,
@@ -22,17 +25,22 @@ pub struct ParsedFeed {
pub entries: Vec<Entry>,
}
/// One item of a feed.
#[derive(Debug, Default)]
pub struct Entry {
/// What tells this item from the feed's others: its `<guid>`, or failing that its link, its
/// enclosure's address or its title (`pick_guid`).
pub guid: String,
pub title: Option<String>,
pub link: Option<String>,
/// Unix seconds.
pub published: Option<i64>,
pub description: Option<String>,
/// The item's Markdown, when the feed sends it beside HTML of its own (`<source:markdown>`):
/// the page can show either (#285). With no HTML it is the description instead (#284).
pub markdown: Option<String>,
pub categories: Vec<String>,
/// `<itunes:explicit>` said yes.
pub explicit: bool,
/// Episode artwork; falls back to the feed's in the UI.
pub image: Option<String>,
@@ -43,21 +51,22 @@ pub struct Entry {
pub enclosures: Vec<Enclosure>,
}
/// 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<String>,
/// The size the feed declares, in bytes.
pub length: Option<i64>,
}
/// What `fetch` got.
pub enum Fetched {
/// Server said 304, or returned a body we already have.
NotModified,
Body {
bytes: Vec<u8>,
etag: Option<String>,
last_modified: Option<String>,
},
/// A body to read, with the validators to send next time.
Body { bytes: Vec<u8>, etag: Option<String>, last_modified: Option<String> },
}
/// 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<Vec<u8>> {
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<String>,
}
@@ -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<String>) {
// A feed's own text, which can end in a stray comma: "Soccer," was a category of its own.