1#![doc = include_str!("../README.md")]
2#![forbid(unsafe_code)]
3#![deny(missing_docs)]
4#![deny(
5 clippy::all,
6 clippy::cargo,
7 clippy::pedantic,
8 clippy::expect_used,
9 clippy::missing_errors_doc,
10 clippy::missing_panics_doc,
11 clippy::panic,
12 clippy::todo,
13 clippy::unimplemented,
14 clippy::unwrap_used
15)]
16#![allow(clippy::module_name_repetitions)]
17
18use std::collections::BTreeSet;
19use std::error::Error as StdError;
20use std::fmt;
21use std::future::Future;
22use std::path::Path;
23use std::time::Duration;
24
25use url::Url;
26
27#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
29pub struct NoCreateTarget;
30
31#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
33pub enum UploadSourceKind {
34 Path,
36 Reader,
38 Bytes,
40}
41
42#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
44pub enum DraftFilePolicyKind {
45 ReplaceAll,
47 UpsertByFilename,
49 KeepExistingAndAdd,
51}
52
53#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
55pub enum ExistingFileConflictPolicyKind {
56 Error,
58 Skip,
60 Overwrite,
62 OverwriteKeepingHistory,
64}
65
66#[derive(Clone, Debug, PartialEq, Eq)]
68pub struct CreatePublicationRequest<Target, Metadata, Upload> {
69 pub target: Target,
74 pub metadata: Metadata,
76 pub uploads: Vec<Upload>,
78}
79
80impl<Target, Metadata, Upload> CreatePublicationRequest<Target, Metadata, Upload> {
81 #[must_use]
83 pub fn new(target: Target, metadata: Metadata, uploads: Vec<Upload>) -> Self {
84 Self {
85 target,
86 metadata,
87 uploads,
88 }
89 }
90
91 #[must_use]
93 pub fn with_upload(mut self, upload: Upload) -> Self {
94 self.uploads.push(upload);
95 self
96 }
97
98 #[must_use]
100 pub fn map_uploads<MappedUpload>(
101 self,
102 mut map: impl FnMut(Upload) -> MappedUpload,
103 ) -> CreatePublicationRequest<Target, Metadata, MappedUpload> {
104 CreatePublicationRequest {
105 target: self.target,
106 metadata: self.metadata,
107 uploads: self.uploads.into_iter().map(&mut map).collect(),
108 }
109 }
110}
111
112impl<Metadata, Upload> CreatePublicationRequest<NoCreateTarget, Metadata, Upload> {
113 #[must_use]
115 pub fn untargeted(metadata: Metadata, uploads: Vec<Upload>) -> Self {
116 Self::new(NoCreateTarget, metadata, uploads)
117 }
118}
119
120#[derive(Clone, Debug, PartialEq, Eq)]
122pub struct UpdatePublicationRequest<ResourceId, Metadata, Policy, Upload> {
123 pub resource_id: ResourceId,
125 pub metadata: Metadata,
127 pub policy: Policy,
129 pub uploads: Vec<Upload>,
131}
132
133impl<ResourceId, Metadata, Policy, Upload>
134 UpdatePublicationRequest<ResourceId, Metadata, Policy, Upload>
135{
136 #[must_use]
138 pub fn new(
139 resource_id: ResourceId,
140 metadata: Metadata,
141 policy: Policy,
142 uploads: Vec<Upload>,
143 ) -> Self {
144 Self {
145 resource_id,
146 metadata,
147 policy,
148 uploads,
149 }
150 }
151
152 #[must_use]
154 pub fn with_upload(mut self, upload: Upload) -> Self {
155 self.uploads.push(upload);
156 self
157 }
158
159 #[must_use]
161 pub fn map_uploads<MappedUpload>(
162 self,
163 mut map: impl FnMut(Upload) -> MappedUpload,
164 ) -> UpdatePublicationRequest<ResourceId, Metadata, Policy, MappedUpload> {
165 UpdatePublicationRequest {
166 resource_id: self.resource_id,
167 metadata: self.metadata,
168 policy: self.policy,
169 uploads: self.uploads.into_iter().map(&mut map).collect(),
170 }
171 }
172}
173
174#[derive(Clone, Debug, PartialEq, Eq)]
176pub enum UploadNameValidationError {
177 EmptyFilename,
179 DuplicateFilename {
181 filename: String,
183 },
184}
185
186impl fmt::Display for UploadNameValidationError {
187 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
188 match self {
189 Self::EmptyFilename => f.write_str("upload filename cannot be empty"),
190 Self::DuplicateFilename { filename } => {
191 write!(f, "duplicate upload filename: {filename}")
192 }
193 }
194 }
195}
196
197impl StdError for UploadNameValidationError {}
198
199pub trait ClientContext {
201 type Endpoint;
203 type PollOptions;
205 type Error;
207
208 fn endpoint(&self) -> &Self::Endpoint;
210
211 fn poll_options(&self) -> &Self::PollOptions;
213
214 fn request_timeout(&self) -> Option<Duration>;
216
217 fn connect_timeout(&self) -> Option<Duration>;
219}
220
221pub trait MaybeAuthenticatedClient: ClientContext {
223 fn has_auth(&self) -> bool;
225}
226
227pub trait UploadSpecLike {
229 fn filename(&self) -> &str;
231
232 fn source_kind(&self) -> UploadSourceKind;
234
235 fn content_length(&self) -> Option<u64> {
237 None
238 }
239
240 fn content_type(&self) -> Option<&str> {
242 None
243 }
244}
245
246pub trait RepositoryFile {
248 type Id: Clone;
250
251 fn file_id(&self) -> Option<Self::Id>;
253
254 fn file_name(&self) -> &str;
256
257 fn size_bytes(&self) -> Option<u64>;
259
260 fn checksum(&self) -> Option<&str> {
262 None
263 }
264
265 fn download_url(&self) -> Option<&Url> {
267 None
268 }
269}
270
271pub trait RepositoryRecord {
278 type Id: Clone;
280 type File: RepositoryFile;
282
283 fn resource_id(&self) -> Option<Self::Id>;
285
286 fn title(&self) -> Option<&str>;
288
289 fn files(&self) -> &[Self::File];
291}
292
293pub trait DoiBackedRecord {
295 type Doi: Clone;
297
298 fn doi(&self) -> Option<Self::Doi>;
300}
301
302pub trait DraftResource {
304 type Id: Clone;
306 type File: RepositoryFile;
308
309 fn draft_id(&self) -> Self::Id;
311
312 fn files(&self) -> &[Self::File];
314}
315
316pub trait PublicationOutcome {
318 type PublicResource;
320
321 fn public_resource(&self) -> &Self::PublicResource;
323
324 fn created(&self) -> Option<bool> {
328 None
329 }
330}
331
332pub trait MutablePublicationOutcome: PublicationOutcome {
334 type MutableResource;
336
337 fn mutable_resource(&self) -> Option<&Self::MutableResource>;
339}
340
341pub trait DraftState {
343 fn is_published(&self) -> bool;
345
346 fn allows_metadata_updates(&self) -> bool;
348}
349
350pub trait ReadPublicResource: ClientContext {
352 type ResourceId;
354 type Resource;
356
357 fn get_public_resource(
359 &self,
360 id: &Self::ResourceId,
361 ) -> impl Future<Output = Result<Self::Resource, Self::Error>>;
362}
363
364pub trait SearchPublicResources: ClientContext {
366 type Query;
368 type SearchResults;
370
371 fn search_public_resources(
373 &self,
374 query: &Self::Query,
375 ) -> impl Future<Output = Result<Self::SearchResults, Self::Error>>;
376}
377
378pub trait SearchResultsLike {
380 type Item;
382
383 fn items(&self) -> &[Self::Item];
385
386 fn total_hits(&self) -> Option<u64> {
388 None
389 }
390
391 #[must_use]
393 fn page_len(&self) -> usize {
394 self.items().len()
395 }
396
397 #[must_use]
399 fn is_empty(&self) -> bool {
400 self.items().is_empty()
401 }
402}
403
404impl<T> SearchResultsLike for Vec<T> {
405 type Item = T;
406
407 fn items(&self) -> &[Self::Item] {
408 self.as_slice()
409 }
410}
411
412pub trait ListResourceFiles: ClientContext {
414 type ResourceId;
416 type File;
418
419 fn list_resource_files(
421 &self,
422 id: &Self::ResourceId,
423 ) -> impl Future<Output = Result<Vec<Self::File>, Self::Error>>;
424}
425
426pub trait DownloadNamedPublicFile: ClientContext {
428 type ResourceId;
430 type Download;
432
433 fn download_named_public_file_to_path(
435 &self,
436 id: &Self::ResourceId,
437 name: &str,
438 path: &Path,
439 ) -> impl Future<Output = Result<Self::Download, Self::Error>>;
440}
441
442pub trait CreatePublication: ClientContext {
444 type CreateTarget;
446 type Metadata;
448 type Upload;
450 type Output;
452
453 fn create_publication(
455 &self,
456 request: CreatePublicationRequest<Self::CreateTarget, Self::Metadata, Self::Upload>,
457 ) -> impl Future<Output = Result<Self::Output, Self::Error>>;
458}
459
460pub trait UpdatePublication: ClientContext {
462 type ResourceId;
464 type Metadata;
466 type FilePolicy;
468 type Upload;
470 type Output;
472
473 fn update_publication(
475 &self,
476 request: UpdatePublicationRequest<
477 Self::ResourceId,
478 Self::Metadata,
479 Self::FilePolicy,
480 Self::Upload,
481 >,
482 ) -> impl Future<Output = Result<Self::Output, Self::Error>>;
483}
484
485pub trait LookupByDoi: ClientContext {
487 type Doi;
489 type Resource;
491
492 fn get_public_resource_by_doi(
494 &self,
495 doi: &Self::Doi,
496 ) -> impl Future<Output = Result<Self::Resource, Self::Error>>;
497}
498
499pub trait ResolveLatestPublicResource: ClientContext {
501 type ResourceId;
503 type Resource;
505
506 fn resolve_latest_public_resource(
508 &self,
509 id: &Self::ResourceId,
510 ) -> impl Future<Output = Result<Self::Resource, Self::Error>>;
511}
512
513pub trait ResolveLatestPublicResourceByDoi: ClientContext {
515 type Doi;
517 type Resource;
519
520 fn resolve_latest_public_resource_by_doi(
522 &self,
523 doi: &Self::Doi,
524 ) -> impl Future<Output = Result<Self::Resource, Self::Error>>;
525}
526
527pub trait DraftFilePolicy {
529 fn kind(&self) -> DraftFilePolicyKind;
531}
532
533pub trait ExistingFileConflictPolicy {
535 fn kind(&self) -> ExistingFileConflictPolicyKind;
537}
538
539pub trait DraftWorkflow: ClientContext {
541 type Draft: DraftResource;
543 type Metadata;
545 type Upload;
547 type FilePolicy;
549 type UploadResult;
551 type Published;
553
554 fn create_draft(
556 &self,
557 metadata: &Self::Metadata,
558 ) -> impl Future<Output = Result<Self::Draft, Self::Error>>;
559
560 fn update_draft_metadata(
562 &self,
563 draft_id: &<Self::Draft as DraftResource>::Id,
564 metadata: &Self::Metadata,
565 ) -> impl Future<Output = Result<Self::Draft, Self::Error>>;
566
567 fn reconcile_draft_files(
569 &self,
570 draft: &Self::Draft,
571 policy: Self::FilePolicy,
572 uploads: Vec<Self::Upload>,
573 ) -> impl Future<Output = Result<Vec<Self::UploadResult>, Self::Error>>;
574
575 fn publish_draft(
577 &self,
578 draft_id: &<Self::Draft as DraftResource>::Id,
579 ) -> impl Future<Output = Result<Self::Published, Self::Error>>;
580}
581
582pub trait CoreRepositoryClient:
584 ClientContext
585 + CreatePublication
586 + DownloadNamedPublicFile
587 + ListResourceFiles
588 + ReadPublicResource
589 + SearchPublicResources
590 + UpdatePublication
591{
592}
593
594impl<T> CoreRepositoryClient for T where
595 T: ClientContext
596 + CreatePublication
597 + DownloadNamedPublicFile
598 + ListResourceFiles
599 + ReadPublicResource
600 + SearchPublicResources
601 + UpdatePublication
602{
603}
604
605pub trait DoiVersionedRepositoryClient:
607 CoreRepositoryClient + LookupByDoi + ResolveLatestPublicResource + ResolveLatestPublicResourceByDoi
608{
609}
610
611impl<T> DoiVersionedRepositoryClient for T where
612 T: CoreRepositoryClient
613 + LookupByDoi
614 + ResolveLatestPublicResource
615 + ResolveLatestPublicResourceByDoi
616{
617}
618
619pub trait DraftPublishingRepositoryClient: CoreRepositoryClient + DraftWorkflow {}
621
622impl<T> DraftPublishingRepositoryClient for T where T: CoreRepositoryClient + DraftWorkflow {}
623
624#[must_use]
626pub fn find_file_by_name<'a, File>(
627 files: impl IntoIterator<Item = &'a File>,
628 name: &str,
629) -> Option<&'a File>
630where
631 File: RepositoryFile + ?Sized + 'a,
632{
633 files.into_iter().find(|file| file.file_name() == name)
634}
635
636#[must_use]
638pub fn has_file_named<'a, File>(files: impl IntoIterator<Item = &'a File>, name: &str) -> bool
639where
640 File: RepositoryFile + ?Sized + 'a,
641{
642 find_file_by_name(files, name).is_some()
643}
644
645#[must_use]
647pub fn find_embedded_record_file_by_name<'a, Record>(
648 record: &'a Record,
649 name: &str,
650) -> Option<&'a Record::File>
651where
652 Record: RepositoryRecord + ?Sized,
653{
654 find_file_by_name(record.files().iter(), name)
655}
656
657#[must_use]
659pub fn find_embedded_draft_file_by_name<'a, Draft>(
660 draft: &'a Draft,
661 name: &str,
662) -> Option<&'a Draft::File>
663where
664 Draft: DraftResource + ?Sized,
665{
666 find_file_by_name(draft.files().iter(), name)
667}
668
669pub fn collect_upload_filenames<'a, Upload>(
679 uploads: impl IntoIterator<Item = &'a Upload>,
680) -> Result<BTreeSet<String>, UploadNameValidationError>
681where
682 Upload: UploadSpecLike + ?Sized + 'a,
683{
684 let mut filenames = BTreeSet::new();
685 for upload in uploads {
686 let filename = upload.filename();
687 if filename.is_empty() {
688 return Err(UploadNameValidationError::EmptyFilename);
689 }
690 if !filenames.insert(filename.to_owned()) {
691 return Err(UploadNameValidationError::DuplicateFilename {
692 filename: filename.to_owned(),
693 });
694 }
695 }
696 Ok(filenames)
697}
698
699pub fn validate_upload_filenames<'a, Upload>(
705 uploads: impl IntoIterator<Item = &'a Upload>,
706) -> Result<(), UploadNameValidationError>
707where
708 Upload: UploadSpecLike + ?Sized + 'a,
709{
710 collect_upload_filenames(uploads).map(|_| ())
711}
712
713#[must_use]
715pub fn has_upload_filename<'a, Upload>(
716 uploads: impl IntoIterator<Item = &'a Upload>,
717 filename: &str,
718) -> bool
719where
720 Upload: UploadSpecLike + ?Sized + 'a,
721{
722 uploads
723 .into_iter()
724 .any(|upload| upload.filename() == filename)
725}
726
727pub mod prelude {
729 pub use crate::{
730 collect_upload_filenames, find_embedded_draft_file_by_name,
731 find_embedded_record_file_by_name, find_file_by_name, has_file_named, has_upload_filename,
732 validate_upload_filenames, ClientContext, CoreRepositoryClient, CreatePublication,
733 CreatePublicationRequest, DoiBackedRecord, DoiVersionedRepositoryClient,
734 DownloadNamedPublicFile, DraftFilePolicy, DraftFilePolicyKind,
735 DraftPublishingRepositoryClient, DraftResource, DraftState, DraftWorkflow,
736 ExistingFileConflictPolicy, ExistingFileConflictPolicyKind, ListResourceFiles, LookupByDoi,
737 MaybeAuthenticatedClient, MutablePublicationOutcome, NoCreateTarget, PublicationOutcome,
738 ReadPublicResource, RepositoryFile, RepositoryRecord, ResolveLatestPublicResource,
739 ResolveLatestPublicResourceByDoi, SearchPublicResources, SearchResultsLike,
740 UpdatePublication, UpdatePublicationRequest, UploadNameValidationError, UploadSourceKind,
741 UploadSpecLike,
742 };
743}
744
745#[cfg(test)]
746mod self_tests;