use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::{BTreeMap, HashSet};
use super::error::OpenConnectorError;
const DEFAULT_REQUEST_TIMEOUT_SECONDS: u64 = 30;
const DEFAULT_SCAN_TIMEOUT_SECONDS: u64 = 300;
const DEFAULT_MAX_PAGES: u32 = 100;
const DEFAULT_MAX_ROWS: u64 = 100_000;
const DEFAULT_CACHE_MAX_BYTES: u64 = 256 * 1024 * 1024;
fn default_request_timeout_seconds() -> u64 {
DEFAULT_REQUEST_TIMEOUT_SECONDS
}
fn default_scan_timeout_seconds() -> u64 {
DEFAULT_SCAN_TIMEOUT_SECONDS
}
fn default_max_pages() -> u32 {
DEFAULT_MAX_PAGES
}
fn default_max_rows() -> u64 {
DEFAULT_MAX_ROWS
}
fn default_cache_max_bytes() -> u64 {
DEFAULT_CACHE_MAX_BYTES
}
fn default_max_response_bytes() -> u64 {
super::client::DEFAULT_MAX_RESPONSE_BYTES as u64
}
fn default_max_attempts() -> u32 {
super::client::MAX_ATTEMPTS
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct OpenConnectorConfig {
pub runtime_token_env: String,
#[serde(default = "default_request_timeout_seconds")]
pub request_timeout_seconds: u64,
#[serde(default = "default_scan_timeout_seconds")]
pub scan_timeout_seconds: u64,
#[serde(default = "default_max_pages")]
pub max_pages: u32,
#[serde(default = "default_max_rows")]
pub max_rows: u64,
#[serde(default = "default_cache_max_bytes")]
pub cache_max_bytes: u64,
#[serde(default = "default_max_response_bytes")]
pub max_response_bytes: u64,
#[serde(default = "default_max_attempts")]
pub max_attempts: u32,
#[serde(default)]
pub cache_ttl_seconds: u64,
#[serde(default)]
pub raw_action_allowlist: Vec<String>,
#[serde(default)]
pub bindings: Vec<OpenConnectorBinding>,
}
impl OpenConnectorConfig {
pub fn validate(&self) -> Result<(), OpenConnectorError> {
if self.runtime_token_env.trim().is_empty() {
return Err(OpenConnectorError::EmptyRuntimeTokenEnv);
}
if self.request_timeout_seconds == 0 {
return Err(OpenConnectorError::ZeroSafetyBound {
field: "request_timeout_seconds",
});
}
if self.scan_timeout_seconds == 0 {
return Err(OpenConnectorError::ZeroSafetyBound {
field: "scan_timeout_seconds",
});
}
if self.max_pages == 0 {
return Err(OpenConnectorError::ZeroSafetyBound { field: "max_pages" });
}
if self.max_rows == 0 {
return Err(OpenConnectorError::ZeroSafetyBound { field: "max_rows" });
}
if self.max_response_bytes == 0 {
return Err(OpenConnectorError::ZeroSafetyBound {
field: "max_response_bytes",
});
}
if self.max_attempts == 0 {
return Err(OpenConnectorError::ZeroSafetyBound {
field: "max_attempts",
});
}
if self
.raw_action_allowlist
.iter()
.any(|entry| entry.trim().is_empty())
{
return Err(OpenConnectorError::EmptyAllowlistEntry);
}
for entry in &self.raw_action_allowlist {
validate_action_id(entry)?;
}
let mut names = HashSet::with_capacity(self.bindings.len());
for binding in &self.bindings {
binding.validate()?;
if !names.insert(binding.name.as_str()) {
return Err(OpenConnectorError::DuplicateBindingName {
name: binding.name.clone(),
});
}
}
Ok(())
}
}
pub(crate) fn validate_action_id(action_id: &str) -> Result<(), OpenConnectorError> {
let reason = if action_id.contains('/') {
Some("must not contain '/'")
} else if action_id == "." || action_id == ".." {
Some("a bare dot segment is resolved by URL joining")
} else {
None
};
match reason {
Some(reason) => Err(OpenConnectorError::InvalidActionId {
action_id: action_id.to_string(),
reason: reason.to_string(),
}),
None => Ok(()),
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct OpenConnectorBinding {
pub name: String,
pub source_pack: String,
#[serde(default)]
pub source_pack_version: Option<u32>,
#[serde(default)]
pub connection_alias: Option<String>,
#[serde(default)]
pub resource: BTreeMap<String, Value>,
pub tables: Vec<String>,
}
impl OpenConnectorBinding {
fn validate(&self) -> Result<(), OpenConnectorError> {
if self.name.trim().is_empty() {
return Err(OpenConnectorError::EmptyBindingName);
}
if self.source_pack.trim().is_empty() {
return Err(OpenConnectorError::EmptySourcePack {
binding: self.name.clone(),
});
}
if self.tables.is_empty() {
return Err(OpenConnectorError::EmptyTableList {
binding: self.name.clone(),
});
}
for (key, value) in &self.resource {
if value.is_null() {
return Err(OpenConnectorError::NullResourceValue {
binding: self.name.clone(),
key: key.clone(),
});
}
}
let mut tables = HashSet::with_capacity(self.tables.len());
for table in &self.tables {
if table.trim().is_empty() {
return Err(OpenConnectorError::EmptyTableName {
binding: self.name.clone(),
});
}
if !tables.insert(table.as_str()) {
return Err(OpenConnectorError::DuplicateTableName {
binding: self.name.clone(),
table: table.clone(),
});
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(yaml: &str) -> OpenConnectorConfig {
serde_yaml::from_str(yaml).expect("parse config")
}
#[test]
fn parses_design_spec_example() {
let config = parse(
r#"
runtime_token_env: OPEN_CONNECTOR_TOKEN
request_timeout_seconds: 30
scan_timeout_seconds: 300
max_pages: 100
max_rows: 100000
cache_max_bytes: 268435456
raw_action_allowlist:
- github.list_repository_issues
- github.search_code
bindings:
- name: github_skardi
source_pack: github
connection_alias: work
resource:
owner: SkardiLabs
repo: skardi
tables:
- issues
- pull_requests
- commits
"#,
);
config.validate().expect("spec example is valid");
assert_eq!(config.raw_action_allowlist.len(), 2);
let binding = &config.bindings[0];
assert_eq!(binding.name, "github_skardi");
assert_eq!(binding.source_pack, "github");
assert_eq!(binding.connection_alias.as_deref(), Some("work"));
assert_eq!(binding.source_pack_version, None);
assert_eq!(
binding.resource.get("owner"),
Some(&Value::from("SkardiLabs"))
);
assert_eq!(binding.tables, vec!["issues", "pull_requests", "commits"]);
}
#[test]
fn defaults_applied_to_minimal_config() {
let config = parse("runtime_token_env: OPEN_CONNECTOR_TOKEN");
config.validate().expect("minimal config is valid");
assert_eq!(config.request_timeout_seconds, 30);
assert_eq!(config.scan_timeout_seconds, 300);
assert_eq!(config.max_pages, 100);
assert_eq!(config.max_rows, 100_000);
assert_eq!(config.cache_max_bytes, 256 * 1024 * 1024);
assert_eq!(config.max_response_bytes, 16 * 1024 * 1024);
assert_eq!(config.max_attempts, 3);
assert_eq!(config.cache_ttl_seconds, 0);
assert!(config.raw_action_allowlist.is_empty());
assert!(config.bindings.is_empty());
}
#[test]
fn resource_values_keep_their_yaml_types() {
let config = parse(
r#"
runtime_token_env: T
bindings:
- name: gh
source_pack: github
resource: { owner: acme, repo: widgets, issueNumber: 42 }
tables: [issue_comments]
"#,
);
config.validate().expect("typed resources are valid");
let resource = &config.bindings[0].resource;
assert_eq!(resource.get("owner"), Some(&Value::from("acme")));
assert_eq!(resource.get("issueNumber"), Some(&Value::from(42)));
}
#[test]
fn validate_rejects_null_resource_values() {
let config = parse(
r#"
runtime_token_env: T
bindings:
- name: gh
source_pack: github
resource: { owner: acme, repo: ~ }
tables: [issues]
"#,
);
assert!(matches!(
config.validate(),
Err(OpenConnectorError::NullResourceValue { ref binding, ref key })
if binding == "gh" && key == "repo"
));
}
#[test]
fn binding_optional_fields_default() {
let config = parse(
r#"
runtime_token_env: T
bindings:
- name: b
source_pack: github
tables: [issues]
"#,
);
let binding = &config.bindings[0];
assert_eq!(binding.connection_alias, None);
assert_eq!(binding.source_pack_version, None);
assert!(binding.resource.is_empty());
}
#[test]
fn source_pack_version_pin_parses() {
let config = parse(
r#"
runtime_token_env: T
bindings:
- name: b
source_pack: github
source_pack_version: 1
tables: [issues]
"#,
);
assert_eq!(config.bindings[0].source_pack_version, Some(1));
}
#[test]
fn validate_rejects_empty_runtime_token_env() {
let config = parse("runtime_token_env: ' '");
assert!(matches!(
config.validate(),
Err(OpenConnectorError::EmptyRuntimeTokenEnv)
));
}
#[test]
fn validate_rejects_zero_safety_bounds() {
let config = parse("runtime_token_env: T\nmax_pages: 0");
assert!(matches!(
config.validate(),
Err(OpenConnectorError::ZeroSafetyBound { field: "max_pages" })
));
let config = parse("runtime_token_env: T\nmax_rows: 0");
assert!(matches!(
config.validate(),
Err(OpenConnectorError::ZeroSafetyBound { field: "max_rows" })
));
}
#[test]
fn validate_rejects_zero_timeouts() {
let config = parse("runtime_token_env: T\nrequest_timeout_seconds: 0");
assert!(matches!(
config.validate(),
Err(OpenConnectorError::ZeroSafetyBound {
field: "request_timeout_seconds"
})
));
let config = parse("runtime_token_env: T\nscan_timeout_seconds: 0");
assert!(matches!(
config.validate(),
Err(OpenConnectorError::ZeroSafetyBound {
field: "scan_timeout_seconds"
})
));
}
#[test]
fn validate_rejects_zero_client_bounds() {
let config = parse("runtime_token_env: T\nmax_response_bytes: 0");
assert!(matches!(
config.validate(),
Err(OpenConnectorError::ZeroSafetyBound {
field: "max_response_bytes"
})
));
let config = parse("runtime_token_env: T\nmax_attempts: 0");
assert!(matches!(
config.validate(),
Err(OpenConnectorError::ZeroSafetyBound {
field: "max_attempts"
})
));
}
#[test]
fn validate_rejects_empty_allowlist_entry() {
let config = parse("runtime_token_env: T\nraw_action_allowlist: ['github.x', ' ']");
assert!(matches!(
config.validate(),
Err(OpenConnectorError::EmptyAllowlistEntry)
));
}
#[test]
fn validate_rejects_traversal_allowlist_entries() {
for bad in ["..", ".", "a/b"] {
let config = parse(&format!(
"runtime_token_env: T\nraw_action_allowlist: ['{bad}']"
));
assert!(
matches!(
config.validate(),
Err(OpenConnectorError::InvalidActionId { .. })
),
"'{bad}' should be rejected"
);
}
let config =
parse("runtime_token_env: T\nraw_action_allowlist: ['github.list_repository_issues']");
config.validate().expect("namespaced ID is valid");
}
#[test]
fn validate_rejects_duplicate_binding_names() {
let config = parse(
r#"
runtime_token_env: T
bindings:
- name: dup
source_pack: github
tables: [issues]
- name: dup
source_pack: jira
tables: [issues]
"#,
);
assert!(matches!(
config.validate(),
Err(OpenConnectorError::DuplicateBindingName { ref name }) if name == "dup"
));
}
#[test]
fn validate_rejects_empty_binding_name() {
let config = parse(
"runtime_token_env: T\nbindings:\n - name: ' '\n source_pack: github\n tables: [issues]",
);
assert!(matches!(
config.validate(),
Err(OpenConnectorError::EmptyBindingName)
));
}
#[test]
fn validate_rejects_empty_source_pack() {
let config = parse(
"runtime_token_env: T\nbindings:\n - name: b\n source_pack: ''\n tables: [issues]",
);
assert!(matches!(
config.validate(),
Err(OpenConnectorError::EmptySourcePack { ref binding }) if binding == "b"
));
}
#[test]
fn validate_rejects_empty_table_list() {
let config = parse(
"runtime_token_env: T\nbindings:\n - name: b\n source_pack: github\n tables: []",
);
assert!(matches!(
config.validate(),
Err(OpenConnectorError::EmptyTableList { ref binding }) if binding == "b"
));
}
#[test]
fn validate_rejects_empty_table_name() {
let config = parse(
"runtime_token_env: T\nbindings:\n - name: b\n source_pack: github\n tables: ['']",
);
assert!(matches!(
config.validate(),
Err(OpenConnectorError::EmptyTableName { ref binding }) if binding == "b"
));
}
#[test]
fn validate_rejects_duplicate_table_names() {
let config = parse(
"runtime_token_env: T\nbindings:\n - name: b\n source_pack: github\n tables: [issues, issues]",
);
assert!(matches!(
config.validate(),
Err(OpenConnectorError::DuplicateTableName { ref binding, ref table })
if binding == "b" && table == "issues"
));
}
#[test]
fn parse_rejects_unknown_top_level_field() {
let err = serde_yaml::from_str::<OpenConnectorConfig>(
"runtime_token_env: T\nraw_action_allowlists: [github.x]",
)
.unwrap_err();
assert!(
err.to_string().contains("raw_action_allowlists"),
"error should name the unknown field: {err}"
);
}
#[test]
fn parse_rejects_misspelled_source_pack_version() {
let err = serde_yaml::from_str::<OpenConnectorConfig>(
"runtime_token_env: T\nbindings:\n - name: b\n source_pack: github\n source_pack_versions: 1\n tables: [issues]",
)
.unwrap_err();
assert!(
err.to_string().contains("source_pack_versions"),
"error should name the unknown field: {err}"
);
}
#[test]
fn parse_rejects_unknown_binding_field() {
let err = serde_yaml::from_str::<OpenConnectorConfig>(
"runtime_token_env: T\nbindings:\n - name: b\n source_pack: github\n table: issues\n tables: [issues]",
)
.unwrap_err();
assert!(
err.to_string().contains("table"),
"error should name the unknown field: {err}"
);
}
#[test]
fn parse_rejects_relational_contract_overrides_on_bindings() {
for (field, value) in [
("action", "github.delete_repository"),
("row_path", "$.other"),
("pagination", "cursor"),
("columns", "[]"),
] {
let err = serde_yaml::from_str::<OpenConnectorConfig>(&format!(
"runtime_token_env: T\nbindings:\n - name: b\n source_pack: github\n {field}: {value}\n tables: [issues]",
))
.unwrap_err();
assert!(
err.to_string().contains(field),
"override '{field}' should be rejected by name: {err}"
);
}
}
#[test]
fn parse_rejects_misspelled_cache_field() {
let err = serde_yaml::from_str::<OpenConnectorConfig>(
"runtime_token_env: T\ncache_ttl_second: 60",
)
.unwrap_err();
assert!(
err.to_string().contains("cache_ttl_second"),
"error should name the unknown field: {err}"
);
}
}