Skip to main content

synd_client/client/
mod.rs

1use std::time::Duration;
2#[cfg(unix)]
3use std::{path::Path, path::PathBuf};
4
5use url::Url;
6
7use crate::SyndApiError;
8
9mod authentication;
10mod daemon;
11mod feed_events;
12mod graphql;
13mod session;
14mod tls;
15
16pub use authentication::ApiCredential;
17use authentication::ClientAuthentication;
18pub use feed_events::FeedEventWatch;
19use tls::ClientTls;
20
21#[derive(Debug, Clone)]
22pub struct ClientOptions {
23    timeout: Duration,
24    user_agent: String,
25    tls: ClientTls,
26}
27
28impl ClientOptions {
29    pub fn new(timeout: Duration, user_agent: impl Into<String>) -> Self {
30        Self {
31            timeout,
32            user_agent: user_agent.into(),
33            tls: ClientTls::default(),
34        }
35    }
36
37    pub fn with_root_certificate_pem(mut self, pem: &[u8]) -> Result<Self, SyndApiError> {
38        self.tls.add_root_certificate_pem(pem)?;
39        Ok(self)
40    }
41
42    #[must_use]
43    pub fn danger_accept_invalid_certs(mut self, accept: bool) -> Self {
44        self.tls.accept_invalid_certificates(accept);
45        self
46    }
47}
48
49/// Client for the syndicationd API.
50#[derive(Clone)]
51pub struct Client {
52    #[expect(clippy::struct_field_names)]
53    client: reqwest::Client,
54    authentication: ClientAuthentication,
55    endpoint: Url,
56    feed_event_transport: FeedEventTransport,
57}
58
59#[derive(Clone)]
60enum FeedEventTransport {
61    Tcp {
62        connector: Option<tokio_tungstenite::Connector>,
63    },
64    #[cfg(unix)]
65    Unix { socket_path: PathBuf },
66}
67
68impl Client {
69    pub fn new(endpoint: Url, options: ClientOptions) -> Result<Self, SyndApiError> {
70        endpoint.join("/")?;
71        let connector = options.tls.websocket_connector()?;
72        let client = Self::builder(options)?
73            .build()
74            .map_err(SyndApiError::BuildRequest)?;
75
76        Ok(Self {
77            client,
78            endpoint,
79            authentication: ClientAuthentication::Required,
80            feed_event_transport: FeedEventTransport::Tcp { connector },
81        })
82    }
83
84    #[cfg(unix)]
85    pub fn new_unix(
86        socket_path: impl AsRef<Path>,
87        options: ClientOptions,
88    ) -> Result<Self, SyndApiError> {
89        let socket_path = socket_path.as_ref().to_path_buf();
90        let client = Self::builder(options)?
91            .unix_socket(socket_path.clone())
92            .build()
93            .map_err(SyndApiError::BuildRequest)?;
94
95        Ok(Self {
96            client,
97            endpoint: Url::parse("http://localhost")?,
98            authentication: ClientAuthentication::TransportTrusted,
99            feed_event_transport: FeedEventTransport::Unix { socket_path },
100        })
101    }
102
103    fn builder(options: ClientOptions) -> Result<reqwest::ClientBuilder, SyndApiError> {
104        let ClientOptions {
105            timeout,
106            user_agent,
107            tls,
108        } = options;
109        let builder = reqwest::ClientBuilder::new()
110            .user_agent(user_agent)
111            .timeout(timeout)
112            .connect_timeout(Duration::from_secs(10));
113        tls.configure_http(builder)
114    }
115
116    pub fn set_credential(&mut self, credential: ApiCredential) -> Result<(), SyndApiError> {
117        self.authentication.configure(credential)
118    }
119
120    pub fn set_local_token(&mut self, token: &str) -> Result<(), SyndApiError> {
121        self.set_credential(ApiCredential::LocalBearer {
122            token: token.to_owned(),
123        })
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use core::assert_matches;
130    use std::time::Duration;
131
132    use reqwest::header;
133
134    use super::{ApiCredential, Client, ClientOptions};
135    use crate::SyndApiError;
136
137    #[test]
138    fn tcp_client_requires_credential_before_authorized_request() {
139        let client =
140            Client::new(url::Url::parse("http://127.0.0.1:8080").unwrap(), options()).unwrap();
141        let mut headers = header::HeaderMap::new();
142
143        let error = client
144            .authentication
145            .apply_authorization_header(&mut headers)
146            .unwrap_err();
147
148        assert_matches!(error, SyndApiError::MissingCredential);
149        assert!(!headers.contains_key(header::AUTHORIZATION));
150    }
151
152    #[test]
153    fn configured_credential_is_written_to_authorization_header() {
154        let mut client =
155            Client::new(url::Url::parse("http://127.0.0.1:8080").unwrap(), options()).unwrap();
156        client
157            .set_credential(ApiCredential::LocalBearer {
158                token: "secret".to_owned(),
159            })
160            .unwrap();
161        let mut headers = header::HeaderMap::new();
162
163        client
164            .authentication
165            .apply_authorization_header(&mut headers)
166            .unwrap();
167
168        assert_eq!(headers.get(header::AUTHORIZATION).unwrap(), "Bearer secret");
169    }
170
171    #[cfg(unix)]
172    #[test]
173    fn unix_client_uses_transport_trust_without_authorization_header() {
174        let client = Client::new_unix("/tmp/synd-client-test.sock", options()).unwrap();
175        let mut headers = header::HeaderMap::new();
176
177        client
178            .authentication
179            .apply_authorization_header(&mut headers)
180            .unwrap();
181
182        assert!(!headers.contains_key(header::AUTHORIZATION));
183    }
184
185    fn options() -> ClientOptions {
186        ClientOptions::new(Duration::from_secs(1), "synd-client-test")
187    }
188}