Skip to main content

chan/
client.rs

1use std::sync::Arc;
2
3use reqwest::header::{HeaderValue, IF_MODIFIED_SINCE, LAST_MODIFIED, USER_AGENT};
4use reqwest::StatusCode;
5use serde::de::DeserializeOwned;
6
7use crate::archive::Archive;
8use crate::board::{Board, BoardList};
9use crate::catalog::Catalog;
10use crate::error::{Error, Result};
11use crate::index::IndexPage;
12use crate::thread::Thread;
13use crate::threadlist::ThreadList;
14
15/// Default API host. 4chan serves all ro endpoints from here.
16pub const API_HOST: &str = "https://a.4cdn.org";
17
18/// Default CDN host for attachments.
19pub const CDN_HOST: &str = "https://i.4cdn.org";
20
21/// User-Agent sent on every request, including those made through a
22/// caller-supplied [`reqwest::Client`] that carries none of its own.
23const USER_AGENT_STR: &str = concat!("chan-rs/", env!("CARGO_PKG_VERSION"));
24
25/// Async client for the 4chan JSON API.
26/// Cheap to clone, internal config is `Arc`-shared. Construct with
27/// [`Client::new`] for sensible defaults, or [`Client::with_client`] to plug
28/// in your own `reqwest::Client` (matching `backgrounds`/rchan ergonomics).
29#[derive(Clone)]
30pub struct Client {
31    http: reqwest::Client,
32    inner: Arc<Inner>,
33}
34
35struct Inner {
36    api_host: String,
37}
38
39/// A response paired with the `Last-Modified` header value, for callers using
40/// `If-Modified-Since` polling.
41#[derive(Debug, Clone)]
42pub struct Conditional<T> {
43    pub value: T,
44    pub last_modified: Option<String>,
45}
46
47impl Client {
48    /// New client with a fresh `reqwest::Client` and a `chan-rs/<ver>`
49    /// User-Agent.
50    pub fn new() -> Self {
51        let http = reqwest::Client::builder()
52            .user_agent(USER_AGENT_STR)
53            .build()
54            .expect("reqwest::Client::builder default config is valid");
55        Self::with_client(http)
56    }
57
58    /// New client reusing an existing `reqwest::Client`. Lets callers share
59    /// connection pools and proxy/TLS config across multiple APIs.
60    pub fn with_client(http: reqwest::Client) -> Self {
61        Self {
62            http,
63            inner: Arc::new(Inner {
64                api_host: API_HOST.to_string(),
65            }),
66        }
67    }
68
69    /// Override the API host. Useful for tests or proxies.
70    pub fn with_api_host(mut self, host: impl Into<String>) -> Self {
71        self.inner = Arc::new(Inner { api_host: host.into() });
72        self
73    }
74
75    // Endpoints
76
77    /// `GET /boards.json`, return every board the API exposes.
78    pub async fn get_boards(&self) -> Result<Vec<Board>> {
79        let list: BoardList = self.get_json("/boards.json").await?;
80        Ok(list.boards)
81    }
82
83    /// `GET /{board}/catalog.json`, every thread on the board, with up to 5 reply previews 
84    /// per thread.
85    pub async fn get_board_catalog(&self, board: &str) -> Result<Catalog> {
86        self.get_json(&format!("/{}/catalog.json", board)).await
87    }
88
89    /// `GET /{board}/threads.json`, a lightweight per-thread `last_modified` summary
90    pub async fn get_threads(&self, board: &str) -> Result<ThreadList> {
91        self.get_json(&format!("/{}/threads.json", board)).await
92    }
93
94    /// `GET /{board}/archive.json`, archived OP numbers, oldest first. Errors with 
95    /// [`Error::NotFound`] on boards that have no archive.
96    pub async fn get_archive(&self, board: &str) -> Result<Archive> {
97        self.get_json(&format!("/{}/archive.json", board)).await
98    }
99
100    /// `GET /{board}/{page}.json`, one index page (1.=15) with full thread previews. 
101    pub async fn get_index_page(&self, board: &str, page: u8) -> Result<IndexPage> {
102        self.get_json(&format!("/{}/{}.json", board, page)).await
103    }
104
105    /// `GET /{board}/thread/{no}.json`, every post in a thread.
106    pub async fn get_full_thread(&self, board: &str, thread_no: u64) -> Result<Thread> {
107        self.get_json(&format!("/{}/thread/{}.json", board, thread_no))
108            .await
109    }
110
111    // Conditional GET variants
112
113    // Return `Ok(None)` on `304 Not Modified`, otherwise wrap the value with
114    // the new `Last-Modified` header value to feed into the next call.
115
116    pub async fn get_threads_if_modified(
117        &self,
118        board: &str,
119        since: Option<&str>,
120    ) -> Result<Option<Conditional<ThreadList>>> {
121        self.get_json_if_modified(&format!("/{}/threads.json", board), since)
122            .await
123    }
124
125    pub async fn get_catalog_if_modified(
126        &self,
127        board: &str,
128        since: Option<&str>,
129    ) -> Result<Option<Conditional<Catalog>>> {
130        self.get_json_if_modified(&format!("/{}/catalog.json", board), since)
131            .await
132    }
133
134    pub async fn get_full_thread_if_modified(
135        &self,
136        board: &str,
137        thread_no: u64,
138        since: Option<&str>,
139    ) -> Result<Option<Conditional<Thread>>> {
140        self.get_json_if_modified(
141            &format!("/{}/thread/{}.json", board, thread_no),
142            since,
143        )
144        .await
145    }
146
147    //Plumbing
148
149    async fn get_json<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
150        let url = format!("{}{}", self.inner.api_host, path);
151        let resp = self
152            .http
153            .get(&url)
154            .header(USER_AGENT, USER_AGENT_STR)
155            .send()
156            .await?;
157        Self::check_status(&resp, &url)?;
158        let bytes = resp.bytes().await?;
159        let value = serde_json::from_slice(&bytes)?;
160        Ok(value)
161    }
162
163    async fn get_json_if_modified<T: DeserializeOwned>(
164        &self,
165        path: &str,
166        since: Option<&str>,
167    ) -> Result<Option<Conditional<T>>> {
168        let url = format!("{}{}", self.inner.api_host, path);
169        let mut req = self.http.get(&url);
170        if let Some(s) = since {
171            let header_value = HeaderValue::from_str(s)
172                .map_err(|e| Error::InvalidHeader(e.to_string()))?;
173            req = req.header(IF_MODIFIED_SINCE, header_value);
174        }
175
176        // Always present a User-Agent even if the caller supplied a baren reqwest::Client
177        req = req.header(USER_AGENT, USER_AGENT_STR);
178
179        let resp = req.send().await?;
180        if resp.status() == StatusCode::NOT_MODIFIED {
181            return Ok(None);
182        }
183        Self::check_status(&resp, &url)?;
184        let last_modified = resp
185            .headers()
186            .get(LAST_MODIFIED)
187            .and_then(|v| v.to_str().ok())
188            .map(|s| s.to_string());
189        let bytes = resp.bytes().await?;
190        let value = serde_json::from_slice(&bytes)?;
191        Ok(Some(Conditional { value, last_modified }))
192    }
193
194    fn check_status(resp: &reqwest::Response, url: &str) -> Result<()> {
195        let status = resp.status();
196        if status == StatusCode::NOT_FOUND {
197            return Err(Error::NotFound(url.to_string()));
198        }
199        if !status.is_success() {
200            return Err(Error::Status {
201                status: status.as_u16(),
202                url: url.to_string(),
203            });
204        }
205        Ok(())
206    }
207}
208
209impl Default for Client {
210    fn default() -> Self { Self::new() }
211}