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