use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ContractError {
#[error("Verification failed: {0}")]
VerificationFailed(String),
#[error("Serialization error: {0}")]
SerializationError(String),
#[error("IO error: {0}")]
IoError(String),
#[error("Invalid contract: {0}")]
InvalidContract(String),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "UPPERCASE")]
pub enum ContractMethod {
Get,
Post,
Put,
Delete,
Patch,
Head,
Options,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContractRequest {
pub method: ContractMethod,
pub path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub query: Option<HashMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub headers: Option<HashMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<serde_json::Value>,
}
impl ContractRequest {
pub fn new(method: ContractMethod, path: impl Into<String>) -> Self {
Self {
method,
path: path.into(),
query: None,
headers: None,
body: None,
}
}
pub fn with_query(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.query
.get_or_insert_with(HashMap::new)
.insert(key.into(), value.into());
self
}
pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.headers
.get_or_insert_with(HashMap::new)
.insert(key.into(), value.into());
self
}
pub fn with_body(mut self, body: serde_json::Value) -> Self {
self.body = Some(body);
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContractResponse {
pub status: u16,
#[serde(skip_serializing_if = "Option::is_none")]
pub headers: Option<HashMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<serde_json::Value>,
}
impl ContractResponse {
pub fn new(status: u16) -> Self {
Self {
status,
headers: None,
body: None,
}
}
pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.headers
.get_or_insert_with(HashMap::new)
.insert(key.into(), value.into());
self
}
pub fn with_body(mut self, body: serde_json::Value) -> Self {
self.body = Some(body);
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContractInteraction {
pub description: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider_state: Option<String>,
pub request: ContractRequest,
pub response: ContractResponse,
}
impl ContractInteraction {
pub fn new(
description: impl Into<String>,
request: ContractRequest,
response: ContractResponse,
) -> Self {
Self {
description: description.into(),
provider_state: None,
request,
response,
}
}
pub fn with_provider_state(mut self, state: impl Into<String>) -> Self {
self.provider_state = Some(state.into());
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Contract {
pub consumer: Participant,
pub provider: Participant,
pub interactions: Vec<ContractInteraction>,
pub metadata: ContractMetadata,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Participant {
pub name: String,
}
impl Participant {
pub fn new(name: impl Into<String>) -> Self {
Self { name: name.into() }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[derive(Default)]
pub struct ContractMetadata {
pub pact_specification: PactSpecification,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PactSpecification {
pub version: String,
}
impl Default for PactSpecification {
fn default() -> Self {
Self {
version: "3.0.0".to_string(),
}
}
}
pub struct ContractBuilder {
consumer: String,
provider: String,
interactions: Vec<ContractInteraction>,
}
impl ContractBuilder {
pub fn new(consumer: impl Into<String>, provider: impl Into<String>) -> Self {
Self {
consumer: consumer.into(),
provider: provider.into(),
interactions: Vec::new(),
}
}
pub fn add_interaction(&mut self, interaction: ContractInteraction) -> &mut Self {
self.interactions.push(interaction);
self
}
pub fn build(self) -> Contract {
Contract {
consumer: Participant::new(self.consumer),
provider: Participant::new(self.provider),
interactions: self.interactions,
metadata: ContractMetadata::default(),
}
}
}
pub struct ContractManager {
contracts_dir: PathBuf,
}
impl ContractManager {
pub fn new(contracts_dir: PathBuf) -> Self {
Self { contracts_dir }
}
pub fn save(&self, contract: &Contract) -> Result<(), ContractError> {
std::fs::create_dir_all(&self.contracts_dir)
.map_err(|e| ContractError::IoError(e.to_string()))?;
let filename = format!(
"{}-{}.json",
contract.consumer.name.to_lowercase(),
contract.provider.name.to_lowercase()
);
let path = self.contracts_dir.join(filename);
let json = serde_json::to_string_pretty(contract)
.map_err(|e| ContractError::SerializationError(e.to_string()))?;
std::fs::write(path, json).map_err(|e| ContractError::IoError(e.to_string()))?;
Ok(())
}
pub fn load(&self, consumer: &str, provider: &str) -> Result<Contract, ContractError> {
let filename = format!(
"{}-{}.json",
consumer.to_lowercase(),
provider.to_lowercase()
);
let path = self.contracts_dir.join(filename);
let content =
std::fs::read_to_string(path).map_err(|e| ContractError::IoError(e.to_string()))?;
let contract: Contract = serde_json::from_str(&content)
.map_err(|e| ContractError::SerializationError(e.to_string()))?;
Ok(contract)
}
pub fn list(&self) -> Result<Vec<(String, String)>, ContractError> {
let entries = std::fs::read_dir(&self.contracts_dir)
.map_err(|e| ContractError::IoError(e.to_string()))?;
let mut contracts = Vec::new();
for entry in entries {
let entry = entry.map_err(|e| ContractError::IoError(e.to_string()))?;
let filename = entry.file_name().to_string_lossy().to_string();
if filename.ends_with(".json") {
if let Some(parts) = filename.strip_suffix(".json") {
let parts: Vec<&str> = parts.split('-').collect();
if parts.len() >= 2 {
contracts.push((parts[0].to_string(), parts[1].to_string()));
}
}
}
}
Ok(contracts)
}
}
pub struct ContractVerifier;
impl ContractVerifier {
pub fn verify_interaction(
interaction: &ContractInteraction,
actual_response: &ContractResponse,
) -> Result<(), ContractError> {
if interaction.response.status != actual_response.status {
return Err(ContractError::VerificationFailed(format!(
"Status mismatch: expected {}, got {}",
interaction.response.status, actual_response.status
)));
}
if let Some(expected_headers) = &interaction.response.headers {
let actual_headers = actual_response.headers.as_ref().ok_or_else(|| {
ContractError::VerificationFailed("Missing response headers".to_string())
})?;
for (key, expected_value) in expected_headers {
let actual_value = actual_headers.get(key).ok_or_else(|| {
ContractError::VerificationFailed(format!("Missing header: {}", key))
})?;
if actual_value != expected_value {
return Err(ContractError::VerificationFailed(format!(
"Header mismatch for '{}': expected '{}', got '{}'",
key, expected_value, actual_value
)));
}
}
}
if let Some(expected_body) = &interaction.response.body {
let actual_body = actual_response.body.as_ref().ok_or_else(|| {
ContractError::VerificationFailed("Missing response body".to_string())
})?;
if expected_body != actual_body {
return Err(ContractError::VerificationFailed(format!(
"Body mismatch: expected {:?}, got {:?}",
expected_body, actual_body
)));
}
}
Ok(())
}
pub fn verify_contract(
_contract: &Contract,
interactions_results: &[(ContractInteraction, ContractResponse)],
) -> Result<(), ContractError> {
for (interaction, actual_response) in interactions_results {
Self::verify_interaction(interaction, actual_response)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_contract_builder() {
let request = ContractRequest::new(ContractMethod::Get, "/api/users/1");
let response =
ContractResponse::new(200).with_body(serde_json::json!({"id": 1, "name": "Alice"}));
let mut builder = ContractBuilder::new("Frontend", "Backend");
builder.add_interaction(ContractInteraction::new(
"get user by ID",
request,
response,
));
let contract = builder.build();
assert_eq!(contract.consumer.name, "Frontend");
assert_eq!(contract.provider.name, "Backend");
assert_eq!(contract.interactions.len(), 1);
}
#[test]
fn test_contract_verification_success() {
let interaction = ContractInteraction::new(
"get user",
ContractRequest::new(ContractMethod::Get, "/api/users/1"),
ContractResponse::new(200).with_body(serde_json::json!({"id": 1})),
);
let actual = ContractResponse::new(200).with_body(serde_json::json!({"id": 1}));
assert!(ContractVerifier::verify_interaction(&interaction, &actual).is_ok());
}
#[test]
fn test_contract_verification_status_mismatch() {
let interaction = ContractInteraction::new(
"get user",
ContractRequest::new(ContractMethod::Get, "/api/users/1"),
ContractResponse::new(200),
);
let actual = ContractResponse::new(404);
assert!(ContractVerifier::verify_interaction(&interaction, &actual).is_err());
}
#[test]
fn test_contract_request_builder() {
let request = ContractRequest::new(ContractMethod::Post, "/api/users")
.with_query("page", "1")
.with_header("Content-Type", "application/json")
.with_body(serde_json::json!({"name": "Bob"}));
assert_eq!(request.method, ContractMethod::Post);
assert_eq!(request.path, "/api/users");
assert!(request.query.is_some());
assert!(request.headers.is_some());
assert!(request.body.is_some());
}
}