# fourchan-rs
Async [4chan JSON API](https://github.com/4chan/4chan-API) client and type bindings for Rust.
Hits the read-only endpoints and normalizes the responses into typed structs. Async-only, built on `reqwest` + `tokio`. Published as `fourchan-rs`; the library import name is `chan`.
## Features
- Typed bindings for every read-only endpoint: boards, catalog, index pages, thread lists, full threads, and archives.
- `If-Modified-Since` polling variants that return `Ok(None)` on `304 Not Modified`.
- Normalized posts: 4chan mixes post and attachment metadata into one object; chan-rs splits attachments out so `post.attachment.is_some()` is the single source of truth.
- Attachment helpers for CDN URLs (`url`, `thumbnail_url`) and kind checks (`is_image`, `is_video`, `is_animated`).
- Cheap-to-clone `Client` (`Arc`-shared config). Accepts a user-supplied `reqwest::Client` to share connection pools / proxy / TLS config.
- No imposed rate limit. Callers stay within 4chan's published limits themselves.
## Requirements
- Rust 1.85 or newer.
- A tokio async runtime, re-exported as `chan::tokio` (no separate dependency required).
- Network access to `a.4cdn.org` (API) and `i.4cdn.org` (attachment CDN).
- TLS via rustls, no system OpenSSL needed.
## Install
```toml
[dependencies]
fourchan-rs = "0.1"
```
The tokio runtime is re-exported as `chan::tokio`, so no separate tokio dependency is needed.
## Quick start
```rust
#[chan::tokio::main]
async fn main() -> chan::Result<()> {
let client = chan::Client::new();
let boards = client.get_boards().await?;
println!("{} boards", boards.len());
let catalog = client.get_board_catalog("po").await?;
for thread in catalog.threads() {
println!("/po/{}: {} replies", thread.no(), thread.op.replies.unwrap_or(0));
}
Ok(())
}
```
## Endpoints
| `get_boards()` | `/boards.json` |
| `get_board_catalog(board)` | `/{board}/catalog.json` |
| `get_threads(board)` | `/{board}/threads.json` |
| `get_archive(board)` | `/{board}/archive.json` |
| `get_index_page(board, page)` | `/{board}/{1..=15}.json` |
| `get_full_thread(board, no)` | `/{board}/thread/{no}.json` |
Conditional (`If-Modified-Since`) variants: `get_threads_if_modified`, `get_catalog_if_modified`, `get_full_thread_if_modified`. Each takes the previous `Last-Modified` value and returns `Option<Conditional<T>>`, `None` on `304`.
A missing board or thread errors with `Error::NotFound`.
## Polling
The previous `Last-Modified` is fed back in; `Ok(None)` signals no change, so re-rendering happens only when content actually changes:
```rust
let mut since: Option<String> = None;
loop {
match client.get_full_thread_if_modified("po", 570368, since.as_deref()).await? {
Some(cond) => {
since = cond.last_modified;
for post in &cond.value.posts {
// handle post
}
}
None => { /* 304 Not Modified */ }
}
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
}
```
## Examples
```sh
cargo run --example list_boards
cargo run --example catalog_images -- po
cargo run --example poll_thread -- po 570368
```
## Custom client
```rust
let http = reqwest::Client::builder()
.user_agent("my-app/1.0")
.build()?;
let client = chan::Client::with_client(http);
// Or override the API host:
let client = chan::Client::new().with_api_host("http://localhost:8080");
```
A `chan-rs/<version>` User-Agent is sent on every request even when the supplied `reqwest::Client` carries none.