Merge pull request #7 from InputUsername/feature/use-attohttpc

Use the attohttpc crate instead of ureq
This commit is contained in:
Koen Bolhuis 2021-09-02 00:14:25 +02:00 committed by GitHub
commit 1e611f1182
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 93 additions and 79 deletions

View File

@ -18,4 +18,4 @@ publish = true
thiserror = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
ureq = { version = "2", features = ["json"] }
attohttpc = { version = "0.17", features = ["json"] }

View File

@ -1,8 +1,8 @@
use std::io;
use serde::Deserialize;
/// Represents errors that can occor while interacting with the API.
use attohttpc::Response;
/// Represents errors that can occur while interacting with the API.
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// The API returned a non-200 status code.
@ -15,17 +15,13 @@ pub enum Error {
error: String,
},
/// The input data could not be converted into JSON.
#[error("could not convert request input into JSON")]
RequestJson(#[source] serde_json::Error),
/// The HTTP response could not be converted into JSON.
#[error("could not convert HTTP response into JSON")]
ResponseJson(#[source] io::Error),
/// The request or response data could not be converted into or from JSON.
#[error("could not convert request or response data into or from JSON")]
Json(#[source] attohttpc::Error),
/// There was some other HTTP error while interacting with the API.
#[error("HTTP error")]
Http(#[source] Box<ureq::Error>),
Http(#[source] attohttpc::Error),
/// The token that was attempted to be used for authentication is invalid.
#[error("invalid authentication token")]
@ -36,6 +32,20 @@ pub enum Error {
NotAuthenticated,
}
impl Error {
/// If the response is a client or server error (status [400-599]),
/// deserialize it into `Error::Api`. Otherwise, return the original response.
pub(crate) fn try_from_error_response(response: Response) -> Result<Response, Self> {
let status = response.status();
if status.is_client_error() || status.is_server_error() {
let api_error: ApiError = response.json()?;
Err(api_error.into())
} else {
Ok(response)
}
}
}
#[derive(Debug, Deserialize)]
struct ApiError {
code: u16,
@ -51,14 +61,11 @@ impl From<ApiError> for Error {
}
}
impl From<ureq::Error> for Error {
fn from(error: ureq::Error) -> Self {
match error {
ureq::Error::Status(_code, response) => match response.into_json::<ApiError>() {
Ok(api_error) => api_error.into(),
Err(err) => Error::ResponseJson(err),
},
ureq::Error::Transport(_) => Error::Http(Box::new(error)),
impl From<attohttpc::Error> for Error {
fn from(error: attohttpc::Error) -> Self {
match error.kind() {
attohttpc::ErrorKind::Json(_) => Self::Json(error),
_ => Self::Http(error),
}
}
}

View File

@ -1,7 +1,5 @@
use serde::Serialize;
use ureq::Agent;
use super::endpoint::Endpoint;
use super::request::*;
use super::response::*;
@ -18,11 +16,9 @@ const API_ROOT_URL: &str = "https://api.listenbrainz.org/1/";
///
/// Client's methods can return the following errors:
/// - [`Error::Api`]: the API returned a non-`2XX` status.
/// - [`Error::RequestJson`]: the request data could not be converted into JSON.
/// - [`Error::ResponseJson`]: the response data could not be converted into JSON.
/// - [`Error::Json`]: the request or response data could not be converted from or into JSON.
/// - [`Error::Http`]: there was some other HTTP error while interacting with the API.
pub struct Client {
agent: Agent,
api_root_url: String,
}
@ -30,14 +26,12 @@ impl Client {
/// Construct a new client.
pub fn new() -> Self {
Self {
agent: ureq::agent(),
api_root_url: API_ROOT_URL.to_string(),
}
}
pub fn new_with_url(url: &str) -> Self {
Self {
agent: ureq::agent(),
api_root_url: url.to_string(),
}
}
@ -47,7 +41,7 @@ impl Client {
fn get<R: ResponseType>(&self, endpoint: Endpoint) -> Result<R, Error> {
let endpoint = format!("{}{}", self.api_root_url, endpoint);
let response = self.agent.get(&endpoint).call()?;
let response = attohttpc::get(endpoint).send()?;
R::from_response(response)
}
@ -63,19 +57,19 @@ impl Client {
) -> Result<Option<R>, Error> {
let endpoint = format!("{}{}", self.api_root_url, endpoint);
let mut request = self.agent.get(&endpoint);
let mut request = attohttpc::get(endpoint);
if let Some(count) = count {
request = request.query("count", &count.to_string());
request = request.param("count", count);
}
if let Some(offset) = offset {
request = request.query("offset", &offset.to_string());
request = request.param("offset", offset);
}
if let Some(range) = range {
request = request.query("range", range);
request = request.param("range", range);
}
let response = request.call()?;
let response = request.send()?;
// API returns 204 and an empty document if there are no statistics
if response.status() == 204 {
@ -92,14 +86,12 @@ impl Client {
D: Serialize,
R: ResponseType,
{
let data = serde_json::to_value(data).map_err(Error::RequestJson)?;
let endpoint = format!("{}{}", self.api_root_url, endpoint);
let response = self.agent
.post(&endpoint)
.set("Authorization", &format!("Token {}", token))
.send_json(data)?;
let response = attohttpc::post(endpoint)
.header("Authorization", &format!("Token {}", token))
.json(&data)?
.send()?;
R::from_response(response)
}
@ -117,10 +109,7 @@ impl Client {
pub fn validate_token(&self, token: &str) -> Result<ValidateTokenResponse, Error> {
let endpoint = format!("{}{}", self.api_root_url, Endpoint::ValidateToken);
let response = self.agent
.get(&endpoint)
.query("token", token)
.call()?;
let response = attohttpc::get(endpoint).param("token", token).send()?;
ResponseType::from_response(response)
}
@ -163,22 +152,22 @@ impl Client {
) -> Result<UserListensResponse, Error> {
let endpoint = format!("{}{}", self.api_root_url, Endpoint::UserListens(user_name));
let mut request = self.agent.get(&endpoint);
let mut request = attohttpc::get(endpoint);
if let Some(min_ts) = min_ts {
request = request.query("min_ts", &min_ts.to_string());
request = request.param("min_ts", min_ts);
}
if let Some(max_ts) = max_ts {
request = request.query("max_ts", &max_ts.to_string());
request = request.param("max_ts", max_ts);
}
if let Some(count) = count {
request = request.query("count", &count.to_string());
request = request.param("count", count);
}
if let Some(time_range) = time_range {
request = request.query("time_range", &time_range.to_string());
request = request.param("time_range", time_range);
}
let response = request.call()?;
let response = request.send()?;
ResponseType::from_response(response)
}
@ -187,12 +176,11 @@ impl Client {
pub fn get_latest_import(&self, user_name: &str) -> Result<GetLatestImportResponse, Error> {
let endpoint = format!("{}{}", self.api_root_url, Endpoint::LatestImport);
self.agent
.get(&endpoint)
.query("user_name", user_name)
.call()?
.into_json()
.map_err(Error::ResponseJson)
let response = attohttpc::get(endpoint)
.param("user_name", user_name)
.send()?;
ResponseType::from_response(response)
}
/// Endpoint: [`latest-import`](https://listenbrainz.readthedocs.io/en/production/dev/api/#post--1-latest-import) (`POST`)
@ -226,13 +214,13 @@ impl Client {
Endpoint::StatsUserListeningActivity(user_name)
);
let mut request = self.agent.get(&endpoint);
let mut request = attohttpc::get(endpoint);
if let Some(range) = range {
request = request.query("range", range);
request = request.param("range", range);
}
let response = request.call()?;
let response = request.send()?;
// API returns 204 and an empty document if there are no statistics
if response.status() == 204 {
@ -254,13 +242,13 @@ impl Client {
Endpoint::StatsUserDailyActivity(user_name)
);
let mut request = self.agent.get(&endpoint);
let mut request = attohttpc::get(endpoint);
if let Some(range) = range {
request = request.query("range", range);
request = request.param("range", range);
}
let response = request.call()?;
let response = request.send()?;
// API returns 204 and an empty document if there are no statistics
if response.status() == 204 {
@ -299,16 +287,16 @@ impl Client {
Endpoint::StatsUserArtistMap(user_name)
);
let mut request = self.agent.get(&endpoint);
let mut request = attohttpc::get(endpoint);
if let Some(range) = range {
request = request.query("range", range);
request = request.param("range", range);
}
if let Some(force_recalculate) = force_recalculate {
request = request.query("force_recalculate", &force_recalculate.to_string());
request = request.param("force_recalculate", force_recalculate);
}
let response = request.call()?;
let response = request.send()?;
ResponseType::from_response(response)
}
@ -342,13 +330,13 @@ impl Client {
) -> Result<StatusGetDumpInfoResponse, Error> {
let endpoint = format!("{}{}", self.api_root_url, Endpoint::StatusGetDumpInfo);
let mut request = self.agent.get(&endpoint);
let mut request = attohttpc::get(endpoint);
if let Some(id) = id {
request = request.query("id", &id.to_string());
request = request.param("id", id);
}
let response = request.call()?;
let response = request.send()?;
ResponseType::from_response(response)
}

View File

@ -6,9 +6,9 @@
use std::collections::HashMap;
use attohttpc::Response;
use serde::de::DeserializeOwned;
use serde::Deserialize;
use ureq::Response;
use crate::Error;
@ -31,14 +31,32 @@ impl RateLimit {
/// Extract rate limiting information from the `X-RateLimit-` headers.
/// Only returns `Some` if all fields are present and valid.
fn from_headers(response: &Response) -> Option<Self> {
let limit = response.header("X-RateLimit-Limit")?
.parse().ok()?;
let remaining = response.header("X-RateLimit-Remaining")?
.parse().ok()?;
let reset_in = response.header("X-RateLimit-Reset-In")?
.parse().ok()?;
let reset = response.header("X-RateLimit-Reset")?
.parse().ok()?;
let headers = response.headers();
let limit = headers
.get("X-RateLimit-Limit")?
.to_str()
.ok()?
.parse()
.ok()?;
let remaining = headers
.get("X-RateLimit-Remaining")?
.to_str()
.ok()?
.parse()
.ok()?;
let reset_in = headers
.get("X-RateLimit-Reset-In")?
.to_str()
.ok()?
.parse()
.ok()?;
let reset = headers
.get("X-RateLimit-Reset")?
.to_str()
.ok()?
.parse()
.ok()?;
Some(Self {
limit,
@ -50,7 +68,7 @@ impl RateLimit {
}
/// Internal trait for response types.
/// Allows converting the response type from a `ureq::Response`,
/// Allows converting the response type from an `attohttpc::Response`,
/// by deserializing the body into the response type and then
/// adding the `rate_limit` field from headers.
pub(crate) trait ResponseType: DeserializeOwned {
@ -77,8 +95,9 @@ macro_rules! response_type {
impl ResponseType for $name {
fn from_response(response: Response) -> Result<Self, Error> {
let response = Error::try_from_error_response(response)?;
let rate_limit = RateLimit::from_headers(&response);
let mut result: Self = response.into_json().map_err(Error::ResponseJson)?;
let mut result: Self = response.json()?;
result.rate_limit = rate_limit;
Ok(result)
}