Skip to main content

wekan_cli/
client.rs

1pub mod auth;
2pub mod boards;
3pub mod cards;
4pub mod comments;
5mod decoding;
6pub use decoding::{ResponseDecodeKind, ResponseDiagnostic};
7pub mod error;
8pub mod lists;
9mod raw;
10pub mod swimlanes;
11mod transport;
12pub mod users;
13
14use std::{net::IpAddr, time::Duration};
15
16use reqwest::{Client, redirect::Policy};
17use thiserror::Error;
18use url::{Host, Url};
19
20pub use auth::{AuthSession, LoginRequest, LogoutRequest, RegisterRequest};
21pub use boards::{
22    BoardColor, BoardCounts, BoardDocument, BoardPermission, BoardSummary, BoardType,
23    BoardWatchLevel, BoardWatcher, CreateBoardRequest, CreateBoardResult, DeleteBoardResult,
24    PresentParentTask, RenameBoardResult,
25};
26pub use cards::{
27    ArchiveCardResult, CardCustomField, CardCustomFieldValue, CardDependency, CardDocument,
28    CardLocation, CardPoker, CardSticker, CardStickerHighlight, CardSummary, CardVote,
29    CreateCardRequest, CreateCardResult, DeleteCardResult, RestoreCardResult, UpdateCardRequest,
30    UpdateCardResult,
31};
32pub use comments::{
33    CommentDocument, CommentSummary, CreateCommentRequest, CreateCommentResult, DeleteCommentResult,
34};
35pub use error::ClientError;
36pub use lists::{
37    CreateListRequest, CreateListResult, DeleteListResult, ListDocument, ListSummary, ListWipLimit,
38    UpdateListRequest, UpdateListResult,
39};
40pub use raw::RawApiResponse;
41pub(crate) use raw::{RawApiAuthentication, RawApiRequest, RawApiRequestBody, RawApiRequestError};
42pub use swimlanes::{
43    CreateSwimlaneRequest, CreateSwimlaneResult, DeleteSwimlaneResult, SwimlaneDocument,
44    SwimlaneSummary, UpdateSwimlaneRequest, UpdateSwimlaneResult,
45};
46pub use users::{
47    CreateUserRequest, CreateUserResult, CurrentUser, CurrentUserBoard, CurrentUserEmail,
48    CurrentUserProfile, UserAction, UserActionResult, UserCard, UserCardQuery, UserEmail,
49    UserOrganization, UserProfile, UserRecord, UserSummary, UserTeam,
50};
51
52const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
53const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
54
55#[derive(Clone, Debug, Eq, PartialEq)]
56pub struct ServerUrl(Url);
57
58impl ServerUrl {
59    pub fn parse(raw: &str) -> Result<Self, ServerUrlError> {
60        let mut url = Url::parse(raw).map_err(ServerUrlError::Invalid)?;
61
62        if !matches!(url.scheme(), "http" | "https") {
63            return Err(ServerUrlError::UnsupportedScheme(url.scheme().to_owned()));
64        }
65        if url.host().is_none() || url.cannot_be_a_base() {
66            return Err(ServerUrlError::MissingHost);
67        }
68        if !url.username().is_empty() || url.password().is_some() {
69            return Err(ServerUrlError::EmbeddedCredentials);
70        }
71        if url.query().is_some() {
72            return Err(ServerUrlError::Query);
73        }
74        if url.fragment().is_some() {
75            return Err(ServerUrlError::Fragment);
76        }
77        if !url.path().ends_with('/') {
78            url.path_segments_mut()
79                .map_err(|_| ServerUrlError::MissingHost)?
80                .push("");
81        }
82
83        Ok(Self(url))
84    }
85
86    pub fn as_str(&self) -> &str {
87        self.0.as_str()
88    }
89
90    pub fn join(&self, path: &str) -> Result<Url, url::ParseError> {
91        self.0.join(path)
92    }
93
94    fn is_http_loopback(&self) -> bool {
95        self.0.scheme() == "http" && is_loopback(&self.0)
96    }
97
98    fn enforce_network_policy(&self, allow_insecure_http: bool) -> Result<(), ServerUrlError> {
99        if self.0.scheme() == "http" && !allow_insecure_http && !is_loopback(&self.0) {
100            return Err(ServerUrlError::InsecureHttp);
101        }
102        Ok(())
103    }
104}
105
106fn is_loopback(url: &Url) -> bool {
107    match url.host() {
108        Some(Host::Domain(domain)) => domain.eq_ignore_ascii_case("localhost"),
109        Some(Host::Ipv4(address)) => IpAddr::V4(address).is_loopback(),
110        Some(Host::Ipv6(address)) => IpAddr::V6(address).is_loopback(),
111        None => false,
112    }
113}
114
115#[derive(Debug, Error)]
116pub enum ServerUrlError {
117    #[error("the server URL is invalid: {0}")]
118    Invalid(#[source] url::ParseError),
119    #[error("unsupported server URL scheme `{0}`; use http or https")]
120    UnsupportedScheme(String),
121    #[error("the server URL must include a host")]
122    MissingHost,
123    #[error("the server URL must not contain a username or password")]
124    EmbeddedCredentials,
125    #[error("the server URL must not contain a query string")]
126    Query,
127    #[error("the server URL must not contain a fragment")]
128    Fragment,
129    #[error(
130        "plaintext HTTP is allowed only for localhost or loopback addresses; use HTTPS or pass --allow-insecure-http"
131    )]
132    InsecureHttp,
133}
134
135pub struct WekanClientFactory {
136    server: ServerUrl,
137    profile: Option<String>,
138    profile_store_namespace: Option<String>,
139    allow_insecure_http: bool,
140}
141
142impl WekanClientFactory {
143    pub fn for_server(server: ServerUrl, allow_insecure_http: bool) -> Self {
144        Self {
145            server,
146            profile: None,
147            profile_store_namespace: None,
148            allow_insecure_http,
149        }
150    }
151
152    pub fn for_resolved_profile(
153        server: ServerUrl,
154        profile: String,
155        profile_store_namespace: String,
156        allow_insecure_http: bool,
157    ) -> Self {
158        Self {
159            server,
160            profile: Some(profile),
161            profile_store_namespace: Some(profile_store_namespace),
162            allow_insecure_http,
163        }
164    }
165
166    #[cfg(test)]
167    pub(crate) fn new(server: Option<String>, allow_insecure_http: bool) -> Self {
168        let server = ServerUrl::parse(
169            server
170                .as_deref()
171                .expect("test client factories require a server identity"),
172        )
173        .expect("test client factories require a valid server identity");
174        Self::for_resolved_profile(
175            server,
176            "default".to_owned(),
177            "test-store".to_owned(),
178            allow_insecure_http,
179        )
180    }
181
182    #[cfg(test)]
183    pub(crate) fn for_profile(server: String, profile: String, allow_insecure_http: bool) -> Self {
184        let server = ServerUrl::parse(&server)
185            .expect("test client factories require a valid server identity");
186        Self::for_resolved_profile(
187            server,
188            profile,
189            "test-store".to_owned(),
190            allow_insecure_http,
191        )
192    }
193
194    pub fn profile(&self) -> Option<&str> {
195        self.profile.as_deref()
196    }
197
198    pub(crate) fn profile_store_namespace(&self) -> Option<&str> {
199        self.profile_store_namespace.as_deref()
200    }
201
202    pub fn create(&self) -> Result<WekanClient, WekanClientFactoryError> {
203        self.server
204            .enforce_network_policy(self.allow_insecure_http)?;
205        WekanClient::new(self.server.clone()).map_err(WekanClientFactoryError::Build)
206    }
207
208    pub(crate) const fn server_identity(&self) -> &ServerUrl {
209        &self.server
210    }
211}
212
213#[derive(Debug, Error)]
214pub enum WekanClientFactoryError {
215    #[error(transparent)]
216    ServerUrl(#[from] ServerUrlError),
217    #[error("the HTTP client could not be initialized")]
218    Build(#[source] ClientError),
219}
220
221#[derive(Clone, Debug)]
222pub struct WekanClient {
223    server: ServerUrl,
224    http: Client,
225}
226
227impl WekanClient {
228    fn new(server: ServerUrl) -> Result<Self, ClientError> {
229        Self::from_builder(server, Client::builder())
230    }
231
232    fn from_builder(
233        server: ServerUrl,
234        builder: reqwest::ClientBuilder,
235    ) -> Result<Self, ClientError> {
236        let mut builder = builder
237            .connect_timeout(CONNECT_TIMEOUT)
238            .timeout(REQUEST_TIMEOUT)
239            .redirect(Policy::none())
240            .retry(reqwest::retry::never())
241            .user_agent(concat!("wekan-cli/", env!("CARGO_PKG_VERSION")));
242        if server.is_http_loopback() {
243            builder = builder.no_proxy();
244        }
245        let http = builder.build().map_err(ClientError::Build)?;
246        Ok(Self { server, http })
247    }
248
249    pub fn server(&self) -> &ServerUrl {
250        &self.server
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use reqwest::Client;
257    use secrecy::SecretString;
258    use serde_json::json;
259    use wiremock::{
260        Mock, MockServer, ResponseTemplate,
261        matchers::{method, path},
262    };
263
264    use super::{
265        RegisterRequest, ServerUrl, ServerUrlError, WekanClient, WekanClientFactory,
266        WekanClientFactoryError,
267    };
268
269    #[test]
270    fn client_factory_defers_network_permission_until_creation() {
271        let server = ServerUrl::parse("http://wekan.example").unwrap();
272        let factory = WekanClientFactory::for_server(server.clone(), false);
273
274        assert_eq!(factory.server_identity(), &server);
275        assert!(matches!(
276            factory.create(),
277            Err(WekanClientFactoryError::ServerUrl(
278                ServerUrlError::InsecureHttp
279            ))
280        ));
281
282        assert!(
283            WekanClientFactory::for_server(server, true)
284                .create()
285                .is_ok()
286        );
287    }
288
289    #[test]
290    fn normalizes_server_paths_without_losing_subpaths() {
291        let server = ServerUrl::parse("https://example.com/wekan").unwrap();
292        assert_eq!(server.as_str(), "https://example.com/wekan/");
293        assert_eq!(
294            server.join("users/register").unwrap().as_str(),
295            "https://example.com/wekan/users/register"
296        );
297        assert_eq!(
298            server.join("users/login").unwrap().as_str(),
299            "https://example.com/wekan/users/login"
300        );
301        assert_eq!(
302            server.join("api/user").unwrap().as_str(),
303            "https://example.com/wekan/api/user"
304        );
305    }
306
307    #[test]
308    fn normalization_preserves_percent_encoded_path_segments() {
309        let server = ServerUrl::parse("https://example.com/tenant%20one/wekan").unwrap();
310        assert_eq!(server.as_str(), "https://example.com/tenant%20one/wekan/");
311    }
312
313    #[test]
314    fn client_factory_permits_http_for_loopback_hosts() {
315        for raw in [
316            "http://localhost:3000",
317            "http://127.0.0.1:3000",
318            "http://[::1]:3000",
319        ] {
320            let server = ServerUrl::parse(raw).unwrap();
321            assert!(
322                WekanClientFactory::for_server(server, false)
323                    .create()
324                    .is_ok()
325            );
326        }
327    }
328
329    #[test]
330    fn rejects_url_components_that_make_credentials_or_keys_ambiguous() {
331        assert!(matches!(
332            ServerUrl::parse("https://alice:secret@example.com"),
333            Err(ServerUrlError::EmbeddedCredentials)
334        ));
335        assert!(matches!(
336            ServerUrl::parse("https://example.com?tenant=one"),
337            Err(ServerUrlError::Query)
338        ));
339        assert!(matches!(
340            ServerUrl::parse("https://example.com#fragment"),
341            Err(ServerUrlError::Fragment)
342        ));
343    }
344
345    #[tokio::test]
346    async fn loopback_http_bypasses_a_configured_proxy() {
347        let destination = MockServer::start().await;
348        let proxy = MockServer::start().await;
349        Mock::given(method("POST"))
350            .and(path("/users/register"))
351            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
352                "id": "user-1",
353                "token": "server-token",
354                "tokenExpires": "2030-01-02T03:04:05Z"
355            })))
356            .expect(1)
357            .mount(&destination)
358            .await;
359        let server = ServerUrl::parse(&destination.uri()).unwrap();
360        let builder = Client::builder().proxy(reqwest::Proxy::all(proxy.uri()).unwrap());
361        let client = WekanClient::from_builder(server, builder).unwrap();
362
363        client
364            .register(&RegisterRequest {
365                username: Some("alice".to_owned()),
366                email: None,
367                password: SecretString::from("test-password".to_owned()),
368            })
369            .await
370            .expect("the loopback request must go directly to its destination");
371
372        destination.verify().await;
373        assert!(proxy.received_requests().await.unwrap().is_empty());
374    }
375}