dimescheduler 0.1.0

Official Rust client library for the Dime.Scheduler API.
Documentation
use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC};
use reqwest::Method;
use serde::de::DeserializeOwned;
use serde::Serialize;
use std::time::Duration;

use crate::error::{extract_error_message, Error, Result};

const API_KEY_HEADER: &str = "X-API-KEY";
const USER_AGENT: &str = concat!("dimescheduler-rust/", env!("CARGO_PKG_VERSION"));
const TIMEOUT: Duration = Duration::from_secs(60);

/// Same set JavaScript's `encodeURIComponent` leaves unescaped.
const SEGMENT: &AsciiSet = &NON_ALPHANUMERIC
    .remove(b'-')
    .remove(b'_')
    .remove(b'.')
    .remove(b'~');

/// Percent-encodes one path segment (ids, job numbers) so `/` and spaces can't
/// split or break the route.
pub(crate) fn encode_segment(s: &str) -> String {
    utf8_percent_encode(s, SEGMENT).to_string()
}

/// Shared HTTP transport behind every accessor. Cloning is cheap:
/// `reqwest::Client` is an `Arc` around a connection pool.
#[derive(Clone)]
pub(crate) struct Transport {
    http: reqwest::Client,
    base_url: String,
    api_key: String,
}

impl Transport {
    pub(crate) fn new(base_url: String, api_key: String) -> Self {
        // Only constant, valid settings go into the builder, so this cannot
        // fail in practice; the plansolve crate makes the same call.
        let http = reqwest::Client::builder()
            .timeout(TIMEOUT)
            .user_agent(USER_AGENT)
            .build()
            .expect("reqwest client with constant settings");
        Self {
            http,
            base_url: base_url.trim_end_matches('/').to_string(),
            api_key,
        }
    }

    pub(crate) fn base_url(&self) -> &str {
        &self.base_url
    }

    pub(crate) async fn get_json<R: DeserializeOwned>(
        &self,
        path: &str,
        query: &[(&str, String)],
    ) -> Result<R> {
        let req = self.http.get(self.url(path)).query(query);
        Self::decode(self.sign(req).send().await?).await
    }

    pub(crate) async fn send_json<B, R>(&self, method: Method, path: &str, body: &B) -> Result<R>
    where
        B: Serialize + ?Sized,
        R: DeserializeOwned,
    {
        let req = self.http.request(method, self.url(path)).json(body);
        Self::decode(self.sign(req).send().await?).await
    }

    pub(crate) async fn send_empty<B>(&self, method: Method, path: &str, body: &B) -> Result<()>
    where
        B: Serialize + ?Sized,
    {
        let req = self.http.request(method, self.url(path)).json(body);
        let resp = self.sign(req).send().await?;
        Self::check_status(resp).await.map(|_| ())
    }

    fn url(&self, path: &str) -> String {
        format!("{}{}", self.base_url, path)
    }

    fn sign(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
        req.header(API_KEY_HEADER, &self.api_key)
            .header(reqwest::header::ACCEPT, "application/json")
    }

    async fn check_status(resp: reqwest::Response) -> Result<reqwest::Response> {
        let status = resp.status();
        if status.is_success() {
            return Ok(resp);
        }
        let body = resp.text().await.unwrap_or_default();
        Err(Error::Api {
            status: status.as_u16(),
            body: extract_error_message(&body),
        })
    }

    async fn decode<R: DeserializeOwned>(resp: reqwest::Response) -> Result<R> {
        let resp = Self::check_status(resp).await?;
        Ok(resp.json::<R>().await?)
    }
}