hydrus_api/api_core/
client.rs

1use crate::api_core::common::{
2    FileIdentifier, FileRecord, FileSelection, FileServiceSelection, OptionalStringNumber,
3};
4use crate::api_core::endpoints::access_management::{
5    ApiVersion, ApiVersionResponse, GetServices, GetServicesResponse, SessionKey,
6    SessionKeyResponse, VerifyAccessKey, VerifyAccessKeyResponse,
7};
8use crate::api_core::endpoints::adding_files::{
9    AddFile, AddFileRequest, AddFileResponse, ArchiveFiles, ArchiveFilesRequest, DeleteFiles,
10    DeleteFilesRequest, UnarchiveFiles, UnarchiveFilesRequest, UndeleteFiles, UndeleteFilesRequest,
11};
12use crate::api_core::endpoints::adding_notes::{
13    DeleteNotes, DeleteNotesRequest, SetNotes, SetNotesRequest,
14};
15use crate::api_core::endpoints::adding_tags::{
16    AddTags, AddTagsRequest, CleanTags, CleanTagsResponse,
17};
18use crate::api_core::endpoints::adding_urls::{
19    AddUrl, AddUrlRequest, AddUrlResponse, AssociateUrl, AssociateUrlRequest, GetUrlFiles,
20    GetUrlFilesResponse, GetUrlInfo, GetUrlInfoResponse,
21};
22use crate::api_core::endpoints::client_builder::ClientBuilder;
23use crate::api_core::endpoints::managing_cookies_and_http_headers::{
24    GetCookies, GetCookiesResponse, SetCookies, SetCookiesRequest, SetUserAgent,
25    SetUserAgentRequest,
26};
27use crate::api_core::endpoints::managing_pages::{
28    AddFiles, AddFilesRequest, FocusPage, FocusPageRequest, GetPageInfo, GetPageInfoResponse,
29    GetPages, GetPagesResponse,
30};
31use crate::api_core::endpoints::searching_and_fetching_files::{
32    FileMetadata, FileMetadataResponse, FileMetadataType, FileSearchOptions, GetFile,
33    SearchFileHashes, SearchFileHashesResponse, SearchFiles, SearchFilesResponse, SearchQueryEntry,
34};
35use crate::api_core::endpoints::Endpoint;
36use crate::error::{Error, Result};
37use bytes::Buf;
38use reqwest::Response;
39use serde::de::DeserializeOwned;
40use serde::Serialize;
41use std::collections::HashMap;
42use std::fmt::Debug;
43
44use super::endpoints::adding_tags::{SearchTags, SearchTagsResponse, TagSearchOptions};
45
46const ACCESS_KEY_HEADER: &str = "Hydrus-Client-API-Access-Key";
47const CONTENT_TYPE_HEADER: &str = "Content-Type";
48const ACCEPT_HEADER: &str = "Accept";
49
50#[cfg(feature = "cbor")]
51const CONTENT_TYPE_CBOR: &str = "application/cbor";
52#[cfg(feature = "json")]
53const CONTENT_TYPE_JSON: &str = "application/json";
54
55#[derive(Clone)]
56/// A low level Client for the hydrus API. It provides basic abstraction
57/// over the REST api.
58#[derive(Debug)]
59pub struct Client {
60    pub(crate) inner: reqwest::Client,
61    pub(crate) base_url: String,
62    pub(crate) access_key: String,
63}
64
65impl Client {
66    /// Returns a builder for the client
67    pub fn builder() -> ClientBuilder {
68        ClientBuilder::default()
69    }
70
71    /// Creates a new client to start requests against the hydrus api.
72    pub fn new<S: AsRef<str>>(url: S, access_key: S) -> Self {
73        Self {
74            inner: reqwest::Client::new(),
75            access_key: access_key.as_ref().to_string(),
76            base_url: url.as_ref().to_string(),
77        }
78    }
79    /// Returns the current API version. It's being incremented every time the API changes.
80    #[tracing::instrument(skip(self), level = "debug")]
81    pub async fn api_version(&self) -> Result<ApiVersionResponse> {
82        self.get_and_parse::<ApiVersion, ()>(&()).await
83    }
84
85    /// Creates a new session key
86    #[tracing::instrument(skip(self), level = "debug")]
87    pub async fn session_key(&self) -> Result<SessionKeyResponse> {
88        self.get_and_parse::<SessionKey, ()>(&()).await
89    }
90
91    /// Verifies if the access key is valid and returns some information about its permissions
92    #[tracing::instrument(skip(self), level = "debug")]
93    pub async fn verify_access_key(&self) -> Result<VerifyAccessKeyResponse> {
94        self.get_and_parse::<VerifyAccessKey, ()>(&()).await
95    }
96
97    /// Returns the list of tag and file services of the client
98    #[tracing::instrument(skip(self), level = "debug")]
99    pub async fn get_services(&self) -> Result<GetServicesResponse> {
100        self.get_and_parse::<GetServices, ()>(&()).await
101    }
102
103    /// Adds a file to hydrus
104    #[tracing::instrument(skip(self), level = "debug")]
105    pub async fn add_file<S: ToString + Debug>(&self, path: S) -> Result<AddFileResponse> {
106        let path = path.to_string();
107        self.post_and_parse::<AddFile>(AddFileRequest { path })
108            .await
109    }
110
111    /// Adds a file from binary data to hydrus
112    #[tracing::instrument(skip(self, data), level = "debug")]
113    pub async fn add_binary_file(&self, data: Vec<u8>) -> Result<AddFileResponse> {
114        self.post_binary::<AddFile>(data).await
115    }
116
117    /// Moves files with matching hashes to the trash
118    #[tracing::instrument(skip(self), level = "debug")]
119    pub async fn delete_files(
120        &self,
121        files: FileSelection,
122        service: FileServiceSelection,
123        reason: Option<String>,
124    ) -> Result<()> {
125        self.post::<DeleteFiles>(DeleteFilesRequest {
126            file_selection: files,
127            service_selection: service,
128            reason,
129        })
130        .await?;
131
132        Ok(())
133    }
134
135    /// Pulls files out of the trash by hash
136    #[tracing::instrument(skip(self), level = "debug")]
137    pub async fn undelete_files(
138        &self,
139        files: FileSelection,
140        service: FileServiceSelection,
141    ) -> Result<()> {
142        self.post::<UndeleteFiles>(UndeleteFilesRequest {
143            file_selection: files,
144            service_selection: service,
145        })
146        .await?;
147
148        Ok(())
149    }
150
151    /// Moves files from the inbox into the archive
152    #[tracing::instrument(skip(self), level = "debug")]
153    pub async fn archive_files(
154        &self,
155        files: FileSelection,
156        service: FileServiceSelection,
157    ) -> Result<()> {
158        self.post::<ArchiveFiles>(ArchiveFilesRequest {
159            file_selection: files,
160            service_selection: service,
161        })
162        .await?;
163
164        Ok(())
165    }
166
167    /// Moves files from the archive into the inbox
168    #[tracing::instrument(skip(self), level = "debug")]
169    pub async fn unarchive_files(
170        &self,
171        files: FileSelection,
172        service: FileServiceSelection,
173    ) -> Result<()> {
174        self.post::<UnarchiveFiles>(UnarchiveFilesRequest {
175            file_selection: files,
176            service_selection: service,
177        })
178        .await?;
179
180        Ok(())
181    }
182
183    /// Returns the list of tags as the client would see them in a human friendly order
184    #[tracing::instrument(skip(self), level = "debug")]
185    pub async fn clean_tags(&self, tags: Vec<String>) -> Result<CleanTagsResponse> {
186        self.get_and_parse::<CleanTags, [(&str, String)]>(&[(
187            "tags",
188            Self::serialize_query_object(tags)?,
189        )])
190        .await
191    }
192
193    /// Adds tags to files with the given hashes
194    #[tracing::instrument(skip(self), level = "debug")]
195    pub async fn add_tags(&self, request: AddTagsRequest) -> Result<()> {
196        self.post::<AddTags>(request).await?;
197
198        Ok(())
199    }
200
201    /// Searches for tags by name
202    #[tracing::instrument(skip(self), level = "debug")]
203    pub async fn search_tags<S: ToString + Debug>(
204        &self,
205        query: S,
206        options: TagSearchOptions,
207    ) -> Result<SearchTagsResponse> {
208        let mut args = options.into_query_args();
209        args.push(("search", query.to_string()));
210        self.get_and_parse::<SearchTags, [(&str, String)]>(&args)
211            .await
212    }
213
214    /// Searches for files
215    #[tracing::instrument(skip(self), level = "debug")]
216    pub async fn search_files(
217        &self,
218        query: Vec<SearchQueryEntry>,
219        options: FileSearchOptions,
220    ) -> Result<SearchFilesResponse> {
221        let mut args = options.into_query_args();
222        args.push(("tags", Self::serialize_query_object(query)?));
223        self.get_and_parse::<SearchFiles, [(&str, String)]>(&args)
224            .await
225    }
226
227    /// Searches for file hashes
228    #[tracing::instrument(skip(self), level = "debug")]
229    pub async fn search_file_hashes(
230        &self,
231        query: Vec<SearchQueryEntry>,
232        options: FileSearchOptions,
233    ) -> Result<SearchFileHashesResponse> {
234        let mut args = options.into_query_args();
235        args.push(("tags", Self::serialize_query_object(query)?));
236        args.push(("return_hashes", Self::serialize_query_object(true)?));
237        self.get_and_parse::<SearchFileHashes, [(&str, String)]>(&args)
238            .await
239    }
240
241    /// Returns the metadata for a given list of file_ids or hashes
242    #[tracing::instrument(skip(self), level = "debug")]
243    pub async fn get_file_metadata<M: FileMetadataType>(
244        &self,
245        file_ids: Vec<u64>,
246        hashes: Vec<String>,
247    ) -> Result<FileMetadataResponse<M>> {
248        let id_query = if file_ids.len() > 0 {
249            ("file_ids", Self::serialize_query_object(file_ids)?)
250        } else {
251            ("hashes", Self::serialize_query_object(hashes)?)
252        };
253        let query = [
254            id_query,
255            (
256                "only_return_identifiers",
257                Self::serialize_query_object(M::only_identifiers())?,
258            ),
259            (
260                "only_return_basic_information",
261                Self::serialize_query_object(M::only_basic_information())?,
262            ),
263        ];
264        self.get_and_parse::<FileMetadata<M>, [(&str, String)]>(&query)
265            .await
266    }
267
268    /// Returns the metadata for a single file identifier
269    #[tracing::instrument(skip(self), level = "debug")]
270    pub async fn get_file_metadata_by_identifier<M: FileMetadataType>(
271        &self,
272        id: FileIdentifier,
273    ) -> Result<M::Response> {
274        let mut response = match id.clone() {
275            FileIdentifier::ID(id) => self.get_file_metadata::<M>(vec![id], vec![]).await?,
276            FileIdentifier::Hash(hash) => self.get_file_metadata::<M>(vec![], vec![hash]).await?,
277        };
278
279        response
280            .metadata
281            .pop()
282            .ok_or_else(|| Error::FileNotFound(id))
283    }
284
285    /// Returns the bytes of a file from hydrus
286    #[tracing::instrument(skip(self), level = "debug")]
287    pub async fn get_file(&self, id: FileIdentifier) -> Result<FileRecord> {
288        let response = match id {
289            FileIdentifier::ID(id) => {
290                self.get::<GetFile, [(&str, u64)]>(&[("file_id", id)])
291                    .await?
292            }
293            FileIdentifier::Hash(hash) => {
294                self.get::<GetFile, [(&str, String)]>(&[("hash", hash)])
295                    .await?
296            }
297        };
298        let mime_type = response
299            .headers()
300            .get("mime-type")
301            .cloned()
302            .map(|h| h.to_str().unwrap().to_string())
303            .unwrap_or("image/jpeg".into());
304
305        let bytes = response.bytes().await?.to_vec();
306
307        Ok(FileRecord { bytes, mime_type })
308    }
309
310    /// Returns all files associated with the given url
311    #[tracing::instrument(skip(self), level = "debug")]
312    pub async fn get_url_files<S: AsRef<str> + Debug>(
313        &self,
314        url: S,
315    ) -> Result<GetUrlFilesResponse> {
316        self.get_and_parse::<GetUrlFiles, [(&str, &str)]>(&[("url", url.as_ref())])
317            .await
318    }
319
320    /// Returns information about the given url
321    #[tracing::instrument(skip(self), level = "debug")]
322    pub async fn get_url_info<S: AsRef<str> + Debug>(&self, url: S) -> Result<GetUrlInfoResponse> {
323        self.get_and_parse::<GetUrlInfo, [(&str, &str)]>(&[("url", url.as_ref())])
324            .await
325    }
326
327    /// Adds an url to hydrus, optionally with additional tags and a destination page
328    #[tracing::instrument(skip(self), level = "debug")]
329    pub async fn add_url(&self, request: AddUrlRequest) -> Result<AddUrlResponse> {
330        self.post_and_parse::<AddUrl>(request).await
331    }
332
333    /// Associates urls with the given file hashes
334    #[tracing::instrument(skip(self), level = "debug")]
335    pub async fn associate_urls(&self, urls: Vec<String>, hashes: Vec<String>) -> Result<()> {
336        self.post::<AssociateUrl>(AssociateUrlRequest {
337            hashes,
338            urls_to_add: urls,
339            urls_to_delete: vec![],
340        })
341        .await?;
342
343        Ok(())
344    }
345
346    /// Disassociates urls with the given file hashes
347    #[tracing::instrument(skip(self), level = "debug")]
348    pub async fn disassociate_urls(&self, urls: Vec<String>, hashes: Vec<String>) -> Result<()> {
349        self.post::<AssociateUrl>(AssociateUrlRequest {
350            hashes,
351            urls_to_add: vec![],
352            urls_to_delete: urls,
353        })
354        .await?;
355
356        Ok(())
357    }
358
359    /// Sets the notes for the file
360    #[tracing::instrument(skip(self), level = "debug")]
361    pub async fn set_notes(
362        &self,
363        id: FileIdentifier,
364        notes: HashMap<String, String>,
365    ) -> Result<()> {
366        self.post::<SetNotes>(SetNotesRequest::new(id, notes))
367            .await?;
368
369        Ok(())
370    }
371
372    /// Deletes the notes of a file
373    #[tracing::instrument(skip(self), level = "debug")]
374    pub async fn delete_notes(&self, id: FileIdentifier, note_names: Vec<String>) -> Result<()> {
375        self.post::<DeleteNotes>(DeleteNotesRequest::new(id, note_names))
376            .await?;
377
378        Ok(())
379    }
380
381    /// Returns all pages of the client
382    #[tracing::instrument(skip(self), level = "debug")]
383    pub async fn get_pages(&self) -> Result<GetPagesResponse> {
384        self.get_and_parse::<GetPages, ()>(&()).await
385    }
386
387    /// Returns information about a single page
388    #[tracing::instrument(skip(self), level = "debug")]
389    pub async fn get_page_info<S: AsRef<str> + Debug>(
390        &self,
391        page_key: S,
392    ) -> Result<GetPageInfoResponse> {
393        self.get_and_parse::<GetPageInfo, [(&str, &str)]>(&[("page_key", page_key.as_ref())])
394            .await
395    }
396
397    /// Focuses a page in the client
398    #[tracing::instrument(skip(self), level = "debug")]
399    pub async fn focus_page<S: ToString + Debug>(&self, page_key: S) -> Result<()> {
400        let page_key = page_key.to_string();
401        self.post::<FocusPage>(FocusPageRequest { page_key })
402            .await?;
403
404        Ok(())
405    }
406
407    /// Adds files to a page
408    #[tracing::instrument(skip(self), level = "debug")]
409    pub async fn add_files_to_page<S: ToString + Debug>(
410        &self,
411        page_key: S,
412        file_ids: Vec<u64>,
413        hashes: Vec<String>,
414    ) -> Result<()> {
415        let page_key = page_key.to_string();
416        self.post::<AddFiles>(AddFilesRequest {
417            page_key,
418            file_ids,
419            hashes,
420        })
421        .await?;
422
423        Ok(())
424    }
425
426    /// Returns all cookies for the given domain
427    #[tracing::instrument(skip(self), level = "debug")]
428    pub async fn get_cookies<S: AsRef<str> + Debug>(
429        &self,
430        domain: S,
431    ) -> Result<GetCookiesResponse> {
432        self.get_and_parse::<GetCookies, [(&str, &str)]>(&[("domain", domain.as_ref())])
433            .await
434    }
435
436    /// Sets some cookies for some websites.
437    /// Each entry needs to be in the format `[<name>, <value>, <domain>, <path>, <expires>]`
438    /// with the types `[String, String, String, String, u64]`
439    #[tracing::instrument(skip(self), level = "debug")]
440    pub async fn set_cookies(&self, cookies: Vec<[OptionalStringNumber; 5]>) -> Result<()> {
441        self.post::<SetCookies>(SetCookiesRequest { cookies })
442            .await?;
443
444        Ok(())
445    }
446
447    /// Sets the user agent that is being used for every request hydrus starts
448    #[tracing::instrument(skip(self), level = "debug")]
449    pub async fn set_user_agent<S: ToString + Debug>(&self, user_agent: S) -> Result<()> {
450        let user_agent = user_agent.to_string();
451        self.post::<SetUserAgent>(SetUserAgentRequest { user_agent })
452            .await?;
453
454        Ok(())
455    }
456
457    /// Starts a get request to the path
458    #[tracing::instrument(skip(self), level = "trace")]
459    async fn get<E: Endpoint, Q: Serialize + Debug + ?Sized>(&self, query: &Q) -> Result<Response> {
460        tracing::trace!("GET request to {}", E::path());
461        #[cfg(feature = "json")]
462        let content_type = CONTENT_TYPE_JSON;
463        #[cfg(feature = "cbor")]
464        let content_type = CONTENT_TYPE_CBOR;
465        #[cfg(feature = "json")]
466        let params: [(&str, &str); 0] = [];
467        #[cfg(feature = "cbor")]
468        let params = [("cbor", true)];
469
470        let response = self
471            .inner
472            .get(format!("{}/{}", self.base_url, E::path()))
473            .header(ACCESS_KEY_HEADER, &self.access_key)
474            .header(CONTENT_TYPE_HEADER, content_type)
475            .header(ACCEPT_HEADER, content_type)
476            .query(query)
477            .query(&params)
478            .send()
479            .await?;
480
481        Self::extract_error(response).await
482    }
483
484    /// Starts a get request to the path associated with the Endpoint Type
485    #[tracing::instrument(skip(self), level = "trace")]
486    async fn get_and_parse<E: Endpoint, Q: Serialize + Debug + ?Sized>(
487        &self,
488        query: &Q,
489    ) -> Result<E::Response> {
490        let response = self.get::<E, Q>(query).await?;
491
492        Self::extract_content(response).await
493    }
494
495    /// Serializes a given object into a json or cbor query object
496    #[tracing::instrument(skip(obj), level = "trace")]
497    fn serialize_query_object<S: Serialize>(obj: S) -> Result<String> {
498        #[cfg(feature = "json")]
499        {
500            tracing::trace!("Serializing query to JSON");
501            serde_json::ser::to_string(&obj).map_err(|e| Error::Serialization(e.to_string()))
502        }
503
504        #[cfg(feature = "cbor")]
505        {
506            tracing::trace!("Serializing query to CBOR");
507            let mut buf = Vec::new();
508            ciborium::ser::into_writer(&obj, &mut buf)
509                .map_err(|e| Error::Serialization(e.to_string()))?;
510            Ok(base64::encode(buf))
511        }
512    }
513
514    /// Stats a post request to the path associated with the Endpoint Type
515    #[tracing::instrument(skip(self), level = "trace")]
516    async fn post<E: Endpoint>(&self, body: E::Request) -> Result<Response> {
517        tracing::trace!("POST request to {}", E::path());
518        let body = Self::serialize_body(body)?;
519
520        #[cfg(feature = "cbor")]
521        let content_type = CONTENT_TYPE_CBOR;
522        #[cfg(feature = "json")]
523        let content_type = CONTENT_TYPE_JSON;
524
525        let response = self
526            .inner
527            .post(format!("{}/{}", self.base_url, E::path()))
528            .body(body)
529            .header(ACCESS_KEY_HEADER, &self.access_key)
530            .header(CONTENT_TYPE_HEADER, content_type)
531            .header(ACCEPT_HEADER, content_type)
532            .send()
533            .await?;
534        let response = Self::extract_error(response).await?;
535        Ok(response)
536    }
537
538    /// Serializes a body into either CBOR or JSON
539    #[tracing::instrument(skip(body), level = "trace")]
540    fn serialize_body<S: Serialize>(body: S) -> Result<Vec<u8>> {
541        let mut buf = Vec::new();
542
543        #[cfg(feature = "json")]
544        {
545            tracing::trace!("Serializing body to JSON");
546            serde_json::to_writer(&mut buf, &body)
547                .map_err(|e| Error::Serialization(e.to_string()))?;
548        }
549        #[cfg(feature = "cbor")]
550        {
551            tracing::trace!("Serializing body to CBOR");
552            ciborium::ser::into_writer(&body, &mut buf)
553                .map_err(|e| Error::Serialization(e.to_string()))?;
554        }
555
556        Ok(buf)
557    }
558
559    /// Stats a post request and parses the body as json
560    #[tracing::instrument(skip(self), level = "trace")]
561    async fn post_and_parse<E: Endpoint>(&self, body: E::Request) -> Result<E::Response> {
562        let response = self.post::<E>(body).await?;
563
564        Self::extract_content(response).await
565    }
566
567    /// Stats a post request to the path associated with the return type
568    /// This currently only supports JSON because of a limitation of the
569    /// hydrus client api.
570    #[tracing::instrument(skip(self, data), level = "trace")]
571    async fn post_binary<E: Endpoint>(&self, data: Vec<u8>) -> Result<E::Response> {
572        tracing::trace!("Binary POST request to {}", E::path());
573
574        #[cfg(feature = "cbor")]
575        let content_type = CONTENT_TYPE_CBOR;
576        #[cfg(feature = "json")]
577        let content_type = CONTENT_TYPE_JSON;
578
579        let response = self
580            .inner
581            .post(format!("{}/{}", self.base_url, E::path()))
582            .body(data)
583            .header(ACCESS_KEY_HEADER, &self.access_key)
584            .header(CONTENT_TYPE_HEADER, "application/octet-stream")
585            .header(ACCEPT_HEADER, content_type)
586            .send()
587            .await?;
588        let response = Self::extract_error(response).await?;
589
590        Self::extract_content(response).await
591    }
592
593    /// Returns an error with the response text content if the status doesn't indicate success
594    #[tracing::instrument(level = "trace")]
595    async fn extract_error(response: Response) -> Result<Response> {
596        if !response.status().is_success() {
597            let msg = response.text().await?;
598            tracing::error!("API returned error '{}'", msg);
599            Err(Error::Hydrus(msg))
600        } else {
601            Ok(response)
602        }
603    }
604
605    /// Parses the response as JSOn
606    #[tracing::instrument(level = "trace")]
607    async fn extract_content<T: DeserializeOwned + Debug>(response: Response) -> Result<T> {
608        let bytes = response.bytes().await?;
609        let reader = bytes.reader();
610        #[cfg(feature = "json")]
611        let content = {
612            tracing::trace!("Deserializing content from JSON");
613            serde_json::from_reader::<_, T>(reader)
614                .map_err(|e| Error::Deserialization(e.to_string()))?
615        };
616        #[cfg(feature = "cbor")]
617        let content = {
618            tracing::trace!("Deserializing content from CBOR");
619            ciborium::de::from_reader(reader).map_err(|e| Error::Deserialization(e.to_string()))?
620        };
621        tracing::trace!("response content: {:?}", content);
622
623        Ok(content)
624    }
625}