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:
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
194
src/core/network.rs
Normal 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(_)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user