v0.2.11-pre.003
This commit is contained in:
331
crates/ksp-offchain-transport-lib/src/http_client.rs
Normal file
331
crates/ksp-offchain-transport-lib/src/http_client.rs
Normal file
@@ -0,0 +1,331 @@
|
||||
// file: crates/ksp-offchain-transport-lib/src/http_client.rs
|
||||
// version: 1
|
||||
|
||||
//! Crate-wide hardened REST client used internally by off-chain capability adapters.
|
||||
|
||||
/// Bounded successful JSON document returned by the crate-internal REST client.
|
||||
pub(crate) struct HttpJsonDocument {
|
||||
bytes: std::vec::Vec<u8>,
|
||||
}
|
||||
|
||||
impl HttpJsonDocument {
|
||||
/// Returns the validated raw JSON bytes for provider-specific typed deserialization.
|
||||
#[must_use]
|
||||
pub(crate) fn as_bytes(&self) -> &[u8] {
|
||||
return self.bytes.as_slice();
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for HttpJsonDocument {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter.debug_struct("HttpJsonDocument").field("byte_len", &self.bytes.len()).finish();
|
||||
}
|
||||
}
|
||||
|
||||
/// Crate-internal fixed-origin GET request.
|
||||
///
|
||||
/// URLs and headers are deliberately absent from [`std::fmt::Debug`] because future provider adapters can attach credentials to headers.
|
||||
pub(crate) struct HttpGetRequest {
|
||||
headers: reqwest::header::HeaderMap,
|
||||
url: reqwest::Url,
|
||||
}
|
||||
|
||||
impl HttpGetRequest {
|
||||
/// Creates one HTTPS GET request from a crate-owned official provider URL.
|
||||
pub(crate) fn new_https(url: &'static str) -> ksp_core_lib::Result<Self> {
|
||||
return Self::parse(url, false);
|
||||
}
|
||||
|
||||
/// Appends one non-secret query pair using URL encoding.
|
||||
pub(crate) fn append_query_pair(&mut self, name: &'static str, value: &str) {
|
||||
self.url.query_pairs_mut().append_pair(name, value);
|
||||
return;
|
||||
}
|
||||
|
||||
/// Adds one sensitive header without exposing its value through this type's debug representation.
|
||||
pub(crate) fn insert_sensitive_header(&mut self, name: &'static str, value: &str) -> ksp_core_lib::Result<()> {
|
||||
let name_result = reqwest::header::HeaderName::from_bytes(name.as_bytes());
|
||||
let name = match name_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_REQUEST_INVALID, "Off-chain HTTP header name is invalid")
|
||||
.with_context("field", "header_name")
|
||||
.with_source(error),
|
||||
);
|
||||
},
|
||||
};
|
||||
let value_result = reqwest::header::HeaderValue::from_bytes(value.as_bytes());
|
||||
let mut value = match value_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_REQUEST_INVALID, "Off-chain HTTP header value is invalid")
|
||||
.with_context("field", "header_value")
|
||||
.with_source(error),
|
||||
);
|
||||
},
|
||||
};
|
||||
value.set_sensitive(true);
|
||||
self.headers.insert(name, value);
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
/// Creates a plain-HTTP request for loopback-only deterministic unit tests.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn new_test_http(url: &str) -> ksp_core_lib::Result<Self> {
|
||||
return Self::parse(url, true);
|
||||
}
|
||||
|
||||
fn parse(url: &str, allow_http_for_tests: bool) -> ksp_core_lib::Result<Self> {
|
||||
let parsed_result = reqwest::Url::parse(url);
|
||||
let parsed = match parsed_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_REQUEST_INVALID, "Off-chain provider URL is invalid")
|
||||
.with_context("field", "provider_url")
|
||||
.with_source(error),
|
||||
);
|
||||
},
|
||||
};
|
||||
let scheme_allowed = parsed.scheme() == "https" || (allow_http_for_tests && parsed.scheme() == "http");
|
||||
if !scheme_allowed || parsed.host_str().is_none() {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_REQUEST_INVALID, "Off-chain provider URL must use an allowed scheme and host")
|
||||
.with_context("field", "provider_url"),
|
||||
);
|
||||
}
|
||||
if !parsed.username().is_empty() || parsed.password().is_some() {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_REQUEST_INVALID, "Off-chain provider URL cannot embed credentials")
|
||||
.with_context("field", "provider_url"),
|
||||
);
|
||||
}
|
||||
return std::result::Result::Ok(Self { headers: reqwest::header::HeaderMap::new(), url: parsed });
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for HttpGetRequest {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter.write_str("HttpGetRequest(<redacted>)");
|
||||
}
|
||||
}
|
||||
|
||||
/// Shareable hardened REST client owned by Off-chain Transport.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct HttpRestClient {
|
||||
client: reqwest::Client,
|
||||
settings: crate::HttpClientSettings,
|
||||
}
|
||||
|
||||
impl HttpRestClient {
|
||||
/// Builds one hardened client with redirects, system proxies and reqwest automatic retries disabled.
|
||||
pub(crate) fn new(settings: crate::HttpClientSettings) -> ksp_core_lib::Result<Self> {
|
||||
let client_result = reqwest::Client::builder()
|
||||
.connect_timeout(settings.connect_timeout())
|
||||
.timeout(settings.request_timeout())
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.referer(false)
|
||||
.retry(reqwest::retry::never())
|
||||
.no_proxy()
|
||||
.user_agent(concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")))
|
||||
.build();
|
||||
let client = match client_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_CLIENT_BUILD_FAILED, "Off-chain HTTP client could not be initialized")
|
||||
.with_source(error.without_url()),
|
||||
);
|
||||
},
|
||||
};
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
connect_timeout_ms = duration_millis_u64(settings.connect_timeout()),
|
||||
request_timeout_ms = duration_millis_u64(settings.request_timeout()),
|
||||
max_response_body_bytes = settings.max_response_body_bytes(),
|
||||
"created hardened off-chain HTTP REST client"
|
||||
);
|
||||
return std::result::Result::Ok(Self { client, settings });
|
||||
}
|
||||
|
||||
/// Executes one GET request and returns only a bounded syntactically valid JSON document.
|
||||
pub(crate) async fn get_json(&self, provider: &'static str, operation: &'static str, request: HttpGetRequest) -> ksp_core_lib::Result<HttpJsonDocument> {
|
||||
let send_result = self.client.get(request.url).headers(request.headers).send().await;
|
||||
let mut response = match send_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(map_reqwest_error(provider, operation, error)),
|
||||
};
|
||||
let status = response.status().as_u16();
|
||||
let retry_after = parse_retry_after(response.headers());
|
||||
if !(200..300).contains(&status) {
|
||||
return classify_http_status(provider, operation, status, retry_after);
|
||||
}
|
||||
if let std::option::Option::Some(content_length) = response.content_length()
|
||||
&& content_length > usize_to_u64(self.settings.max_response_body_bytes())
|
||||
{
|
||||
return response_too_large(provider, operation, self.settings.max_response_body_bytes());
|
||||
}
|
||||
let mut body = std::vec::Vec::new();
|
||||
loop {
|
||||
let chunk_result = response.chunk().await;
|
||||
let chunk = match chunk_result {
|
||||
std::result::Result::Ok(std::option::Option::Some(value)) => value,
|
||||
std::result::Result::Ok(std::option::Option::None) => break,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(map_reqwest_error(provider, operation, error)),
|
||||
};
|
||||
let next_len = body.len().saturating_add(chunk.len());
|
||||
if next_len > self.settings.max_response_body_bytes() {
|
||||
return response_too_large(provider, operation, self.settings.max_response_body_bytes());
|
||||
}
|
||||
body.extend_from_slice(chunk.as_ref());
|
||||
}
|
||||
let json_validation = serde_json::from_slice::<serde::de::IgnoredAny>(body.as_slice());
|
||||
if let std::result::Result::Err(error) = json_validation {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_INVALID_JSON, "Off-chain provider returned invalid JSON")
|
||||
.with_context("provider", provider)
|
||||
.with_context("operation", operation)
|
||||
.with_source(error),
|
||||
);
|
||||
}
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
provider = provider,
|
||||
operation = operation,
|
||||
http_status = status,
|
||||
response_body_bytes = body.len(),
|
||||
"completed off-chain HTTP REST request"
|
||||
);
|
||||
return std::result::Result::Ok(HttpJsonDocument { bytes: body });
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_http_status(
|
||||
provider: &'static str,
|
||||
operation: &'static str,
|
||||
status: u16,
|
||||
retry_after: std::option::Option<std::time::Duration>,
|
||||
) -> ksp_core_lib::Result<HttpJsonDocument> {
|
||||
if status == 401 || status == 403 {
|
||||
return std::result::Result::Err(http_status_error(
|
||||
crate::ERROR_CODE_HTTP_ACCESS_DENIED,
|
||||
"Off-chain provider denied HTTP access",
|
||||
provider,
|
||||
operation,
|
||||
status,
|
||||
std::option::Option::None,
|
||||
));
|
||||
}
|
||||
if status == 429 {
|
||||
return std::result::Result::Err(http_status_error(
|
||||
crate::ERROR_CODE_HTTP_RATE_LIMITED,
|
||||
"Off-chain provider rate-limited the request",
|
||||
provider,
|
||||
operation,
|
||||
status,
|
||||
retry_after,
|
||||
));
|
||||
}
|
||||
if status == 408 || (500..600).contains(&status) {
|
||||
return std::result::Result::Err(http_status_error(
|
||||
crate::ERROR_CODE_HTTP_TEMPORARY_FAILURE,
|
||||
"Off-chain provider returned a temporary HTTP failure",
|
||||
provider,
|
||||
operation,
|
||||
status,
|
||||
retry_after,
|
||||
));
|
||||
}
|
||||
return std::result::Result::Err(http_status_error(
|
||||
crate::ERROR_CODE_HTTP_REQUEST_FAILED,
|
||||
"Off-chain provider returned an unsuccessful HTTP status",
|
||||
provider,
|
||||
operation,
|
||||
status,
|
||||
std::option::Option::None,
|
||||
));
|
||||
}
|
||||
|
||||
fn http_status_error(
|
||||
code: ksp_core_lib::ErrorCode,
|
||||
message: &'static str,
|
||||
provider: &'static str,
|
||||
operation: &'static str,
|
||||
status: u16,
|
||||
retry_after: std::option::Option<std::time::Duration>,
|
||||
) -> ksp_core_lib::Error {
|
||||
let mut error = ksp_core_lib::Error::new(code, message)
|
||||
.with_context("provider", provider)
|
||||
.with_context("operation", operation)
|
||||
.with_context("http_status", status.to_string());
|
||||
if let std::option::Option::Some(value) = retry_after {
|
||||
error = error.with_context("retry_after_seconds", value.as_secs().to_string());
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
fn map_reqwest_error(provider: &'static str, operation: &'static str, error: reqwest::Error) -> ksp_core_lib::Error {
|
||||
let code = if error.is_timeout() {
|
||||
crate::ERROR_CODE_HTTP_TIMEOUT
|
||||
} else if error.is_connect() {
|
||||
crate::ERROR_CODE_HTTP_CONNECTION_FAILED
|
||||
} else {
|
||||
crate::ERROR_CODE_HTTP_REQUEST_FAILED
|
||||
};
|
||||
let message = if code == crate::ERROR_CODE_HTTP_TIMEOUT {
|
||||
"Off-chain HTTP request timed out"
|
||||
} else if code == crate::ERROR_CODE_HTTP_CONNECTION_FAILED {
|
||||
"Off-chain HTTP connection failed"
|
||||
} else {
|
||||
"Off-chain HTTP request failed"
|
||||
};
|
||||
return ksp_core_lib::Error::new(code, message)
|
||||
.with_context("provider", provider)
|
||||
.with_context("operation", operation)
|
||||
.with_source(error.without_url());
|
||||
}
|
||||
|
||||
fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> std::option::Option<std::time::Duration> {
|
||||
let value = match headers.get(reqwest::header::RETRY_AFTER) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
let text = match value.to_str() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
let seconds = match text.parse::<u64>() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
return std::option::Option::Some(std::cmp::min(std::time::Duration::from_secs(seconds), crate::HTTP_MAX_RETRY_AFTER));
|
||||
}
|
||||
|
||||
fn response_too_large(provider: &'static str, operation: &'static str, limit: usize) -> ksp_core_lib::Result<HttpJsonDocument> {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_RESPONSE_TOO_LARGE, "Off-chain provider response exceeded the configured body limit")
|
||||
.with_context("provider", provider)
|
||||
.with_context("operation", operation)
|
||||
.with_context("max_response_body_bytes", limit.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
fn duration_millis_u64(duration: std::time::Duration) -> u64 {
|
||||
return match u64::try_from(duration.as_millis()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => u64::MAX,
|
||||
};
|
||||
}
|
||||
|
||||
fn usize_to_u64(value: usize) -> u64 {
|
||||
return match u64::try_from(value) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => u64::MAX,
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/http_client.rs"]
|
||||
mod tests;
|
||||
Reference in New Issue
Block a user