Add CBOR support

closes #11

Signed-off-by: trivernis <trivernis@protonmail.com>
pull/14/head
trivernis 2 years ago
parent 59abaa2cec
commit 4156cfe210
Signed by: Trivernis
GPG Key ID: DFFFCC2C7A02DB45

@ -1,6 +1,6 @@
[package]
name = "hydrus-api"
version = "0.7.1"
version = "0.8.0"
authors = ["trivernis <trivernis@protonmail.com>"]
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"]

@ -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<E: Endpoint, Q: Serialize + Debug + ?Sized>(&self, query: &Q) -> Result<Response> {
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<E: Endpoint, Q: Serialize + Debug + ?Sized>(
&self,
query: &Q,
) -> Result<E::Response> {
let response = self.get::<E, Q>(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<E: Endpoint>(&self, body: E::Request) -> Result<Response> {
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<E: Endpoint>(&self, body: E::Request) -> Result<E::Response> {
let response = self.post::<E>(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<E: Endpoint>(&self, data: Vec<u8>) -> Result<E::Response> {
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<Response> {
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<T: DeserializeOwned + Debug>(response: Response) -> Result<T> {
let content = response.json::<T>().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<ApiVersionResponse> {
@ -230,7 +145,7 @@ impl Client {
pub async fn clean_tags(&self, tags: Vec<String>) -> Result<CleanTagsResponse> {
self.get_and_parse::<CleanTags, [(&str, String)]>(&[(
"tags",
string_list_to_json_array(tags),
Self::serialize_query_object(tags)?,
)])
.await
}
@ -251,7 +166,7 @@ impl Client {
options: FileSearchOptions,
) -> Result<SearchFilesResponse> {
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::<SearchFiles, [(&str, String)]>(&args)
.await
}
@ -264,7 +179,7 @@ impl Client {
options: FileSearchOptions,
) -> Result<SearchFileHashesResponse> {
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::<SearchFileHashes, [(&str, String)]>(&args)
.await
@ -278,9 +193,9 @@ impl Client {
hashes: Vec<String>,
) -> Result<FileMetadataResponse> {
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::<FileMetadata, [(&str, String)]>(&[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<E: Endpoint, Q: Serialize + Debug + ?Sized>(&self, query: &Q) -> Result<Response> {
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(&params)
.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<E: Endpoint, Q: Serialize + Debug + ?Sized>(
&self,
query: &Q,
) -> Result<E::Response> {
let response = self.get::<E, Q>(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<S: Serialize>(obj: S) -> Result<String> {
#[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<E: Endpoint>(&self, body: E::Request) -> Result<Response> {
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<S: Serialize>(body: S) -> Result<Vec<u8>> {
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<E: Endpoint>(&self, body: E::Request) -> Result<E::Response> {
let response = self.post::<E>(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<E: Endpoint>(&self, data: Vec<u8>) -> Result<E::Response> {
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::<E::Response>().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<Response> {
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<T: DeserializeOwned + Debug>(response: Response) -> Result<T> {
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)
}
}

@ -155,7 +155,7 @@ impl Endpoint for GetFile {
}
}
#[derive(Clone, Debug)]
#[derive(Clone, Debug, Serialize)]
pub enum SearchQueryEntry {
Tag(String),
OrChain(Vec<String>),

@ -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}"),
}
}
}

@ -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.");

@ -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<SearchQueryEntry>) -> String {
let entry_list: Vec<String> = 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>) -> String {
format!("[\"{}\"]", l.join("\",\""))
}
pub(crate) fn number_list_to_json_array<T: ToString>(l: Vec<T>) -> 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<Tag>) -> Vec<String> {
tags.into_iter().map(|t| t.to_string()).collect()

Loading…
Cancel
Save