1use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::path::PathBuf;
8use thiserror::Error;
9
10#[derive(Debug, Error)]
12pub enum ContractError {
13 #[error("Verification failed: {0}")]
14 VerificationFailed(String),
15
16 #[error("Serialization error: {0}")]
17 SerializationError(String),
18
19 #[error("IO error: {0}")]
20 IoError(String),
21
22 #[error("Invalid contract: {0}")]
23 InvalidContract(String),
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
28#[serde(rename_all = "UPPERCASE")]
29pub enum ContractMethod {
30 Get,
31 Post,
32 Put,
33 Delete,
34 Patch,
35 Head,
36 Options,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct ContractRequest {
42 pub method: ContractMethod,
44
45 pub path: String,
47
48 #[serde(skip_serializing_if = "Option::is_none")]
50 pub query: Option<HashMap<String, String>>,
51
52 #[serde(skip_serializing_if = "Option::is_none")]
54 pub headers: Option<HashMap<String, String>>,
55
56 #[serde(skip_serializing_if = "Option::is_none")]
58 pub body: Option<serde_json::Value>,
59}
60
61impl ContractRequest {
62 pub fn new(method: ContractMethod, path: impl Into<String>) -> Self {
64 Self {
65 method,
66 path: path.into(),
67 query: None,
68 headers: None,
69 body: None,
70 }
71 }
72
73 pub fn with_query(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
75 self.query
76 .get_or_insert_with(HashMap::new)
77 .insert(key.into(), value.into());
78 self
79 }
80
81 pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
83 self.headers
84 .get_or_insert_with(HashMap::new)
85 .insert(key.into(), value.into());
86 self
87 }
88
89 pub fn with_body(mut self, body: serde_json::Value) -> Self {
91 self.body = Some(body);
92 self
93 }
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct ContractResponse {
99 pub status: u16,
101
102 #[serde(skip_serializing_if = "Option::is_none")]
104 pub headers: Option<HashMap<String, String>>,
105
106 #[serde(skip_serializing_if = "Option::is_none")]
108 pub body: Option<serde_json::Value>,
109}
110
111impl ContractResponse {
112 pub fn new(status: u16) -> Self {
114 Self {
115 status,
116 headers: None,
117 body: None,
118 }
119 }
120
121 pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
123 self.headers
124 .get_or_insert_with(HashMap::new)
125 .insert(key.into(), value.into());
126 self
127 }
128
129 pub fn with_body(mut self, body: serde_json::Value) -> Self {
131 self.body = Some(body);
132 self
133 }
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct ContractInteraction {
139 pub description: String,
141
142 #[serde(skip_serializing_if = "Option::is_none")]
144 pub provider_state: Option<String>,
145
146 pub request: ContractRequest,
148
149 pub response: ContractResponse,
151}
152
153impl ContractInteraction {
154 pub fn new(
156 description: impl Into<String>,
157 request: ContractRequest,
158 response: ContractResponse,
159 ) -> Self {
160 Self {
161 description: description.into(),
162 provider_state: None,
163 request,
164 response,
165 }
166 }
167
168 pub fn with_provider_state(mut self, state: impl Into<String>) -> Self {
170 self.provider_state = Some(state.into());
171 self
172 }
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct Contract {
178 pub consumer: Participant,
180
181 pub provider: Participant,
183
184 pub interactions: Vec<ContractInteraction>,
186
187 pub metadata: ContractMetadata,
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct Participant {
194 pub name: String,
195}
196
197impl Participant {
198 pub fn new(name: impl Into<String>) -> Self {
199 Self { name: name.into() }
200 }
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize)]
205#[serde(rename_all = "camelCase")]
206#[derive(Default)]
207pub struct ContractMetadata {
208 pub pact_specification: PactSpecification,
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct PactSpecification {
214 pub version: String,
215}
216
217impl Default for PactSpecification {
218 fn default() -> Self {
219 Self {
220 version: "3.0.0".to_string(),
221 }
222 }
223}
224
225pub struct ContractBuilder {
227 consumer: String,
228 provider: String,
229 interactions: Vec<ContractInteraction>,
230}
231
232impl ContractBuilder {
233 pub fn new(consumer: impl Into<String>, provider: impl Into<String>) -> Self {
243 Self {
244 consumer: consumer.into(),
245 provider: provider.into(),
246 interactions: Vec::new(),
247 }
248 }
249
250 pub fn add_interaction(&mut self, interaction: ContractInteraction) -> &mut Self {
269 self.interactions.push(interaction);
270 self
271 }
272
273 pub fn build(self) -> Contract {
275 Contract {
276 consumer: Participant::new(self.consumer),
277 provider: Participant::new(self.provider),
278 interactions: self.interactions,
279 metadata: ContractMetadata::default(),
280 }
281 }
282}
283
284pub struct ContractManager {
286 contracts_dir: PathBuf,
287}
288
289impl ContractManager {
290 pub fn new(contracts_dir: PathBuf) -> Self {
301 Self { contracts_dir }
302 }
303
304 pub fn save(&self, contract: &Contract) -> Result<(), ContractError> {
306 std::fs::create_dir_all(&self.contracts_dir)
308 .map_err(|e| ContractError::IoError(e.to_string()))?;
309
310 let filename = format!(
312 "{}-{}.json",
313 contract.consumer.name.to_lowercase(),
314 contract.provider.name.to_lowercase()
315 );
316 let path = self.contracts_dir.join(filename);
317
318 let json = serde_json::to_string_pretty(contract)
320 .map_err(|e| ContractError::SerializationError(e.to_string()))?;
321
322 std::fs::write(path, json).map_err(|e| ContractError::IoError(e.to_string()))?;
324
325 Ok(())
326 }
327
328 pub fn load(&self, consumer: &str, provider: &str) -> Result<Contract, ContractError> {
330 let filename = format!(
331 "{}-{}.json",
332 consumer.to_lowercase(),
333 provider.to_lowercase()
334 );
335 let path = self.contracts_dir.join(filename);
336
337 let content =
338 std::fs::read_to_string(path).map_err(|e| ContractError::IoError(e.to_string()))?;
339
340 let contract: Contract = serde_json::from_str(&content)
341 .map_err(|e| ContractError::SerializationError(e.to_string()))?;
342
343 Ok(contract)
344 }
345
346 pub fn list(&self) -> Result<Vec<(String, String)>, ContractError> {
358 let entries = std::fs::read_dir(&self.contracts_dir)
359 .map_err(|e| ContractError::IoError(e.to_string()))?;
360
361 let mut contracts = Vec::new();
362
363 for entry in entries {
364 let entry = entry.map_err(|e| ContractError::IoError(e.to_string()))?;
365 let path = entry.path();
366
367 if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
368 continue;
369 }
370
371 let content = std::fs::read_to_string(&path)
372 .map_err(|e| ContractError::IoError(e.to_string()))?;
373 let contract: Contract = serde_json::from_str(&content)
374 .map_err(|e| ContractError::SerializationError(e.to_string()))?;
375
376 contracts.push((contract.consumer.name, contract.provider.name));
377 }
378
379 Ok(contracts)
380 }
381}
382
383pub struct ContractVerifier;
385
386impl ContractVerifier {
387 pub fn verify_interaction(
398 interaction: &ContractInteraction,
399 actual_response: &ContractResponse,
400 ) -> Result<(), ContractError> {
401 if interaction.response.status != actual_response.status {
403 return Err(ContractError::VerificationFailed(format!(
404 "Status mismatch: expected {}, got {}",
405 interaction.response.status, actual_response.status
406 )));
407 }
408
409 if let Some(expected_headers) = &interaction.response.headers {
411 let actual_headers = actual_response.headers.as_ref().ok_or_else(|| {
412 ContractError::VerificationFailed("Missing response headers".to_string())
413 })?;
414
415 for (key, expected_value) in expected_headers {
416 let actual_value = actual_headers.get(key).ok_or_else(|| {
417 ContractError::VerificationFailed(format!("Missing header: {}", key))
418 })?;
419
420 if actual_value != expected_value {
421 return Err(ContractError::VerificationFailed(format!(
422 "Header mismatch for '{}': expected '{}', got '{}'",
423 key, expected_value, actual_value
424 )));
425 }
426 }
427 }
428
429 if let Some(expected_body) = &interaction.response.body {
431 let actual_body = actual_response.body.as_ref().ok_or_else(|| {
432 ContractError::VerificationFailed("Missing response body".to_string())
433 })?;
434
435 if expected_body != actual_body {
436 return Err(ContractError::VerificationFailed(format!(
437 "Body mismatch: expected {:?}, got {:?}",
438 expected_body, actual_body
439 )));
440 }
441 }
442
443 Ok(())
444 }
445
446 pub fn verify_contract(
448 _contract: &Contract,
449 interactions_results: &[(ContractInteraction, ContractResponse)],
450 ) -> Result<(), ContractError> {
451 for (interaction, actual_response) in interactions_results {
452 Self::verify_interaction(interaction, actual_response)?;
453 }
454 Ok(())
455 }
456}
457
458#[cfg(test)]
459mod tests {
460 use super::*;
461
462 #[test]
463 fn test_contract_builder() {
464 let request = ContractRequest::new(ContractMethod::Get, "/api/users/1");
465 let response =
466 ContractResponse::new(200).with_body(serde_json::json!({"id": 1, "name": "Alice"}));
467
468 let mut builder = ContractBuilder::new("Frontend", "Backend");
469 builder.add_interaction(ContractInteraction::new(
470 "get user by ID",
471 request,
472 response,
473 ));
474
475 let contract = builder.build();
476 assert_eq!(contract.consumer.name, "Frontend");
477 assert_eq!(contract.provider.name, "Backend");
478 assert_eq!(contract.interactions.len(), 1);
479 }
480
481 #[test]
482 fn test_contract_verification_success() {
483 let interaction = ContractInteraction::new(
484 "get user",
485 ContractRequest::new(ContractMethod::Get, "/api/users/1"),
486 ContractResponse::new(200).with_body(serde_json::json!({"id": 1})),
487 );
488
489 let actual = ContractResponse::new(200).with_body(serde_json::json!({"id": 1}));
490
491 assert!(ContractVerifier::verify_interaction(&interaction, &actual).is_ok());
492 }
493
494 #[test]
495 fn test_contract_verification_status_mismatch() {
496 let interaction = ContractInteraction::new(
497 "get user",
498 ContractRequest::new(ContractMethod::Get, "/api/users/1"),
499 ContractResponse::new(200),
500 );
501
502 let actual = ContractResponse::new(404);
503
504 assert!(ContractVerifier::verify_interaction(&interaction, &actual).is_err());
505 }
506
507 #[test]
508 fn test_contract_request_builder() {
509 let request = ContractRequest::new(ContractMethod::Post, "/api/users")
510 .with_query("page", "1")
511 .with_header("Content-Type", "application/json")
512 .with_body(serde_json::json!({"name": "Bob"}));
513
514 assert_eq!(request.method, ContractMethod::Post);
515 assert_eq!(request.path, "/api/users");
516 assert!(request.query.is_some());
517 assert!(request.headers.is_some());
518 assert!(request.body.is_some());
519 }
520
521 struct TempDir(PathBuf);
523
524 impl TempDir {
525 fn new() -> Self {
526 let dir = std::env::temp_dir()
527 .join(format!("armature-contract-test-{}", uuid::Uuid::new_v4()));
528 std::fs::create_dir_all(&dir).unwrap();
529 Self(dir)
530 }
531 }
532
533 impl Drop for TempDir {
534 fn drop(&mut self) {
535 let _ = std::fs::remove_dir_all(&self.0);
536 }
537 }
538
539 #[test]
540 fn list_round_trips_hyphenated_consumer_and_provider_names_into_load() {
541 let dir = TempDir::new();
542 let manager = ContractManager::new(dir.0.clone());
543
544 let contract = ContractBuilder::new("my-service", "backend-api").build();
549 manager.save(&contract).unwrap();
550
551 let listed = manager.list().unwrap();
552 assert_eq!(
553 listed,
554 vec![("my-service".to_string(), "backend-api".to_string())]
555 );
556
557 let (consumer, provider) = &listed[0];
558 let loaded = manager
559 .load(consumer, provider)
560 .expect("list()'s output must round-trip into load()");
561 assert_eq!(loaded.consumer.name, "my-service");
562 assert_eq!(loaded.provider.name, "backend-api");
563 }
564
565 #[test]
566 fn list_returns_multiple_contracts_with_non_hyphenated_names_too() {
567 let dir = TempDir::new();
568 let manager = ContractManager::new(dir.0.clone());
569
570 manager
571 .save(&ContractBuilder::new("Frontend", "Backend").build())
572 .unwrap();
573 manager
574 .save(&ContractBuilder::new("Mobile", "Backend").build())
575 .unwrap();
576
577 let mut listed = manager.list().unwrap();
578 listed.sort();
579 assert_eq!(
580 listed,
581 vec![
582 ("Frontend".to_string(), "Backend".to_string()),
583 ("Mobile".to_string(), "Backend".to_string()),
584 ]
585 );
586 }
587}