Skip to main content

figshare_rs/
query.rs

1//! Typed search and list query builders for Figshare articles.
2
3use serde_json::{Map, Value};
4
5use crate::error::FigshareError;
6use crate::metadata::DefinedType;
7
8/// Supported sort fields for article list and search endpoints.
9#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10pub enum ArticleOrder {
11    /// Sort by creation date.
12    CreatedDate,
13    /// Sort by publication date.
14    PublishedDate,
15    /// Sort by modification date.
16    ModifiedDate,
17    /// Sort by views.
18    Views,
19    /// Sort by shares.
20    Shares,
21    /// Sort by downloads.
22    Downloads,
23    /// Sort by citations.
24    Cites,
25}
26
27impl ArticleOrder {
28    #[must_use]
29    pub(crate) fn as_api_str(self) -> &'static str {
30        match self {
31            Self::CreatedDate => "created_date",
32            Self::PublishedDate => "published_date",
33            Self::ModifiedDate => "modified_date",
34            Self::Views => "views",
35            Self::Shares => "shares",
36            Self::Downloads => "downloads",
37            Self::Cites => "cites",
38        }
39    }
40}
41
42/// Sort direction for list and search endpoints.
43#[derive(Clone, Copy, Debug, PartialEq, Eq)]
44pub enum OrderDirection {
45    /// Ascending order.
46    Asc,
47    /// Descending order.
48    Desc,
49}
50
51impl OrderDirection {
52    #[must_use]
53    pub(crate) fn as_api_str(self) -> &'static str {
54        match self {
55            Self::Asc => "asc",
56            Self::Desc => "desc",
57        }
58    }
59}
60
61/// Shared query options for public and authenticated article list/search calls.
62#[derive(Clone, Debug, PartialEq, Eq, Default)]
63pub struct ArticleQuery {
64    /// Search string used by `POST .../search` endpoints.
65    pub search_for: Option<String>,
66    /// Filter results published since the given ISO-8601 string.
67    pub published_since: Option<String>,
68    /// Filter results modified since the given ISO-8601 string.
69    pub modified_since: Option<String>,
70    /// Restrict results to the given institution.
71    pub institution: Option<u64>,
72    /// Restrict results to the given group.
73    pub group: Option<u64>,
74    /// Restrict results to the given item type.
75    pub item_type: Option<DefinedType>,
76    /// Restrict results to the given resource DOI.
77    pub resource_doi: Option<String>,
78    /// Restrict results to the given DOI.
79    pub doi: Option<String>,
80    /// Restrict results to the given handle.
81    pub handle: Option<String>,
82    /// Restrict results to the given project.
83    pub project_id: Option<u64>,
84    /// Legacy resource title filter retained for backward-compatibility checks.
85    pub resource_title: Option<String>,
86    /// Optional sort field.
87    pub order: Option<ArticleOrder>,
88    /// Optional sort direction.
89    pub order_direction: Option<OrderDirection>,
90    /// Page number based pagination.
91    pub page: Option<u64>,
92    /// Page size based pagination.
93    pub page_size: Option<u64>,
94    /// Offset based pagination.
95    pub offset: Option<u64>,
96    /// Limit based pagination.
97    pub limit: Option<u64>,
98    /// Extra raw key-value pairs forwarded as-is.
99    pub custom: Vec<(String, String)>,
100}
101
102impl ArticleQuery {
103    /// Starts building a query.
104    ///
105    /// # Examples
106    ///
107    /// ```
108    /// use figshare_rs::{ArticleOrder, ArticleQuery, DefinedType, OrderDirection};
109    ///
110    /// let query = ArticleQuery::builder()
111    ///     .item_type(DefinedType::Dataset)
112    ///     .doi("10.6084/m9.figshare.123")
113    ///     .order(ArticleOrder::PublishedDate)
114    ///     .order_direction(OrderDirection::Desc)
115    ///     .limit(10)
116    ///     .build();
117    ///
118    /// assert_eq!(query.item_type, Some(DefinedType::Dataset));
119    /// assert_eq!(query.doi.as_deref(), Some("10.6084/m9.figshare.123"));
120    /// assert_eq!(query.limit, Some(10));
121    /// ```
122    #[must_use]
123    pub fn builder() -> ArticleQueryBuilder {
124        ArticleQueryBuilder::default()
125    }
126
127    pub(crate) fn as_public_list_query_pairs(
128        &self,
129    ) -> Result<Vec<(String, String)>, FigshareError> {
130        self.validate_pagination()?;
131        Self::ensure_unsupported_fields(
132            "list_public_articles",
133            [
134                ("search_for", self.search_for.is_some()),
135                ("project_id", self.project_id.is_some()),
136                ("resource_title", self.resource_title.is_some()),
137            ],
138        )?;
139
140        let mut pairs = Vec::new();
141        self.push_common_pairs(&mut pairs);
142        if let Some(item_type) = &self.item_type {
143            if let Some(id) = item_type.api_id() {
144                pairs.push(("item_type".into(), id.to_string()));
145            }
146        }
147        if let Some(resource_doi) = &self.resource_doi {
148            pairs.push(("resource_doi".into(), resource_doi.clone()));
149        }
150        if let Some(doi) = &self.doi {
151            pairs.push(("doi".into(), doi.clone()));
152        }
153        if let Some(handle) = &self.handle {
154            pairs.push(("handle".into(), handle.clone()));
155        }
156        if let Some(order) = self.order {
157            pairs.push(("order".into(), order.as_api_str().into()));
158        }
159        if let Some(order_direction) = self.order_direction {
160            pairs.push((
161                "order_direction".into(),
162                order_direction.as_api_str().into(),
163            ));
164        }
165        self.push_pagination_pairs(&mut pairs);
166        pairs.extend(self.custom.iter().cloned());
167        Ok(pairs)
168    }
169
170    pub(crate) fn as_own_list_query_pairs(&self) -> Result<Vec<(String, String)>, FigshareError> {
171        self.validate_pagination()?;
172        Self::ensure_unsupported_fields(
173            "list_own_articles",
174            [
175                ("search_for", self.search_for.is_some()),
176                ("published_since", self.published_since.is_some()),
177                ("modified_since", self.modified_since.is_some()),
178                ("institution", self.institution.is_some()),
179                ("group", self.group.is_some()),
180                ("item_type", self.item_type.is_some()),
181                ("resource_doi", self.resource_doi.is_some()),
182                ("resource_title", self.resource_title.is_some()),
183                ("order", self.order.is_some()),
184                ("order_direction", self.order_direction.is_some()),
185                ("doi", self.doi.is_some()),
186                ("handle", self.handle.is_some()),
187                ("project_id", self.project_id.is_some()),
188            ],
189        )?;
190
191        let mut pairs = Vec::new();
192        self.push_pagination_pairs(&mut pairs);
193        pairs.extend(self.custom.iter().cloned());
194        Ok(pairs)
195    }
196
197    pub(crate) fn as_public_search_body(&self) -> Result<Value, FigshareError> {
198        self.validate_pagination()?;
199        Self::ensure_unsupported_fields(
200            "search_public_articles",
201            [("resource_title", self.resource_title.is_some())],
202        )?;
203
204        let mut object = Map::new();
205        self.insert_common_search_fields(&mut object);
206        if let Some(item_type) = &self.item_type {
207            if let Some(id) = item_type.api_id() {
208                object.insert("item_type".into(), Value::from(id));
209            }
210        }
211        if let Some(resource_doi) = &self.resource_doi {
212            object.insert("resource_doi".into(), Value::String(resource_doi.clone()));
213        }
214        if let Some(doi) = &self.doi {
215            object.insert("doi".into(), Value::String(doi.clone()));
216        }
217        if let Some(handle) = &self.handle {
218            object.insert("handle".into(), Value::String(handle.clone()));
219        }
220        if let Some(project_id) = self.project_id {
221            object.insert("project_id".into(), Value::from(project_id));
222        }
223        if let Some(order) = self.order {
224            object.insert("order".into(), Value::String(order.as_api_str().into()));
225        }
226        if let Some(order_direction) = self.order_direction {
227            object.insert(
228                "order_direction".into(),
229                Value::String(order_direction.as_api_str().into()),
230            );
231        }
232        self.insert_pagination_fields(&mut object);
233        for (key, value) in &self.custom {
234            object.insert(key.clone(), Value::String(value.clone()));
235        }
236        Ok(Value::Object(object))
237    }
238
239    pub(crate) fn as_own_search_body(&self) -> Result<Value, FigshareError> {
240        self.validate_pagination()?;
241        Self::ensure_unsupported_fields(
242            "search_own_articles",
243            [("resource_title", self.resource_title.is_some())],
244        )?;
245
246        let mut object = Map::new();
247        self.insert_common_search_fields(&mut object);
248        if let Some(item_type) = &self.item_type {
249            if let Some(id) = item_type.api_id() {
250                object.insert("item_type".into(), Value::from(id));
251            }
252        }
253        if let Some(resource_doi) = &self.resource_doi {
254            object.insert("resource_doi".into(), Value::String(resource_doi.clone()));
255        }
256        if let Some(doi) = &self.doi {
257            object.insert("doi".into(), Value::String(doi.clone()));
258        }
259        if let Some(handle) = &self.handle {
260            object.insert("handle".into(), Value::String(handle.clone()));
261        }
262        if let Some(project_id) = self.project_id {
263            object.insert("project_id".into(), Value::from(project_id));
264        }
265        if let Some(order) = self.order {
266            object.insert("order".into(), Value::String(order.as_api_str().into()));
267        }
268        if let Some(order_direction) = self.order_direction {
269            object.insert(
270                "order_direction".into(),
271                Value::String(order_direction.as_api_str().into()),
272            );
273        }
274        self.insert_pagination_fields(&mut object);
275        for (key, value) in &self.custom {
276            object.insert(key.clone(), Value::String(value.clone()));
277        }
278        Ok(Value::Object(object))
279    }
280
281    fn validate_pagination(&self) -> Result<(), FigshareError> {
282        let uses_page = self.page.is_some() || self.page_size.is_some();
283        let uses_offset = self.limit.is_some() || self.offset.is_some();
284        if uses_page && uses_offset {
285            return Err(FigshareError::InvalidState(
286                "cannot mix page/page_size with limit/offset pagination".into(),
287            ));
288        }
289        Ok(())
290    }
291
292    fn push_common_pairs(&self, pairs: &mut Vec<(String, String)>) {
293        if let Some(published_since) = &self.published_since {
294            pairs.push(("published_since".into(), published_since.clone()));
295        }
296        if let Some(modified_since) = &self.modified_since {
297            pairs.push(("modified_since".into(), modified_since.clone()));
298        }
299        if let Some(institution) = self.institution {
300            pairs.push(("institution".into(), institution.to_string()));
301        }
302        if let Some(group) = self.group {
303            pairs.push(("group".into(), group.to_string()));
304        }
305    }
306
307    fn push_pagination_pairs(&self, pairs: &mut Vec<(String, String)>) {
308        if let Some(page) = self.page {
309            pairs.push(("page".into(), page.to_string()));
310        }
311        if let Some(page_size) = self.page_size {
312            pairs.push(("page_size".into(), page_size.to_string()));
313        }
314        if let Some(offset) = self.offset {
315            pairs.push(("offset".into(), offset.to_string()));
316        }
317        if let Some(limit) = self.limit {
318            pairs.push(("limit".into(), limit.to_string()));
319        }
320    }
321
322    fn insert_common_search_fields(&self, object: &mut Map<String, Value>) {
323        if let Some(search_for) = &self.search_for {
324            object.insert("search_for".into(), Value::String(search_for.clone()));
325        }
326        if let Some(published_since) = &self.published_since {
327            object.insert(
328                "published_since".into(),
329                Value::String(published_since.clone()),
330            );
331        }
332        if let Some(modified_since) = &self.modified_since {
333            object.insert(
334                "modified_since".into(),
335                Value::String(modified_since.clone()),
336            );
337        }
338        if let Some(institution) = self.institution {
339            object.insert("institution".into(), Value::from(institution));
340        }
341        if let Some(group) = self.group {
342            object.insert("group".into(), Value::from(group));
343        }
344    }
345
346    fn insert_pagination_fields(&self, object: &mut Map<String, Value>) {
347        if let Some(page) = self.page {
348            object.insert("page".into(), Value::from(page));
349        }
350        if let Some(page_size) = self.page_size {
351            object.insert("page_size".into(), Value::from(page_size));
352        }
353        if let Some(offset) = self.offset {
354            object.insert("offset".into(), Value::from(offset));
355        }
356        if let Some(limit) = self.limit {
357            object.insert("limit".into(), Value::from(limit));
358        }
359    }
360
361    fn ensure_unsupported_fields<const N: usize>(
362        endpoint: &str,
363        fields: [(&'static str, bool); N],
364    ) -> Result<(), FigshareError> {
365        let unsupported = fields
366            .into_iter()
367            .filter_map(|(name, is_set)| is_set.then_some(name))
368            .collect::<Vec<_>>();
369        if unsupported.is_empty() {
370            return Ok(());
371        }
372
373        Err(FigshareError::InvalidState(format!(
374            "{} not supported for {endpoint}",
375            unsupported.join(", ")
376        )))
377    }
378}
379
380/// Builder for [`ArticleQuery`].
381#[derive(Clone, Debug, Default)]
382pub struct ArticleQueryBuilder {
383    query: ArticleQuery,
384}
385
386impl ArticleQueryBuilder {
387    /// Sets the free-form search string used by search endpoints.
388    #[must_use]
389    pub fn search_for(mut self, search_for: impl Into<String>) -> Self {
390        self.query.search_for = Some(search_for.into());
391        self
392    }
393
394    /// Sets the publication lower bound.
395    #[must_use]
396    pub fn published_since(mut self, published_since: impl Into<String>) -> Self {
397        self.query.published_since = Some(published_since.into());
398        self
399    }
400
401    /// Sets the modification lower bound.
402    #[must_use]
403    pub fn modified_since(mut self, modified_since: impl Into<String>) -> Self {
404        self.query.modified_since = Some(modified_since.into());
405        self
406    }
407
408    /// Restricts results to one institution.
409    #[must_use]
410    pub fn institution(mut self, institution: u64) -> Self {
411        self.query.institution = Some(institution);
412        self
413    }
414
415    /// Restricts results to one group.
416    #[must_use]
417    pub fn group(mut self, group: u64) -> Self {
418        self.query.group = Some(group);
419        self
420    }
421
422    /// Restricts results to one defined type.
423    #[must_use]
424    pub fn item_type(mut self, item_type: DefinedType) -> Self {
425        self.query.item_type = Some(item_type);
426        self
427    }
428
429    /// Filters by resource DOI.
430    #[must_use]
431    pub fn resource_doi(mut self, resource_doi: impl Into<String>) -> Self {
432        self.query.resource_doi = Some(resource_doi.into());
433        self
434    }
435
436    /// Filters by DOI.
437    #[must_use]
438    pub fn doi(mut self, doi: impl Into<String>) -> Self {
439        self.query.doi = Some(doi.into());
440        self
441    }
442
443    /// Filters by handle.
444    #[must_use]
445    pub fn handle(mut self, handle: impl Into<String>) -> Self {
446        self.query.handle = Some(handle.into());
447        self
448    }
449
450    /// Filters by project ID.
451    #[must_use]
452    pub fn project_id(mut self, project_id: u64) -> Self {
453        self.query.project_id = Some(project_id);
454        self
455    }
456
457    /// Legacy resource title filter retained only for compatibility checks.
458    #[must_use]
459    pub fn resource_title(mut self, resource_title: impl Into<String>) -> Self {
460        self.query.resource_title = Some(resource_title.into());
461        self
462    }
463
464    /// Sets the sort field.
465    #[must_use]
466    pub fn order(mut self, order: ArticleOrder) -> Self {
467        self.query.order = Some(order);
468        self
469    }
470
471    /// Sets the sort direction.
472    #[must_use]
473    pub fn order_direction(mut self, order_direction: OrderDirection) -> Self {
474        self.query.order_direction = Some(order_direction);
475        self
476    }
477
478    /// Uses page-based pagination.
479    #[must_use]
480    pub fn page(mut self, page: u64) -> Self {
481        self.query.page = Some(page);
482        self
483    }
484
485    /// Uses page-size pagination.
486    #[must_use]
487    pub fn page_size(mut self, page_size: u64) -> Self {
488        self.query.page_size = Some(page_size);
489        self
490    }
491
492    /// Uses offset-based pagination.
493    #[must_use]
494    pub fn offset(mut self, offset: u64) -> Self {
495        self.query.offset = Some(offset);
496        self
497    }
498
499    /// Uses limit-based pagination.
500    #[must_use]
501    pub fn limit(mut self, limit: u64) -> Self {
502        self.query.limit = Some(limit);
503        self
504    }
505
506    /// Adds a raw custom key-value pair.
507    #[must_use]
508    pub fn custom(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
509        self.query.custom.push((key.into(), value.into()));
510        self
511    }
512
513    /// Finishes the builder.
514    #[must_use]
515    pub fn build(self) -> ArticleQuery {
516        self.query
517    }
518}
519
520#[cfg(test)]
521mod tests {
522    use serde_json::json;
523
524    use super::{ArticleOrder, ArticleQuery, OrderDirection};
525    use crate::metadata::DefinedType;
526
527    #[test]
528    fn order_enums_match_api_strings() {
529        assert_eq!(ArticleOrder::CreatedDate.as_api_str(), "created_date");
530        assert_eq!(ArticleOrder::PublishedDate.as_api_str(), "published_date");
531        assert_eq!(ArticleOrder::ModifiedDate.as_api_str(), "modified_date");
532        assert_eq!(ArticleOrder::Views.as_api_str(), "views");
533        assert_eq!(ArticleOrder::Shares.as_api_str(), "shares");
534        assert_eq!(ArticleOrder::Downloads.as_api_str(), "downloads");
535        assert_eq!(ArticleOrder::Cites.as_api_str(), "cites");
536        assert_eq!(OrderDirection::Asc.as_api_str(), "asc");
537        assert_eq!(OrderDirection::Desc.as_api_str(), "desc");
538    }
539
540    #[test]
541    fn query_serializes_public_list_pairs() {
542        let query = ArticleQuery::builder()
543            .published_since("2024-01-01")
544            .item_type(DefinedType::Dataset)
545            .doi("10.6084/m9.figshare.123")
546            .order(ArticleOrder::PublishedDate)
547            .order_direction(OrderDirection::Desc)
548            .page(2)
549            .page_size(25)
550            .custom("foo", "bar")
551            .build();
552
553        let pairs = query.as_public_list_query_pairs().unwrap();
554        assert!(pairs.contains(&("published_since".into(), "2024-01-01".into())));
555        assert!(pairs.contains(&("item_type".into(), "3".into())));
556        assert!(pairs.contains(&("doi".into(), "10.6084/m9.figshare.123".into())));
557        assert!(pairs.contains(&("foo".into(), "bar".into())));
558    }
559
560    #[test]
561    fn builder_populates_all_fields_and_own_search_serializes_them() {
562        let query = ArticleQuery::builder()
563            .search_for("figshare")
564            .published_since("2024-01-01")
565            .modified_since("2024-02-01")
566            .institution(7)
567            .group(11)
568            .item_type(DefinedType::Dataset)
569            .resource_doi("10.1000/resource")
570            .doi("10.6084/m9.figshare.123")
571            .handle("12345/abc")
572            .project_id(99)
573            .order(ArticleOrder::Downloads)
574            .order_direction(OrderDirection::Asc)
575            .offset(20)
576            .limit(10)
577            .custom("custom_flag", "yes")
578            .build();
579
580        assert_eq!(query.search_for.as_deref(), Some("figshare"));
581        assert_eq!(query.published_since.as_deref(), Some("2024-01-01"));
582        assert_eq!(query.modified_since.as_deref(), Some("2024-02-01"));
583        assert_eq!(query.institution, Some(7));
584        assert_eq!(query.group, Some(11));
585        assert_eq!(query.item_type, Some(DefinedType::Dataset));
586        assert_eq!(query.resource_doi.as_deref(), Some("10.1000/resource"));
587        assert_eq!(query.doi.as_deref(), Some("10.6084/m9.figshare.123"));
588        assert_eq!(query.handle.as_deref(), Some("12345/abc"));
589        assert_eq!(query.project_id, Some(99));
590        assert_eq!(query.order, Some(ArticleOrder::Downloads));
591        assert_eq!(query.order_direction, Some(OrderDirection::Asc));
592        assert_eq!(query.offset, Some(20));
593        assert_eq!(query.limit, Some(10));
594        assert_eq!(query.custom, vec![("custom_flag".into(), "yes".into())]);
595
596        let body = query.as_own_search_body().unwrap();
597        assert_eq!(body["search_for"], "figshare");
598        assert_eq!(body["published_since"], "2024-01-01");
599        assert_eq!(body["modified_since"], "2024-02-01");
600        assert_eq!(body["institution"], 7);
601        assert_eq!(body["group"], 11);
602        assert_eq!(body["item_type"], 3);
603        assert_eq!(body["resource_doi"], "10.1000/resource");
604        assert_eq!(body["doi"], "10.6084/m9.figshare.123");
605        assert_eq!(body["handle"], "12345/abc");
606        assert_eq!(body["project_id"], 99);
607        assert_eq!(body["order"], "downloads");
608        assert_eq!(body["order_direction"], "asc");
609        assert_eq!(body["offset"], 20);
610        assert_eq!(body["limit"], 10);
611        assert_eq!(body["custom_flag"], "yes");
612    }
613
614    #[test]
615    fn query_serializes_public_search_body_without_search_for() {
616        let query = ArticleQuery::builder()
617            .item_type(DefinedType::Dataset)
618            .limit(10)
619            .build();
620
621        let body = query.as_public_search_body().unwrap();
622        assert_eq!(body["item_type"], 3);
623        assert_eq!(body["limit"], 10);
624    }
625
626    #[test]
627    fn query_rejects_mixed_pagination_styles() {
628        let query = ArticleQuery {
629            page: Some(1),
630            limit: Some(10),
631            ..ArticleQuery::default()
632        };
633        assert!(query.as_public_list_query_pairs().is_err());
634        assert!(query.as_public_search_body().is_err());
635    }
636
637    #[test]
638    fn own_list_rejects_unsupported_filters() {
639        let query = ArticleQuery::builder()
640            .item_type(DefinedType::Dataset)
641            .page(1)
642            .build();
643        assert!(query.as_own_list_query_pairs().is_err());
644    }
645
646    #[test]
647    fn public_list_rejects_search_only_filters() {
648        let query = ArticleQuery::builder().project_id(7).page(1).build();
649        assert!(query.as_public_list_query_pairs().is_err());
650    }
651
652    #[test]
653    fn own_list_allows_only_pagination_and_custom_pairs() {
654        let query = ArticleQuery::builder()
655            .page(3)
656            .page_size(40)
657            .custom("cursor", "abc")
658            .build();
659
660        assert_eq!(
661            query.as_own_list_query_pairs().unwrap(),
662            vec![
663                ("page".into(), "3".into()),
664                ("page_size".into(), "40".into()),
665                ("cursor".into(), "abc".into())
666            ]
667        );
668    }
669
670    #[test]
671    fn public_search_and_own_search_reject_resource_title() {
672        let query = ArticleQuery::builder()
673            .search_for("example")
674            .resource_title("legacy")
675            .build();
676        assert!(query.as_public_search_body().is_err());
677        assert!(query.as_own_search_body().is_err());
678    }
679
680    #[test]
681    fn public_list_omits_unknown_defined_type_without_numeric_id() {
682        let query = ArticleQuery::builder()
683            .item_type(DefinedType::Unknown("custom widget".into()))
684            .page(1)
685            .build();
686        let pairs = query.as_public_list_query_pairs().unwrap();
687        assert_eq!(pairs, vec![("page".into(), "1".into())]);
688    }
689
690    #[test]
691    fn public_search_serializes_common_fields_and_custom_values() {
692        let query = ArticleQuery::builder()
693            .search_for("climate")
694            .published_since("2024-01-01")
695            .modified_since("2024-02-01")
696            .institution(5)
697            .group(6)
698            .handle("12345/example")
699            .custom("extra", "value")
700            .limit(5)
701            .build();
702        let body = query.as_public_search_body().unwrap();
703
704        assert_eq!(
705            body,
706            json!({
707                "search_for": "climate",
708                "published_since": "2024-01-01",
709                "modified_since": "2024-02-01",
710                "institution": 5,
711                "group": 6,
712                "handle": "12345/example",
713                "extra": "value",
714                "limit": 5
715            })
716        );
717    }
718}