From 3999d2506404923443708abf67562c9468ad6bf3 Mon Sep 17 00:00:00 2001 From: Omer Yacine Date: Thu, 24 Oct 2024 22:09:16 +0200 Subject: [PATCH] refactor(dispatcher): use generic error placeholder for the ApiClient --- src/transport/client.rs | 41 ++++---------------- src/transport/client/helpers.rs | 16 ++++---- src/transport/client/native.rs | 57 +++++++++++++++++----------- src/transport/client/wasm.rs | 67 +++++++++++++++++++++++---------- src/transport/endpoints.rs | 33 +++++++++------- 5 files changed, 119 insertions(+), 95 deletions(-) diff --git a/src/transport/client.rs b/src/transport/client.rs index 0024302..023f112 100644 --- a/src/transport/client.rs +++ b/src/transport/client.rs @@ -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; @@ -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 + async fn new(conf: Self::Conf) -> Result where Self: Sized; - fn process_schema(&self, schema: EndpointSchema) -> Result; - - fn to_data_request(&self, request: R) -> Result { - self.process_schema(request.to_endpoint_schema()?) - } + fn to_data_request(&self, request: R) -> Result; // 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; + async fn execute_request(&self, request: Self::Request) -> Result; // TODO default implementation should be possible if Execute::Response is a serde deserializable type - async fn dispatcher(&self, request: R) -> Result; -} - -#[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(&self, request: R) -> Result; } // Not all client implementations will have an exact equivalent of HTTP methods @@ -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 { + pub fn build_url(&self, base_url: &Url) -> Result { let mut path = self.path_schema.to_string(); // Replace placeholders in the path with encoded values if path_params are provided @@ -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 { diff --git a/src/transport/client/helpers.rs b/src/transport/client/helpers.rs index 37ae54c..77fe399 100644 --- a/src/transport/client/helpers.rs +++ b/src/transport/client/helpers.rs @@ -1,4 +1,4 @@ -use super::{ApiClient, ApiClientError}; +use super::ApiClient; use crate::transport::endpoints::{AddressBalanceRequest, AddressBalanceResponse, ConsensusTipRequest, GetAddressUtxosRequest}; use crate::types::{Address, Currency, PublicKey, SiacoinElement, SpendPolicy, V2TransactionBuilder}; @@ -6,13 +6,13 @@ use async_trait::async_trait; use thiserror::Error; #[derive(Debug, Error)] -pub enum ApiClientHelpersError { +pub enum ApiClientHelpersError { #[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 @@ -20,11 +20,11 @@ pub enum ApiClientHelpersError { /// This crate is focused on catering to the Komodo Defi Framework integration #[async_trait] pub trait ApiClientHelpers: ApiClient { - async fn current_height(&self) -> Result { + async fn current_height(&self) -> Result { Ok(self.dispatcher(ConsensusTipRequest).await?.height) } - async fn address_balance(&self, address: Address) -> Result { + async fn address_balance(&self, address: Address) -> Result { self.dispatcher(AddressBalanceRequest { address }).await } @@ -33,7 +33,7 @@ pub trait ApiClientHelpers: ApiClient { address: &Address, limit: Option, offset: Option, - ) -> Result, ApiClientError> { + ) -> Result, Self::Error> { self.dispatcher(GetAddressUtxosRequest { address: address.clone(), limit, @@ -60,7 +60,7 @@ pub trait ApiClientHelpers: ApiClient { &self, address: &Address, total_amount: Currency, - ) -> Result<(Vec, Currency), ApiClientHelpersError> { + ) -> Result<(Vec, Currency), ApiClientHelpersError> { let mut unspent_outputs = self.get_unspent_outputs(address, None, None).await?; // Sort outputs from largest to smallest @@ -106,7 +106,7 @@ pub trait ApiClientHelpers: ApiClient { tx_builder: &mut V2TransactionBuilder, public_key: &PublicKey, miner_fee: Currency, - ) -> Result<(), ApiClientHelpersError> { + ) -> Result<(), ApiClientHelpersError> { let address = public_key.address(); let outputs_total: Currency = tx_builder.siacoin_outputs.iter().map(|output| output.value).sum(); diff --git a/src/transport/client/native.rs b/src/transport/client/native.rs index f894568..6f60968 100644 --- a/src/transport/client/native.rs +++ b/src/transport/client/native.rs @@ -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)] @@ -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, @@ -29,15 +46,16 @@ 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 { + async fn new(conf: Self::Conf) -> Result { 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); @@ -45,7 +63,7 @@ impl ApiClient for NativeClient { .default_headers(headers) .timeout(Duration::from_secs(timeout)) .build() - .map_err(ApiClientError::ReqwestError)?; + .map_err(ClientError::ReqwestError)?; let ret = NativeClient { client, @@ -56,43 +74,40 @@ impl ApiClient for NativeClient { Ok(ret) } - fn process_schema(&self, schema: EndpointSchema) -> Result { - let url = schema.build_url(&self.base_url)?; + fn to_data_request(&self, request: R) -> Result { + 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.client.execute(request).await.map_err(ApiClientError::ReqwestError) + async fn execute_request(&self, request: Self::Request) -> Result { + self.client.execute(request).await.map_err(ClientError::ReqwestError) } - async fn dispatcher(&self, request: R) -> Result { + async fn dispatcher(&self, request: R) -> Result { 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::() .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::().to_string(), }) } @@ -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 }) }, } } diff --git a/src/transport/client/wasm.rs b/src/transport/client/wasm.rs index 9ecfea2..a3fbb90 100644 --- a/src/transport/client/wasm.rs +++ b/src/transport/client/wasm.rs @@ -1,21 +1,42 @@ -use crate::transport::client::{ApiClient, ApiClientError, ApiClientHelpers, Body, EndpointSchema, SchemaMethod}; -use crate::transport::endpoints::{ConsensusTipRequest, SiaApiRequest}; +use crate::transport::client::{ApiClient, ApiClientHelpers, Body, SchemaMethod}; +use crate::transport::endpoints::{ConsensusTipRequest, EndpointSchemaError, SiaApiRequest}; use async_trait::async_trait; use http::StatusCode; use serde::Deserialize; use std::collections::HashMap; -use url::Url; +use thiserror::Error; +use url::{ParseError, Url}; pub mod wasm_fetch; +use crate::transport::client::wasm::wasm_fetch::FetchError; use wasm_fetch::{Body as FetchBody, FetchMethod, FetchRequest, FetchResponse}; #[derive(Clone)] -pub struct Client { +pub struct WasmClient { pub base_url: Url, pub headers: HashMap, } +#[derive(Debug, Error)] +pub enum ClientError { + #[error("Request error: {0}")] + WasmFetchError(#[from] FetchError), + #[error("Url parse error: {0}")] + UrlParseError(#[from] ParseError), + #[error("Endpoint schema creation error: {0}")] + EndpointError(#[from] EndpointSchemaError), + #[error("Deserialization error: {0}")] + DeserializationError(#[from] serde_json::Error), + #[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 }, + // FIXME: Remove this error type. + #[error("FixmePlaceholder error: {0}")] + FixmePlaceholder(String), +} + #[derive(Clone, Debug, Deserialize)] pub struct Conf { pub server_url: Url, @@ -24,13 +45,14 @@ pub struct Conf { } #[async_trait] -impl ApiClient for Client { +impl ApiClient for WasmClient { type Request = FetchRequest; type Response = FetchResponse; + type Error = ClientError; type Conf = Conf; - async fn new(conf: Self::Conf) -> Result { - let client = Client { + async fn new(conf: Self::Conf) -> Result { + let client = WasmClient { base_url: conf.server_url, headers: conf.headers, }; @@ -39,12 +61,13 @@ impl ApiClient for Client { Ok(client) } - fn process_schema(&self, schema: EndpointSchema) -> Result { + fn to_data_request(&self, request: R) -> Result { + let schema = request.to_endpoint_schema().map_err(ClientError::EndpointError)?; let url = schema.build_url(&self.base_url)?; let method = match schema.method { SchemaMethod::Get => FetchMethod::Get, SchemaMethod::Post => FetchMethod::Post, - _ => return Err(ApiClientError::FixmePlaceholder("Unsupported method".to_string())), + _ => return Err(ClientError::FixmePlaceholder("Unsupported method".to_string())), }; let body = match schema.body { Body::Utf8(body) => Some(FetchBody::Utf8(body)), @@ -60,15 +83,15 @@ impl ApiClient for Client { }) } - async fn execute_request(&self, request: Self::Request) -> Result { + async fn execute_request(&self, request: Self::Request) -> Result { request .execute() .await - .map_err(|e| ApiClientError::FixmePlaceholder(format!("FIXME {}", e))) + .map_err(|e| ClientError::FixmePlaceholder(format!("FIXME {}", e))) } // Dispatcher function that converts the request and handles execution - async fn dispatcher(&self, request: R) -> Result { + async fn dispatcher(&self, request: R) -> Result { let request = self.to_data_request(request)?; // Convert request to data request // Execute the request @@ -77,10 +100,14 @@ impl ApiClient for Client { match response.status { StatusCode::OK => { let response_body = match response.body { - Some(FetchBody::Json(body)) => serde_json::from_value(body).map_err(ApiClientError::Serde)?, - Some(FetchBody::Utf8(body)) => serde_json::from_str(&body).map_err(ApiClientError::Serde)?, + Some(FetchBody::Json(body)) => { + serde_json::from_value(body).map_err(ClientError::DeserializationError)? + }, + Some(FetchBody::Utf8(body)) => { + serde_json::from_str(&body).map_err(ClientError::DeserializationError)? + }, _ => { - return Err(ApiClientError::FixmePlaceholder( + return Err(ClientError::FixmePlaceholder( "Unsupported body type in response".to_string(), )) }, @@ -91,7 +118,7 @@ impl ApiClient for Client { 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::().to_string(), }) } @@ -103,7 +130,7 @@ impl ApiClient for Client { .map(|b| format!("{}", b)) // Use Display trait to format Body .unwrap_or_else(|| "".to_string()); // If body is None, use an empty string - Err(ApiClientError::UnexpectedHttpStatus { status, body }) + Err(ClientError::UnexpectedHttpStatus { status, body }) }, } } @@ -113,7 +140,7 @@ impl ApiClient for Client { // Just this is needed to implement the `ApiClientHelpers` trait // unless custom implementations for the traits methods are needed #[async_trait] -impl ApiClientHelpers for Client {} +impl ApiClientHelpers for WasmClient {} #[cfg(all(target_arch = "wasm32", test))] mod wasm_tests { @@ -132,7 +159,7 @@ mod wasm_tests { async fn test_sia_wasm_client_client_error() { use crate::transport::endpoints::TxpoolBroadcastRequest; use crate::types::V2Transaction; - let client = Client::new(CONF.clone()).await.unwrap(); + let client = WasmClient::new(CONF.clone()).await.unwrap(); let tx_str = r#" { @@ -186,7 +213,7 @@ mod wasm_tests { v2transactions: vec![tx], }; match client.dispatcher(req).await.expect_err("Expected HTTP 400 error") { - ApiClientError::UnexpectedHttpStatus { + ClientError::UnexpectedHttpStatus { status: StatusCode::BAD_REQUEST, body: _, } => (), diff --git a/src/transport/endpoints.rs b/src/transport/endpoints.rs index d55104b..21e2aba 100644 --- a/src/transport/endpoints.rs +++ b/src/transport/endpoints.rs @@ -1,8 +1,9 @@ -use crate::transport::client::{ApiClientError, Body, EndpointSchema, EndpointSchemaBuilder, SchemaMethod}; +use crate::transport::client::{Body, EndpointSchema, EndpointSchemaBuilder, SchemaMethod}; use crate::types::{Address, BlockID, Currency, Event, Hash256, SiacoinElement, V1Transaction, V2Transaction}; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use thiserror::Error; const ENDPOINT_ADDRESSES_BALANCE: &str = "api/addresses/{address}/balance"; const ENDPOINT_ADDRESSES_EVENTS: &str = "api/addresses/{address}/events"; @@ -14,13 +15,19 @@ const ENDPOINT_TXPOOL_FEE: &str = "api/txpool/fee"; const ENDPOINT_TXPOOL_TRANSACTIONS: &str = "api/txpool/transactions"; const ENDPOINT_DEBUG_MINE: &str = "api/debug/mine"; +#[derive(Debug, Error)] +pub enum EndpointSchemaError { + #[error("Serialization error: {0}")] + SerializationError(#[from] serde_json::Error), +} + pub trait SiaApiRequest: Send { type Response: DeserializeOwned; // Applicable for requests that return HTTP 204 No Content fn is_empty_response() -> Option { None } - fn to_endpoint_schema(&self) -> Result; + fn to_endpoint_schema(&self) -> Result; } /// Represents the request-response pair for fetching the current consensus tip of the Sia network. @@ -47,7 +54,7 @@ pub struct ConsensusTipRequest; impl SiaApiRequest for ConsensusTipRequest { type Response = ConsensusTipResponse; - fn to_endpoint_schema(&self) -> Result { + fn to_endpoint_schema(&self) -> Result { Ok(EndpointSchemaBuilder::new(ENDPOINT_CONSENSUS_TIP.to_owned(), SchemaMethod::Get).build()) } } @@ -89,7 +96,7 @@ pub struct AddressBalanceRequest { impl SiaApiRequest for AddressBalanceRequest { type Response = AddressBalanceResponse; - fn to_endpoint_schema(&self) -> Result { + fn to_endpoint_schema(&self) -> Result { let mut path_params = HashMap::new(); path_params.insert("address".to_owned(), self.address.to_string()); @@ -136,7 +143,7 @@ pub struct GetEventRequest { impl SiaApiRequest for GetEventRequest { type Response = Event; - fn to_endpoint_schema(&self) -> Result { + fn to_endpoint_schema(&self) -> Result { // Create the path_params HashMap to substitute {txid} in the path schema let mut path_params = HashMap::new(); path_params.insert("txid".to_owned(), self.txid.to_string()); @@ -180,7 +187,7 @@ pub struct AddressesEventsRequest { impl SiaApiRequest for AddressesEventsRequest { type Response = Vec; - fn to_endpoint_schema(&self) -> Result { + fn to_endpoint_schema(&self) -> Result { let mut path_params = HashMap::new(); path_params.insert("address".to_owned(), self.address.to_string()); @@ -235,7 +242,7 @@ pub struct GetAddressUtxosRequest { impl SiaApiRequest for GetAddressUtxosRequest { type Response = Vec; - fn to_endpoint_schema(&self) -> Result { + fn to_endpoint_schema(&self) -> Result { let mut path_params = HashMap::new(); path_params.insert("address".to_owned(), self.address.to_string()); @@ -299,9 +306,9 @@ impl SiaApiRequest for TxpoolBroadcastRequest { fn is_empty_response() -> Option { Some(EmptyResponse) } - fn to_endpoint_schema(&self) -> Result { + fn to_endpoint_schema(&self) -> Result { // Serialize the transactions into a JSON body - let body = serde_json::to_value(self).map_err(ApiClientError::Serde)?; + let body = serde_json::to_value(self).map_err(EndpointSchemaError::SerializationError)?; let body = body.to_string(); Ok( EndpointSchemaBuilder::new(ENDPOINT_TXPOOL_BROADCAST.to_owned(), SchemaMethod::Post) @@ -343,7 +350,7 @@ pub struct TxpoolFeeResponse(pub Currency); impl SiaApiRequest for TxpoolFeeRequest { type Response = TxpoolFeeResponse; - fn to_endpoint_schema(&self) -> Result { + fn to_endpoint_schema(&self) -> Result { Ok( EndpointSchemaBuilder::new(ENDPOINT_TXPOOL_FEE.to_owned(), SchemaMethod::Get).build(), // No path_params, query_params, or body needed for this request ) @@ -374,7 +381,7 @@ impl SiaApiRequest for TxpoolTransactionsRequest { fn is_empty_response() -> Option { Some(EmptyResponse) } - fn to_endpoint_schema(&self) -> Result { + fn to_endpoint_schema(&self) -> Result { Ok( EndpointSchemaBuilder::new(ENDPOINT_TXPOOL_TRANSACTIONS.to_owned(), SchemaMethod::Get).build(), // No path_params, query_params, or body needed for this request ) @@ -414,9 +421,9 @@ impl SiaApiRequest for DebugMineRequest { fn is_empty_response() -> Option { Some(EmptyResponse) } - fn to_endpoint_schema(&self) -> Result { + fn to_endpoint_schema(&self) -> Result { // Serialize the request into a JSON string - let body = serde_json::to_string(self).map_err(ApiClientError::Serde)?; + let body = serde_json::to_string(self).map_err(EndpointSchemaError::SerializationError)?; Ok( EndpointSchemaBuilder::new(ENDPOINT_DEBUG_MINE.to_owned(), SchemaMethod::Post) .body(Body::Utf8(body)) // Set the JSON body for the POST request