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

refactor(dispatcher): use generic error placeholder for the ApiClient #7

Open
wants to merge 1 commit into
base: release_candidate_1
Choose a base branch
from
Open
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
41 changes: 8 additions & 33 deletions src/transport/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
use serde_json::Value as JsonValue;
use std::collections::HashMap;
use thiserror::Error;
use url::Url;
use url::{ParseError, Url};

#[cfg(not(target_arch = "wasm32"))] pub mod native;
#[cfg(target_arch = "wasm32")] pub mod wasm;
Expand All @@ -25,46 +25,21 @@ use reqwest::Error as ReqwestError;
pub trait ApiClient: Clone {
type Request;
type Response;
type Error;
type Conf;

async fn new(conf: Self::Conf) -> Result<Self, ApiClientError>
async fn new(conf: Self::Conf) -> Result<Self, Self::Error>
where
Self: Sized;

fn process_schema(&self, schema: EndpointSchema) -> Result<Self::Request, ApiClientError>;

fn to_data_request<R: SiaApiRequest>(&self, request: R) -> Result<Self::Request, ApiClientError> {
self.process_schema(request.to_endpoint_schema()?)
}
fn to_data_request<R: SiaApiRequest>(&self, request: R) -> Result<Self::Request, Self::Error>;

// TODO this can have a default implementation if an associated type can provide .execute()
// eg self.client().execute(request).await.map_err(Self::ClientError)
async fn execute_request(&self, request: Self::Request) -> Result<Self::Response, ApiClientError>;
async fn execute_request(&self, request: Self::Request) -> Result<Self::Response, Self::Error>;

// TODO default implementation should be possible if Execute::Response is a serde deserializable type
async fn dispatcher<R: SiaApiRequest>(&self, request: R) -> Result<R::Response, ApiClientError>;
}

#[derive(Debug, Error)]
pub enum ApiClientError {
#[error("BuildError error: {0}")]
BuildError(String),
#[error("FixmePlaceholder error: {0}")]
FixmePlaceholder(String), // FIXME this entire enum needs refactoring to not use client-specific error types
#[error("UrlParse error: {0}")]
UrlParse(#[from] url::ParseError),
#[error("UnexpectedHttpStatus error: status:{status} body:{body}")]
UnexpectedHttpStatus { status: http::StatusCode, body: String },
#[error("Serde error: {0}")]
Serde(#[from] serde_json::Error),
#[error("UnexpectedEmptyResponse error: {expected_type}")]
UnexpectedEmptyResponse { expected_type: String },
#[error("WasmFetchError error: {0}")]
#[cfg(target_arch = "wasm32")]
WasmFetchError(#[from] FetchError),
#[error("ReqwestError error: {0}")]
#[cfg(not(target_arch = "wasm32"))]
ReqwestError(#[from] ReqwestError), // FIXME remove this; it should be generalized enough to not need arch-specific error types
async fn dispatcher<R: SiaApiRequest>(&self, request: R) -> Result<R::Response, Self::Error>;
}

// Not all client implementations will have an exact equivalent of HTTP methods
Expand Down Expand Up @@ -149,7 +124,7 @@ pub enum Body {

impl EndpointSchema {
// Safely build the URL using percent-encoding for path params
pub fn build_url(&self, base_url: &Url) -> Result<Url, ApiClientError> {
pub fn build_url(&self, base_url: &Url) -> Result<Url, ParseError> {
let mut path = self.path_schema.to_string();

// Replace placeholders in the path with encoded values if path_params are provided
Expand All @@ -161,7 +136,7 @@ impl EndpointSchema {
}

// Combine base_url with the constructed path
let mut url = base_url.join(&path).map_err(ApiClientError::UrlParse)?;
let mut url = base_url.join(&path)?;

// Add query parameters if any
if let Some(query_params) = &self.query_params {
Expand Down
16 changes: 8 additions & 8 deletions src/transport/client/helpers.rs
Original file line number Diff line number Diff line change
@@ -1,30 +1,30 @@
use super::{ApiClient, ApiClientError};
use super::ApiClient;
use crate::transport::endpoints::{AddressBalanceRequest, AddressBalanceResponse, ConsensusTipRequest,
GetAddressUtxosRequest};
use crate::types::{Address, Currency, PublicKey, SiacoinElement, SpendPolicy, V2TransactionBuilder};
use async_trait::async_trait;
use thiserror::Error;

#[derive(Debug, Error)]
pub enum ApiClientHelpersError {
pub enum ApiClientHelpersError<E> {
#[error(
"ApiClientHelpersError::SelectOutputs: insufficent amount, available: {available:?} required: {required:?}"
)]
SelectOutputs { available: Currency, required: Currency },
#[error("ApiClientHelpersError::ApiClientError: {0}")]
ApiClientError(#[from] ApiClientError),
ApiClientError(#[from] E),
}

/// Helper methods for the ApiClient trait
/// These generally provide higher level functionality than the base ApiClient trait
/// This crate is focused on catering to the Komodo Defi Framework integration
#[async_trait]
pub trait ApiClientHelpers: ApiClient {
async fn current_height(&self) -> Result<u64, ApiClientError> {
async fn current_height(&self) -> Result<u64, Self::Error> {
Ok(self.dispatcher(ConsensusTipRequest).await?.height)
}

async fn address_balance(&self, address: Address) -> Result<AddressBalanceResponse, ApiClientError> {
async fn address_balance(&self, address: Address) -> Result<AddressBalanceResponse, Self::Error> {
self.dispatcher(AddressBalanceRequest { address }).await
}

Expand All @@ -33,7 +33,7 @@ pub trait ApiClientHelpers: ApiClient {
address: &Address,
limit: Option<i64>,
offset: Option<i64>,
) -> Result<Vec<SiacoinElement>, ApiClientError> {
) -> Result<Vec<SiacoinElement>, Self::Error> {
self.dispatcher(GetAddressUtxosRequest {
address: address.clone(),
limit,
Expand All @@ -60,7 +60,7 @@ pub trait ApiClientHelpers: ApiClient {
&self,
address: &Address,
total_amount: Currency,
) -> Result<(Vec<SiacoinElement>, Currency), ApiClientHelpersError> {
) -> Result<(Vec<SiacoinElement>, Currency), ApiClientHelpersError<Self::Error>> {
let mut unspent_outputs = self.get_unspent_outputs(address, None, None).await?;

// Sort outputs from largest to smallest
Expand Down Expand Up @@ -106,7 +106,7 @@ pub trait ApiClientHelpers: ApiClient {
tx_builder: &mut V2TransactionBuilder,
public_key: &PublicKey,
miner_fee: Currency,
) -> Result<(), ApiClientHelpersError> {
) -> Result<(), ApiClientHelpersError<Self::Error>> {
let address = public_key.address();
let outputs_total: Currency = tx_builder.siacoin_outputs.iter().map(|output| output.value).sum();

Expand Down
57 changes: 36 additions & 21 deletions src/transport/client/native.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
use crate::transport::endpoints::{ConsensusTipRequest, SiaApiRequest};
use crate::transport::endpoints::{ConsensusTipRequest, EndpointSchemaError, SiaApiRequest};
use async_trait::async_trait;
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine;
use http::header::{HeaderMap, HeaderValue, AUTHORIZATION};
use http::header::{HeaderMap, HeaderValue, InvalidHeaderValue, AUTHORIZATION};
use reqwest::Client as ReqwestClient;
use serde::Deserialize;
use url::Url;
use thiserror::Error;
use url::{ParseError, Url};

use crate::transport::client::{ApiClient, ApiClientError, ApiClientHelpers, Body as ClientBody, EndpointSchema};
use crate::transport::client::{ApiClient, ApiClientHelpers, Body as ClientBody};
use core::time::Duration;

#[derive(Clone)]
Expand All @@ -16,6 +17,22 @@ pub struct NativeClient {
pub base_url: Url,
}

#[derive(Debug, Error)]
pub enum ClientError {
#[error("Client initialization error: {0}")]
InitializationError(#[from] InvalidHeaderValue),
#[error("Reqwest error: {0}")]
ReqwestError(#[from] reqwest::Error),
#[error("Url parse error: {0}")]
UrlParseError(#[from] ParseError),
#[error("Endpoint schema creation error: {0}")]
EndpointError(#[from] EndpointSchemaError),
#[error("Unexpected empty resposne, expected: {expected_type}")]
UnexpectedEmptyResponse { expected_type: String },
#[error("Unexpected HTTP status: [status: {status} body: {body}]")]
UnexpectedHttpStatus { status: http::StatusCode, body: String },
}

#[derive(Clone, Debug, Deserialize)]
pub struct Conf {
pub server_url: Url,
Expand All @@ -29,23 +46,24 @@ pub struct Conf {
impl ApiClient for NativeClient {
type Request = reqwest::Request;
type Response = reqwest::Response;
type Error = ClientError;
type Conf = Conf;

async fn new(conf: Self::Conf) -> Result<Self, ApiClientError> {
async fn new(conf: Self::Conf) -> Result<Self, Self::Error> {
let mut headers = HeaderMap::new();
if let Some(password) = &conf.password {
let auth_value = format!("Basic {}", BASE64.encode(format!(":{}", password)));
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&auth_value).map_err(|e| ApiClientError::BuildError(e.to_string()))?,
HeaderValue::from_str(&auth_value).map_err(ClientError::InitializationError)?,
);
}
let timeout = conf.timeout.unwrap_or(10);
let client = ReqwestClient::builder()
.default_headers(headers)
.timeout(Duration::from_secs(timeout))
.build()
.map_err(ApiClientError::ReqwestError)?;
.map_err(ClientError::ReqwestError)?;

let ret = NativeClient {
client,
Expand All @@ -56,43 +74,40 @@ impl ApiClient for NativeClient {
Ok(ret)
}

fn process_schema(&self, schema: EndpointSchema) -> Result<Self::Request, ApiClientError> {
let url = schema.build_url(&self.base_url)?;
fn to_data_request<R: SiaApiRequest>(&self, request: R) -> Result<Self::Request, Self::Error> {
let schema = request.to_endpoint_schema().map_err(ClientError::EndpointError)?;
let url = schema.build_url(&self.base_url).map_err(ClientError::UrlParseError)?;
let req = match schema.body {
ClientBody::None => self.client.request(schema.method.into(), url).build(),
ClientBody::Utf8(body) => self.client.request(schema.method.into(), url).body(body).build(),
ClientBody::Json(body) => self.client.request(schema.method.into(), url).json(&body).build(),
ClientBody::Bytes(body) => self.client.request(schema.method.into(), url).body(body).build(),
}
.map_err(ApiClientError::ReqwestError)?;
.map_err(ClientError::ReqwestError)?;
Ok(req)
}

async fn execute_request(&self, request: Self::Request) -> Result<Self::Response, ApiClientError> {
self.client.execute(request).await.map_err(ApiClientError::ReqwestError)
async fn execute_request(&self, request: Self::Request) -> Result<Self::Response, Self::Error> {
self.client.execute(request).await.map_err(ClientError::ReqwestError)
}

async fn dispatcher<R: SiaApiRequest>(&self, request: R) -> Result<R::Response, ApiClientError> {
async fn dispatcher<R: SiaApiRequest>(&self, request: R) -> Result<R::Response, Self::Error> {
let request = self.to_data_request(request)?;

// Execute the request using reqwest client
let response = self
.client
.execute(request)
.await
.map_err(ApiClientError::ReqwestError)?;
let response = self.execute_request(request).await?;

// Check the response status and return the appropriate result
match response.status() {
reqwest::StatusCode::OK => Ok(response
.json::<R::Response>()
.await
.map_err(ApiClientError::ReqwestError)?),
.map_err(ClientError::ReqwestError)?),
reqwest::StatusCode::NO_CONTENT => {
if let Some(resp_type) = R::is_empty_response() {
Ok(resp_type)
} else {
Err(ApiClientError::UnexpectedEmptyResponse {
Err(ClientError::UnexpectedEmptyResponse {
expected_type: std::any::type_name::<R::Response>().to_string(),
})
}
Expand All @@ -106,7 +121,7 @@ impl ApiClient for NativeClient {
.map_err(|e| format!("Failed to retrieve body: {}", e))
.unwrap_or_else(|e| e);

Err(ApiClientError::UnexpectedHttpStatus { status, body })
Err(ClientError::UnexpectedHttpStatus { status, body })
},
}
}
Expand Down
Loading