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