Skip to main content

armature_testing/
contract.rs

1//! Contract Testing (Pact)
2//!
3//! Provides consumer-driven contract testing capabilities.
4
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::path::PathBuf;
8use thiserror::Error;
9
10/// Contract testing errors
11#[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/// HTTP method for contract
27#[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/// Contract request
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct ContractRequest {
42    /// HTTP method
43    pub method: ContractMethod,
44
45    /// Request path
46    pub path: String,
47
48    /// Query parameters
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub query: Option<HashMap<String, String>>,
51
52    /// Request headers
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub headers: Option<HashMap<String, String>>,
55
56    /// Request body
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub body: Option<serde_json::Value>,
59}
60
61impl ContractRequest {
62    /// Create new contract request
63    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    /// Add query parameter
74    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    /// Add header
82    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    /// Set body
90    pub fn with_body(mut self, body: serde_json::Value) -> Self {
91        self.body = Some(body);
92        self
93    }
94}
95
96/// Contract response
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct ContractResponse {
99    /// HTTP status code
100    pub status: u16,
101
102    /// Response headers
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub headers: Option<HashMap<String, String>>,
105
106    /// Response body
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub body: Option<serde_json::Value>,
109}
110
111impl ContractResponse {
112    /// Create new contract response
113    pub fn new(status: u16) -> Self {
114        Self {
115            status,
116            headers: None,
117            body: None,
118        }
119    }
120
121    /// Add header
122    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    /// Set body
130    pub fn with_body(mut self, body: serde_json::Value) -> Self {
131        self.body = Some(body);
132        self
133    }
134}
135
136/// Contract interaction
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct ContractInteraction {
139    /// Interaction description
140    pub description: String,
141
142    /// Provider state (optional)
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub provider_state: Option<String>,
145
146    /// Request
147    pub request: ContractRequest,
148
149    /// Response
150    pub response: ContractResponse,
151}
152
153impl ContractInteraction {
154    /// Create new interaction
155    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    /// Set provider state
169    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/// Consumer contract (Pact)
176#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct Contract {
178    /// Consumer name
179    pub consumer: Participant,
180
181    /// Provider name
182    pub provider: Participant,
183
184    /// Interactions
185    pub interactions: Vec<ContractInteraction>,
186
187    /// Metadata
188    pub metadata: ContractMetadata,
189}
190
191/// Participant (consumer or provider)
192#[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/// Contract metadata
204#[derive(Debug, Clone, Serialize, Deserialize)]
205#[serde(rename_all = "camelCase")]
206#[derive(Default)]
207pub struct ContractMetadata {
208    pub pact_specification: PactSpecification,
209}
210
211/// Pact specification version
212#[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
225/// Contract builder
226pub struct ContractBuilder {
227    consumer: String,
228    provider: String,
229    interactions: Vec<ContractInteraction>,
230}
231
232impl ContractBuilder {
233    /// Create new contract builder
234    ///
235    /// # Examples
236    ///
237    /// ```
238    /// use armature_testing::contract::*;
239    ///
240    /// let builder = ContractBuilder::new("MyConsumer", "MyProvider");
241    /// ```
242    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    /// Add interaction
251    ///
252    /// # Examples
253    ///
254    /// ```
255    /// use armature_testing::contract::*;
256    ///
257    /// let request = ContractRequest::new(ContractMethod::Get, "/api/users/1");
258    /// let response = ContractResponse::new(200)
259    ///     .with_body(serde_json::json!({"id": 1, "name": "Alice"}));
260    ///
261    /// let mut builder = ContractBuilder::new("Frontend", "Backend");
262    /// builder.add_interaction(ContractInteraction::new(
263    ///     "get user by ID",
264    ///     request,
265    ///     response,
266    /// ));
267    /// ```
268    pub fn add_interaction(&mut self, interaction: ContractInteraction) -> &mut Self {
269        self.interactions.push(interaction);
270        self
271    }
272
273    /// Build contract
274    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
284/// Contract manager
285pub struct ContractManager {
286    contracts_dir: PathBuf,
287}
288
289impl ContractManager {
290    /// Create new contract manager
291    ///
292    /// # Examples
293    ///
294    /// ```
295    /// use armature_testing::contract::ContractManager;
296    /// use std::path::PathBuf;
297    ///
298    /// let manager = ContractManager::new(PathBuf::from("./pacts"));
299    /// ```
300    pub fn new(contracts_dir: PathBuf) -> Self {
301        Self { contracts_dir }
302    }
303
304    /// Save contract to file
305    pub fn save(&self, contract: &Contract) -> Result<(), ContractError> {
306        // Create contracts directory if it doesn't exist
307        std::fs::create_dir_all(&self.contracts_dir)
308            .map_err(|e| ContractError::IoError(e.to_string()))?;
309
310        // Generate filename
311        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        // Serialize contract
319        let json = serde_json::to_string_pretty(contract)
320            .map_err(|e| ContractError::SerializationError(e.to_string()))?;
321
322        // Write to file
323        std::fs::write(path, json).map_err(|e| ContractError::IoError(e.to_string()))?;
324
325        Ok(())
326    }
327
328    /// Load contract from file
329    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    /// List all contracts.
347    ///
348    /// Reads consumer/provider names back out of each contract file's JSON
349    /// body (`Contract::consumer`/`Contract::provider`), rather than
350    /// splitting the filename on `-`: `save()` lowercases and joins the
351    /// names with `-` to build `{consumer}-{provider}.json`, which is not
352    /// reversible by splitting when either name itself contains a hyphen
353    /// (e.g. consumer `"my-service"` -> filename `my-service-backend.json`
354    /// would misparse as consumer `"my"`, provider `"service"`). Reading the
355    /// JSON instead means the returned pairs always round-trip into
356    /// `load()`, whatever characters the original names contained.
357    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
383/// Contract verifier
384pub struct ContractVerifier;
385
386impl ContractVerifier {
387    /// Verify a contract interaction's expected response against an actual
388    /// response.
389    ///
390    /// This checks only the **response** side (status, headers, body) of
391    /// `interaction` against `actual_response`; it never inspects, sends, or
392    /// otherwise uses `interaction.request` — issuing the real HTTP call
393    /// implied by `interaction.request` and capturing its response is the
394    /// caller's responsibility, not this method's. Pact-style provider
395    /// verification (replaying the request and asserting the response
396    /// contract) is only partially covered as a result.
397    pub fn verify_interaction(
398        interaction: &ContractInteraction,
399        actual_response: &ContractResponse,
400    ) -> Result<(), ContractError> {
401        // Verify status
402        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        // Verify headers (if specified)
410        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        // Verify body (if specified)
430        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    /// Verify entire contract
447    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    /// Isolated scratch directory under the OS temp dir, cleaned up on drop.
522    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        // Both names contain hyphens, so the filename
545        // "my-service-backend-api.json" cannot be unambiguously split back
546        // into (consumer, provider) by position; list() must read the
547        // names from the JSON body instead.
548        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}