Skip to main content

yt/api/
articles.rs

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
66/// Articles matching a YouTrack query, most recently updated first (YouTrack's order).
67pub 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
93/// Creates an article and returns its readable ID.
94pub 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
115/// The fields of `POST articles/{id}`; `parent: Some(None)` moves the article to the top level.
116pub 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}