diff --git a/Cargo.toml b/Cargo.toml index a638bf6..326b368 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hydrus-api" -version = "0.7.1" +version = "0.8.0" authors = ["trivernis "] edition = "2018" license = "Apache-2.0" @@ -12,12 +12,16 @@ repository = "https://github.com/trivernis/hydrus-api-rs" [dependencies] serde = { version = "1.0.136", features = ["derive"] } -reqwest = { version = "0.11.10", features = ["json"] } +reqwest = { version = "0.11.10", features = ["json"]} tracing = "0.1.32" mime = "0.3.16" chrono = "0.4.19" regex = "1.5.5" lazy_static = "1.4.0" +bytes = "1.1.0" +ciborium = {version = "0.2.0", optional = true} +serde_json = {version = "1.0.79", optional = true} +base64 = {version = "0.13.0", optional = true} [dev-dependencies] maplit = "1.0.2" @@ -29,4 +33,7 @@ version = "1.17.0" features = ["macros", "rt-multi-thread"] [features] +default = ["json"] rustls = ["reqwest/rustls"] +cbor = ["ciborium", "base64"] +json = ["serde_json"] \ No newline at end of file diff --git a/src/api_core/client.rs b/src/api_core/client.rs index ec4568b..fe616c1 100644 --- a/src/api_core/client.rs +++ b/src/api_core/client.rs @@ -28,16 +28,19 @@ use crate::api_core::searching_and_fetching_files::{ }; use crate::api_core::Endpoint; use crate::error::{Error, Result}; -use crate::utils::{ - number_list_to_json_array, search_query_list_to_json_array, string_list_to_json_array, -}; +use bytes::Buf; use reqwest::Response; use serde::de::DeserializeOwned; use serde::Serialize; use std::collections::HashMap; use std::fmt::Debug; -static ACCESS_KEY_HEADER: &str = "Hydrus-Client-API-Access-Key"; +const ACCESS_KEY_HEADER: &str = "Hydrus-Client-API-Access-Key"; +const CONTENT_TYPE_HEADER: &str = "Content-Type"; +#[cfg(feature = "cbor")] +const CONTENT_TYPE_CBOR: &str = "application/cbor"; +#[cfg(feature = "json")] +const CONTENT_TYPE_JSON: &str = "application/json"; #[derive(Clone)] /// A low level Client for the hydrus API. It provides basic abstraction @@ -63,94 +66,6 @@ impl Client { base_url: url.as_ref().to_string(), } } - - /// Starts a get request to the path - #[tracing::instrument(skip(self), level = "trace")] - async fn get(&self, query: &Q) -> Result { - tracing::trace!("GET request to {}", E::path()); - let response = self - .inner - .get(format!("{}/{}", self.base_url, E::path())) - .header(ACCESS_KEY_HEADER, &self.access_key) - .query(query) - .send() - .await?; - - Self::extract_error(response).await - } - - /// Starts a get request to the path associated with the Endpoint Type - #[tracing::instrument(skip(self), level = "trace")] - async fn get_and_parse( - &self, - query: &Q, - ) -> Result { - let response = self.get::(query).await?; - - Self::extract_content(response).await - } - - /// Stats a post request to the path associated with the Endpoint Type - #[tracing::instrument(skip(self), level = "trace")] - async fn post(&self, body: E::Request) -> Result { - tracing::trace!("POST request to {}", E::path()); - let response = self - .inner - .post(format!("{}/{}", self.base_url, E::path())) - .json(&body) - .header(ACCESS_KEY_HEADER, &self.access_key) - .send() - .await?; - let response = Self::extract_error(response).await?; - Ok(response) - } - - /// Stats a post request and parses the body as json - #[tracing::instrument(skip(self), level = "trace")] - async fn post_and_parse(&self, body: E::Request) -> Result { - let response = self.post::(body).await?; - - Self::extract_content(response).await - } - - /// Stats a post request to the path associated with the return type - #[tracing::instrument(skip(self, data), level = "trace")] - async fn post_binary(&self, data: Vec) -> Result { - tracing::trace!("Binary POST request to {}", E::path()); - let response = self - .inner - .post(format!("{}/{}", self.base_url, E::path())) - .body(data) - .header(ACCESS_KEY_HEADER, &self.access_key) - .header("Content-Type", "application/octet-stream") - .send() - .await?; - let response = Self::extract_error(response).await?; - - Self::extract_content(response).await - } - - /// Returns an error with the response text content if the status doesn't indicate success - #[tracing::instrument(level = "trace")] - async fn extract_error(response: Response) -> Result { - if !response.status().is_success() { - let msg = response.text().await?; - tracing::error!("API returned error '{}'", msg); - Err(Error::Hydrus(msg)) - } else { - Ok(response) - } - } - - /// Parses the response as JSOn - #[tracing::instrument(level = "trace")] - async fn extract_content(response: Response) -> Result { - let content = response.json::().await?; - tracing::trace!("response content: {:?}", content); - - Ok(content) - } - /// Returns the current API version. It's being incremented every time the API changes. #[tracing::instrument(skip(self), level = "debug")] pub async fn api_version(&self) -> Result { @@ -230,7 +145,7 @@ impl Client { pub async fn clean_tags(&self, tags: Vec) -> Result { self.get_and_parse::(&[( "tags", - string_list_to_json_array(tags), + Self::serialize_query_object(tags)?, )]) .await } @@ -251,7 +166,7 @@ impl Client { options: FileSearchOptions, ) -> Result { let mut args = options.into_query_args(); - args.push(("tags", search_query_list_to_json_array(query))); + args.push(("tags", Self::serialize_query_object(query)?)); self.get_and_parse::(&args) .await } @@ -264,7 +179,7 @@ impl Client { options: FileSearchOptions, ) -> Result { let mut args = options.into_query_args(); - args.push(("tags", search_query_list_to_json_array(query))); + args.push(("tags", Self::serialize_query_object(query)?)); args.push(("return_hashes", String::from("true"))); self.get_and_parse::(&args) .await @@ -278,9 +193,9 @@ impl Client { hashes: Vec, ) -> Result { let query = if file_ids.len() > 0 { - ("file_ids", number_list_to_json_array(file_ids)) + ("file_ids", Self::serialize_query_object(file_ids)?) } else { - ("hashes", string_list_to_json_array(hashes)) + ("hashes", Self::serialize_query_object(hashes)?) }; self.get_and_parse::(&[query]) .await @@ -474,4 +389,149 @@ impl Client { Ok(()) } + + /// Starts a get request to the path + #[tracing::instrument(skip(self), level = "trace")] + async fn get(&self, query: &Q) -> Result { + tracing::trace!("GET request to {}", E::path()); + #[cfg(feature = "json")] + let content_type = CONTENT_TYPE_JSON; + #[cfg(feature = "cbor")] + let content_type = CONTENT_TYPE_CBOR; + #[cfg(feature = "json")] + let params: [(&str, &str); 0] = []; + #[cfg(feature = "cbor")] + let params = [("cbor", true)]; + + let response = self + .inner + .get(format!("{}/{}", self.base_url, E::path())) + .header(ACCESS_KEY_HEADER, &self.access_key) + .header(CONTENT_TYPE_HEADER, content_type) + .query(query) + .query(¶ms) + .send() + .await?; + + Self::extract_error(response).await + } + + /// Starts a get request to the path associated with the Endpoint Type + #[tracing::instrument(skip(self), level = "trace")] + async fn get_and_parse( + &self, + query: &Q, + ) -> Result { + let response = self.get::(query).await?; + + Self::extract_content(response).await + } + + /// Serializes a given object into a json or cbor query object + #[tracing::instrument(skip(obj), level = "trace")] + fn serialize_query_object(obj: S) -> Result { + #[cfg(feature = "json")] + { + serde_json::ser::to_string(&obj).map_err(|e| Error::Serialization(e.to_string())) + } + + #[cfg(feature = "cbor")] + { + let mut buf = Vec::new(); + ciborium::ser::into_writer(&obj, &mut buf) + .map_err(|e| Error::Serialization(e.to_string()))?; + Ok(base64::encode(buf)) + } + } + + /// Stats a post request to the path associated with the Endpoint Type + #[tracing::instrument(skip(self), level = "trace")] + async fn post(&self, body: E::Request) -> Result { + tracing::trace!("POST request to {}", E::path()); + let body = Self::serialize_body(body)?; + + #[cfg(feature = "cbor")] + let content_type = CONTENT_TYPE_CBOR; + #[cfg(feature = "json")] + let content_type = CONTENT_TYPE_JSON; + + let response = self + .inner + .post(format!("{}/{}", self.base_url, E::path())) + .body(body) + .header(ACCESS_KEY_HEADER, &self.access_key) + .header("Content-Type", content_type) + .send() + .await?; + let response = Self::extract_error(response).await?; + Ok(response) + } + + /// Serializes a body into either CBOR or JSON + #[tracing::instrument(skip(body), level = "trace")] + fn serialize_body(body: S) -> Result> { + let mut buf = Vec::new(); + #[cfg(feature = "cbor")] + ciborium::ser::into_writer(&body, &mut buf) + .map_err(|e| Error::Serialization(e.to_string()))?; + #[cfg(feature = "json")] + serde_json::to_writer(&mut buf, &body).map_err(|e| Error::Serialization(e.to_string()))?; + + Ok(buf) + } + + /// Stats a post request and parses the body as json + #[tracing::instrument(skip(self), level = "trace")] + async fn post_and_parse(&self, body: E::Request) -> Result { + let response = self.post::(body).await?; + + Self::extract_content(response).await + } + + /// Stats a post request to the path associated with the return type + /// This currently only supports JSON because of a limitation of the + /// hydrus client api. + #[tracing::instrument(skip(self, data), level = "trace")] + async fn post_binary(&self, data: Vec) -> Result { + tracing::trace!("Binary POST request to {}", E::path()); + let response = self + .inner + .post(format!("{}/{}", self.base_url, E::path())) + .body(data) + .header(ACCESS_KEY_HEADER, &self.access_key) + .header(CONTENT_TYPE_HEADER, "application/octet-stream") + .send() + .await?; + let response = Self::extract_error(response).await?; + + response.json::().await.map_err(Error::from) + } + + /// Returns an error with the response text content if the status doesn't indicate success + #[tracing::instrument(level = "trace")] + async fn extract_error(response: Response) -> Result { + if !response.status().is_success() { + let msg = response.text().await?; + tracing::error!("API returned error '{}'", msg); + Err(Error::Hydrus(msg)) + } else { + Ok(response) + } + } + + /// Parses the response as JSOn + #[tracing::instrument(level = "trace")] + async fn extract_content(response: Response) -> Result { + let bytes = response.bytes().await?; + let reader = bytes.reader(); + #[cfg(feature = "json")] + let content = serde_json::from_reader::<_, T>(reader) + .map_err(|e| Error::Deserialization(e.to_string()))?; + #[cfg(feature = "cbor")] + let content = + ciborium::de::from_reader(reader).map_err(|e| Error::Deserialization(e.to_string()))?; + tracing::trace!("response content: {:?}", content); + + Ok(content) + } } diff --git a/src/api_core/searching_and_fetching_files.rs b/src/api_core/searching_and_fetching_files.rs index 9899d99..42ca9f8 100644 --- a/src/api_core/searching_and_fetching_files.rs +++ b/src/api_core/searching_and_fetching_files.rs @@ -155,7 +155,7 @@ impl Endpoint for GetFile { } } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Serialize)] pub enum SearchQueryEntry { Tag(String), OrChain(Vec), diff --git a/src/error.rs b/src/error.rs index 7ac5198..2d60317 100644 --- a/src/error.rs +++ b/src/error.rs @@ -14,6 +14,8 @@ pub enum Error { FileNotFound(FileIdentifier), InvalidMime(String), BuildError(String), + Serialization(String), + Deserialization(String), } impl fmt::Display for Error { @@ -24,11 +26,13 @@ impl fmt::Display for Error { Self::InvalidServiceType(service_type) => { write!(f, "Invalid Service Type '{}'", service_type) } - Self::ImportFailed(msg) => write!(f, "File import failed: {}", msg), - Self::ImportVetoed(msg) => write!(f, "File import vetoed: {}", msg), + Self::ImportFailed(msg) => write!(f, "File import failed: {msg}"), + Self::ImportVetoed(msg) => write!(f, "File import vetoed: {msg}"), Self::FileNotFound(id) => write!(f, "File {:?} not found", id), - Self::InvalidMime(mime) => write!(f, "Failed to parse invalid mime {}", mime), - Self::BuildError(error) => write!(f, "Build error {}", error), + Self::InvalidMime(mime) => write!(f, "Failed to parse invalid mime {mime}"), + Self::BuildError(error) => write!(f, "Build error {error}"), + Self::Serialization(msg) => write!(f, "Failed to serialize request {msg}"), + Self::Deserialization(msg) => write!(f, "Failed to deserialize request {msg}"), } } } diff --git a/src/lib.rs b/src/lib.rs index 4abc643..d7b6884 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,10 @@ //! token that can be retrieved in the hydrus client from the *review services* dialog. //! Different actions require different permissions, you can read about it in the [official docs](https://hydrusnetwork.github.io/hydrus/help/client_api.html). //! +//! Starting with hydrus version 477, CBOR can be used as an alternative to JSON. +//! CBOR support can be enabled with the `cbor` feature of this crate. This feature is +//! incompatible with the `json` feature which is enabled by default. +//! //! ## Hydrus Usage Example //! //! ``` @@ -88,3 +92,9 @@ pub mod api_core; pub mod error; pub mod utils; pub mod wrapper; + +#[cfg(all(feature = "cbor", feature = "json"))] +compile_error!("Feature 'cbor' and 'json' cannot be enabled at the same time"); + +#[cfg(not(any(feature = "cbor", feature = "json")))] +compile_error!("Either the 'json' or 'cbor' feature must be selected."); diff --git a/src/utils.rs b/src/utils.rs index 9b176fd..50020d3 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,38 +1,7 @@ use crate::api_core::common::FileIdentifier; -use crate::api_core::searching_and_fetching_files::SearchQueryEntry; use crate::wrapper::tag::Tag; use chrono::{Datelike, Duration}; -/// Converts a list of Search Query entries into a json array -pub(crate) fn search_query_list_to_json_array(l: Vec) -> String { - let entry_list: Vec = l - .into_iter() - .map(|e| match e { - SearchQueryEntry::Tag(t) => format!("\"{}\"", t), - SearchQueryEntry::OrChain(c) => string_list_to_json_array(c), - }) - .collect(); - - format!("[{}]", entry_list.join(",")) -} - -pub(crate) fn string_list_to_json_array(l: Vec) -> String { - format!("[\"{}\"]", l.join("\",\"")) -} - -pub(crate) fn number_list_to_json_array(l: Vec) -> String { - format!( - "[{}]", - l.into_iter() - .fold(String::from(""), |acc, val| format!( - "{},{}", - acc, - val.to_string() - )) - .trim_start_matches(",") - ) -} - /// Converts a list of tags into a list of string tags pub fn tag_list_to_string_list(tags: Vec) -> Vec { tags.into_iter().map(|t| t.to_string()).collect()