Wave 5: Schemas command, sync command, network policy, test fixtures (bd-x15, bd-3f4, bd-1cv, bd-lx6)
- Implement schemas command with list/show modes, regex filtering, ref expansion - Implement sync command with conditional fetch, content hash diffing, dry-run - Add NetworkPolicy enum (Auto/Offline/OnlineOnly) with env var + CLI flag resolution - Integrate network policy into AsyncHttpClient and fetch command - Create test fixtures (petstore.json/yaml, minimal.json) and integration test helpers - Fix clippy lints: derivable_impls, len_zero, borrow-after-move, deprecated API - 192 tests passing (179 unit + 13 integration), all quality gates green
This commit is contained in:
@@ -1,6 +1,22 @@
|
||||
use clap::Args as ClapArgs;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use clap::Args as ClapArgs;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::core::cache::{CacheManager, CacheMetadata, compute_hash, validate_alias};
|
||||
use crate::core::config::{AuthType, Config, CredentialSource, config_path};
|
||||
use crate::core::http::{AsyncHttpClient, ConditionalFetchResult};
|
||||
use crate::core::indexer::{Format, build_index, detect_format, normalize_to_json};
|
||||
use crate::core::spec::SpecIndex;
|
||||
use crate::errors::SwaggerCliError;
|
||||
use crate::output::robot;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI arguments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Re-fetch and update a cached spec
|
||||
#[derive(Debug, ClapArgs)]
|
||||
@@ -8,11 +24,766 @@ pub struct Args {
|
||||
/// Alias to sync
|
||||
pub alias: String,
|
||||
|
||||
/// Sync all cached specs
|
||||
#[arg(long)]
|
||||
pub all: bool,
|
||||
|
||||
/// Check for changes without writing
|
||||
#[arg(long)]
|
||||
pub dry_run: bool,
|
||||
|
||||
/// Re-fetch regardless of cache freshness
|
||||
#[arg(long)]
|
||||
pub force: bool,
|
||||
|
||||
/// Include detailed change lists in output
|
||||
#[arg(long)]
|
||||
pub details: bool,
|
||||
|
||||
/// Auth profile name from config
|
||||
#[arg(long)]
|
||||
pub auth: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn execute(_args: &Args, _robot: bool) -> Result<(), SwaggerCliError> {
|
||||
Err(SwaggerCliError::Usage("sync not yet implemented".into()))
|
||||
// ---------------------------------------------------------------------------
|
||||
// Diff types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MAX_DETAIL_ITEMS: usize = 200;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct EndpointKey {
|
||||
path: String,
|
||||
method: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct SchemaDiff {
|
||||
added: Vec<String>,
|
||||
removed: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct EndpointDiff {
|
||||
added: Vec<EndpointKey>,
|
||||
removed: Vec<EndpointKey>,
|
||||
modified: Vec<EndpointKey>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct ChangeSummary {
|
||||
endpoints_added: usize,
|
||||
endpoints_removed: usize,
|
||||
endpoints_modified: usize,
|
||||
schemas_added: usize,
|
||||
schemas_removed: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct ChangeDetails {
|
||||
endpoints: EndpointDiff,
|
||||
schemas: SchemaDiff,
|
||||
truncated: bool,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Robot output
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct SyncOutput {
|
||||
alias: String,
|
||||
changed: bool,
|
||||
reason: String,
|
||||
local_version: String,
|
||||
remote_version: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
changes: Option<ChangeSummary>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
details: Option<ChangeDetails>,
|
||||
dry_run: bool,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Index diffing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build a comparable key for an endpoint: (path, method).
|
||||
fn endpoint_key(ep: &crate::core::spec::IndexedEndpoint) -> (String, String) {
|
||||
(ep.path.clone(), ep.method.clone())
|
||||
}
|
||||
|
||||
/// Build a fingerprint of an endpoint for modification detection.
|
||||
/// Includes summary, parameters, deprecated status, and request body info.
|
||||
fn endpoint_fingerprint(ep: &crate::core::spec::IndexedEndpoint) -> String {
|
||||
let params: Vec<String> = ep
|
||||
.parameters
|
||||
.iter()
|
||||
.map(|p| format!("{}:{}:{}", p.name, p.location, p.required))
|
||||
.collect();
|
||||
|
||||
format!(
|
||||
"{}|{}|{}|{}|{}",
|
||||
ep.summary.as_deref().unwrap_or(""),
|
||||
ep.deprecated,
|
||||
params.join(","),
|
||||
ep.request_body_required,
|
||||
ep.request_body_content_types.join(","),
|
||||
)
|
||||
}
|
||||
|
||||
fn compute_diff(old: &SpecIndex, new: &SpecIndex) -> (ChangeSummary, ChangeDetails) {
|
||||
// Endpoint diff
|
||||
let old_keys: BTreeSet<(String, String)> = old.endpoints.iter().map(endpoint_key).collect();
|
||||
let new_keys: BTreeSet<(String, String)> = new.endpoints.iter().map(endpoint_key).collect();
|
||||
|
||||
let old_fingerprints: BTreeMap<(String, String), String> = old
|
||||
.endpoints
|
||||
.iter()
|
||||
.map(|ep| (endpoint_key(ep), endpoint_fingerprint(ep)))
|
||||
.collect();
|
||||
let new_fingerprints: BTreeMap<(String, String), String> = new
|
||||
.endpoints
|
||||
.iter()
|
||||
.map(|ep| (endpoint_key(ep), endpoint_fingerprint(ep)))
|
||||
.collect();
|
||||
|
||||
let added_keys: Vec<(String, String)> = new_keys.difference(&old_keys).cloned().collect();
|
||||
let removed_keys: Vec<(String, String)> = old_keys.difference(&new_keys).cloned().collect();
|
||||
let common_keys: BTreeSet<&(String, String)> = old_keys.intersection(&new_keys).collect();
|
||||
|
||||
let modified_keys: Vec<(String, String)> = common_keys
|
||||
.into_iter()
|
||||
.filter(|k| old_fingerprints.get(*k) != new_fingerprints.get(*k))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
// Schema diff
|
||||
let old_schemas: BTreeSet<String> = old.schemas.iter().map(|s| s.name.clone()).collect();
|
||||
let new_schemas: BTreeSet<String> = new.schemas.iter().map(|s| s.name.clone()).collect();
|
||||
|
||||
let schemas_added: Vec<String> = new_schemas.difference(&old_schemas).cloned().collect();
|
||||
let schemas_removed: Vec<String> = old_schemas.difference(&new_schemas).cloned().collect();
|
||||
|
||||
let total_items = added_keys.len()
|
||||
+ removed_keys.len()
|
||||
+ modified_keys.len()
|
||||
+ schemas_added.len()
|
||||
+ schemas_removed.len();
|
||||
let truncated = total_items > MAX_DETAIL_ITEMS;
|
||||
|
||||
let summary = ChangeSummary {
|
||||
endpoints_added: added_keys.len(),
|
||||
endpoints_removed: removed_keys.len(),
|
||||
endpoints_modified: modified_keys.len(),
|
||||
schemas_added: schemas_added.len(),
|
||||
schemas_removed: schemas_removed.len(),
|
||||
};
|
||||
|
||||
let to_endpoint_keys = |keys: Vec<(String, String)>, limit: usize| -> Vec<EndpointKey> {
|
||||
keys.into_iter()
|
||||
.take(limit)
|
||||
.map(|(path, method)| EndpointKey { path, method })
|
||||
.collect()
|
||||
};
|
||||
|
||||
let details = ChangeDetails {
|
||||
endpoints: EndpointDiff {
|
||||
added: to_endpoint_keys(added_keys, MAX_DETAIL_ITEMS),
|
||||
removed: to_endpoint_keys(removed_keys, MAX_DETAIL_ITEMS),
|
||||
modified: to_endpoint_keys(modified_keys, MAX_DETAIL_ITEMS),
|
||||
},
|
||||
schemas: SchemaDiff {
|
||||
added: schemas_added.into_iter().take(MAX_DETAIL_ITEMS).collect(),
|
||||
removed: schemas_removed.into_iter().take(MAX_DETAIL_ITEMS).collect(),
|
||||
},
|
||||
truncated,
|
||||
};
|
||||
|
||||
(summary, details)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth credential resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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})"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core sync logic (testable with explicit cache path)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn sync_inner(
|
||||
args: &Args,
|
||||
cache_path: PathBuf,
|
||||
robot_mode: bool,
|
||||
) -> Result<(), SwaggerCliError> {
|
||||
let start = Instant::now();
|
||||
|
||||
let cm = CacheManager::new(cache_path);
|
||||
validate_alias(&args.alias)?;
|
||||
|
||||
// 1. Load existing metadata and index
|
||||
let (old_index, meta) = cm.load_index(&args.alias)?;
|
||||
|
||||
let url = meta.url.clone().ok_or_else(|| {
|
||||
SwaggerCliError::Usage(format!(
|
||||
"alias '{}' has no URL (fetched from stdin/file). Cannot sync.",
|
||||
args.alias
|
||||
))
|
||||
})?;
|
||||
|
||||
// 2. Build HTTP client
|
||||
let cfg = Config::load(&config_path(None))?;
|
||||
let mut builder = AsyncHttpClient::builder().allow_insecure_http(url.starts_with("http://"));
|
||||
|
||||
if let Some(profile_name) = &args.auth {
|
||||
let profile = cfg.auth_profiles.get(profile_name).ok_or_else(|| {
|
||||
SwaggerCliError::Auth(format!("auth profile '{profile_name}' not found in config"))
|
||||
})?;
|
||||
let credential = resolve_credential(&profile.credential)?;
|
||||
match &profile.auth_type {
|
||||
AuthType::Bearer => {
|
||||
builder = builder
|
||||
.auth_header("Authorization".to_string(), format!("Bearer {credential}"));
|
||||
}
|
||||
AuthType::ApiKey { header } => {
|
||||
builder = builder.auth_header(header.clone(), credential);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let client = builder.build();
|
||||
|
||||
// 3. Conditional fetch
|
||||
let (etag, last_modified) = if args.force {
|
||||
(None, None)
|
||||
} else {
|
||||
(meta.etag.as_deref(), meta.last_modified.as_deref())
|
||||
};
|
||||
|
||||
let fetch_result = client.fetch_conditional(&url, etag, last_modified).await?;
|
||||
|
||||
match fetch_result {
|
||||
ConditionalFetchResult::NotModified => {
|
||||
output_no_changes(args, &meta, "304 Not Modified", robot_mode, start.elapsed());
|
||||
return Ok(());
|
||||
}
|
||||
ConditionalFetchResult::Modified(result) => {
|
||||
// 4. Check content hash
|
||||
let new_content_hash = compute_hash(&result.bytes);
|
||||
|
||||
if new_content_hash == meta.content_hash && !args.force {
|
||||
output_no_changes(
|
||||
args,
|
||||
&meta,
|
||||
"content hash unchanged",
|
||||
robot_mode,
|
||||
start.elapsed(),
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 5. Normalize and build index
|
||||
let format = detect_format(&result.bytes, Some(&url), result.content_type.as_deref());
|
||||
let format_str = match format {
|
||||
Format::Json => "json",
|
||||
Format::Yaml => "yaml",
|
||||
};
|
||||
|
||||
let json_bytes = normalize_to_json(&result.bytes, format)?;
|
||||
let value: serde_json::Value = serde_json::from_slice(&json_bytes)?;
|
||||
let new_index = build_index(&value, &new_content_hash, meta.generation + 1)?;
|
||||
|
||||
// 6. Compute diff
|
||||
let (summary, details) = compute_diff(&old_index, &new_index);
|
||||
|
||||
let has_changes = summary.endpoints_added > 0
|
||||
|| summary.endpoints_removed > 0
|
||||
|| summary.endpoints_modified > 0
|
||||
|| summary.schemas_added > 0
|
||||
|| summary.schemas_removed > 0;
|
||||
|
||||
// Even if diff is empty, content hash changed so we still update
|
||||
let changed = new_content_hash != meta.content_hash || has_changes;
|
||||
|
||||
// 7. Write cache (unless dry-run)
|
||||
if !args.dry_run && changed {
|
||||
cm.write_cache(
|
||||
&args.alias,
|
||||
&result.bytes,
|
||||
&json_bytes,
|
||||
&new_index,
|
||||
Some(url.clone()),
|
||||
&new_index.info.version,
|
||||
&new_index.info.title,
|
||||
format_str,
|
||||
result.etag.clone(),
|
||||
result.last_modified.clone(),
|
||||
Some(meta.generation),
|
||||
)?;
|
||||
}
|
||||
|
||||
// 8. Output
|
||||
output_changes(
|
||||
args,
|
||||
&meta,
|
||||
&new_index,
|
||||
changed,
|
||||
&summary,
|
||||
&details,
|
||||
robot_mode,
|
||||
start.elapsed(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn output_no_changes(
|
||||
args: &Args,
|
||||
meta: &CacheMetadata,
|
||||
reason: &str,
|
||||
robot_mode: bool,
|
||||
duration: Duration,
|
||||
) {
|
||||
if robot_mode {
|
||||
let output = SyncOutput {
|
||||
alias: args.alias.clone(),
|
||||
changed: false,
|
||||
reason: reason.to_string(),
|
||||
local_version: meta.spec_version.clone(),
|
||||
remote_version: None,
|
||||
changes: None,
|
||||
details: None,
|
||||
dry_run: args.dry_run,
|
||||
};
|
||||
robot::robot_success(output, "sync", duration);
|
||||
} else {
|
||||
println!("'{}' is up to date ({})", args.alias, reason);
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn output_changes(
|
||||
args: &Args,
|
||||
old_meta: &CacheMetadata,
|
||||
new_index: &SpecIndex,
|
||||
changed: bool,
|
||||
summary: &ChangeSummary,
|
||||
details: &ChangeDetails,
|
||||
robot_mode: bool,
|
||||
duration: Duration,
|
||||
) {
|
||||
if robot_mode {
|
||||
let output = SyncOutput {
|
||||
alias: args.alias.clone(),
|
||||
changed,
|
||||
reason: if changed {
|
||||
"content changed".to_string()
|
||||
} else {
|
||||
"no changes detected".to_string()
|
||||
},
|
||||
local_version: old_meta.spec_version.clone(),
|
||||
remote_version: Some(new_index.info.version.clone()),
|
||||
changes: Some(summary.clone()),
|
||||
details: if args.details {
|
||||
Some(details.clone())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
dry_run: args.dry_run,
|
||||
};
|
||||
robot::robot_success(output, "sync", duration);
|
||||
} else if changed {
|
||||
let prefix = if args.dry_run { "[dry-run] " } else { "" };
|
||||
println!(
|
||||
"{prefix}'{}' has changes (v{} -> v{})",
|
||||
args.alias, old_meta.spec_version, new_index.info.version
|
||||
);
|
||||
println!(
|
||||
" Endpoints: +{} -{} ~{}",
|
||||
summary.endpoints_added, summary.endpoints_removed, summary.endpoints_modified
|
||||
);
|
||||
println!(
|
||||
" Schemas: +{} -{}",
|
||||
summary.schemas_added, summary.schemas_removed
|
||||
);
|
||||
if args.dry_run {
|
||||
println!(" (dry run -- no changes written)");
|
||||
}
|
||||
} else {
|
||||
println!("'{}' is up to date (content unchanged)", args.alias);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn execute(args: &Args, robot: bool) -> Result<(), SwaggerCliError> {
|
||||
if args.all {
|
||||
return Err(SwaggerCliError::Usage(
|
||||
"sync --all is not yet implemented".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let cache = crate::core::config::cache_dir();
|
||||
sync_inner(args, cache, robot).await
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::core::cache::CacheManager;
|
||||
use crate::core::indexer::build_index;
|
||||
use crate::core::spec::{
|
||||
IndexInfo, IndexedEndpoint, IndexedParam, IndexedSchema, IndexedTag, SpecIndex,
|
||||
};
|
||||
|
||||
fn make_test_index(endpoints: Vec<IndexedEndpoint>, schemas: Vec<IndexedSchema>) -> SpecIndex {
|
||||
let tags: Vec<IndexedTag> = vec![];
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_endpoint(path: &str, method: &str, summary: Option<&str>) -> IndexedEndpoint {
|
||||
IndexedEndpoint {
|
||||
path: path.into(),
|
||||
method: method.into(),
|
||||
summary: summary.map(String::from),
|
||||
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: format!(
|
||||
"/paths/{}/{}",
|
||||
path.replace('/', "~1"),
|
||||
method.to_lowercase()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn make_schema(name: &str) -> IndexedSchema {
|
||||
IndexedSchema {
|
||||
name: name.into(),
|
||||
schema_ptr: format!("/components/schemas/{name}"),
|
||||
}
|
||||
}
|
||||
|
||||
// -- Diff computation tests ------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_diff_no_changes() {
|
||||
let endpoints = vec![make_endpoint("/pets", "GET", Some("List pets"))];
|
||||
let schemas = vec![make_schema("Pet")];
|
||||
let old = make_test_index(endpoints.clone(), schemas.clone());
|
||||
let new = make_test_index(endpoints, schemas);
|
||||
|
||||
let (summary, details) = compute_diff(&old, &new);
|
||||
assert_eq!(summary.endpoints_added, 0);
|
||||
assert_eq!(summary.endpoints_removed, 0);
|
||||
assert_eq!(summary.endpoints_modified, 0);
|
||||
assert_eq!(summary.schemas_added, 0);
|
||||
assert_eq!(summary.schemas_removed, 0);
|
||||
assert!(!details.truncated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diff_added_endpoint() {
|
||||
let old = make_test_index(
|
||||
vec![make_endpoint("/pets", "GET", Some("List pets"))],
|
||||
vec![],
|
||||
);
|
||||
let new = make_test_index(
|
||||
vec![
|
||||
make_endpoint("/pets", "GET", Some("List pets")),
|
||||
make_endpoint("/pets", "POST", Some("Create pet")),
|
||||
],
|
||||
vec![],
|
||||
);
|
||||
|
||||
let (summary, details) = compute_diff(&old, &new);
|
||||
assert_eq!(summary.endpoints_added, 1);
|
||||
assert_eq!(summary.endpoints_removed, 0);
|
||||
assert_eq!(details.endpoints.added.len(), 1);
|
||||
assert_eq!(details.endpoints.added[0].path, "/pets");
|
||||
assert_eq!(details.endpoints.added[0].method, "POST");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diff_removed_endpoint() {
|
||||
let old = make_test_index(
|
||||
vec![
|
||||
make_endpoint("/pets", "GET", Some("List pets")),
|
||||
make_endpoint("/pets", "POST", Some("Create pet")),
|
||||
],
|
||||
vec![],
|
||||
);
|
||||
let new = make_test_index(
|
||||
vec![make_endpoint("/pets", "GET", Some("List pets"))],
|
||||
vec![],
|
||||
);
|
||||
|
||||
let (summary, details) = compute_diff(&old, &new);
|
||||
assert_eq!(summary.endpoints_removed, 1);
|
||||
assert_eq!(details.endpoints.removed.len(), 1);
|
||||
assert_eq!(details.endpoints.removed[0].method, "POST");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diff_modified_endpoint() {
|
||||
let old = make_test_index(
|
||||
vec![make_endpoint("/pets", "GET", Some("List pets"))],
|
||||
vec![],
|
||||
);
|
||||
let new = make_test_index(
|
||||
vec![make_endpoint("/pets", "GET", Some("List all pets"))],
|
||||
vec![],
|
||||
);
|
||||
|
||||
let (summary, _details) = compute_diff(&old, &new);
|
||||
assert_eq!(summary.endpoints_modified, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diff_added_schema() {
|
||||
let old = make_test_index(vec![], vec![make_schema("Pet")]);
|
||||
let new = make_test_index(vec![], vec![make_schema("Pet"), make_schema("Error")]);
|
||||
|
||||
let (summary, details) = compute_diff(&old, &new);
|
||||
assert_eq!(summary.schemas_added, 1);
|
||||
assert_eq!(details.schemas.added, vec!["Error"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diff_removed_schema() {
|
||||
let old = make_test_index(vec![], vec![make_schema("Pet"), make_schema("Error")]);
|
||||
let new = make_test_index(vec![], vec![make_schema("Pet")]);
|
||||
|
||||
let (summary, details) = compute_diff(&old, &new);
|
||||
assert_eq!(summary.schemas_removed, 1);
|
||||
assert_eq!(details.schemas.removed, vec!["Error"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diff_endpoint_modified_by_params() {
|
||||
let mut ep_old = make_endpoint("/pets", "GET", Some("List pets"));
|
||||
ep_old.parameters = vec![IndexedParam {
|
||||
name: "limit".into(),
|
||||
location: "query".into(),
|
||||
required: false,
|
||||
description: None,
|
||||
}];
|
||||
|
||||
let mut ep_new = make_endpoint("/pets", "GET", Some("List pets"));
|
||||
ep_new.parameters = vec![
|
||||
IndexedParam {
|
||||
name: "limit".into(),
|
||||
location: "query".into(),
|
||||
required: false,
|
||||
description: None,
|
||||
},
|
||||
IndexedParam {
|
||||
name: "offset".into(),
|
||||
location: "query".into(),
|
||||
required: false,
|
||||
description: None,
|
||||
},
|
||||
];
|
||||
|
||||
let old = make_test_index(vec![ep_old], vec![]);
|
||||
let new = make_test_index(vec![ep_new], vec![]);
|
||||
|
||||
let (summary, _) = compute_diff(&old, &new);
|
||||
assert_eq!(summary.endpoints_modified, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diff_truncation() {
|
||||
// Create enough items to exceed MAX_DETAIL_ITEMS
|
||||
let mut new_endpoints: Vec<IndexedEndpoint> = Vec::new();
|
||||
for i in 0..250 {
|
||||
new_endpoints.push(make_endpoint(&format!("/item{i}"), "GET", None));
|
||||
}
|
||||
|
||||
let old = make_test_index(vec![], vec![]);
|
||||
let new = make_test_index(new_endpoints, vec![]);
|
||||
|
||||
let (summary, details) = compute_diff(&old, &new);
|
||||
assert_eq!(summary.endpoints_added, 250);
|
||||
assert!(details.truncated);
|
||||
assert_eq!(details.endpoints.added.len(), MAX_DETAIL_ITEMS);
|
||||
}
|
||||
|
||||
// -- Hash-based change detection (unit) ------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_sync_no_changes_same_hash() {
|
||||
// Simulate: same content hash -> no changes
|
||||
let raw_bytes =
|
||||
br#"{"openapi":"3.0.3","info":{"title":"Test","version":"1.0.0"},"paths":{}}"#;
|
||||
let content_hash = compute_hash(raw_bytes);
|
||||
assert_eq!(content_hash, compute_hash(raw_bytes));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_changes_different_hash() {
|
||||
let raw_v1 = br#"{"openapi":"3.0.3","info":{"title":"Test","version":"1.0.0"},"paths":{}}"#;
|
||||
let raw_v2 = br#"{"openapi":"3.0.3","info":{"title":"Test","version":"2.0.0"},"paths":{}}"#;
|
||||
let hash_v1 = compute_hash(raw_v1);
|
||||
let hash_v2 = compute_hash(raw_v2);
|
||||
assert_ne!(hash_v1, hash_v2);
|
||||
}
|
||||
|
||||
// -- Integration: sync with local cache ------------------------------------
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn write_test_cache(
|
||||
cache_path: &std::path::Path,
|
||||
alias: &str,
|
||||
spec_json: &serde_json::Value,
|
||||
url: Option<String>,
|
||||
) -> CacheMetadata {
|
||||
let cm = CacheManager::new(cache_path.to_path_buf());
|
||||
let raw_bytes = serde_json::to_vec(spec_json).unwrap();
|
||||
let content_hash = compute_hash(&raw_bytes);
|
||||
let json_bytes = raw_bytes.clone();
|
||||
let index = build_index(spec_json, &content_hash, 1).unwrap();
|
||||
|
||||
cm.write_cache(
|
||||
alias,
|
||||
&raw_bytes,
|
||||
&json_bytes,
|
||||
&index,
|
||||
url,
|
||||
&index.info.version,
|
||||
&index.info.title,
|
||||
"json",
|
||||
Some("\"etag-v1\"".to_string()),
|
||||
Some("Wed, 21 Oct 2025 07:28:00 GMT".to_string()),
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_diff_detects_new_endpoint_in_index() {
|
||||
let spec_v1 = serde_json::json!({
|
||||
"openapi": "3.0.3",
|
||||
"info": { "title": "Test", "version": "1.0.0" },
|
||||
"paths": {
|
||||
"/pets": {
|
||||
"get": {
|
||||
"summary": "List pets",
|
||||
"responses": { "200": { "description": "OK" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let spec_v2 = serde_json::json!({
|
||||
"openapi": "3.0.3",
|
||||
"info": { "title": "Test", "version": "2.0.0" },
|
||||
"paths": {
|
||||
"/pets": {
|
||||
"get": {
|
||||
"summary": "List pets",
|
||||
"responses": { "200": { "description": "OK" } }
|
||||
},
|
||||
"post": {
|
||||
"summary": "Create pet",
|
||||
"responses": { "201": { "description": "Created" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let raw_v1 = serde_json::to_vec(&spec_v1).unwrap();
|
||||
let raw_v2 = serde_json::to_vec(&spec_v2).unwrap();
|
||||
let hash_v1 = compute_hash(&raw_v1);
|
||||
let hash_v2 = compute_hash(&raw_v2);
|
||||
|
||||
let index_v1 = build_index(&spec_v1, &hash_v1, 1).unwrap();
|
||||
let index_v2 = build_index(&spec_v2, &hash_v2, 2).unwrap();
|
||||
|
||||
let (summary, details) = compute_diff(&index_v1, &index_v2);
|
||||
assert_eq!(summary.endpoints_added, 1);
|
||||
assert_eq!(summary.endpoints_removed, 0);
|
||||
assert_eq!(summary.endpoints_modified, 0);
|
||||
assert_eq!(details.endpoints.added.len(), 1);
|
||||
assert_eq!(details.endpoints.added[0].path, "/pets");
|
||||
assert_eq!(details.endpoints.added[0].method, "POST");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_diff_complex_scenario() {
|
||||
// Old: GET /pets, GET /pets/{id}, DELETE /pets/{id}, schemas: Pet, Error
|
||||
// New: GET /pets (modified summary), POST /pets, GET /pets/{id}, schemas: Pet, Owner
|
||||
let old = make_test_index(
|
||||
vec![
|
||||
make_endpoint("/pets", "GET", Some("List pets")),
|
||||
make_endpoint("/pets/{id}", "GET", Some("Get pet")),
|
||||
make_endpoint("/pets/{id}", "DELETE", Some("Delete pet")),
|
||||
],
|
||||
vec![make_schema("Pet"), make_schema("Error")],
|
||||
);
|
||||
|
||||
let new = make_test_index(
|
||||
vec![
|
||||
make_endpoint("/pets", "GET", Some("List all pets")), // modified summary
|
||||
make_endpoint("/pets", "POST", Some("Create pet")), // added
|
||||
make_endpoint("/pets/{id}", "GET", Some("Get pet")), // unchanged
|
||||
],
|
||||
vec![make_schema("Pet"), make_schema("Owner")], // Error removed, Owner added
|
||||
);
|
||||
|
||||
let (summary, details) = compute_diff(&old, &new);
|
||||
|
||||
assert_eq!(summary.endpoints_added, 1); // POST /pets
|
||||
assert_eq!(summary.endpoints_removed, 1); // DELETE /pets/{id}
|
||||
assert_eq!(summary.endpoints_modified, 1); // GET /pets summary changed
|
||||
assert_eq!(summary.schemas_added, 1); // Owner
|
||||
assert_eq!(summary.schemas_removed, 1); // Error
|
||||
|
||||
assert_eq!(details.endpoints.added[0].method, "POST");
|
||||
assert_eq!(details.endpoints.removed[0].method, "DELETE");
|
||||
assert_eq!(details.endpoints.modified[0].path, "/pets");
|
||||
assert_eq!(details.schemas.added, vec!["Owner"]);
|
||||
assert_eq!(details.schemas.removed, vec!["Error"]);
|
||||
assert!(!details.truncated);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user