Skip to main content

atrg_feed/
generator.rs

1//! Feed generator builder.
2//!
3//! [`FeedGenerator`] collects feed registrations and produces an Axum router
4//! that serves `app.bsky.feed.describeFeedGenerator` and
5//! `app.bsky.feed.getFeedSkeleton` XRPC endpoints.
6
7use std::collections::HashMap;
8use std::future::Future;
9use std::sync::Arc;
10
11use axum::Router;
12
13use atrg_core::AppState;
14
15use crate::handler::{FeedHandler, FeedRequest};
16use crate::routes;
17use crate::types::{FeedConfig, FeedSkeleton};
18
19/// A feed generator that manages multiple feeds and produces Axum routes.
20///
21/// # Example
22///
23/// ```rust,ignore
24/// let feeds = FeedGenerator::new("did:web:feeds.example.com")
25///     .feed("my-feed", "My Custom Feed", None, my_handler)
26///     .feed("other-feed", "Other Feed", Some("A description"), other_handler)
27///     .into_router();
28/// ```
29pub struct FeedGenerator {
30    /// The DID of the feed generator service.
31    did: String,
32    /// Registered feeds: id -> (config, handler).
33    feeds: HashMap<String, (FeedConfig, FeedHandler)>,
34}
35
36impl FeedGenerator {
37    /// Create a new feed generator with the given service DID.
38    ///
39    /// The DID identifies this feed generator on the AT Protocol network
40    /// (e.g. `"did:web:feeds.example.com"`).
41    pub fn new(did: impl Into<String>) -> Self {
42        Self {
43            did: did.into(),
44            feeds: HashMap::new(),
45        }
46    }
47
48    /// Register a feed with the given ID, display name, optional description,
49    /// and handler function.
50    ///
51    /// The handler is called each time a client requests the feed skeleton.
52    /// It receives a [`FeedRequest`] and the [`AppState`], and must return
53    /// a [`FeedSkeleton`] or an [`XrpcError`](atrg_xrpc::XrpcError).
54    ///
55    /// # Example
56    ///
57    /// ```rust,ignore
58    /// async fn chronological(
59    ///     req: FeedRequest,
60    ///     state: AppState,
61    /// ) -> Result<FeedSkeleton, XrpcError> {
62    ///     // query DB, build skeleton ...
63    ///     Ok(FeedSkeleton { feed: vec![], cursor: None })
64    /// }
65    ///
66    /// let gen = FeedGenerator::new("did:web:example.com")
67    ///     .feed("chrono", "Chronological", Some("Latest posts"), chronological);
68    /// ```
69    pub fn feed<F, Fut>(
70        mut self,
71        id: &str,
72        display_name: &str,
73        description: Option<&str>,
74        handler: F,
75    ) -> Self
76    where
77        F: Fn(FeedRequest, AppState) -> Fut + Send + Sync + 'static,
78        Fut: Future<Output = Result<FeedSkeleton, atrg_xrpc::XrpcError>> + Send + 'static,
79    {
80        let config = FeedConfig {
81            id: id.to_string(),
82            display_name: display_name.to_string(),
83            description: description.map(|s| s.to_string()),
84            avatar: None,
85        };
86        let handler: FeedHandler = Arc::new(move |req, state| Box::pin(handler(req, state)));
87        self.feeds.insert(id.to_string(), (config, handler));
88        self
89    }
90
91    /// Register a feed from an existing [`FeedConfig`] and handler function.
92    ///
93    /// This is useful when feed configurations are loaded from `atrg.toml`
94    /// or another config source.
95    pub fn feed_with_config<F, Fut>(mut self, config: FeedConfig, handler: F) -> Self
96    where
97        F: Fn(FeedRequest, AppState) -> Fut + Send + Sync + 'static,
98        Fut: Future<Output = Result<FeedSkeleton, atrg_xrpc::XrpcError>> + Send + 'static,
99    {
100        let id = config.id.clone();
101        let handler: FeedHandler = Arc::new(move |req, state| Box::pin(handler(req, state)));
102        self.feeds.insert(id, (config, handler));
103        self
104    }
105
106    /// Return the service DID for this feed generator.
107    pub fn did(&self) -> &str {
108        &self.did
109    }
110
111    /// Return the number of registered feeds.
112    pub fn feed_count(&self) -> usize {
113        self.feeds.len()
114    }
115
116    /// Build an Axum router with the XRPC feed endpoints.
117    ///
118    /// Registers:
119    /// - `GET /xrpc/app.bsky.feed.describeFeedGenerator`
120    /// - `GET /xrpc/app.bsky.feed.getFeedSkeleton`
121    pub fn into_router(self) -> Router<AppState> {
122        routes::build_router(self.did, self.feeds)
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn new_generator_has_no_feeds() {
132        let gen = FeedGenerator::new("did:web:example.com");
133        assert_eq!(gen.did(), "did:web:example.com");
134        assert_eq!(gen.feed_count(), 0);
135    }
136
137    #[test]
138    fn register_feeds_increments_count() {
139        let gen = FeedGenerator::new("did:web:example.com")
140            .feed("a", "Feed A", None, |_req, _state| async {
141                Ok(FeedSkeleton {
142                    feed: vec![],
143                    cursor: None,
144                })
145            })
146            .feed("b", "Feed B", Some("desc"), |_req, _state| async {
147                Ok(FeedSkeleton {
148                    feed: vec![],
149                    cursor: None,
150                })
151            });
152        assert_eq!(gen.feed_count(), 2);
153    }
154
155    #[test]
156    fn duplicate_id_overwrites() {
157        let gen = FeedGenerator::new("did:web:example.com")
158            .feed("a", "First", None, |_req, _state| async {
159                Ok(FeedSkeleton {
160                    feed: vec![],
161                    cursor: None,
162                })
163            })
164            .feed("a", "Second", None, |_req, _state| async {
165                Ok(FeedSkeleton {
166                    feed: vec![],
167                    cursor: None,
168                })
169            });
170        assert_eq!(gen.feed_count(), 1);
171    }
172
173    #[test]
174    fn feed_with_config_registers_feed() {
175        let config = FeedConfig {
176            id: "custom-feed".to_string(),
177            display_name: "Custom Feed".to_string(),
178            description: Some("A custom feed from config".to_string()),
179            avatar: None,
180        };
181        let gen = FeedGenerator::new("did:web:example.com").feed_with_config(
182            config,
183            |_req, _state| async {
184                Ok(FeedSkeleton {
185                    feed: vec![],
186                    cursor: None,
187                })
188            },
189        );
190        assert_eq!(gen.feed_count(), 1);
191    }
192}