1use serde_json::{Map, Value};
4
5use crate::error::FigshareError;
6use crate::metadata::DefinedType;
7
8#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10pub enum ArticleOrder {
11 CreatedDate,
13 PublishedDate,
15 ModifiedDate,
17 Views,
19 Shares,
21 Downloads,
23 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
44pub enum OrderDirection {
45 Asc,
47 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#[derive(Clone, Debug, PartialEq, Eq, Default)]
63pub struct ArticleQuery {
64 pub search_for: Option<String>,
66 pub published_since: Option<String>,
68 pub modified_since: Option<String>,
70 pub institution: Option<u64>,
72 pub group: Option<u64>,
74 pub item_type: Option<DefinedType>,
76 pub resource_doi: Option<String>,
78 pub doi: Option<String>,
80 pub handle: Option<String>,
82 pub project_id: Option<u64>,
84 pub resource_title: Option<String>,
86 pub order: Option<ArticleOrder>,
88 pub order_direction: Option<OrderDirection>,
90 pub page: Option<u64>,
92 pub page_size: Option<u64>,
94 pub offset: Option<u64>,
96 pub limit: Option<u64>,
98 pub custom: Vec<(String, String)>,
100}
101
102impl ArticleQuery {
103 #[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#[derive(Clone, Debug, Default)]
382pub struct ArticleQueryBuilder {
383 query: ArticleQuery,
384}
385
386impl ArticleQueryBuilder {
387 #[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 #[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 #[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 #[must_use]
410 pub fn institution(mut self, institution: u64) -> Self {
411 self.query.institution = Some(institution);
412 self
413 }
414
415 #[must_use]
417 pub fn group(mut self, group: u64) -> Self {
418 self.query.group = Some(group);
419 self
420 }
421
422 #[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 #[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 #[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 #[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 #[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 #[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 #[must_use]
466 pub fn order(mut self, order: ArticleOrder) -> Self {
467 self.query.order = Some(order);
468 self
469 }
470
471 #[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 #[must_use]
480 pub fn page(mut self, page: u64) -> Self {
481 self.query.page = Some(page);
482 self
483 }
484
485 #[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 #[must_use]
494 pub fn offset(mut self, offset: u64) -> Self {
495 self.query.offset = Some(offset);
496 self
497 }
498
499 #[must_use]
501 pub fn limit(mut self, limit: u64) -> Self {
502 self.query.limit = Some(limit);
503 self
504 }
505
506 #[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 #[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}