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