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