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