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:
teernisse
2026-02-12 14:36:22 -05:00
parent faa6281790
commit 346fef9135
11 changed files with 2051 additions and 31 deletions

View File

@@ -4,6 +4,7 @@ use std::time::Duration;
use reqwest::{StatusCode, Url};
use tokio::net::lookup_host;
use crate::core::network::{NetworkPolicy, check_remote_fetch};
use crate::errors::SwaggerCliError;
const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
@@ -141,6 +142,15 @@ pub struct FetchResult {
pub last_modified: Option<String>,
}
/// Result of a conditional fetch (If-None-Match / If-Modified-Since).
#[derive(Debug, Clone)]
pub enum ConditionalFetchResult {
/// Server returned 304 Not Modified -- cached content is still current.
NotModified,
/// Server returned new content.
Modified(FetchResult),
}
// ---------------------------------------------------------------------------
// AsyncHttpClient builder
// ---------------------------------------------------------------------------
@@ -153,6 +163,7 @@ pub struct AsyncHttpClient {
allow_insecure_http: bool,
allowed_private_hosts: Vec<String>,
auth_headers: Vec<(String, String)>,
network_policy: NetworkPolicy,
}
impl Default for AsyncHttpClient {
@@ -165,6 +176,7 @@ impl Default for AsyncHttpClient {
allow_insecure_http: false,
allowed_private_hosts: Vec::new(),
auth_headers: Vec::new(),
network_policy: NetworkPolicy::Auto,
}
}
}
@@ -174,7 +186,85 @@ impl AsyncHttpClient {
AsyncHttpClientBuilder::default()
}
/// Fetch a spec with conditional request headers.
///
/// Sends If-None-Match (for ETag) and If-Modified-Since (for Last-Modified)
/// when provided. Returns `NotModified` on 304, `Modified` on 200.
pub async fn fetch_conditional(
&self,
url: &str,
etag: Option<&str>,
last_modified: Option<&str>,
) -> Result<ConditionalFetchResult, SwaggerCliError> {
let parsed = validate_url(url, self.allow_insecure_http)?;
let host = parsed
.host_str()
.ok_or_else(|| SwaggerCliError::InvalidSpec(format!("URL '{url}' has no host")))?;
let port = parsed.port_or_known_default().unwrap_or(443);
resolve_and_check(host, port, &self.allowed_private_hosts).await?;
let client = self.build_reqwest_client()?;
let mut attempts = 0u32;
loop {
let mut request = client.get(parsed.clone());
for (name, value) in &self.auth_headers {
request = request.header(name.as_str(), value.as_str());
}
if let Some(etag_val) = etag {
request = request.header(reqwest::header::IF_NONE_MATCH, etag_val);
}
if let Some(lm_val) = last_modified {
request = request.header(reqwest::header::IF_MODIFIED_SINCE, lm_val);
}
let response = request.send().await.map_err(SwaggerCliError::Network)?;
let status = response.status();
match status {
StatusCode::NOT_MODIFIED => {
return Ok(ConditionalFetchResult::NotModified);
}
s if s.is_success() => {
let result = self.read_response(response).await?;
return Ok(ConditionalFetchResult::Modified(result));
}
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
return Err(SwaggerCliError::Auth(format!(
"server returned {status} for '{url}'"
)));
}
StatusCode::NOT_FOUND => {
return Err(SwaggerCliError::InvalidSpec(format!(
"spec not found at '{url}' (404)"
)));
}
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(),
));
}
let delay = self.retry_delay(&response, attempts);
tokio::time::sleep(delay).await;
}
_ => {
return Err(SwaggerCliError::InvalidSpec(format!(
"unexpected status {status} fetching '{url}'"
)));
}
}
}
}
pub async fn fetch_spec(&self, url: &str) -> Result<FetchResult, 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
@@ -300,6 +390,7 @@ pub struct AsyncHttpClientBuilder {
allow_insecure_http: bool,
allowed_private_hosts: Vec<String>,
auth_headers: Vec<(String, String)>,
network_policy: NetworkPolicy,
}
impl AsyncHttpClientBuilder {
@@ -338,6 +429,11 @@ impl AsyncHttpClientBuilder {
self
}
pub fn network_policy(mut self, policy: NetworkPolicy) -> Self {
self.network_policy = policy;
self
}
pub fn build(self) -> AsyncHttpClient {
AsyncHttpClient {
connect_timeout: self.connect_timeout.unwrap_or(DEFAULT_CONNECT_TIMEOUT),
@@ -347,6 +443,7 @@ impl AsyncHttpClientBuilder {
allow_insecure_http: self.allow_insecure_http,
allowed_private_hosts: self.allowed_private_hosts,
auth_headers: self.auth_headers,
network_policy: self.network_policy,
}
}
}