fourchan-rs 0.1.1

Async 4chan JSON API client and type bindings
Documentation
use std::sync::Arc;

use reqwest::header::{HeaderValue, IF_MODIFIED_SINCE, LAST_MODIFIED, USER_AGENT};
use reqwest::StatusCode;
use serde::de::DeserializeOwned;

use crate::archive::Archive;
use crate::board::{Board, BoardList};
use crate::catalog::Catalog;
use crate::error::{Error, Result};
use crate::index::IndexPage;
use crate::thread::Thread;
use crate::threadlist::ThreadList;

/// Default API host. 4chan serves all ro endpoints from here.
pub const API_HOST: &str = "https://a.4cdn.org";

/// Default CDN host for attachments.
pub const CDN_HOST: &str = "https://i.4cdn.org";

/// User-Agent sent on every request, including those made through a
/// caller-supplied [`reqwest::Client`] that carries none of its own.
const USER_AGENT_STR: &str = concat!("chan-rs/", env!("CARGO_PKG_VERSION"));

/// Async client for the 4chan JSON API.
/// Cheap to clone, internal config is `Arc`-shared. Construct with
/// [`Client::new`] for sensible defaults, or [`Client::with_client`] to plug
/// in your own `reqwest::Client` (matching `backgrounds`/rchan ergonomics).
#[derive(Clone)]
pub struct Client {
    http: reqwest::Client,
    inner: Arc<Inner>,
}

struct Inner {
    api_host: String,
}

/// A response paired with the `Last-Modified` header value, for callers using
/// `If-Modified-Since` polling.
#[derive(Debug, Clone)]
pub struct Conditional<T> {
    pub value: T,
    pub last_modified: Option<String>,
}

impl Client {
    /// New client with a fresh `reqwest::Client` and a `chan-rs/<ver>`
    /// User-Agent.
    pub fn new() -> Self {
        let http = reqwest::Client::builder()
            .user_agent(USER_AGENT_STR)
            .build()
            .expect("reqwest::Client::builder default config is valid");
        Self::with_client(http)
    }

    /// New client reusing an existing `reqwest::Client`. Lets callers share
    /// connection pools and proxy/TLS config across multiple APIs.
    pub fn with_client(http: reqwest::Client) -> Self {
        Self {
            http,
            inner: Arc::new(Inner {
                api_host: API_HOST.to_string(),
            }),
        }
    }

    /// Override the API host. Useful for tests or proxies.
    pub fn with_api_host(mut self, host: impl Into<String>) -> Self {
        self.inner = Arc::new(Inner { api_host: host.into() });
        self
    }

    // Endpoints

    /// `GET /boards.json`, return every board the API exposes.
    pub async fn get_boards(&self) -> Result<Vec<Board>> {
        let list: BoardList = self.get_json("/boards.json").await?;
        Ok(list.boards)
    }

    /// `GET /{board}/catalog.json`, every thread on the board, with up to 5 reply previews 
    /// per thread.
    pub async fn get_board_catalog(&self, board: &str) -> Result<Catalog> {
        self.get_json(&format!("/{}/catalog.json", board)).await
    }

    /// `GET /{board}/threads.json`, a lightweight per-thread `last_modified` summary
    pub async fn get_threads(&self, board: &str) -> Result<ThreadList> {
        self.get_json(&format!("/{}/threads.json", board)).await
    }

    /// `GET /{board}/archive.json`, archived OP numbers, oldest first. Errors with 
    /// [`Error::NotFound`] on boards that have no archive.
    pub async fn get_archive(&self, board: &str) -> Result<Archive> {
        self.get_json(&format!("/{}/archive.json", board)).await
    }

    /// `GET /{board}/{page}.json`, one index page (1.=15) with full thread previews. 
    pub async fn get_index_page(&self, board: &str, page: u8) -> Result<IndexPage> {
        self.get_json(&format!("/{}/{}.json", board, page)).await
    }

    /// `GET /{board}/thread/{no}.json`, every post in a thread.
    pub async fn get_full_thread(&self, board: &str, thread_no: u64) -> Result<Thread> {
        self.get_json(&format!("/{}/thread/{}.json", board, thread_no))
            .await
    }

    // Conditional GET variants

    // Return `Ok(None)` on `304 Not Modified`, otherwise wrap the value with
    // the new `Last-Modified` header value to feed into the next call.

    pub async fn get_threads_if_modified(
        &self,
        board: &str,
        since: Option<&str>,
    ) -> Result<Option<Conditional<ThreadList>>> {
        self.get_json_if_modified(&format!("/{}/threads.json", board), since)
            .await
    }

    pub async fn get_catalog_if_modified(
        &self,
        board: &str,
        since: Option<&str>,
    ) -> Result<Option<Conditional<Catalog>>> {
        self.get_json_if_modified(&format!("/{}/catalog.json", board), since)
            .await
    }

    pub async fn get_full_thread_if_modified(
        &self,
        board: &str,
        thread_no: u64,
        since: Option<&str>,
    ) -> Result<Option<Conditional<Thread>>> {
        self.get_json_if_modified(
            &format!("/{}/thread/{}.json", board, thread_no),
            since,
        )
        .await
    }

    //Plumbing

    async fn get_json<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
        let url = format!("{}{}", self.inner.api_host, path);
        let resp = self
            .http
            .get(&url)
            .header(USER_AGENT, USER_AGENT_STR)
            .send()
            .await?;
        Self::check_status(&resp, &url)?;
        let bytes = resp.bytes().await?;
        let value = serde_json::from_slice(&bytes)?;
        Ok(value)
    }

    async fn get_json_if_modified<T: DeserializeOwned>(
        &self,
        path: &str,
        since: Option<&str>,
    ) -> Result<Option<Conditional<T>>> {
        let url = format!("{}{}", self.inner.api_host, path);
        let mut req = self.http.get(&url);
        if let Some(s) = since {
            let header_value = HeaderValue::from_str(s)
                .map_err(|e| Error::InvalidHeader(e.to_string()))?;
            req = req.header(IF_MODIFIED_SINCE, header_value);
        }

        // Always present a User-Agent even if the caller supplied a baren reqwest::Client
        req = req.header(USER_AGENT, USER_AGENT_STR);

        let resp = req.send().await?;
        if resp.status() == StatusCode::NOT_MODIFIED {
            return Ok(None);
        }
        Self::check_status(&resp, &url)?;
        let last_modified = resp
            .headers()
            .get(LAST_MODIFIED)
            .and_then(|v| v.to_str().ok())
            .map(|s| s.to_string());
        let bytes = resp.bytes().await?;
        let value = serde_json::from_slice(&bytes)?;
        Ok(Some(Conditional { value, last_modified }))
    }

    fn check_status(resp: &reqwest::Response, url: &str) -> Result<()> {
        let status = resp.status();
        if status == StatusCode::NOT_FOUND {
            return Err(Error::NotFound(url.to_string()));
        }
        if !status.is_success() {
            return Err(Error::Status {
                status: status.as_u16(),
                url: url.to_string(),
            });
        }
        Ok(())
    }
}

impl Default for Client {
    fn default() -> Self { Self::new() }
}