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#[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 pub fn builder() -> ClientBuilder {
68 ClientBuilder::default()
69 }
70
71 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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(¶ms)
478 .send()
479 .await?;
480
481 Self::extract_error(response).await
482 }
483
484 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}