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
15pub const API_HOST: &str = "https://a.4cdn.org";
17
18pub const CDN_HOST: &str = "https://i.4cdn.org";
20
21const USER_AGENT_STR: &str = concat!("chan-rs/", env!("CARGO_PKG_VERSION"));
24
25#[derive(Clone)]
30pub struct Client {
31 http: reqwest::Client,
32 inner: Arc<Inner>,
33}
34
35struct Inner {
36 api_host: String,
37}
38
39#[derive(Debug, Clone)]
42pub struct Conditional<T> {
43 pub value: T,
44 pub last_modified: Option<String>,
45}
46
47impl Client {
48 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 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 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 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 pub async fn get_board_catalog(&self, board: &str) -> Result<Catalog> {
86 self.get_json(&format!("/{}/catalog.json", board)).await
87 }
88
89 pub async fn get_threads(&self, board: &str) -> Result<ThreadList> {
91 self.get_json(&format!("/{}/threads.json", board)).await
92 }
93
94 pub async fn get_archive(&self, board: &str) -> Result<Archive> {
97 self.get_json(&format!("/{}/archive.json", board)).await
98 }
99
100 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 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 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 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 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}