list: new --sort flag accepts path (default), method, or tag. All sort modes maintain alias-first grouping for cross-alias queries. method sort orders GET/POST/PUT/PATCH/DELETE/etc. tag sort uses first tag as primary key with path and method as tiebreakers. show: merge path-item-level parameters into operation parameters per OpenAPI 3.x spec. Path-level params apply to all operations unless overridden by an operation-level param with the same (name, in) pair. Uses the operation_ptr to derive the parent path-item pointer and resolve_json_pointer to access the raw spec.
899 lines
28 KiB
Rust
899 lines
28 KiB
Rust
use std::collections::BTreeMap;
|
|
use std::time::Instant;
|
|
|
|
use clap::Args as ClapArgs;
|
|
use regex::Regex;
|
|
use serde::Serialize;
|
|
use tabled::Tabled;
|
|
|
|
use crate::core::cache::CacheManager;
|
|
use crate::core::config::cache_dir;
|
|
use crate::core::indexer::method_rank;
|
|
use crate::errors::SwaggerCliError;
|
|
use crate::output::robot;
|
|
use crate::output::table::render_table_or_empty;
|
|
|
|
/// List endpoints from a cached spec
|
|
#[derive(Debug, ClapArgs)]
|
|
pub struct Args {
|
|
/// Alias of the cached spec (omit when using --all-aliases)
|
|
pub alias: Option<String>,
|
|
|
|
/// Query across every cached alias
|
|
#[arg(long)]
|
|
pub all_aliases: bool,
|
|
|
|
/// Filter by HTTP method (case-insensitive)
|
|
#[arg(long, short = 'm')]
|
|
pub method: Option<String>,
|
|
|
|
/// Filter by tag (endpoints containing this tag)
|
|
#[arg(long, short = 't')]
|
|
pub tag: Option<String>,
|
|
|
|
/// Filter by path pattern (regex)
|
|
#[arg(long, short = 'p')]
|
|
pub path: Option<String>,
|
|
|
|
/// Sort order: path, method, or tag
|
|
#[arg(long, default_value = "path", value_parser = ["path", "method", "tag"])]
|
|
pub sort: String,
|
|
|
|
/// Maximum number of endpoints to show
|
|
#[arg(long, short = 'n', default_value = "50")]
|
|
pub limit: usize,
|
|
|
|
/// Show all endpoints (no limit)
|
|
#[arg(long, short = 'a')]
|
|
pub all: bool,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Robot output structs
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct ListOutput {
|
|
endpoints: Vec<EndpointEntry>,
|
|
total: usize,
|
|
filtered: usize,
|
|
applied_filters: BTreeMap<String, String>,
|
|
meta: ListMeta,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct AllAliasesListOutput {
|
|
endpoints: Vec<AliasEndpointEntry>,
|
|
aliases_searched: Vec<String>,
|
|
total: usize,
|
|
filtered: usize,
|
|
applied_filters: BTreeMap<String, String>,
|
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
|
warnings: Vec<String>,
|
|
duration_ms: u64,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct EndpointEntry {
|
|
path: String,
|
|
method: String,
|
|
summary: Option<String>,
|
|
operation_id: Option<String>,
|
|
tags: Vec<String>,
|
|
deprecated: bool,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct AliasEndpointEntry {
|
|
alias: String,
|
|
path: String,
|
|
method: String,
|
|
summary: Option<String>,
|
|
operation_id: Option<String>,
|
|
tags: Vec<String>,
|
|
deprecated: bool,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct ListMeta {
|
|
alias: String,
|
|
spec_version: String,
|
|
cached_at: String,
|
|
duration_ms: u64,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Human output row
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Tabled)]
|
|
struct EndpointRow {
|
|
#[tabled(rename = "METHOD")]
|
|
method: String,
|
|
#[tabled(rename = "PATH")]
|
|
path: String,
|
|
#[tabled(rename = "SUMMARY")]
|
|
summary: String,
|
|
}
|
|
|
|
#[derive(Tabled)]
|
|
struct AliasEndpointRow {
|
|
#[tabled(rename = "ALIAS")]
|
|
alias: String,
|
|
#[tabled(rename = "METHOD")]
|
|
method: String,
|
|
#[tabled(rename = "PATH")]
|
|
path: String,
|
|
#[tabled(rename = "SUMMARY")]
|
|
summary: String,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Execute
|
|
// ---------------------------------------------------------------------------
|
|
|
|
pub async fn execute(args: &Args, robot_mode: bool) -> Result<(), SwaggerCliError> {
|
|
if args.all_aliases {
|
|
return execute_all_aliases(args, robot_mode).await;
|
|
}
|
|
|
|
let alias = args.alias.as_deref().ok_or_else(|| {
|
|
SwaggerCliError::Usage("An alias is required unless --all-aliases is specified".to_string())
|
|
})?;
|
|
|
|
let start = Instant::now();
|
|
|
|
// Compile path regex early so we fail fast on invalid patterns
|
|
let path_regex = match &args.path {
|
|
Some(pattern) => {
|
|
let re = Regex::new(pattern).map_err(|e| {
|
|
SwaggerCliError::Usage(format!("Invalid path regex '{pattern}': {e}"))
|
|
})?;
|
|
Some(re)
|
|
}
|
|
None => None,
|
|
};
|
|
|
|
let cm = CacheManager::new(cache_dir());
|
|
let (index, meta) = cm.load_index(alias)?;
|
|
|
|
let total = index.endpoints.len();
|
|
|
|
// ---- Filter ----
|
|
let method_upper = args.method.as_ref().map(|m| m.to_uppercase());
|
|
let tag_lower = args.tag.as_ref().map(|t| t.to_lowercase());
|
|
|
|
let mut filtered: Vec<_> = index
|
|
.endpoints
|
|
.into_iter()
|
|
.filter(|ep| {
|
|
if let Some(ref m) = method_upper
|
|
&& ep.method.to_uppercase() != *m
|
|
{
|
|
return false;
|
|
}
|
|
if let Some(ref t) = tag_lower
|
|
&& !ep
|
|
.tags
|
|
.iter()
|
|
.any(|tag| tag.to_lowercase().contains(t.as_str()))
|
|
{
|
|
return false;
|
|
}
|
|
if let Some(ref re) = path_regex
|
|
&& !re.is_match(&ep.path)
|
|
{
|
|
return false;
|
|
}
|
|
true
|
|
})
|
|
.collect();
|
|
|
|
let filtered_count = filtered.len();
|
|
|
|
// ---- Sort ----
|
|
match args.sort.as_str() {
|
|
"method" => {
|
|
filtered.sort_by(|a, b| {
|
|
method_rank(&a.method)
|
|
.cmp(&method_rank(&b.method))
|
|
.then_with(|| a.path.cmp(&b.path))
|
|
});
|
|
}
|
|
"tag" => {
|
|
filtered.sort_by(|a, b| {
|
|
let tag_a = a.tags.first().map(String::as_str).unwrap_or("");
|
|
let tag_b = b.tags.first().map(String::as_str).unwrap_or("");
|
|
tag_a
|
|
.cmp(tag_b)
|
|
.then_with(|| a.path.cmp(&b.path))
|
|
.then_with(|| method_rank(&a.method).cmp(&method_rank(&b.method)))
|
|
});
|
|
}
|
|
// "path" or anything else: default sort
|
|
_ => {
|
|
filtered.sort_by(|a, b| {
|
|
a.path
|
|
.cmp(&b.path)
|
|
.then_with(|| method_rank(&a.method).cmp(&method_rank(&b.method)))
|
|
});
|
|
}
|
|
}
|
|
|
|
// ---- Limit ----
|
|
if !args.all {
|
|
filtered.truncate(args.limit);
|
|
}
|
|
|
|
let duration = start.elapsed();
|
|
|
|
// ---- Output ----
|
|
if robot_mode {
|
|
let mut applied_filters = BTreeMap::new();
|
|
if let Some(ref m) = args.method {
|
|
applied_filters.insert("method".into(), m.clone());
|
|
}
|
|
if let Some(ref t) = args.tag {
|
|
applied_filters.insert("tag".into(), t.clone());
|
|
}
|
|
if let Some(ref p) = args.path {
|
|
applied_filters.insert("path".into(), p.clone());
|
|
}
|
|
|
|
let entries: Vec<EndpointEntry> = filtered
|
|
.iter()
|
|
.map(|ep| EndpointEntry {
|
|
path: ep.path.clone(),
|
|
method: ep.method.clone(),
|
|
summary: ep.summary.clone(),
|
|
operation_id: ep.operation_id.clone(),
|
|
tags: ep.tags.clone(),
|
|
deprecated: ep.deprecated,
|
|
})
|
|
.collect();
|
|
|
|
let output = ListOutput {
|
|
endpoints: entries,
|
|
total,
|
|
filtered: filtered_count,
|
|
applied_filters,
|
|
meta: ListMeta {
|
|
alias: alias.to_string(),
|
|
spec_version: meta.spec_version.clone(),
|
|
cached_at: meta.fetched_at.to_rfc3339(),
|
|
duration_ms: duration.as_millis().min(u64::MAX as u128) as u64,
|
|
},
|
|
};
|
|
|
|
robot::robot_success(output, "list", duration);
|
|
} else {
|
|
println!("API: {} ({} endpoints)", index.info.title, total);
|
|
println!();
|
|
|
|
let rows: Vec<EndpointRow> = filtered
|
|
.iter()
|
|
.map(|ep| EndpointRow {
|
|
method: ep.method.clone(),
|
|
path: ep.path.clone(),
|
|
summary: ep.summary.clone().unwrap_or_default(),
|
|
})
|
|
.collect();
|
|
|
|
let table = render_table_or_empty(&rows, "No endpoints match the given filters.");
|
|
println!("{table}");
|
|
|
|
if !rows.is_empty() {
|
|
println!();
|
|
if filtered_count > rows.len() {
|
|
println!(
|
|
"Showing {} of {} (filtered from {}). Use --all to show everything.",
|
|
rows.len(),
|
|
filtered_count,
|
|
total
|
|
);
|
|
} else {
|
|
println!("Showing {} of {}", rows.len(), total);
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// All-aliases execution
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async fn execute_all_aliases(args: &Args, robot_mode: bool) -> Result<(), SwaggerCliError> {
|
|
let start = Instant::now();
|
|
|
|
let path_regex = match &args.path {
|
|
Some(pattern) => {
|
|
let re = Regex::new(pattern).map_err(|e| {
|
|
SwaggerCliError::Usage(format!("Invalid path regex '{pattern}': {e}"))
|
|
})?;
|
|
Some(re)
|
|
}
|
|
None => None,
|
|
};
|
|
|
|
let cm = CacheManager::new(cache_dir());
|
|
let alias_metas = cm.list_aliases()?;
|
|
|
|
if alias_metas.is_empty() {
|
|
return Err(SwaggerCliError::Usage(
|
|
"No cached aliases found. Fetch a spec first with 'swagger-cli fetch'.".to_string(),
|
|
));
|
|
}
|
|
|
|
let method_upper = args.method.as_ref().map(|m| m.to_uppercase());
|
|
let tag_lower = args.tag.as_ref().map(|t| t.to_lowercase());
|
|
|
|
let mut all_entries: Vec<AliasEndpointEntry> = Vec::new();
|
|
let mut aliases_searched: Vec<String> = Vec::new();
|
|
let mut warnings: Vec<String> = Vec::new();
|
|
let mut total_endpoints: usize = 0;
|
|
|
|
let mut sorted_aliases: Vec<_> = alias_metas.iter().map(|m| m.alias.as_str()).collect();
|
|
sorted_aliases.sort();
|
|
|
|
for alias_name in &sorted_aliases {
|
|
match cm.load_index(alias_name) {
|
|
Ok((index, _meta)) => {
|
|
aliases_searched.push((*alias_name).to_string());
|
|
total_endpoints += index.endpoints.len();
|
|
|
|
for ep in &index.endpoints {
|
|
if let Some(ref m) = method_upper
|
|
&& ep.method.to_uppercase() != *m
|
|
{
|
|
continue;
|
|
}
|
|
if let Some(ref t) = tag_lower
|
|
&& !ep
|
|
.tags
|
|
.iter()
|
|
.any(|tag| tag.to_lowercase().contains(t.as_str()))
|
|
{
|
|
continue;
|
|
}
|
|
if let Some(ref re) = path_regex
|
|
&& !re.is_match(&ep.path)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
all_entries.push(AliasEndpointEntry {
|
|
alias: (*alias_name).to_string(),
|
|
path: ep.path.clone(),
|
|
method: ep.method.clone(),
|
|
summary: ep.summary.clone(),
|
|
operation_id: ep.operation_id.clone(),
|
|
tags: ep.tags.clone(),
|
|
deprecated: ep.deprecated,
|
|
});
|
|
}
|
|
}
|
|
Err(e) => {
|
|
warnings.push(format!("Failed to load alias '{alias_name}': {e}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
if aliases_searched.is_empty() {
|
|
return Err(SwaggerCliError::Cache(
|
|
"All aliases failed to load".to_string(),
|
|
));
|
|
}
|
|
|
|
let filtered_count = all_entries.len();
|
|
|
|
// Sort: alias first for grouping, then apply user's --sort preference
|
|
match args.sort.as_str() {
|
|
"method" => {
|
|
all_entries.sort_by(|a, b| {
|
|
a.alias
|
|
.cmp(&b.alias)
|
|
.then_with(|| method_rank(&a.method).cmp(&method_rank(&b.method)))
|
|
.then_with(|| a.path.cmp(&b.path))
|
|
});
|
|
}
|
|
"tag" => {
|
|
all_entries.sort_by(|a, b| {
|
|
let tag_a = a.tags.first().map(String::as_str).unwrap_or("");
|
|
let tag_b = b.tags.first().map(String::as_str).unwrap_or("");
|
|
a.alias
|
|
.cmp(&b.alias)
|
|
.then_with(|| tag_a.cmp(tag_b))
|
|
.then_with(|| a.path.cmp(&b.path))
|
|
.then_with(|| method_rank(&a.method).cmp(&method_rank(&b.method)))
|
|
});
|
|
}
|
|
// "path" or default
|
|
_ => {
|
|
all_entries.sort_by(|a, b| {
|
|
a.alias
|
|
.cmp(&b.alias)
|
|
.then_with(|| a.path.cmp(&b.path))
|
|
.then_with(|| method_rank(&a.method).cmp(&method_rank(&b.method)))
|
|
});
|
|
}
|
|
}
|
|
|
|
// ---- Limit ----
|
|
if !args.all {
|
|
all_entries.truncate(args.limit);
|
|
}
|
|
|
|
let duration = start.elapsed();
|
|
|
|
if robot_mode {
|
|
let mut applied_filters = BTreeMap::new();
|
|
if let Some(ref m) = args.method {
|
|
applied_filters.insert("method".into(), m.clone());
|
|
}
|
|
if let Some(ref t) = args.tag {
|
|
applied_filters.insert("tag".into(), t.clone());
|
|
}
|
|
if let Some(ref p) = args.path {
|
|
applied_filters.insert("path".into(), p.clone());
|
|
}
|
|
|
|
let output = AllAliasesListOutput {
|
|
endpoints: all_entries,
|
|
aliases_searched,
|
|
total: total_endpoints,
|
|
filtered: filtered_count,
|
|
applied_filters,
|
|
warnings,
|
|
duration_ms: duration.as_millis().min(u64::MAX as u128) as u64,
|
|
};
|
|
|
|
robot::robot_success(output, "list", duration);
|
|
} else {
|
|
println!("All aliases ({} searched)\n", aliases_searched.len());
|
|
|
|
let rows: Vec<AliasEndpointRow> = all_entries
|
|
.iter()
|
|
.map(|ep| AliasEndpointRow {
|
|
alias: ep.alias.clone(),
|
|
method: ep.method.clone(),
|
|
path: ep.path.clone(),
|
|
summary: ep.summary.clone().unwrap_or_default(),
|
|
})
|
|
.collect();
|
|
|
|
let table = render_table_or_empty(&rows, "No endpoints match the given filters.");
|
|
println!("{table}");
|
|
|
|
if !rows.is_empty() {
|
|
println!();
|
|
if filtered_count > rows.len() {
|
|
println!(
|
|
"Showing {} of {} (filtered from {}). Use --all to show everything.",
|
|
rows.len(),
|
|
filtered_count,
|
|
total_endpoints
|
|
);
|
|
} else {
|
|
println!("Showing {} of {}", rows.len(), total_endpoints);
|
|
}
|
|
}
|
|
|
|
if !warnings.is_empty() {
|
|
println!();
|
|
for w in &warnings {
|
|
eprintln!("Warning: {w}");
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::core::spec::{
|
|
IndexInfo, IndexedEndpoint, IndexedParam, IndexedSchema, IndexedTag, SpecIndex,
|
|
};
|
|
|
|
fn make_test_endpoint(
|
|
path: &str,
|
|
method: &str,
|
|
summary: Option<&str>,
|
|
tags: &[&str],
|
|
deprecated: bool,
|
|
) -> IndexedEndpoint {
|
|
IndexedEndpoint {
|
|
path: path.to_string(),
|
|
method: method.to_string(),
|
|
summary: summary.map(|s| s.to_string()),
|
|
description: None,
|
|
operation_id: Some(format!(
|
|
"{}{}",
|
|
method.to_lowercase(),
|
|
path.replace('/', "_")
|
|
)),
|
|
tags: tags.iter().map(|t| t.to_string()).collect(),
|
|
deprecated,
|
|
parameters: vec![IndexedParam {
|
|
name: "id".into(),
|
|
location: "path".into(),
|
|
required: true,
|
|
description: None,
|
|
}],
|
|
request_body_required: method != "GET" && method != "DELETE",
|
|
request_body_content_types: if method != "GET" && method != "DELETE" {
|
|
vec!["application/json".into()]
|
|
} else {
|
|
vec![]
|
|
},
|
|
security_schemes: vec!["bearerAuth".into()],
|
|
security_required: true,
|
|
operation_ptr: format!(
|
|
"#/paths/~1{}/{}",
|
|
path.trim_start_matches('/'),
|
|
method.to_lowercase()
|
|
),
|
|
}
|
|
}
|
|
|
|
fn make_test_index() -> SpecIndex {
|
|
SpecIndex {
|
|
index_version: 1,
|
|
generation: 1,
|
|
content_hash: "sha256:test".into(),
|
|
openapi: "3.0.3".into(),
|
|
info: IndexInfo {
|
|
title: "Petstore API".into(),
|
|
version: "1.0.0".into(),
|
|
},
|
|
endpoints: vec![
|
|
make_test_endpoint("/pets", "GET", Some("List all pets"), &["pets"], false),
|
|
make_test_endpoint("/pets", "POST", Some("Create a pet"), &["pets"], false),
|
|
make_test_endpoint(
|
|
"/pets/{petId}",
|
|
"GET",
|
|
Some("Get a pet by ID"),
|
|
&["pets"],
|
|
false,
|
|
),
|
|
make_test_endpoint(
|
|
"/pets/{petId}",
|
|
"DELETE",
|
|
Some("Delete a pet"),
|
|
&["pets"],
|
|
true,
|
|
),
|
|
make_test_endpoint(
|
|
"/store/inventory",
|
|
"GET",
|
|
Some("Get store inventory"),
|
|
&["store"],
|
|
false,
|
|
),
|
|
],
|
|
schemas: vec![IndexedSchema {
|
|
name: "Pet".into(),
|
|
schema_ptr: "#/components/schemas/Pet".into(),
|
|
}],
|
|
tags: vec![
|
|
IndexedTag {
|
|
name: "pets".into(),
|
|
description: Some("Pet operations".into()),
|
|
endpoint_count: 4,
|
|
},
|
|
IndexedTag {
|
|
name: "store".into(),
|
|
description: Some("Store operations".into()),
|
|
endpoint_count: 1,
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
/// Apply the same filtering logic used in execute() to the test index.
|
|
fn filter_endpoints<'a>(
|
|
index: &'a SpecIndex,
|
|
method: Option<&str>,
|
|
tag: Option<&str>,
|
|
path_pattern: Option<&str>,
|
|
) -> Result<Vec<&'a IndexedEndpoint>, SwaggerCliError> {
|
|
let path_regex = match path_pattern {
|
|
Some(p) => Some(
|
|
Regex::new(p).map_err(|e| SwaggerCliError::Usage(format!("Invalid regex: {e}")))?,
|
|
),
|
|
None => None,
|
|
};
|
|
|
|
let method_upper = method.map(|m| m.to_uppercase());
|
|
let tag_lower = tag.map(|t| t.to_lowercase());
|
|
|
|
let results: Vec<&IndexedEndpoint> = index
|
|
.endpoints
|
|
.iter()
|
|
.filter(|ep| {
|
|
if let Some(ref m) = method_upper
|
|
&& ep.method.to_uppercase() != *m
|
|
{
|
|
return false;
|
|
}
|
|
if let Some(ref t) = tag_lower
|
|
&& !ep
|
|
.tags
|
|
.iter()
|
|
.any(|tag| tag.to_lowercase().contains(t.as_str()))
|
|
{
|
|
return false;
|
|
}
|
|
if let Some(ref re) = path_regex
|
|
&& !re.is_match(&ep.path)
|
|
{
|
|
return false;
|
|
}
|
|
true
|
|
})
|
|
.collect();
|
|
|
|
Ok(results)
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_by_method() {
|
|
let index = make_test_index();
|
|
let results = filter_endpoints(&index, Some("GET"), None, None).unwrap();
|
|
|
|
assert_eq!(results.len(), 3);
|
|
for ep in &results {
|
|
assert_eq!(ep.method, "GET");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_by_method_case_insensitive() {
|
|
let index = make_test_index();
|
|
let results = filter_endpoints(&index, Some("get"), None, None).unwrap();
|
|
assert_eq!(results.len(), 3);
|
|
|
|
let results = filter_endpoints(&index, Some("Post"), None, None).unwrap();
|
|
assert_eq!(results.len(), 1);
|
|
assert_eq!(results[0].method, "POST");
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_by_tag() {
|
|
let index = make_test_index();
|
|
let results = filter_endpoints(&index, None, Some("store"), None).unwrap();
|
|
|
|
assert_eq!(results.len(), 1);
|
|
assert_eq!(results[0].path, "/store/inventory");
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_by_tag_case_insensitive() {
|
|
let index = make_test_index();
|
|
let results = filter_endpoints(&index, None, Some("PETS"), None).unwrap();
|
|
assert_eq!(results.len(), 4);
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_by_path_regex() {
|
|
let index = make_test_index();
|
|
let results = filter_endpoints(&index, None, None, Some(r"\{petId\}")).unwrap();
|
|
|
|
assert_eq!(results.len(), 2);
|
|
for ep in &results {
|
|
assert!(ep.path.contains("{petId}"));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_by_path_regex_prefix() {
|
|
let index = make_test_index();
|
|
let results = filter_endpoints(&index, None, None, Some("^/pets")).unwrap();
|
|
assert_eq!(results.len(), 4);
|
|
}
|
|
|
|
#[test]
|
|
fn test_invalid_regex_error() {
|
|
let index = make_test_index();
|
|
let result = filter_endpoints(&index, None, None, Some("[invalid"));
|
|
|
|
assert!(result.is_err());
|
|
let err = result.unwrap_err();
|
|
match err {
|
|
SwaggerCliError::Usage(msg) => {
|
|
assert!(msg.contains("Invalid regex"), "unexpected message: {msg}");
|
|
}
|
|
other => panic!("expected Usage error, got: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_combined_filters() {
|
|
let index = make_test_index();
|
|
let results = filter_endpoints(&index, Some("GET"), Some("pets"), None).unwrap();
|
|
|
|
assert_eq!(results.len(), 2);
|
|
for ep in &results {
|
|
assert_eq!(ep.method, "GET");
|
|
assert!(ep.tags.contains(&"pets".to_string()));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_no_matches() {
|
|
let index = make_test_index();
|
|
let results = filter_endpoints(&index, Some("PATCH"), None, None).unwrap();
|
|
assert!(results.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_sort_by_method() {
|
|
let index = make_test_index();
|
|
let mut endpoints: Vec<IndexedEndpoint> = index.endpoints.clone();
|
|
|
|
endpoints.sort_by(|a, b| {
|
|
method_rank(&a.method)
|
|
.cmp(&method_rank(&b.method))
|
|
.then_with(|| a.path.cmp(&b.path))
|
|
});
|
|
|
|
// All GETs come first, then POST, then DELETE
|
|
assert_eq!(endpoints[0].method, "GET");
|
|
assert_eq!(endpoints[1].method, "GET");
|
|
assert_eq!(endpoints[2].method, "GET");
|
|
assert_eq!(endpoints[3].method, "POST");
|
|
assert_eq!(endpoints[4].method, "DELETE");
|
|
}
|
|
|
|
#[test]
|
|
fn test_sort_by_path_default() {
|
|
let index = make_test_index();
|
|
let mut endpoints: Vec<IndexedEndpoint> = index.endpoints.clone();
|
|
|
|
endpoints.sort_by(|a, b| {
|
|
a.path
|
|
.cmp(&b.path)
|
|
.then_with(|| method_rank(&a.method).cmp(&method_rank(&b.method)))
|
|
});
|
|
|
|
assert_eq!(endpoints[0].path, "/pets");
|
|
assert_eq!(endpoints[0].method, "GET");
|
|
assert_eq!(endpoints[1].path, "/pets");
|
|
assert_eq!(endpoints[1].method, "POST");
|
|
assert_eq!(endpoints[2].path, "/pets/{petId}");
|
|
assert_eq!(endpoints[2].method, "GET");
|
|
assert_eq!(endpoints[3].path, "/pets/{petId}");
|
|
assert_eq!(endpoints[3].method, "DELETE");
|
|
assert_eq!(endpoints[4].path, "/store/inventory");
|
|
}
|
|
|
|
#[test]
|
|
fn test_sort_by_tag() {
|
|
let index = make_test_index();
|
|
let mut endpoints: Vec<IndexedEndpoint> = index.endpoints.clone();
|
|
|
|
endpoints.sort_by(|a, b| {
|
|
let tag_a = a.tags.first().map(String::as_str).unwrap_or("");
|
|
let tag_b = b.tags.first().map(String::as_str).unwrap_or("");
|
|
tag_a
|
|
.cmp(tag_b)
|
|
.then_with(|| a.path.cmp(&b.path))
|
|
.then_with(|| method_rank(&a.method).cmp(&method_rank(&b.method)))
|
|
});
|
|
|
|
// "pets" < "store" alphabetically
|
|
assert!(endpoints[0].tags.contains(&"pets".to_string()));
|
|
assert!(endpoints[1].tags.contains(&"pets".to_string()));
|
|
assert!(endpoints[2].tags.contains(&"pets".to_string()));
|
|
assert!(endpoints[3].tags.contains(&"pets".to_string()));
|
|
assert!(endpoints[4].tags.contains(&"store".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_limit_applied() {
|
|
let index = make_test_index();
|
|
let mut endpoints = index.endpoints.clone();
|
|
|
|
let limit: usize = 2;
|
|
endpoints.truncate(limit);
|
|
|
|
assert_eq!(endpoints.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_limit_larger_than_count() {
|
|
let index = make_test_index();
|
|
let mut endpoints = index.endpoints.clone();
|
|
|
|
endpoints.truncate(100);
|
|
assert_eq!(endpoints.len(), 5);
|
|
}
|
|
|
|
#[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("FOOBAR"), 99);
|
|
}
|
|
|
|
#[test]
|
|
fn test_method_rank_case_insensitive() {
|
|
assert_eq!(method_rank("get"), 0);
|
|
assert_eq!(method_rank("Post"), 1);
|
|
assert_eq!(method_rank("delete"), 4);
|
|
}
|
|
|
|
#[test]
|
|
fn test_endpoint_entry_serialization() {
|
|
let entry = EndpointEntry {
|
|
path: "/pets".into(),
|
|
method: "GET".into(),
|
|
summary: Some("List pets".into()),
|
|
operation_id: Some("listPets".into()),
|
|
tags: vec!["pets".into()],
|
|
deprecated: false,
|
|
};
|
|
|
|
let json = serde_json::to_string(&entry).unwrap();
|
|
assert!(json.contains("\"path\":\"/pets\""));
|
|
assert!(json.contains("\"method\":\"GET\""));
|
|
assert!(json.contains("\"deprecated\":false"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_list_output_serialization() {
|
|
let output = ListOutput {
|
|
endpoints: vec![],
|
|
total: 5,
|
|
filtered: 0,
|
|
applied_filters: BTreeMap::new(),
|
|
meta: ListMeta {
|
|
alias: "petstore".into(),
|
|
spec_version: "1.0.0".into(),
|
|
cached_at: "2025-01-01T00:00:00+00:00".into(),
|
|
duration_ms: 42,
|
|
},
|
|
};
|
|
|
|
let json = serde_json::to_string(&output).unwrap();
|
|
assert!(json.contains("\"total\":5"));
|
|
assert!(json.contains("\"filtered\":0"));
|
|
assert!(json.contains("\"alias\":\"petstore\""));
|
|
}
|
|
|
|
#[test]
|
|
fn test_applied_filters_populated() {
|
|
let mut filters: BTreeMap<String, String> = BTreeMap::new();
|
|
filters.insert("method".into(), "GET".into());
|
|
filters.insert("tag".into(), "pets".into());
|
|
filters.insert("path".into(), "^/pets".into());
|
|
|
|
assert_eq!(filters.len(), 3);
|
|
assert_eq!(filters.get("method").unwrap(), "GET");
|
|
}
|
|
|
|
#[test]
|
|
fn test_make_test_index_structure() {
|
|
let index = make_test_index();
|
|
|
|
assert_eq!(index.info.title, "Petstore API");
|
|
assert_eq!(index.info.version, "1.0.0");
|
|
assert_eq!(index.endpoints.len(), 5);
|
|
assert_eq!(index.schemas.len(), 1);
|
|
assert_eq!(index.tags.len(), 2);
|
|
}
|
|
}
|