Skip to main content

zenodo_rs/
client_uploader_traits_impl.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, SearchResultsLike, UpdatePublication,
11    UpdatePublicationRequest, UploadSourceKind, UploadSpecLike,
12};
13use secrecy::ExposeSecret;
14use url::Url;
15
16use crate::client::ZenodoClient;
17use crate::downloads::ResolvedDownload;
18use crate::endpoint::Endpoint;
19use crate::error::ZenodoError;
20use crate::ids::{DepositionFileId, DepositionId, Doi, RecordId};
21use crate::metadata::DepositMetadataUpdate;
22use crate::model::{BucketObject, Deposition, DepositionFile, PublishedRecord, Record, RecordFile};
23use crate::pagination::Page;
24use crate::poll::PollOptions;
25use crate::records::RecordQuery;
26use crate::upload::{FileReplacePolicy, UploadSource, UploadSpec};
27
28impl ClientContext for ZenodoClient {
29    type Endpoint = Endpoint;
30    type PollOptions = PollOptions;
31    type Error = ZenodoError;
32
33    fn endpoint(&self) -> &Self::Endpoint {
34        ZenodoClient::endpoint(self)
35    }
36
37    fn poll_options(&self) -> &Self::PollOptions {
38        ZenodoClient::poll_options(self)
39    }
40
41    fn request_timeout(&self) -> Option<Duration> {
42        ZenodoClient::request_timeout(self)
43    }
44
45    fn connect_timeout(&self) -> Option<Duration> {
46        ZenodoClient::connect_timeout(self)
47    }
48}
49
50impl MaybeAuthenticatedClient for ZenodoClient {
51    fn has_auth(&self) -> bool {
52        !self.auth.token.expose_secret().is_empty()
53    }
54}
55
56impl UploadSpecLike for UploadSpec {
57    fn filename(&self) -> &str {
58        &self.filename
59    }
60
61    fn source_kind(&self) -> UploadSourceKind {
62        match self.source {
63            UploadSource::Path(_) => UploadSourceKind::Path,
64            UploadSource::Reader { .. } => UploadSourceKind::Reader,
65        }
66    }
67
68    fn content_length(&self) -> Option<u64> {
69        UploadSpec::content_length(self).ok()
70    }
71
72    fn content_type(&self) -> Option<&str> {
73        Some(self.content_type.as_ref())
74    }
75}
76
77impl RepositoryFile for DepositionFile {
78    type Id = DepositionFileId;
79
80    fn file_id(&self) -> Option<Self::Id> {
81        Some(self.id.clone())
82    }
83
84    fn file_name(&self) -> &str {
85        &self.filename
86    }
87
88    fn size_bytes(&self) -> Option<u64> {
89        Some(self.filesize)
90    }
91
92    fn checksum(&self) -> Option<&str> {
93        self.checksum.as_deref()
94    }
95}
96
97impl RepositoryFile for BucketObject {
98    type Id = String;
99
100    fn file_id(&self) -> Option<Self::Id> {
101        self.id.clone()
102    }
103
104    fn file_name(&self) -> &str {
105        &self.key
106    }
107
108    fn size_bytes(&self) -> Option<u64> {
109        Some(self.size)
110    }
111
112    fn checksum(&self) -> Option<&str> {
113        self.checksum.as_deref()
114    }
115}
116
117impl RepositoryFile for RecordFile {
118    type Id = String;
119
120    fn file_id(&self) -> Option<Self::Id> {
121        Some(self.id.clone())
122    }
123
124    fn file_name(&self) -> &str {
125        &self.key
126    }
127
128    fn size_bytes(&self) -> Option<u64> {
129        Some(self.size)
130    }
131
132    fn checksum(&self) -> Option<&str> {
133        self.checksum.as_deref()
134    }
135
136    fn download_url(&self) -> Option<&Url> {
137        RecordFile::download_url(self)
138    }
139}
140
141impl RepositoryRecord for Record {
142    type Id = RecordId;
143    type File = RecordFile;
144
145    fn resource_id(&self) -> Option<Self::Id> {
146        Some(self.id)
147    }
148
149    fn title(&self) -> Option<&str> {
150        Some(&self.metadata.title)
151    }
152
153    fn files(&self) -> &[Self::File] {
154        &self.files
155    }
156}
157
158impl DoiBackedRecord for Record {
159    type Doi = Doi;
160
161    fn doi(&self) -> Option<Self::Doi> {
162        self.doi.clone()
163    }
164}
165
166impl RepositoryRecord for Deposition {
167    type Id = DepositionId;
168    type File = DepositionFile;
169
170    fn resource_id(&self) -> Option<Self::Id> {
171        Some(self.id)
172    }
173
174    fn title(&self) -> Option<&str> {
175        self.metadata
176            .get("title")
177            .and_then(serde_json::Value::as_str)
178    }
179
180    fn files(&self) -> &[Self::File] {
181        &self.files
182    }
183}
184
185impl DoiBackedRecord for Deposition {
186    type Doi = Doi;
187
188    fn doi(&self) -> Option<Self::Doi> {
189        self.doi.clone()
190    }
191}
192
193impl DraftResource for Deposition {
194    type Id = DepositionId;
195    type File = DepositionFile;
196
197    fn draft_id(&self) -> Self::Id {
198        self.id
199    }
200
201    fn files(&self) -> &[Self::File] {
202        &self.files
203    }
204}
205
206impl DraftState for Deposition {
207    fn is_published(&self) -> bool {
208        Deposition::is_published(self)
209    }
210
211    fn allows_metadata_updates(&self) -> bool {
212        Deposition::allows_metadata_edits(self)
213    }
214}
215
216impl PublicationOutcome for PublishedRecord {
217    type PublicResource = Record;
218
219    fn public_resource(&self) -> &Self::PublicResource {
220        &self.record
221    }
222}
223
224impl MutablePublicationOutcome for PublishedRecord {
225    type MutableResource = Deposition;
226
227    fn mutable_resource(&self) -> Option<&Self::MutableResource> {
228        Some(&self.deposition)
229    }
230}
231
232impl<T> SearchResultsLike for Page<T> {
233    type Item = T;
234
235    fn items(&self) -> &[Self::Item] {
236        &self.hits
237    }
238
239    fn total_hits(&self) -> Option<u64> {
240        self.total
241    }
242}
243
244impl ReadPublicResource for ZenodoClient {
245    type ResourceId = RecordId;
246    type Resource = Record;
247
248    async fn get_public_resource(
249        &self,
250        id: &Self::ResourceId,
251    ) -> Result<Self::Resource, Self::Error> {
252        ZenodoClient::get_record(self, *id).await
253    }
254}
255
256impl SearchPublicResources for ZenodoClient {
257    type Query = RecordQuery;
258    type SearchResults = Page<Record>;
259
260    async fn search_public_resources(
261        &self,
262        query: &Self::Query,
263    ) -> Result<Self::SearchResults, Self::Error> {
264        ZenodoClient::search_records(self, query).await
265    }
266}
267
268impl ListResourceFiles for ZenodoClient {
269    type ResourceId = RecordId;
270    type File = RecordFile;
271
272    async fn list_resource_files(
273        &self,
274        id: &Self::ResourceId,
275    ) -> Result<Vec<Self::File>, Self::Error> {
276        ZenodoClient::list_record_files(self, *id).await
277    }
278}
279
280impl DownloadNamedPublicFile for ZenodoClient {
281    type ResourceId = RecordId;
282    type Download = ResolvedDownload;
283
284    async fn download_named_public_file_to_path(
285        &self,
286        id: &Self::ResourceId,
287        name: &str,
288        path: &Path,
289    ) -> Result<Self::Download, Self::Error> {
290        ZenodoClient::download_record_file_by_key_to_path(self, *id, name, path).await
291    }
292}
293
294impl CreatePublication for ZenodoClient {
295    type CreateTarget = NoCreateTarget;
296    type Metadata = DepositMetadataUpdate;
297    type Upload = UploadSpec;
298    type Output = PublishedRecord;
299
300    async fn create_publication(
301        &self,
302        request: CreatePublicationRequest<Self::CreateTarget, Self::Metadata, Self::Upload>,
303    ) -> Result<Self::Output, Self::Error> {
304        let CreatePublicationRequest {
305            target: _,
306            metadata,
307            uploads,
308        } = request;
309        ZenodoClient::create_and_publish_dataset(self, &metadata, uploads).await
310    }
311}
312
313impl UpdatePublication for ZenodoClient {
314    type ResourceId = DepositionId;
315    type Metadata = DepositMetadataUpdate;
316    type FilePolicy = FileReplacePolicy;
317    type Upload = UploadSpec;
318    type Output = PublishedRecord;
319
320    async fn update_publication(
321        &self,
322        request: UpdatePublicationRequest<
323            Self::ResourceId,
324            Self::Metadata,
325            Self::FilePolicy,
326            Self::Upload,
327        >,
328    ) -> Result<Self::Output, Self::Error> {
329        let UpdatePublicationRequest {
330            resource_id,
331            metadata,
332            policy,
333            uploads,
334        } = request;
335        ZenodoClient::publish_dataset_with_policy(self, resource_id, &metadata, policy, uploads)
336            .await
337    }
338}
339
340impl LookupByDoi for ZenodoClient {
341    type Doi = Doi;
342    type Resource = Record;
343
344    async fn get_public_resource_by_doi(
345        &self,
346        doi: &Self::Doi,
347    ) -> Result<Self::Resource, Self::Error> {
348        ZenodoClient::get_record_by_doi(self, doi).await
349    }
350}
351
352impl ResolveLatestPublicResource for ZenodoClient {
353    type ResourceId = RecordId;
354    type Resource = Record;
355
356    async fn resolve_latest_public_resource(
357        &self,
358        id: &Self::ResourceId,
359    ) -> Result<Self::Resource, Self::Error> {
360        ZenodoClient::resolve_latest_version(self, *id).await
361    }
362}
363
364impl ResolveLatestPublicResourceByDoi for ZenodoClient {
365    type Doi = Doi;
366    type Resource = Record;
367
368    async fn resolve_latest_public_resource_by_doi(
369        &self,
370        doi: &Self::Doi,
371    ) -> Result<Self::Resource, Self::Error> {
372        ZenodoClient::resolve_latest_by_doi(self, doi).await
373    }
374}
375
376impl DraftFilePolicy for FileReplacePolicy {
377    fn kind(&self) -> DraftFilePolicyKind {
378        match self {
379            Self::ReplaceAll => DraftFilePolicyKind::ReplaceAll,
380            Self::UpsertByFilename => DraftFilePolicyKind::UpsertByFilename,
381            Self::KeepExistingAndAdd => DraftFilePolicyKind::KeepExistingAndAdd,
382        }
383    }
384}
385
386impl DraftWorkflow for ZenodoClient {
387    type Draft = Deposition;
388    type Metadata = DepositMetadataUpdate;
389    type Upload = UploadSpec;
390    type FilePolicy = FileReplacePolicy;
391    type UploadResult = BucketObject;
392    type Published = Deposition;
393
394    async fn create_draft(&self, metadata: &Self::Metadata) -> Result<Self::Draft, Self::Error> {
395        let draft = ZenodoClient::create_deposition(self).await?;
396        ZenodoClient::update_metadata(self, draft.id, metadata).await
397    }
398
399    async fn update_draft_metadata(
400        &self,
401        draft_id: &<Self::Draft as DraftResource>::Id,
402        metadata: &Self::Metadata,
403    ) -> Result<Self::Draft, Self::Error> {
404        ZenodoClient::update_metadata(self, *draft_id, metadata).await
405    }
406
407    async fn reconcile_draft_files(
408        &self,
409        draft: &Self::Draft,
410        policy: Self::FilePolicy,
411        uploads: Vec<Self::Upload>,
412    ) -> Result<Vec<Self::UploadResult>, Self::Error> {
413        ZenodoClient::reconcile_files(self, draft, policy, uploads).await
414    }
415
416    async fn publish_draft(
417        &self,
418        draft_id: &<Self::Draft as DraftResource>::Id,
419    ) -> Result<Self::Published, Self::Error> {
420        ZenodoClient::publish(self, *draft_id).await
421    }
422}
423
424#[cfg(test)]
425mod tests {
426    use client_uploader_traits::{
427        collect_upload_filenames, CoreRepositoryClient, DoiVersionedRepositoryClient,
428        DraftPublishingRepositoryClient, MaybeAuthenticatedClient, MutablePublicationOutcome,
429        PublicationOutcome, RepositoryFile, RepositoryRecord, SearchResultsLike,
430    };
431    use serde_json::json;
432
433    use super::*;
434    use crate::client::Auth;
435
436    fn assert_core_client<T>()
437    where
438        T: CoreRepositoryClient,
439    {
440    }
441
442    fn assert_doi_client<T>()
443    where
444        T: DoiVersionedRepositoryClient,
445    {
446    }
447
448    fn assert_draft_client<T>()
449    where
450        T: DraftPublishingRepositoryClient,
451    {
452    }
453
454    #[test]
455    fn zenodo_client_satisfies_repository_client_bundles() {
456        assert_core_client::<ZenodoClient>();
457        assert_doi_client::<ZenodoClient>();
458        assert_draft_client::<ZenodoClient>();
459    }
460
461    #[test]
462    fn client_context_and_auth_traits_reflect_client_configuration() {
463        let client = ZenodoClient::new(Auth::new("token")).unwrap();
464
465        assert!(client.has_auth());
466        assert_eq!(ClientContext::request_timeout(&client), None);
467        assert_eq!(ClientContext::connect_timeout(&client), None);
468        assert_eq!(
469            ClientContext::poll_options(&client),
470            &PollOptions::default()
471        );
472        assert!(matches!(
473            ClientContext::endpoint(&client),
474            &Endpoint::Production
475        ));
476    }
477
478    #[test]
479    fn upload_spec_trait_reports_filename_source_kind_and_metadata() {
480        let spec = UploadSpec::from_reader(
481            "artifact.bin",
482            std::io::Cursor::new(vec![1_u8, 2, 3]),
483            3,
484            mime::APPLICATION_OCTET_STREAM,
485        );
486
487        assert_eq!(spec.filename(), "artifact.bin");
488        assert_eq!(spec.source_kind(), UploadSourceKind::Reader);
489        assert_eq!(UploadSpecLike::content_length(&spec), Some(3));
490        assert_eq!(
491            UploadSpecLike::content_type(&spec),
492            Some("application/octet-stream")
493        );
494    }
495
496    #[test]
497    fn shared_upload_filename_helper_accepts_zenodo_upload_specs() {
498        let uploads = [
499            UploadSpec::from_reader(
500                "artifact.bin",
501                std::io::Cursor::new(vec![1_u8]),
502                1,
503                mime::APPLICATION_OCTET_STREAM,
504            ),
505            UploadSpec::from_reader(
506                "manifest.json",
507                std::io::Cursor::new(vec![2_u8]),
508                1,
509                mime::APPLICATION_JSON,
510            ),
511        ];
512
513        let filenames = collect_upload_filenames(uploads.iter()).unwrap();
514        assert!(filenames.contains("artifact.bin"));
515        assert!(filenames.contains("manifest.json"));
516    }
517
518    #[test]
519    fn file_policy_trait_matches_zenodo_replace_policy_variants() {
520        assert_eq!(
521            DraftFilePolicy::kind(&FileReplacePolicy::ReplaceAll),
522            DraftFilePolicyKind::ReplaceAll
523        );
524        assert_eq!(
525            DraftFilePolicy::kind(&FileReplacePolicy::UpsertByFilename),
526            DraftFilePolicyKind::UpsertByFilename
527        );
528        assert_eq!(
529            DraftFilePolicy::kind(&FileReplacePolicy::KeepExistingAndAdd),
530            DraftFilePolicyKind::KeepExistingAndAdd
531        );
532    }
533
534    #[test]
535    fn record_related_traits_expose_expected_views() {
536        let record: Record = serde_json::from_value(json!({
537            "id": 42,
538            "recid": "42",
539            "doi": "10.5281/zenodo.42",
540            "metadata": { "title": "Record title" },
541            "files": [{
542                "id": "file-1",
543                "key": "artifact.bin",
544                "size": 3,
545                "checksum": "md5:abc",
546                "links": {
547                    "content": "https://example.invalid/content"
548                }
549            }],
550            "links": {}
551        }))
552        .unwrap();
553
554        assert_eq!(record.resource_id(), Some(RecordId(42)));
555        assert_eq!(record.title(), Some("Record title"));
556        assert_eq!(record.doi(), Some(Doi::new("10.5281/zenodo.42").unwrap()));
557        assert_eq!(record.files()[0].file_name(), "artifact.bin");
558        assert_eq!(record.files()[0].size_bytes(), Some(3));
559        assert_eq!(record.files()[0].checksum(), Some("md5:abc"));
560        assert_eq!(
561            record.files()[0].download_url().map(Url::as_str),
562            Some("https://example.invalid/content")
563        );
564    }
565
566    #[test]
567    fn bucket_object_and_deposition_expose_shared_repository_views() {
568        let uploaded = BucketObject {
569            id: Some("bucket-file".to_owned()),
570            key: "artifact.bin".to_owned(),
571            size: 3,
572            checksum: Some("md5:abc".to_owned()),
573            extra: std::collections::BTreeMap::default(),
574        };
575        let deposition: Deposition = serde_json::from_value(json!({
576            "id": 7,
577            "doi": "10.5281/zenodo.7",
578            "submitted": false,
579            "state": "inprogress",
580            "metadata": {
581                "title": "Draft title"
582            },
583            "files": [{
584                "id": "draft-file",
585                "filename": "artifact.bin",
586                "filesize": 3
587            }],
588            "links": {}
589        }))
590        .unwrap();
591
592        assert_eq!(uploaded.file_id(), Some("bucket-file".to_owned()));
593        assert_eq!(uploaded.file_name(), "artifact.bin");
594        assert_eq!(uploaded.size_bytes(), Some(3));
595        assert_eq!(uploaded.checksum(), Some("md5:abc"));
596
597        assert_eq!(deposition.resource_id(), Some(DepositionId(7)));
598        assert_eq!(deposition.title(), Some("Draft title"));
599        assert_eq!(
600            deposition.doi(),
601            Some(Doi::new("10.5281/zenodo.7").unwrap())
602        );
603        assert_eq!(
604            RepositoryRecord::files(&deposition)[0].file_name(),
605            "artifact.bin"
606        );
607    }
608
609    #[test]
610    fn deposition_and_publication_traits_expose_expected_views() {
611        let deposition: Deposition = serde_json::from_value(json!({
612            "id": 7,
613            "submitted": false,
614            "state": "inprogress",
615            "metadata": {},
616            "files": [{
617                "id": "draft-file",
618                "filename": "artifact.bin",
619                "filesize": 3
620            }],
621            "links": {}
622        }))
623        .unwrap();
624        let record: Record = serde_json::from_value(json!({
625            "id": 8,
626            "recid": "8",
627            "metadata": { "title": "Published title" },
628            "files": [],
629            "links": {}
630        }))
631        .unwrap();
632        let published = PublishedRecord {
633            deposition: deposition.clone(),
634            record,
635        };
636
637        assert_eq!(deposition.draft_id(), DepositionId(7));
638        assert!(!DraftState::is_published(&deposition));
639        assert!(DraftState::allows_metadata_updates(&deposition));
640        assert_eq!(
641            DraftResource::files(&deposition)[0].file_id(),
642            Some(DepositionFileId::from("draft-file"))
643        );
644        assert_eq!(
645            DraftResource::files(&deposition)[0].file_name(),
646            "artifact.bin"
647        );
648        assert_eq!(DraftResource::files(&deposition)[0].size_bytes(), Some(3));
649        assert_eq!(published.public_resource().id, RecordId(8));
650        assert_eq!(published.created(), None);
651        assert_eq!(
652            published.mutable_resource().map(DraftResource::draft_id),
653            Some(DepositionId(7))
654        );
655    }
656
657    #[test]
658    fn page_trait_exposes_hits_and_total() {
659        let page: Page<Record> = Page {
660            hits: vec![
661                serde_json::from_value(json!({
662                    "id": 1,
663                    "recid": "1",
664                    "metadata": { "title": "one" },
665                    "files": [],
666                    "links": {}
667                }))
668                .unwrap(),
669                serde_json::from_value(json!({
670                    "id": 2,
671                    "recid": "2",
672                    "metadata": { "title": "two" },
673                    "files": [],
674                    "links": {}
675                }))
676                .unwrap(),
677            ],
678            total: Some(10),
679            next: None,
680            prev: None,
681        };
682
683        assert_eq!(page.items().len(), 2);
684        assert_eq!(page.total_hits(), Some(10));
685        assert_eq!(page.page_len(), 2);
686        assert!(!page.is_empty());
687    }
688}