Skip to content

Commit

Permalink
feat(api-client-framework): initial implementation (#1161)
Browse files Browse the repository at this point in the history
* feat(api-client-framework): initial implementation
  • Loading branch information
rimrakhimov authored Dec 25, 2024
1 parent 35a6209 commit d60c1d8
Show file tree
Hide file tree
Showing 5 changed files with 202 additions and 0 deletions.
1 change: 1 addition & 0 deletions libs/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ members = [
"solidity-metadata",
"sourcify",
"verification-common",
"api-client-framework",
]
17 changes: 17 additions & 0 deletions libs/api-client-framework/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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 }
92 changes: 92 additions & 0 deletions libs/api-client-framework/src/async_client.rs
Original file line number Diff line number Diff line change
@@ -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<Self, Error> {
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<EndpointType: Endpoint>(
&self,
endpoint: &EndpointType,
) -> Result<<EndpointType as Endpoint>::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<T: for<'a> Deserialize<'a>>(response: Response) -> Result<T, Error> {
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)
}
55 changes: 55 additions & 0 deletions libs/api-client-framework/src/endpoint.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
None
}

/// The HTTP body associated with this endpoint. If not implemented, defaults to `None`.
///
/// Implementors should inline this.
#[inline]
fn body(&self) -> Option<String> {
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: Serialize>(q: &Q) -> Option<String> {
serde_urlencoded::to_string(q).ok()
}
37 changes: 37 additions & 0 deletions libs/api-client-framework/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<serde_json::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<reqwest_middleware::Error> 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),
}
}
}

0 comments on commit d60c1d8

Please sign in to comment.