use serde::{Deserialize, Serialize};
use wyrd_spec::operator_connection::HttpsOrigin;
use crate::error::WyrdClientError;
pub const GRPC_DEFAULT_ENDPOINT: &str = "http://localhost:50051";
pub const GRPC_DEFAULT_PORT: u16 = 50051;
#[must_use]
pub fn grpc_endpoint_for(server_url: &str) -> String {
match reqwest::Url::parse(server_url) {
Ok(url) => match url.host_str() {
Some(host) => format!("{}://{host}:{GRPC_DEFAULT_PORT}", url.scheme()),
None => server_url.to_owned(),
},
Err(_) => server_url.to_owned(),
}
}
pub const GRPC_DEFAULT_TIMEOUT_MS: u64 = 30_000;
pub const GRPC_DEFAULT_CONNECT_RETRIES: u32 = 3;
pub const GRPC_DEFAULT_KEEPALIVE_INTERVAL_MS: u64 = 20_000;
pub const GRPC_DEFAULT_KEEPALIVE_TIMEOUT_MS: u64 = 5_000;
pub const GRPC_DEFAULT_MAX_MESSAGE_BYTES: usize = 4 * 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct GrpcConfig {
pub endpoint: String,
pub timeout_ms: u64,
pub connect_retries: u32,
#[serde(default = "default_keepalive_interval_ms")]
pub keepalive_interval_ms: u64,
#[serde(default = "default_keepalive_timeout_ms")]
pub keepalive_timeout_ms: u64,
#[serde(default = "default_max_message_bytes")]
pub max_message_bytes: usize,
}
fn default_keepalive_interval_ms() -> u64 {
GRPC_DEFAULT_KEEPALIVE_INTERVAL_MS
}
fn default_keepalive_timeout_ms() -> u64 {
GRPC_DEFAULT_KEEPALIVE_TIMEOUT_MS
}
fn default_max_message_bytes() -> usize {
GRPC_DEFAULT_MAX_MESSAGE_BYTES
}
impl Default for GrpcConfig {
fn default() -> Self {
Self {
endpoint: GRPC_DEFAULT_ENDPOINT.to_string(),
timeout_ms: GRPC_DEFAULT_TIMEOUT_MS,
connect_retries: GRPC_DEFAULT_CONNECT_RETRIES,
keepalive_interval_ms: GRPC_DEFAULT_KEEPALIVE_INTERVAL_MS,
keepalive_timeout_ms: GRPC_DEFAULT_KEEPALIVE_TIMEOUT_MS,
max_message_bytes: GRPC_DEFAULT_MAX_MESSAGE_BYTES,
}
}
}
impl GrpcConfig {
pub fn validate(&self) -> Result<(), WyrdClientError> {
if self.endpoint.is_empty() {
return Err(WyrdClientError::Config {
field: "grpc_config.endpoint".to_string(),
reason: "must not be empty".to_string(),
});
}
if self.timeout_ms == 0 {
return Err(WyrdClientError::Config {
field: "grpc_config.timeout_ms".to_string(),
reason: "must be at least 1".to_string(),
});
}
Ok(())
}
}
pub const HTTP_DEFAULT_BASE_URL: &str = "http://localhost:8080";
pub const HTTP_DEFAULT_TIMEOUT_MS: u64 = 30_000;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct HttpConfig {
#[serde(default = "default_base_url")]
pub base_url: String,
#[serde(default = "default_timeout_ms")]
pub timeout_ms: u64,
#[serde(default)]
pub compression: bool,
}
fn default_base_url() -> String {
HTTP_DEFAULT_BASE_URL.to_string()
}
fn default_timeout_ms() -> u64 {
HTTP_DEFAULT_TIMEOUT_MS
}
impl Default for HttpConfig {
fn default() -> Self {
Self {
base_url: HTTP_DEFAULT_BASE_URL.to_string(),
timeout_ms: HTTP_DEFAULT_TIMEOUT_MS,
compression: false,
}
}
}
impl HttpConfig {
pub fn validate(&self) -> Result<HttpsOrigin, WyrdClientError> {
let invalid = |reason: String| WyrdClientError::Config {
field: "http_config.base_url".to_string(),
reason,
};
if self.base_url.is_empty() {
return Err(invalid("must not be empty".to_owned()));
}
if self.timeout_ms == 0 {
return Err(WyrdClientError::Config {
field: "http_config.timeout_ms".to_string(),
reason: "must be at least 1".to_string(),
});
}
let url = reqwest::Url::parse(&self.base_url)
.map_err(|error| invalid(format!("not an absolute URL: {error}")))?;
HttpsOrigin::of_url(&url).map_err(|error| match url.scheme() {
"http" if url.username().is_empty() && url.password().is_none() => invalid(
"remote cleartext HTTP is not allowed; use https:// or a loopback host".to_owned(),
),
_ => invalid(error.to_string()),
})
}
}
pub const MOCK_DEFAULT_LABEL: &str = "default";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct MockConfig {
pub label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fail_on_drain: Option<u32>,
}
impl Default for MockConfig {
fn default() -> Self {
Self {
label: MOCK_DEFAULT_LABEL.to_string(),
fail_on_drain: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(tag = "transport", content = "params", rename_all = "snake_case")]
pub enum TransportConfig {
Grpc(GrpcConfig),
Http(HttpConfig),
Mock(MockConfig),
}
impl Default for TransportConfig {
fn default() -> Self {
Self::Grpc(GrpcConfig::default())
}
}
impl TransportConfig {
pub fn name(&self) -> &'static str {
match self {
Self::Grpc(_) => "grpc",
Self::Http(_) => "http",
Self::Mock(_) => "mock",
}
}
pub fn validate(&self) -> Result<(), WyrdClientError> {
match self {
Self::Grpc(c) => c.validate(),
Self::Http(c) => c.validate().map(drop),
Self::Mock(_) => Ok(()),
}
}
}
#[cfg(test)]
mod tests {
use super::{GrpcConfig, HttpConfig, MockConfig, TransportConfig};
use crate::error::WyrdClientError;
use wyrd_spec::operator_connection::HttpsOrigin;
fn origin(base_url: &str) -> Result<HttpsOrigin, WyrdClientError> {
HttpConfig {
base_url: base_url.to_owned(),
..HttpConfig::default()
}
.validate()
}
#[test]
fn remote_cleartext_malformed_and_unsupported_targets_are_refused() {
for url in [
"http://wyrd.example.com",
"http://wyrd.example.com:8080/api",
"http://10.0.0.5:8080",
"HTTP://wyrd.example.com",
"Http://wyrd.example.com",
"http://localhost.example.com",
"http://127.0.0.1.example.com",
"wyrd.example.com",
"https://",
"http//wyrd.example.com",
"ftp://wyrd.example.com",
"file:///etc/passwd",
"https://alice:s3cret@wyrd.example.com",
"https://alice@wyrd.example.com",
"http://alice:s3cret@localhost:8080",
] {
let error = origin(url).expect_err(url).to_string();
assert!(
!error.contains("alice") && !error.contains("s3cret"),
"{url} refusal discloses userinfo: {error}"
);
}
}
#[test]
fn https_and_loopback_http_are_accepted() {
for (url, expected) in [
("https://wyrd.example.com", "https://wyrd.example.com"),
("HTTPS://Wyrd.Example.com/api", "https://wyrd.example.com"),
(
"https://wyrd.example.com:443/api/?q=1#f",
"https://wyrd.example.com",
),
("http://localhost:8080", "http://localhost:8080"),
("HTTP://LOCALHOST:8080/", "http://localhost:8080"),
("http://127.0.0.1:8080", "http://127.0.0.1:8080"),
("http://[::1]:8080", "http://[::1]:8080"),
] {
assert_eq!(origin(url).expect(url).as_str(), expected, "{url}");
}
}
#[test]
fn validate_refuses_each_invalid_field() {
for (config, field, reason) in [
(
TransportConfig::Grpc(GrpcConfig {
endpoint: String::new(),
..GrpcConfig::default()
}),
"grpc_config.endpoint",
"must not be empty",
),
(
TransportConfig::Grpc(GrpcConfig {
timeout_ms: 0,
..GrpcConfig::default()
}),
"grpc_config.timeout_ms",
"must be at least 1",
),
(
TransportConfig::Http(HttpConfig {
base_url: String::new(),
..HttpConfig::default()
}),
"http_config.base_url",
"must not be empty",
),
(
TransportConfig::Http(HttpConfig {
timeout_ms: 0,
..HttpConfig::default()
}),
"http_config.timeout_ms",
"must be at least 1",
),
] {
let error = config.validate().expect_err(field);
assert!(
matches!(&error, WyrdClientError::Config { field: f, reason: r } if f == field && r == reason),
"{field}: {error:?}"
);
}
}
#[test]
fn validate_accepts_defaults_and_boundary_values() {
for config in [
TransportConfig::Grpc(GrpcConfig::default()),
TransportConfig::Grpc(GrpcConfig {
connect_retries: 0,
..GrpcConfig::default()
}),
TransportConfig::Http(HttpConfig::default()),
TransportConfig::Http(HttpConfig {
base_url: "https://example.com".to_owned(),
..HttpConfig::default()
}),
TransportConfig::Mock(MockConfig::default()),
] {
assert!(config.validate().is_ok(), "{}", config.name());
}
}
#[test]
fn mock_config_rejects_unknown_fields() {
let parsed = serde_json::from_str::<MockConfig>(
r#"{"label":"buf","fail_on_drain":null,"extra":true}"#,
);
assert!(parsed.is_err());
}
}