redmine_api/api/
wiki_pages.rs

1//! Wiki Pages Rest API Endpoint definitions
2//!
3//! [Redmine Documentation](https://www.redmine.org/projects/redmine/wiki/Rest_WikiPages)
4//!
5//! - [X] project specific wiki page endpoint
6//! - [X] specific wiki page endpoint
7//! - [X] specific wiki page old version endpoint
8//! - [X] create or update wiki page endpoint
9//! - [X] delete wiki page endpoint
10//! - [ ] attachments
11
12use derive_builder::Builder;
13use reqwest::Method;
14use serde::Serialize;
15use std::borrow::Cow;
16
17use crate::api::attachments::Attachment;
18use crate::api::users::UserEssentials;
19use crate::api::{Endpoint, NoPagination, QueryParams, ReturnsJsonResponse};
20
21/// The types of associated data which can be fetched along with a wiki page
22#[derive(Debug, Clone)]
23pub enum WikiPageInclude {
24    /// Wiki Page Attachments
25    Attachments,
26}
27
28impl std::fmt::Display for WikiPageInclude {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        match self {
31            Self::Attachments => {
32                write!(f, "attachments")
33            }
34        }
35    }
36}
37
38/// The parent of a wiki page
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
40pub struct WikiPageParent {
41    /// title
42    pub title: String,
43}
44
45/// a type for wiki pages to use as an API return type for the list call
46///
47/// alternatively you can use your own type limited to the fields you need
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
49pub struct WikiPageEssentials {
50    /// title
51    pub title: String,
52    /// the parent of this page
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub parent: Option<WikiPageParent>,
55    /// the version number of the wiki page
56    pub version: u64,
57    /// The time when this wiki page was created
58    #[serde(
59        serialize_with = "crate::api::serialize_rfc3339",
60        deserialize_with = "crate::api::deserialize_rfc3339"
61    )]
62    pub created_on: time::OffsetDateTime,
63    /// The time when this wiki page was last updated
64    #[serde(
65        serialize_with = "crate::api::serialize_rfc3339",
66        deserialize_with = "crate::api::deserialize_rfc3339"
67    )]
68    pub updated_on: time::OffsetDateTime,
69    /// wiki page attachments (only when include parameter is used)
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub attachments: Option<Vec<Attachment>>,
72}
73
74/// a type for wiki pages to use as an API return type
75///
76/// alternatively you can use your own type limited to the fields you need
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
78pub struct WikiPage {
79    /// title
80    pub title: String,
81    /// the parent of this page
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub parent: Option<WikiPageParent>,
84    /// author
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub author: Option<UserEssentials>,
87    /// the text body of the wiki page
88    pub text: String,
89    /// the version number of the wiki page
90    pub version: u64,
91    /// the comments supplied when saving this version of the page
92    pub comments: String,
93    /// The time when this wiki page was created
94    #[serde(
95        serialize_with = "crate::api::serialize_rfc3339",
96        deserialize_with = "crate::api::deserialize_rfc3339"
97    )]
98    pub created_on: time::OffsetDateTime,
99    /// The time when this wiki page was last updated
100    #[serde(
101        serialize_with = "crate::api::serialize_rfc3339",
102        deserialize_with = "crate::api::deserialize_rfc3339"
103    )]
104    pub updated_on: time::OffsetDateTime,
105    /// wiki page attachments (only when include parameter is used)
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub attachments: Option<Vec<Attachment>>,
108}
109
110/// The endpoint for all wiki pages in a project
111#[derive(Debug, Clone, Builder)]
112#[builder(setter(strip_option))]
113pub struct ListProjectWikiPages<'a> {
114    /// project id or name as it appears in the URL
115    #[builder(setter(into))]
116    project_id_or_name: Cow<'a, str>,
117    /// Include associated data
118    #[builder(default)]
119    include: Option<Vec<WikiPageInclude>>,
120}
121
122impl<'a> ReturnsJsonResponse for ListProjectWikiPages<'a> {}
123impl<'a> NoPagination for ListProjectWikiPages<'a> {}
124
125impl<'a> ListProjectWikiPages<'a> {
126    /// Create a builder for the endpoint.
127    #[must_use]
128    pub fn builder() -> ListProjectWikiPagesBuilder<'a> {
129        ListProjectWikiPagesBuilder::default()
130    }
131}
132
133impl<'a> Endpoint for ListProjectWikiPages<'a> {
134    fn method(&self) -> Method {
135        Method::GET
136    }
137
138    fn endpoint(&self) -> Cow<'static, str> {
139        format!("projects/{}/wiki/index.json", self.project_id_or_name).into()
140    }
141
142    fn parameters(&self) -> QueryParams<'_> {
143        let mut params = QueryParams::default();
144        params.push_opt("include", self.include.as_ref());
145        params
146    }
147}
148
149/// The endpoint for a specific Redmine project wiki page
150#[derive(Debug, Clone, Builder)]
151#[builder(setter(strip_option))]
152pub struct GetProjectWikiPage<'a> {
153    /// the project id or name as it appears in the URL
154    #[builder(setter(into))]
155    project_id_or_name: Cow<'a, str>,
156    /// the title as it appears in the URL
157    #[builder(setter(into))]
158    title: Cow<'a, str>,
159    /// the types of associate data to include
160    #[builder(default)]
161    include: Option<Vec<WikiPageInclude>>,
162}
163
164impl ReturnsJsonResponse for GetProjectWikiPage<'_> {}
165impl NoPagination for GetProjectWikiPage<'_> {}
166
167impl<'a> GetProjectWikiPage<'a> {
168    /// Create a builder for the endpoint.
169    #[must_use]
170    pub fn builder() -> GetProjectWikiPageBuilder<'a> {
171        GetProjectWikiPageBuilder::default()
172    }
173}
174
175impl Endpoint for GetProjectWikiPage<'_> {
176    fn method(&self) -> Method {
177        Method::GET
178    }
179
180    fn endpoint(&self) -> Cow<'static, str> {
181        format!(
182            "projects/{}/wiki/{}.json",
183            &self.project_id_or_name, &self.title
184        )
185        .into()
186    }
187
188    fn parameters(&self) -> QueryParams<'_> {
189        let mut params = QueryParams::default();
190        params.push_opt("include", self.include.as_ref());
191        params
192    }
193}
194
195/// The endpoint for a specific Redmine project wiki page version
196#[derive(Debug, Clone, Builder)]
197#[builder(setter(strip_option))]
198pub struct GetProjectWikiPageVersion<'a> {
199    /// the project id or name as it appears in the URL
200    #[builder(setter(into))]
201    project_id_or_name: Cow<'a, str>,
202    /// the title as it appears in the URL
203    #[builder(setter(into))]
204    title: Cow<'a, str>,
205    /// the version
206    version: u64,
207    /// the types of associate data to include
208    #[builder(default)]
209    include: Option<Vec<WikiPageInclude>>,
210}
211
212impl ReturnsJsonResponse for GetProjectWikiPageVersion<'_> {}
213impl NoPagination for GetProjectWikiPageVersion<'_> {}
214
215impl<'a> GetProjectWikiPageVersion<'a> {
216    /// Create a builder for the endpoint.
217    #[must_use]
218    pub fn builder() -> GetProjectWikiPageVersionBuilder<'a> {
219        GetProjectWikiPageVersionBuilder::default()
220    }
221}
222
223impl Endpoint for GetProjectWikiPageVersion<'_> {
224    fn method(&self) -> Method {
225        Method::GET
226    }
227
228    fn endpoint(&self) -> Cow<'static, str> {
229        format!(
230            "projects/{}/wiki/{}/{}.json",
231            &self.project_id_or_name, &self.title, &self.version,
232        )
233        .into()
234    }
235
236    fn parameters(&self) -> QueryParams<'_> {
237        let mut params = QueryParams::default();
238        params.push_opt("include", self.include.as_ref());
239        params
240    }
241}
242
243/// The endpoint to create or update a Redmine project wiki page
244#[derive(Debug, Clone, Builder, serde::Serialize, serde::Deserialize)]
245#[builder(setter(strip_option))]
246pub struct CreateOrUpdateProjectWikiPage<'a> {
247    /// the project id or name as it appears in the URL
248    #[serde(skip_serializing)]
249    #[builder(setter(into))]
250    project_id_or_name: Cow<'a, str>,
251    /// the title as it appears in the URL
252    #[serde(skip_serializing)]
253    #[builder(setter(into))]
254    title: Cow<'a, str>,
255    /// the version to update, if the version is not this a 409 Conflict is returned
256    #[serde(skip_serializing_if = "Option::is_none")]
257    #[builder(default)]
258    version: Option<u64>,
259    /// the body text of the page
260    #[builder(setter(into))]
261    text: Cow<'a, str>,
262    /// the comment for the update history
263    #[builder(setter(into))]
264    comments: Cow<'a, str>,
265}
266
267impl<'a> CreateOrUpdateProjectWikiPage<'a> {
268    /// Create a builder for the endpoint.
269    #[must_use]
270    pub fn builder() -> CreateOrUpdateProjectWikiPageBuilder<'a> {
271        CreateOrUpdateProjectWikiPageBuilder::default()
272    }
273}
274
275impl Endpoint for CreateOrUpdateProjectWikiPage<'_> {
276    fn method(&self) -> Method {
277        Method::PUT
278    }
279
280    fn endpoint(&self) -> Cow<'static, str> {
281        format!(
282            "projects/{}/wiki/{}.json",
283            &self.project_id_or_name, &self.title
284        )
285        .into()
286    }
287
288    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
289        Ok(Some((
290            "application/json",
291            serde_json::to_vec(&WikiPageWrapper::<CreateOrUpdateProjectWikiPage> {
292                wiki_page: (*self).to_owned(),
293            })?,
294        )))
295    }
296}
297
298/// The endpoint to delete a Redmine project wiki page
299#[derive(Debug, Clone, Builder)]
300#[builder(setter(strip_option))]
301pub struct DeleteProjectWikiPage<'a> {
302    /// the project id or name as it appears in the URL
303    #[builder(setter(into))]
304    project_id_or_name: Cow<'a, str>,
305    /// the title as it appears in the URL
306    #[builder(setter(into))]
307    title: Cow<'a, str>,
308}
309
310impl<'a> DeleteProjectWikiPage<'a> {
311    /// Create a builder for the endpoint.
312    #[must_use]
313    pub fn builder() -> DeleteProjectWikiPageBuilder<'a> {
314        DeleteProjectWikiPageBuilder::default()
315    }
316}
317
318impl<'a> Endpoint for DeleteProjectWikiPage<'a> {
319    fn method(&self) -> Method {
320        Method::DELETE
321    }
322
323    fn endpoint(&self) -> Cow<'static, str> {
324        format!(
325            "projects/{}/wiki/{}.json",
326            &self.project_id_or_name, &self.title
327        )
328        .into()
329    }
330}
331
332/// helper struct for outer layers with a wiki_pages field holding the inner data
333#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
334pub struct WikiPagesWrapper<T> {
335    /// to parse JSON with wiki_pages key
336    pub wiki_pages: Vec<T>,
337}
338
339/// A lot of APIs in Redmine wrap their data in an extra layer, this is a
340/// helper struct for outer layers with a wiki_page field holding the inner data
341#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
342pub struct WikiPageWrapper<T> {
343    /// to parse JSON with an wiki_page key
344    pub wiki_page: T,
345}
346
347#[cfg(test)]
348pub(crate) mod test {
349    use crate::api::projects::{ListProjects, Project, ProjectsInclude, test::PROJECT_LOCK};
350
351    use super::*;
352    use std::error::Error;
353    use tokio::sync::RwLock;
354    use tracing_test::traced_test;
355
356    /// needed so we do not get 404s when listing while
357    /// creating/deleting or creating/updating/deleting
358    pub static PROJECT_WIKI_PAGE_LOCK: RwLock<()> = RwLock::const_new(());
359
360    #[traced_test]
361    #[test]
362    fn test_list_project_wiki_pages() -> Result<(), Box<dyn Error>> {
363        let _r_project = PROJECT_LOCK.blocking_read();
364        let _r_project_wiki_pages = PROJECT_WIKI_PAGE_LOCK.blocking_read();
365        dotenvy::dotenv()?;
366        let redmine = crate::api::Redmine::from_env(
367            reqwest::blocking::Client::builder()
368                .use_rustls_tls()
369                .build()?,
370        )?;
371        let endpoint = ListProjectWikiPages::builder()
372            .project_id_or_name("25")
373            .build()?;
374        redmine.json_response_body::<_, WikiPagesWrapper<WikiPageEssentials>>(&endpoint)?;
375        Ok(())
376    }
377
378    /// this tests if any of the results contain a field we are not deserializing
379    ///
380    /// this will only catch fields we missed if they are part of the response but
381    /// it is better than nothing
382    #[traced_test]
383    #[test]
384    fn test_completeness_wiki_page_essentials() -> Result<(), Box<dyn Error>> {
385        let _r_project = PROJECT_LOCK.blocking_read();
386        let _r_issues = PROJECT_WIKI_PAGE_LOCK.blocking_read();
387        dotenvy::dotenv()?;
388        let redmine = crate::api::Redmine::from_env(
389            reqwest::blocking::Client::builder()
390                .use_rustls_tls()
391                .build()?,
392        )?;
393        let endpoint = ListProjects::builder()
394            .include(vec![ProjectsInclude::EnabledModules])
395            .build()?;
396        let projects = redmine.json_response_body_all_pages_iter::<_, Project>(&endpoint);
397        let mut checked_projects = 0;
398        for project in projects {
399            let project = project?;
400            if !project
401                .enabled_modules
402                .is_some_and(|em| em.iter().any(|m| m.name == "wiki"))
403            {
404                // skip projects where wiki is disabled
405                continue;
406            }
407            let endpoint = ListProjectWikiPages::builder()
408                .project_id_or_name(project.id.to_string())
409                .include(vec![WikiPageInclude::Attachments])
410                .build()?;
411            let Ok(WikiPagesWrapper { wiki_pages: values }) =
412                redmine.json_response_body::<_, WikiPagesWrapper<serde_json::Value>>(&endpoint)
413            else {
414                // TODO: some projects return a 404 for their wiki for unknown reasons even with an
415                //       enabled wiki module. They also do not have a wiki tab so I assume
416                //       it is intentional, they are not closed or archived either
417                continue;
418            };
419            checked_projects += 1;
420            for value in values {
421                let o: WikiPageEssentials = serde_json::from_value(value.clone())?;
422                let reserialized = serde_json::to_value(o)?;
423                assert_eq!(value, reserialized);
424            }
425        }
426        assert!(checked_projects > 0);
427        Ok(())
428    }
429
430    #[traced_test]
431    #[test]
432    fn test_get_project_wiki_page() -> Result<(), Box<dyn Error>> {
433        let _r_project = PROJECT_LOCK.blocking_read();
434        let _r_project_wiki_pages = PROJECT_WIKI_PAGE_LOCK.blocking_read();
435        dotenvy::dotenv()?;
436        let redmine = crate::api::Redmine::from_env(
437            reqwest::blocking::Client::builder()
438                .use_rustls_tls()
439                .build()?,
440        )?;
441        let endpoint = GetProjectWikiPage::builder()
442            .project_id_or_name("25")
443            .title("Administration")
444            .build()?;
445        redmine.json_response_body::<_, WikiPageWrapper<WikiPage>>(&endpoint)?;
446        Ok(())
447    }
448
449    /// this tests if any of the results contain a field we are not deserializing
450    ///
451    /// this will only catch fields we missed if they are part of the response but
452    /// it is better than nothing
453    #[traced_test]
454    #[test]
455    fn test_completeness_wiki_page() -> Result<(), Box<dyn Error>> {
456        let _r_project = PROJECT_LOCK.blocking_read();
457        let _r_issues = PROJECT_WIKI_PAGE_LOCK.blocking_read();
458        dotenvy::dotenv()?;
459        let redmine = crate::api::Redmine::from_env(
460            reqwest::blocking::Client::builder()
461                .use_rustls_tls()
462                .build()?,
463        )?;
464        let endpoint = ListProjects::builder()
465            .include(vec![ProjectsInclude::EnabledModules])
466            .build()?;
467        let projects = redmine.json_response_body_all_pages_iter::<_, Project>(&endpoint);
468        let mut checked_pages = 0;
469        for project in projects {
470            let project = project?;
471            if !project
472                .enabled_modules
473                .is_some_and(|em| em.iter().any(|m| m.name == "wiki"))
474            {
475                // skip projects where wiki is disabled
476                continue;
477            }
478            let endpoint = ListProjectWikiPages::builder()
479                .project_id_or_name(project.id.to_string())
480                .include(vec![WikiPageInclude::Attachments])
481                .build()?;
482            let Ok(WikiPagesWrapper { wiki_pages }) =
483                redmine.json_response_body::<_, WikiPagesWrapper<WikiPageEssentials>>(&endpoint)
484            else {
485                // TODO: some projects return a 404 for their wiki for unknown reasons even with an
486                //       enabled wiki module. They also do not have a wiki tab so I assume
487                //       it is intentional, they are not closed or archived either
488                continue;
489            };
490            checked_pages += 1;
491            for wiki_page in wiki_pages {
492                let endpoint = GetProjectWikiPage::builder()
493                    .project_id_or_name(project.id.to_string())
494                    .title(wiki_page.title)
495                    .include(vec![WikiPageInclude::Attachments])
496                    .build()?;
497                let WikiPageWrapper { wiki_page: value } = redmine
498                    .json_response_body::<_, WikiPageWrapper<serde_json::Value>>(&endpoint)?;
499                let o: WikiPage = serde_json::from_value(value.clone())?;
500                let reserialized = serde_json::to_value(o)?;
501                assert_eq!(value, reserialized);
502            }
503        }
504        assert!(checked_pages > 0);
505        Ok(())
506    }
507
508    #[traced_test]
509    #[test]
510    fn test_get_project_wiki_page_version() -> Result<(), Box<dyn Error>> {
511        let _r_project = PROJECT_LOCK.blocking_read();
512        let _r_project_wiki_pages = PROJECT_WIKI_PAGE_LOCK.blocking_read();
513        dotenvy::dotenv()?;
514        let redmine = crate::api::Redmine::from_env(
515            reqwest::blocking::Client::builder()
516                .use_rustls_tls()
517                .build()?,
518        )?;
519        let endpoint = GetProjectWikiPageVersion::builder()
520            .project_id_or_name("25")
521            .title("Administration")
522            .version(18)
523            .build()?;
524        redmine.json_response_body::<_, WikiPageWrapper<WikiPage>>(&endpoint)?;
525        Ok(())
526    }
527
528    #[traced_test]
529    #[test]
530    fn test_create_update_and_delete_project_wiki_page() -> Result<(), Box<dyn Error>> {
531        let _r_project = PROJECT_LOCK.blocking_read();
532        let _w_project_wiki_pages = PROJECT_WIKI_PAGE_LOCK.blocking_write();
533        dotenvy::dotenv()?;
534        let redmine = crate::api::Redmine::from_env(
535            reqwest::blocking::Client::builder()
536                .use_rustls_tls()
537                .build()?,
538        )?;
539        let endpoint = GetProjectWikiPage::builder()
540            .project_id_or_name("25")
541            .title("CreateWikiPageTest")
542            .build()?;
543        if redmine.ignore_response_body(&endpoint).is_ok() {
544            // left-over from past test that failed to complete
545            let endpoint = DeleteProjectWikiPage::builder()
546                .project_id_or_name("25")
547                .title("CreateWikiPageTest")
548                .build()?;
549            redmine.ignore_response_body(&endpoint)?;
550        }
551        let endpoint = CreateOrUpdateProjectWikiPage::builder()
552            .project_id_or_name("25")
553            .title("CreateWikiPageTest")
554            .text("Test Content")
555            .comments("Create Page Test")
556            .build()?;
557        redmine.ignore_response_body(&endpoint)?;
558        let endpoint = CreateOrUpdateProjectWikiPage::builder()
559            .project_id_or_name("25")
560            .title("CreateWikiPageTest")
561            .text("Test Content Updates")
562            .version(1)
563            .comments("Update Page Test")
564            .build()?;
565        redmine.ignore_response_body(&endpoint)?;
566        let endpoint = DeleteProjectWikiPage::builder()
567            .project_id_or_name("25")
568            .title("CreateWikiPageTest")
569            .build()?;
570        redmine.ignore_response_body(&endpoint)?;
571        Ok(())
572    }
573}