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,
}
}
}

View File

@@ -2,6 +2,7 @@ pub mod cache;
pub mod config;
pub mod http;
pub mod indexer;
pub mod network;
pub mod refs;
pub mod search;
pub mod spec;

194
src/core/network.rs Normal file
View File

@@ -0,0 +1,194 @@
use std::fmt;
use std::str::FromStr;
use crate::errors::SwaggerCliError;
/// Global network access policy.
///
/// Controls whether swagger-cli makes outbound HTTP requests.
/// Parsed from `--network` CLI flag or `SWAGGER_CLI_NETWORK` env var.
/// Flag takes precedence over env var.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum NetworkPolicy {
/// Allow network calls when needed (default). No restrictions.
#[default]
Auto,
/// Block ALL outbound network calls preemptively. Fetch/sync for remote
/// URLs return `OfflineMode` without attempting the request. Local files
/// and stdin are unaffected. Index-only commands (list, search, show,
/// tags, schemas, aliases, doctor, cache) work normally.
Offline,
/// Allow network calls but surface a distinct error when the network is
/// unreachable. Differs from `Offline` in that it attempts the request
/// before failing, allowing agents to distinguish "blocked by policy"
/// from "network actually down."
OnlineOnly,
}
impl fmt::Display for NetworkPolicy {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Auto => write!(f, "auto"),
Self::Offline => write!(f, "offline"),
Self::OnlineOnly => write!(f, "online-only"),
}
}
}
impl FromStr for NetworkPolicy {
type Err = SwaggerCliError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_ascii_lowercase().as_str() {
"auto" => Ok(Self::Auto),
"offline" => Ok(Self::Offline),
"online-only" | "online_only" | "onlineonly" => Ok(Self::OnlineOnly),
other => Err(SwaggerCliError::Usage(format!(
"invalid network policy '{other}'. Valid options: auto, offline, online-only"
))),
}
}
}
/// Resolve the effective network policy from CLI flag and env var.
///
/// Precedence: CLI flag (if not "auto" or explicitly set) > env var > default (auto).
pub fn resolve_policy(cli_value: &str) -> Result<NetworkPolicy, SwaggerCliError> {
// CLI flag takes precedence
let from_flag = NetworkPolicy::from_str(cli_value)?;
if from_flag != NetworkPolicy::Auto {
return Ok(from_flag);
}
// Check env var
if let Ok(env_val) = std::env::var("SWAGGER_CLI_NETWORK") {
return NetworkPolicy::from_str(&env_val);
}
Ok(NetworkPolicy::Auto)
}
/// Check whether a remote fetch is allowed under the current policy.
///
/// Returns `Ok(())` if the fetch may proceed, or `Err(OfflineMode)` if blocked.
/// This is called before any HTTP request for remote URLs.
/// Local file and stdin sources bypass this check entirely.
pub fn check_remote_fetch(policy: NetworkPolicy) -> Result<(), SwaggerCliError> {
if policy == NetworkPolicy::Offline {
return Err(SwaggerCliError::OfflineMode(
"network access is disabled (--network offline). \
Remote URLs cannot be fetched in offline mode."
.into(),
));
}
Ok(())
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_auto() {
assert_eq!(
NetworkPolicy::from_str("auto").unwrap(),
NetworkPolicy::Auto
);
assert_eq!(
NetworkPolicy::from_str("AUTO").unwrap(),
NetworkPolicy::Auto
);
assert_eq!(
NetworkPolicy::from_str("Auto").unwrap(),
NetworkPolicy::Auto
);
}
#[test]
fn test_parse_offline() {
assert_eq!(
NetworkPolicy::from_str("offline").unwrap(),
NetworkPolicy::Offline
);
assert_eq!(
NetworkPolicy::from_str("OFFLINE").unwrap(),
NetworkPolicy::Offline
);
}
#[test]
fn test_parse_online_only() {
assert_eq!(
NetworkPolicy::from_str("online-only").unwrap(),
NetworkPolicy::OnlineOnly
);
assert_eq!(
NetworkPolicy::from_str("online_only").unwrap(),
NetworkPolicy::OnlineOnly
);
assert_eq!(
NetworkPolicy::from_str("onlineonly").unwrap(),
NetworkPolicy::OnlineOnly
);
assert_eq!(
NetworkPolicy::from_str("ONLINE-ONLY").unwrap(),
NetworkPolicy::OnlineOnly
);
}
#[test]
fn test_parse_invalid() {
let err = NetworkPolicy::from_str("bogus").unwrap_err();
assert!(matches!(err, SwaggerCliError::Usage(_)));
}
#[test]
fn test_display() {
assert_eq!(NetworkPolicy::Auto.to_string(), "auto");
assert_eq!(NetworkPolicy::Offline.to_string(), "offline");
assert_eq!(NetworkPolicy::OnlineOnly.to_string(), "online-only");
}
#[test]
fn test_check_remote_fetch_auto_allowed() {
assert!(check_remote_fetch(NetworkPolicy::Auto).is_ok());
}
#[test]
fn test_check_remote_fetch_online_only_allowed() {
assert!(check_remote_fetch(NetworkPolicy::OnlineOnly).is_ok());
}
#[test]
fn test_check_remote_fetch_offline_blocked() {
let err = check_remote_fetch(NetworkPolicy::Offline).unwrap_err();
assert!(matches!(err, SwaggerCliError::OfflineMode(_)));
}
#[test]
fn test_resolve_policy_flag_offline() {
// When flag is explicitly "offline", it takes precedence regardless of env
assert_eq!(resolve_policy("offline").unwrap(), NetworkPolicy::Offline);
}
#[test]
fn test_resolve_policy_flag_online_only() {
// When flag is explicitly "online-only", it takes precedence
assert_eq!(
resolve_policy("online-only").unwrap(),
NetworkPolicy::OnlineOnly
);
}
#[test]
fn test_resolve_policy_invalid_flag() {
let err = resolve_policy("bogus").unwrap_err();
assert!(matches!(err, SwaggerCliError::Usage(_)));
}
}