Skip to main content

wekan_cli/
client.rs

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