use base64::Engine;
use base64::engine::general_purpose::STANDARD as Base64;
use clap::ArgMatches;
use pact_models::pact::Pact;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::cli::{
pact_broker::main::{
HALClient, Notice, PactBrokerError, process_notices,
utils::{
get_auth, get_broker_relation, get_broker_url, get_custom_headers, get_ssl_options,
},
},
utils::{self, git_info},
};
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ProviderContractPublishRoot {
#[serde(rename = "_embedded")]
embedded: Embedded,
#[serde(rename = "_links")]
links: Links,
notices: Vec<Notice>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Embedded {
version: Version,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Pacticipant {
name: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SelfField {
href: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Version {
number: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Links {
#[serde(rename = "pb:pacticipant-version-tags")]
pb_pacticipant_version_tags: Vec<Value>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PbPacticipant {
href: String,
name: String,
title: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PbPacticipantVersion {
href: String,
name: String,
title: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PfProviderContract {
href: String,
name: String,
title: String,
}
pub fn publish(args: &ArgMatches) -> Result<Value, PactBrokerError> {
let contract_file = args
.get_one::<String>("contract-file")
.expect("CONTRACT_FILE is required");
let contract_content = std::fs::read_to_string(contract_file).map_err(|e| {
println!("❌ Failed to read contract file: {}", e);
PactBrokerError::IoError(e.to_string())
})?;
let broker_url = get_broker_url(args).trim_end_matches('/').to_string();
let auth = get_auth(args);
let custom_headers = get_custom_headers(args);
let ssl_options = get_ssl_options(args);
let hal_client: HALClient = HALClient::with_url(
&broker_url,
Some(auth.clone()),
ssl_options.clone(),
custom_headers.clone(),
);
let publish_contract_href_path = tokio::runtime::Runtime::new().unwrap().block_on(async {
get_broker_relation(
hal_client.clone(),
"pf:publish-provider-contract".to_string(),
broker_url.to_string(),
)
.await
});
match publish_contract_href_path {
Ok(publish_contract_href) => {
let provider_name = args
.get_one::<String>("provider")
.expect("PROVIDER is required");
let mut provider_app_version = args.get_one::<String>("provider-app-version");
let mut branch = args.get_one::<String>("branch");
let tag_with_git_branch = args.get_flag("tag-with-git-branch");
let build_url = args.get_one::<String>("build-url");
let default_specification = "oas".to_string();
let specification = args
.get_one::<String>("specification")
.unwrap_or(&default_specification);
let default_content_type = "application/yaml".to_string();
let content_type = args
.get_one::<String>("content-type")
.unwrap_or(&default_content_type);
let auto_detect_version_properties: bool =
args.get_flag("auto-detect-version-properties");
let (git_commit, git_branch);
if auto_detect_version_properties {
git_commit = git_info::commit(false);
git_branch = git_info::branch(false);
} else {
git_commit = Some("".to_string());
git_branch = Some("".to_string());
}
if auto_detect_version_properties {
if provider_app_version == None {
provider_app_version = git_commit.as_ref();
println!(
"🔍 Auto detected git commit: {}",
provider_app_version.unwrap().to_string()
);
} else {
println!(
"🔍 auto_detect_version_properties set to {}, but provider_app_version provided {}",
auto_detect_version_properties,
provider_app_version.unwrap().to_string()
);
}
if branch == None {
branch = git_branch.as_ref();
println!(
"🔍 Auto detected git branch: {}",
branch.unwrap().to_string()
);
} else {
println!(
"🔍 auto_detect_version_properties set to {}, but branch provided {}",
auto_detect_version_properties,
branch.unwrap().to_string()
);
}
}
let publish_contract_href = publish_contract_href.replace("{provider}", provider_name);
let verification_exit_code = args.get_one::<String>("verification-exit-code");
let verification_success = if args.contains_id("verification-success")
&& args.get_flag("verification-success")
{
true
} else if args.contains_id("no-verification-success")
&& args.get_flag("no-verification-success")
{
false
} else if let Some(exit_code_str) = verification_exit_code {
match exit_code_str.parse::<i32>() {
Ok(code) => code == 0,
Err(_) => false,
}
} else {
false
};
let verification_results_path = args.get_one::<String>("verification-results");
let verification_results_content = if let Some(file_path) = verification_results_path {
Some(std::fs::read_to_string(file_path).map_err(|e| {
eprintln!(
"❌ Failed to read verification results file '{}': {}",
file_path, e
);
PactBrokerError::IoError(e.to_string())
})?)
} else {
None
};
let verification_results_content_type =
args.get_one::<String>("verification-results-content-type");
let verification_results_format = args.get_one::<String>("verification-results-format");
let verifier = args.get_one::<String>("verifier");
let verifier_version = args.get_one::<String>("verifier-version");
let mut contract_params = json!({
"content": Base64.encode(contract_content),
"specification": specification,
"contentType": content_type,
});
if verification_results_content.is_some()
|| verifier.is_some()
|| verifier_version.is_some()
{
let mut verification_results_params = serde_json::Map::new();
verification_results_params
.insert("success".to_string(), Value::Bool(verification_success));
if let Some(content) = verification_results_content {
verification_results_params
.insert("content".to_string(), Value::String(Base64.encode(content)));
}
if let Some(content_type) = verification_results_content_type {
verification_results_params.insert(
"contentType".to_string(),
Value::String(content_type.to_string()),
);
}
if let Some(format) = verification_results_format {
verification_results_params
.insert("format".to_string(), Value::String(format.to_string()));
}
if let Some(verifier) = verifier {
verification_results_params
.insert("verifier".to_string(), Value::String(verifier.to_string()));
}
if let Some(verifier_version) = verifier_version {
verification_results_params.insert(
"verifierVersion".to_string(),
Value::String(verifier_version.to_string()),
);
}
contract_params["selfVerificationResults"] =
Value::Object(verification_results_params);
}
let mut payload = json!({
"pacticipantVersionNumber": provider_app_version,
"contract": contract_params,
});
if let Some(tags) = args.get_many::<String>("tag") {
payload["tags"] = serde_json::Value::Array(vec![]);
for tag in tags {
payload["tags"]
.as_array_mut()
.unwrap()
.push(serde_json::Value::String(tag.to_string()));
}
};
if tag_with_git_branch {
if !payload.get("tags").map_or(false, |v| v.is_array()) {
payload["tags"] = serde_json::Value::Array(vec![]);
}
payload["tags"]
.as_array_mut()
.unwrap()
.push(serde_json::Value::String(
git_info::branch(false).unwrap_or_default(),
));
}
if let Some(branch) = branch {
payload["branch"] = Value::String(branch.to_string());
}
if let Some(build_url) = build_url {
payload["buildUrl"] = Value::String(build_url.to_string());
}
let output: Result<Option<&String>, clap::parser::MatchesError> =
args.try_get_one::<String>("output");
println!(
"📨 Attempting to publish provider contract for provider: {} version: {}",
provider_name,
provider_app_version.unwrap().to_string()
);
let res = tokio::runtime::Runtime::new().unwrap().block_on(async {
hal_client
.clone()
.post_json(
&(publish_contract_href),
&payload.to_string(),
Some({
let mut headers = std::collections::HashMap::new();
headers.insert(
"Accept".to_string(),
"application/problem+json".to_string(),
);
headers
}),
)
.await
});
match res {
Ok(res) => match output {
Ok(Some(output)) => {
if output == "pretty" {
let json = serde_json::to_string_pretty(&res).unwrap();
println!("{}", json);
} else if output == "json" {
return Ok(res.clone());
} else {
let parsed_res =
serde_json::from_value::<ProviderContractPublishRoot>(res);
match parsed_res {
Ok(parsed_res) => {
print!("✅ ");
process_notices(&parsed_res.notices);
}
Err(err) => {
println!(
"✅ Provider contract published successfully for provider: {} version: {}",
provider_name,
provider_app_version.unwrap().to_string()
);
println!(
"⚠️ Warning: Failed to process response notices - Error: {:?}",
err
);
return Err(PactBrokerError::ContentError(err.to_string()));
}
}
}
}
_ => {
println!("{:?}", res.clone());
}
},
Err(err) => {
match &err {
crate::cli::pact_broker::main::PactBrokerError::ValidationErrorWithNotices(messages, notices) => {
println!("❌ Provider contract publication failed:");
for message in messages {
println!(" {}", message);
}
if !notices.is_empty() {
println!("\nDetails:");
process_notices(notices);
}
},
_ => {
println!("❌ {}", err.to_string());
}
}
return Err(err);
}
}
Ok(json!({}))
}
Err(err) => Err(err),
}
}
#[cfg(test)]
mod publish_provider_contract_tests {
use crate::cli::pactflow::main::provider_contracts::publish::publish;
use crate::cli::pactflow::main::subcommands::add_publish_provider_contract_subcommand;
use base64::{Engine, engine::general_purpose::STANDARD as Base64};
use pact_consumer::prelude::*;
use pact_models::prelude::Generator;
use pact_models::{PactSpecification, generators};
use serde_json::json;
#[cfg(not(target_os = "windows"))]
#[test]
fn publish_provider_contract_success() {
let provider_name = "Bar";
let provider_version_number = "1";
let branch_name = "main";
let tag = "dev";
let build_url = "http://build";
let contract_content_yaml = "some:\n contract";
let contract_content_base64 = Base64.encode(contract_content_yaml);
let verification_results_content = "some results";
let verification_results_content_base64 = Base64.encode(verification_results_content);
let request_body = json!({
"pacticipantVersionNumber": provider_version_number,
"tags": [tag],
"branch": branch_name,
"buildUrl": build_url,
"contract": {
"content": contract_content_base64,
"contentType": "application/yaml",
"specification": "oas",
"selfVerificationResults": {
"success": true,
"content": verification_results_content_base64,
"contentType": "text/plain",
"format": "text",
"verifier": "my custom tool",
"verifierVersion": "1.0"
}
}
});
let publish_provider_contract_path_generator = generators! {
"BODY" => {
"$._links.pb:pf:publish-provider-contract.href" => Generator::MockServerURL(
format!("/contracts/publish/{}", provider_name.to_string()),
".*\\/contracts\\/publish\\/.*".to_string()
)
}
};
let index_response_body = json_pattern!({
"_links": {
"pf:publish-provider-contract": {
"href": term!(format!(".*\\/contracts\\/publish\\/{}",provider_name), format!("http://localhost:1234/contracts/publish/{}", provider_name)),
}
}
});
let success_response_body = json!({
"notices": [
{ "text": "some notice", "type": "info" }
],
"_embedded": {
"version": {
"number": provider_version_number
}
},
"_links": {
"pb:pacticipant-version-tags": [json!({})],
"pb:branch-version": json!({})
}
});
let config = MockServerConfig {
pact_specification: PactSpecification::V2,
..MockServerConfig::default()
};
let pact_broker_service = PactBuilder::new("pact-broker-cli", "PactFlow")
.interaction("a request for the index resource", "", |mut i| {
i.given("the pb:publish-provider-contract relation exists in the index resource");
i.request
.get()
.path("/")
.header("Accept", "application/hal+json")
.header("Accept", "application/json");
i.response
.status(200)
.header("Content-Type", "application/hal+json;charset=utf-8")
.json_body(index_response_body)
.generators()
.add_generators(publish_provider_contract_path_generator);
i
})
.interaction("a request to publish a provider contract", "", |mut i| {
i.request
.post()
.path(format!("/contracts/publish/{}", provider_name))
.header("Content-Type", "application/json")
.header("Accept", "application/hal+json,application/problem+json")
.json_body(request_body.clone());
i.response
.status(200)
.header("Content-Type", "application/hal+json;charset=utf-8")
.json_body(success_response_body.clone());
i
})
.start_mock_server(None, Some(config));
let mock_server_url = pact_broker_service.url();
let matches = add_publish_provider_contract_subcommand().get_matches_from(vec![
"publish-provider-contract",
"tests/fixtures/provider-contract.yaml",
"-b",
mock_server_url.as_str(),
"--provider",
provider_name,
"--provider-app-version",
provider_version_number,
"--branch",
branch_name,
"--tag",
tag,
"--build-url",
build_url,
"--content-type",
"application/yaml",
"--verification-success",
"--verification-results",
"tests/fixtures/verification-results.txt",
"--verification-results-content-type",
"text/plain",
"--verification-results-format",
"text",
"--verifier",
"my custom tool",
"--verifier-version",
"1.0",
"--output",
"json",
]);
let result = publish(&matches);
assert!(result.is_ok());
let value = result.unwrap();
assert!(value.is_object());
let notices = value.get("notices").unwrap();
assert!(notices.is_array());
assert!(notices[0]["text"].as_str().unwrap().contains("some notice"));
let embedded = value.get("_embedded").unwrap();
let version = embedded.get("version").unwrap();
assert_eq!(version.get("number").unwrap(), provider_version_number);
}
#[cfg(not(target_os = "windows"))]
#[test]
fn publish_provider_contract_with_missing_verification_results_file() {
let matches = add_publish_provider_contract_subcommand().get_matches_from(vec![
"publish-provider-contract",
"tests/fixtures/provider-contract.yaml",
"-b",
"http://localhost:1234",
"--provider",
"Bar",
"--provider-app-version",
"1",
"--verification-results",
"tests/fixtures/non-existent-file.txt",
]);
let result = publish(&matches);
assert!(result.is_err());
let error = result.unwrap_err();
match error {
crate::cli::pact_broker::main::PactBrokerError::IoError(_) => {
}
_ => panic!("Expected IoError but got: {:?}", error),
}
}
}