Skip to main content

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