Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(api-client-framework): initial implementation #1161

Merged
merged 6 commits into from
Dec 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 {
rimrakhimov marked this conversation as resolved.
Show resolved Hide resolved
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),
}
}
}
Loading