1use crate::{GetNodesRequest, GetNodesResponse};
2use crate::{Key, Limiter};
3use derive_more::{From, Into};
4use errgonomic::handle;
5use reqwest::Client as HttpClient;
6use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue};
7use secrecy::ExposeSecret as _;
8use serde::de::DeserializeOwned;
9use std::sync::LazyLock;
10use thiserror::Error;
11use url::Url;
12use url_macro::url;
13
14pub static BASE_URL: LazyLock<Url> = LazyLock::new(|| url!("https://workflowy.com/api/v1/"));
16
17#[derive(From, Into, Clone, Debug)]
18pub struct Client {
19 pub inner: HttpClient,
20 pub base: Url,
21 pub limiter: Limiter,
22}
23
24impl Client {
25 const NODES_PATH: &str = "nodes";
26
27 pub fn new(key: impl Into<Key>) -> Result<Self, ClientNewError> {
28 use ClientNewError::*;
29 let key = key.into();
30 Ok(handle!(Self::try_from(key), TryFromKeyFailed))
31 }
32
33 pub async fn get_nodes(&self, request: &GetNodesRequest<'_>) -> Result<GetNodesResponse, ClientGetNodesError> {
34 use ClientGetNodesError::*;
35 let url = self
36 .base
37 .join(Self::NODES_PATH)
38 .expect("always succeeds because `NODES_PATH` is a valid relative URL path");
39 let result = self.inner.get(url).query(&request).send().await;
40 let response = handle!(result, SendFailed);
41 let nodes = handle!(Self::handle(response).await, HandleFailed);
42 Ok(nodes)
43 }
44
45 pub async fn handle<T>(response: reqwest::Response) -> Result<T, HandleError>
46 where
47 T: DeserializeOwned,
48 {
49 use HandleError::*;
50 let status = response.status();
51 if status.is_success() {
52 let response = response.json::<T>().await;
53 Ok(handle!(response, DecodeResponseFailed))
54 } else {
55 let body = response.text().await;
56 let body = handle!(body, ReadBodyFailed, status);
57 Err(CheckStatusFailed {
58 status,
59 body,
60 })
61 }
62 }
63}
64
65#[derive(Error, Debug)]
66pub enum HandleError {
67 #[error("Workflowy returned status '{status}': '{body}'")]
68 CheckStatusFailed { status: reqwest::StatusCode, body: String },
69 #[error("failed to read Workflowy error response body for status '{status}'")]
70 ReadBodyFailed { source: reqwest::Error, status: reqwest::StatusCode },
71 #[error("failed to decode Workflowy response")]
72 DecodeResponseFailed { source: reqwest::Error },
73}
74
75#[derive(Error, Debug)]
76pub enum ClientNewError {
77 #[error("failed to create Workflowy API client from key")]
78 TryFromKeyFailed { source: ConvertKeyToClientError },
79}
80
81impl From<HttpClient> for Client {
82 fn from(inner: HttpClient) -> Self {
83 Self {
84 inner,
85 base: BASE_URL.clone(),
86 limiter: Limiter,
87 }
88 }
89}
90
91impl TryFrom<Key> for Client {
92 type Error = ConvertKeyToClientError;
93
94 fn try_from(key: Key) -> Result<Self, Self::Error> {
95 use ConvertKeyToClientError::*;
96 let header_value_raw = format!("Bearer {}", key.expose_secret());
97 let mut header_value = handle!(HeaderValue::try_from(header_value_raw), HeaderValueTryFromFailed, key);
98 header_value.set_sensitive(true);
99 let mut headers = HeaderMap::new();
100 headers.insert(AUTHORIZATION, header_value);
101 let inner = HttpClient::builder().default_headers(headers).build();
102 let inner = handle!(inner, BuildHttpClientFailed);
103 Ok(Self::from(inner))
104 }
105}
106
107#[derive(Error, Debug)]
108pub enum ConvertKeyToClientError {
109 #[error("failed to convert Workflowy API key into an authorization header value")]
110 HeaderValueTryFromFailed { source: reqwest::header::InvalidHeaderValue, key: Key },
111 #[error("failed to build HTTP client")]
112 BuildHttpClientFailed { source: reqwest::Error },
113}
114
115#[derive(Error, Debug)]
116pub enum ClientGetNodesError {
117 #[error("failed to send get nodes request")]
118 SendFailed { source: reqwest::Error },
119 #[error("failed to handle get nodes response")]
120 HandleFailed { source: HandleError },
121}