1use futures::Stream;
2use serde::Deserialize;
3use serde_json::{Map, Value, json};
4
5use crate::api::issues::{ProjectRef, UserRef};
6use crate::api::{Client, Projection};
7use crate::error::Result;
8
9#[derive(Clone, Debug, Deserialize)]
10#[serde(rename_all = "camelCase")]
11pub struct ArticleRef {
12 pub id_readable: String,
13}
14
15#[derive(Clone, Debug, Deserialize)]
16#[serde(rename_all = "camelCase")]
17pub struct ArticleRow {
18 pub id_readable: String,
19 pub summary: Option<String>,
20 pub project: Option<ProjectRef>,
21 pub parent_article: Option<ArticleRef>,
22 #[serde(default)]
23 pub child_articles: Vec<ArticleRef>,
24 #[serde(default)]
25 pub updated: i64,
26}
27
28impl Projection for ArticleRow {
29 const FIELDS: &'static str = "idReadable,summary,project(shortName,name),parentArticle(idReadable),childArticles(idReadable),updated";
30}
31
32#[derive(Clone, Debug, Deserialize)]
33#[serde(rename_all = "camelCase")]
34pub struct Article {
35 pub id_readable: String,
36 pub summary: Option<String>,
37 pub project: Option<ProjectRef>,
38 pub parent_article: Option<ArticleRef>,
39 #[serde(default)]
40 pub child_articles: Vec<ArticleRef>,
41 #[serde(default)]
42 pub updated: i64,
43 pub content: Option<String>,
44 pub reporter: Option<UserRef>,
45 #[serde(default)]
46 pub created: i64,
47}
48
49impl Projection for Article {
50 const FIELDS: &'static str = "idReadable,summary,project(shortName,name),parentArticle(idReadable),childArticles(idReadable),updated,content,reporter(login,fullName),created";
51}
52
53impl Article {
54 pub fn row(&self) -> ArticleRow {
55 ArticleRow {
56 id_readable: self.id_readable.clone(),
57 summary: self.summary.clone(),
58 project: self.project.clone(),
59 parent_article: self.parent_article.clone(),
60 child_articles: self.child_articles.clone(),
61 updated: self.updated,
62 }
63 }
64}
65
66pub fn search<'a>(
68 client: &'a Client,
69 query: &str,
70 page_size: usize,
71) -> impl Stream<Item = Result<ArticleRow>> + Send + 'a {
72 let mut parameters = Vec::new();
73 if !query.is_empty() {
74 parameters.push(("query".to_string(), query.to_string()));
75 }
76 client.paginate("articles".to_string(), parameters, page_size)
77}
78
79pub async fn get(client: &Client, id: &str) -> Result<Article> {
80 client.get(&format!("articles/{id}"), &[]).await
81}
82
83#[derive(Deserialize)]
84#[serde(rename_all = "camelCase")]
85struct Created {
86 id_readable: String,
87}
88
89impl Projection for Created {
90 const FIELDS: &'static str = "idReadable";
91}
92
93pub async fn create(
95 client: &Client,
96 project: &str,
97 summary: &str,
98 content: Option<&str>,
99 parent: Option<&str>,
100) -> Result<String> {
101 let mut body = json!({
102 "project": {"shortName": project},
103 "summary": summary,
104 });
105 if let Some(content) = content {
106 body["content"] = json!(content);
107 }
108 if let Some(parent) = parent {
109 body["parentArticle"] = json!({"idReadable": parent});
110 }
111 let created: Created = client.post("articles", &body).await?;
112 Ok(created.id_readable)
113}
114
115pub fn update_body(
117 summary: Option<&str>,
118 content: Option<&str>,
119 parent: Option<Option<&str>>,
120) -> Value {
121 let mut body = Map::new();
122 if let Some(summary) = summary {
123 body.insert("summary".into(), json!(summary));
124 }
125 if let Some(content) = content {
126 body.insert("content".into(), json!(content));
127 }
128 match parent {
129 Some(Some(parent)) => {
130 body.insert("parentArticle".into(), json!({"idReadable": parent}));
131 }
132 Some(None) => {
133 body.insert("parentArticle".into(), Value::Null);
134 }
135 None => {}
136 }
137 Value::Object(body)
138}
139
140pub async fn update(client: &Client, id: &str, body: &Value) -> Result<()> {
141 client.post_unit(&format!("articles/{id}"), body).await
142}
143
144pub async fn delete(client: &Client, id: &str) -> Result<()> {
145 client.delete(&format!("articles/{id}")).await
146}
147
148#[cfg(test)]
149mod tests {
150 use futures::TryStreamExt as _;
151 use serde_json::json;
152 use wiremock::matchers::{body_json, method, path, query_param};
153 use wiremock::{Mock, MockServer, ResponseTemplate};
154
155 use super::*;
156 use crate::api::test_client;
157
158 #[test]
159 fn update_bodies_carry_only_what_changes() {
160 assert_eq!(
161 update_body(Some("Title"), None, None),
162 json!({"summary": "Title"})
163 );
164 assert_eq!(
165 update_body(None, Some("Text"), Some(None)),
166 json!({"content": "Text", "parentArticle": null})
167 );
168 assert_eq!(
169 update_body(None, None, Some(Some("KB-A-1"))),
170 json!({"parentArticle": {"idReadable": "KB-A-1"}})
171 );
172 }
173
174 #[tokio::test]
175 async fn articles_are_searched_created_read_updated_and_deleted() {
176 let server = MockServer::start().await;
177 Mock::given(method("GET"))
178 .and(path("/api/articles"))
179 .and(query_param("query", "project: {KB}"))
180 .respond_with(ResponseTemplate::new(200).set_body_json(json!([
181 {"idReadable": "KB-A-2", "summary": "Child", "project": {"shortName": "KB", "name": "Knowledge"}, "parentArticle": {"idReadable": "KB-A-1"}, "childArticles": [], "updated": 2}
182 ])))
183 .mount(&server)
184 .await;
185 Mock::given(method("POST"))
186 .and(path("/api/articles"))
187 .and(body_json(json!({
188 "project": {"shortName": "KB"},
189 "summary": "Child",
190 "content": "Text",
191 "parentArticle": {"idReadable": "KB-A-1"}
192 })))
193 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"idReadable": "KB-A-2"})))
194 .expect(1)
195 .mount(&server)
196 .await;
197 Mock::given(method("GET"))
198 .and(path("/api/articles/KB-A-2"))
199 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
200 "idReadable": "KB-A-2", "summary": "Child", "project": {"shortName": "KB", "name": "Knowledge"},
201 "parentArticle": {"idReadable": "KB-A-1"}, "childArticles": [], "updated": 2,
202 "content": "Text", "reporter": {"login": "jane", "fullName": "Jane"}, "created": 1
203 })))
204 .mount(&server)
205 .await;
206 Mock::given(method("POST"))
207 .and(path("/api/articles/KB-A-2"))
208 .and(body_json(json!({"summary": "Renamed"})))
209 .respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
210 .expect(1)
211 .mount(&server)
212 .await;
213 Mock::given(method("DELETE"))
214 .and(path("/api/articles/KB-A-2"))
215 .respond_with(ResponseTemplate::new(200))
216 .expect(1)
217 .mount(&server)
218 .await;
219 let client = test_client(&server.uri());
220 let rows: Vec<ArticleRow> = search(&client, "project: {KB}", 50)
221 .try_collect()
222 .await
223 .unwrap();
224 assert_eq!(
225 rows[0].parent_article.as_ref().unwrap().id_readable,
226 "KB-A-1"
227 );
228 let id = create(&client, "KB", "Child", Some("Text"), Some("KB-A-1"))
229 .await
230 .unwrap();
231 assert_eq!(id, "KB-A-2");
232 let article = get(&client, "KB-A-2").await.unwrap();
233 assert_eq!(article.content.as_deref(), Some("Text"));
234 assert_eq!(article.row().id_readable, "KB-A-2");
235 update(&client, "KB-A-2", &update_body(Some("Renamed"), None, None))
236 .await
237 .unwrap();
238 delete(&client, "KB-A-2").await.unwrap();
239 }
240}