From 508f697a51236cf122cee2760b23aa6d4d128b86 Mon Sep 17 00:00:00 2001 From: Koen Bolhuis Date: Sat, 30 Jan 2021 03:06:02 +0100 Subject: [PATCH] Add rate limiting information to response types Add the `rate_limit` field to all response types, which contains rate limiting information such as maximum and remaining requests in the current time frame, as well as when the next time frame starts. All response types now also implement the `ResponseType` trait (internal for now). Currently this trait allows converting from `ureq::Response` to the response type, by extracting rate limiting information from the response headers and deserializing the response body into the response type. --- src/raw/client.rs | 49 ++++---- src/raw/response.rs | 266 +++++++++++++++++++++++++++++++------------- 2 files changed, 217 insertions(+), 98 deletions(-) diff --git a/src/raw/client.rs b/src/raw/client.rs index c83e00d..cb4cd7a 100644 --- a/src/raw/client.rs +++ b/src/raw/client.rs @@ -1,4 +1,3 @@ -use serde::de::DeserializeOwned; use serde::Serialize; use ureq::Agent; @@ -36,19 +35,17 @@ impl Client { /// Helper method to perform a GET request against an endpoint /// without any query parameters. - fn get(&self, endpoint: Endpoint) -> Result { + fn get(&self, endpoint: Endpoint) -> Result { let endpoint = format!("{}{}", API_ROOT_URL, endpoint); - self.agent - .get(&endpoint) - .call()? - .into_json() - .map_err(Error::ResponseJson) + let response = self.agent.get(&endpoint).call()?; + + R::from_response(response) } /// Helper method to perform a GET request against (most) statistics /// endpoints that share common query parameters. - fn get_stats( + fn get_stats( &self, endpoint: Endpoint, count: Option, @@ -75,7 +72,7 @@ impl Client { if response.status() == 204 { Ok(None) } else { - response.into_json().map(Some).map_err(Error::ResponseJson) + R::from_response(response).map(Some) } } @@ -84,18 +81,18 @@ impl Client { fn post(&self, endpoint: Endpoint, token: &str, data: D) -> Result where D: Serialize, - R: DeserializeOwned, + R: ResponseType, { let data = serde_json::to_value(data).map_err(Error::RequestJson)?; let endpoint = format!("{}{}", API_ROOT_URL, endpoint); - self.agent + let response = self.agent .post(&endpoint) .set("Authorization", &format!("Token {}", token)) - .send_json(data)? - .into_json() - .map_err(Error::ResponseJson) + .send_json(data)?; + + R::from_response(response) } /// Endpoint: [`submit-listens`](https://listenbrainz.readthedocs.io/en/production/dev/api/#post--1-submit-listens) @@ -111,12 +108,12 @@ impl Client { pub fn validate_token(&self, token: &str) -> Result { let endpoint = format!("{}{}", API_ROOT_URL, Endpoint::ValidateToken); - self.agent + let response = self.agent .get(&endpoint) .query("token", token) - .call()? - .into_json() - .map_err(Error::ResponseJson) + .call()?; + + ResponseType::from_response(response) } /// Endpoint: [`delete-listen`](https://listenbrainz.readthedocs.io/en/production/dev/api/#post--1-delete-listen) @@ -172,7 +169,9 @@ impl Client { request = request.query("time_range", &time_range.to_string()); } - request.call()?.into_json().map_err(Error::ResponseJson) + let response = request.call()?; + + ResponseType::from_response(response) } /// Endpoint: [`latest-import`](https://listenbrainz.readthedocs.io/en/production/dev/api/#get--1-latest-import) (`GET`) @@ -230,7 +229,7 @@ impl Client { if response.status() == 204 { Ok(None) } else { - response.into_json().map(Some).map_err(Error::ResponseJson) + ResponseType::from_response(response).map(Some) } } @@ -258,7 +257,7 @@ impl Client { if response.status() == 204 { Ok(None) } else { - response.into_json().map(Some).map_err(Error::ResponseJson) + ResponseType::from_response(response).map(Some) } } @@ -300,7 +299,9 @@ impl Client { request = request.query("force_recalculate", &force_recalculate.to_string()); } - request.call()?.into_json().map_err(Error::ResponseJson) + let response = request.call()?; + + ResponseType::from_response(response) } /// Endpoint: [`stats/user/{user_name}/releases`](https://listenbrainz.readthedocs.io/en/production/dev/api/#get--1-stats-user-(user_name)-releases) @@ -338,6 +339,8 @@ impl Client { request = request.query("id", &id.to_string()); } - request.call()?.into_json().map_err(Error::ResponseJson) + let response = request.call()?; + + ResponseType::from_response(response) } } diff --git a/src/raw/response.rs b/src/raw/response.rs index 61f8f3a..73778f3 100644 --- a/src/raw/response.rs +++ b/src/raw/response.rs @@ -1,43 +1,133 @@ //! Low-level response data models. +//! +//! Every response type has the `rate_limit` field, which contains rate limiting +//! information. See the documentation of the [`RateLimit`] type for more +//! details. use std::collections::HashMap; +use serde::de::DeserializeOwned; use serde::Deserialize; +use ureq::Response; + +use crate::Error; + +/// Contains rate limiting information. +/// +/// ListenBrainz API rate limiting is described in the [API docs]. +/// Prefer using the [`RateLimit::reset_in`] field over [`RateLimit::reset`], +/// as the former is resilient against clients with incorrect clocks. +/// +/// [API docs]: https://listenbrainz.readthedocs.io/en/production/dev/api/#rate-limiting +#[derive(Debug)] +pub struct RateLimit { + pub limit: u64, + pub remaining: u64, + pub reset_in: u64, + pub reset: i64, +} + +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 { + 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()?; + + Some(Self { + limit, + remaining, + reset_in, + reset, + }) + } +} + +/// Internal trait for response types. +/// Allows converting the response type from a `ureq::Response`, +/// by deserializing the body into the response type and then +/// adding the `rate_limit` field from headers. +pub(crate) trait ResponseType: DeserializeOwned { + fn from_response(response: Response) -> Result; +} + +/// Internal macro for response types. +/// Wraps the definition of a response type, adds the `rate_limit` field, +/// and implements the `ResponseType` trait. +macro_rules! response_type { + ( + $(#[$meta:meta])* + pub struct $name:ident { + $(pub $field:ident: $field_ty:ty),* + $(,)? + } + ) => { + $(#[$meta])* + pub struct $name { + #[serde(skip)] + pub rate_limit: Option, + $(pub $field: $field_ty),* + } + + impl ResponseType for $name { + fn from_response(response: Response) -> Result { + let rate_limit = RateLimit::from_headers(&response); + let mut result: Self = response.into_json().map_err(Error::ResponseJson)?; + result.rate_limit = rate_limit; + Ok(result) + } + } + } +} // --------- submit-listens -/// Response type for [`Client::submit_listens`](super::Client::submit_listens). -#[derive(Debug, Deserialize)] -pub struct SubmitListensResponse { - pub status: String, +response_type! { + /// Response type for [`Client::submit_listens`](super::Client::submit_listens). + #[derive(Debug, Deserialize)] + pub struct SubmitListensResponse { + pub status: String, + } } // --------- validate-token -/// Response type for [`Client::validate_token`](super::Client::validate_token). -#[derive(Debug, Deserialize)] -pub struct ValidateTokenResponse { - pub code: u16, - pub message: String, +response_type! { + /// Response type for [`Client::validate_token`](super::Client::validate_token). + #[derive(Debug, Deserialize)] + pub struct ValidateTokenResponse { + pub code: u16, + pub message: String, - pub valid: bool, - pub user_name: Option, + pub valid: bool, + pub user_name: Option, + } } // --------- delete-listen -/// Response type for [`Client::delete_listen`](super::Client::delete_listen). -#[derive(Debug, Deserialize)] -pub struct DeleteListenResponse { - pub status: String, +response_type! { + /// Response type for [`Client::delete_listen`](super::Client::delete_listen). + #[derive(Debug, Deserialize)] + pub struct DeleteListenResponse { + pub status: String, + } } // --------- users/{user_list}/recent-listens -/// Response type for [`Client::users_recent_listens`](super::Client::users_recent_listens). -#[derive(Debug, Deserialize)] -pub struct UsersRecentListensResponse { - pub payload: UsersRecentListensPayload, +response_type! { + /// Response type for [`Client::users_recent_listens`](super::Client::users_recent_listens). + #[derive(Debug, Deserialize)] + pub struct UsersRecentListensResponse { + pub payload: UsersRecentListensPayload, + } } /// Type of the [`UsersRecentListensResponse::payload`] field. @@ -69,10 +159,12 @@ pub struct UsersRecentListensTrackMetadata { // --------- user/{user_name}/listen-count -/// Response type for [`Client::user_listen_count`](super::Client::user_listen_count). -#[derive(Debug, Deserialize)] -pub struct UserListenCountResponse { - pub payload: UserListenCountPayload, +response_type! { + /// Response type for [`Client::user_listen_count`](super::Client::user_listen_count). + #[derive(Debug, Deserialize)] + pub struct UserListenCountResponse { + pub payload: UserListenCountPayload, + } } /// Type of the [`UserListenCountResponse::payload`] field. @@ -83,10 +175,12 @@ pub struct UserListenCountPayload { // -------- user/{user_name}/playing-now -/// Response type for [`Client::user_playing_now`](super::Client::user_playing_now). -#[derive(Debug, Deserialize)] -pub struct UserPlayingNowResponse { - pub payload: UserPlayingNowPayload, +response_type! { + /// Response type for [`Client::user_playing_now`](super::Client::user_playing_now). + #[derive(Debug, Deserialize)] + pub struct UserPlayingNowResponse { + pub payload: UserPlayingNowPayload, + } } /// Type of the [`UserPlayingNowResponse::payload`] field. @@ -117,10 +211,12 @@ pub struct UserPlayingNowTrackMetadata { // -------- user/{user_name}/listens -/// Response type for [`Client::user_listens`](super::Client::user_listens). -#[derive(Debug, Deserialize)] -pub struct UserListensResponse { - pub payload: UserListensPayload, +response_type! { + /// Response type for [`Client::user_listens`](super::Client::user_listens). + #[derive(Debug, Deserialize)] + pub struct UserListensResponse { + pub payload: UserListensPayload, + } } /// Type of the [`UserListensResponse::payload`] field. @@ -153,27 +249,33 @@ pub struct UserListensTrackMetadata { // --------- latest-import (GET) -/// Response type for [`Client::get_latest_import`](super::Client::get_latest_import). -#[derive(Debug, Deserialize)] -pub struct GetLatestImportResponse { - pub latest_import: i64, - pub musicbrainz_id: String, +response_type! { + /// Response type for [`Client::get_latest_import`](super::Client::get_latest_import). + #[derive(Debug, Deserialize)] + pub struct GetLatestImportResponse { + pub latest_import: i64, + pub musicbrainz_id: String, + } } // --------- latest-import (POST) -/// Response type for [`Client::update_latest_import`](super::Client::update_latest_import). -#[derive(Debug, Deserialize)] -pub struct UpdateLatestImportResponse { - pub status: String, +response_type! { + /// Response type for [`Client::update_latest_import`](super::Client::update_latest_import). + #[derive(Debug, Deserialize)] + pub struct UpdateLatestImportResponse { + pub status: String, + } } // --------- stats/sitewide/artists -/// Response type for [`Client::stats_sitewide_artists`](super::Client::stats_sitewide_artists). -#[derive(Debug, Deserialize)] -pub struct StatsSitewideArtistsResponse { - pub payload: StatsSitewideArtistsPayload, +response_type! { + /// Response type for [`Client::stats_sitewide_artists`](super::Client::stats_sitewide_artists). + #[derive(Debug, Deserialize)] + pub struct StatsSitewideArtistsResponse { + pub payload: StatsSitewideArtistsPayload, + } } /// Type of the [`StatsSitewideArtistsResponse::payload`] field. @@ -208,10 +310,12 @@ pub struct StatsSitewideArtistsArtist { // --------- stats/user/{user_name}/listening-activity -/// Response type for [`Client::stats_user_listening_activity`](super::Client::stats_user_listening_activity). -#[derive(Debug, Deserialize)] -pub struct StatsUserListeningActivityResponse { - pub payload: StatsUserListeningActivityPayload, +response_type! { + /// Response type for [`Client::stats_user_listening_activity`](super::Client::stats_user_listening_activity). + #[derive(Debug, Deserialize)] + pub struct StatsUserListeningActivityResponse { + pub payload: StatsUserListeningActivityPayload, + } } /// Type of the [`StatsUserListeningActivityResponse::payload`] field. @@ -235,10 +339,12 @@ pub struct StatsUserListeningActivityListeningActivity { // --------- stats/user/{user_name}/daily-activity -/// Response type for [`Client::stats_user_daily_activity`](super::Client::stats_user_daily_activity). -#[derive(Debug, Deserialize)] -pub struct StatsUserDailyActivityResponse { - pub payload: StatsUserDailyActivityPayload, +response_type! { + /// Response type for [`Client::stats_user_daily_activity`](super::Client::stats_user_daily_activity). + #[derive(Debug, Deserialize)] + pub struct StatsUserDailyActivityResponse { + pub payload: StatsUserDailyActivityPayload, + } } /// Type of the [`StatsUserDailyActivityResponse::payload`] field. @@ -267,10 +373,12 @@ pub struct StatsUserDailyActivityHour { // --------- stats/user/{user_name}/recordings -/// Response type of [`Client::stats_user_recordings`](super::Client::stats_user_recordings). -#[derive(Debug, Deserialize)] -pub struct StatsUserRecordingsResponse { - pub payload: StatsUserRecordingsPayload, +response_type! { + /// Response type of [`Client::stats_user_recordings`](super::Client::stats_user_recordings). + #[derive(Debug, Deserialize)] + pub struct StatsUserRecordingsResponse { + pub payload: StatsUserRecordingsPayload, + } } /// Type of the [`StatsUserRecordingsResponse::payload`] field. @@ -303,10 +411,12 @@ pub struct StatsUserRecordingsRecording { // --------- stats/user/{user_name}/artist-map -/// Response type of [`Client::stats_user_artist_map`](super::Client::stats_user_artist_map). -#[derive(Debug, Deserialize)] -pub struct StatsUserArtistMapResponse { - pub payload: StatsUserArtistMapPayload, +response_type! { + /// Response type of [`Client::stats_user_artist_map`](super::Client::stats_user_artist_map). + #[derive(Debug, Deserialize)] + pub struct StatsUserArtistMapResponse { + pub payload: StatsUserArtistMapPayload, + } } /// Type of the [`StatsUserArtistMapResponse::payload`] field. @@ -329,10 +439,12 @@ pub struct StatsUserArtistMapCountry { // --------- stats/user/{user_name}/releases -/// Response type for [`Client::stats_user_releases`](super::Client::stats_user_releases). -#[derive(Debug, Deserialize)] -pub struct StatsUserReleasesResponse { - pub payload: StatsUserReleasesPayload, +response_type! { + /// Response type for [`Client::stats_user_releases`](super::Client::stats_user_releases). + #[derive(Debug, Deserialize)] + pub struct StatsUserReleasesResponse { + pub payload: StatsUserReleasesPayload, + } } /// Type of the [`StatsUserReleasesResponse::payload`] field. @@ -362,10 +474,12 @@ pub struct StatsUserReleasesRelease { // --------- stats/user/{user_name}/artists -/// Response type of [`Client::stats_user_artists`](super::Client::stats_user_artists). -#[derive(Debug, Deserialize)] -pub struct StatsUserArtistsResponse { - pub payload: StatsUserArtistsPayload, +response_type! { + /// Response type of [`Client::stats_user_artists`](super::Client::stats_user_artists). + #[derive(Debug, Deserialize)] + pub struct StatsUserArtistsResponse { + pub payload: StatsUserArtistsPayload, + } } /// Type of the [`StatsUserArtistsResponse::payload`] field. @@ -392,12 +506,14 @@ pub struct StatsUserArtistsArtist { // --------- status/get-dump-info -/// Response type for [`Client::status_get_dump_info`](super::Client::status_get_dump_info). -#[derive(Debug, Deserialize)] -pub struct StatusGetDumpInfoResponse { - pub code: u16, - pub message: String, +response_type! { + /// Response type for [`Client::status_get_dump_info`](super::Client::status_get_dump_info). + #[derive(Debug, Deserialize)] + pub struct StatusGetDumpInfoResponse { + pub code: u16, + pub message: String, - pub id: i64, - pub timestamp: String, + pub id: i64, + pub timestamp: String, + } }