r621 0.3.3

Provides a client to access e621
Documentation
use crate::{Id};
use crate::post::{Post, RawPost, RawPosts};
use anyhow::Result;
use base64::{engine::GeneralPurpose, Engine};
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, USER_AGENT};
use reqwest::Response;
use std::time::{Duration, Instant};

const MAX_REQ_PER_SEC: u8 = 2;

#[derive(Clone, Debug)]
pub struct Client {
    host: &'static str,
    http_client: reqwest::Client,
    last_request_time: Instant,
    request_counter: u8
}

impl Client {

    /// Create a new e621 client.
    ///
    /// ```
    ///use r621::prelude::*;
    ///use std::error::Error;
    ///
    ///#[tokio::main]
    ///async fn main() -> Result<(), Box<dyn Error>> {
    ///    let user_agent = "MyProject/1.0 (by username on e621)";
    ///    let auth = Authentication::Authorized {
    ///        username: "hexerade",
    ///        apikey: "1nHrmzmsvJf26EhU1F7CjnjC"
    ///    };
    ///    let mut esix_client = Client::new(auth, user_agent);
    ///}
    /// ```
    pub fn new(auth: Authentication, useragent: &str) -> Result<Self> {
        let mut header_map = HeaderMap::new();
        header_map.append(USER_AGENT, HeaderValue::from_str(useragent)?);
        if let Authentication::Authorized { username, apikey } = auth {
            let authorization = Self::get_authorization_value(username, apikey);
            header_map.append(AUTHORIZATION, HeaderValue::from_str(&authorization)?);
        }

        let http_client = reqwest::Client::builder()
            .default_headers(header_map)
            .build()?;

        Ok(Client {
            host: "https://e621.net",
            http_client,
            last_request_time: Instant::now(),
            request_counter: 0
        })
    }

    /// Query the e621 api for posts
    ///
    /// ```
    ///use r621::prelude::*;
    ///use std::error::Error;
    ///
    ///#[tokio::main]
    ///async fn main() -> Result<(), Box<dyn Error>> {
    ///    let user_agent = "MyProject/1.0 (by username on e621)";
    ///    let auth = Authentication::Authorized {
    ///        username: "hexerade",
    ///        apikey: "1nHrmzmsvJf26EhU1F7CjnjC"
    ///    };
    ///    let mut esix_client = Client::new(auth, user_agent);
    ///    let posts = esix_client.list_posts(None, Some("lucario"), None).await?;
    ///}
    /// ```
    pub async fn list_posts(
        &mut self,
        limit: Option<u16>,
        tags: Option<String>,
        page: Option<u32>,
    ) -> Result<Vec<Post>> {
        self.request_limiter().await;
        let res = self.list_posts_raw(limit, tags, page).await?;
        res.error_for_status_ref()?;
        Ok(res.json::<RawPosts>().await?.into())
    }

    /// Get a specific post
    ///
    /// ```
    ///use r621::prelude::*;
    ///use std::error::Error;
    ///
    ///#[tokio::main]
    ///async fn main() -> Result<(), Box<dyn Error>> {
    ///    let user_agent = "MyProject/1.0 (by username on e621)";
    ///    let auth = Authentication::Authorized {
    ///        username: "hexerade",
    ///        apikey: "1nHrmzmsvJf26EhU1F7CjnjC"
    ///    };
    ///    let mut esix_client = Client::new(auth, user_agent);
    ///    let post = esix_client.get_post(1337).await?;
    ///}
    /// ```
    pub async fn get_post(&mut self, post_id: Id) -> Result<Post> {
        self.request_limiter().await;
        let res = self.get_post_raw(post_id).await?;
        res.error_for_status_ref()?;
        Ok(res.json::<RawPost>().await?.into())
    }

    pub async fn get_post_raw(&mut self, post_id: Id) -> Result<Response> {
        let url = url::Url::parse(format!("{}/posts/{}.json", self.host, post_id).as_str())?;
        self.request_counter = self.request_counter+1;
        Ok(self.http_client.get(url.as_str()).send().await?)
    }

    async fn request_limiter(&mut self) {
        let wait_time = Instant::now() - self.last_request_time;
        if Instant::now() - self.last_request_time > Duration::from_secs(1) {
            self.last_request_time = Instant::now();
            self.request_counter = 0;
            return;
        }

        if self.request_counter >= MAX_REQ_PER_SEC {
            tokio::time::sleep(wait_time).await;
            self.last_request_time = Instant::now();
            self.request_counter = 0;
            return;
        }
    }

    fn get_authorization_value(username: &str, apikey: &str) -> String {
        let base64_engine = GeneralPurpose::new(&base64::alphabet::STANDARD, Default::default());
        base64_engine.encode(format!("{username}:{apikey}"))
    }

    async fn list_posts_raw(
        &mut self,
        limit: Option<u16>,
        tags: Option<String>,
        page: Option<u32>,
    ) -> Result<Response> {
        let mut url = url::Url::parse(format!("{}/posts.json", self.host).as_str())?;

        let mut query_params = Vec::new();
        if let Some(limit) = limit {
            query_params.push(format!("limit={limit}"));
        }

        if let Some(page) = page {
            query_params.push(format!("page={page}"));
        }

        if let Some(tags) = tags {
            if !tags.is_empty() {
                query_params.push(format!("tags={tags}"));
            }
        }

        url.set_query(Some(&query_params.join("&")));

        self.request_counter = self.request_counter+1;
        Ok(self.http_client.get(url.as_str()).send().await?)
    }
}

#[derive(Clone, Copy, Debug)]
pub enum Authentication<'a> {
    Authorized { username: &'a str, apikey: &'a str },
    Unauthorized,
}