1use reqwest::{StatusCode, header::ACCEPT};
2use secrecy::{ExposeSecret, SecretString};
3use serde::{Deserialize, Serialize};
4use serde_json::Number;
5use time::{OffsetDateTime, format_description::well_known::Rfc3339};
6
7use super::{ClientError, WekanClient, transport::decode_success};
8
9mod transfer;
10pub use transfer::TransferSwimlaneRequest;
11
12pub const SWIMLANE_COLORS: &[&str] = &[
13 "white",
14 "green",
15 "yellow",
16 "orange",
17 "red",
18 "purple",
19 "blue",
20 "sky",
21 "lime",
22 "pink",
23 "black",
24 "silver",
25 "peachpuff",
26 "crimson",
27 "plum",
28 "darkgreen",
29 "slateblue",
30 "magenta",
31 "gold",
32 "navy",
33 "gray",
34 "saddlebrown",
35 "paleturquoise",
36 "mistyrose",
37 "indigo",
38];
39
40#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
41#[serde(deny_unknown_fields, rename_all = "camelCase")]
42pub struct SwimlaneSummary {
43 #[serde(rename = "_id")]
44 pub swimlane_id: String,
45 pub title: String,
46}
47
48#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
49#[serde(
50 deny_unknown_fields,
51 rename_all(deserialize = "camelCase", serialize = "snake_case")
52)]
53pub struct SwimlaneDocument {
54 #[serde(rename(deserialize = "_id", serialize = "swimlane_id"))]
55 pub swimlane_id: String,
56 pub title: String,
57 pub archived: bool,
58 pub archived_at: Option<String>,
59 pub board_id: String,
60 pub created_at: String,
61 pub sort: Option<Number>,
62 pub color: Option<String>,
63 pub updated_at: Option<String>,
64 pub modified_at: String,
65 #[serde(rename(deserialize = "type", serialize = "swimlane_type"))]
66 pub swimlane_type: String,
67 pub height: Option<Number>,
68}
69
70#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
71#[serde(rename_all = "camelCase")]
72pub struct CreateSwimlaneRequest {
73 pub title: String,
74 #[serde(skip_serializing_if = "Option::is_none")]
75 pub sort: Option<Number>,
76}
77
78#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
79pub struct UpdateSwimlaneRequest {
80 pub title: String,
81}
82
83#[derive(Clone, Debug, Eq, PartialEq)]
84pub struct CreateSwimlaneResult {
85 pub swimlane_id: String,
86}
87
88#[derive(Clone, Debug, Eq, PartialEq)]
89pub struct UpdateSwimlaneResult {
90 pub swimlane_id: String,
91}
92
93#[derive(Clone, Debug, Eq, PartialEq)]
94pub struct DeleteSwimlaneResult {
95 pub swimlane_id: String,
96}
97
98#[derive(Deserialize)]
99#[serde(deny_unknown_fields)]
100struct IdResponse {
101 #[serde(rename = "_id")]
102 id: String,
103}
104
105impl WekanClient {
106 pub async fn board_swimlanes(
107 &self,
108 board_id: &str,
109 token: &SecretString,
110 ) -> Result<Vec<SwimlaneSummary>, ClientError> {
111 let body = self
112 .execute_success_request(
113 self.http
114 .get(self.swimlane_endpoint(board_id, None)?)
115 .header(ACCEPT, "application/json")
116 .bearer_auth(token.expose_secret()),
117 )
118 .await?;
119 let swimlanes = decode_success(&body, "board swimlane collection", true)?;
120 validate_swimlane_summaries(swimlanes)
121 }
122
123 pub async fn swimlane(
124 &self,
125 board_id: &str,
126 swimlane_id: &str,
127 token: &SecretString,
128 ) -> Result<SwimlaneDocument, ClientError> {
129 let body = self
130 .execute_success_request(
131 self.http
132 .get(self.swimlane_endpoint(board_id, Some(swimlane_id))?)
133 .header(ACCEPT, "application/json")
134 .bearer_auth(token.expose_secret()),
135 )
136 .await?;
137 if body.is_empty() {
138 return Err(ClientError::EmbeddedServer {
139 http_status: StatusCode::OK,
140 wekan_status_code: StatusCode::NOT_FOUND.as_u16(),
141 server_error: Some("swimlane-not-found".to_owned()),
142 server_reason: Some("Swimlane not found".to_owned()),
143 });
144 }
145 let swimlane = decode_success(&body, "swimlane", true)?;
146 validate_swimlane_document(swimlane)
147 }
148
149 pub async fn create_swimlane(
150 &self,
151 board_id: &str,
152 request: &CreateSwimlaneRequest,
153 token: &SecretString,
154 ) -> Result<CreateSwimlaneResult, ClientError> {
155 let body = self
156 .execute_success_request(
157 self.http
158 .post(self.swimlane_endpoint(board_id, None)?)
159 .header(ACCEPT, "application/json")
160 .bearer_auth(token.expose_secret())
161 .json(request),
162 )
163 .await?;
164 let response: IdResponse = decode_success(&body, "swimlane creation", true)?;
165 require_non_empty(&response.id, "swimlane creation", "swimlane id")?;
166 Ok(CreateSwimlaneResult {
167 swimlane_id: response.id,
168 })
169 }
170
171 pub async fn update_swimlane(
172 &self,
173 board_id: &str,
174 swimlane_id: &str,
175 request: &UpdateSwimlaneRequest,
176 token: &SecretString,
177 ) -> Result<UpdateSwimlaneResult, ClientError> {
178 let body = self
179 .execute_success_request(
180 self.http
181 .put(self.swimlane_endpoint(board_id, Some(swimlane_id))?)
182 .header(ACCEPT, "application/json")
183 .bearer_auth(token.expose_secret())
184 .json(request),
185 )
186 .await?;
187 let response: IdResponse = decode_success(&body, "swimlane update", true)?;
188 require_non_empty(&response.id, "swimlane update", "swimlane id")?;
189 Ok(UpdateSwimlaneResult {
190 swimlane_id: response.id,
191 })
192 }
193
194 pub async fn delete_swimlane(
195 &self,
196 board_id: &str,
197 swimlane_id: &str,
198 token: &SecretString,
199 ) -> Result<DeleteSwimlaneResult, ClientError> {
200 let body = self
201 .execute_success_request(
202 self.http
203 .delete(self.swimlane_endpoint(board_id, Some(swimlane_id))?)
204 .header(ACCEPT, "application/json")
205 .bearer_auth(token.expose_secret()),
206 )
207 .await?;
208 let response: IdResponse = decode_success(&body, "swimlane deletion", true)?;
209 require_non_empty(&response.id, "swimlane deletion", "swimlane id")?;
210 Ok(DeleteSwimlaneResult {
211 swimlane_id: response.id,
212 })
213 }
214
215 pub(super) fn swimlane_endpoint(
216 &self,
217 board_id: &str,
218 swimlane_id: Option<&str>,
219 ) -> Result<reqwest::Url, ClientError> {
220 let mut endpoint =
221 self.server()
222 .join("api/boards")
223 .map_err(|error| ClientError::Protocol {
224 diagnostic: None,
225 message: format!("could not build the swimlane endpoint: {error}"),
226 success_status_received: false,
227 })?;
228 let mut segments = endpoint
229 .path_segments_mut()
230 .map_err(|()| ClientError::Protocol {
231 diagnostic: None,
232 message: "could not add identifiers to the swimlane endpoint".to_owned(),
233 success_status_received: false,
234 })?;
235 segments.push(board_id);
236 segments.push("swimlanes");
237 if let Some(swimlane_id) = swimlane_id {
238 segments.push(swimlane_id);
239 }
240 drop(segments);
241 Ok(endpoint)
242 }
243}
244
245pub fn is_valid_swimlane_color(value: &str) -> bool {
246 SWIMLANE_COLORS.contains(&value)
247 || value.len() == 7
248 && value.starts_with('#')
249 && value[1..].bytes().all(|byte| byte.is_ascii_hexdigit())
250}
251
252fn validate_swimlane_summaries(
253 swimlanes: Vec<SwimlaneSummary>,
254) -> Result<Vec<SwimlaneSummary>, ClientError> {
255 for swimlane in &swimlanes {
256 require_non_empty(
257 &swimlane.swimlane_id,
258 "board swimlane collection",
259 "swimlane id",
260 )?;
261 require_non_empty(&swimlane.title, "board swimlane collection", "title")?;
262 }
263 Ok(swimlanes)
264}
265
266fn validate_swimlane_document(swimlane: SwimlaneDocument) -> Result<SwimlaneDocument, ClientError> {
267 require_non_empty(&swimlane.swimlane_id, "swimlane", "swimlane id")?;
268 require_non_empty(&swimlane.title, "swimlane", "title")?;
269 require_non_empty(&swimlane.board_id, "swimlane", "board id")?;
270 require_non_empty(&swimlane.swimlane_type, "swimlane", "type")?;
271 validate_date_time(&swimlane.created_at, "createdAt", "swimlane")?;
272 validate_date_time(&swimlane.modified_at, "modifiedAt", "swimlane")?;
273 for (field, value) in [
274 ("archivedAt", swimlane.archived_at.as_deref()),
275 ("updatedAt", swimlane.updated_at.as_deref()),
276 ] {
277 validate_optional_date_time(value, field, "swimlane")?;
278 }
279 if let Some(color) = &swimlane.color
280 && !color.is_empty()
281 && !is_valid_swimlane_color(color)
282 {
283 return Err(protocol_error(format!(
284 "the swimlane response contained an invalid color `{color}`"
285 )));
286 }
287 if let Some(height) = &swimlane.height {
288 let valid = height
289 .as_f64()
290 .is_some_and(|height| height == -1.0 || (50.0..=2000.0).contains(&height));
291 if !valid {
292 return Err(protocol_error(
293 "the swimlane response contained a height outside -1 or 50 through 2000".to_owned(),
294 ));
295 }
296 }
297 Ok(swimlane)
298}
299
300fn require_non_empty(value: &str, operation: &str, field: &str) -> Result<(), ClientError> {
301 if value.is_empty() {
302 return Err(protocol_error(format!(
303 "the {operation} response contained an empty {field}"
304 )));
305 }
306 Ok(())
307}
308
309fn validate_optional_date_time(
310 value: Option<&str>,
311 field: &str,
312 operation: &str,
313) -> Result<(), ClientError> {
314 if let Some(value) = value {
315 validate_date_time(value, field, operation)?;
316 }
317 Ok(())
318}
319
320fn validate_date_time(value: &str, field: &str, operation: &str) -> Result<(), ClientError> {
321 OffsetDateTime::parse(value, &Rfc3339)
322 .map(|_| ())
323 .map_err(|_| {
324 protocol_error(format!(
325 "the {operation} response contained an invalid {field} date-time"
326 ))
327 })
328}
329
330fn protocol_error(message: String) -> ClientError {
331 ClientError::Protocol {
332 diagnostic: None,
333 message,
334 success_status_received: true,
335 }
336}
337
338#[cfg(test)]
339mod tests {
340 use serde_json::json;
341
342 use super::{SwimlaneDocument, SwimlaneSummary, is_valid_swimlane_color};
343
344 #[test]
345 fn color_validation_matches_wekan_v11_06() {
346 for value in ["white", "silver", "indigo", "#12aBcF"] {
347 assert!(is_valid_swimlane_color(value));
348 }
349 for value in ["", "belize", "#12345", "#12345g", "123456"] {
350 assert!(!is_valid_swimlane_color(value));
351 }
352 }
353
354 #[test]
355 fn response_objects_reject_unmapped_fields() {
356 assert!(
357 serde_json::from_value::<SwimlaneSummary>(json!({
358 "_id": "swimlane-1",
359 "title": "Delivery",
360 "unexpected": true
361 }))
362 .is_err()
363 );
364 assert!(
365 serde_json::from_value::<SwimlaneDocument>(json!({
366 "_id": "swimlane-1",
367 "title": "Delivery",
368 "archived": false,
369 "boardId": "board-1",
370 "createdAt": "2030-01-02T03:04:05Z",
371 "modifiedAt": "2030-01-02T03:04:05Z",
372 "type": "swimlane",
373 "unexpected": true
374 }))
375 .is_err()
376 );
377 }
378}