use crate::config::HttpMethod;
use crate::error::{ApiForgeError, Result};
use crate::steps::{Step, StepContext, StepOutput};
use crate::utils::TemplateEngine;
use async_trait::async_trait;
use semver::Version;
use std::collections::HashMap;
use std::time::Duration;
use tokio::time::sleep;
pub struct HealthCheckStep {
version: Version,
}
impl HealthCheckStep {
pub fn new(version: Version) -> Self {
Self { version }
}
async fn check_health(&self, ctx: &StepContext) -> Result<bool> {
let health_config = ctx.config.health_check.as_ref().ok_or_else(|| {
ApiForgeError::Config("Health check configuration missing".to_string())
})?;
let mut template_ctx = HashMap::new();
template_ctx.insert("version".to_string(), self.version.to_string());
template_ctx.insert("project".to_string(), ctx.config.project.name.clone());
let mut engine = TemplateEngine::new();
let url = engine.render(&health_config.url, &template_ctx)?;
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
.map_err(|e| {
ApiForgeError::StepFailed(format!("Failed to create HTTP client: {}", e))
})?;
let request = match health_config.method {
HttpMethod::GET => client.get(&url),
HttpMethod::POST => client.post(&url),
HttpMethod::HEAD => client.head(&url),
HttpMethod::PUT => client.put(&url),
};
let response = request.send().await.map_err(|e| {
ApiForgeError::StepFailed(format!("Health check request failed: {}", e))
})?;
if response.status().as_u16() != health_config.expected_status {
tracing::debug!(
"Health check failed: expected status {}, got {}",
health_config.expected_status,
response.status()
);
return Ok(false);
}
if let (Some(field), Some(expected_value)) = (
&health_config.expected_body_field,
&health_config.expected_body_value,
) {
let body: serde_json::Value = response.json().await.map_err(|e| {
ApiForgeError::StepFailed(format!("Failed to parse health check response: {}", e))
})?;
let actual_value = body.pointer(field).and_then(|v| v.as_str()).unwrap_or("");
let resolved_expected = engine.render(expected_value, &template_ctx)?;
if actual_value != resolved_expected {
tracing::debug!(
"Health check failed: expected {} = '{}', got '{}'",
field,
resolved_expected,
actual_value
);
return Ok(false);
}
}
Ok(true)
}
}
#[async_trait]
impl Step for HealthCheckStep {
fn name(&self) -> &str {
"health-check"
}
fn description(&self) -> &str {
"Verify deployed service health"
}
async fn validate(&self, ctx: &StepContext) -> Result<()> {
if ctx.config.health_check.is_none() {
return Err(ApiForgeError::Config(
"Health check configuration missing".to_string(),
));
}
Ok(())
}
async fn execute(&self, ctx: &StepContext) -> Result<StepOutput> {
let health_config = ctx.config.health_check.as_ref().ok_or_else(|| {
ApiForgeError::Config("Health check configuration missing".to_string())
})?;
let timeout = Duration::from_secs(health_config.timeout);
let interval = Duration::from_secs(health_config.interval);
let start = std::time::Instant::now();
let mut attempts = 0;
loop {
attempts += 1;
tracing::debug!("Health check attempt {}", attempts);
ctx.report_progress(&format!(
"health-check: attempt {} ({}s elapsed of {}s budget)",
attempts,
start.elapsed().as_secs(),
health_config.timeout
));
match self.check_health(ctx).await {
Ok(true) => {
return Ok(StepOutput::ok(format!(
"Health check passed after {} attempts",
attempts
)));
}
Ok(false) => {
tracing::debug!("Health check failed, retrying...");
}
Err(e) => {
tracing::debug!("Health check error: {}", e);
}
}
if start.elapsed() >= timeout {
return Err(ApiForgeError::StepFailed(format!(
"Health check failed after {} attempts over {}s",
attempts,
start.elapsed().as_secs()
)));
}
sleep(interval).await;
}
}
async fn dry_run(&self, ctx: &StepContext) -> Result<StepOutput> {
let health_config = ctx.config.health_check.as_ref();
match health_config {
Some(config) => Ok(StepOutput::ok(format!(
"Would check health at {} via {:?} (expect status {})",
config.url, config.method, config.expected_status
))),
None => Ok(StepOutput::skipped("No health check configuration")),
}
}
}