Skip to main content

figshare_rs/
uploader_traits.rs

1use std::path::Path;
2use std::time::Duration;
3
4use client_uploader_traits::{
5    ClientContext, CreatePublication, CreatePublicationRequest, DoiBackedRecord,
6    DownloadNamedPublicFile, DraftFilePolicy, DraftFilePolicyKind, DraftResource, DraftState,
7    DraftWorkflow, ListResourceFiles, LookupByDoi, MaybeAuthenticatedClient,
8    MutablePublicationOutcome, NoCreateTarget, PublicationOutcome, ReadPublicResource,
9    RepositoryFile, RepositoryRecord, ResolveLatestPublicResource,
10    ResolveLatestPublicResourceByDoi, SearchPublicResources, UpdatePublication,
11    UpdatePublicationRequest, UploadSourceKind, UploadSpecLike,
12};
13
14use crate::{
15    Article, ArticleFile, ArticleId, ArticleMetadata, ArticleQuery, Doi, Endpoint, FigshareClient,
16    FigshareError, FileReplacePolicy, PollOptions, PublishedArticle, ResolvedDownload,
17    UploadSource, UploadSpec,
18};
19
20impl ClientContext for FigshareClient {
21    type Endpoint = Endpoint;
22    type PollOptions = PollOptions;
23    type Error = FigshareError;
24
25    fn endpoint(&self) -> &Self::Endpoint {
26        FigshareClient::endpoint(self)
27    }
28
29    fn poll_options(&self) -> &Self::PollOptions {
30        FigshareClient::poll_options(self)
31    }
32
33    fn request_timeout(&self) -> Option<Duration> {
34        FigshareClient::request_timeout(self)
35    }
36
37    fn connect_timeout(&self) -> Option<Duration> {
38        FigshareClient::connect_timeout(self)
39    }
40}
41
42impl MaybeAuthenticatedClient for FigshareClient {
43    fn has_auth(&self) -> bool {
44        !self.auth.is_anonymous()
45    }
46}
47
48impl UploadSpecLike for UploadSpec {
49    fn filename(&self) -> &str {
50        &self.filename
51    }
52
53    fn source_kind(&self) -> UploadSourceKind {
54        match self.source {
55            UploadSource::Path(_) => UploadSourceKind::Path,
56            UploadSource::Reader { .. } => UploadSourceKind::Reader,
57        }
58    }
59
60    fn content_length(&self) -> Option<u64> {
61        match self.source {
62            UploadSource::Path(_) => None,
63            UploadSource::Reader { content_length, .. } => Some(content_length),
64        }
65    }
66}
67
68impl DraftFilePolicy for FileReplacePolicy {
69    fn kind(&self) -> DraftFilePolicyKind {
70        match self {
71            Self::ReplaceAll => DraftFilePolicyKind::ReplaceAll,
72            Self::UpsertByFilename => DraftFilePolicyKind::UpsertByFilename,
73            Self::KeepExistingAndAdd => DraftFilePolicyKind::KeepExistingAndAdd,
74        }
75    }
76}
77
78impl RepositoryFile for ArticleFile {
79    type Id = crate::FileId;
80
81    fn file_id(&self) -> Option<Self::Id> {
82        Some(self.id)
83    }
84
85    fn file_name(&self) -> &str {
86        &self.name
87    }
88
89    fn size_bytes(&self) -> Option<u64> {
90        Some(self.size)
91    }
92
93    fn checksum(&self) -> Option<&str> {
94        self.computed_md5
95            .as_deref()
96            .or(self.supplied_md5.as_deref())
97    }
98
99    fn download_url(&self) -> Option<&url::Url> {
100        self.download_url.as_ref()
101    }
102}
103
104impl RepositoryRecord for Article {
105    type Id = ArticleId;
106    type File = ArticleFile;
107
108    fn resource_id(&self) -> Option<Self::Id> {
109        Some(self.id)
110    }
111
112    fn title(&self) -> Option<&str> {
113        Some(&self.title)
114    }
115
116    fn files(&self) -> &[Self::File] {
117        self.files.as_slice()
118    }
119}
120
121impl DoiBackedRecord for Article {
122    type Doi = Doi;
123
124    fn doi(&self) -> Option<Self::Doi> {
125        self.doi.clone()
126    }
127}
128
129impl DraftResource for Article {
130    type Id = ArticleId;
131    type File = ArticleFile;
132
133    fn draft_id(&self) -> Self::Id {
134        self.id
135    }
136
137    fn files(&self) -> &[Self::File] {
138        self.files.as_slice()
139    }
140}
141
142impl DraftState for Article {
143    fn is_published(&self) -> bool {
144        self.is_public_article()
145    }
146
147    fn allows_metadata_updates(&self) -> bool {
148        !self.is_public_article()
149    }
150}
151
152impl PublicationOutcome for Article {
153    type PublicResource = Article;
154
155    fn public_resource(&self) -> &Self::PublicResource {
156        self
157    }
158}
159
160impl PublicationOutcome for PublishedArticle {
161    type PublicResource = Article;
162
163    fn public_resource(&self) -> &Self::PublicResource {
164        &self.public_article
165    }
166}
167
168impl MutablePublicationOutcome for PublishedArticle {
169    type MutableResource = Article;
170
171    fn mutable_resource(&self) -> Option<&Self::MutableResource> {
172        Some(&self.article)
173    }
174}
175
176impl ReadPublicResource for FigshareClient {
177    type ResourceId = ArticleId;
178    type Resource = Article;
179
180    async fn get_public_resource(
181        &self,
182        id: &Self::ResourceId,
183    ) -> Result<Self::Resource, Self::Error> {
184        self.get_public_article(*id).await
185    }
186}
187
188impl SearchPublicResources for FigshareClient {
189    type Query = ArticleQuery;
190    type SearchResults = Vec<Article>;
191
192    async fn search_public_resources(
193        &self,
194        query: &Self::Query,
195    ) -> Result<Self::SearchResults, Self::Error> {
196        self.search_public_articles(query).await
197    }
198}
199
200impl ListResourceFiles for FigshareClient {
201    type ResourceId = ArticleId;
202    type File = ArticleFile;
203
204    async fn list_resource_files(
205        &self,
206        id: &Self::ResourceId,
207    ) -> Result<Vec<Self::File>, Self::Error> {
208        let article = self.get_public_article(*id).await?;
209        let version = public_article_version_number(self, &article).await?;
210        self.list_public_article_version_files(article.id, version)
211            .await
212    }
213}
214
215impl DownloadNamedPublicFile for FigshareClient {
216    type ResourceId = ArticleId;
217    type Download = ResolvedDownload;
218
219    async fn download_named_public_file_to_path(
220        &self,
221        id: &Self::ResourceId,
222        name: &str,
223        path: &Path,
224    ) -> Result<Self::Download, Self::Error> {
225        self.download_public_article_file_by_name_to_path(*id, name, false, path)
226            .await
227    }
228}
229
230impl CreatePublication for FigshareClient {
231    type CreateTarget = NoCreateTarget;
232    type Metadata = ArticleMetadata;
233    type Upload = UploadSpec;
234    type Output = PublishedArticle;
235
236    async fn create_publication(
237        &self,
238        request: CreatePublicationRequest<Self::CreateTarget, Self::Metadata, Self::Upload>,
239    ) -> Result<Self::Output, Self::Error> {
240        let CreatePublicationRequest {
241            target: _,
242            metadata,
243            uploads,
244        } = request;
245        self.create_and_publish_article(&metadata, uploads).await
246    }
247}
248
249impl UpdatePublication for FigshareClient {
250    type ResourceId = ArticleId;
251    type Metadata = ArticleMetadata;
252    type FilePolicy = FileReplacePolicy;
253    type Upload = UploadSpec;
254    type Output = PublishedArticle;
255
256    async fn update_publication(
257        &self,
258        request: UpdatePublicationRequest<
259            Self::ResourceId,
260            Self::Metadata,
261            Self::FilePolicy,
262            Self::Upload,
263        >,
264    ) -> Result<Self::Output, Self::Error> {
265        let UpdatePublicationRequest {
266            resource_id,
267            metadata,
268            policy,
269            uploads,
270        } = request;
271        self.publish_existing_article_with_policy(resource_id, &metadata, policy, uploads)
272            .await
273    }
274}
275
276impl LookupByDoi for FigshareClient {
277    type Doi = Doi;
278    type Resource = Article;
279
280    async fn get_public_resource_by_doi(
281        &self,
282        doi: &Self::Doi,
283    ) -> Result<Self::Resource, Self::Error> {
284        self.get_public_article_by_doi(doi).await
285    }
286}
287
288impl ResolveLatestPublicResource for FigshareClient {
289    type ResourceId = ArticleId;
290    type Resource = Article;
291
292    async fn resolve_latest_public_resource(
293        &self,
294        id: &Self::ResourceId,
295    ) -> Result<Self::Resource, Self::Error> {
296        self.resolve_latest_public_article(*id).await
297    }
298}
299
300impl ResolveLatestPublicResourceByDoi for FigshareClient {
301    type Doi = Doi;
302    type Resource = Article;
303
304    async fn resolve_latest_public_resource_by_doi(
305        &self,
306        doi: &Self::Doi,
307    ) -> Result<Self::Resource, Self::Error> {
308        self.resolve_latest_public_article_by_doi(doi).await
309    }
310}
311
312impl DraftWorkflow for FigshareClient {
313    type Draft = Article;
314    type Metadata = ArticleMetadata;
315    type Upload = UploadSpec;
316    type FilePolicy = FileReplacePolicy;
317    type UploadResult = ArticleFile;
318    type Published = Article;
319
320    async fn create_draft(&self, metadata: &Self::Metadata) -> Result<Self::Draft, Self::Error> {
321        self.create_article(metadata).await
322    }
323
324    async fn update_draft_metadata(
325        &self,
326        draft_id: &<Self::Draft as DraftResource>::Id,
327        metadata: &Self::Metadata,
328    ) -> Result<Self::Draft, Self::Error> {
329        self.update_article(*draft_id, metadata).await
330    }
331
332    async fn reconcile_draft_files(
333        &self,
334        draft: &Self::Draft,
335        policy: Self::FilePolicy,
336        uploads: Vec<Self::Upload>,
337    ) -> Result<Vec<Self::UploadResult>, Self::Error> {
338        self.reconcile_files(draft, policy, uploads).await
339    }
340
341    async fn publish_draft(
342        &self,
343        draft_id: &<Self::Draft as DraftResource>::Id,
344    ) -> Result<Self::Published, Self::Error> {
345        self.publish_article(*draft_id).await
346    }
347}
348
349async fn public_article_version_number(
350    client: &FigshareClient,
351    article: &Article,
352) -> Result<u64, FigshareError> {
353    if let Some(version) = article.version_number() {
354        return Ok(version);
355    }
356
357    Ok(client
358        .list_public_article_versions(article.id)
359        .await?
360        .into_iter()
361        .map(|version| version.version)
362        .max()
363        .unwrap_or(1))
364}
365
366#[cfg(test)]
367mod tests {
368    use std::time::Duration;
369
370    use client_uploader_traits::{
371        CoreRepositoryClient, DoiBackedRecord, DoiVersionedRepositoryClient, DraftFilePolicy,
372        DraftFilePolicyKind, DraftPublishingRepositoryClient, DraftResource, DraftState,
373        MutablePublicationOutcome, PublicationOutcome, RepositoryFile, RepositoryRecord,
374        UploadSourceKind, UploadSpecLike,
375    };
376    use serde_json::json;
377
378    use crate::{Auth, FileId};
379
380    use super::*;
381
382    fn assert_core_repository_client<C>()
383    where
384        C: CoreRepositoryClient,
385    {
386    }
387
388    fn assert_doi_versioned_repository_client<C>()
389    where
390        C: DoiVersionedRepositoryClient,
391    {
392    }
393
394    fn assert_draft_publishing_repository_client<C>()
395    where
396        C: DraftPublishingRepositoryClient,
397    {
398    }
399
400    fn assert_publication_outcome<T>()
401    where
402        T: PublicationOutcome,
403    {
404    }
405
406    #[test]
407    fn figshare_client_satisfies_repository_client_bundles() {
408        assert_core_repository_client::<FigshareClient>();
409        assert_doi_versioned_repository_client::<FigshareClient>();
410        assert_draft_publishing_repository_client::<FigshareClient>();
411        assert_publication_outcome::<Article>();
412        assert_publication_outcome::<PublishedArticle>();
413    }
414
415    #[test]
416    fn client_context_and_auth_traits_expose_config() {
417        let endpoint = Endpoint::Custom("http://localhost:8080/v2/".parse().unwrap());
418        let poll = PollOptions {
419            max_wait: Duration::from_secs(9),
420            initial_delay: Duration::from_millis(50),
421            max_delay: Duration::from_secs(2),
422        };
423        let client = FigshareClient::builder(Auth::new("secret-token"))
424            .endpoint(endpoint.clone())
425            .poll_options(poll.clone())
426            .request_timeout(Duration::from_secs(11))
427            .connect_timeout(Duration::from_secs(3))
428            .build()
429            .unwrap();
430        let anonymous = FigshareClient::builder(Auth::anonymous()).build().unwrap();
431
432        assert_eq!(ClientContext::endpoint(&client), &endpoint);
433        assert_eq!(ClientContext::poll_options(&client), &poll);
434        assert_eq!(
435            ClientContext::request_timeout(&client),
436            Some(Duration::from_secs(11))
437        );
438        assert_eq!(
439            ClientContext::connect_timeout(&client),
440            Some(Duration::from_secs(3))
441        );
442        assert!(MaybeAuthenticatedClient::has_auth(&client));
443        assert!(!MaybeAuthenticatedClient::has_auth(&anonymous));
444    }
445
446    #[test]
447    fn upload_spec_like_reports_source_metadata() {
448        let path_upload = UploadSpec::from_path("/tmp/archive.tar.gz").unwrap();
449        let reader_upload =
450            UploadSpec::from_reader("artifact.bin", std::io::Cursor::new(vec![1, 2, 3]), 3);
451
452        assert_eq!(UploadSpecLike::filename(&path_upload), "archive.tar.gz");
453        assert_eq!(
454            UploadSpecLike::source_kind(&path_upload),
455            UploadSourceKind::Path
456        );
457        assert_eq!(UploadSpecLike::content_length(&path_upload), None);
458        assert_eq!(UploadSpecLike::content_type(&path_upload), None);
459
460        assert_eq!(UploadSpecLike::filename(&reader_upload), "artifact.bin");
461        assert_eq!(
462            UploadSpecLike::source_kind(&reader_upload),
463            UploadSourceKind::Reader
464        );
465        assert_eq!(UploadSpecLike::content_length(&reader_upload), Some(3));
466    }
467
468    #[test]
469    fn file_replace_policy_maps_to_shared_kind() {
470        assert_eq!(
471            DraftFilePolicy::kind(&FileReplacePolicy::ReplaceAll),
472            DraftFilePolicyKind::ReplaceAll
473        );
474        assert_eq!(
475            DraftFilePolicy::kind(&FileReplacePolicy::UpsertByFilename),
476            DraftFilePolicyKind::UpsertByFilename
477        );
478        assert_eq!(
479            DraftFilePolicy::kind(&FileReplacePolicy::KeepExistingAndAdd),
480            DraftFilePolicyKind::KeepExistingAndAdd
481        );
482    }
483
484    #[test]
485    fn article_and_file_models_implement_shared_inspection_traits() {
486        let draft: Article = serde_json::from_value(json!({
487            "id": 42,
488            "title": "Draft dataset",
489            "doi": "10.6084/m9.figshare.42",
490            "status": "draft",
491            "is_public": false,
492            "files": [{
493                "id": 7,
494                "name": "artifact.bin",
495                "size": 12,
496                "computed_md5": "abc123",
497                "download_url": "https://example.com/file"
498            }]
499        }))
500        .unwrap();
501        let published: Article = serde_json::from_value(json!({
502            "id": 42,
503            "title": "Draft dataset",
504            "status": "public",
505            "is_public": true
506        }))
507        .unwrap();
508        let file = &draft.files[0];
509
510        assert_eq!(RepositoryRecord::resource_id(&draft), Some(ArticleId(42)));
511        assert_eq!(RepositoryRecord::title(&draft), Some("Draft dataset"));
512        assert_eq!(RepositoryRecord::files(&draft).len(), 1);
513        assert_eq!(
514            DoiBackedRecord::doi(&draft),
515            Some(Doi::new("10.6084/m9.figshare.42").unwrap())
516        );
517        assert_eq!(DraftResource::draft_id(&draft), ArticleId(42));
518        assert_eq!(DraftResource::files(&draft).len(), 1);
519        assert!(!DraftState::is_published(&draft));
520        assert!(DraftState::allows_metadata_updates(&draft));
521        assert!(DraftState::is_published(&published));
522        assert!(!DraftState::allows_metadata_updates(&published));
523
524        assert_eq!(RepositoryFile::file_id(file), Some(FileId(7)));
525        assert_eq!(RepositoryFile::file_name(file), "artifact.bin");
526        assert_eq!(RepositoryFile::size_bytes(file), Some(12));
527        assert_eq!(RepositoryFile::checksum(file), Some("abc123"));
528        assert_eq!(
529            RepositoryFile::download_url(file).map(url::Url::as_str),
530            Some("https://example.com/file")
531        );
532    }
533
534    #[test]
535    fn published_article_implements_shared_outcome_traits() {
536        let article: Article = serde_json::from_value(json!({
537            "id": 42,
538            "title": "Private article",
539            "status": "draft"
540        }))
541        .unwrap();
542        let public_article: Article = serde_json::from_value(json!({
543            "id": 42,
544            "title": "Public article",
545            "status": "public",
546            "is_public": true
547        }))
548        .unwrap();
549        let outcome = PublishedArticle {
550            article: article.clone(),
551            public_article: public_article.clone(),
552        };
553
554        assert_eq!(
555            PublicationOutcome::public_resource(&outcome),
556            &public_article
557        );
558        assert_eq!(PublicationOutcome::created(&outcome), None);
559        assert_eq!(
560            MutablePublicationOutcome::mutable_resource(&outcome),
561            Some(&article)
562        );
563    }
564
565    #[test]
566    fn article_implements_publication_outcome() {
567        let article: Article = serde_json::from_value(json!({
568            "id": 42,
569            "title": "Public article",
570            "status": "public",
571            "is_public": true
572        }))
573        .unwrap();
574
575        assert_eq!(PublicationOutcome::public_resource(&article), &article);
576        assert_eq!(PublicationOutcome::created(&article), None);
577    }
578}