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}