-
Notifications
You must be signed in to change notification settings - Fork 132
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(api-client-framework): initial implementation (#1161)
* feat(api-client-framework): initial implementation
- Loading branch information
1 parent
35a6209
commit d60c1d8
Showing
5 changed files
with
202 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -15,4 +15,5 @@ members = [ | |
"solidity-metadata", | ||
"sourcify", | ||
"verification-common", | ||
"api-client-framework", | ||
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 } |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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), | ||
} | ||
} | ||
} |