Skip to main content

figshare_rs/
client.rs

1//! Low-level typed Figshare client operations.
2//!
3//! Use this module when you want direct access to Figshare's article, file,
4//! upload, and catalog endpoints without the higher-level orchestration from
5//! [`crate::workflow`].
6
7use std::cmp::min;
8use std::io::{Read, Seek, SeekFrom};
9use std::path::Path;
10use std::time::{Duration, Instant};
11
12use md5::{Digest, Md5};
13use reqwest::header::{ACCEPT, AUTHORIZATION, LOCATION};
14use reqwest::{Method, RequestBuilder};
15use secrecy::{ExposeSecret, SecretString};
16use serde::de::DeserializeOwned;
17use serde::Deserialize;
18use tempfile::NamedTempFile;
19use tokio::time::sleep;
20use url::Url;
21
22use crate::endpoint::Endpoint;
23use crate::error::FigshareError;
24use crate::ids::{ArticleId, Doi, FileId};
25use crate::metadata::ArticleMetadata;
26use crate::model::{
27    Article, ArticleCategory, ArticleFile, ArticleLicense, ArticleVersion, UploadSession,
28    UploadStatus,
29};
30use crate::poll::PollOptions;
31use crate::query::ArticleQuery;
32
33/// Token authentication for Figshare API requests.
34#[derive(Clone)]
35pub struct Auth {
36    /// API token used for authenticated requests, or `None` for anonymous access.
37    pub token: Option<SecretString>,
38}
39
40impl Auth {
41    /// Standard environment variable for a Figshare API token.
42    pub const TOKEN_ENV_VAR: &'static str = "FIGSHARE_TOKEN";
43
44    /// Creates a new authentication wrapper from a raw token string.
45    #[must_use]
46    pub fn new(token: impl Into<String>) -> Self {
47        Self {
48            token: Some(SecretString::from(token.into())),
49        }
50    }
51
52    /// Creates an anonymous authentication wrapper for public API calls.
53    ///
54    /// # Examples
55    ///
56    /// ```
57    /// use figshare_rs::Auth;
58    ///
59    /// let auth = Auth::anonymous();
60    /// assert!(auth.is_anonymous());
61    /// ```
62    #[must_use]
63    pub fn anonymous() -> Self {
64        Self { token: None }
65    }
66
67    /// Reads a Figshare API token from [`Self::TOKEN_ENV_VAR`].
68    ///
69    /// # Errors
70    ///
71    /// Returns an error if the environment variable is missing or invalid.
72    pub fn from_env() -> Result<Self, FigshareError> {
73        Self::from_env_var(Self::TOKEN_ENV_VAR)
74    }
75
76    /// Reads a Figshare API token from a custom environment variable.
77    ///
78    /// # Errors
79    ///
80    /// Returns an error if the environment variable is missing or invalid.
81    pub fn from_env_var(name: &str) -> Result<Self, FigshareError> {
82        let token = std::env::var(name).map_err(|source| FigshareError::EnvVar {
83            name: name.to_owned(),
84            source,
85        })?;
86        Ok(Self::new(token))
87    }
88
89    /// Returns whether this authentication wrapper is anonymous.
90    #[must_use]
91    pub fn is_anonymous(&self) -> bool {
92        self.token.is_none()
93    }
94}
95
96impl From<SecretString> for Auth {
97    fn from(token: SecretString) -> Self {
98        Self { token: Some(token) }
99    }
100}
101
102impl std::fmt::Debug for Auth {
103    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104        if self.is_anonymous() {
105            f.debug_struct("Auth")
106                .field("token", &"<anonymous>")
107                .finish()
108        } else {
109            f.debug_struct("Auth")
110                .field("token", &"<redacted>")
111                .finish()
112        }
113    }
114}
115
116/// Builder for configuring a [`FigshareClient`].
117#[derive(Clone, Debug)]
118pub struct FigshareClientBuilder {
119    auth: Auth,
120    endpoint: Endpoint,
121    poll: PollOptions,
122    user_agent: Option<String>,
123    request_timeout: Option<Duration>,
124    connect_timeout: Option<Duration>,
125}
126
127impl FigshareClientBuilder {
128    /// Overrides the API endpoint used by the client.
129    #[must_use]
130    pub fn endpoint(mut self, endpoint: Endpoint) -> Self {
131        self.endpoint = endpoint;
132        self
133    }
134
135    /// Overrides the `User-Agent` header sent on each request.
136    #[must_use]
137    pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
138        self.user_agent = Some(user_agent.into());
139        self
140    }
141
142    /// Sets the overall HTTP request timeout used by the underlying client.
143    #[must_use]
144    pub fn request_timeout(mut self, timeout: Duration) -> Self {
145        self.request_timeout = Some(timeout);
146        self
147    }
148
149    /// Sets the TCP connect timeout used by the underlying client.
150    #[must_use]
151    pub fn connect_timeout(mut self, timeout: Duration) -> Self {
152        self.connect_timeout = Some(timeout);
153        self
154    }
155
156    /// Overrides the polling policy used by upload and publish helpers.
157    #[must_use]
158    pub fn poll_options(mut self, poll: PollOptions) -> Self {
159        self.poll = poll;
160        self
161    }
162
163    /// Builds a configured [`FigshareClient`].
164    ///
165    /// # Errors
166    ///
167    /// Returns an error if the underlying `reqwest` client cannot be built.
168    pub fn build(self) -> Result<FigshareClient, FigshareError> {
169        let user_agent = self
170            .user_agent
171            .unwrap_or_else(|| format!("{}/{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")));
172
173        let mut inner = reqwest::Client::builder().user_agent(&user_agent);
174        if let Some(timeout) = self.request_timeout {
175            inner = inner.timeout(timeout);
176        }
177        if let Some(timeout) = self.connect_timeout {
178            inner = inner.connect_timeout(timeout);
179        }
180        let inner = inner.build()?;
181
182        Ok(FigshareClient {
183            inner,
184            auth: self.auth,
185            endpoint: self.endpoint,
186            poll: self.poll,
187            request_timeout: self.request_timeout,
188            connect_timeout: self.connect_timeout,
189        })
190    }
191}
192
193/// Typed async client for the core Figshare REST API.
194#[derive(Clone, Debug)]
195pub struct FigshareClient {
196    pub(crate) inner: reqwest::Client,
197    pub(crate) auth: Auth,
198    pub(crate) endpoint: Endpoint,
199    pub(crate) poll: PollOptions,
200    pub(crate) request_timeout: Option<Duration>,
201    pub(crate) connect_timeout: Option<Duration>,
202}
203
204impl FigshareClient {
205    const MAX_PAGE_SIZE: u64 = 1_000;
206
207    /// Starts building a new client from authentication settings.
208    #[must_use]
209    pub fn builder(auth: Auth) -> FigshareClientBuilder {
210        FigshareClientBuilder {
211            auth,
212            endpoint: Endpoint::default(),
213            poll: PollOptions::default(),
214            user_agent: None,
215            request_timeout: None,
216            connect_timeout: None,
217        }
218    }
219
220    /// Builds a client with default endpoint and polling options.
221    ///
222    /// # Errors
223    ///
224    /// Returns an error if the underlying HTTP client cannot be initialized.
225    pub fn new(auth: Auth) -> Result<Self, FigshareError> {
226        Self::builder(auth).build()
227    }
228
229    /// Builds a client directly from a raw API token.
230    ///
231    /// # Errors
232    ///
233    /// Returns an error if the underlying HTTP client cannot be initialized.
234    pub fn with_token(token: impl Into<String>) -> Result<Self, FigshareError> {
235        Self::new(Auth::new(token))
236    }
237
238    /// Builds an anonymous client for public API calls.
239    ///
240    /// # Errors
241    ///
242    /// Returns an error if the underlying HTTP client cannot be initialized.
243    pub fn anonymous() -> Result<Self, FigshareError> {
244        Self::new(Auth::anonymous())
245    }
246
247    /// Builds a client from [`Auth::TOKEN_ENV_VAR`].
248    ///
249    /// # Errors
250    ///
251    /// Returns an error if the environment variable is missing or invalid, or
252    /// if the underlying HTTP client cannot be initialized.
253    pub fn from_env() -> Result<Self, FigshareError> {
254        Self::new(Auth::from_env()?)
255    }
256
257    /// Returns the configured API endpoint.
258    #[must_use]
259    pub fn endpoint(&self) -> &Endpoint {
260        &self.endpoint
261    }
262
263    /// Returns the configured polling behavior.
264    #[must_use]
265    pub fn poll_options(&self) -> &PollOptions {
266        &self.poll
267    }
268
269    /// Returns the configured overall HTTP request timeout.
270    #[must_use]
271    pub fn request_timeout(&self) -> Option<Duration> {
272        self.request_timeout
273    }
274
275    /// Returns the configured TCP connect timeout.
276    #[must_use]
277    pub fn connect_timeout(&self) -> Option<Duration> {
278        self.connect_timeout
279    }
280
281    pub(crate) fn request(
282        &self,
283        method: Method,
284        path: &str,
285        auth_required: bool,
286    ) -> Result<RequestBuilder, FigshareError> {
287        let url = self.endpoint.base_url()?.join(path)?;
288        self.request_url(method, url, auth_required)
289    }
290
291    pub(crate) fn request_url(
292        &self,
293        method: Method,
294        url: Url,
295        auth_required: bool,
296    ) -> Result<RequestBuilder, FigshareError> {
297        if !self.is_trusted_api_url(&url)? {
298            return Err(FigshareError::InvalidState(format!(
299                "refusing API request to different origin: {url}"
300            )));
301        }
302
303        let mut request = self
304            .inner
305            .request(method, url)
306            .header(ACCEPT, "application/json");
307        if auth_required {
308            request = request.header(
309                AUTHORIZATION,
310                self.authorization_header_value("api request")?,
311            );
312        }
313
314        Ok(request)
315    }
316
317    pub(crate) fn upload_request_url(
318        &self,
319        method: Method,
320        url: Url,
321    ) -> Result<RequestBuilder, FigshareError> {
322        if !self.is_trusted_upload_url(&url)? {
323            return Err(FigshareError::InvalidState(format!(
324                "refusing upload request to different origin: {url}"
325            )));
326        }
327
328        Ok(self.inner.request(method, url).header(
329            AUTHORIZATION,
330            self.authorization_header_value("upload request")?,
331        ))
332    }
333
334    pub(crate) fn download_request_url(
335        &self,
336        method: Method,
337        url: Url,
338        auth_download: bool,
339    ) -> Result<RequestBuilder, FigshareError> {
340        let url = if auth_download {
341            self.with_download_token(url, "private file download")?
342        } else {
343            url
344        };
345        Ok(self.inner.request(method, url))
346    }
347
348    pub(crate) async fn execute_json<T>(&self, request: RequestBuilder) -> Result<T, FigshareError>
349    where
350        T: DeserializeOwned,
351    {
352        let response = request.send().await?;
353        if !response.status().is_success() {
354            return Err(FigshareError::from_response(response).await);
355        }
356
357        let bytes = response.bytes().await?;
358        Ok(serde_json::from_slice(&bytes)?)
359    }
360
361    pub(crate) async fn execute_unit(&self, request: RequestBuilder) -> Result<(), FigshareError> {
362        let response = request.send().await?;
363        if !response.status().is_success() {
364            return Err(FigshareError::from_response(response).await);
365        }
366
367        Ok(())
368    }
369
370    pub(crate) async fn execute_response(
371        &self,
372        request: RequestBuilder,
373    ) -> Result<reqwest::Response, FigshareError> {
374        let response = request.send().await?;
375        if !response.status().is_success() {
376            return Err(FigshareError::from_response(response).await);
377        }
378
379        Ok(response)
380    }
381
382    async fn execute_location(&self, request: RequestBuilder) -> Result<Url, FigshareError> {
383        let response = request.send().await?;
384        if !response.status().is_success() {
385            return Err(FigshareError::from_response(response).await);
386        }
387
388        let response_url = response.url().clone();
389        if let Some(location) = response
390            .headers()
391            .get(LOCATION)
392            .and_then(|value| value.to_str().ok())
393        {
394            return parse_location(&response_url, location);
395        }
396
397        let bytes = response.bytes().await?;
398        if bytes.is_empty() {
399            return Err(FigshareError::InvalidState(
400                "successful Figshare response did not include a location".into(),
401            ));
402        }
403
404        if let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) {
405            if let Some(location) = value.get("location").and_then(serde_json::Value::as_str) {
406                return parse_location(&response_url, location);
407            }
408            if let Some(location) = value.as_str() {
409                return parse_location(&response_url, location);
410            }
411        }
412
413        let text = String::from_utf8_lossy(&bytes);
414        parse_location(&response_url, text.trim())
415    }
416
417    fn is_trusted_api_url(&self, url: &Url) -> Result<bool, FigshareError> {
418        Ok(self.endpoint.base_url()?.origin() == url.origin())
419    }
420
421    fn is_trusted_upload_url(&self, url: &Url) -> Result<bool, FigshareError> {
422        let endpoint = self.endpoint.base_url()?;
423        Ok(endpoint.origin() == url.origin()
424            || url.host_str().is_some_and(is_trusted_figshare_upload_host))
425    }
426
427    fn authorization_header_value(&self, operation: &'static str) -> Result<String, FigshareError> {
428        let token = self
429            .auth
430            .token
431            .as_ref()
432            .ok_or(FigshareError::MissingAuth(operation))?;
433        Ok(format!("token {}", token.expose_secret()))
434    }
435
436    fn with_download_token(
437        &self,
438        mut url: Url,
439        operation: &'static str,
440    ) -> Result<Url, FigshareError> {
441        let token = self
442            .auth
443            .token
444            .as_ref()
445            .ok_or(FigshareError::MissingAuth(operation))?;
446
447        let should_append = url
448            .host_str()
449            .is_some_and(|host| host.eq_ignore_ascii_case("ndownloader.figshare.com"))
450            || self.endpoint.base_url()?.origin() == url.origin();
451
452        if should_append {
453            url.query_pairs_mut()
454                .append_pair("token", token.expose_secret());
455        }
456
457        Ok(url)
458    }
459
460    async fn get_public_article_by_url(&self, url: &Url) -> Result<Article, FigshareError> {
461        self.execute_json(self.request_url(Method::GET, url.clone(), false)?)
462            .await
463    }
464
465    async fn get_own_article_by_url(&self, url: &Url) -> Result<Article, FigshareError> {
466        self.execute_json(self.request_url(Method::GET, url.clone(), true)?)
467            .await
468    }
469
470    async fn get_file_by_url(&self, url: &Url) -> Result<ArticleFile, FigshareError> {
471        self.execute_json(self.request_url(Method::GET, url.clone(), true)?)
472            .await
473    }
474
475    /// Lists public licenses.
476    ///
477    /// # Errors
478    ///
479    /// Returns an error if the request fails or Figshare returns a non-success
480    /// response.
481    pub async fn list_licenses(&self) -> Result<Vec<ArticleLicense>, FigshareError> {
482        self.execute_json(self.request(Method::GET, "licenses", false)?)
483            .await
484    }
485
486    /// Lists public categories.
487    ///
488    /// # Errors
489    ///
490    /// Returns an error if the request fails or Figshare returns a non-success
491    /// response.
492    pub async fn list_categories(&self) -> Result<Vec<ArticleCategory>, FigshareError> {
493        self.execute_json(self.request(Method::GET, "categories", false)?)
494            .await
495    }
496
497    /// Lists categories available to the authenticated account.
498    ///
499    /// # Errors
500    ///
501    /// Returns an error if authentication is missing, if the request fails, or
502    /// if Figshare returns a non-success response.
503    pub async fn list_account_categories(&self) -> Result<Vec<ArticleCategory>, FigshareError> {
504        self.execute_json(self.request(Method::GET, "account/categories", true)?)
505            .await
506    }
507
508    /// Lists public articles.
509    ///
510    /// # Errors
511    ///
512    /// Returns an error if the request fails or Figshare returns a non-success
513    /// response.
514    pub async fn list_public_articles(
515        &self,
516        query: &ArticleQuery,
517    ) -> Result<Vec<Article>, FigshareError> {
518        let pairs = query.as_public_list_query_pairs()?;
519        self.execute_json(self.request(Method::GET, "articles", false)?.query(&pairs))
520            .await
521    }
522
523    /// Searches public articles.
524    ///
525    /// # Errors
526    ///
527    /// Returns an error if the query is invalid, if the request fails, or if
528    /// Figshare returns a non-success response.
529    pub(crate) async fn search_public_articles(
530        &self,
531        query: &ArticleQuery,
532    ) -> Result<Vec<Article>, FigshareError> {
533        let body = query.as_public_search_body()?;
534        self.execute_json(
535            self.request(Method::POST, "articles/search", false)?
536                .json(&body),
537        )
538        .await
539    }
540
541    /// Reads one public article by ID.
542    ///
543    /// # Errors
544    ///
545    /// Returns an error if the request fails or Figshare returns a non-success
546    /// response.
547    pub(crate) async fn get_public_article(&self, id: ArticleId) -> Result<Article, FigshareError> {
548        self.execute_json(self.request(Method::GET, &format!("articles/{id}"), false)?)
549            .await
550    }
551
552    /// Lists public versions for one article.
553    ///
554    /// # Errors
555    ///
556    /// Returns an error if the request fails or Figshare returns a non-success
557    /// response.
558    pub async fn list_public_article_versions(
559        &self,
560        id: ArticleId,
561    ) -> Result<Vec<ArticleVersion>, FigshareError> {
562        self.execute_json(self.request(Method::GET, &format!("articles/{id}/versions"), false)?)
563            .await
564    }
565
566    /// Reads one specific public article version.
567    ///
568    /// # Errors
569    ///
570    /// Returns an error if the request fails or Figshare returns a non-success
571    /// response.
572    pub async fn get_public_article_version(
573        &self,
574        id: ArticleId,
575        version: u64,
576    ) -> Result<Article, FigshareError> {
577        self.execute_json(self.request(
578            Method::GET,
579            &format!("articles/{id}/versions/{version}"),
580            false,
581        )?)
582        .await
583    }
584
585    /// Resolves a public article by exact DOI match.
586    ///
587    /// # Errors
588    ///
589    /// Returns an error if the request fails, Figshare returns a non-success
590    /// response, or no exact DOI match is found.
591    pub(crate) async fn get_public_article_by_doi(
592        &self,
593        doi: &Doi,
594    ) -> Result<Article, FigshareError> {
595        let hits = self
596            .list_public_articles(&ArticleQuery::builder().doi(doi.as_str()).limit(10).build())
597            .await?;
598        hits.into_iter()
599            .find(|article| article.doi.as_ref() == Some(doi))
600            .ok_or_else(|| {
601                FigshareError::UnsupportedSelector(format!(
602                    "no public article matched DOI {}",
603                    doi.as_str()
604                ))
605            })
606    }
607
608    /// Resolves the latest public article version for a given article ID.
609    ///
610    /// # Errors
611    ///
612    /// Returns an error if the request fails or Figshare returns a non-success
613    /// response.
614    pub(crate) async fn resolve_latest_public_article(
615        &self,
616        id: ArticleId,
617    ) -> Result<Article, FigshareError> {
618        let article = self.get_public_article(id).await?;
619        let versions = self.list_public_article_versions(id).await?;
620        let Some(latest) = versions.iter().max_by_key(|version| version.version) else {
621            return Ok(article);
622        };
623
624        if article.version_number() == Some(latest.version) {
625            return Ok(article);
626        }
627
628        self.get_public_article_version(id, latest.version).await
629    }
630
631    /// Resolves the latest public article version for a DOI.
632    ///
633    /// # Errors
634    ///
635    /// Returns an error if the request fails or no matching article exists.
636    pub(crate) async fn resolve_latest_public_article_by_doi(
637        &self,
638        doi: &Doi,
639    ) -> Result<Article, FigshareError> {
640        let article = self.get_public_article_by_doi(doi).await?;
641        self.resolve_latest_public_article(article.id).await
642    }
643
644    /// Lists the authenticated account's own articles.
645    ///
646    /// # Errors
647    ///
648    /// Returns an error if authentication is missing, if the request fails, or
649    /// if Figshare returns a non-success response.
650    pub async fn list_own_articles(
651        &self,
652        query: &ArticleQuery,
653    ) -> Result<Vec<Article>, FigshareError> {
654        let pairs = query.as_own_list_query_pairs()?;
655        self.execute_json(
656            self.request(Method::GET, "account/articles", true)?
657                .query(&pairs),
658        )
659        .await
660    }
661
662    /// Searches the authenticated account's own articles.
663    ///
664    /// # Errors
665    ///
666    /// Returns an error if authentication is missing, if the query is invalid,
667    /// if the request fails, or if Figshare returns a non-success response.
668    pub async fn search_own_articles(
669        &self,
670        query: &ArticleQuery,
671    ) -> Result<Vec<Article>, FigshareError> {
672        let body = query.as_own_search_body()?;
673        self.execute_json(
674            self.request(Method::POST, "account/articles/search", true)?
675                .json(&body),
676        )
677        .await
678    }
679
680    /// Reads one private article owned by the authenticated account.
681    ///
682    /// # Errors
683    ///
684    /// Returns an error if authentication is missing, if the request fails, or
685    /// if Figshare returns a non-success response.
686    pub async fn get_own_article(&self, id: ArticleId) -> Result<Article, FigshareError> {
687        self.execute_json(self.request(Method::GET, &format!("account/articles/{id}"), true)?)
688            .await
689    }
690
691    /// Creates a new private article.
692    ///
693    /// # Errors
694    ///
695    /// Returns an error if authentication is missing, if the request fails, or
696    /// if Figshare returns a non-success response.
697    pub(crate) async fn create_article(
698        &self,
699        metadata: &ArticleMetadata,
700    ) -> Result<Article, FigshareError> {
701        let location = self
702            .execute_location(
703                self.request(Method::POST, "account/articles", true)?
704                    .json(&metadata.to_payload()),
705            )
706            .await?;
707        self.get_own_article_by_url(&location).await
708    }
709
710    /// Updates a private article in place.
711    ///
712    /// # Errors
713    ///
714    /// Returns an error if authentication is missing, if the request fails, or
715    /// if Figshare returns a non-success response.
716    pub(crate) async fn update_article(
717        &self,
718        id: ArticleId,
719        metadata: &ArticleMetadata,
720    ) -> Result<Article, FigshareError> {
721        self.execute_unit(
722            self.request(Method::PUT, &format!("account/articles/{id}"), true)?
723                .json(&metadata.to_payload()),
724        )
725        .await?;
726        self.get_own_article(id).await
727    }
728
729    /// Deletes a private article.
730    ///
731    /// # Errors
732    ///
733    /// Returns an error if authentication is missing, if the request fails, or
734    /// if Figshare returns a non-success response.
735    pub async fn delete_article(&self, id: ArticleId) -> Result<(), FigshareError> {
736        self.execute_unit(self.request(Method::DELETE, &format!("account/articles/{id}"), true)?)
737            .await
738    }
739
740    /// Reserves a DOI for a private article.
741    ///
742    /// # Errors
743    ///
744    /// Returns an error if authentication is missing, if the request fails, or
745    /// if Figshare returns a non-success response.
746    pub async fn reserve_doi(&self, id: ArticleId) -> Result<Doi, FigshareError> {
747        #[derive(Deserialize)]
748        struct Payload {
749            doi: Doi,
750        }
751
752        let payload: Payload = self
753            .execute_json(self.request(
754                Method::POST,
755                &format!("account/articles/{id}/reserve_doi"),
756                true,
757            )?)
758            .await?;
759        Ok(payload.doi)
760    }
761
762    /// Publishes a private article and returns the new public version.
763    ///
764    /// # Errors
765    ///
766    /// Returns an error if authentication is missing, if the request fails, or
767    /// if Figshare returns a non-success response.
768    pub(crate) async fn publish_article(&self, id: ArticleId) -> Result<Article, FigshareError> {
769        let location = self
770            .execute_location(self.request(
771                Method::POST,
772                &format!("account/articles/{id}/publish"),
773                true,
774            )?)
775            .await?;
776        self.wait_for_public_article_by_url(&location).await
777    }
778
779    /// Lists files attached to a private article.
780    ///
781    /// # Errors
782    ///
783    /// Returns an error if authentication is missing, if the request fails, or
784    /// if Figshare returns a non-success response.
785    pub async fn list_files(&self, id: ArticleId) -> Result<Vec<ArticleFile>, FigshareError> {
786        self.list_paginated_files(&format!("account/articles/{id}/files"), true)
787            .await
788    }
789
790    /// Lists files attached to one public article version.
791    ///
792    /// # Errors
793    ///
794    /// Returns an error if the request fails or if Figshare returns a
795    /// non-success response.
796    pub async fn list_public_article_version_files(
797        &self,
798        article_id: ArticleId,
799        version: u64,
800    ) -> Result<Vec<ArticleFile>, FigshareError> {
801        self.list_paginated_files(
802            &format!("articles/{article_id}/versions/{version}/files"),
803            false,
804        )
805        .await
806    }
807
808    /// Reads one file attached to a private article.
809    ///
810    /// # Errors
811    ///
812    /// Returns an error if authentication is missing, if the request fails, or
813    /// if Figshare returns a non-success response.
814    pub async fn get_file(
815        &self,
816        article_id: ArticleId,
817        file_id: FileId,
818    ) -> Result<ArticleFile, FigshareError> {
819        self.execute_json(self.request(
820            Method::GET,
821            &format!("account/articles/{article_id}/files/{file_id}"),
822            true,
823        )?)
824        .await
825    }
826
827    /// Deletes a file from a private article.
828    ///
829    /// # Errors
830    ///
831    /// Returns an error if authentication is missing, if the request fails, or
832    /// if Figshare returns a non-success response.
833    pub async fn delete_file(
834        &self,
835        article_id: ArticleId,
836        file_id: FileId,
837    ) -> Result<(), FigshareError> {
838        self.execute_unit(self.request(
839            Method::DELETE,
840            &format!("account/articles/{article_id}/files/{file_id}"),
841            true,
842        )?)
843        .await
844    }
845
846    /// Initiates a hosted file upload for a private article.
847    ///
848    /// # Errors
849    ///
850    /// Returns an error if authentication is missing, if the request fails, or
851    /// if Figshare returns a non-success response.
852    pub async fn initiate_file_upload(
853        &self,
854        article_id: ArticleId,
855        name: &str,
856        size: u64,
857        md5: &str,
858    ) -> Result<ArticleFile, FigshareError> {
859        let payload = serde_json::json!({
860            "name": name,
861            "size": size,
862            "md5": md5,
863        });
864        let location = self
865            .execute_location(
866                self.request(
867                    Method::POST,
868                    &format!("account/articles/{article_id}/files"),
869                    true,
870                )?
871                .json(&payload),
872            )
873            .await?;
874        self.get_file_by_url(&location).await
875    }
876
877    /// Initiates a link-only file attachment for a private article.
878    ///
879    /// # Errors
880    ///
881    /// Returns an error if authentication is missing, if the request fails, or
882    /// if Figshare returns a non-success response.
883    pub async fn initiate_link_file(
884        &self,
885        article_id: ArticleId,
886        link: &str,
887    ) -> Result<ArticleFile, FigshareError> {
888        let payload = serde_json::json!({ "link": link });
889        let location = self
890            .execute_location(
891                self.request(
892                    Method::POST,
893                    &format!("account/articles/{article_id}/files"),
894                    true,
895                )?
896                .json(&payload),
897            )
898            .await?;
899        self.get_file_by_url(&location).await
900    }
901
902    /// Reads one upload session from the upload service.
903    ///
904    /// # Errors
905    ///
906    /// Returns an error if authentication is missing, if the request fails, or
907    /// if Figshare returns a non-success response.
908    pub async fn get_upload_session(
909        &self,
910        upload_url: &Url,
911    ) -> Result<UploadSession, FigshareError> {
912        self.execute_json(self.upload_request_url(Method::GET, upload_url.clone())?)
913            .await
914    }
915
916    /// Uploads one part to the upload service.
917    ///
918    /// # Errors
919    ///
920    /// Returns an error if authentication is missing, if the request fails, or
921    /// if Figshare returns a non-success response.
922    pub async fn upload_part(
923        &self,
924        upload_url: &Url,
925        part_no: u64,
926        bytes: Vec<u8>,
927    ) -> Result<(), FigshareError> {
928        self.execute_unit(
929            self.upload_request_url(Method::PUT, upload_part_url(upload_url, part_no)?)?
930                .body(bytes),
931        )
932        .await
933    }
934
935    /// Resets one uploaded part.
936    ///
937    /// # Errors
938    ///
939    /// Returns an error if authentication is missing, if the request fails, or
940    /// if Figshare returns a non-success response.
941    pub async fn reset_upload_part(
942        &self,
943        upload_url: &Url,
944        part_no: u64,
945    ) -> Result<(), FigshareError> {
946        self.execute_unit(
947            self.upload_request_url(Method::DELETE, upload_part_url(upload_url, part_no)?)?,
948        )
949        .await
950    }
951
952    /// Marks an uploaded file as complete.
953    ///
954    /// # Errors
955    ///
956    /// Returns an error if authentication is missing, if the request fails, or
957    /// if Figshare returns a non-success response.
958    pub async fn complete_file_upload(
959        &self,
960        article_id: ArticleId,
961        file_id: FileId,
962    ) -> Result<(), FigshareError> {
963        self.execute_unit(self.request(
964            Method::POST,
965            &format!("account/articles/{article_id}/files/{file_id}"),
966            true,
967        )?)
968        .await
969    }
970
971    /// Uploads a local file path to a private article.
972    ///
973    /// # Errors
974    ///
975    /// Returns an error if authentication is missing, if the local file cannot
976    /// be read, if the request fails, or if Figshare returns a non-success
977    /// response.
978    pub async fn upload_path(
979        &self,
980        article_id: ArticleId,
981        path: &Path,
982    ) -> Result<ArticleFile, FigshareError> {
983        let filename = path
984            .file_name()
985            .map(|name| name.to_string_lossy().into_owned())
986            .ok_or_else(|| {
987                FigshareError::InvalidState("path has no final file name segment".into())
988            })?;
989        self.upload_path_with_filename(article_id, &filename, path)
990            .await
991    }
992
993    /// Uploads data from a blocking reader by staging it to a temporary file
994    /// and performing a standard Figshare hosted upload.
995    ///
996    /// # Errors
997    ///
998    /// Returns an error if authentication is missing, if staging the reader
999    /// fails, if the request fails, or if Figshare returns a non-success
1000    /// response.
1001    pub async fn upload_reader<R>(
1002        &self,
1003        article_id: ArticleId,
1004        filename: &str,
1005        reader: R,
1006        content_length: u64,
1007    ) -> Result<ArticleFile, FigshareError>
1008    where
1009        R: Read + Send + 'static,
1010    {
1011        let staged = tokio::task::spawn_blocking(move || stage_reader(reader, content_length))
1012            .await
1013            .map_err(|error| {
1014                FigshareError::InvalidState(format!("reader staging task failed: {error}"))
1015            })??;
1016        self.upload_path_with_filename(article_id, filename, staged.path())
1017            .await
1018    }
1019
1020    pub(crate) async fn wait_for_public_article_by_url(
1021        &self,
1022        url: &Url,
1023    ) -> Result<Article, FigshareError> {
1024        let start = Instant::now();
1025        let mut delay = self.poll.initial_delay;
1026
1027        loop {
1028            match self.get_public_article_by_url(url).await {
1029                Ok(article) => return Ok(article),
1030                Err(FigshareError::Http { status, .. })
1031                    if status == reqwest::StatusCode::NOT_FOUND
1032                        && start.elapsed() < self.poll.max_wait =>
1033                {
1034                    sleep(delay).await;
1035                    delay = min(delay.saturating_mul(2), self.poll.max_delay);
1036                }
1037                Err(error) => return Err(error),
1038            }
1039
1040            if start.elapsed() >= self.poll.max_wait {
1041                return Err(FigshareError::Timeout("public article publication"));
1042            }
1043        }
1044    }
1045
1046    pub(crate) async fn wait_for_own_article_public(
1047        &self,
1048        article_id: ArticleId,
1049    ) -> Result<Article, FigshareError> {
1050        let start = Instant::now();
1051        let mut delay = self.poll.initial_delay;
1052
1053        loop {
1054            let article = self.get_own_article(article_id).await?;
1055            if article.is_public_article() {
1056                return Ok(article);
1057            }
1058            if start.elapsed() >= self.poll.max_wait {
1059                return Err(FigshareError::Timeout("private article publication"));
1060            }
1061
1062            sleep(delay).await;
1063            delay = min(delay.saturating_mul(2), self.poll.max_delay);
1064        }
1065    }
1066
1067    pub(crate) async fn upload_path_with_filename(
1068        &self,
1069        article_id: ArticleId,
1070        filename: &str,
1071        path: &Path,
1072    ) -> Result<ArticleFile, FigshareError> {
1073        let path = path.to_path_buf();
1074        let checksum_path = path.clone();
1075        let (md5, size) = tokio::task::spawn_blocking(move || checksum_and_size(&checksum_path))
1076            .await
1077            .map_err(|error| {
1078                FigshareError::InvalidState(format!("checksum task failed: {error}"))
1079            })??;
1080
1081        let file = self
1082            .initiate_file_upload(article_id, filename, size, &md5)
1083            .await?;
1084        let result = async {
1085            let upload_url = file
1086                .upload_session_url()
1087                .cloned()
1088                .ok_or(FigshareError::MissingLink("upload_url"))?;
1089            let session = self.get_upload_session(&upload_url).await?;
1090
1091            for part in &session.parts {
1092                let path = path.clone();
1093                let start_offset = part.start_offset;
1094                let len = part.len();
1095                let bytes =
1096                    tokio::task::spawn_blocking(move || read_path_range(&path, start_offset, len))
1097                        .await
1098                        .map_err(|error| {
1099                            FigshareError::InvalidState(format!(
1100                                "path part read task failed: {error}"
1101                            ))
1102                        })??;
1103                self.upload_part(&upload_url, part.part_no, bytes).await?;
1104            }
1105
1106            self.complete_file_upload(article_id, file.id).await?;
1107            let final_session = self.wait_for_upload_completion(&upload_url).await?;
1108            if matches!(final_session.status, UploadStatus::Aborted) {
1109                return Err(FigshareError::InvalidState(
1110                    "Figshare upload was aborted".into(),
1111                ));
1112            }
1113
1114            self.get_file(article_id, file.id).await
1115        }
1116        .await;
1117
1118        match result {
1119            Ok(file) => Ok(file),
1120            Err(error) => {
1121                let _ = self.delete_file(article_id, file.id).await;
1122                Err(error)
1123            }
1124        }
1125    }
1126
1127    async fn wait_for_upload_completion(
1128        &self,
1129        upload_url: &Url,
1130    ) -> Result<UploadSession, FigshareError> {
1131        let start = Instant::now();
1132        let mut delay = self.poll.initial_delay;
1133
1134        loop {
1135            let session = self.get_upload_session(upload_url).await?;
1136            if session.is_completed() {
1137                return Ok(session);
1138            }
1139            if matches!(session.status, UploadStatus::Aborted) {
1140                return Ok(session);
1141            }
1142            if start.elapsed() >= self.poll.max_wait {
1143                return Err(FigshareError::Timeout("upload completion"));
1144            }
1145
1146            sleep(delay).await;
1147            delay = min(delay.saturating_mul(2), self.poll.max_delay);
1148        }
1149    }
1150
1151    async fn list_paginated_files(
1152        &self,
1153        path: &str,
1154        auth_required: bool,
1155    ) -> Result<Vec<ArticleFile>, FigshareError> {
1156        let max_page_size = usize::try_from(Self::MAX_PAGE_SIZE).map_err(|_| {
1157            FigshareError::InvalidState("configured file page size does not fit usize".into())
1158        })?;
1159        let mut files = Vec::new();
1160        let mut page = 1_u64;
1161
1162        loop {
1163            let batch: Vec<ArticleFile> = self
1164                .execute_json(self.request(Method::GET, path, auth_required)?.query(&[
1165                    ("page", page.to_string()),
1166                    ("page_size", Self::MAX_PAGE_SIZE.to_string()),
1167                ]))
1168                .await?;
1169            let batch_len = batch.len();
1170            files.extend(batch);
1171
1172            if batch_len < max_page_size {
1173                return Ok(files);
1174            }
1175            page += 1;
1176        }
1177    }
1178}
1179
1180fn parse_location(base: &Url, location: &str) -> Result<Url, FigshareError> {
1181    match Url::parse(location) {
1182        Ok(url) => Ok(url),
1183        Err(url::ParseError::RelativeUrlWithoutBase) => Ok(base.join(location)?),
1184        Err(error) => Err(FigshareError::Url(error)),
1185    }
1186}
1187
1188fn is_trusted_figshare_upload_host(host: &str) -> bool {
1189    let host = host.to_ascii_lowercase();
1190    host == "uploads.figshare.com"
1191        || host
1192            .strip_suffix(".figshare.com")
1193            .is_some_and(|subdomain| subdomain.starts_with("fup-"))
1194}
1195
1196fn upload_part_url(upload_url: &Url, part_no: u64) -> Result<Url, FigshareError> {
1197    let mut url = upload_url.clone();
1198    let mut segments = url.path_segments_mut().map_err(|()| {
1199        FigshareError::InvalidState("upload URL cannot accept part number segments".into())
1200    })?;
1201    segments.pop_if_empty();
1202    segments.push(&part_no.to_string());
1203    drop(segments);
1204    Ok(url)
1205}
1206
1207fn checksum_and_size(path: &Path) -> Result<(String, u64), FigshareError> {
1208    let mut file = std::fs::File::open(path)?;
1209    let mut hasher = Md5::new();
1210    let mut size = 0_u64;
1211    let mut buffer = vec![0_u8; 64 * 1024];
1212
1213    loop {
1214        let read = file.read(&mut buffer)?;
1215        if read == 0 {
1216            break;
1217        }
1218        size += u64::try_from(read).map_err(|_| {
1219            FigshareError::InvalidState("read chunk length does not fit in u64".into())
1220        })?;
1221        hasher.update(&buffer[..read]);
1222    }
1223
1224    Ok((hex::encode(hasher.finalize()), size))
1225}
1226
1227fn read_path_range(path: &Path, offset: u64, len: u64) -> Result<Vec<u8>, FigshareError> {
1228    let mut file = std::fs::File::open(path)?;
1229    file.seek(SeekFrom::Start(offset))?;
1230    let len = usize::try_from(len).map_err(|_| {
1231        std::io::Error::new(
1232            std::io::ErrorKind::InvalidInput,
1233            "requested byte range does not fit in memory on this platform",
1234        )
1235    })?;
1236    let mut bytes = vec![0_u8; len];
1237    file.read_exact(&mut bytes)?;
1238    Ok(bytes)
1239}
1240
1241fn stage_reader<R>(mut reader: R, content_length: u64) -> Result<NamedTempFile, FigshareError>
1242where
1243    R: Read,
1244{
1245    let mut tempfile = NamedTempFile::new()?;
1246    let written = std::io::copy(&mut reader.by_ref().take(content_length), &mut tempfile)?;
1247    if written != content_length {
1248        return Err(FigshareError::InvalidState(format!(
1249            "reader produced {written} bytes but {content_length} were declared"
1250        )));
1251    }
1252    Ok(tempfile)
1253}
1254
1255#[cfg(test)]
1256mod tests {
1257    use std::env::VarError;
1258    use std::io::Cursor;
1259    use std::path::Path;
1260    use std::time::Duration;
1261
1262    use axum::extract::State;
1263    use axum::http::StatusCode;
1264    use axum::routing::{get, post};
1265    use axum::{Json, Router};
1266    use reqwest::header::{AUTHORIZATION, LOCATION};
1267    use reqwest::Method;
1268    use secrecy::ExposeSecret;
1269    use serde_json::json;
1270    use tokio::net::TcpListener;
1271    use tokio::time::sleep;
1272    use url::Url;
1273
1274    use super::{checksum_and_size, parse_location, upload_part_url, Auth, FigshareClient};
1275    use crate::{Endpoint, FigshareError, PollOptions};
1276
1277    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1278
1279    struct EnvVarGuard {
1280        name: &'static str,
1281        previous: Option<String>,
1282    }
1283
1284    impl EnvVarGuard {
1285        fn set(name: &'static str, value: Option<&str>) -> Self {
1286            let previous = std::env::var(name).ok();
1287            match value {
1288                Some(value) => std::env::set_var(name, value),
1289                None => std::env::remove_var(name),
1290            }
1291            Self { name, previous }
1292        }
1293    }
1294
1295    impl Drop for EnvVarGuard {
1296        fn drop(&mut self) {
1297            match &self.previous {
1298                Some(value) => std::env::set_var(self.name, value),
1299                None => std::env::remove_var(self.name),
1300            }
1301        }
1302    }
1303
1304    #[test]
1305    fn auth_helpers_cover_anonymous_and_env_loading() {
1306        let anonymous = Auth::anonymous();
1307        assert!(anonymous.is_anonymous());
1308        assert!(format!("{anonymous:?}").contains("<anonymous>"));
1309
1310        let _lock = ENV_LOCK.lock().unwrap();
1311        let _guard = EnvVarGuard::set(Auth::TOKEN_ENV_VAR, Some("figshare-token"));
1312        assert_eq!(
1313            Auth::from_env().unwrap().token.unwrap().expose_secret(),
1314            "figshare-token"
1315        );
1316    }
1317
1318    #[test]
1319    fn auth_env_missing_is_reported() {
1320        let _lock = ENV_LOCK.lock().unwrap();
1321        let _guard = EnvVarGuard::set(Auth::TOKEN_ENV_VAR, None);
1322        match Auth::from_env().unwrap_err() {
1323            FigshareError::EnvVar { name, source } => {
1324                assert_eq!(name, Auth::TOKEN_ENV_VAR);
1325                assert!(matches!(source, VarError::NotPresent));
1326            }
1327            other => panic!("unexpected error: {other:?}"),
1328        }
1329    }
1330
1331    #[test]
1332    fn auth_debug_redacts_tokens_and_custom_env_vars_are_supported() {
1333        const CUSTOM_ENV: &str = "FIGSHARE_RS_TEST_TOKEN";
1334
1335        let auth = Auth::new("secret-token");
1336        assert!(!auth.is_anonymous());
1337        assert!(format!("{auth:?}").contains("<redacted>"));
1338
1339        let _lock = ENV_LOCK.lock().unwrap();
1340        let _guard = EnvVarGuard::set(CUSTOM_ENV, Some("custom-token"));
1341        assert_eq!(
1342            Auth::from_env_var(CUSTOM_ENV)
1343                .unwrap()
1344                .token
1345                .unwrap()
1346                .expose_secret(),
1347            "custom-token"
1348        );
1349    }
1350
1351    #[test]
1352    fn upload_urls_and_locations_are_resolved() {
1353        let base = Url::parse("https://api.figshare.com/v2/account/articles").unwrap();
1354        assert_eq!(
1355            parse_location(&base, "/v2/account/articles/1")
1356                .unwrap()
1357                .as_str(),
1358            "https://api.figshare.com/v2/account/articles/1"
1359        );
1360
1361        let upload_url = Url::parse("https://uploads.figshare.com/upload/token").unwrap();
1362        assert_eq!(
1363            upload_part_url(&upload_url, 7).unwrap().as_str(),
1364            "https://uploads.figshare.com/upload/token/7"
1365        );
1366    }
1367
1368    #[test]
1369    fn checksum_and_reader_staging_cover_local_helpers() {
1370        let dir = tempfile::tempdir().unwrap();
1371        let path = dir.path().join("artifact.bin");
1372        std::fs::write(&path, b"hello").unwrap();
1373
1374        let (md5, size) = checksum_and_size(&path).unwrap();
1375        assert_eq!(size, 5);
1376        assert_eq!(md5, "5d41402abc4b2a76b9719d911017c592");
1377
1378        let staged = super::stage_reader(Cursor::new(b"world".to_vec()), 5).unwrap();
1379        assert_eq!(std::fs::read(staged.path()).unwrap(), b"world");
1380    }
1381
1382    #[tokio::test]
1383    async fn request_timeout_is_enforced_for_http_calls() {
1384        #[derive(Clone)]
1385        struct DelayState {
1386            delay: Duration,
1387        }
1388
1389        async fn delayed_article(
1390            State(state): State<DelayState>,
1391        ) -> (StatusCode, Json<serde_json::Value>) {
1392            sleep(state.delay).await;
1393            (
1394                StatusCode::OK,
1395                Json(json!({
1396                    "id": 1,
1397                    "title": "slow"
1398                })),
1399            )
1400        }
1401
1402        let app = Router::new()
1403            .route("/v2/articles/1", get(delayed_article))
1404            .with_state(DelayState {
1405                delay: Duration::from_millis(50),
1406            });
1407        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1408        let addr = listener.local_addr().unwrap();
1409        let server = tokio::spawn(async move {
1410            axum::serve(listener, app).await.unwrap();
1411        });
1412
1413        let client = FigshareClient::builder(Auth::anonymous())
1414            .endpoint(Endpoint::Custom(
1415                Url::parse(&format!("http://{addr}/v2/")).unwrap(),
1416            ))
1417            .request_timeout(Duration::from_millis(10))
1418            .poll_options(PollOptions {
1419                max_wait: Duration::from_millis(25),
1420                initial_delay: Duration::from_millis(1),
1421                max_delay: Duration::from_millis(2),
1422            })
1423            .build()
1424            .unwrap();
1425
1426        let error = client
1427            .get_public_article(crate::ArticleId(1))
1428            .await
1429            .unwrap_err();
1430        match error {
1431            FigshareError::Transport(source) => assert!(source.is_timeout()),
1432            other => panic!("unexpected error: {other:?}"),
1433        }
1434
1435        server.abort();
1436    }
1437
1438    #[test]
1439    fn client_builder_preserves_configuration() {
1440        let poll = PollOptions {
1441            max_wait: Duration::from_secs(3),
1442            initial_delay: Duration::from_millis(2),
1443            max_delay: Duration::from_millis(4),
1444        };
1445        let endpoint = Endpoint::Custom(Url::parse("http://localhost:9999/v2/").unwrap());
1446        let client = FigshareClient::builder(Auth::new("token"))
1447            .endpoint(endpoint.clone())
1448            .user_agent("figshare-rs-tests/0.1")
1449            .request_timeout(Duration::from_secs(7))
1450            .connect_timeout(Duration::from_secs(2))
1451            .poll_options(poll.clone())
1452            .build()
1453            .unwrap();
1454
1455        assert_eq!(client.endpoint(), &endpoint);
1456        assert_eq!(client.poll_options(), &poll);
1457        assert_eq!(client.request_timeout(), Some(Duration::from_secs(7)));
1458        assert_eq!(client.connect_timeout(), Some(Duration::from_secs(2)));
1459        assert!(FigshareClient::anonymous().is_ok());
1460        assert!(FigshareClient::with_token("token").is_ok());
1461    }
1462
1463    #[test]
1464    fn private_operations_require_authentication() {
1465        let client = FigshareClient::anonymous().unwrap();
1466        let error = client
1467            .request(Method::GET, "account/articles/1", true)
1468            .unwrap_err();
1469        assert!(matches!(error, FigshareError::MissingAuth("api request")));
1470        let error = client
1471            .with_download_token(
1472                Url::parse("https://ndownloader.figshare.com/files/1").unwrap(),
1473                "private file download",
1474            )
1475            .unwrap_err();
1476        assert!(matches!(
1477            error,
1478            FigshareError::MissingAuth("private file download")
1479        ));
1480    }
1481
1482    #[test]
1483    fn download_token_is_only_added_for_trusted_hosts() {
1484        let client = FigshareClient::with_token("token").unwrap();
1485        let downloader = client
1486            .with_download_token(
1487                Url::parse("https://ndownloader.figshare.com/files/1").unwrap(),
1488                "private file download",
1489            )
1490            .unwrap();
1491        assert_eq!(downloader.query(), Some("token=token"));
1492
1493        let external = client
1494            .with_download_token(
1495                Url::parse("https://example.com/file.bin").unwrap(),
1496                "private file download",
1497            )
1498            .unwrap();
1499        assert_eq!(external.query(), None);
1500    }
1501
1502    #[test]
1503    fn request_helpers_enforce_origin_policies() {
1504        let client = FigshareClient::builder(Auth::new("token"))
1505            .endpoint(Endpoint::Custom(
1506                Url::parse("https://api.example.test/v2/").unwrap(),
1507            ))
1508            .build()
1509            .unwrap();
1510
1511        let api_error = client
1512            .request_url(
1513                Method::GET,
1514                Url::parse("https://evil.example.test/v2/articles").unwrap(),
1515                false,
1516            )
1517            .unwrap_err();
1518        assert!(
1519            matches!(api_error, FigshareError::InvalidState(message) if message.contains("different origin"))
1520        );
1521
1522        let upload_request = client
1523            .upload_request_url(
1524                Method::PUT,
1525                Url::parse("https://uploads.figshare.com/upload/token").unwrap(),
1526            )
1527            .unwrap()
1528            .build()
1529            .unwrap();
1530        assert_eq!(
1531            upload_request.url().host_str(),
1532            Some("uploads.figshare.com")
1533        );
1534        assert_eq!(
1535            upload_request.headers()[AUTHORIZATION].to_str().unwrap(),
1536            "token token"
1537        );
1538
1539        let regional_upload_request = client
1540            .upload_request_url(
1541                Method::PUT,
1542                Url::parse("https://fup-eu-west-1.figshare.com/upload/token").unwrap(),
1543            )
1544            .unwrap()
1545            .build()
1546            .unwrap();
1547        assert_eq!(
1548            regional_upload_request.url().host_str(),
1549            Some("fup-eu-west-1.figshare.com")
1550        );
1551        assert_eq!(
1552            regional_upload_request.headers()[AUTHORIZATION]
1553                .to_str()
1554                .unwrap(),
1555            "token token"
1556        );
1557
1558        let upload_error = client
1559            .upload_request_url(
1560                Method::PUT,
1561                Url::parse("https://evil.example.test/upload/token").unwrap(),
1562            )
1563            .unwrap_err();
1564        assert!(
1565            matches!(upload_error, FigshareError::InvalidState(message) if message.contains("different origin"))
1566        );
1567
1568        let public_download = client
1569            .download_request_url(
1570                Method::GET,
1571                Url::parse("https://downloads.example.test/file.bin").unwrap(),
1572                false,
1573            )
1574            .unwrap()
1575            .build()
1576            .unwrap();
1577        assert_eq!(public_download.url().query(), None);
1578    }
1579
1580    #[tokio::test]
1581    async fn execute_location_supports_multiple_success_shapes() {
1582        let app = Router::new()
1583            .route(
1584                "/v2/location/header",
1585                post(|| async {
1586                    (
1587                        StatusCode::CREATED,
1588                        [(LOCATION, "/v2/account/articles/1")],
1589                        Json(json!({ "ignored": true })),
1590                    )
1591                }),
1592            )
1593            .route(
1594                "/v2/location/object",
1595                post(|| async {
1596                    (
1597                        StatusCode::CREATED,
1598                        Json(json!({ "location": "/v2/account/articles/2" })),
1599                    )
1600                }),
1601            )
1602            .route(
1603                "/v2/location/string",
1604                post(|| async { (StatusCode::CREATED, Json(json!("/v2/account/articles/3"))) }),
1605            )
1606            .route(
1607                "/v2/location/text",
1608                post(|| async { (StatusCode::CREATED, "/v2/account/articles/4") }),
1609            )
1610            .route("/v2/location/empty", post(|| async { StatusCode::CREATED }));
1611        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1612        let addr = listener.local_addr().unwrap();
1613        let server = tokio::spawn(async move {
1614            axum::serve(listener, app).await.unwrap();
1615        });
1616
1617        let client = FigshareClient::builder(Auth::anonymous())
1618            .endpoint(Endpoint::Custom(
1619                Url::parse(&format!("http://{addr}/v2/")).unwrap(),
1620            ))
1621            .build()
1622            .unwrap();
1623
1624        let header = client
1625            .execute_location(
1626                client
1627                    .request(Method::POST, "location/header", false)
1628                    .unwrap(),
1629            )
1630            .await
1631            .unwrap();
1632        let object = client
1633            .execute_location(
1634                client
1635                    .request(Method::POST, "location/object", false)
1636                    .unwrap(),
1637            )
1638            .await
1639            .unwrap();
1640        let string = client
1641            .execute_location(
1642                client
1643                    .request(Method::POST, "location/string", false)
1644                    .unwrap(),
1645            )
1646            .await
1647            .unwrap();
1648        let text = client
1649            .execute_location(
1650                client
1651                    .request(Method::POST, "location/text", false)
1652                    .unwrap(),
1653            )
1654            .await
1655            .unwrap();
1656        let empty = client
1657            .execute_location(
1658                client
1659                    .request(Method::POST, "location/empty", false)
1660                    .unwrap(),
1661            )
1662            .await
1663            .unwrap_err();
1664
1665        assert_eq!(header.path(), "/v2/account/articles/1");
1666        assert_eq!(object.path(), "/v2/account/articles/2");
1667        assert_eq!(string.path(), "/v2/account/articles/3");
1668        assert_eq!(text.path(), "/v2/account/articles/4");
1669        assert!(
1670            matches!(empty, FigshareError::InvalidState(message) if message.contains("did not include a location"))
1671        );
1672
1673        server.abort();
1674    }
1675
1676    #[tokio::test]
1677    async fn list_paginated_files_fetches_multiple_pages() {
1678        async fn files_route(
1679            State(()): State<()>,
1680            axum::extract::Query(query): axum::extract::Query<
1681                std::collections::HashMap<String, String>,
1682            >,
1683        ) -> Json<Vec<serde_json::Value>> {
1684            let page = query
1685                .get("page")
1686                .and_then(|value| value.parse::<u64>().ok())
1687                .unwrap_or(1);
1688            let count = if page == 1 { 1_000 } else { 1 };
1689            let start = if page == 1 { 1 } else { 1_001 };
1690            Json(
1691                (start..start + count)
1692                    .map(|id| json!({ "id": id, "name": format!("file-{id}.bin"), "size": 1 }))
1693                    .collect(),
1694            )
1695        }
1696
1697        let app = Router::new()
1698            .route("/v2/account/articles/1/files", get(files_route))
1699            .with_state(());
1700        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1701        let addr = listener.local_addr().unwrap();
1702        let server = tokio::spawn(async move {
1703            axum::serve(listener, app).await.unwrap();
1704        });
1705
1706        let client = FigshareClient::builder(Auth::new("token"))
1707            .endpoint(Endpoint::Custom(
1708                Url::parse(&format!("http://{addr}/v2/")).unwrap(),
1709            ))
1710            .build()
1711            .unwrap();
1712
1713        let files = client
1714            .list_paginated_files("account/articles/1/files", true)
1715            .await
1716            .unwrap();
1717        assert_eq!(files.len(), 1_001);
1718        assert_eq!(files.first().unwrap().id.0, 1);
1719        assert_eq!(files.last().unwrap().id.0, 1_001);
1720
1721        server.abort();
1722    }
1723
1724    #[tokio::test]
1725    async fn upload_path_rejects_missing_filename() {
1726        let client = FigshareClient::anonymous().unwrap();
1727        let error = client
1728            .upload_path(crate::ArticleId(1), Path::new("/"))
1729            .await
1730            .unwrap_err();
1731        assert!(
1732            matches!(error, FigshareError::InvalidState(message) if message.contains("path has no final file name segment"))
1733        );
1734    }
1735}