Wave 2: CLI skeleton, cache write, config system, spec indexer (bd-3d2, bd-1ie, bd-1sb, bd-189)

This commit is contained in:
teernisse
2026-02-12 12:41:18 -05:00
parent 8289d3b89f
commit deb2794136
25 changed files with 2008 additions and 36 deletions

View File

@@ -1,5 +1,18 @@
use std::fs::{self, File, OpenOptions};
use std::io::Write;
use std::path::PathBuf;
use std::thread;
use std::time::{Duration, Instant};
use chrono::{DateTime, Utc};
use fs2::FileExt;
use regex::Regex;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::errors::SwaggerCliError;
use super::spec::SpecIndex;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheMetadata {
@@ -29,11 +42,247 @@ impl CacheMetadata {
}
}
/// Validate an alias string for use as a cache directory name.
///
/// Accepts: alphanumeric start, then alphanumeric/dot/dash/underscore, 1-64 chars.
/// Rejects: path separators, `..`, leading dots, Windows reserved device names.
pub fn validate_alias(alias: &str) -> Result<(), SwaggerCliError> {
let pattern = Regex::new(r"^[A-Za-z0-9][A-Za-z0-9._\-]{0,63}$").expect("valid regex");
if !pattern.is_match(alias) {
return Err(SwaggerCliError::Usage(format!(
"Invalid alias '{alias}': must be 1-64 chars, start with alphanumeric, \
contain only alphanumeric/dot/dash/underscore"
)));
}
if alias.contains('/') || alias.contains('\\') {
return Err(SwaggerCliError::Usage(format!(
"Invalid alias '{alias}': path separators not allowed"
)));
}
if alias.contains("..") {
return Err(SwaggerCliError::Usage(format!(
"Invalid alias '{alias}': directory traversal not allowed"
)));
}
if alias.starts_with('.') {
return Err(SwaggerCliError::Usage(format!(
"Invalid alias '{alias}': leading dot not allowed"
)));
}
let stem = alias.split('.').next().unwrap_or(alias);
let reserved = [
"CON", "PRN", "NUL", "AUX", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
"COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
];
if reserved.iter().any(|r| r.eq_ignore_ascii_case(stem)) {
return Err(SwaggerCliError::Usage(format!(
"Invalid alias '{alias}': reserved device name"
)));
}
Ok(())
}
/// Compute a SHA-256 hash of the given bytes, returning "sha256:{hex}".
pub fn compute_hash(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
let result = hasher.finalize();
format!("sha256:{:x}", result)
}
/// Manages the on-disk cache directory structure and write protocol.
pub struct CacheManager {
cache_dir: PathBuf,
}
const LOCK_TIMEOUT_MS: u64 = 1000;
const LOCK_POLL_MS: u64 = 50;
impl CacheManager {
pub fn new(cache_dir: PathBuf) -> Self {
Self { cache_dir }
}
/// Return the directory for a given alias within the cache.
pub fn alias_dir(&self, alias: &str) -> PathBuf {
self.cache_dir.join(alias)
}
/// Ensure the cache root and alias subdirectory exist.
pub fn ensure_dirs(&self, alias: &str) -> Result<(), SwaggerCliError> {
let dir = self.alias_dir(alias);
fs::create_dir_all(&dir).map_err(|e| {
SwaggerCliError::Cache(format!(
"Failed to create cache directory {}: {e}",
dir.display()
))
})?;
Ok(())
}
/// Acquire an exclusive file lock on `{alias_dir}/.lock` with bounded timeout.
fn acquire_lock(&self, alias: &str) -> Result<File, SwaggerCliError> {
let lock_path = self.alias_dir(alias).join(".lock");
let lock_file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&lock_path)
.map_err(|e| {
SwaggerCliError::Cache(format!(
"Failed to open lock file {}: {e}",
lock_path.display()
))
})?;
let deadline = Instant::now() + Duration::from_millis(LOCK_TIMEOUT_MS);
loop {
match lock_file.try_lock_exclusive() {
Ok(()) => return Ok(lock_file),
Err(_) if Instant::now() >= deadline => {
return Err(SwaggerCliError::CacheLocked(alias.to_string()));
}
Err(_) => {
thread::sleep(Duration::from_millis(LOCK_POLL_MS));
}
}
}
}
/// Write spec data to cache using a crash-consistent protocol.
///
/// Each file is written to a `.tmp` suffix, fsynced, then renamed atomically.
/// `meta.json` is written last as the commit marker.
#[allow(clippy::too_many_arguments)]
pub fn write_cache(
&self,
alias: &str,
raw_source_bytes: &[u8],
raw_json_bytes: &[u8],
index: &SpecIndex,
url: Option<String>,
spec_version: &str,
spec_title: &str,
source_format: &str,
etag: Option<String>,
last_modified: Option<String>,
previous_generation: Option<u64>,
) -> Result<CacheMetadata, SwaggerCliError> {
validate_alias(alias)?;
self.ensure_dirs(alias)?;
let _lock = self.acquire_lock(alias)?;
let dir = self.alias_dir(alias);
let content_hash = compute_hash(raw_source_bytes);
let raw_hash = compute_hash(raw_json_bytes);
let generation = previous_generation.map_or(1, |g| g + 1);
let index_bytes =
serde_json::to_vec_pretty(index).map_err(|e| SwaggerCliError::Cache(e.to_string()))?;
let index_hash = compute_hash(&index_bytes);
// Phase 1: Write each file to .tmp, fsync, rename
write_atomic(&dir.join("raw.source"), raw_source_bytes)?;
write_atomic(&dir.join("raw.json"), raw_json_bytes)?;
write_atomic(&dir.join("index.json"), &index_bytes)?;
// Phase 2: Write meta.json LAST as commit marker
let now = Utc::now();
let meta = CacheMetadata {
alias: alias.to_string(),
url,
fetched_at: now,
last_accessed: now,
content_hash,
raw_hash,
etag,
last_modified,
spec_version: spec_version.to_string(),
spec_title: spec_title.to_string(),
endpoint_count: index.endpoints.len(),
schema_count: index.schemas.len(),
raw_size_bytes: raw_source_bytes.len() as u64,
source_format: source_format.to_string(),
index_version: index.index_version,
generation,
index_hash,
};
let meta_bytes =
serde_json::to_vec_pretty(&meta).map_err(|e| SwaggerCliError::Cache(e.to_string()))?;
write_atomic(&dir.join("meta.json"), &meta_bytes)?;
// Best-effort directory fsync (Unix only)
#[cfg(unix)]
{
if let Ok(dir_fd) = File::open(&dir) {
let _ = dir_fd.sync_all();
}
}
Ok(meta)
}
}
/// Write `data` to `path.tmp`, fsync, then rename to `path`.
fn write_atomic(path: &std::path::Path, data: &[u8]) -> Result<(), SwaggerCliError> {
let tmp_path = path.with_extension(format!(
"{}.tmp",
path.extension()
.map_or("".into(), |e| e.to_string_lossy().into_owned())
));
let mut file = File::create(&tmp_path).map_err(|e| {
SwaggerCliError::Cache(format!("Failed to create {}: {e}", tmp_path.display()))
})?;
file.write_all(data).map_err(|e| {
SwaggerCliError::Cache(format!("Failed to write {}: {e}", tmp_path.display()))
})?;
file.sync_all().map_err(|e| {
SwaggerCliError::Cache(format!("Failed to sync {}: {e}", tmp_path.display()))
})?;
fs::rename(&tmp_path, path).map_err(|e| {
SwaggerCliError::Cache(format!(
"Failed to rename {} -> {}: {e}",
tmp_path.display(),
path.display()
))
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
fn make_test_index() -> SpecIndex {
SpecIndex {
index_version: 1,
generation: 1,
content_hash: "sha256:test".into(),
openapi: "3.0.3".into(),
info: super::super::spec::IndexInfo {
title: "Test".into(),
version: "1.0.0".into(),
},
endpoints: vec![],
schemas: vec![],
tags: vec![],
}
}
#[test]
fn test_cache_metadata_serialization_roundtrip() {
let meta = CacheMetadata {
@@ -109,4 +358,79 @@ mod tests {
};
assert!(meta.is_stale(30));
}
#[test]
fn test_validate_alias_accepts_valid() {
assert!(validate_alias("petstore").is_ok());
assert!(validate_alias("my-api").is_ok());
assert!(validate_alias("v1.0").is_ok());
assert!(validate_alias("API_2").is_ok());
}
#[test]
fn test_validate_alias_rejects_traversal() {
assert!(validate_alias("../etc").is_err());
assert!(validate_alias(".hidden").is_err());
assert!(validate_alias("/etc").is_err());
assert!(validate_alias("a\\b").is_err());
}
#[test]
fn test_validate_alias_rejects_reserved() {
assert!(validate_alias("CON").is_err());
assert!(validate_alias("con").is_err());
assert!(validate_alias("NUL").is_err());
assert!(validate_alias("COM1").is_err());
assert!(validate_alias("LPT1").is_err());
}
#[test]
fn test_validate_alias_rejects_too_long() {
let long_alias = "a".repeat(65);
assert!(validate_alias(&long_alias).is_err());
}
#[test]
fn test_compute_hash_deterministic() {
let data = b"hello world";
let h1 = compute_hash(data);
let h2 = compute_hash(data);
assert_eq!(h1, h2);
assert!(h1.starts_with("sha256:"));
assert_eq!(h1.len(), 7 + 64); // "sha256:" + 64 hex chars
}
#[test]
fn test_write_cache_creates_all_files() {
let tmp = tempfile::tempdir().unwrap();
let manager = CacheManager::new(tmp.path().to_path_buf());
let index = make_test_index();
let meta = manager
.write_cache(
"testapi",
b"openapi: 3.0.3",
b"{\"openapi\":\"3.0.3\"}",
&index,
Some("https://example.com/api.json".into()),
"1.0.0",
"Test API",
"yaml",
None,
None,
None,
)
.unwrap();
let alias_dir = tmp.path().join("testapi");
assert!(alias_dir.join("raw.source").exists());
assert!(alias_dir.join("raw.json").exists());
assert!(alias_dir.join("index.json").exists());
assert!(alias_dir.join("meta.json").exists());
assert_eq!(meta.alias, "testapi");
assert_eq!(meta.generation, 1);
assert_eq!(meta.source_format, "yaml");
assert!(meta.content_hash.starts_with("sha256:"));
}
}

247
src/core/config.rs Normal file
View File

@@ -0,0 +1,247 @@
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use crate::errors::SwaggerCliError;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub default_alias: Option<String>,
#[serde(default = "default_stale_threshold_days")]
pub stale_threshold_days: u32,
#[serde(default)]
pub auth_profiles: BTreeMap<String, AuthConfig>,
#[serde(default)]
pub display: DisplayConfig,
}
impl Default for Config {
fn default() -> Self {
Self {
default_alias: None,
stale_threshold_days: default_stale_threshold_days(),
auth_profiles: BTreeMap::new(),
display: DisplayConfig::default(),
}
}
}
fn default_stale_threshold_days() -> u32 {
30
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthConfig {
pub auth_type: AuthType,
pub credential: CredentialSource,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum AuthType {
Bearer,
ApiKey { header: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "source")]
pub enum CredentialSource {
Literal { value: String },
EnvVar { name: String },
Keyring { service: String, account: String },
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DisplayConfig {
#[serde(default)]
pub color: Option<bool>,
#[serde(default)]
pub unicode: Option<bool>,
}
/// Resolve the config file path.
///
/// Precedence: cli_override > SWAGGER_CLI_CONFIG > SWAGGER_CLI_HOME/config/config.toml > XDG
pub fn config_path(cli_override: Option<&Path>) -> PathBuf {
if let Some(p) = cli_override {
return p.to_path_buf();
}
if let Ok(p) = std::env::var("SWAGGER_CLI_CONFIG") {
return PathBuf::from(p);
}
if let Ok(home) = std::env::var("SWAGGER_CLI_HOME") {
return PathBuf::from(home).join("config").join("config.toml");
}
if let Some(dirs) = ProjectDirs::from("", "", "swagger-cli") {
return dirs.config_dir().join("config.toml");
}
PathBuf::from("config.toml")
}
/// Resolve the cache directory.
///
/// Precedence: SWAGGER_CLI_CACHE > SWAGGER_CLI_HOME/cache > XDG cache dir
pub fn cache_dir() -> PathBuf {
if let Ok(p) = std::env::var("SWAGGER_CLI_CACHE") {
return PathBuf::from(p);
}
if let Ok(home) = std::env::var("SWAGGER_CLI_HOME") {
return PathBuf::from(home).join("cache");
}
if let Some(dirs) = ProjectDirs::from("", "", "swagger-cli") {
return dirs.cache_dir().to_path_buf();
}
PathBuf::from("cache")
}
impl Config {
pub fn load(path: &Path) -> Result<Config, SwaggerCliError> {
match std::fs::read_to_string(path) {
Ok(contents) => {
toml::from_str(&contents).map_err(|e| SwaggerCliError::Config(e.to_string()))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Config::default()),
Err(e) => Err(SwaggerCliError::Config(format!(
"failed to read {}: {e}",
path.display()
))),
}
}
pub fn save(&self, path: &Path) -> Result<(), SwaggerCliError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
SwaggerCliError::Config(format!(
"failed to create directory {}: {e}",
parent.display()
))
})?;
}
let contents =
toml::to_string_pretty(self).map_err(|e| SwaggerCliError::Config(e.to_string()))?;
std::fs::write(path, contents).map_err(|e| {
SwaggerCliError::Config(format!("failed to write {}: {e}", path.display()))
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_config_default_values() {
let config = Config::default();
assert_eq!(config.stale_threshold_days, 30);
assert!(config.auth_profiles.is_empty());
assert!(config.default_alias.is_none());
assert!(config.display.color.is_none());
assert!(config.display.unicode.is_none());
}
#[test]
fn test_config_roundtrip() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
let mut config = Config {
default_alias: Some("petstore".into()),
stale_threshold_days: 14,
..Config::default()
};
config.auth_profiles.insert(
"prod".into(),
AuthConfig {
auth_type: AuthType::Bearer,
credential: CredentialSource::EnvVar {
name: "API_TOKEN".into(),
},
},
);
config.auth_profiles.insert(
"staging".into(),
AuthConfig {
auth_type: AuthType::ApiKey {
header: "X-Api-Key".into(),
},
credential: CredentialSource::Keyring {
service: "swagger-cli".into(),
account: "staging".into(),
},
},
);
config.display.color = Some(true);
config.display.unicode = Some(false);
config.save(&path).unwrap();
let loaded = Config::load(&path).unwrap();
assert_eq!(loaded.default_alias, config.default_alias);
assert_eq!(loaded.stale_threshold_days, config.stale_threshold_days);
assert_eq!(loaded.auth_profiles.len(), config.auth_profiles.len());
assert!(loaded.auth_profiles.contains_key("prod"));
assert!(loaded.auth_profiles.contains_key("staging"));
assert_eq!(loaded.display.color, Some(true));
assert_eq!(loaded.display.unicode, Some(false));
}
#[test]
fn test_config_path_precedence() {
let tmp = TempDir::new().unwrap();
// CLI override takes highest precedence over all env-based resolution
let override_path = tmp.path().join("override.toml");
let result = config_path(Some(&override_path));
assert_eq!(result, override_path);
// Without CLI override, result ends with config.toml (from env or XDG)
let result = config_path(None);
assert!(
result.to_string_lossy().ends_with("config.toml"),
"expected path ending in config.toml, got: {}",
result.display()
);
}
#[test]
fn test_config_load_missing_returns_default() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("nonexistent").join("config.toml");
let config = Config::load(&path).unwrap();
assert_eq!(config.stale_threshold_days, 30);
assert!(config.auth_profiles.is_empty());
}
#[test]
fn test_credential_source_serde() {
let source = CredentialSource::EnvVar {
name: "MY_TOKEN".into(),
};
let serialized = toml::to_string(&source).unwrap();
assert!(serialized.contains("source"));
assert!(serialized.contains("EnvVar"));
let deserialized: CredentialSource = toml::from_str(&serialized).unwrap();
match deserialized {
CredentialSource::EnvVar { name } => assert_eq!(name, "MY_TOKEN"),
_ => panic!("expected EnvVar variant"),
}
}
}

648
src/core/indexer.rs Normal file
View File

@@ -0,0 +1,648 @@
use std::collections::HashMap;
use crate::core::spec::{
IndexInfo, IndexedEndpoint, IndexedParam, IndexedSchema, IndexedTag, SpecIndex,
};
use crate::errors::SwaggerCliError;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Format {
Json,
Yaml,
}
/// Detect whether raw bytes are JSON or YAML.
///
/// Priority: content-type header > file extension > content sniffing.
pub fn detect_format(
bytes: &[u8],
filename_hint: Option<&str>,
content_type_hint: Option<&str>,
) -> Format {
if let Some(ct) = content_type_hint {
let ct_lower = ct.to_ascii_lowercase();
if ct_lower.contains("json") {
return Format::Json;
}
if ct_lower.contains("yaml") || ct_lower.contains("yml") {
return Format::Yaml;
}
}
if let Some(name) = filename_hint {
let name_lower = name.to_ascii_lowercase();
if name_lower.ends_with(".json") {
return Format::Json;
}
if name_lower.ends_with(".yaml") || name_lower.ends_with(".yml") {
return Format::Yaml;
}
}
// Content sniffing: try JSON first (stricter), fall back to YAML.
if serde_json::from_slice::<serde_json::Value>(bytes).is_ok() {
Format::Json
} else {
Format::Yaml
}
}
/// If the input is YAML, parse then re-serialize as JSON.
/// If JSON, validate it parses.
pub fn normalize_to_json(bytes: &[u8], format: Format) -> Result<Vec<u8>, SwaggerCliError> {
match format {
Format::Json => {
let _: serde_json::Value = serde_json::from_slice(bytes)?;
Ok(bytes.to_vec())
}
Format::Yaml => {
let value: serde_json::Value = serde_yaml::from_slice(bytes)
.map_err(|e| SwaggerCliError::InvalidSpec(format!("YAML parse error: {e}")))?;
let json_bytes = serde_json::to_vec(&value)?;
Ok(json_bytes)
}
}
}
/// Build a `SpecIndex` from a parsed JSON OpenAPI document.
pub fn build_index(
raw_json: &serde_json::Value,
content_hash: &str,
generation: u64,
) -> Result<SpecIndex, SwaggerCliError> {
let openapi = raw_json
.get("openapi")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let info_obj = raw_json.get("info");
let title = info_obj
.and_then(|i| i.get("title"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let version = info_obj
.and_then(|i| i.get("version"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
// Root-level security schemes (names only).
let root_security = extract_security_scheme_names(raw_json.get("security"));
let mut endpoints = Vec::new();
let mut tag_counts: HashMap<String, usize> = HashMap::new();
if let Some(paths) = raw_json.get("paths").and_then(|p| p.as_object()) {
for (path, path_item) in paths {
let path_obj = match path_item.as_object() {
Some(o) => o,
None => continue,
};
// Path-level parameters apply to all operations under this path.
let path_params = path_obj
.get("parameters")
.and_then(|v| v.as_array())
.map(|arr| extract_params(arr))
.unwrap_or_default();
for (method, operation) in path_obj {
if !is_http_method(method) {
continue;
}
let op = match operation.as_object() {
Some(o) => o,
None => continue,
};
let method_upper = method.to_ascii_uppercase();
let path_encoded = json_pointer_encode(path);
let method_lower = method.to_ascii_lowercase();
let operation_ptr = format!("/paths/{path_encoded}/{method_lower}");
// Merge path-level + operation-level parameters (operation wins on conflict).
let op_params = op
.get("parameters")
.and_then(|v| v.as_array())
.map(|arr| extract_params(arr))
.unwrap_or_default();
let parameters = merge_params(&path_params, &op_params);
let tags: Vec<String> = op
.get("tags")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|t| t.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
for tag in &tags {
*tag_counts.entry(tag.clone()).or_insert(0) += 1;
}
let deprecated = op
.get("deprecated")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let summary = op.get("summary").and_then(|v| v.as_str()).map(String::from);
let description = op
.get("description")
.and_then(|v| v.as_str())
.map(String::from);
let operation_id = op
.get("operationId")
.and_then(|v| v.as_str())
.map(String::from);
let (request_body_required, request_body_content_types) =
extract_request_body(op.get("requestBody"));
// Security: operation-level overrides root. An explicit empty array
// means "no auth required".
let (security_schemes, security_required) = if let Some(op_sec) = op.get("security")
{
let schemes = extract_security_scheme_names(Some(op_sec));
let required = !schemes.is_empty();
(schemes, required)
} else {
let required = !root_security.is_empty();
(root_security.clone(), required)
};
if !resolve_pointer(raw_json, &operation_ptr) {
return Err(SwaggerCliError::InvalidSpec(format!(
"JSON pointer does not resolve: {operation_ptr}"
)));
}
endpoints.push(IndexedEndpoint {
path: path.clone(),
method: method_upper,
summary,
description,
operation_id,
tags,
deprecated,
parameters,
request_body_required,
request_body_content_types,
security_schemes,
security_required,
operation_ptr,
});
}
}
}
// Sort endpoints: path ASC then method rank ASC.
endpoints.sort_by(|a, b| {
a.path
.cmp(&b.path)
.then_with(|| method_rank(&a.method).cmp(&method_rank(&b.method)))
});
// Schemas from components.schemas.
let mut schemas: Vec<IndexedSchema> = Vec::new();
if let Some(components_schemas) = raw_json
.pointer("/components/schemas")
.and_then(|v| v.as_object())
{
for name in components_schemas.keys() {
let schema_ptr = format!("/components/schemas/{}", json_pointer_encode(name));
if !resolve_pointer(raw_json, &schema_ptr) {
return Err(SwaggerCliError::InvalidSpec(format!(
"JSON pointer does not resolve: {schema_ptr}"
)));
}
schemas.push(IndexedSchema {
name: name.clone(),
schema_ptr,
});
}
}
schemas.sort_by(|a, b| a.name.cmp(&b.name));
// Collect tag descriptions from the top-level `tags` array (if present).
let mut tag_descriptions: HashMap<String, Option<String>> = HashMap::new();
if let Some(tags_arr) = raw_json.get("tags").and_then(|v| v.as_array()) {
for tag_obj in tags_arr {
if let Some(name) = tag_obj.get("name").and_then(|v| v.as_str()) {
let desc = tag_obj
.get("description")
.and_then(|v| v.as_str())
.map(String::from);
tag_descriptions.insert(name.to_string(), desc);
}
}
}
let mut tags: Vec<IndexedTag> = tag_counts
.into_iter()
.map(|(name, count)| {
let description = tag_descriptions.get(&name).cloned().unwrap_or(None);
IndexedTag {
name,
description,
endpoint_count: count,
}
})
.collect();
tags.sort_by(|a, b| a.name.cmp(&b.name));
Ok(SpecIndex {
index_version: 1,
generation,
content_hash: content_hash.to_string(),
openapi,
info: IndexInfo { title, version },
endpoints,
schemas,
tags,
})
}
/// Return the sort rank for an HTTP method.
pub fn method_rank(method: &str) -> u8 {
match method.to_ascii_uppercase().as_str() {
"GET" => 0,
"POST" => 1,
"PUT" => 2,
"PATCH" => 3,
"DELETE" => 4,
"OPTIONS" => 5,
"HEAD" => 6,
"TRACE" => 7,
_ => 99,
}
}
/// RFC 6901 JSON pointer encoding for a single segment: `~` -> `~0`, `/` -> `~1`.
pub fn json_pointer_encode(segment: &str) -> String {
segment.replace('~', "~0").replace('/', "~1")
}
/// Check whether a JSON pointer resolves within `value`.
pub fn resolve_pointer(value: &serde_json::Value, pointer: &str) -> bool {
value.pointer(pointer).is_some()
}
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
fn is_http_method(key: &str) -> bool {
matches!(
key.to_ascii_lowercase().as_str(),
"get" | "post" | "put" | "patch" | "delete" | "options" | "head" | "trace"
)
}
fn extract_params(arr: &[serde_json::Value]) -> Vec<IndexedParam> {
arr.iter()
.filter_map(|p| {
let name = p.get("name")?.as_str()?.to_string();
let location = p.get("in")?.as_str()?.to_string();
let required = p.get("required").and_then(|v| v.as_bool()).unwrap_or(false);
let description = p
.get("description")
.and_then(|v| v.as_str())
.map(String::from);
Some(IndexedParam {
name,
location,
required,
description,
})
})
.collect()
}
/// Merge path-level and operation-level parameters. Operation params override
/// path params with the same (name, location) pair.
fn merge_params(path_params: &[IndexedParam], op_params: &[IndexedParam]) -> Vec<IndexedParam> {
let mut merged: Vec<IndexedParam> = path_params.to_vec();
for op_p in op_params {
if let Some(existing) = merged
.iter_mut()
.find(|p| p.name == op_p.name && p.location == op_p.location)
{
*existing = op_p.clone();
} else {
merged.push(op_p.clone());
}
}
merged
}
fn extract_request_body(rb: Option<&serde_json::Value>) -> (bool, Vec<String>) {
let Some(rb) = rb else {
return (false, Vec::new());
};
let required = rb
.get("required")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let content_types = rb
.get("content")
.and_then(|v| v.as_object())
.map(|obj| obj.keys().cloned().collect())
.unwrap_or_default();
(required, content_types)
}
fn extract_security_scheme_names(security: Option<&serde_json::Value>) -> Vec<String> {
let Some(arr) = security.and_then(|v| v.as_array()) else {
return Vec::new();
};
let mut names: Vec<String> = Vec::new();
for item in arr {
if let Some(obj) = item.as_object() {
for key in obj.keys() {
if !names.contains(key) {
names.push(key.clone());
}
}
}
}
names.sort();
names
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detect_format_json() {
let bytes = b"{}";
assert_eq!(
detect_format(bytes, None, Some("application/json")),
Format::Json,
);
assert_eq!(detect_format(bytes, Some("spec.json"), None), Format::Json,);
}
#[test]
fn test_detect_format_yaml() {
let bytes = b"openapi: '3.0.0'";
assert_eq!(
detect_format(bytes, None, Some("application/x-yaml")),
Format::Yaml,
);
assert_eq!(detect_format(bytes, Some("spec.yaml"), None), Format::Yaml,);
assert_eq!(detect_format(bytes, Some("spec.yml"), None), Format::Yaml,);
}
#[test]
fn test_detect_format_sniffing() {
// Valid JSON -> detected as JSON even without hints.
let json_bytes = br#"{"openapi":"3.0.0"}"#;
assert_eq!(detect_format(json_bytes, None, None), Format::Json);
// Invalid JSON but valid YAML -> falls back to YAML.
let yaml_bytes = b"openapi: '3.0.0'\ninfo:\n title: Test";
assert_eq!(detect_format(yaml_bytes, None, None), Format::Yaml);
}
#[test]
fn test_yaml_normalization_roundtrip() {
let yaml = br#"
openapi: "3.0.0"
info:
title: Test API
version: "1.0"
paths: {}
"#;
let json_bytes = normalize_to_json(yaml, Format::Yaml).unwrap();
let parsed: serde_json::Value = serde_json::from_slice(&json_bytes).unwrap();
assert_eq!(parsed["openapi"], "3.0.0");
assert_eq!(parsed["info"]["title"], "Test API");
}
#[test]
fn test_json_pointer_encoding() {
assert_eq!(json_pointer_encode("/pet/{petId}"), "~1pet~1{petId}");
assert_eq!(json_pointer_encode("simple"), "simple");
assert_eq!(json_pointer_encode("a~b/c"), "a~0b~1c");
}
#[test]
fn test_method_rank_ordering() {
assert_eq!(method_rank("GET"), 0);
assert_eq!(method_rank("POST"), 1);
assert_eq!(method_rank("PUT"), 2);
assert_eq!(method_rank("PATCH"), 3);
assert_eq!(method_rank("DELETE"), 4);
assert_eq!(method_rank("OPTIONS"), 5);
assert_eq!(method_rank("HEAD"), 6);
assert_eq!(method_rank("TRACE"), 7);
assert_eq!(method_rank("CUSTOM"), 99);
// Case-insensitive.
assert_eq!(method_rank("get"), 0);
assert_eq!(method_rank("Post"), 1);
}
#[test]
fn test_build_index_basic() {
let spec: serde_json::Value = serde_json::json!({
"openapi": "3.0.3",
"info": { "title": "Pet Store", "version": "1.0.0" },
"paths": {
"/pets": {
"get": {
"operationId": "listPets",
"summary": "List all pets",
"tags": ["pets"],
"parameters": [
{ "name": "limit", "in": "query", "required": false }
],
"responses": { "200": { "description": "OK" } }
},
"post": {
"operationId": "createPet",
"summary": "Create a pet",
"tags": ["pets"],
"requestBody": {
"required": true,
"content": { "application/json": {} }
},
"responses": { "201": { "description": "Created" } }
}
},
"/pets/{petId}": {
"get": {
"operationId": "showPetById",
"summary": "Get a pet",
"tags": ["pets"],
"parameters": [
{ "name": "petId", "in": "path", "required": true }
],
"responses": { "200": { "description": "OK" } }
}
}
},
"components": {
"schemas": {
"Pet": { "type": "object" },
"Error": { "type": "object" }
}
}
});
let index = build_index(&spec, "sha256:abc", 42).unwrap();
assert_eq!(index.index_version, 1);
assert_eq!(index.generation, 42);
assert_eq!(index.content_hash, "sha256:abc");
assert_eq!(index.openapi, "3.0.3");
assert_eq!(index.info.title, "Pet Store");
assert_eq!(index.info.version, "1.0.0");
// 3 endpoints total.
assert_eq!(index.endpoints.len(), 3);
// Sorted: /pets GET < /pets POST < /pets/{petId} GET.
assert_eq!(index.endpoints[0].path, "/pets");
assert_eq!(index.endpoints[0].method, "GET");
assert_eq!(index.endpoints[1].path, "/pets");
assert_eq!(index.endpoints[1].method, "POST");
assert_eq!(index.endpoints[2].path, "/pets/{petId}");
// POST /pets has request body.
assert!(index.endpoints[1].request_body_required);
assert_eq!(
index.endpoints[1].request_body_content_types,
vec!["application/json"]
);
// Schemas sorted: Error < Pet.
assert_eq!(index.schemas.len(), 2);
assert_eq!(index.schemas[0].name, "Error");
assert_eq!(index.schemas[1].name, "Pet");
// Single tag with count 3.
assert_eq!(index.tags.len(), 1);
assert_eq!(index.tags[0].name, "pets");
assert_eq!(index.tags[0].endpoint_count, 3);
// Verify pointers resolve.
for ep in &index.endpoints {
assert!(
resolve_pointer(&spec, &ep.operation_ptr),
"Pointer should resolve: {}",
ep.operation_ptr,
);
}
for schema in &index.schemas {
assert!(
resolve_pointer(&spec, &schema.schema_ptr),
"Pointer should resolve: {}",
schema.schema_ptr,
);
}
}
#[test]
fn test_security_inheritance() {
let spec: serde_json::Value = serde_json::json!({
"openapi": "3.0.3",
"info": { "title": "Auth Test", "version": "1.0.0" },
"security": [{ "api_key": [] }],
"paths": {
"/secured": {
"get": {
"summary": "Inherits root security",
"responses": { "200": { "description": "OK" } }
}
},
"/public": {
"get": {
"summary": "Explicitly no auth",
"security": [],
"responses": { "200": { "description": "OK" } }
}
},
"/custom": {
"get": {
"summary": "Custom auth",
"security": [{ "bearer": [] }],
"responses": { "200": { "description": "OK" } }
}
}
}
});
let index = build_index(&spec, "sha256:test", 1).unwrap();
// /custom -> custom security.
let custom = index
.endpoints
.iter()
.find(|e| e.path == "/custom")
.unwrap();
assert_eq!(custom.security_schemes, vec!["bearer"]);
assert!(custom.security_required);
// /public -> empty security array means no auth.
let public = index
.endpoints
.iter()
.find(|e| e.path == "/public")
.unwrap();
assert!(public.security_schemes.is_empty());
assert!(!public.security_required);
// /secured -> inherits root security.
let secured = index
.endpoints
.iter()
.find(|e| e.path == "/secured")
.unwrap();
assert_eq!(secured.security_schemes, vec!["api_key"]);
assert!(secured.security_required);
}
#[test]
fn test_resolve_pointer_valid_and_invalid() {
let val: serde_json::Value = serde_json::json!({
"a": { "b": { "c": 1 } }
});
assert!(resolve_pointer(&val, "/a/b/c"));
assert!(resolve_pointer(&val, "/a/b"));
assert!(!resolve_pointer(&val, "/a/b/d"));
assert!(!resolve_pointer(&val, "/x"));
}
#[test]
fn test_build_index_from_fixture() {
let fixture = include_str!("../../tests/fixtures/petstore.json");
let spec: serde_json::Value = serde_json::from_str(fixture).unwrap();
let index = build_index(&spec, "sha256:fixture", 1).unwrap();
assert_eq!(index.openapi, "3.0.3");
assert_eq!(index.info.title, "Petstore");
assert!(!index.endpoints.is_empty());
assert!(!index.schemas.is_empty());
// Verify sort order: endpoints sorted by path then method rank.
for window in index.endpoints.windows(2) {
let ordering = window[0]
.path
.cmp(&window[1].path)
.then_with(|| method_rank(&window[0].method).cmp(&method_rank(&window[1].method)));
assert!(
ordering.is_le(),
"Endpoints not sorted: {} {} > {} {}",
window[0].path,
window[0].method,
window[1].path,
window[1].method,
);
}
}
}

View File

@@ -1,2 +1,4 @@
pub mod cache;
pub mod config;
pub mod indexer;
pub mod spec;