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 diagnostic: None,
222 message: format!("could not build the swimlane endpoint: {error}"),
223 success_status_received: false,
224 })?;
225 let mut segments = endpoint
226 .path_segments_mut()
227 .map_err(|()| ClientError::Protocol {
228 diagnostic: None,
229 message: "could not add identifiers to the swimlane endpoint".to_owned(),
230 success_status_received: false,
231 })?;
232 segments.push(board_id);
233 segments.push("swimlanes");
234 if let Some(swimlane_id) = swimlane_id {
235 segments.push(swimlane_id);
236 }
237 drop(segments);
238 Ok(endpoint)
239 }
240}
241
242pub fn is_valid_swimlane_color(value: &str) -> bool {
243 SWIMLANE_COLORS.contains(&value)
244 || value.len() == 7
245 && value.starts_with('#')
246 && value[1..].bytes().all(|byte| byte.is_ascii_hexdigit())
247}
248
249fn validate_swimlane_summaries(
250 swimlanes: Vec<SwimlaneSummary>,
251) -> Result<Vec<SwimlaneSummary>, ClientError> {
252 for swimlane in &swimlanes {
253 require_non_empty(
254 &swimlane.swimlane_id,
255 "board swimlane collection",
256 "swimlane id",
257 )?;
258 require_non_empty(&swimlane.title, "board swimlane collection", "title")?;
259 }
260 Ok(swimlanes)
261}
262
263fn validate_swimlane_document(swimlane: SwimlaneDocument) -> Result<SwimlaneDocument, ClientError> {
264 require_non_empty(&swimlane.swimlane_id, "swimlane", "swimlane id")?;
265 require_non_empty(&swimlane.title, "swimlane", "title")?;
266 require_non_empty(&swimlane.board_id, "swimlane", "board id")?;
267 require_non_empty(&swimlane.swimlane_type, "swimlane", "type")?;
268 validate_date_time(&swimlane.created_at, "createdAt", "swimlane")?;
269 validate_date_time(&swimlane.modified_at, "modifiedAt", "swimlane")?;
270 for (field, value) in [
271 ("archivedAt", swimlane.archived_at.as_deref()),
272 ("updatedAt", swimlane.updated_at.as_deref()),
273 ] {
274 validate_optional_date_time(value, field, "swimlane")?;
275 }
276 if let Some(color) = &swimlane.color
277 && !color.is_empty()
278 && !is_valid_swimlane_color(color)
279 {
280 return Err(protocol_error(format!(
281 "the swimlane response contained an invalid color `{color}`"
282 )));
283 }
284 if let Some(height) = &swimlane.height {
285 let valid = height
286 .as_f64()
287 .is_some_and(|height| height == -1.0 || (50.0..=2000.0).contains(&height));
288 if !valid {
289 return Err(protocol_error(
290 "the swimlane response contained a height outside -1 or 50 through 2000".to_owned(),
291 ));
292 }
293 }
294 Ok(swimlane)
295}
296
297fn require_non_empty(value: &str, operation: &str, field: &str) -> Result<(), ClientError> {
298 if value.is_empty() {
299 return Err(protocol_error(format!(
300 "the {operation} response contained an empty {field}"
301 )));
302 }
303 Ok(())
304}
305
306fn validate_optional_date_time(
307 value: Option<&str>,
308 field: &str,
309 operation: &str,
310) -> Result<(), ClientError> {
311 if let Some(value) = value {
312 validate_date_time(value, field, operation)?;
313 }
314 Ok(())
315}
316
317fn validate_date_time(value: &str, field: &str, operation: &str) -> Result<(), ClientError> {
318 OffsetDateTime::parse(value, &Rfc3339)
319 .map(|_| ())
320 .map_err(|_| {
321 protocol_error(format!(
322 "the {operation} response contained an invalid {field} date-time"
323 ))
324 })
325}
326
327fn protocol_error(message: String) -> ClientError {
328 ClientError::Protocol {
329 diagnostic: None,
330 message,
331 success_status_received: true,
332 }
333}
334
335#[cfg(test)]
336mod tests {
337 use serde_json::json;
338
339 use super::{SwimlaneDocument, SwimlaneSummary, is_valid_swimlane_color};
340
341 #[test]
342 fn color_validation_matches_wekan_v11_06() {
343 for value in ["white", "silver", "indigo", "#12aBcF"] {
344 assert!(is_valid_swimlane_color(value));
345 }
346 for value in ["", "belize", "#12345", "#12345g", "123456"] {
347 assert!(!is_valid_swimlane_color(value));
348 }
349 }
350
351 #[test]
352 fn response_objects_reject_unmapped_fields() {
353 assert!(
354 serde_json::from_value::<SwimlaneSummary>(json!({
355 "_id": "swimlane-1",
356 "title": "Delivery",
357 "unexpected": true
358 }))
359 .is_err()
360 );
361 assert!(
362 serde_json::from_value::<SwimlaneDocument>(json!({
363 "_id": "swimlane-1",
364 "title": "Delivery",
365 "archived": false,
366 "boardId": "board-1",
367 "createdAt": "2030-01-02T03:04:05Z",
368 "modifiedAt": "2030-01-02T03:04:05Z",
369 "type": "swimlane",
370 "unexpected": true
371 }))
372 .is_err()
373 );
374 }
375}