Wave 6: Integration tests, golden tests, index invariant tests, diff command (bd-rex, bd-2gp, bd-1ck)
This commit is contained in:
@@ -9,6 +9,7 @@ use fs2::FileExt;
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use crate::errors::SwaggerCliError;
|
||||
|
||||
@@ -46,8 +47,11 @@ impl CacheMetadata {
|
||||
///
|
||||
/// Accepts: alphanumeric start, then alphanumeric/dot/dash/underscore, 1-64 chars.
|
||||
/// Rejects: path separators, `..`, leading dots, Windows reserved device names.
|
||||
static ALIAS_PATTERN: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"^[A-Za-z0-9][A-Za-z0-9._\-]{0,63}$").expect("valid regex"));
|
||||
|
||||
pub fn validate_alias(alias: &str) -> Result<(), SwaggerCliError> {
|
||||
let pattern = Regex::new(r"^[A-Za-z0-9][A-Za-z0-9._\-]{0,63}$").expect("valid regex");
|
||||
let pattern = &*ALIAS_PATTERN;
|
||||
|
||||
if !pattern.is_match(alias) {
|
||||
return Err(SwaggerCliError::Usage(format!(
|
||||
|
||||
@@ -134,12 +134,45 @@ impl Config {
|
||||
let contents =
|
||||
toml::to_string_pretty(self).map_err(|e| SwaggerCliError::Config(e.to_string()))?;
|
||||
|
||||
std::fs::write(path, contents).map_err(|e| {
|
||||
SwaggerCliError::Config(format!("failed to write {}: {e}", path.display()))
|
||||
// Atomic write: write to .tmp, fsync, then rename to avoid corruption on crash
|
||||
let tmp_path = path.with_extension("toml.tmp");
|
||||
|
||||
std::fs::write(&tmp_path, &contents).map_err(|e| {
|
||||
SwaggerCliError::Config(format!("failed to write {}: {e}", tmp_path.display()))
|
||||
})?;
|
||||
|
||||
// Best-effort fsync
|
||||
if let Ok(file) = std::fs::File::open(&tmp_path) {
|
||||
let _ = file.sync_all();
|
||||
}
|
||||
|
||||
std::fs::rename(&tmp_path, path).map_err(|e| {
|
||||
SwaggerCliError::Config(format!(
|
||||
"failed to rename {} -> {}: {e}",
|
||||
tmp_path.display(),
|
||||
path.display()
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a credential source to its string value.
|
||||
///
|
||||
/// Used by fetch and sync commands to obtain auth tokens from config profiles.
|
||||
pub fn resolve_credential(source: &CredentialSource) -> Result<String, SwaggerCliError> {
|
||||
match source {
|
||||
CredentialSource::Literal { value } => Ok(value.clone()),
|
||||
CredentialSource::EnvVar { name } => std::env::var(name).map_err(|_| {
|
||||
SwaggerCliError::Auth(format!(
|
||||
"environment variable '{name}' not set (required by auth profile)"
|
||||
))
|
||||
}),
|
||||
CredentialSource::Keyring { service, account } => Err(SwaggerCliError::Auth(format!(
|
||||
"keyring credential lookup not yet implemented (service={service}, account={account})"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
353
src/core/diff.rs
Normal file
353
src/core/diff.rs
Normal file
@@ -0,0 +1,353 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use super::spec::{IndexedEndpoint, SpecIndex};
|
||||
|
||||
/// Key that uniquely identifies an endpoint: (METHOD, path).
|
||||
type EndpointKey = (String, String);
|
||||
|
||||
fn endpoint_key(ep: &IndexedEndpoint) -> EndpointKey {
|
||||
(ep.method.to_uppercase(), ep.path.clone())
|
||||
}
|
||||
|
||||
/// Checks whether two endpoints differ structurally (beyond just path+method).
|
||||
fn endpoints_differ(left: &IndexedEndpoint, right: &IndexedEndpoint) -> bool {
|
||||
left.summary != right.summary
|
||||
|| left.deprecated != right.deprecated
|
||||
|| left.parameters.len() != right.parameters.len()
|
||||
|| left.request_body_required != right.request_body_required
|
||||
|| left.request_body_content_types != right.request_body_content_types
|
||||
|| left.security_schemes != right.security_schemes
|
||||
|| left.security_required != right.security_required
|
||||
|| left.tags != right.tags
|
||||
|| left.operation_id != right.operation_id
|
||||
|| left.description != right.description
|
||||
|| params_differ(left, right)
|
||||
}
|
||||
|
||||
fn params_differ(left: &IndexedEndpoint, right: &IndexedEndpoint) -> bool {
|
||||
if left.parameters.len() != right.parameters.len() {
|
||||
return true;
|
||||
}
|
||||
for (lp, rp) in left.parameters.iter().zip(right.parameters.iter()) {
|
||||
if lp.name != rp.name || lp.location != rp.location || lp.required != rp.required {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DiffResult {
|
||||
pub endpoints: EndpointDiff,
|
||||
pub schemas: SchemaDiff,
|
||||
pub summary: DiffSummary,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct EndpointDiff {
|
||||
/// Each entry: [method, path]
|
||||
pub added: Vec<[String; 2]>,
|
||||
/// Each entry: [method, path]
|
||||
pub removed: Vec<[String; 2]>,
|
||||
/// Each entry: [method, path]
|
||||
pub modified: Vec<[String; 2]>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SchemaDiff {
|
||||
pub added: Vec<String>,
|
||||
pub removed: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DiffSummary {
|
||||
pub total_changes: usize,
|
||||
pub has_breaking: bool,
|
||||
}
|
||||
|
||||
/// Compare two spec indexes and produce a structural diff.
|
||||
///
|
||||
/// Breaking changes: removed endpoints.
|
||||
pub fn diff_indexes(left: &SpecIndex, right: &SpecIndex) -> DiffResult {
|
||||
// Build lookup maps by endpoint key
|
||||
let left_map: std::collections::BTreeMap<EndpointKey, &IndexedEndpoint> = left
|
||||
.endpoints
|
||||
.iter()
|
||||
.map(|ep| (endpoint_key(ep), ep))
|
||||
.collect();
|
||||
let right_map: std::collections::BTreeMap<EndpointKey, &IndexedEndpoint> = right
|
||||
.endpoints
|
||||
.iter()
|
||||
.map(|ep| (endpoint_key(ep), ep))
|
||||
.collect();
|
||||
|
||||
let left_keys: BTreeSet<_> = left_map.keys().cloned().collect();
|
||||
let right_keys: BTreeSet<_> = right_map.keys().cloned().collect();
|
||||
|
||||
let added: Vec<[String; 2]> = right_keys
|
||||
.difference(&left_keys)
|
||||
.map(|(method, path)| [method.clone(), path.clone()])
|
||||
.collect();
|
||||
|
||||
let removed: Vec<[String; 2]> = left_keys
|
||||
.difference(&right_keys)
|
||||
.map(|(method, path)| [method.clone(), path.clone()])
|
||||
.collect();
|
||||
|
||||
let modified: Vec<[String; 2]> = left_keys
|
||||
.intersection(&right_keys)
|
||||
.filter(|key| {
|
||||
let l = left_map[key];
|
||||
let r = right_map[key];
|
||||
endpoints_differ(l, r)
|
||||
})
|
||||
.map(|(method, path)| [method.clone(), path.clone()])
|
||||
.collect();
|
||||
|
||||
// Schema diff by name
|
||||
let left_schemas: BTreeSet<&str> = left.schemas.iter().map(|s| s.name.as_str()).collect();
|
||||
let right_schemas: BTreeSet<&str> = right.schemas.iter().map(|s| s.name.as_str()).collect();
|
||||
|
||||
let schemas_added: Vec<String> = right_schemas
|
||||
.difference(&left_schemas)
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
let schemas_removed: Vec<String> = left_schemas
|
||||
.difference(&right_schemas)
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
|
||||
let has_breaking = !removed.is_empty();
|
||||
let total_changes =
|
||||
added.len() + removed.len() + modified.len() + schemas_added.len() + schemas_removed.len();
|
||||
|
||||
DiffResult {
|
||||
endpoints: EndpointDiff {
|
||||
added,
|
||||
removed,
|
||||
modified,
|
||||
},
|
||||
schemas: SchemaDiff {
|
||||
added: schemas_added,
|
||||
removed: schemas_removed,
|
||||
},
|
||||
summary: DiffSummary {
|
||||
total_changes,
|
||||
has_breaking,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::core::spec::{IndexInfo, IndexedEndpoint, IndexedParam, IndexedSchema, SpecIndex};
|
||||
|
||||
fn make_endpoint(path: &str, method: &str, summary: &str) -> IndexedEndpoint {
|
||||
IndexedEndpoint {
|
||||
path: path.to_string(),
|
||||
method: method.to_string(),
|
||||
summary: Some(summary.to_string()),
|
||||
description: None,
|
||||
operation_id: None,
|
||||
tags: vec![],
|
||||
deprecated: false,
|
||||
parameters: vec![],
|
||||
request_body_required: false,
|
||||
request_body_content_types: vec![],
|
||||
security_schemes: vec![],
|
||||
security_required: false,
|
||||
operation_ptr: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn make_index(endpoints: Vec<IndexedEndpoint>, schemas: Vec<IndexedSchema>) -> SpecIndex {
|
||||
SpecIndex {
|
||||
index_version: 1,
|
||||
generation: 1,
|
||||
content_hash: "sha256:test".into(),
|
||||
openapi: "3.0.3".into(),
|
||||
info: IndexInfo {
|
||||
title: "Test".into(),
|
||||
version: "1.0.0".into(),
|
||||
},
|
||||
endpoints,
|
||||
schemas,
|
||||
tags: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_identical_indexes_no_changes() {
|
||||
let eps = vec![
|
||||
make_endpoint("/pets", "GET", "List pets"),
|
||||
make_endpoint("/pets", "POST", "Create pet"),
|
||||
];
|
||||
let schemas = vec![IndexedSchema {
|
||||
name: "Pet".into(),
|
||||
schema_ptr: "#/components/schemas/Pet".into(),
|
||||
}];
|
||||
let left = make_index(eps.clone(), schemas.clone());
|
||||
let right = make_index(eps, schemas);
|
||||
|
||||
let result = diff_indexes(&left, &right);
|
||||
assert_eq!(result.summary.total_changes, 0);
|
||||
assert!(!result.summary.has_breaking);
|
||||
assert!(result.endpoints.added.is_empty());
|
||||
assert!(result.endpoints.removed.is_empty());
|
||||
assert!(result.endpoints.modified.is_empty());
|
||||
assert!(result.schemas.added.is_empty());
|
||||
assert!(result.schemas.removed.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_added_endpoint() {
|
||||
let left = make_index(vec![make_endpoint("/pets", "GET", "List pets")], vec![]);
|
||||
let right = make_index(
|
||||
vec![
|
||||
make_endpoint("/pets", "GET", "List pets"),
|
||||
make_endpoint("/pets", "POST", "Create pet"),
|
||||
],
|
||||
vec![],
|
||||
);
|
||||
|
||||
let result = diff_indexes(&left, &right);
|
||||
assert_eq!(result.endpoints.added.len(), 1);
|
||||
assert_eq!(result.endpoints.added[0], ["POST", "/pets"]);
|
||||
assert_eq!(result.endpoints.removed.len(), 0);
|
||||
assert!(!result.summary.has_breaking);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_removed_endpoint_is_breaking() {
|
||||
let left = make_index(
|
||||
vec![
|
||||
make_endpoint("/pets", "GET", "List pets"),
|
||||
make_endpoint("/pets", "POST", "Create pet"),
|
||||
],
|
||||
vec![],
|
||||
);
|
||||
let right = make_index(vec![make_endpoint("/pets", "GET", "List pets")], vec![]);
|
||||
|
||||
let result = diff_indexes(&left, &right);
|
||||
assert_eq!(result.endpoints.removed.len(), 1);
|
||||
assert_eq!(result.endpoints.removed[0], ["POST", "/pets"]);
|
||||
assert!(result.summary.has_breaking);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_modified_endpoint() {
|
||||
let left = make_index(vec![make_endpoint("/pets", "GET", "List pets")], vec![]);
|
||||
let mut modified_ep = make_endpoint("/pets", "GET", "List all pets");
|
||||
modified_ep.deprecated = true;
|
||||
let right = make_index(vec![modified_ep], vec![]);
|
||||
|
||||
let result = diff_indexes(&left, &right);
|
||||
assert_eq!(result.endpoints.modified.len(), 1);
|
||||
assert_eq!(result.endpoints.modified[0], ["GET", "/pets"]);
|
||||
assert_eq!(result.endpoints.added.len(), 0);
|
||||
assert_eq!(result.endpoints.removed.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_schema_added() {
|
||||
let left = make_index(vec![], vec![]);
|
||||
let right = make_index(
|
||||
vec![],
|
||||
vec![IndexedSchema {
|
||||
name: "Pet".into(),
|
||||
schema_ptr: "#/components/schemas/Pet".into(),
|
||||
}],
|
||||
);
|
||||
|
||||
let result = diff_indexes(&left, &right);
|
||||
assert_eq!(result.schemas.added, vec!["Pet"]);
|
||||
assert!(result.schemas.removed.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_schema_removed() {
|
||||
let left = make_index(
|
||||
vec![],
|
||||
vec![IndexedSchema {
|
||||
name: "Pet".into(),
|
||||
schema_ptr: "#/components/schemas/Pet".into(),
|
||||
}],
|
||||
);
|
||||
let right = make_index(vec![], vec![]);
|
||||
|
||||
let result = diff_indexes(&left, &right);
|
||||
assert!(result.schemas.added.is_empty());
|
||||
assert_eq!(result.schemas.removed, vec!["Pet"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_total_changes_count() {
|
||||
let left = make_index(
|
||||
vec![
|
||||
make_endpoint("/pets", "GET", "List pets"),
|
||||
make_endpoint("/pets", "DELETE", "Delete pet"),
|
||||
],
|
||||
vec![IndexedSchema {
|
||||
name: "Pet".into(),
|
||||
schema_ptr: "#/components/schemas/Pet".into(),
|
||||
}],
|
||||
);
|
||||
let right = make_index(
|
||||
vec![
|
||||
make_endpoint("/pets", "GET", "List all pets"), // modified
|
||||
make_endpoint("/pets", "POST", "Create pet"), // added
|
||||
// DELETE removed
|
||||
],
|
||||
vec![
|
||||
// Pet removed
|
||||
IndexedSchema {
|
||||
name: "NewPet".into(),
|
||||
schema_ptr: "#/components/schemas/NewPet".into(),
|
||||
}, // added
|
||||
],
|
||||
);
|
||||
|
||||
let result = diff_indexes(&left, &right);
|
||||
// 1 added ep + 1 removed ep + 1 modified ep + 1 added schema + 1 removed schema = 5
|
||||
assert_eq!(result.summary.total_changes, 5);
|
||||
assert!(result.summary.has_breaking); // DELETE was removed
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parameter_change_detected() {
|
||||
let mut left_ep = make_endpoint("/pets", "GET", "List pets");
|
||||
left_ep.parameters = vec![IndexedParam {
|
||||
name: "limit".into(),
|
||||
location: "query".into(),
|
||||
required: false,
|
||||
description: None,
|
||||
}];
|
||||
|
||||
let mut right_ep = make_endpoint("/pets", "GET", "List pets");
|
||||
right_ep.parameters = vec![IndexedParam {
|
||||
name: "limit".into(),
|
||||
location: "query".into(),
|
||||
required: true, // changed from false to true
|
||||
description: None,
|
||||
}];
|
||||
|
||||
let left = make_index(vec![left_ep], vec![]);
|
||||
let right = make_index(vec![right_ep], vec![]);
|
||||
|
||||
let result = diff_indexes(&left, &right);
|
||||
assert_eq!(result.endpoints.modified.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_indexes() {
|
||||
let left = make_index(vec![], vec![]);
|
||||
let right = make_index(vec![], vec![]);
|
||||
|
||||
let result = diff_indexes(&left, &right);
|
||||
assert_eq!(result.summary.total_changes, 0);
|
||||
assert!(!result.summary.has_breaking);
|
||||
}
|
||||
}
|
||||
@@ -196,6 +196,9 @@ impl AsyncHttpClient {
|
||||
etag: Option<&str>,
|
||||
last_modified: Option<&str>,
|
||||
) -> Result<ConditionalFetchResult, SwaggerCliError> {
|
||||
// Check network policy before any HTTP request
|
||||
check_remote_fetch(self.network_policy)?;
|
||||
|
||||
let parsed = validate_url(url, self.allow_insecure_http)?;
|
||||
|
||||
let host = parsed
|
||||
@@ -245,9 +248,10 @@ impl AsyncHttpClient {
|
||||
s if s == StatusCode::TOO_MANY_REQUESTS || s.is_server_error() => {
|
||||
attempts += 1;
|
||||
if attempts > self.max_retries {
|
||||
return Err(SwaggerCliError::Network(
|
||||
client.get(url).send().await.unwrap_err(),
|
||||
));
|
||||
return Err(SwaggerCliError::InvalidSpec(format!(
|
||||
"request to '{url}' failed after {} retries (last status: {status})",
|
||||
self.max_retries,
|
||||
)));
|
||||
}
|
||||
let delay = self.retry_delay(&response, attempts);
|
||||
tokio::time::sleep(delay).await;
|
||||
@@ -303,9 +307,10 @@ impl AsyncHttpClient {
|
||||
s if s == StatusCode::TOO_MANY_REQUESTS || s.is_server_error() => {
|
||||
attempts += 1;
|
||||
if attempts > self.max_retries {
|
||||
return Err(SwaggerCliError::Network(
|
||||
client.get(url).send().await.unwrap_err(),
|
||||
));
|
||||
return Err(SwaggerCliError::InvalidSpec(format!(
|
||||
"request to '{url}' failed after {} retries (last status: {status})",
|
||||
self.max_retries,
|
||||
)));
|
||||
}
|
||||
let delay = self.retry_delay(&response, attempts);
|
||||
tokio::time::sleep(delay).await;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod cache;
|
||||
pub mod config;
|
||||
pub mod diff;
|
||||
pub mod http;
|
||||
pub mod indexer;
|
||||
pub mod network;
|
||||
|
||||
Reference in New Issue
Block a user