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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user