use once_cell::sync::Lazy;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
static SSM_PATTERN: Lazy<Regex> = Lazy::new(|| Regex::new(r"\$\{ssm:([^}]+)\}").unwrap());
async fn resolve_ssm_refs(
aws: &crate::integrations::aws::AwsClient,
input: &str,
) -> crate::error::Result<String> {
let mut result = input.to_string();
for cap in SSM_PATTERN.captures_iter(input) {
let param_name = &cap[1];
let value = aws.get_ssm_parameter(param_name).await?;
result = result.replace(&format!("${{ssm:{}}}", param_name), &value);
}
Ok(result)
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Config {
pub project: ProjectConfig,
pub git: GitConfig,
pub docker: DockerConfig,
pub kubernetes: KubernetesConfig,
pub aws: AwsConfig,
#[serde(skip_serializing_if = "Option::is_none")]
pub cloudfront: Option<CloudFrontConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub github: Option<GitHubConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub notifications: Option<NotificationsConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub health_check: Option<HealthCheckConfig>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ProjectConfig {
pub name: String,
pub language: Language,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Language {
Rust,
Node,
Python,
Go,
Java,
}
impl Language {
pub fn version_file(&self) -> &str {
match self {
Language::Rust => "Cargo.toml",
Language::Node => "package.json",
Language::Python => "pyproject.toml",
Language::Go => "go.mod",
Language::Java => "pom.xml",
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GitConfig {
pub main_branch: String,
pub tag_format: String,
#[serde(default = "default_true")]
pub changelog: bool,
pub commit_message: String,
#[serde(default = "default_remote")]
pub remote: String,
#[serde(default = "default_true")]
pub require_clean: bool,
#[serde(default = "default_true")]
pub require_main_branch: bool,
#[serde(default = "default_git_fetch_timeout")]
pub fetch_timeout_secs: u64,
#[serde(default = "default_git_push_timeout")]
pub push_timeout_secs: u64,
#[serde(default = "default_git_operation_timeout")]
pub operation_timeout_secs: u64,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct DockerConfig {
pub registry: DockerRegistry,
pub repository: String,
#[serde(default = "default_dockerfile")]
pub dockerfile: String,
#[serde(default = "default_context")]
pub context: String,
pub tags: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub build_args: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum DockerRegistry {
AwsEcr,
DockerHub,
Ghcr,
Custom,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct KubernetesConfig {
pub context: String,
pub namespace: String,
pub deployment: String,
pub manifest_path: String,
pub image_field: String,
#[serde(default = "default_rollout_timeout")]
pub rollout_timeout: u64,
#[serde(default = "default_min_ready_percent")]
pub min_ready_percent: u8,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AwsConfig {
pub region: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub profile: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CloudFrontConfig {
pub distribution_id: String,
#[serde(default = "default_cloudfront_paths")]
pub paths: Vec<String>,
}
fn default_cloudfront_paths() -> Vec<String> {
vec!["/*".to_string()]
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GitHubConfig {
pub repository: String,
pub token: String,
#[serde(default = "default_true")]
pub create_release: bool,
#[serde(default = "default_false")]
pub prerelease: bool,
#[serde(default = "default_false")]
pub draft: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct NotificationsConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub slack: Option<SlackConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub webhook: Option<WebhookConfig>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SlackConfig {
pub webhook_url: String,
pub message: String,
#[serde(default = "default_notify_on")]
pub notify_on: NotifyOn,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct WebhookConfig {
pub url: String,
#[serde(default = "default_method")]
pub method: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub headers: Option<HashMap<String, String>>,
pub body: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum NotifyOn {
Success,
Failure,
Both,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)]
#[serde(rename_all = "UPPERCASE")]
pub enum HttpMethod {
#[default]
GET,
POST,
HEAD,
PUT,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct HealthCheckConfig {
pub url: String,
#[serde(default = "default_http_method")]
pub method: HttpMethod,
#[serde(default = "default_expected_status")]
pub expected_status: u16,
#[serde(skip_serializing_if = "Option::is_none")]
pub expected_body_field: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expected_body_value: Option<String>,
#[serde(default = "default_health_timeout")]
pub timeout: u64,
#[serde(default = "default_health_interval")]
pub interval: u64,
}
fn default_http_method() -> HttpMethod {
HttpMethod::GET
}
fn default_true() -> bool {
true
}
fn default_false() -> bool {
false
}
fn default_remote() -> String {
"origin".to_string()
}
fn default_dockerfile() -> String {
"Dockerfile".to_string()
}
fn default_context() -> String {
".".to_string()
}
fn default_rollout_timeout() -> u64 {
300
}
fn default_min_ready_percent() -> u8 {
100
}
fn default_notify_on() -> NotifyOn {
NotifyOn::Both
}
fn default_method() -> String {
"POST".to_string()
}
fn default_git_fetch_timeout() -> u64 {
60
}
fn default_git_push_timeout() -> u64 {
120
}
fn default_git_operation_timeout() -> u64 {
30
}
fn default_expected_status() -> u16 {
200
}
fn default_health_timeout() -> u64 {
60
}
fn default_health_interval() -> u64 {
5
}
impl Config {
pub fn from_file(path: &PathBuf) -> crate::error::Result<Self> {
let content = std::fs::read_to_string(path).map_err(|e| {
crate::error::ApiForgeError::Config(format!("Failed to read config file: {}", e))
})?;
let mut config: Config = toml::from_str(&content).map_err(|e| {
crate::error::ApiForgeError::Config(format!("Failed to parse config: {}", e))
})?;
config.resolve_env_vars()?;
config.validate()?;
Ok(config)
}
fn resolve_env_vars(&mut self) -> crate::error::Result<()> {
use crate::utils::env::resolve_env_vars;
if let Some(ref mut github) = self.github {
github.token = resolve_env_vars(&github.token)?;
github.repository = resolve_env_vars(&github.repository)?;
}
if let Some(ref mut notifications) = self.notifications {
if let Some(ref mut slack) = notifications.slack {
slack.webhook_url = resolve_env_vars(&slack.webhook_url)?;
}
if let Some(ref mut webhook) = notifications.webhook {
webhook.url = resolve_env_vars(&webhook.url)?;
webhook.body = resolve_env_vars(&webhook.body)?;
if let Some(ref mut headers) = webhook.headers {
for value in headers.values_mut() {
*value = resolve_env_vars(value)?;
}
}
}
}
if let Some(ref mut hc) = self.health_check {
hc.url = resolve_env_vars(&hc.url)?;
}
Ok(())
}
pub fn has_ssm_references(&self) -> bool {
let mut fields: Vec<&str> = Vec::new();
if let Some(ref github) = self.github {
fields.push(&github.token);
}
if let Some(ref notifications) = self.notifications {
if let Some(ref slack) = notifications.slack {
fields.push(&slack.webhook_url);
}
if let Some(ref webhook) = notifications.webhook {
fields.push(&webhook.url);
fields.push(&webhook.body);
if let Some(ref headers) = webhook.headers {
fields.extend(headers.values().map(|v| v.as_str()));
}
}
}
if let Some(ref hc) = self.health_check {
fields.push(&hc.url);
}
fields.iter().any(|f| SSM_PATTERN.is_match(f))
}
pub async fn resolve_ssm_parameters(&mut self) -> crate::error::Result<()> {
if !self.has_ssm_references() {
return Ok(());
}
if self.aws.region.is_empty() {
return Err(crate::error::ApiForgeError::Config(
"aws.region is required to resolve ${ssm:...} references".to_string(),
));
}
let aws = if let Some(ref profile) = self.aws.profile {
crate::integrations::aws::AwsClient::with_profile(&self.aws.region, profile).await?
} else {
crate::integrations::aws::AwsClient::new(&self.aws.region).await?
};
if let Some(ref mut github) = self.github {
github.token = resolve_ssm_refs(&aws, &github.token).await?;
}
if let Some(ref mut notifications) = self.notifications {
if let Some(ref mut slack) = notifications.slack {
slack.webhook_url = resolve_ssm_refs(&aws, &slack.webhook_url).await?;
}
if let Some(ref mut webhook) = notifications.webhook {
webhook.url = resolve_ssm_refs(&aws, &webhook.url).await?;
webhook.body = resolve_ssm_refs(&aws, &webhook.body).await?;
if let Some(ref mut headers) = webhook.headers {
for value in headers.values_mut() {
*value = resolve_ssm_refs(&aws, value).await?;
}
}
}
}
if let Some(ref mut hc) = self.health_check {
hc.url = resolve_ssm_refs(&aws, &hc.url).await?;
}
Ok(())
}
pub fn validate(&self) -> crate::error::Result<()> {
if !self.git.tag_format.contains("{version}") {
return Err(crate::error::ApiForgeError::Config(
"git.tag_format must contain {version} placeholder".to_string(),
));
}
if self.git.fetch_timeout_secs == 0 {
return Err(crate::error::ApiForgeError::Config(
"git.fetch_timeout_secs must be greater than 0".to_string(),
));
}
if self.git.push_timeout_secs == 0 {
return Err(crate::error::ApiForgeError::Config(
"git.push_timeout_secs must be greater than 0".to_string(),
));
}
if self.git.operation_timeout_secs == 0 {
return Err(crate::error::ApiForgeError::Config(
"git.operation_timeout_secs must be greater than 0".to_string(),
));
}
if self.docker.repository.is_empty() {
return Err(crate::error::ApiForgeError::Config(
"docker.repository cannot be empty".to_string(),
));
}
if self.docker.tags.is_empty() {
return Err(crate::error::ApiForgeError::Config(
"docker.tags must have at least one tag pattern".to_string(),
));
}
if self.kubernetes.namespace.is_empty() {
return Err(crate::error::ApiForgeError::Config(
"kubernetes.namespace cannot be empty".to_string(),
));
}
if self.kubernetes.deployment.is_empty() {
return Err(crate::error::ApiForgeError::Config(
"kubernetes.deployment cannot be empty".to_string(),
));
}
if self.kubernetes.context.is_empty() {
return Err(crate::error::ApiForgeError::Config(
"kubernetes.context cannot be empty".to_string(),
));
}
if self.kubernetes.min_ready_percent > 100 {
return Err(crate::error::ApiForgeError::Config(
"kubernetes.min_ready_percent must be between 0-100".to_string(),
));
}
if self.kubernetes.rollout_timeout == 0 {
return Err(crate::error::ApiForgeError::Config(
"kubernetes.rollout_timeout must be greater than 0".to_string(),
));
}
if matches!(self.docker.registry, DockerRegistry::AwsEcr) && self.aws.region.is_empty() {
return Err(crate::error::ApiForgeError::Config(
"aws.region is required when using ECR registry".to_string(),
));
}
if let Some(ref cf) = self.cloudfront {
if cf.distribution_id.is_empty() {
return Err(crate::error::ApiForgeError::Config(
"cloudfront.distribution_id cannot be empty".to_string(),
));
}
if cf.paths.is_empty() {
return Err(crate::error::ApiForgeError::Config(
"cloudfront.paths must have at least one path".to_string(),
));
}
for path in &cf.paths {
if !path.starts_with('/') {
return Err(crate::error::ApiForgeError::Config(format!(
"cloudfront path '{}' must start with '/'",
path
)));
}
}
if self.aws.region.is_empty() {
return Err(crate::error::ApiForgeError::Config(
"aws.region is required when using CloudFront invalidation".to_string(),
));
}
}
let tag_regex = regex::Regex::new(r"^[a-zA-Z0-9][a-zA-Z0-9_.-]*$").unwrap();
for tag in &self.docker.tags {
if tag.is_empty() {
return Err(crate::error::ApiForgeError::Config(
"docker.tags cannot contain empty strings".to_string(),
));
}
let resolved_tag = tag
.replace("{version}", "1.2.3")
.replace("{major}", "1")
.replace("{minor}", "2")
.replace("{patch}", "3")
.replace("{git_sha}", "abcdef0")
.replace("{git_sha_full}", "abcdef0123456789");
if resolved_tag.contains('{') || resolved_tag.contains('}') {
return Err(crate::error::ApiForgeError::Config(format!(
"docker tag '{}' contains unsupported placeholder(s). Supported placeholders: {{version}}, {{major}}, {{minor}}, {{patch}}, {{git_sha}}, {{git_sha_full}}",
tag
)));
}
if resolved_tag.len() > 128 {
return Err(crate::error::ApiForgeError::Config(format!(
"docker tag '{}' exceeds 128 character limit after template resolution",
tag
)));
}
if !tag_regex.is_match(&resolved_tag) {
return Err(crate::error::ApiForgeError::Config(format!(
"docker tag '{}' has invalid format after template resolution ('{}'). Tags must start with alphanumeric and contain only [a-zA-Z0-9_.-]",
tag, resolved_tag
)));
}
}
if !self.kubernetes.manifest_path.is_empty() {
}
if let Some(ref hc) = self.health_check {
if hc.url.is_empty() {
return Err(crate::error::ApiForgeError::Config(
"health_check.url cannot be empty".to_string(),
));
}
if hc.timeout == 0 {
return Err(crate::error::ApiForgeError::Config(
"health_check.timeout must be greater than 0".to_string(),
));
}
if hc.interval == 0 {
return Err(crate::error::ApiForgeError::Config(
"health_check.interval must be greater than 0".to_string(),
));
}
}
if let Some(ref notify) = self.notifications {
if let Some(ref slack) = notify.slack {
if slack.webhook_url.is_empty() {
return Err(crate::error::ApiForgeError::Config(
"notifications.slack.webhook_url cannot be empty".to_string(),
));
}
}
}
Ok(())
}
pub fn save(&self, path: &PathBuf) -> crate::error::Result<()> {
let content = toml::to_string_pretty(self).map_err(|e| {
crate::error::ApiForgeError::Config(format!("Failed to serialize config: {}", e))
})?;
std::fs::write(path, content).map_err(|e| {
crate::error::ApiForgeError::Config(format!("Failed to write config file: {}", e))
})?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn write_config(content: &str) -> tempfile::NamedTempFile {
let mut file = tempfile::NamedTempFile::new().unwrap();
file.write_all(content.as_bytes()).unwrap();
file
}
fn base_config(extra: &str) -> String {
format!(
r#"[project]
name = "test"
language = "rust"
[git]
main_branch = "main"
tag_format = "v{{version}}"
commit_message = "release"
[docker]
registry = "aws_ecr"
repository = "test"
tags = ["{{version}}"]
[kubernetes]
context = "prod"
namespace = "default"
deployment = "test"
manifest_path = "k8s/deployment.yaml"
image_field = "0"
[aws]
region = "us-east-1"
{extra}"#
)
}
#[test]
fn test_from_file_resolves_env_vars_in_secrets() {
std::env::set_var("APIFORGE_TEST_GH_TOKEN", "ghx_resolved_token");
let file = write_config(&base_config(
"\n[github]\nrepository = \"owner/repo\"\ntoken = \"${APIFORGE_TEST_GH_TOKEN}\"\n",
));
let config = Config::from_file(&file.path().to_path_buf()).unwrap();
assert_eq!(
config.github.unwrap().token,
"ghx_resolved_token",
"env var reference must be resolved at load time"
);
}
#[test]
fn test_from_file_errors_on_missing_env_var() {
std::env::remove_var("APIFORGE_TEST_MISSING_TOKEN");
let file = write_config(&base_config(
"\n[github]\nrepository = \"owner/repo\"\ntoken = \"${APIFORGE_TEST_MISSING_TOKEN}\"\n",
));
let err = Config::from_file(&file.path().to_path_buf()).unwrap_err();
assert!(
err.to_string().contains("APIFORGE_TEST_MISSING_TOKEN"),
"error should name the missing variable, got: {err}"
);
}
#[test]
fn test_from_file_resolves_slack_webhook_env() {
std::env::set_var(
"APIFORGE_TEST_SLACK_URL",
"https://hooks.slack.com/services/T0/B0/x",
);
let file = write_config(&base_config(
"\n[notifications.slack]\nwebhook_url = \"${APIFORGE_TEST_SLACK_URL}\"\nmessage = \"{{ version }}\"\n",
));
let config = Config::from_file(&file.path().to_path_buf()).unwrap();
assert_eq!(
config.notifications.unwrap().slack.unwrap().webhook_url,
"https://hooks.slack.com/services/T0/B0/x"
);
}
#[test]
fn test_validate_rejects_unknown_tag_placeholder() {
let file = write_config(&base_config("").replace("{version}\"]", "{unknown}\"]"));
assert!(Config::from_file(&file.path().to_path_buf()).is_err());
}
}
#[cfg(test)]
mod aws_feature_tests {
use super::*;
use std::io::Write;
fn write_config(content: &str) -> tempfile::NamedTempFile {
let mut file = tempfile::NamedTempFile::new().unwrap();
file.write_all(content.as_bytes()).unwrap();
file
}
fn base_config(extra: &str) -> String {
format!(
r#"[project]
name = "test"
language = "rust"
[git]
main_branch = "main"
tag_format = "v{{version}}"
commit_message = "release"
[docker]
registry = "ghcr"
repository = "test"
tags = ["{{version}}"]
[kubernetes]
context = "prod"
namespace = "default"
deployment = "test"
manifest_path = "k8s/deployment.yaml"
image_field = "0"
[aws]
region = "us-east-1"
{extra}"#
)
}
#[test]
fn test_cloudfront_config_parses_with_default_paths() {
let file = write_config(&base_config(
"\n[cloudfront]\ndistribution_id = \"E1ABCD23EFGH45\"\n",
));
let config = Config::from_file(&file.path().to_path_buf()).unwrap();
let cf = config.cloudfront.unwrap();
assert_eq!(cf.distribution_id, "E1ABCD23EFGH45");
assert_eq!(cf.paths, vec!["/*".to_string()]);
}
#[test]
fn test_cloudfront_rejects_empty_distribution() {
let file = write_config(&base_config("\n[cloudfront]\ndistribution_id = \"\"\n"));
assert!(Config::from_file(&file.path().to_path_buf()).is_err());
}
#[test]
fn test_cloudfront_rejects_relative_paths() {
let file = write_config(&base_config(
"\n[cloudfront]\ndistribution_id = \"E1\"\npaths = [\"api/*\"]\n",
));
assert!(Config::from_file(&file.path().to_path_buf()).is_err());
}
#[test]
fn test_ssm_reference_detection() {
let file = write_config(&base_config(
"\n[github]\nrepository = \"o/r\"\ntoken = \"${ssm:/apiforge/github-token}\"\n",
));
let config = Config::from_file(&file.path().to_path_buf()).unwrap();
assert!(config.has_ssm_references());
assert_eq!(
config.github.as_ref().unwrap().token,
"${ssm:/apiforge/github-token}"
);
}
#[test]
fn test_no_ssm_references_in_plain_config() {
let file = write_config(&base_config(""));
let config = Config::from_file(&file.path().to_path_buf()).unwrap();
assert!(!config.has_ssm_references());
}
}