notra 0.1.1

Unofficial Rust SDK for the Notra API
Documentation
use std::time::Duration;

use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
use serde::de::DeserializeOwned;
use serde::Serialize;

use crate::content::Content;
use crate::error::{ErrorResponse, NotraError, Result};

const DEFAULT_BASE_URL: &str = "https://api.usenotra.com";
const DEFAULT_TIMEOUT_SECS: u64 = 30;

pub struct Notra {
    pub(crate) http: reqwest::Client,
    pub(crate) base_url: String,
}

impl Notra {
    pub fn builder() -> NotraBuilder {
        NotraBuilder::default()
    }

    pub fn content(&self) -> Content<'_> {
        Content::new(self)
    }

    pub(crate) async fn get<T: DeserializeOwned>(
        &self,
        path: &str,
        query: &[(&str, String)],
    ) -> Result<T> {
        let url = format!("{}{}", self.base_url, path);
        let resp = self.http.get(&url).query(query).send().await?;
        self.handle_response(resp).await
    }

    pub(crate) async fn post<T: DeserializeOwned, B: Serialize>(
        &self,
        path: &str,
        body: &B,
    ) -> Result<T> {
        let url = format!("{}{}", self.base_url, path);
        let resp = self.http.post(&url).json(body).send().await?;
        self.handle_response(resp).await
    }

    pub(crate) async fn patch<T: DeserializeOwned, B: Serialize>(
        &self,
        path: &str,
        body: &B,
    ) -> Result<T> {
        let url = format!("{}{}", self.base_url, path);
        let resp = self.http.patch(&url).json(body).send().await?;
        self.handle_response(resp).await
    }

    pub(crate) async fn delete<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
        let url = format!("{}{}", self.base_url, path);
        let resp = self.http.delete(&url).send().await?;
        self.handle_response(resp).await
    }

    async fn handle_response<T: DeserializeOwned>(
        &self,
        resp: reqwest::Response,
    ) -> Result<T> {
        let status = resp.status();
        if status.is_success() {
            Ok(resp.json().await?)
        } else {
            let body = resp.text().await.unwrap_or_default();
            let message = serde_json::from_str::<ErrorResponse>(&body)
                .ok()
                .and_then(|e| e.message)
                .unwrap_or(body);
            Err(NotraError::Api {
                status: status.as_u16(),
                message,
            })
        }
    }
}

#[derive(Default)]
pub struct NotraBuilder {
    bearer_auth: Option<String>,
    server_url: Option<String>,
    timeout: Option<Duration>,
}

impl NotraBuilder {
    pub fn bearer_auth(mut self, token: impl Into<String>) -> Self {
        self.bearer_auth = Some(token.into());
        self
    }

    pub fn server_url(mut self, url: impl Into<String>) -> Self {
        self.server_url = Some(url.into());
        self
    }

    pub fn timeout(mut self, timeout: Duration) -> Self {
        self.timeout = Some(timeout);
        self
    }

    pub fn build(self) -> Result<Notra> {
        let token = self
            .bearer_auth
            .ok_or_else(|| NotraError::Builder("bearer_auth is required".into()))?;

        let mut headers = HeaderMap::new();
        let auth_value = format!("Bearer {}", token);
        headers.insert(
            AUTHORIZATION,
            HeaderValue::from_str(&auth_value)
                .map_err(|e| NotraError::Builder(format!("invalid auth token: {e}")))?,
        );
        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));

        let timeout = self.timeout.unwrap_or(Duration::from_secs(DEFAULT_TIMEOUT_SECS));

        let http = reqwest::Client::builder()
            .default_headers(headers)
            .timeout(timeout)
            .build()
            .map_err(|e| NotraError::Builder(format!("failed to build HTTP client: {e}")))?;

        let base_url = self
            .server_url
            .unwrap_or_else(|| DEFAULT_BASE_URL.to_string());

        Ok(Notra { http, base_url })
    }
}