Skip to main content

wekan_cli/
client.rs

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