Skip to main content

wekan_cli/
client.rs

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