use crate::connector::{ConnectorConfig, ConnectorType};
use crate::errors::OrionError;
use crate::storage::repositories::connectors::{CreateConnectorRequest, UpdateConnectorRequest};
use super::common::{validate_id, validate_name};
pub fn validate_connector_config(
connector_type: ConnectorType,
config: &serde_json::Value,
) -> Result<(), OrionError> {
let type_str = connector_type.as_str();
let mut config_with_type = config.clone();
if let Some(obj) = config_with_type.as_object_mut() {
obj.insert(
"type".to_string(),
serde_json::Value::String(type_str.to_string()),
);
} else {
return Err(OrionError::validation(
"Connector config must be a JSON object".to_string(),
));
}
let parsed: ConnectorConfig = serde_json::from_value(config_with_type).map_err(|e| {
OrionError::validation(format!(
"Invalid connector config for type '{type_str}': {e}"
))
})?;
validate_operation_gate_keys(connector_type, config)?;
validate_retry_keys(connector_type, config)?;
super::endpoints::validate_endpoint_schemes(&parsed)?;
if let ConnectorConfig::Http(http_config) = &parsed
&& !http_config.url.is_empty()
{
let parsed_url = url::Url::parse(&http_config.url).map_err(|e| {
OrionError::validation(format!("Invalid connector URL '{}': {e}", http_config.url))
})?;
let scheme = parsed_url.scheme();
if scheme != "http" && scheme != "https" {
return Err(OrionError::validation(format!(
"Connector URL must use http or https scheme, got '{scheme}'"
)));
}
}
if let ConnectorConfig::Http(c) = &parsed
&& c.retry.max_retries > 16
{
return Err(OrionError::validation(format!(
"retry.max_retries must be <= 16 (backoff doubles per attempt), got {}",
c.retry.max_retries
)));
}
if let ConnectorConfig::Http(c) = &parsed {
for method in &c.operations.methods {
if !crate::connector::VALID_HTTP_METHODS
.iter()
.any(|valid| valid.eq_ignore_ascii_case(method))
{
return Err(OrionError::validation(format!(
"Invalid HTTP method '{method}' in operations.methods. Must be \
one of: {}",
crate::connector::VALID_HTTP_METHODS.join(", ")
)));
}
}
}
if let ConnectorConfig::Cache(cache_config) = &parsed {
if !crate::connector::VALID_CACHE_BACKENDS.contains(&cache_config.backend.as_str()) {
return Err(OrionError::validation(format!(
"Invalid cache backend '{}'. Must be one of: {}",
cache_config.backend,
crate::connector::VALID_CACHE_BACKENDS.join(", ")
)));
}
if cache_config.backend == "redis"
&& cache_config
.url
.as_ref()
.is_none_or(|u| u.trim().is_empty())
{
return Err(OrionError::validation(
"Cache connector with backend='redis' requires a non-empty 'url'".to_string(),
));
}
}
Ok(())
}
const ALLOWED_RETRY_KEYS: &[&str] = &["max_retries", "retry_delay_ms"];
fn validate_retry_keys(
connector_type: ConnectorType,
config: &serde_json::Value,
) -> Result<(), OrionError> {
let Some(retry) = config.get("retry") else {
return Ok(());
};
if !matches!(connector_type, ConnectorType::Http) {
return Err(OrionError::validation(format!(
"Connector type '{connector_type}' has no retry policy — only `http` \
connectors read 'retry', so this block would be silently ignored. \
Remove it, or configure retries on the calling workflow instead."
)));
}
let Some(object) = retry.as_object() else {
return Err(OrionError::validation(format!(
"Connector 'retry' must be a JSON object with keys: {}",
ALLOWED_RETRY_KEYS.join(", ")
)));
};
let unknown: Vec<&str> = object
.keys()
.map(String::as_str)
.filter(|key| !ALLOWED_RETRY_KEYS.contains(key))
.collect();
if unknown.is_empty() {
return Ok(());
}
Err(OrionError::validation(format!(
"Connector 'retry' has unrecognised key(s) {unknown:?} — a key the retry \
policy does not read silently leaves the default policy in place. \
Valid keys: {}",
ALLOWED_RETRY_KEYS.join(", ")
)))
}
fn validate_operation_gate_keys(
connector_type: ConnectorType,
config: &serde_json::Value,
) -> Result<(), OrionError> {
let Some(operations) = config.get("operations") else {
return Ok(());
};
let Some(object) = operations.as_object() else {
return Err(OrionError::validation(format!(
"Connector 'operations' must be a JSON object of operation gates, one of: {}",
connector_type.operation_gate_keys().join(", ")
)));
};
let allowed = connector_type.operation_gate_keys();
let unknown: Vec<&str> = object
.keys()
.map(String::as_str)
.filter(|key| !allowed.contains(key))
.collect();
if unknown.is_empty() {
return Ok(());
}
Err(OrionError::validation(format!(
"Unknown operation gate(s) {:?} for connector type '{connector_type}'. A gate \
that is not read leaves the operation allowed, so this would be a silent \
no-op — must be one of: {}",
unknown,
allowed.join(", ")
)))
}
pub fn validate_create_connector(req: &CreateConnectorRequest) -> Result<(), OrionError> {
if let Some(ref id) = req.id {
validate_id(id, "connector.id")?;
}
validate_name(&req.name, "connector.name")?;
validate_connector_config(req.connector_type, &req.config)?;
reject_masked_values(&req.config)?;
Ok(())
}
pub fn reject_masked_values(config: &serde_json::Value) -> Result<(), OrionError> {
if let Some(path) = crate::connector::find_masked_value(config) {
return Err(OrionError::validation(format!(
"Connector config field '{path}' is the masked placeholder that \
GET /api/v1/admin/connectors returns, not a real value. Send the \
actual secret, or omit the field to keep the stored one."
)));
}
Ok(())
}
pub fn validate_update_connector(req: &UpdateConnectorRequest) -> Result<(), OrionError> {
if let Some(ref name) = req.name {
validate_name(name, "connector.name")?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_connector_config_http_valid() {
let config = json!({
"url": "https://example.com/api",
"method": "POST"
});
assert!(validate_connector_config(ConnectorType::Http, &config).is_ok());
}
#[test]
fn a_misspelled_retry_key_is_refused_and_named() {
let config = json!({
"url": "https://example.com/api",
"retry": {"max_attempts": 5}
});
let err = validate_connector_config(ConnectorType::Http, &config)
.expect_err("unknown retry key must not be silently ignored");
let message = err.client_message();
assert!(message.contains("max_attempts"), "{message}");
assert!(message.contains("max_retries"), "{message}");
let config = json!({
"url": "https://example.com/api",
"retry": {"max_retries": 5, "retry_delay_ms": 100}
});
assert!(validate_connector_config(ConnectorType::Http, &config).is_ok());
}
#[test]
fn test_connector_config_http_invalid_scheme() {
let config = json!({
"url": "ftp://example.com/api",
"method": "POST"
});
assert!(validate_connector_config(ConnectorType::Http, &config).is_err());
}
#[test]
fn test_connector_config_invalid_structure() {
let config = json!("not an object");
assert!(validate_connector_config(ConnectorType::Http, &config).is_err());
}
#[test]
fn test_connector_config_http_empty_url() {
let config = json!({"url": ""});
assert!(validate_connector_config(ConnectorType::Http, &config).is_ok());
}
#[test]
fn test_connector_config_http_invalid_url() {
let config = json!({"url": "not a valid url"});
assert!(validate_connector_config(ConnectorType::Http, &config).is_err());
}
#[test]
fn test_validate_create_connector_with_id() {
let req = CreateConnectorRequest {
tags: vec![],
id: Some("my-conn-1".to_string()),
name: "My Connector".to_string(),
connector_type: ConnectorType::Http,
config: json!({"url": "https://example.com"}),
enabled: None,
};
assert!(validate_create_connector(&req).is_ok());
}
#[test]
fn test_validate_create_connector_invalid_id() {
let req = CreateConnectorRequest {
tags: vec![],
id: Some("bad id!".to_string()),
name: "My Connector".to_string(),
connector_type: ConnectorType::Http,
config: json!({"url": "https://example.com"}),
enabled: None,
};
assert!(validate_create_connector(&req).is_err());
}
#[test]
fn test_validate_create_connector_empty_name() {
let req = CreateConnectorRequest {
tags: vec![],
id: None,
name: "".to_string(),
connector_type: ConnectorType::Http,
config: json!({"url": "https://example.com"}),
enabled: None,
};
assert!(validate_create_connector(&req).is_err());
}
#[test]
fn test_validate_update_connector_with_name() {
let req = UpdateConnectorRequest {
tags: None,
name: Some("Updated Name".to_string()),
connector_type: None,
config: None,
enabled: None,
};
assert!(validate_update_connector(&req).is_ok());
}
#[test]
fn test_validate_update_connector_invalid_name() {
let req = UpdateConnectorRequest {
tags: None,
name: Some(" ".to_string()),
connector_type: None,
config: None,
enabled: None,
};
assert!(validate_update_connector(&req).is_err());
}
#[test]
fn test_validate_update_connector_type_only() {
let req = UpdateConnectorRequest {
tags: None,
name: None,
connector_type: Some(ConnectorType::Http),
config: None,
enabled: None,
};
assert!(validate_update_connector(&req).is_ok());
}
#[test]
fn test_validate_update_connector_type_and_config() {
let req = UpdateConnectorRequest {
tags: None,
name: None,
connector_type: Some(ConnectorType::Http),
config: Some(json!({"url": "https://example.com"})),
enabled: None,
};
assert!(validate_update_connector(&req).is_ok());
}
#[test]
fn test_validate_update_connector_defers_config_to_the_handler() {
let req = UpdateConnectorRequest {
tags: None,
name: None,
connector_type: Some(ConnectorType::Http),
config: Some(json!("not an object")),
enabled: None,
};
assert!(validate_update_connector(&req).is_ok());
assert!(
validate_connector_config(ConnectorType::Http, &json!("not an object")).is_err(),
"the handler's post-unmask validation must still reject this"
);
}
#[test]
fn test_validate_update_connector_no_fields() {
let req = UpdateConnectorRequest {
tags: None,
name: None,
connector_type: None,
config: None,
enabled: None,
};
assert!(validate_update_connector(&req).is_ok());
}
#[test]
fn test_connector_config_db_missing_connection_string() {
assert!(validate_connector_config(ConnectorType::Db, &json!({})).is_err());
}
#[test]
fn test_connector_config_db_valid() {
let config = json!({"connection_string": "sqlite::memory:"});
assert!(validate_connector_config(ConnectorType::Db, &config).is_ok());
}
#[test]
fn http_method_allow_list_rejects_a_method_http_call_cannot_issue() {
let config = json!({
"url": "https://example.com",
"operations": { "methods": ["GET", "GTE"] }
});
let err = validate_connector_config(ConnectorType::Http, &config)
.expect_err("a misspelled method must not be persisted");
assert!(err.to_string().contains("GTE"), "{err}");
}
#[test]
fn http_method_allow_list_accepts_the_supported_methods_in_any_case() {
let config = json!({
"url": "https://example.com",
"operations": { "methods": ["get", "POST", "Put", "PATCH", "delete"] }
});
assert!(validate_connector_config(ConnectorType::Http, &config).is_ok());
}
#[test]
fn test_connector_config_cache_invalid_backend() {
let config = json!({"backend": "memcached"});
assert!(validate_connector_config(ConnectorType::Cache, &config).is_err());
}
#[test]
fn test_connector_config_cache_redis_requires_url() {
let config = json!({"backend": "redis"});
assert!(validate_connector_config(ConnectorType::Cache, &config).is_err());
let config = json!({"backend": "redis", "url": "redis://localhost:6379"});
assert!(validate_connector_config(ConnectorType::Cache, &config).is_ok());
}
#[test]
fn retry_allowed_keys_match_retry_config() {
let value =
serde_json::to_value(crate::connector::RetryConfig::default()).expect("serialize");
let mut from_struct: Vec<&str> = value
.as_object()
.expect("RetryConfig serializes as an object")
.keys()
.map(String::as_str)
.collect();
from_struct.sort_unstable();
let mut allowed = ALLOWED_RETRY_KEYS.to_vec();
allowed.sort_unstable();
assert_eq!(
allowed, from_struct,
"ALLOWED_RETRY_KEYS has drifted from RetryConfig's fields"
);
}
}