From d60c1d802896bfa38d8f0bd67fffa15ad1fb55e9 Mon Sep 17 00:00:00 2001 From: Rim Rakhimov Date: Wed, 25 Dec 2024 20:11:44 +0400 Subject: [PATCH] feat(api-client-framework): initial implementation (#1161) * feat(api-client-framework): initial implementation --- libs/Cargo.toml | 1 + libs/api-client-framework/Cargo.toml | 17 ++++ libs/api-client-framework/src/async_client.rs | 92 +++++++++++++++++++ libs/api-client-framework/src/endpoint.rs | 55 +++++++++++ libs/api-client-framework/src/lib.rs | 37 ++++++++ 5 files changed, 202 insertions(+) create mode 100644 libs/api-client-framework/Cargo.toml create mode 100644 libs/api-client-framework/src/async_client.rs create mode 100644 libs/api-client-framework/src/endpoint.rs create mode 100644 libs/api-client-framework/src/lib.rs diff --git a/libs/Cargo.toml b/libs/Cargo.toml index 3c6c0ef53..2880c172d 100644 --- a/libs/Cargo.toml +++ b/libs/Cargo.toml @@ -15,4 +15,5 @@ members = [ "solidity-metadata", "sourcify", "verification-common", + "api-client-framework", ] diff --git a/libs/api-client-framework/Cargo.toml b/libs/api-client-framework/Cargo.toml new file mode 100644 index 000000000..d85f3a77a --- /dev/null +++ b/libs/api-client-framework/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "api-client-framework" +description = "A framework to be used when writing custom http api clients." +version = "0.1.0" +edition = "2021" + +[dependencies] +anyhow = { version = "1.0", default-features = false } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +reqwest-middleware = { version = "0.4", default-features = false } +reqwest-retry = { version = "0.7", default-features = false } +serde = { version = "1", default-features = false } +serde_json = { version = "1", default-features = false, features = ["std"] } +serde_path_to_error = { version = "0.1.16", default-features = false } +serde_urlencoded = { version = "0.7", default-features = false } +thiserror = { version = "2", default-features = false } +url = { version = "2", default-features = false } diff --git a/libs/api-client-framework/src/async_client.rs b/libs/api-client-framework/src/async_client.rs new file mode 100644 index 000000000..abe31b733 --- /dev/null +++ b/libs/api-client-framework/src/async_client.rs @@ -0,0 +1,92 @@ +use super::endpoint::Endpoint; +use crate::Error; +use reqwest::{header::HeaderMap, Response, StatusCode}; +use reqwest_middleware::ClientBuilder; +use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware}; +use serde::Deserialize; +use std::time::Duration; + +#[derive(Clone)] +pub struct HttpApiClientConfig { + /// The maximum time limit for an API request. If a request takes longer than this, it will be + /// cancelled. + pub http_timeout: Duration, + /// Maximum number of allowed retries attempts. Defaults to 1. + pub max_retries: u32, + /// A default set of HTTP headers which will be sent with each API request. + pub default_headers: HeaderMap, +} + +impl Default for HttpApiClientConfig { + fn default() -> Self { + Self { + http_timeout: Duration::from_secs(30), + max_retries: 1, + default_headers: HeaderMap::default(), + } + } +} + +#[derive(Clone)] +pub struct HttpApiClient { + base_url: url::Url, + http_client: reqwest_middleware::ClientWithMiddleware, +} + +impl HttpApiClient { + pub fn new(base_url: url::Url, config: HttpApiClientConfig) -> Result { + let retry_policy = ExponentialBackoff::builder().build_with_max_retries(config.max_retries); + let reqwest_client = reqwest::Client::builder() + .default_headers(config.default_headers) + .timeout(config.http_timeout) + .build()?; + let client = ClientBuilder::new(reqwest_client) + .with(RetryTransientMiddleware::new_with_policy(retry_policy)) + .build(); + Ok(Self { + base_url, + http_client: client, + }) + } + + /// Issue an API request of the given type. + pub async fn request( + &self, + endpoint: &EndpointType, + ) -> Result<::Response, Error> { + // Build the request + let mut request = self + .http_client + .request(endpoint.method(), endpoint.url(&self.base_url)); + + if let Some(body) = endpoint.body() { + request = request.body(body); + request = request.header( + reqwest::header::CONTENT_TYPE, + endpoint.content_type().as_ref(), + ); + } + + let response = request.send().await?; + process_api_response(response).await + } +} + +async fn process_api_response Deserialize<'a>>(response: Response) -> Result { + let status = response.status(); + match status { + status if status.is_success() => (), + StatusCode::NOT_FOUND => return Err(Error::NotFound), + status => { + return Err(Error::InvalidStatusCode { + status_code: status, + message: response.text().await?, + }) + } + } + + let raw_value = response.bytes().await?; + let deserializer = &mut serde_json::Deserializer::from_slice(raw_value.as_ref()); + let value: T = serde_path_to_error::deserialize(deserializer)?; + Ok(value) +} diff --git a/libs/api-client-framework/src/endpoint.rs b/libs/api-client-framework/src/endpoint.rs new file mode 100644 index 000000000..a6f5b58bc --- /dev/null +++ b/libs/api-client-framework/src/endpoint.rs @@ -0,0 +1,55 @@ +use serde::{Deserialize, Serialize}; +use std::{borrow::Cow, fmt::Debug}; +use url::Url; + +/// Represents a specification for an API call that can be built into an HTTP request and sent. +/// New endpoints should implement this trait. +/// +/// If the request succeeds, the call will resolve to a `Response`. +pub trait Endpoint { + type Response: for<'a> Deserialize<'a> + Debug; + + /// The HTTP Method used for this endpoint (e.g. GET, PATCH, DELETE) + fn method(&self) -> reqwest::Method; + + /// The relative URL path for this endpoint + fn path(&self) -> String; + + /// The url-encoded query string associated with this endpoint. Defaults to `None`. + /// + /// Implementors should inline this. + #[inline] + fn query(&self) -> Option { + None + } + + /// The HTTP body associated with this endpoint. If not implemented, defaults to `None`. + /// + /// Implementors should inline this. + #[inline] + fn body(&self) -> Option { + None + } + + /// Builds and returns a formatted full URL, including query, for the endpoint. + /// + /// Implementors should generally not override this. + fn url(&self, base_url: &Url) -> Url { + let mut url = base_url.join(&self.path()).unwrap(); + url.set_query(self.query().as_deref()); + url + } + + /// If `body` is populated, indicates the body MIME type (defaults to JSON). + /// + /// Implementors generally do not need to override this. + fn content_type(&self) -> Cow<'static, str> { + Cow::Borrowed("application/json") + } +} + +/// A utility function for serializing parameters into a URL query string. +#[inline] +pub fn serialize_query(q: &Q) -> Option { + serde_urlencoded::to_string(q).ok() +} diff --git a/libs/api-client-framework/src/lib.rs b/libs/api-client-framework/src/lib.rs new file mode 100644 index 000000000..f0ba3f321 --- /dev/null +++ b/libs/api-client-framework/src/lib.rs @@ -0,0 +1,37 @@ +//! Adapted from https://github.com/cloudflare/cloudflare-rs + +mod async_client; +mod endpoint; + +pub use async_client::{HttpApiClient, HttpApiClientConfig}; +pub use endpoint::{serialize_query, Endpoint}; + +/******************** Config definition ********************/ + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("middleware error: {0}")] + Middleware(anyhow::Error), + #[error("request error: {0}")] + Request(#[from] reqwest::Error), + #[error("response deserialization failed: {0}")] + Deserialization(#[from] serde_path_to_error::Error), + #[error("request returned with invalid status code: 404 - Not Found")] + NotFound, + #[error("request returned with invalid status code: {status_code} - {message}")] + InvalidStatusCode { + status_code: reqwest::StatusCode, + message: String, + }, + #[error("{0:#?}")] + CustomError(anyhow::Error), +} + +impl From for Error { + fn from(value: reqwest_middleware::Error) -> Self { + match value { + reqwest_middleware::Error::Middleware(error) => Error::Middleware(error), + reqwest_middleware::Error::Reqwest(error) => Error::Request(error), + } + } +}