mockforge-core 0.3.116

Shared logic for MockForge - routing, validation, latency, proxy
Documentation
/// Docker Compose generation for MockForge
///
/// Automatically generates docker-compose.yml files for local integration testing
/// with networked mock services
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

/// Docker Compose configuration structure
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DockerComposeConfig {
    /// Docker Compose file format version
    pub version: String,
    /// Service definitions keyed by service name
    pub services: HashMap<String, ServiceConfig>,
    /// Network definitions for service communication
    #[serde(skip_serializing_if = "Option::is_none")]
    pub networks: Option<HashMap<String, NetworkConfig>>,
    /// Volume definitions for persistent storage
    #[serde(skip_serializing_if = "Option::is_none")]
    pub volumes: Option<HashMap<String, VolumeConfig>>,
}

/// Docker service configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceConfig {
    /// Docker image to use for this service
    pub image: String,
    /// Build configuration if building from source
    #[serde(skip_serializing_if = "Option::is_none")]
    pub build: Option<BuildConfig>,
    /// Port mappings in format "host:container"
    #[serde(skip_serializing_if = "Option::is_none")]
    pub ports: Option<Vec<String>>,
    /// Environment variables for the service
    #[serde(skip_serializing_if = "Option::is_none")]
    pub environment: Option<HashMap<String, String>>,
    /// Volume mounts for persistent data
    #[serde(skip_serializing_if = "Option::is_none")]
    pub volumes: Option<Vec<String>>,
    /// Networks this service is attached to
    #[serde(skip_serializing_if = "Option::is_none")]
    pub networks: Option<Vec<String>>,
    /// Service dependencies (start order)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub depends_on: Option<Vec<String>>,
    /// Health check configuration
    #[serde(skip_serializing_if = "Option::is_none")]
    pub healthcheck: Option<HealthCheckConfig>,
    /// Override default command
    #[serde(skip_serializing_if = "Option::is_none")]
    pub command: Option<String>,
}

/// Build configuration for building Docker images
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BuildConfig {
    /// Build context directory
    pub context: String,
    /// Path to Dockerfile (relative to context)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub dockerfile: Option<String>,
    /// Build arguments (ARG values)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub args: Option<HashMap<String, String>>,
}

/// Docker network configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetworkConfig {
    /// Network driver (bridge, host, overlay, etc.)
    pub driver: String,
    /// Driver-specific options
    #[serde(skip_serializing_if = "Option::is_none")]
    pub driver_opts: Option<HashMap<String, String>>,
}

/// Docker volume configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VolumeConfig {
    /// Volume driver name (optional, defaults to local)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub driver: Option<String>,
    /// Driver-specific options
    #[serde(skip_serializing_if = "Option::is_none")]
    pub driver_opts: Option<HashMap<String, String>>,
}

/// Health check configuration for service monitoring
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HealthCheckConfig {
    /// Health check test command (CMD format)
    pub test: Vec<String>,
    /// Interval between health checks (e.g., "30s")
    pub interval: String,
    /// Timeout for health check (e.g., "10s")
    pub timeout: String,
    /// Number of consecutive failures before marking unhealthy
    pub retries: u32,
}

/// Mock service specification for docker-compose generation
#[derive(Debug, Clone)]
pub struct MockServiceSpec {
    /// Service name identifier
    pub name: String,
    /// Port number to expose
    pub port: u16,
    /// Path to OpenAPI specification file (relative)
    pub spec_path: Option<String>,
    /// Path to MockForge configuration file (relative)
    pub config_path: Option<String>,
}

/// Docker Compose file generator for mock services
pub struct DockerComposeGenerator {
    /// Name of the Docker network to create
    network_name: String,
    /// Base Docker image to use for mock services
    base_image: String,
}

impl DockerComposeGenerator {
    /// Create a new Docker Compose generator with a network name
    ///
    /// # Arguments
    /// * `network_name` - Name of the Docker network to create
    pub fn new(network_name: String) -> Self {
        Self {
            network_name,
            base_image: "mockforge:latest".to_string(),
        }
    }

    /// Set a custom base Docker image for generated services
    ///
    /// # Arguments
    /// * `image` - Docker image name (e.g., "mockforge:1.0.0")
    pub fn with_image(mut self, image: String) -> Self {
        self.base_image = image;
        self
    }

    /// Generate docker-compose configuration for multiple mock services
    pub fn generate(&self, services: Vec<MockServiceSpec>) -> DockerComposeConfig {
        let mut compose_services = HashMap::new();
        let mut networks = HashMap::new();

        // Add network configuration
        networks.insert(
            self.network_name.clone(),
            NetworkConfig {
                driver: "bridge".to_string(),
                driver_opts: None,
            },
        );

        // Generate service configurations
        for service_spec in services.iter() {
            let service_name = format!("mockforge-{}", service_spec.name);

            let mut environment = HashMap::new();
            environment.insert("RUST_LOG".to_string(), "info".to_string());
            environment.insert("MOCKFORGE_PORT".to_string(), service_spec.port.to_string());

            if let Some(spec_path) = &service_spec.spec_path {
                environment
                    .insert("MOCKFORGE_OPENAPI_SPEC".to_string(), format!("/specs/{}", spec_path));
            }

            let volumes = vec![
                // Mount specs directory if spec path is provided
                "./specs:/specs:ro".to_string(),
                // Mount config directory if config path is provided
                "./configs:/configs:ro".to_string(),
            ];

            if let Some(config_path) = &service_spec.config_path {
                environment
                    .insert("MOCKFORGE_CONFIG".to_string(), format!("/configs/{}", config_path));
            }

            let service_config = ServiceConfig {
                image: self.base_image.clone(),
                build: None,
                ports: Some(vec![format!("{}:{}", service_spec.port, service_spec.port)]),
                environment: Some(environment),
                volumes: Some(volumes),
                networks: Some(vec![self.network_name.clone()]),
                depends_on: None,
                healthcheck: Some(HealthCheckConfig {
                    test: vec![
                        "CMD".to_string(),
                        "curl".to_string(),
                        "-f".to_string(),
                        format!("http://localhost:{}/health", service_spec.port),
                    ],
                    interval: "10s".to_string(),
                    timeout: "5s".to_string(),
                    retries: 3,
                }),
                command: Some(format!("mockforge http --port {}", service_spec.port)),
            };

            compose_services.insert(service_name, service_config);
        }

        DockerComposeConfig {
            version: "3.8".to_string(),
            services: compose_services,
            networks: Some(networks),
            volumes: None,
        }
    }

    /// Generate docker-compose with dependencies between services
    pub fn generate_with_dependencies(
        &self,
        services: Vec<MockServiceSpec>,
        dependencies: HashMap<String, Vec<String>>,
    ) -> DockerComposeConfig {
        let mut config = self.generate(services);

        // Add dependencies
        for (service, deps) in dependencies {
            let service_key = format!("mockforge-{}", service);
            if let Some(service_config) = config.services.get_mut(&service_key) {
                let formatted_deps: Vec<String> =
                    deps.iter().map(|d| format!("mockforge-{}", d)).collect();
                service_config.depends_on = Some(formatted_deps);
            }
        }

        config
    }

    /// Export configuration to YAML string
    pub fn to_yaml(&self, config: &DockerComposeConfig) -> Result<String, serde_yaml::Error> {
        serde_yaml::to_string(config)
    }

    /// Generate a complete microservices testing setup
    pub fn generate_microservices_setup(
        &self,
        api_services: Vec<(String, u16)>,
    ) -> DockerComposeConfig {
        let mut services = HashMap::new();
        let mut networks = HashMap::new();

        // Add network
        networks.insert(
            self.network_name.clone(),
            NetworkConfig {
                driver: "bridge".to_string(),
                driver_opts: None,
            },
        );

        // Generate mock services
        for (name, port) in api_services {
            let service_name = format!("mock-{}", name);

            let mut environment = HashMap::new();
            environment.insert("RUST_LOG".to_string(), "info".to_string());
            environment.insert("MOCKFORGE_PORT".to_string(), port.to_string());
            environment
                .insert("MOCKFORGE_OPENAPI_SPEC".to_string(), format!("/specs/{}.yaml", name));

            services.insert(
                service_name.clone(),
                ServiceConfig {
                    image: self.base_image.clone(),
                    build: None,
                    ports: Some(vec![format!("{}:{}", port, port)]),
                    environment: Some(environment),
                    volumes: Some(vec![
                        "./specs:/specs:ro".to_string(),
                        "./configs:/configs:ro".to_string(),
                        "./logs:/logs".to_string(),
                    ]),
                    networks: Some(vec![self.network_name.clone()]),
                    depends_on: None,
                    healthcheck: Some(HealthCheckConfig {
                        test: vec![
                            "CMD".to_string(),
                            "curl".to_string(),
                            "-f".to_string(),
                            format!("http://localhost:{}/health", port),
                        ],
                        interval: "10s".to_string(),
                        timeout: "5s".to_string(),
                        retries: 3,
                    }),
                    command: Some(format!(
                        "mockforge http --port {} --spec /specs/{}.yaml",
                        port, name
                    )),
                },
            );
        }

        DockerComposeConfig {
            version: "3.8".to_string(),
            services,
            networks: Some(networks),
            volumes: None,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_docker_compose_generator_basic() {
        let generator = DockerComposeGenerator::new("mockforge-network".to_string());

        let services = vec![MockServiceSpec {
            name: "api".to_string(),
            port: 3000,
            spec_path: Some("api.yaml".to_string()),
            config_path: None,
        }];

        let config = generator.generate(services);

        assert_eq!(config.version, "3.8");
        assert_eq!(config.services.len(), 1);
        assert!(config.services.contains_key("mockforge-api"));
        assert!(config.networks.is_some());
    }

    #[test]
    fn test_docker_compose_with_dependencies() {
        let generator = DockerComposeGenerator::new("test-network".to_string());

        let services = vec![
            MockServiceSpec {
                name: "auth".to_string(),
                port: 3001,
                spec_path: Some("auth.yaml".to_string()),
                config_path: None,
            },
            MockServiceSpec {
                name: "api".to_string(),
                port: 3000,
                spec_path: Some("api.yaml".to_string()),
                config_path: None,
            },
        ];

        let mut dependencies = HashMap::new();
        dependencies.insert("api".to_string(), vec!["auth".to_string()]);

        let config = generator.generate_with_dependencies(services, dependencies);

        assert_eq!(config.services.len(), 2);

        // Check that api depends on auth
        let api_service = config.services.get("mockforge-api").unwrap();
        assert!(api_service.depends_on.is_some());
        assert_eq!(api_service.depends_on.as_ref().unwrap()[0], "mockforge-auth");
    }

    #[test]
    fn test_microservices_setup_generation() {
        let generator = DockerComposeGenerator::new("microservices".to_string());

        let api_services = vec![
            ("users".to_string(), 3001),
            ("orders".to_string(), 3002),
            ("payments".to_string(), 3003),
        ];

        let config = generator.generate_microservices_setup(api_services);

        assert_eq!(config.services.len(), 3);
        assert!(config.services.contains_key("mock-users"));
        assert!(config.services.contains_key("mock-orders"));
        assert!(config.services.contains_key("mock-payments"));
    }

    #[test]
    fn test_yaml_export() {
        let generator = DockerComposeGenerator::new("test-network".to_string());

        let services = vec![MockServiceSpec {
            name: "test".to_string(),
            port: 3000,
            spec_path: None,
            config_path: None,
        }];

        let config = generator.generate(services);
        let yaml = generator.to_yaml(&config);

        assert!(yaml.is_ok());
        let yaml_str = yaml.unwrap();
        assert!(yaml_str.contains("version: '3.8'"));
        assert!(yaml_str.contains("mockforge-test"));
    }
}