mockforge_core/
docker_compose.rs

1/// Docker Compose generation for MockForge
2///
3/// Automatically generates docker-compose.yml files for local integration testing
4/// with networked mock services
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8/// Docker Compose service configuration
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct DockerComposeConfig {
11    pub version: String,
12    pub services: HashMap<String, ServiceConfig>,
13    #[serde(skip_serializing_if = "Option::is_none")]
14    pub networks: Option<HashMap<String, NetworkConfig>>,
15    #[serde(skip_serializing_if = "Option::is_none")]
16    pub volumes: Option<HashMap<String, VolumeConfig>>,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct ServiceConfig {
21    pub image: String,
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub build: Option<BuildConfig>,
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub ports: Option<Vec<String>>,
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub environment: Option<HashMap<String, String>>,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub volumes: Option<Vec<String>>,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub networks: Option<Vec<String>>,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub depends_on: Option<Vec<String>>,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub healthcheck: Option<HealthCheckConfig>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub command: Option<String>,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct BuildConfig {
42    pub context: String,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub dockerfile: Option<String>,
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub args: Option<HashMap<String, String>>,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct NetworkConfig {
51    pub driver: String,
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub driver_opts: Option<HashMap<String, String>>,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct VolumeConfig {
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub driver: Option<String>,
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub driver_opts: Option<HashMap<String, String>>,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct HealthCheckConfig {
66    pub test: Vec<String>,
67    pub interval: String,
68    pub timeout: String,
69    pub retries: u32,
70}
71
72/// Mock service specification for docker-compose generation
73#[derive(Debug, Clone)]
74pub struct MockServiceSpec {
75    pub name: String,
76    pub port: u16,
77    pub spec_path: Option<String>,
78    pub config_path: Option<String>,
79}
80
81/// Docker Compose generator
82pub struct DockerComposeGenerator {
83    network_name: String,
84    base_image: String,
85}
86
87impl DockerComposeGenerator {
88    pub fn new(network_name: String) -> Self {
89        Self {
90            network_name,
91            base_image: "mockforge:latest".to_string(),
92        }
93    }
94
95    pub fn with_image(mut self, image: String) -> Self {
96        self.base_image = image;
97        self
98    }
99
100    /// Generate docker-compose configuration for multiple mock services
101    pub fn generate(&self, services: Vec<MockServiceSpec>) -> DockerComposeConfig {
102        let mut compose_services = HashMap::new();
103        let mut networks = HashMap::new();
104
105        // Add network configuration
106        networks.insert(
107            self.network_name.clone(),
108            NetworkConfig {
109                driver: "bridge".to_string(),
110                driver_opts: None,
111            },
112        );
113
114        // Generate service configurations
115        for service_spec in services.iter() {
116            let service_name = format!("mockforge-{}", service_spec.name);
117
118            let mut environment = HashMap::new();
119            environment.insert("RUST_LOG".to_string(), "info".to_string());
120            environment.insert("MOCKFORGE_PORT".to_string(), service_spec.port.to_string());
121
122            if let Some(spec_path) = &service_spec.spec_path {
123                environment
124                    .insert("MOCKFORGE_OPENAPI_SPEC".to_string(), format!("/specs/{}", spec_path));
125            }
126
127            let volumes = vec![
128                // Mount specs directory if spec path is provided
129                "./specs:/specs:ro".to_string(),
130                // Mount config directory if config path is provided
131                "./configs:/configs:ro".to_string(),
132            ];
133
134            if let Some(config_path) = &service_spec.config_path {
135                environment
136                    .insert("MOCKFORGE_CONFIG".to_string(), format!("/configs/{}", config_path));
137            }
138
139            let service_config = ServiceConfig {
140                image: self.base_image.clone(),
141                build: None,
142                ports: Some(vec![format!("{}:{}", service_spec.port, service_spec.port)]),
143                environment: Some(environment),
144                volumes: Some(volumes),
145                networks: Some(vec![self.network_name.clone()]),
146                depends_on: None,
147                healthcheck: Some(HealthCheckConfig {
148                    test: vec![
149                        "CMD".to_string(),
150                        "curl".to_string(),
151                        "-f".to_string(),
152                        format!("http://localhost:{}/health", service_spec.port),
153                    ],
154                    interval: "10s".to_string(),
155                    timeout: "5s".to_string(),
156                    retries: 3,
157                }),
158                command: Some(format!("mockforge http --port {}", service_spec.port)),
159            };
160
161            compose_services.insert(service_name, service_config);
162        }
163
164        DockerComposeConfig {
165            version: "3.8".to_string(),
166            services: compose_services,
167            networks: Some(networks),
168            volumes: None,
169        }
170    }
171
172    /// Generate docker-compose with dependencies between services
173    pub fn generate_with_dependencies(
174        &self,
175        services: Vec<MockServiceSpec>,
176        dependencies: HashMap<String, Vec<String>>,
177    ) -> DockerComposeConfig {
178        let mut config = self.generate(services);
179
180        // Add dependencies
181        for (service, deps) in dependencies {
182            let service_key = format!("mockforge-{}", service);
183            if let Some(service_config) = config.services.get_mut(&service_key) {
184                let formatted_deps: Vec<String> =
185                    deps.iter().map(|d| format!("mockforge-{}", d)).collect();
186                service_config.depends_on = Some(formatted_deps);
187            }
188        }
189
190        config
191    }
192
193    /// Export configuration to YAML string
194    pub fn to_yaml(&self, config: &DockerComposeConfig) -> Result<String, serde_yaml::Error> {
195        serde_yaml::to_string(config)
196    }
197
198    /// Generate a complete microservices testing setup
199    pub fn generate_microservices_setup(
200        &self,
201        api_services: Vec<(String, u16)>,
202    ) -> DockerComposeConfig {
203        let mut services = HashMap::new();
204        let mut networks = HashMap::new();
205
206        // Add network
207        networks.insert(
208            self.network_name.clone(),
209            NetworkConfig {
210                driver: "bridge".to_string(),
211                driver_opts: None,
212            },
213        );
214
215        // Generate mock services
216        for (name, port) in api_services {
217            let service_name = format!("mock-{}", name);
218
219            let mut environment = HashMap::new();
220            environment.insert("RUST_LOG".to_string(), "info".to_string());
221            environment.insert("MOCKFORGE_PORT".to_string(), port.to_string());
222            environment
223                .insert("MOCKFORGE_OPENAPI_SPEC".to_string(), format!("/specs/{}.yaml", name));
224
225            services.insert(
226                service_name.clone(),
227                ServiceConfig {
228                    image: self.base_image.clone(),
229                    build: None,
230                    ports: Some(vec![format!("{}:{}", port, port)]),
231                    environment: Some(environment),
232                    volumes: Some(vec![
233                        "./specs:/specs:ro".to_string(),
234                        "./configs:/configs:ro".to_string(),
235                        "./logs:/logs".to_string(),
236                    ]),
237                    networks: Some(vec![self.network_name.clone()]),
238                    depends_on: None,
239                    healthcheck: Some(HealthCheckConfig {
240                        test: vec![
241                            "CMD".to_string(),
242                            "curl".to_string(),
243                            "-f".to_string(),
244                            format!("http://localhost:{}/health", port),
245                        ],
246                        interval: "10s".to_string(),
247                        timeout: "5s".to_string(),
248                        retries: 3,
249                    }),
250                    command: Some(format!(
251                        "mockforge http --port {} --spec /specs/{}.yaml",
252                        port, name
253                    )),
254                },
255            );
256        }
257
258        DockerComposeConfig {
259            version: "3.8".to_string(),
260            services,
261            networks: Some(networks),
262            volumes: None,
263        }
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[test]
272    fn test_docker_compose_generator_basic() {
273        let generator = DockerComposeGenerator::new("mockforge-network".to_string());
274
275        let services = vec![MockServiceSpec {
276            name: "api".to_string(),
277            port: 3000,
278            spec_path: Some("api.yaml".to_string()),
279            config_path: None,
280        }];
281
282        let config = generator.generate(services);
283
284        assert_eq!(config.version, "3.8");
285        assert_eq!(config.services.len(), 1);
286        assert!(config.services.contains_key("mockforge-api"));
287        assert!(config.networks.is_some());
288    }
289
290    #[test]
291    fn test_docker_compose_with_dependencies() {
292        let generator = DockerComposeGenerator::new("test-network".to_string());
293
294        let services = vec![
295            MockServiceSpec {
296                name: "auth".to_string(),
297                port: 3001,
298                spec_path: Some("auth.yaml".to_string()),
299                config_path: None,
300            },
301            MockServiceSpec {
302                name: "api".to_string(),
303                port: 3000,
304                spec_path: Some("api.yaml".to_string()),
305                config_path: None,
306            },
307        ];
308
309        let mut dependencies = HashMap::new();
310        dependencies.insert("api".to_string(), vec!["auth".to_string()]);
311
312        let config = generator.generate_with_dependencies(services, dependencies);
313
314        assert_eq!(config.services.len(), 2);
315
316        // Check that api depends on auth
317        let api_service = config.services.get("mockforge-api").unwrap();
318        assert!(api_service.depends_on.is_some());
319        assert_eq!(api_service.depends_on.as_ref().unwrap()[0], "mockforge-auth");
320    }
321
322    #[test]
323    fn test_microservices_setup_generation() {
324        let generator = DockerComposeGenerator::new("microservices".to_string());
325
326        let api_services = vec![
327            ("users".to_string(), 3001),
328            ("orders".to_string(), 3002),
329            ("payments".to_string(), 3003),
330        ];
331
332        let config = generator.generate_microservices_setup(api_services);
333
334        assert_eq!(config.services.len(), 3);
335        assert!(config.services.contains_key("mock-users"));
336        assert!(config.services.contains_key("mock-orders"));
337        assert!(config.services.contains_key("mock-payments"));
338    }
339
340    #[test]
341    fn test_yaml_export() {
342        let generator = DockerComposeGenerator::new("test-network".to_string());
343
344        let services = vec![MockServiceSpec {
345            name: "test".to_string(),
346            port: 3000,
347            spec_path: None,
348            config_path: None,
349        }];
350
351        let config = generator.generate(services);
352        let yaml = generator.to_yaml(&config);
353
354        assert!(yaml.is_ok());
355        let yaml_str = yaml.unwrap();
356        assert!(yaml_str.contains("version: '3.8'"));
357        assert!(yaml_str.contains("mockforge-test"));
358    }
359}