use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct HttpBootstrapConfig {
pub endpoints: Vec<EndpointConfig>,
#[serde(default = "default_timeout_seconds")]
pub timeout_seconds: u64,
#[serde(default = "default_max_retries")]
pub max_retries: u32,
#[serde(default = "default_retry_delay_ms")]
pub retry_delay_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_pages: Option<u64>,
}
fn default_timeout_seconds() -> u64 {
30
}
fn default_max_retries() -> u32 {
3
}
fn default_retry_delay_ms() -> u64 {
1000
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct EndpointConfig {
pub url: String,
#[serde(default = "default_method")]
pub method: HttpMethod,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub headers: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth: Option<AuthConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pagination: Option<PaginationConfig>,
pub response: ResponseConfig,
}
fn default_method() -> HttpMethod {
HttpMethod::Get
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "UPPERCASE")]
pub enum HttpMethod {
Get,
Post,
Put,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum AuthConfig {
Bearer {
token_env: String,
},
ApiKey {
location: ApiKeyLocation,
name: String,
value_env: String,
},
Basic {
username_env: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
password_env: Option<String>,
},
#[serde(rename = "oauth2-client-credentials")]
OAuth2ClientCredentials {
token_url: String,
client_id_env: String,
client_secret_env: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
scopes: Vec<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub enum ApiKeyLocation {
Header,
Query,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum PaginationConfig {
OffsetLimit {
#[serde(default = "default_offset_param")]
offset_param: String,
#[serde(default = "default_limit_param")]
limit_param: String,
page_size: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
total_path: Option<String>,
},
PageNumber {
#[serde(default = "default_page_param")]
page_param: String,
#[serde(default = "default_per_page_param")]
page_size_param: String,
page_size: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
total_pages_path: Option<String>,
},
Cursor {
cursor_param: String,
cursor_path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
has_more_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
page_size_param: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
page_size: Option<u64>,
},
LinkHeader {
#[serde(default, skip_serializing_if = "Option::is_none")]
page_size_param: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
page_size: Option<u64>,
},
NextUrl {
next_url_path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
base_url: Option<String>,
},
}
impl HttpBootstrapConfig {
pub fn validate(&self) -> anyhow::Result<()> {
if self.endpoints.is_empty() {
return Err(anyhow::anyhow!(
"Validation error: at least one endpoint must be configured"
));
}
if self.timeout_seconds == 0 {
return Err(anyhow::anyhow!(
"Validation error: timeoutSeconds must be greater than 0"
));
}
for (i, endpoint) in self.endpoints.iter().enumerate() {
endpoint.validate(i)?;
}
Ok(())
}
}
impl EndpointConfig {
fn validate(&self, index: usize) -> anyhow::Result<()> {
if self.url.is_empty() {
return Err(anyhow::anyhow!(
"Validation error: endpoint[{index}].url cannot be empty"
));
}
if !self.url.starts_with("http://") && !self.url.starts_with("https://") {
return Err(anyhow::anyhow!(
"Validation error: endpoint[{index}].url must start with http:// or https://"
));
}
if self.response.mappings.is_empty() {
return Err(anyhow::anyhow!(
"Validation error: endpoint[{index}].response.mappings must have at least one mapping"
));
}
if let Some(ref pagination) = self.pagination {
pagination.validate(index)?;
}
for (j, mapping) in self.response.mappings.iter().enumerate() {
mapping.validate(index, j)?;
}
Ok(())
}
}
impl PaginationConfig {
fn validate(&self, endpoint_index: usize) -> anyhow::Result<()> {
match self {
PaginationConfig::OffsetLimit { page_size, .. } => {
if *page_size == 0 {
return Err(anyhow::anyhow!(
"Validation error: endpoint[{endpoint_index}].pagination.page_size must be greater than 0"
));
}
}
PaginationConfig::PageNumber { page_size, .. } => {
if *page_size == 0 {
return Err(anyhow::anyhow!(
"Validation error: endpoint[{endpoint_index}].pagination.page_size must be greater than 0"
));
}
}
PaginationConfig::Cursor {
cursor_param,
cursor_path,
..
} => {
if cursor_param.is_empty() {
return Err(anyhow::anyhow!(
"Validation error: endpoint[{endpoint_index}].pagination.cursor_param cannot be empty"
));
}
if cursor_path.is_empty() {
return Err(anyhow::anyhow!(
"Validation error: endpoint[{endpoint_index}].pagination.cursor_path cannot be empty"
));
}
}
PaginationConfig::NextUrl { next_url_path, .. } => {
if next_url_path.is_empty() {
return Err(anyhow::anyhow!(
"Validation error: endpoint[{endpoint_index}].pagination.next_url_path cannot be empty"
));
}
}
PaginationConfig::LinkHeader { .. } => {}
}
Ok(())
}
}
impl ElementMappingConfig {
fn validate(&self, endpoint_index: usize, mapping_index: usize) -> anyhow::Result<()> {
if self.template.id.is_empty() {
return Err(anyhow::anyhow!(
"Validation error: endpoint[{endpoint_index}].mappings[{mapping_index}].template.id cannot be empty"
));
}
if self.template.labels.is_empty() {
return Err(anyhow::anyhow!(
"Validation error: endpoint[{endpoint_index}].mappings[{mapping_index}].template.labels must have at least one label"
));
}
if self.element_type == ElementType::Relation {
if self.template.from.is_none() {
return Err(anyhow::anyhow!(
"Validation error: endpoint[{endpoint_index}].mappings[{mapping_index}].template.from is required for relation mappings"
));
}
if self.template.to.is_none() {
return Err(anyhow::anyhow!(
"Validation error: endpoint[{endpoint_index}].mappings[{mapping_index}].template.to is required for relation mappings"
));
}
}
Ok(())
}
}
fn default_offset_param() -> String {
"offset".to_string()
}
fn default_limit_param() -> String {
"limit".to_string()
}
fn default_page_param() -> String {
"page".to_string()
}
fn default_per_page_param() -> String {
"per_page".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ResponseConfig {
#[serde(default = "default_items_path")]
pub items_path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content_type: Option<ContentTypeOverride>,
pub mappings: Vec<ElementMappingConfig>,
}
fn default_items_path() -> String {
"$".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum ContentTypeOverride {
Json,
Xml,
Yaml,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ElementMappingConfig {
pub element_type: ElementType,
pub template: ElementTemplate,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum ElementType {
Node,
Relation,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ElementTemplate {
pub id: String,
pub labels: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub properties: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub from: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub to: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_deserialize_full_config() {
let json = r#"{
"endpoints": [{
"url": "https://api.example.com/users",
"method": "GET",
"auth": {
"type": "bearer",
"token_env": "API_TOKEN"
},
"pagination": {
"type": "offset-limit",
"offset_param": "offset",
"limit_param": "limit",
"page_size": 100
},
"response": {
"itemsPath": "$.data",
"mappings": [{
"elementType": "node",
"template": {
"id": "{{item.id}}",
"labels": ["User"],
"properties": {
"name": "{{item.name}}"
}
}
}]
}
}],
"timeoutSeconds": 30,
"maxRetries": 3,
"retryDelayMs": 1000
}"#;
let config: HttpBootstrapConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.endpoints.len(), 1);
assert_eq!(config.timeout_seconds, 30);
assert_eq!(config.endpoints[0].url, "https://api.example.com/users");
}
#[test]
fn test_deserialize_cursor_pagination() {
let json = r#"{
"type": "cursor",
"cursor_param": "starting_after",
"cursor_path": "$.data[-1].id",
"has_more_path": "$.has_more",
"page_size_param": "limit",
"page_size": 100
}"#;
let config: PaginationConfig = serde_json::from_str(json).unwrap();
match config {
PaginationConfig::Cursor {
cursor_param,
cursor_path,
has_more_path,
..
} => {
assert_eq!(cursor_param, "starting_after");
assert_eq!(cursor_path, "$.data[-1].id");
assert_eq!(has_more_path, Some("$.has_more".to_string()));
}
_ => panic!("Expected Cursor pagination"),
}
}
#[test]
fn test_deserialize_oauth2_auth() {
let json = r#"{
"type": "oauth2-client-credentials",
"token_url": "https://auth.example.com/token",
"client_id_env": "CLIENT_ID",
"client_secret_env": "CLIENT_SECRET",
"scopes": ["read", "write"]
}"#;
let config: AuthConfig = serde_json::from_str(json).unwrap();
match config {
AuthConfig::OAuth2ClientCredentials {
token_url, scopes, ..
} => {
assert_eq!(token_url, "https://auth.example.com/token");
assert_eq!(scopes, vec!["read", "write"]);
}
_ => panic!("Expected OAuth2ClientCredentials"),
}
}
#[test]
fn test_deserialize_next_url_pagination() {
let json = r#"{
"type": "next-url",
"next_url_path": "$.nextRecordsUrl",
"base_url": "https://instance.salesforce.com"
}"#;
let config: PaginationConfig = serde_json::from_str(json).unwrap();
match config {
PaginationConfig::NextUrl {
next_url_path,
base_url,
} => {
assert_eq!(next_url_path, "$.nextRecordsUrl");
assert_eq!(
base_url,
Some("https://instance.salesforce.com".to_string())
);
}
_ => panic!("Expected NextUrl pagination"),
}
}
fn make_valid_config() -> HttpBootstrapConfig {
HttpBootstrapConfig {
endpoints: vec![EndpointConfig {
url: "https://api.example.com/users".to_string(),
method: HttpMethod::Get,
headers: HashMap::new(),
body: None,
auth: None,
pagination: None,
response: ResponseConfig {
items_path: "$".to_string(),
content_type: None,
mappings: vec![ElementMappingConfig {
element_type: ElementType::Node,
template: ElementTemplate {
id: "{{item.id}}".to_string(),
labels: vec!["User".to_string()],
properties: None,
from: None,
to: None,
},
}],
},
}],
timeout_seconds: 30,
max_retries: 3,
retry_delay_ms: 1000,
max_pages: None,
}
}
#[test]
fn test_validate_valid_config() {
let config = make_valid_config();
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_no_endpoints() {
let mut config = make_valid_config();
config.endpoints.clear();
let err = config.validate().unwrap_err();
assert!(err.to_string().contains("at least one endpoint"));
}
#[test]
fn test_validate_empty_url() {
let mut config = make_valid_config();
config.endpoints[0].url = String::new();
let err = config.validate().unwrap_err();
assert!(err.to_string().contains("url cannot be empty"));
}
#[test]
fn test_validate_no_mappings() {
let mut config = make_valid_config();
config.endpoints[0].response.mappings.clear();
let err = config.validate().unwrap_err();
assert!(err.to_string().contains("at least one mapping"));
}
#[test]
fn test_validate_zero_page_size() {
let mut config = make_valid_config();
config.endpoints[0].pagination = Some(PaginationConfig::OffsetLimit {
offset_param: "offset".to_string(),
limit_param: "limit".to_string(),
page_size: 0,
total_path: None,
});
let err = config.validate().unwrap_err();
assert!(err.to_string().contains("page_size must be greater than 0"));
}
#[test]
fn test_validate_zero_timeout() {
let mut config = make_valid_config();
config.timeout_seconds = 0;
let err = config.validate().unwrap_err();
assert!(err
.to_string()
.contains("timeoutSeconds must be greater than 0"));
}
#[test]
fn test_validate_relation_missing_from() {
let mut config = make_valid_config();
config.endpoints[0].response.mappings[0].element_type = ElementType::Relation;
let err = config.validate().unwrap_err();
assert!(err.to_string().contains("from is required"));
}
#[test]
fn test_validate_empty_labels() {
let mut config = make_valid_config();
config.endpoints[0].response.mappings[0]
.template
.labels
.clear();
let err = config.validate().unwrap_err();
assert!(err.to_string().contains("at least one label"));
}
}