redmine_api/api/
news.rs

1//! News Rest API Endpoint definitions
2//!
3//! [Redmine Documentation](https://www.redmine.org/projects/redmine/wiki/Rest_News)
4//!
5//! - [x] all news endpoint
6//! - [x] project news endpoint
7//!
8use derive_builder::Builder;
9use reqwest::Method;
10use std::borrow::Cow;
11
12use crate::api::projects::ProjectEssentials;
13use crate::api::users::UserEssentials;
14use crate::api::{Endpoint, Pageable, ReturnsJsonResponse};
15
16/// a type for news to use as an API return type
17///
18/// alternatively you can use your own type limited to the fields you need
19#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
20pub struct News {
21    /// numeric id
22    pub id: u64,
23    /// the project the news was published in
24    pub project: ProjectEssentials,
25    /// the author of the news
26    pub author: UserEssentials,
27    /// the title of the news
28    pub title: String,
29    /// the summary of the news
30    pub summary: String,
31    /// the description of the news (body)
32    pub description: String,
33    /// The time when this project was created
34    #[serde(
35        serialize_with = "crate::api::serialize_rfc3339",
36        deserialize_with = "crate::api::deserialize_rfc3339"
37    )]
38    pub created_on: time::OffsetDateTime,
39}
40/// The endpoint for all news
41#[derive(Debug, Clone, Builder)]
42#[builder(setter(strip_option))]
43pub struct ListNews {}
44
45impl ReturnsJsonResponse for ListNews {}
46impl Pageable for ListNews {
47    fn response_wrapper_key(&self) -> String {
48        "news".to_string()
49    }
50}
51
52impl ListNews {
53    /// Create a builder for the endpoint.
54    #[must_use]
55    pub fn builder() -> ListNewsBuilder {
56        ListNewsBuilder::default()
57    }
58}
59
60impl Endpoint for ListNews {
61    fn method(&self) -> Method {
62        Method::GET
63    }
64
65    fn endpoint(&self) -> Cow<'static, str> {
66        "news.json".into()
67    }
68}
69
70/// The endpoint for project news
71#[derive(Debug, Clone, Builder)]
72#[builder(setter(strip_option))]
73pub struct ListProjectNews<'a> {
74    /// project id or name as it appears in the URL
75    #[builder(setter(into))]
76    project_id_or_name: Cow<'a, str>,
77}
78
79impl ReturnsJsonResponse for ListProjectNews<'_> {}
80impl Pageable for ListProjectNews<'_> {
81    fn response_wrapper_key(&self) -> String {
82        "news".to_string()
83    }
84}
85
86impl<'a> ListProjectNews<'a> {
87    /// Create a builder for the endpoint.
88    #[must_use]
89    pub fn builder() -> ListProjectNewsBuilder<'a> {
90        ListProjectNewsBuilder::default()
91    }
92}
93
94impl Endpoint for ListProjectNews<'_> {
95    fn method(&self) -> Method {
96        Method::GET
97    }
98
99    fn endpoint(&self) -> Cow<'static, str> {
100        format!("projects/{}/news.json", self.project_id_or_name).into()
101    }
102}
103
104/// The endpoint for a specific news item
105#[derive(Debug, Clone, Builder)]
106#[builder(setter(strip_option))]
107pub struct GetNews {
108    /// the id of the news item to retrieve
109    id: u64,
110}
111
112impl ReturnsJsonResponse for GetNews {}
113impl crate::api::NoPagination for GetNews {}
114
115impl GetNews {
116    /// Create a builder for the endpoint.
117    #[must_use]
118    pub fn builder() -> GetNewsBuilder {
119        GetNewsBuilder::default()
120    }
121}
122
123impl Endpoint for GetNews {
124    fn method(&self) -> Method {
125        Method::GET
126    }
127
128    fn endpoint(&self) -> Cow<'static, str> {
129        format!("news/{}.json", self.id).into()
130    }
131}
132
133/// The endpoint to create a Redmine news item
134#[derive(Debug, Clone, Builder, serde::Serialize)]
135#[builder(setter(strip_option))]
136pub struct CreateNews<'a> {
137    /// project id or name as it appears in the URL
138    #[builder(setter(into))]
139    #[serde(skip_serializing)]
140    project_id_or_name: Cow<'a, str>,
141    /// the title of the news
142    #[builder(setter(into))]
143    title: Cow<'a, str>,
144    /// the summary of the news
145    #[builder(setter(into), default)]
146    summary: Option<Cow<'a, str>>,
147    /// the description of the news (body)
148    #[builder(setter(into))]
149    description: Cow<'a, str>,
150}
151
152impl<'a> CreateNews<'a> {
153    /// Create a builder for the endpoint.
154    #[must_use]
155    pub fn builder() -> CreateNewsBuilder<'a> {
156        CreateNewsBuilder::default()
157    }
158}
159
160impl crate::api::NoPagination for CreateNews<'_> {}
161
162impl Endpoint for CreateNews<'_> {
163    fn method(&self) -> Method {
164        Method::POST
165    }
166
167    fn endpoint(&self) -> Cow<'static, str> {
168        format!("projects/{}/news.json", self.project_id_or_name).into()
169    }
170
171    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
172        Ok(Some((
173            "application/json",
174            serde_json::to_vec(&SingleNewsWrapper::<CreateNews> {
175                news: (*self).to_owned(),
176            })?,
177        )))
178    }
179}
180
181/// The endpoint to update a Redmine news item
182#[derive(Debug, Clone, Builder, serde::Serialize)]
183#[builder(setter(strip_option))]
184pub struct UpdateNews<'a> {
185    /// the id of the news item to update
186    #[serde(skip_serializing)]
187    id: u64,
188    /// the title of the news
189    #[builder(setter(into), default)]
190    #[serde(skip_serializing_if = "Option::is_none")]
191    title: Option<Cow<'a, str>>,
192    /// the summary of the news
193    #[builder(setter(into), default)]
194    #[serde(skip_serializing_if = "Option::is_none")]
195    summary: Option<Cow<'a, str>>,
196    /// the description of the news (body)
197    #[builder(setter(into), default)]
198    #[serde(skip_serializing_if = "Option::is_none")]
199    description: Option<Cow<'a, str>>,
200}
201
202impl<'a> UpdateNews<'a> {
203    /// Create a builder for the endpoint.
204    #[must_use]
205    pub fn builder() -> UpdateNewsBuilder<'a> {
206        UpdateNewsBuilder::default()
207    }
208}
209
210impl Endpoint for UpdateNews<'_> {
211    fn method(&self) -> Method {
212        Method::PUT
213    }
214
215    fn endpoint(&self) -> Cow<'static, str> {
216        format!("news/{}.json", self.id).into()
217    }
218
219    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
220        Ok(Some((
221            "application/json",
222            serde_json::to_vec(&SingleNewsWrapper::<UpdateNews> {
223                news: (*self).to_owned(),
224            })?,
225        )))
226    }
227}
228
229/// The endpoint to delete a Redmine news item
230#[derive(Debug, Clone, Builder)]
231#[builder(setter(strip_option))]
232pub struct DeleteNews {
233    /// the id of the news item to delete
234    id: u64,
235}
236
237impl DeleteNews {
238    /// Create a builder for the endpoint.
239    #[must_use]
240    pub fn builder() -> DeleteNewsBuilder {
241        DeleteNewsBuilder::default()
242    }
243}
244
245impl Endpoint for DeleteNews {
246    fn method(&self) -> Method {
247        Method::DELETE
248    }
249
250    fn endpoint(&self) -> Cow<'static, str> {
251        format!("news/{}.json", self.id).into()
252    }
253}
254
255/// helper struct for outer layers with a news field holding the inner data
256#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
257pub struct NewsWrapper<T> {
258    /// to parse JSON with news key
259    pub news: Vec<T>,
260}
261
262/// A lot of APIs in Redmine wrap their data in an extra layer, this is a
263/// helper struct for outer layers with a news field holding the inner data
264#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
265pub struct SingleNewsWrapper<T> {
266    /// to parse JSON with a news key
267    pub news: T,
268}
269
270#[cfg(test)]
271mod test {
272    use super::*;
273    use pretty_assertions::assert_eq;
274    use std::error::Error;
275    use tracing_test::traced_test;
276
277    #[traced_test]
278    #[test]
279    fn test_list_news_first_page() -> Result<(), Box<dyn Error>> {
280        dotenvy::dotenv()?;
281        let redmine = crate::api::Redmine::from_env(
282            reqwest::blocking::Client::builder()
283                .use_rustls_tls()
284                .build()?,
285        )?;
286        let endpoint = ListNews::builder().build()?;
287        redmine.json_response_body_page::<_, News>(&endpoint, 0, 25)?;
288        Ok(())
289    }
290
291    #[traced_test]
292    #[test]
293    fn test_list_news_all_pages() -> Result<(), Box<dyn Error>> {
294        dotenvy::dotenv()?;
295        let redmine = crate::api::Redmine::from_env(
296            reqwest::blocking::Client::builder()
297                .use_rustls_tls()
298                .build()?,
299        )?;
300        let endpoint = ListNews::builder().build()?;
301        redmine.json_response_body_all_pages::<_, News>(&endpoint)?;
302        Ok(())
303    }
304
305    #[traced_test]
306    #[test]
307    fn test_get_update_delete_news() -> Result<(), Box<dyn Error>> {
308        crate::api::test_helpers::with_project("test_get_update_delete_news", |redmine, _, name| {
309            let create_endpoint = CreateNews::builder()
310                .project_id_or_name(name)
311                .title("Test News")
312                .summary("Test Summary")
313                .description("Test Description")
314                .build()?;
315            redmine.ignore_response_body(&create_endpoint)?;
316            let list_endpoint = ListProjectNews::builder()
317                .project_id_or_name(name)
318                .build()?;
319            let news: Vec<News> = redmine.json_response_body_all_pages(&list_endpoint)?;
320            let created_news = news
321                .into_iter()
322                .find(|n| n.title == "Test News")
323                .ok_or("Could not find created news")?;
324            let get_endpoint = GetNews::builder().id(created_news.id).build()?;
325            let fetched_news: SingleNewsWrapper<News> =
326                redmine.json_response_body(&get_endpoint)?;
327            assert_eq!(created_news, fetched_news.news);
328            let update_endpoint = UpdateNews::builder()
329                .id(created_news.id)
330                .title("New Test News")
331                .build()?;
332            redmine.ignore_response_body(&update_endpoint)?;
333            let delete_endpoint = DeleteNews::builder().id(created_news.id).build()?;
334            redmine.ignore_response_body(&delete_endpoint)?;
335            Ok(())
336        })
337    }
338
339    /// this tests if any of the results contain a field we are not deserializing
340    ///
341    /// this will only catch fields we missed if they are part of the response but
342    /// it is better than nothing
343    #[traced_test]
344    #[test]
345    fn test_completeness_news_type() -> Result<(), Box<dyn Error>> {
346        dotenvy::dotenv()?;
347        let redmine = crate::api::Redmine::from_env(
348            reqwest::blocking::Client::builder()
349                .use_rustls_tls()
350                .build()?,
351        )?;
352        let endpoint = ListNews::builder().build()?;
353        let values: Vec<serde_json::Value> = redmine.json_response_body_all_pages(&endpoint)?;
354        for value in values {
355            let o: News = serde_json::from_value(value.clone())?;
356            let reserialized = serde_json::to_value(o)?;
357            assert_eq!(value, reserialized);
358        }
359        Ok(())
360    }
361}