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}