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