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