Wave 7: Phase 2 features - sync --all, external refs, cross-alias discovery, CI/CD, reliability tests (bd-1ky, bd-1bp, bd-1rk, bd-1lj, bd-gvr, bd-1x5)
- Sync --all with async concurrency, per-host throttling, failure budgets, resumable execution - External ref bundling at fetch time with origin tracking - Cross-alias discovery (--all-aliases) for list and search commands - CI/CD pipeline (.gitlab-ci.yml), cargo-deny config, Dockerfile, install script - Reliability test suite: crash consistency (8 tests), lock contention (3 tests), property-based (4 tests) - Criterion performance benchmarks (5 benchmarks) - Bug fix: doctor --fix now repairs missing index.json when raw.json exists - Bug fix: shared $ref references no longer incorrectly flagged as circular (refs.rs) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because one or more lines are too long
156
.gitlab-ci.yml
Normal file
156
.gitlab-ci.yml
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
# CI/CD pipeline for swagger-cli
|
||||||
|
# Stages: test -> build -> release
|
||||||
|
|
||||||
|
stages:
|
||||||
|
- test
|
||||||
|
- build
|
||||||
|
- release
|
||||||
|
|
||||||
|
variables:
|
||||||
|
CARGO_HOME: ${CI_PROJECT_DIR}/.cargo
|
||||||
|
RUSTFLAGS: "-D warnings"
|
||||||
|
|
||||||
|
default:
|
||||||
|
image: rust:1.93
|
||||||
|
cache:
|
||||||
|
key: ${CI_COMMIT_REF_SLUG}
|
||||||
|
paths:
|
||||||
|
- .cargo/
|
||||||
|
- target/
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test stage
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test:unit:
|
||||||
|
stage: test
|
||||||
|
script:
|
||||||
|
- cargo test --lib
|
||||||
|
rules:
|
||||||
|
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||||
|
- if: $CI_COMMIT_BRANCH
|
||||||
|
|
||||||
|
test:integration:
|
||||||
|
stage: test
|
||||||
|
script:
|
||||||
|
- cargo test --test '*'
|
||||||
|
rules:
|
||||||
|
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||||
|
- if: $CI_COMMIT_BRANCH
|
||||||
|
|
||||||
|
lint:
|
||||||
|
stage: test
|
||||||
|
script:
|
||||||
|
- rustup component add rustfmt clippy
|
||||||
|
- cargo fmt --check
|
||||||
|
- cargo clippy -- -D warnings
|
||||||
|
rules:
|
||||||
|
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||||
|
- if: $CI_COMMIT_BRANCH
|
||||||
|
|
||||||
|
security:deps:
|
||||||
|
stage: test
|
||||||
|
script:
|
||||||
|
- cargo install cargo-deny --locked
|
||||||
|
- cargo install cargo-audit --locked
|
||||||
|
- cargo deny check
|
||||||
|
- cargo audit
|
||||||
|
rules:
|
||||||
|
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||||
|
- if: $CI_COMMIT_BRANCH
|
||||||
|
allow_failure: false
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Build stage — cross-compile for 4 platform targets
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
.build-template: &build-template
|
||||||
|
stage: build
|
||||||
|
script:
|
||||||
|
- rustup target add ${TARGET}
|
||||||
|
- cargo build --release --target ${TARGET}
|
||||||
|
- mkdir -p artifacts/
|
||||||
|
- cp target/${TARGET}/release/swagger-cli artifacts/swagger-cli-${TARGET}
|
||||||
|
artifacts:
|
||||||
|
paths:
|
||||||
|
- artifacts/
|
||||||
|
expire_in: 1 week
|
||||||
|
rules:
|
||||||
|
- if: $CI_COMMIT_TAG
|
||||||
|
|
||||||
|
build:aarch64-apple-darwin:
|
||||||
|
<<: *build-template
|
||||||
|
tags:
|
||||||
|
- macos
|
||||||
|
- arm64
|
||||||
|
variables:
|
||||||
|
TARGET: aarch64-apple-darwin
|
||||||
|
|
||||||
|
build:x86_64-apple-darwin:
|
||||||
|
<<: *build-template
|
||||||
|
tags:
|
||||||
|
- macos
|
||||||
|
- x86_64
|
||||||
|
variables:
|
||||||
|
TARGET: x86_64-apple-darwin
|
||||||
|
|
||||||
|
build:x86_64-unknown-linux-gnu:
|
||||||
|
<<: *build-template
|
||||||
|
variables:
|
||||||
|
TARGET: x86_64-unknown-linux-gnu
|
||||||
|
|
||||||
|
build:aarch64-unknown-linux-gnu:
|
||||||
|
<<: *build-template
|
||||||
|
before_script:
|
||||||
|
- apt-get update -qq && apt-get install -y -qq gcc-aarch64-linux-gnu
|
||||||
|
- export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc
|
||||||
|
variables:
|
||||||
|
TARGET: aarch64-unknown-linux-gnu
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Release stage — tag-only
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
release:artifacts:
|
||||||
|
stage: release
|
||||||
|
image: alpine:latest
|
||||||
|
dependencies:
|
||||||
|
- build:aarch64-apple-darwin
|
||||||
|
- build:x86_64-apple-darwin
|
||||||
|
- build:x86_64-unknown-linux-gnu
|
||||||
|
- build:aarch64-unknown-linux-gnu
|
||||||
|
before_script:
|
||||||
|
- apk add --no-cache curl minisign coreutils
|
||||||
|
script:
|
||||||
|
- cd artifacts/
|
||||||
|
- sha256sum swagger-cli-* > SHA256SUMS
|
||||||
|
- echo "${MINISIGN_SECRET_KEY}" > /tmp/minisign.key
|
||||||
|
- minisign -S -s /tmp/minisign.key -m SHA256SUMS
|
||||||
|
- rm -f /tmp/minisign.key
|
||||||
|
# Upload all artifacts to GitLab Package Registry
|
||||||
|
- |
|
||||||
|
for file in swagger-cli-* SHA256SUMS SHA256SUMS.minisig; do
|
||||||
|
curl --header "JOB-TOKEN: ${CI_JOB_TOKEN}" \
|
||||||
|
--upload-file "${file}" \
|
||||||
|
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/swagger-cli/${CI_COMMIT_TAG}/${file}"
|
||||||
|
done
|
||||||
|
rules:
|
||||||
|
- if: $CI_COMMIT_TAG
|
||||||
|
artifacts:
|
||||||
|
paths:
|
||||||
|
- artifacts/
|
||||||
|
|
||||||
|
release:docker:
|
||||||
|
stage: release
|
||||||
|
image: docker:latest
|
||||||
|
services:
|
||||||
|
- docker:dind
|
||||||
|
variables:
|
||||||
|
DOCKER_TLS_CERTDIR: "/certs"
|
||||||
|
script:
|
||||||
|
- docker login -u ${CI_REGISTRY_USER} -p ${CI_REGISTRY_PASSWORD} ${CI_REGISTRY}
|
||||||
|
- docker build -t ${CI_REGISTRY_IMAGE}:${CI_COMMIT_TAG} -t ${CI_REGISTRY_IMAGE}:latest .
|
||||||
|
- docker push ${CI_REGISTRY_IMAGE}:${CI_COMMIT_TAG}
|
||||||
|
- docker push ${CI_REGISTRY_IMAGE}:latest
|
||||||
|
rules:
|
||||||
|
- if: $CI_COMMIT_TAG
|
||||||
97
Cargo.lock
generated
97
Cargo.lock
generated
@@ -125,6 +125,21 @@ version = "0.22.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bit-set"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
|
||||||
|
dependencies = [
|
||||||
|
"bit-vec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bit-vec"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.10.0"
|
version = "2.10.0"
|
||||||
@@ -507,6 +522,21 @@ dependencies = [
|
|||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures"
|
||||||
|
version = "0.3.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
|
||||||
|
dependencies = [
|
||||||
|
"futures-channel",
|
||||||
|
"futures-core",
|
||||||
|
"futures-executor",
|
||||||
|
"futures-io",
|
||||||
|
"futures-sink",
|
||||||
|
"futures-task",
|
||||||
|
"futures-util",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-channel"
|
name = "futures-channel"
|
||||||
version = "0.3.31"
|
version = "0.3.31"
|
||||||
@@ -514,6 +544,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
|
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -522,6 +553,17 @@ version = "0.3.31"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
|
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-executor"
|
||||||
|
version = "0.3.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"futures-task",
|
||||||
|
"futures-util",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-io"
|
name = "futures-io"
|
||||||
version = "0.3.31"
|
version = "0.3.31"
|
||||||
@@ -557,6 +599,7 @@ version = "0.3.31"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
|
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-io",
|
"futures-io",
|
||||||
"futures-macro",
|
"futures-macro",
|
||||||
@@ -1296,6 +1339,31 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proptest"
|
||||||
|
version = "1.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532"
|
||||||
|
dependencies = [
|
||||||
|
"bit-set",
|
||||||
|
"bit-vec",
|
||||||
|
"bitflags",
|
||||||
|
"num-traits",
|
||||||
|
"rand",
|
||||||
|
"rand_chacha",
|
||||||
|
"rand_xorshift",
|
||||||
|
"regex-syntax",
|
||||||
|
"rusty-fork",
|
||||||
|
"tempfile",
|
||||||
|
"unarray",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quick-error"
|
||||||
|
version = "1.2.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quinn"
|
name = "quinn"
|
||||||
version = "0.11.9"
|
version = "0.11.9"
|
||||||
@@ -1395,6 +1463,15 @@ dependencies = [
|
|||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_xorshift"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
|
||||||
|
dependencies = [
|
||||||
|
"rand_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rayon"
|
name = "rayon"
|
||||||
version = "1.11.0"
|
version = "1.11.0"
|
||||||
@@ -1579,6 +1656,18 @@ version = "1.0.22"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rusty-fork"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
|
||||||
|
dependencies = [
|
||||||
|
"fnv",
|
||||||
|
"quick-error",
|
||||||
|
"tempfile",
|
||||||
|
"wait-timeout",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ryu"
|
name = "ryu"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
@@ -1768,8 +1857,10 @@ dependencies = [
|
|||||||
"criterion",
|
"criterion",
|
||||||
"directories",
|
"directories",
|
||||||
"fs2",
|
"fs2",
|
||||||
|
"futures",
|
||||||
"mockito",
|
"mockito",
|
||||||
"predicates",
|
"predicates",
|
||||||
|
"proptest",
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -2090,6 +2181,12 @@ version = "1.19.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unarray"
|
||||||
|
version = "0.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ clap = { version = "4", features = ["derive", "env"] }
|
|||||||
colored = "3"
|
colored = "3"
|
||||||
directories = "6"
|
directories = "6"
|
||||||
fs2 = "0.4"
|
fs2 = "0.4"
|
||||||
|
futures = "0.3"
|
||||||
regex = "1"
|
regex = "1"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
@@ -26,9 +27,14 @@ assert_cmd = "2"
|
|||||||
criterion = "0.5"
|
criterion = "0.5"
|
||||||
mockito = "1"
|
mockito = "1"
|
||||||
predicates = "3"
|
predicates = "3"
|
||||||
|
proptest = "1"
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||||
|
|
||||||
|
[[bench]]
|
||||||
|
name = "perf"
|
||||||
|
harness = false
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
lto = true
|
lto = true
|
||||||
|
|||||||
24
Dockerfile
Normal file
24
Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Multi-stage build for swagger-cli
|
||||||
|
# Builder: compile with musl for fully static binary
|
||||||
|
# Runtime: minimal Alpine image
|
||||||
|
|
||||||
|
FROM rust:1.93-alpine AS builder
|
||||||
|
|
||||||
|
RUN apk add --no-cache musl-dev
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
COPY src/ src/
|
||||||
|
|
||||||
|
RUN cargo build --release --target x86_64-unknown-linux-musl \
|
||||||
|
&& strip target/x86_64-unknown-linux-musl/release/swagger-cli
|
||||||
|
|
||||||
|
# Runtime stage
|
||||||
|
FROM alpine:latest
|
||||||
|
|
||||||
|
RUN apk add --no-cache ca-certificates
|
||||||
|
|
||||||
|
COPY --from=builder /build/target/x86_64-unknown-linux-musl/release/swagger-cli /usr/local/bin/swagger-cli
|
||||||
|
|
||||||
|
ENTRYPOINT ["swagger-cli"]
|
||||||
126
benches/perf.rs
Normal file
126
benches/perf.rs
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
//! Performance benchmarks for core operations.
|
||||||
|
//!
|
||||||
|
//! Run with: `cargo bench`
|
||||||
|
//! First run establishes baseline. Subsequent runs report regressions.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use criterion::{Criterion, criterion_group, criterion_main};
|
||||||
|
|
||||||
|
fn fixture_path(name: &str) -> PathBuf {
|
||||||
|
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||||
|
PathBuf::from(manifest_dir)
|
||||||
|
.join("tests")
|
||||||
|
.join("fixtures")
|
||||||
|
.join(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_petstore_json() -> serde_json::Value {
|
||||||
|
let path = fixture_path("petstore.json");
|
||||||
|
let bytes = fs::read(&path).expect("failed to read petstore.json fixture");
|
||||||
|
serde_json::from_slice(&bytes).expect("failed to parse petstore.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bench_json_parse(c: &mut Criterion) {
|
||||||
|
let path = fixture_path("petstore.json");
|
||||||
|
let bytes = fs::read(&path).expect("failed to read petstore.json fixture");
|
||||||
|
|
||||||
|
c.bench_function("parse_petstore_json", |b| {
|
||||||
|
b.iter(|| {
|
||||||
|
let _: serde_json::Value = serde_json::from_slice(&bytes).expect("parse failed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bench_build_index(c: &mut Criterion) {
|
||||||
|
let raw_json = load_petstore_json();
|
||||||
|
|
||||||
|
c.bench_function("build_index_petstore", |b| {
|
||||||
|
b.iter(|| {
|
||||||
|
// Simulate endpoint extraction (mirrors build_index core logic)
|
||||||
|
if let Some(paths) = raw_json.get("paths").and_then(|v| v.as_object()) {
|
||||||
|
let mut endpoints = Vec::new();
|
||||||
|
for (path, methods) in paths {
|
||||||
|
if let Some(methods_map) = methods.as_object() {
|
||||||
|
for (method, _op) in methods_map {
|
||||||
|
endpoints.push((path.clone(), method.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(!endpoints.is_empty());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bench_hash_computation(c: &mut Criterion) {
|
||||||
|
let path = fixture_path("petstore.json");
|
||||||
|
let bytes = fs::read(&path).expect("failed to read petstore.json");
|
||||||
|
|
||||||
|
c.bench_function("sha256_petstore", |b| {
|
||||||
|
b.iter(|| {
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(&bytes);
|
||||||
|
let _result = format!("sha256:{:x}", hasher.finalize());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bench_json_pointer_resolution(c: &mut Criterion) {
|
||||||
|
let raw_json = load_petstore_json();
|
||||||
|
|
||||||
|
c.bench_function("json_pointer_resolution", |b| {
|
||||||
|
b.iter(|| {
|
||||||
|
let _ = raw_json
|
||||||
|
.pointer("/paths/~1pets/get/summary")
|
||||||
|
.map(|v| v.as_str());
|
||||||
|
let _ = raw_json
|
||||||
|
.pointer("/components/schemas/Pet")
|
||||||
|
.map(|v| v.is_object());
|
||||||
|
let _ = raw_json.pointer("/info/title").map(|v| v.as_str());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bench_search_scoring(c: &mut Criterion) {
|
||||||
|
let raw_json = load_petstore_json();
|
||||||
|
|
||||||
|
// Pre-extract summaries for search simulation
|
||||||
|
let mut summaries: Vec<String> = Vec::new();
|
||||||
|
if let Some(paths) = raw_json.get("paths").and_then(|v| v.as_object()) {
|
||||||
|
for (_path, methods) in paths {
|
||||||
|
if let Some(methods_map) = methods.as_object() {
|
||||||
|
for (_method, op) in methods_map {
|
||||||
|
if let Some(summary) = op.get("summary").and_then(|v| v.as_str()) {
|
||||||
|
summaries.push(summary.to_lowercase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.bench_function("search_scoring_pet", |b| {
|
||||||
|
let query = "pet";
|
||||||
|
b.iter(|| {
|
||||||
|
let mut matches = 0u32;
|
||||||
|
for summary in &summaries {
|
||||||
|
if summary.contains(query) {
|
||||||
|
matches += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(matches > 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
criterion_group!(
|
||||||
|
benches,
|
||||||
|
bench_json_parse,
|
||||||
|
bench_build_index,
|
||||||
|
bench_hash_computation,
|
||||||
|
bench_json_pointer_resolution,
|
||||||
|
bench_search_scoring,
|
||||||
|
);
|
||||||
|
criterion_main!(benches);
|
||||||
53
deny.toml
Normal file
53
deny.toml
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# cargo-deny configuration for swagger-cli
|
||||||
|
# https://embarkstudios.github.io/cargo-deny/
|
||||||
|
|
||||||
|
[graph]
|
||||||
|
targets = [
|
||||||
|
"aarch64-apple-darwin",
|
||||||
|
"x86_64-apple-darwin",
|
||||||
|
"x86_64-unknown-linux-gnu",
|
||||||
|
"aarch64-unknown-linux-gnu",
|
||||||
|
]
|
||||||
|
|
||||||
|
[advisories]
|
||||||
|
db-path = "~/.cargo/advisory-db"
|
||||||
|
db-urls = ["https://github.com/rustsec/advisory-db"]
|
||||||
|
vulnerability = "deny"
|
||||||
|
unmaintained = "warn"
|
||||||
|
unsound = "warn"
|
||||||
|
yanked = "deny"
|
||||||
|
notice = "warn"
|
||||||
|
|
||||||
|
[licenses]
|
||||||
|
unlicensed = "deny"
|
||||||
|
copyleft = "deny"
|
||||||
|
allow = [
|
||||||
|
"MIT",
|
||||||
|
"Apache-2.0",
|
||||||
|
"BSD-2-Clause",
|
||||||
|
"BSD-3-Clause",
|
||||||
|
"ISC",
|
||||||
|
"Zlib",
|
||||||
|
"Unicode-3.0",
|
||||||
|
"Unicode-DFS-2016",
|
||||||
|
"OpenSSL",
|
||||||
|
]
|
||||||
|
confidence-threshold = 0.8
|
||||||
|
|
||||||
|
[[licenses.clarify]]
|
||||||
|
name = "ring"
|
||||||
|
expression = "MIT AND ISC AND OpenSSL"
|
||||||
|
license-files = [
|
||||||
|
{ path = "LICENSE", hash = 0xbd0eed23 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[bans]
|
||||||
|
multiple-versions = "warn"
|
||||||
|
wildcards = "deny"
|
||||||
|
highlight = "all"
|
||||||
|
|
||||||
|
[sources]
|
||||||
|
unknown-registry = "deny"
|
||||||
|
unknown-git = "deny"
|
||||||
|
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
|
||||||
|
allow-git = []
|
||||||
186
install.sh
Executable file
186
install.sh
Executable file
@@ -0,0 +1,186 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# swagger-cli installer
|
||||||
|
# Downloads the correct binary for your platform, verifies its checksum,
|
||||||
|
# and installs it to /usr/local/bin or ~/.local/bin.
|
||||||
|
|
||||||
|
REPO_URL="${SWAGGER_CLI_REPO_URL:-https://gitlab.com/api/v4/projects/YOUR_PROJECT_ID/packages/generic/swagger-cli}"
|
||||||
|
VERSION="${SWAGGER_CLI_VERSION:-latest}"
|
||||||
|
|
||||||
|
# --- Helpers ---------------------------------------------------------------
|
||||||
|
|
||||||
|
die() {
|
||||||
|
printf 'Error: %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
info() {
|
||||||
|
printf ' %s\n' "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Secure temp directory with cleanup trap --------------------------------
|
||||||
|
|
||||||
|
TMPDIR_INSTALL="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "${TMPDIR_INSTALL}"' EXIT INT TERM
|
||||||
|
|
||||||
|
# --- OS / arch detection ----------------------------------------------------
|
||||||
|
|
||||||
|
detect_os() {
|
||||||
|
local os
|
||||||
|
os="$(uname -s)"
|
||||||
|
case "${os}" in
|
||||||
|
Darwin) echo "apple-darwin" ;;
|
||||||
|
Linux) echo "unknown-linux-gnu" ;;
|
||||||
|
*) die "Unsupported operating system: ${os}. Only macOS and Linux are supported." ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
detect_arch() {
|
||||||
|
local arch
|
||||||
|
arch="$(uname -m)"
|
||||||
|
case "${arch}" in
|
||||||
|
x86_64|amd64) echo "x86_64" ;;
|
||||||
|
arm64|aarch64) echo "aarch64" ;;
|
||||||
|
*) die "Unsupported architecture: ${arch}. Only x86_64 and aarch64 are supported." ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Checksum verification -------------------------------------------------
|
||||||
|
|
||||||
|
verify_checksum() {
|
||||||
|
local file="$1"
|
||||||
|
local checksums_file="$2"
|
||||||
|
local basename
|
||||||
|
basename="$(basename "${file}")"
|
||||||
|
|
||||||
|
local expected
|
||||||
|
expected="$(grep "${basename}" "${checksums_file}" | awk '{print $1}')"
|
||||||
|
|
||||||
|
if [ -z "${expected}" ]; then
|
||||||
|
die "No checksum found for ${basename} in SHA256SUMS"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local actual
|
||||||
|
if command -v sha256sum >/dev/null 2>&1; then
|
||||||
|
actual="$(sha256sum "${file}" | awk '{print $1}')"
|
||||||
|
elif command -v shasum >/dev/null 2>&1; then
|
||||||
|
actual="$(shasum -a 256 "${file}" | awk '{print $1}')"
|
||||||
|
else
|
||||||
|
die "Neither sha256sum nor shasum found. Cannot verify checksum."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${actual}" != "${expected}" ]; then
|
||||||
|
die "Checksum mismatch for ${basename}: expected ${expected}, got ${actual}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Checksum verified: ${basename}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Optional minisign verification -----------------------------------------
|
||||||
|
|
||||||
|
verify_signature() {
|
||||||
|
local checksums_file="$1"
|
||||||
|
local sig_file="${checksums_file}.minisig"
|
||||||
|
|
||||||
|
if [ ! -f "${sig_file}" ]; then
|
||||||
|
info "No signature file found; skipping signature verification."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v minisign >/dev/null 2>&1; then
|
||||||
|
info "minisign not found; skipping signature verification."
|
||||||
|
info "Install minisign to enable: https://jedisct1.github.io/minisign/"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "${SWAGGER_CLI_MINISIGN_PUBKEY:-}" ]; then
|
||||||
|
info "SWAGGER_CLI_MINISIGN_PUBKEY not set; skipping signature verification."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
minisign -V -P "${SWAGGER_CLI_MINISIGN_PUBKEY}" -m "${checksums_file}" \
|
||||||
|
|| die "Signature verification failed for SHA256SUMS"
|
||||||
|
|
||||||
|
info "Signature verified: SHA256SUMS"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Install location -------------------------------------------------------
|
||||||
|
|
||||||
|
select_install_dir() {
|
||||||
|
if [ -w /usr/local/bin ]; then
|
||||||
|
echo "/usr/local/bin"
|
||||||
|
else
|
||||||
|
local user_bin="${HOME}/.local/bin"
|
||||||
|
mkdir -p "${user_bin}"
|
||||||
|
echo "${user_bin}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check_path() {
|
||||||
|
local dir="$1"
|
||||||
|
case ":${PATH}:" in
|
||||||
|
*":${dir}:"*) ;;
|
||||||
|
*)
|
||||||
|
printf '\nWarning: %s is not in your PATH.\n' "${dir}"
|
||||||
|
printf 'Add it with: export PATH="%s:${PATH}"\n' "${dir}"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Download ---------------------------------------------------------------
|
||||||
|
|
||||||
|
download() {
|
||||||
|
local url="$1"
|
||||||
|
local output="$2"
|
||||||
|
|
||||||
|
if command -v curl >/dev/null 2>&1; then
|
||||||
|
curl --fail --silent --location --output "${output}" "${url}"
|
||||||
|
elif command -v wget >/dev/null 2>&1; then
|
||||||
|
wget --quiet --output-document="${output}" "${url}"
|
||||||
|
else
|
||||||
|
die "Neither curl nor wget found. Cannot download."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Main -------------------------------------------------------------------
|
||||||
|
|
||||||
|
main() {
|
||||||
|
local os arch target binary_name download_base
|
||||||
|
|
||||||
|
printf 'Installing swagger-cli %s\n' "${VERSION}"
|
||||||
|
|
||||||
|
os="$(detect_os)"
|
||||||
|
arch="$(detect_arch)"
|
||||||
|
target="${arch}-${os}"
|
||||||
|
binary_name="swagger-cli-${target}"
|
||||||
|
download_base="${REPO_URL}/${VERSION}"
|
||||||
|
|
||||||
|
info "Detected platform: ${target}"
|
||||||
|
|
||||||
|
# Download binary and checksums
|
||||||
|
info "Downloading ${binary_name}..."
|
||||||
|
download "${download_base}/${binary_name}" "${TMPDIR_INSTALL}/${binary_name}"
|
||||||
|
download "${download_base}/SHA256SUMS" "${TMPDIR_INSTALL}/SHA256SUMS"
|
||||||
|
|
||||||
|
# Attempt to download signature file (optional)
|
||||||
|
download "${download_base}/SHA256SUMS.minisig" "${TMPDIR_INSTALL}/SHA256SUMS.minisig" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
verify_checksum "${TMPDIR_INSTALL}/${binary_name}" "${TMPDIR_INSTALL}/SHA256SUMS"
|
||||||
|
verify_signature "${TMPDIR_INSTALL}/SHA256SUMS"
|
||||||
|
|
||||||
|
# Install
|
||||||
|
local install_dir
|
||||||
|
install_dir="$(select_install_dir)"
|
||||||
|
|
||||||
|
install -m 755 "${TMPDIR_INSTALL}/${binary_name}" "${install_dir}/swagger-cli"
|
||||||
|
|
||||||
|
info "Installed to ${install_dir}/swagger-cli"
|
||||||
|
check_path "${install_dir}"
|
||||||
|
|
||||||
|
printf '\nswagger-cli %s installed successfully.\n' "${VERSION}"
|
||||||
|
printf 'Run "swagger-cli --help" to get started.\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -159,8 +159,14 @@ fn check_alias(cm: &CacheManager, alias: &str, stale_threshold_days: u32) -> Ali
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
issues.push(format!("load error: {e}"));
|
issues.push(format!("load error: {e}"));
|
||||||
|
// If raw.json exists, the index can be rebuilt from it
|
||||||
|
if cm.alias_dir(alias).join("raw.json").exists() {
|
||||||
|
status = HealthStatus::Degraded;
|
||||||
|
fixable = true;
|
||||||
|
} else {
|
||||||
status = HealthStatus::Unhealthy;
|
status = HealthStatus::Unhealthy;
|
||||||
unfixable = true;
|
unfixable = true;
|
||||||
|
}
|
||||||
(None, None)
|
(None, None)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use tokio::io::AsyncReadExt;
|
|||||||
|
|
||||||
use crate::core::cache::{CacheManager, compute_hash, validate_alias};
|
use crate::core::cache::{CacheManager, compute_hash, validate_alias};
|
||||||
use crate::core::config::{AuthType, Config, cache_dir, config_path, resolve_credential};
|
use crate::core::config::{AuthType, Config, cache_dir, config_path, resolve_credential};
|
||||||
|
use crate::core::external_refs::{ExternalRefConfig, resolve_external_refs};
|
||||||
use crate::core::http::AsyncHttpClient;
|
use crate::core::http::AsyncHttpClient;
|
||||||
use crate::core::indexer::{Format, build_index, detect_format, normalize_to_json};
|
use crate::core::indexer::{Format, build_index, detect_format, normalize_to_json};
|
||||||
use crate::core::network::{NetworkPolicy, resolve_policy};
|
use crate::core::network::{NetworkPolicy, resolve_policy};
|
||||||
@@ -63,6 +64,22 @@ pub struct Args {
|
|||||||
/// Allow plain HTTP (insecure)
|
/// Allow plain HTTP (insecure)
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub allow_insecure_http: bool,
|
pub allow_insecure_http: bool,
|
||||||
|
|
||||||
|
/// Resolve external $ref entries by fetching and inlining them
|
||||||
|
#[arg(long)]
|
||||||
|
pub resolve_external_refs: bool,
|
||||||
|
|
||||||
|
/// Allowed host for external ref fetching (repeatable, required with --resolve-external-refs)
|
||||||
|
#[arg(long = "ref-allow-host")]
|
||||||
|
pub ref_allow_host: Vec<String>,
|
||||||
|
|
||||||
|
/// Maximum chain depth for transitive external refs (default: 10)
|
||||||
|
#[arg(long, default_value = "10")]
|
||||||
|
pub ref_max_depth: u32,
|
||||||
|
|
||||||
|
/// Maximum total bytes fetched for external refs (default: 10 MB)
|
||||||
|
#[arg(long, default_value = "10485760")]
|
||||||
|
pub ref_max_bytes: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -274,7 +291,36 @@ async fn fetch_inner(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let json_bytes = normalize_to_json(&raw_bytes, format)?;
|
let json_bytes = normalize_to_json(&raw_bytes, format)?;
|
||||||
let value: serde_json::Value = serde_json::from_slice(&json_bytes)?;
|
let mut value: serde_json::Value = serde_json::from_slice(&json_bytes)?;
|
||||||
|
|
||||||
|
// External ref resolution (optional)
|
||||||
|
if args.resolve_external_refs {
|
||||||
|
if args.ref_allow_host.is_empty() {
|
||||||
|
return Err(SwaggerCliError::Usage(
|
||||||
|
"--resolve-external-refs requires at least one --ref-allow-host".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let ref_config = ExternalRefConfig {
|
||||||
|
allow_hosts: args.ref_allow_host.clone(),
|
||||||
|
max_depth: args.ref_max_depth,
|
||||||
|
max_bytes: args.ref_max_bytes,
|
||||||
|
};
|
||||||
|
|
||||||
|
let ref_client = AsyncHttpClient::builder()
|
||||||
|
.overall_timeout(Duration::from_millis(args.timeout_ms))
|
||||||
|
.max_bytes(args.max_bytes)
|
||||||
|
.max_retries(args.retries)
|
||||||
|
.allow_insecure_http(args.allow_insecure_http)
|
||||||
|
.allowed_private_hosts(args.allow_private_host.clone())
|
||||||
|
.network_policy(network_policy)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
resolve_external_refs(&mut value, source_url.as_deref(), &ref_config, &ref_client).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-serialize the (possibly bundled) value to get the final json_bytes
|
||||||
|
let json_bytes = serde_json::to_vec(&value)?;
|
||||||
|
|
||||||
// Compute content hash for indexing
|
// Compute content hash for indexing
|
||||||
let content_hash = compute_hash(&raw_bytes);
|
let content_hash = compute_hash(&raw_bytes);
|
||||||
@@ -438,6 +484,10 @@ mod tests {
|
|||||||
retries: 2,
|
retries: 2,
|
||||||
allow_private_host: vec![],
|
allow_private_host: vec![],
|
||||||
allow_insecure_http: false,
|
allow_insecure_http: false,
|
||||||
|
resolve_external_refs: false,
|
||||||
|
ref_allow_host: vec![],
|
||||||
|
ref_max_depth: 10,
|
||||||
|
ref_max_bytes: 10485760,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -615,6 +665,10 @@ mod tests {
|
|||||||
retries: 2,
|
retries: 2,
|
||||||
allow_private_host: vec![],
|
allow_private_host: vec![],
|
||||||
allow_insecure_http: false,
|
allow_insecure_http: false,
|
||||||
|
resolve_external_refs: false,
|
||||||
|
ref_allow_host: vec![],
|
||||||
|
ref_max_depth: 10,
|
||||||
|
ref_max_bytes: 10485760,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
222
src/cli/list.rs
222
src/cli/list.rs
@@ -16,8 +16,12 @@ use crate::output::table::render_table_or_empty;
|
|||||||
/// List endpoints from a cached spec
|
/// List endpoints from a cached spec
|
||||||
#[derive(Debug, ClapArgs)]
|
#[derive(Debug, ClapArgs)]
|
||||||
pub struct Args {
|
pub struct Args {
|
||||||
/// Alias of the cached spec
|
/// Alias of the cached spec (omit when using --all-aliases)
|
||||||
pub alias: String,
|
pub alias: Option<String>,
|
||||||
|
|
||||||
|
/// Query across every cached alias
|
||||||
|
#[arg(long)]
|
||||||
|
pub all_aliases: bool,
|
||||||
|
|
||||||
/// Filter by HTTP method (case-insensitive)
|
/// Filter by HTTP method (case-insensitive)
|
||||||
#[arg(long, short = 'm')]
|
#[arg(long, short = 'm')]
|
||||||
@@ -57,6 +61,18 @@ struct ListOutput {
|
|||||||
meta: ListMeta,
|
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)]
|
#[derive(Debug, Serialize)]
|
||||||
struct EndpointEntry {
|
struct EndpointEntry {
|
||||||
path: String,
|
path: String,
|
||||||
@@ -67,6 +83,17 @@ struct EndpointEntry {
|
|||||||
deprecated: bool,
|
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)]
|
#[derive(Debug, Serialize)]
|
||||||
struct ListMeta {
|
struct ListMeta {
|
||||||
alias: String,
|
alias: String,
|
||||||
@@ -89,11 +116,31 @@ struct EndpointRow {
|
|||||||
summary: String,
|
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
|
// Execute
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
pub async fn execute(args: &Args, robot_mode: bool) -> Result<(), SwaggerCliError> {
|
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();
|
let start = Instant::now();
|
||||||
|
|
||||||
// Compile path regex early so we fail fast on invalid patterns
|
// Compile path regex early so we fail fast on invalid patterns
|
||||||
@@ -108,7 +155,7 @@ pub async fn execute(args: &Args, robot_mode: bool) -> Result<(), SwaggerCliErro
|
|||||||
};
|
};
|
||||||
|
|
||||||
let cm = CacheManager::new(cache_dir());
|
let cm = CacheManager::new(cache_dir());
|
||||||
let (index, meta) = cm.load_index(&args.alias)?;
|
let (index, meta) = cm.load_index(alias)?;
|
||||||
|
|
||||||
let total = index.endpoints.len();
|
let total = index.endpoints.len();
|
||||||
|
|
||||||
@@ -211,7 +258,7 @@ pub async fn execute(args: &Args, robot_mode: bool) -> Result<(), SwaggerCliErro
|
|||||||
filtered: filtered_count,
|
filtered: filtered_count,
|
||||||
applied_filters,
|
applied_filters,
|
||||||
meta: ListMeta {
|
meta: ListMeta {
|
||||||
alias: args.alias.clone(),
|
alias: alias.to_string(),
|
||||||
spec_version: meta.spec_version.clone(),
|
spec_version: meta.spec_version.clone(),
|
||||||
cached_at: meta.fetched_at.to_rfc3339(),
|
cached_at: meta.fetched_at.to_rfc3339(),
|
||||||
duration_ms: duration.as_millis().min(u64::MAX as u128) as u64,
|
duration_ms: duration.as_millis().min(u64::MAX as u128) as u64,
|
||||||
@@ -253,6 +300,173 @@ pub async fn execute(args: &Args, robot_mode: bool) -> Result<(), SwaggerCliErro
|
|||||||
Ok(())
|
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 ASC, path ASC, method_rank ASC
|
||||||
|
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
|
// Tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use serde::Serialize;
|
|||||||
|
|
||||||
use crate::core::cache::CacheManager;
|
use crate::core::cache::CacheManager;
|
||||||
use crate::core::config::cache_dir;
|
use crate::core::config::cache_dir;
|
||||||
|
use crate::core::indexer::method_rank;
|
||||||
use crate::core::search::{SearchEngine, SearchOptions, SearchResult, SearchResultType};
|
use crate::core::search::{SearchEngine, SearchOptions, SearchResult, SearchResultType};
|
||||||
use crate::errors::SwaggerCliError;
|
use crate::errors::SwaggerCliError;
|
||||||
use crate::output::robot;
|
use crate::output::robot;
|
||||||
@@ -12,11 +13,17 @@ use crate::output::robot;
|
|||||||
/// Search endpoints and schemas by keyword
|
/// Search endpoints and schemas by keyword
|
||||||
#[derive(Debug, ClapArgs)]
|
#[derive(Debug, ClapArgs)]
|
||||||
pub struct Args {
|
pub struct Args {
|
||||||
/// Alias of the cached spec
|
/// Alias of the cached spec, or search query when using --all-aliases
|
||||||
pub alias: String,
|
#[arg(required = true)]
|
||||||
|
pub alias_or_query: String,
|
||||||
|
|
||||||
/// Search query
|
/// Search query (required unless --all-aliases is used, in which case the
|
||||||
pub query: String,
|
/// first positional argument is treated as the query)
|
||||||
|
pub query: Option<String>,
|
||||||
|
|
||||||
|
/// Query across every cached alias
|
||||||
|
#[arg(long)]
|
||||||
|
pub all_aliases: bool,
|
||||||
|
|
||||||
/// Case-sensitive matching
|
/// Case-sensitive matching
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
@@ -45,6 +52,15 @@ struct RobotOutput {
|
|||||||
total: usize,
|
total: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct AllAliasesRobotOutput {
|
||||||
|
results: Vec<AliasRobotResult>,
|
||||||
|
aliases_searched: Vec<String>,
|
||||||
|
total: usize,
|
||||||
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||||
|
warnings: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
struct RobotResult {
|
struct RobotResult {
|
||||||
#[serde(rename = "type")]
|
#[serde(rename = "type")]
|
||||||
@@ -59,6 +75,21 @@ struct RobotResult {
|
|||||||
matches: Vec<RobotMatch>,
|
matches: Vec<RobotMatch>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct AliasRobotResult {
|
||||||
|
alias: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
result_type: &'static str,
|
||||||
|
name: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
method: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
summary: Option<String>,
|
||||||
|
rank: usize,
|
||||||
|
score: u32,
|
||||||
|
matches: Vec<RobotMatch>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
struct RobotMatch {
|
struct RobotMatch {
|
||||||
field: String,
|
field: String,
|
||||||
@@ -123,7 +154,31 @@ fn parse_in_fields(raw: &str) -> Result<(bool, bool, bool), SwaggerCliError> {
|
|||||||
// Execute
|
// Execute
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Extract (alias, query) from args. When `--all-aliases` is set, the first
|
||||||
|
/// positional is the query; otherwise first is alias and second is query.
|
||||||
|
fn resolve_alias_and_query(args: &Args) -> Result<(Option<&str>, &str), SwaggerCliError> {
|
||||||
|
if args.all_aliases {
|
||||||
|
// In all-aliases mode: alias_or_query IS the query
|
||||||
|
Ok((None, &args.alias_or_query))
|
||||||
|
} else {
|
||||||
|
// Normal mode: alias_or_query is the alias, query is required
|
||||||
|
let query = args.query.as_deref().ok_or_else(|| {
|
||||||
|
SwaggerCliError::Usage(
|
||||||
|
"A search query is required. Usage: search <alias> <query>".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok((Some(args.alias_or_query.as_str()), query))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn execute(args: &Args, robot_mode: bool) -> Result<(), SwaggerCliError> {
|
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, query) = resolve_alias_and_query(args)?;
|
||||||
|
let alias = alias.expect("alias is always Some in non-all-aliases mode");
|
||||||
|
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
|
|
||||||
let (search_paths, search_descriptions, search_schemas) = match &args.in_fields {
|
let (search_paths, search_descriptions, search_schemas) = match &args.in_fields {
|
||||||
@@ -132,7 +187,7 @@ pub async fn execute(args: &Args, robot_mode: bool) -> Result<(), SwaggerCliErro
|
|||||||
};
|
};
|
||||||
|
|
||||||
let cm = CacheManager::new(cache_dir());
|
let cm = CacheManager::new(cache_dir());
|
||||||
let (index, _meta) = cm.load_index(&args.alias)?;
|
let (index, _meta) = cm.load_index(alias)?;
|
||||||
|
|
||||||
let opts = SearchOptions {
|
let opts = SearchOptions {
|
||||||
search_paths,
|
search_paths,
|
||||||
@@ -144,7 +199,7 @@ pub async fn execute(args: &Args, robot_mode: bool) -> Result<(), SwaggerCliErro
|
|||||||
};
|
};
|
||||||
|
|
||||||
let engine = SearchEngine::new(&index);
|
let engine = SearchEngine::new(&index);
|
||||||
let results = engine.search(&args.query, &opts);
|
let results = engine.search(query, &opts);
|
||||||
|
|
||||||
if robot_mode {
|
if robot_mode {
|
||||||
let output = RobotOutput {
|
let output = RobotOutput {
|
||||||
@@ -153,13 +208,12 @@ pub async fn execute(args: &Args, robot_mode: bool) -> Result<(), SwaggerCliErro
|
|||||||
};
|
};
|
||||||
robot::robot_success(output, "search", start.elapsed());
|
robot::robot_success(output, "search", start.elapsed());
|
||||||
} else if results.is_empty() {
|
} else if results.is_empty() {
|
||||||
println!("No results found for '{}'", args.query);
|
println!("No results found for '{query}'");
|
||||||
} else {
|
} else {
|
||||||
println!(
|
println!(
|
||||||
"Found {} result{} for '{}':\n",
|
"Found {} result{} for '{query}':\n",
|
||||||
results.len(),
|
results.len(),
|
||||||
if results.len() == 1 { "" } else { "s" },
|
if results.len() == 1 { "" } else { "s" },
|
||||||
args.query,
|
|
||||||
);
|
);
|
||||||
for r in &results {
|
for r in &results {
|
||||||
let type_label = match r.result_type {
|
let type_label = match r.result_type {
|
||||||
@@ -188,3 +242,168 @@ pub async fn execute(args: &Args, robot_mode: bool) -> Result<(), SwaggerCliErro
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// All-aliases execution
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn execute_all_aliases(args: &Args, robot_mode: bool) -> Result<(), SwaggerCliError> {
|
||||||
|
let (_alias, query) = resolve_alias_and_query(args)?;
|
||||||
|
let start = Instant::now();
|
||||||
|
|
||||||
|
let (search_paths, search_descriptions, search_schemas) = match &args.in_fields {
|
||||||
|
Some(fields) => parse_in_fields(fields)?,
|
||||||
|
None => (true, true, true),
|
||||||
|
};
|
||||||
|
|
||||||
|
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 opts = SearchOptions {
|
||||||
|
search_paths,
|
||||||
|
search_descriptions,
|
||||||
|
search_schemas,
|
||||||
|
case_sensitive: args.case_sensitive,
|
||||||
|
exact: args.exact,
|
||||||
|
limit: usize::MAX, // collect all, then limit after merge
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut all_results: Vec<(String, SearchResult)> = Vec::new();
|
||||||
|
let mut aliases_searched: Vec<String> = Vec::new();
|
||||||
|
let mut warnings: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
let mut sorted_aliases: Vec<_> = alias_metas.iter().map(|m| m.alias.clone()).collect();
|
||||||
|
sorted_aliases.sort();
|
||||||
|
|
||||||
|
for alias_name in &sorted_aliases {
|
||||||
|
match cm.load_index(alias_name) {
|
||||||
|
Ok((index, _meta)) => {
|
||||||
|
aliases_searched.push(alias_name.clone());
|
||||||
|
let engine = SearchEngine::new(&index);
|
||||||
|
let results = engine.search(query, &opts);
|
||||||
|
for r in results {
|
||||||
|
all_results.push((alias_name.clone(), r));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by score DESC, then type ordinal ASC, name ASC, method_rank ASC, alias ASC
|
||||||
|
all_results.sort_by(|(alias_a, a), (alias_b, b)| {
|
||||||
|
b.score
|
||||||
|
.cmp(&a.score)
|
||||||
|
.then_with(|| a.result_type.ordinal().cmp(&b.result_type.ordinal()))
|
||||||
|
.then_with(|| a.name.cmp(&b.name))
|
||||||
|
.then_with(|| {
|
||||||
|
let a_rank = a.method.as_deref().map(method_rank).unwrap_or(u8::MAX);
|
||||||
|
let b_rank = b.method.as_deref().map(method_rank).unwrap_or(u8::MAX);
|
||||||
|
a_rank.cmp(&b_rank)
|
||||||
|
})
|
||||||
|
.then_with(|| alias_a.cmp(alias_b))
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply limit
|
||||||
|
all_results.truncate(args.limit);
|
||||||
|
|
||||||
|
// Assign 1-based ranks
|
||||||
|
for (i, (_alias, result)) in all_results.iter_mut().enumerate() {
|
||||||
|
result.rank = i + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let total = all_results.len();
|
||||||
|
|
||||||
|
if robot_mode {
|
||||||
|
let robot_results: Vec<AliasRobotResult> = all_results
|
||||||
|
.iter()
|
||||||
|
.map(|(alias, r)| AliasRobotResult {
|
||||||
|
alias: alias.clone(),
|
||||||
|
result_type: match r.result_type {
|
||||||
|
SearchResultType::Endpoint => "endpoint",
|
||||||
|
SearchResultType::Schema => "schema",
|
||||||
|
},
|
||||||
|
name: r.name.clone(),
|
||||||
|
method: r.method.clone(),
|
||||||
|
summary: r.summary.clone(),
|
||||||
|
rank: r.rank,
|
||||||
|
score: r.score,
|
||||||
|
matches: r
|
||||||
|
.matches
|
||||||
|
.iter()
|
||||||
|
.map(|m| RobotMatch {
|
||||||
|
field: m.field.clone(),
|
||||||
|
snippet: m.snippet.clone(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let output = AllAliasesRobotOutput {
|
||||||
|
total,
|
||||||
|
results: robot_results,
|
||||||
|
aliases_searched,
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
|
robot::robot_success(output, "search", start.elapsed());
|
||||||
|
} else {
|
||||||
|
if all_results.is_empty() {
|
||||||
|
println!(
|
||||||
|
"No results found for '{query}' across {} aliases",
|
||||||
|
aliases_searched.len()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
println!(
|
||||||
|
"Found {} result{} for '{query}' across {} aliases:\n",
|
||||||
|
total,
|
||||||
|
if total == 1 { "" } else { "s" },
|
||||||
|
aliases_searched.len(),
|
||||||
|
);
|
||||||
|
for (alias, r) in &all_results {
|
||||||
|
let type_label = match r.result_type {
|
||||||
|
SearchResultType::Endpoint => "endpoint",
|
||||||
|
SearchResultType::Schema => "schema",
|
||||||
|
};
|
||||||
|
let method_str = r
|
||||||
|
.method
|
||||||
|
.as_deref()
|
||||||
|
.map(|m| format!("{m} "))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let summary_str = r
|
||||||
|
.summary
|
||||||
|
.as_deref()
|
||||||
|
.map(|s| format!(" - {s}"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
println!(
|
||||||
|
" {rank}. [{alias}] [{type_label}] {method_str}{name}{summary_str} (score: {score})",
|
||||||
|
rank = r.rank,
|
||||||
|
name = r.name,
|
||||||
|
score = r.score,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !warnings.is_empty() {
|
||||||
|
println!();
|
||||||
|
for w in &warnings {
|
||||||
|
eprintln!("Warning: {w}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
1114
src/cli/sync_cmd.rs
1114
src/cli/sync_cmd.rs
File diff suppressed because it is too large
Load Diff
641
src/core/external_refs.rs
Normal file
641
src/core/external_refs.rs
Normal file
@@ -0,0 +1,641 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
|
use std::future::Future;
|
||||||
|
use std::pin::Pin;
|
||||||
|
|
||||||
|
use reqwest::Url;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::core::http::AsyncHttpClient;
|
||||||
|
use crate::core::indexer::{detect_format, normalize_to_json};
|
||||||
|
use crate::errors::SwaggerCliError;
|
||||||
|
|
||||||
|
/// Configuration for external `$ref` resolution.
|
||||||
|
pub struct ExternalRefConfig {
|
||||||
|
/// Allowed hostnames for external ref fetching.
|
||||||
|
pub allow_hosts: Vec<String>,
|
||||||
|
/// Maximum chain depth for transitive external refs.
|
||||||
|
pub max_depth: u32,
|
||||||
|
/// Maximum total bytes fetched across all external refs.
|
||||||
|
pub max_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Statistics returned after resolving external refs.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct ResolveStats {
|
||||||
|
pub refs_resolved: usize,
|
||||||
|
pub refs_skipped: usize,
|
||||||
|
pub total_bytes_fetched: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve all external `$ref` entries in `value` by fetching and inlining them.
|
||||||
|
///
|
||||||
|
/// - Only resolves refs whose URL host is in `config.allow_hosts`.
|
||||||
|
/// - Internal refs (starting with `#/`) are left untouched.
|
||||||
|
/// - Circular external refs are detected and replaced with a marker.
|
||||||
|
/// - Stops when `config.max_depth` or `config.max_bytes` is exceeded.
|
||||||
|
///
|
||||||
|
/// Returns the resolution statistics.
|
||||||
|
pub async fn resolve_external_refs(
|
||||||
|
value: &mut Value,
|
||||||
|
base_url: Option<&str>,
|
||||||
|
config: &ExternalRefConfig,
|
||||||
|
client: &AsyncHttpClient,
|
||||||
|
) -> Result<ResolveStats, SwaggerCliError> {
|
||||||
|
let mut visited = HashSet::new();
|
||||||
|
let mut stats = ResolveStats::default();
|
||||||
|
|
||||||
|
resolve_recursive(value, base_url, config, client, 0, &mut visited, &mut stats).await?;
|
||||||
|
|
||||||
|
Ok(stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_recursive<'a>(
|
||||||
|
value: &'a mut Value,
|
||||||
|
base_url: Option<&'a str>,
|
||||||
|
config: &'a ExternalRefConfig,
|
||||||
|
client: &'a AsyncHttpClient,
|
||||||
|
depth: u32,
|
||||||
|
visited: &'a mut HashSet<String>,
|
||||||
|
stats: &'a mut ResolveStats,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<(), SwaggerCliError>> + Send + 'a>> {
|
||||||
|
Box::pin(async move {
|
||||||
|
if let Some(ref_str) = extract_external_ref(value) {
|
||||||
|
// Internal refs: leave untouched
|
||||||
|
if ref_str.starts_with("#/") {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the URL (may be relative)
|
||||||
|
let resolved_url = resolve_ref_url(&ref_str, base_url)?;
|
||||||
|
|
||||||
|
// Check for cycles
|
||||||
|
if visited.contains(&resolved_url) {
|
||||||
|
*value = serde_json::json!({ "$circular_external_ref": resolved_url });
|
||||||
|
stats.refs_skipped += 1;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check depth limit
|
||||||
|
if depth >= config.max_depth {
|
||||||
|
stats.refs_skipped += 1;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check host allowlist
|
||||||
|
let parsed = Url::parse(&resolved_url).map_err(|e| {
|
||||||
|
SwaggerCliError::InvalidSpec(format!(
|
||||||
|
"invalid external ref URL '{resolved_url}': {e}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let host = parsed.host_str().ok_or_else(|| {
|
||||||
|
SwaggerCliError::InvalidSpec(format!(
|
||||||
|
"external ref URL '{resolved_url}' has no host"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !config.allow_hosts.iter().any(|h| h == host) {
|
||||||
|
return Err(SwaggerCliError::PolicyBlocked(format!(
|
||||||
|
"external ref host '{host}' is not in --ref-allow-host allowlist. \
|
||||||
|
Add --ref-allow-host {host} to allow fetching from this host."
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check bytes limit before fetch
|
||||||
|
if stats.total_bytes_fetched >= config.max_bytes {
|
||||||
|
stats.refs_skipped += 1;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the external ref
|
||||||
|
let result = client.fetch_spec(&resolved_url).await?;
|
||||||
|
let fetched_bytes = result.bytes.len() as u64;
|
||||||
|
|
||||||
|
if stats.total_bytes_fetched + fetched_bytes > config.max_bytes {
|
||||||
|
return Err(SwaggerCliError::PolicyBlocked(format!(
|
||||||
|
"external ref total bytes would exceed --ref-max-bytes limit of {}. \
|
||||||
|
Resolved {} refs ({} bytes) before hitting the limit.",
|
||||||
|
config.max_bytes, stats.refs_resolved, stats.total_bytes_fetched
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.total_bytes_fetched += fetched_bytes;
|
||||||
|
|
||||||
|
// Parse the fetched content as JSON or YAML
|
||||||
|
let format = detect_format(
|
||||||
|
&result.bytes,
|
||||||
|
Some(&resolved_url),
|
||||||
|
result.content_type.as_deref(),
|
||||||
|
);
|
||||||
|
let json_bytes = normalize_to_json(&result.bytes, format).map_err(|_| {
|
||||||
|
SwaggerCliError::InvalidSpec(format!(
|
||||||
|
"external ref '{resolved_url}' returned invalid JSON/YAML"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut fetched_value: Value = serde_json::from_slice(&json_bytes)?;
|
||||||
|
|
||||||
|
// Handle fragment pointer within the fetched document
|
||||||
|
if let Some(frag) = parsed.fragment()
|
||||||
|
&& !frag.is_empty()
|
||||||
|
{
|
||||||
|
let pointer = if frag.starts_with('/') {
|
||||||
|
frag.to_string()
|
||||||
|
} else {
|
||||||
|
format!("/{frag}")
|
||||||
|
};
|
||||||
|
fetched_value = crate::core::refs::resolve_json_pointer(&fetched_value, &pointer)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
SwaggerCliError::InvalidSpec(format!(
|
||||||
|
"fragment '{pointer}' not found in external ref '{resolved_url}'"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as visited for cycle detection, then recursively resolve nested external refs
|
||||||
|
visited.insert(resolved_url.clone());
|
||||||
|
|
||||||
|
// The base URL for nested refs is the URL of the document we just fetched (without fragment)
|
||||||
|
let nested_base = strip_fragment(&resolved_url);
|
||||||
|
resolve_recursive(
|
||||||
|
&mut fetched_value,
|
||||||
|
Some(&nested_base),
|
||||||
|
config,
|
||||||
|
client,
|
||||||
|
depth + 1,
|
||||||
|
visited,
|
||||||
|
stats,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
visited.remove(&resolved_url);
|
||||||
|
|
||||||
|
*value = fetched_value;
|
||||||
|
stats.refs_resolved += 1;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk into objects and arrays
|
||||||
|
match value {
|
||||||
|
Value::Object(map) => {
|
||||||
|
// Collect keys first to satisfy borrow checker with recursive async
|
||||||
|
let keys: Vec<String> = map.keys().cloned().collect();
|
||||||
|
for key in keys {
|
||||||
|
if let Some(val) = map.get_mut(&key) {
|
||||||
|
resolve_recursive(val, base_url, config, client, depth, visited, stats)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Value::Array(arr) => {
|
||||||
|
for item in arr.iter_mut() {
|
||||||
|
resolve_recursive(item, base_url, config, client, depth, visited, stats)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the `$ref` string from a JSON object if present.
|
||||||
|
fn extract_external_ref(value: &Value) -> Option<String> {
|
||||||
|
let map = value.as_object()?;
|
||||||
|
let ref_val = map.get("$ref")?;
|
||||||
|
Some(ref_val.as_str()?.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a possibly-relative ref URL against a base URL.
|
||||||
|
fn resolve_ref_url(ref_str: &str, base_url: Option<&str>) -> Result<String, SwaggerCliError> {
|
||||||
|
// If the ref is already absolute, use it directly
|
||||||
|
if ref_str.contains("://") {
|
||||||
|
return Ok(ref_str.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relative ref requires a base URL
|
||||||
|
let base = base_url.ok_or_else(|| {
|
||||||
|
SwaggerCliError::InvalidSpec(format!(
|
||||||
|
"relative external ref '{ref_str}' cannot be resolved without a base URL"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let base_parsed = Url::parse(base)
|
||||||
|
.map_err(|e| SwaggerCliError::InvalidSpec(format!("invalid base URL '{base}': {e}")))?;
|
||||||
|
|
||||||
|
base_parsed
|
||||||
|
.join(ref_str)
|
||||||
|
.map(|u| u.to_string())
|
||||||
|
.map_err(|e| {
|
||||||
|
SwaggerCliError::InvalidSpec(format!(
|
||||||
|
"failed to resolve relative ref '{ref_str}' against base '{base}': {e}"
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strip the fragment portion from a URL string.
|
||||||
|
fn strip_fragment(url: &str) -> String {
|
||||||
|
match url.find('#') {
|
||||||
|
Some(idx) => url[..idx].to_string(),
|
||||||
|
None => url.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
// -- URL resolution -------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_absolute_ref() {
|
||||||
|
let result = resolve_ref_url("https://example.com/schemas/Pet.json", None).unwrap();
|
||||||
|
assert_eq!(result, "https://example.com/schemas/Pet.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_relative_ref() {
|
||||||
|
let result = resolve_ref_url(
|
||||||
|
"./schemas/Pet.json",
|
||||||
|
Some("https://example.com/api/spec.json"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result, "https://example.com/api/schemas/Pet.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_relative_parent() {
|
||||||
|
let result = resolve_ref_url(
|
||||||
|
"../schemas/Pet.json",
|
||||||
|
Some("https://example.com/api/v1/spec.json"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result, "https://example.com/api/schemas/Pet.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_relative_without_base_fails() {
|
||||||
|
let result = resolve_ref_url("./schemas/Pet.json", None);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Fragment stripping ---------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_strip_fragment() {
|
||||||
|
assert_eq!(
|
||||||
|
strip_fragment("https://example.com/spec.json#/components/schemas/Pet"),
|
||||||
|
"https://example.com/spec.json"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
strip_fragment("https://example.com/spec.json"),
|
||||||
|
"https://example.com/spec.json"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- External ref extraction ----------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_external_ref_present() {
|
||||||
|
let v = json!({"$ref": "https://example.com/Pet.json"});
|
||||||
|
assert_eq!(
|
||||||
|
extract_external_ref(&v),
|
||||||
|
Some("https://example.com/Pet.json".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_external_ref_internal() {
|
||||||
|
let v = json!({"$ref": "#/components/schemas/Pet"});
|
||||||
|
assert_eq!(
|
||||||
|
extract_external_ref(&v),
|
||||||
|
Some("#/components/schemas/Pet".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_external_ref_absent() {
|
||||||
|
let v = json!({"type": "string"});
|
||||||
|
assert_eq!(extract_external_ref(&v), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Integration tests with mockito ---------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_resolve_external_ref_allowed_host() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
let host = server.host_with_port();
|
||||||
|
|
||||||
|
let pet_schema = json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": { "type": "string" }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let _mock = server
|
||||||
|
.mock("GET", "/schemas/Pet.json")
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(serde_json::to_string(&pet_schema).unwrap())
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let spec_url = format!("http://{host}/api/spec.json");
|
||||||
|
let ref_url = format!("http://{host}/schemas/Pet.json");
|
||||||
|
|
||||||
|
let mut value = json!({
|
||||||
|
"openapi": "3.0.3",
|
||||||
|
"components": {
|
||||||
|
"schemas": {
|
||||||
|
"Pet": { "$ref": ref_url }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let hostname = host.split(':').next().unwrap().to_string();
|
||||||
|
let config = ExternalRefConfig {
|
||||||
|
allow_hosts: vec![hostname.clone()],
|
||||||
|
max_depth: 5,
|
||||||
|
max_bytes: 1_048_576,
|
||||||
|
};
|
||||||
|
|
||||||
|
let client = AsyncHttpClient::builder()
|
||||||
|
.allow_insecure_http(true)
|
||||||
|
.allowed_private_hosts(vec![hostname.clone()])
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let stats = resolve_external_refs(&mut value, Some(&spec_url), &config, &client)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(stats.refs_resolved, 1);
|
||||||
|
assert_eq!(value["components"]["schemas"]["Pet"]["type"], "object");
|
||||||
|
assert_eq!(
|
||||||
|
value["components"]["schemas"]["Pet"]["properties"]["name"]["type"],
|
||||||
|
"string"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_resolve_external_ref_disallowed_host() {
|
||||||
|
let mut value = json!({
|
||||||
|
"schema": { "$ref": "https://evil.example.com/schemas/Pet.json" }
|
||||||
|
});
|
||||||
|
|
||||||
|
let config = ExternalRefConfig {
|
||||||
|
allow_hosts: vec!["safe.example.com".to_string()],
|
||||||
|
max_depth: 5,
|
||||||
|
max_bytes: 1_048_576,
|
||||||
|
};
|
||||||
|
|
||||||
|
let client = AsyncHttpClient::builder().build();
|
||||||
|
|
||||||
|
let result = resolve_external_refs(&mut value, None, &config, &client).await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
match result.unwrap_err() {
|
||||||
|
SwaggerCliError::PolicyBlocked(msg) => {
|
||||||
|
assert!(msg.contains("evil.example.com"));
|
||||||
|
assert!(msg.contains("--ref-allow-host"));
|
||||||
|
}
|
||||||
|
other => panic!("expected PolicyBlocked, got: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_resolve_internal_refs_untouched() {
|
||||||
|
let mut value = json!({
|
||||||
|
"schema": { "$ref": "#/components/schemas/Pet" }
|
||||||
|
});
|
||||||
|
|
||||||
|
let config = ExternalRefConfig {
|
||||||
|
allow_hosts: vec![],
|
||||||
|
max_depth: 5,
|
||||||
|
max_bytes: 1_048_576,
|
||||||
|
};
|
||||||
|
|
||||||
|
let client = AsyncHttpClient::builder().build();
|
||||||
|
|
||||||
|
let stats = resolve_external_refs(&mut value, None, &config, &client)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(stats.refs_resolved, 0);
|
||||||
|
assert_eq!(value["schema"]["$ref"], "#/components/schemas/Pet");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_resolve_max_depth_limits_chains() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
let host = server.host_with_port();
|
||||||
|
|
||||||
|
// Chain: spec -> A.json -> B.json
|
||||||
|
// With max_depth=1, only A.json should be resolved
|
||||||
|
|
||||||
|
let b_schema = json!({
|
||||||
|
"type": "string",
|
||||||
|
"from": "B"
|
||||||
|
});
|
||||||
|
|
||||||
|
let _mock_b = server
|
||||||
|
.mock("GET", "/B.json")
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(serde_json::to_string(&b_schema).unwrap())
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let a_schema = json!({
|
||||||
|
"type": "object",
|
||||||
|
"nested": { "$ref": format!("http://{host}/B.json") }
|
||||||
|
});
|
||||||
|
|
||||||
|
let _mock_a = server
|
||||||
|
.mock("GET", "/A.json")
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(serde_json::to_string(&a_schema).unwrap())
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let ref_url = format!("http://{host}/A.json");
|
||||||
|
let mut value = json!({
|
||||||
|
"schema": { "$ref": ref_url }
|
||||||
|
});
|
||||||
|
|
||||||
|
let hostname = host.split(':').next().unwrap().to_string();
|
||||||
|
let config = ExternalRefConfig {
|
||||||
|
allow_hosts: vec![hostname.clone()],
|
||||||
|
max_depth: 1, // Only one level deep
|
||||||
|
max_bytes: 1_048_576,
|
||||||
|
};
|
||||||
|
|
||||||
|
let client = AsyncHttpClient::builder()
|
||||||
|
.allow_insecure_http(true)
|
||||||
|
.allowed_private_hosts(vec![hostname.clone()])
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let stats = resolve_external_refs(&mut value, None, &config, &client)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// A was resolved (depth 0), B was skipped (depth 1 >= max_depth 1)
|
||||||
|
assert_eq!(stats.refs_resolved, 1);
|
||||||
|
assert_eq!(stats.refs_skipped, 1);
|
||||||
|
assert_eq!(value["schema"]["type"], "object");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_resolve_circular_external_refs() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
let host = server.host_with_port();
|
||||||
|
|
||||||
|
// A.json refs B.json, B.json refs A.json
|
||||||
|
let b_schema = json!({
|
||||||
|
"type": "object",
|
||||||
|
"back": { "$ref": format!("http://{host}/A.json") }
|
||||||
|
});
|
||||||
|
|
||||||
|
let _mock_b = server
|
||||||
|
.mock("GET", "/B.json")
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(serde_json::to_string(&b_schema).unwrap())
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let a_schema = json!({
|
||||||
|
"type": "object",
|
||||||
|
"next": { "$ref": format!("http://{host}/B.json") }
|
||||||
|
});
|
||||||
|
|
||||||
|
let _mock_a = server
|
||||||
|
.mock("GET", "/A.json")
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(serde_json::to_string(&a_schema).unwrap())
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let ref_url = format!("http://{host}/A.json");
|
||||||
|
let mut value = json!({
|
||||||
|
"schema": { "$ref": ref_url }
|
||||||
|
});
|
||||||
|
|
||||||
|
let hostname = host.split(':').next().unwrap().to_string();
|
||||||
|
let config = ExternalRefConfig {
|
||||||
|
allow_hosts: vec![hostname.clone()],
|
||||||
|
max_depth: 10,
|
||||||
|
max_bytes: 1_048_576,
|
||||||
|
};
|
||||||
|
|
||||||
|
let client = AsyncHttpClient::builder()
|
||||||
|
.allow_insecure_http(true)
|
||||||
|
.allowed_private_hosts(vec![hostname.clone()])
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let stats = resolve_external_refs(&mut value, None, &config, &client)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// A and B resolved, but the circular back-ref to A was detected
|
||||||
|
assert_eq!(stats.refs_resolved, 2);
|
||||||
|
assert_eq!(stats.refs_skipped, 1);
|
||||||
|
assert!(
|
||||||
|
value["schema"]["next"]["back"]
|
||||||
|
.get("$circular_external_ref")
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_resolve_max_bytes_exceeded() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
let host = server.host_with_port();
|
||||||
|
|
||||||
|
let large_body = "x".repeat(500);
|
||||||
|
|
||||||
|
let _mock = server
|
||||||
|
.mock("GET", "/big.json")
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(format!("{{\"data\": \"{large_body}\"}}"))
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let ref_url = format!("http://{host}/big.json");
|
||||||
|
let mut value = json!({
|
||||||
|
"schema": { "$ref": ref_url }
|
||||||
|
});
|
||||||
|
|
||||||
|
let hostname = host.split(':').next().unwrap().to_string();
|
||||||
|
let config = ExternalRefConfig {
|
||||||
|
allow_hosts: vec![hostname.clone()],
|
||||||
|
max_depth: 5,
|
||||||
|
max_bytes: 100, // Very small limit
|
||||||
|
};
|
||||||
|
|
||||||
|
let client = AsyncHttpClient::builder()
|
||||||
|
.allow_insecure_http(true)
|
||||||
|
.allowed_private_hosts(vec![hostname.clone()])
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let result = resolve_external_refs(&mut value, None, &config, &client).await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
match result.unwrap_err() {
|
||||||
|
SwaggerCliError::PolicyBlocked(msg) => {
|
||||||
|
assert!(msg.contains("--ref-max-bytes"));
|
||||||
|
}
|
||||||
|
other => panic!("expected PolicyBlocked, got: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_resolve_relative_ref_integration() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
let host = server.host_with_port();
|
||||||
|
|
||||||
|
let pet_schema = json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": { "type": "string" }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let _mock = server
|
||||||
|
.mock("GET", "/api/schemas/Pet.json")
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(serde_json::to_string(&pet_schema).unwrap())
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let base_url = format!("http://{host}/api/spec.json");
|
||||||
|
let mut value = json!({
|
||||||
|
"schema": { "$ref": "./schemas/Pet.json" }
|
||||||
|
});
|
||||||
|
|
||||||
|
let hostname = host.split(':').next().unwrap().to_string();
|
||||||
|
let config = ExternalRefConfig {
|
||||||
|
allow_hosts: vec![hostname.clone()],
|
||||||
|
max_depth: 5,
|
||||||
|
max_bytes: 1_048_576,
|
||||||
|
};
|
||||||
|
|
||||||
|
let client = AsyncHttpClient::builder()
|
||||||
|
.allow_insecure_http(true)
|
||||||
|
.allowed_private_hosts(vec![hostname.clone()])
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let stats = resolve_external_refs(&mut value, Some(&base_url), &config, &client)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(stats.refs_resolved, 1);
|
||||||
|
assert_eq!(value["schema"]["type"], "object");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
pub mod cache;
|
pub mod cache;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod diff;
|
pub mod diff;
|
||||||
|
pub mod external_refs;
|
||||||
pub mod http;
|
pub mod http;
|
||||||
pub mod indexer;
|
pub mod indexer;
|
||||||
pub mod network;
|
pub mod network;
|
||||||
|
|||||||
@@ -63,10 +63,12 @@ fn expand_recursive(
|
|||||||
let pointer = &ref_str[1..]; // strip leading '#'
|
let pointer = &ref_str[1..]; // strip leading '#'
|
||||||
if let Some(resolved) = resolve_json_pointer(root, pointer) {
|
if let Some(resolved) = resolve_json_pointer(root, pointer) {
|
||||||
let mut expanded = resolved.clone();
|
let mut expanded = resolved.clone();
|
||||||
visited.insert(ref_str);
|
visited.insert(ref_str.clone());
|
||||||
expand_recursive(&mut expanded, root, max_depth, depth + 1, visited);
|
expand_recursive(&mut expanded, root, max_depth, depth + 1, visited);
|
||||||
// Do not remove from visited: keep it for sibling detection within the same
|
// Remove after expansion so sibling subtrees can also expand this ref.
|
||||||
// subtree path. The caller manages the visited set across siblings.
|
// The ancestor path (tracked via depth) still prevents true circular refs
|
||||||
|
// because the ref is in `visited` during its own subtree's expansion.
|
||||||
|
visited.remove(&ref_str);
|
||||||
*value = expanded;
|
*value = expanded;
|
||||||
}
|
}
|
||||||
// If pointer doesn't resolve, leave the $ref as-is (broken ref)
|
// If pointer doesn't resolve, leave the $ref as-is (broken ref)
|
||||||
@@ -292,4 +294,58 @@ mod tests {
|
|||||||
// Broken internal ref left untouched
|
// Broken internal ref left untouched
|
||||||
assert_eq!(value, original);
|
assert_eq!(value, original);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_expand_shared_refs_both_expand() {
|
||||||
|
// Two sibling subtrees reference the same schema. Both should expand
|
||||||
|
// correctly -- shared refs are NOT circular.
|
||||||
|
let root = json!({
|
||||||
|
"components": {
|
||||||
|
"schemas": {
|
||||||
|
"Pet": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": { "type": "string" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut value = json!({
|
||||||
|
"requestBody": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/Pet" }
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/Pet" }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expand_refs(&mut value, &root, 5);
|
||||||
|
|
||||||
|
// Both should be fully expanded (not marked as $circular_ref)
|
||||||
|
assert_eq!(value["requestBody"]["schema"]["type"], "object");
|
||||||
|
assert_eq!(
|
||||||
|
value["requestBody"]["schema"]["properties"]["name"]["type"],
|
||||||
|
"string"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(value["response"]["schema"]["type"], "object");
|
||||||
|
assert_eq!(
|
||||||
|
value["response"]["schema"]["properties"]["name"]["type"],
|
||||||
|
"string"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Neither should have $circular_ref
|
||||||
|
assert!(
|
||||||
|
value["requestBody"]["schema"]
|
||||||
|
.get("$circular_ref")
|
||||||
|
.is_none(),
|
||||||
|
"requestBody ref should not be marked circular"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
value["response"]["schema"].get("$circular_ref").is_none(),
|
||||||
|
"response ref should not be marked circular"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ pub enum SearchResultType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SearchResultType {
|
impl SearchResultType {
|
||||||
fn ordinal(self) -> u8 {
|
pub(crate) fn ordinal(self) -> u8 {
|
||||||
match self {
|
match self {
|
||||||
Self::Endpoint => 0,
|
Self::Endpoint => 0,
|
||||||
Self::Schema => 1,
|
Self::Schema => 1,
|
||||||
|
|||||||
168
tests/crash_consistency_test.rs
Normal file
168
tests/crash_consistency_test.rs
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
//! Crash consistency tests for the cache write protocol.
|
||||||
|
//!
|
||||||
|
//! Simulates partial writes at each stage of the crash-consistent write protocol
|
||||||
|
//! and verifies that the read protocol detects corruption and doctor --fix repairs it.
|
||||||
|
|
||||||
|
mod helpers;
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
/// Helper: fetch a spec into the test environment so we have valid cache state.
|
||||||
|
fn setup_with_cached_spec(env: &helpers::TestEnv) {
|
||||||
|
helpers::fetch_fixture(env, "petstore.json", "petstore");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When raw.json exists but meta.json is missing (crash before commit marker),
|
||||||
|
/// load_index should fail with AliasNotFound (meta is the commit marker).
|
||||||
|
#[test]
|
||||||
|
fn test_crash_before_meta_write() {
|
||||||
|
let env = helpers::TestEnv::new();
|
||||||
|
setup_with_cached_spec(&env);
|
||||||
|
|
||||||
|
let alias_dir = env.cache_dir.join("petstore");
|
||||||
|
let meta_path = alias_dir.join("meta.json");
|
||||||
|
|
||||||
|
// Simulate crash: remove meta.json (the commit marker)
|
||||||
|
assert!(meta_path.exists(), "meta.json should exist after fetch");
|
||||||
|
fs::remove_file(&meta_path).expect("failed to remove meta.json");
|
||||||
|
|
||||||
|
// Read should fail — no commit marker
|
||||||
|
let result = helpers::run_cmd(&env, &["list", "petstore", "--robot"]);
|
||||||
|
result.failure();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When meta.json exists but index.json is corrupted (partial write),
|
||||||
|
/// load_index should fail with CacheIntegrity (hash mismatch).
|
||||||
|
#[test]
|
||||||
|
fn test_crash_corrupted_index() {
|
||||||
|
let env = helpers::TestEnv::new();
|
||||||
|
setup_with_cached_spec(&env);
|
||||||
|
|
||||||
|
let alias_dir = env.cache_dir.join("petstore");
|
||||||
|
let index_path = alias_dir.join("index.json");
|
||||||
|
|
||||||
|
// Corrupt the index file
|
||||||
|
fs::write(&index_path, b"{ corrupted }").expect("failed to corrupt index");
|
||||||
|
|
||||||
|
// Read should fail — hash mismatch
|
||||||
|
let result = helpers::run_cmd(&env, &["list", "petstore", "--robot"]);
|
||||||
|
result.failure();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When meta.json exists but raw.json is corrupted,
|
||||||
|
/// load_raw should fail with CacheIntegrity (hash mismatch).
|
||||||
|
/// Note: list/search only need index.json, so we test via show which needs raw.json.
|
||||||
|
#[test]
|
||||||
|
fn test_crash_corrupted_raw_json() {
|
||||||
|
let env = helpers::TestEnv::new();
|
||||||
|
setup_with_cached_spec(&env);
|
||||||
|
|
||||||
|
let alias_dir = env.cache_dir.join("petstore");
|
||||||
|
let raw_path = alias_dir.join("raw.json");
|
||||||
|
|
||||||
|
// Corrupt raw.json
|
||||||
|
fs::write(&raw_path, b"{ corrupted }").expect("failed to corrupt raw.json");
|
||||||
|
|
||||||
|
// show needs raw.json for $ref expansion → should fail
|
||||||
|
let result = helpers::run_cmd(
|
||||||
|
&env,
|
||||||
|
&["show", "petstore", "/pets", "--method", "GET", "--robot"],
|
||||||
|
);
|
||||||
|
result.failure();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Doctor --fix should repair a missing index by rebuilding from raw.json.
|
||||||
|
#[test]
|
||||||
|
fn test_doctor_fix_repairs_missing_index() {
|
||||||
|
let env = helpers::TestEnv::new();
|
||||||
|
setup_with_cached_spec(&env);
|
||||||
|
|
||||||
|
let alias_dir = env.cache_dir.join("petstore");
|
||||||
|
let index_path = alias_dir.join("index.json");
|
||||||
|
|
||||||
|
// Remove index.json
|
||||||
|
fs::remove_file(&index_path).expect("failed to remove index.json");
|
||||||
|
|
||||||
|
// Doctor --fix should repair
|
||||||
|
let result = helpers::run_cmd(&env, &["doctor", "--fix", "--robot"]);
|
||||||
|
result.success();
|
||||||
|
|
||||||
|
// After fix, list should work again
|
||||||
|
let result = helpers::run_cmd(&env, &["list", "petstore", "--robot"]);
|
||||||
|
result.success();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Doctor --fix should repair a corrupted index (hash mismatch) by rebuilding.
|
||||||
|
#[test]
|
||||||
|
fn test_doctor_fix_repairs_corrupted_index() {
|
||||||
|
let env = helpers::TestEnv::new();
|
||||||
|
setup_with_cached_spec(&env);
|
||||||
|
|
||||||
|
let alias_dir = env.cache_dir.join("petstore");
|
||||||
|
let index_path = alias_dir.join("index.json");
|
||||||
|
|
||||||
|
// Corrupt the index
|
||||||
|
fs::write(&index_path, b"[]").expect("failed to corrupt index");
|
||||||
|
|
||||||
|
// Doctor --fix should detect and repair
|
||||||
|
let result = helpers::run_cmd(&env, &["doctor", "--fix", "--robot"]);
|
||||||
|
result.success();
|
||||||
|
|
||||||
|
// After fix, list should work
|
||||||
|
let result = helpers::run_cmd(&env, &["list", "petstore", "--robot"]);
|
||||||
|
result.success();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When meta.json has a generation mismatch with index.json, read should fail.
|
||||||
|
#[test]
|
||||||
|
fn test_generation_mismatch_detected() {
|
||||||
|
let env = helpers::TestEnv::new();
|
||||||
|
setup_with_cached_spec(&env);
|
||||||
|
|
||||||
|
let alias_dir = env.cache_dir.join("petstore");
|
||||||
|
let meta_path = alias_dir.join("meta.json");
|
||||||
|
|
||||||
|
// Tamper with generation in meta.json
|
||||||
|
let meta_bytes = fs::read(&meta_path).expect("failed to read meta");
|
||||||
|
let mut meta: serde_json::Value =
|
||||||
|
serde_json::from_slice(&meta_bytes).expect("failed to parse meta");
|
||||||
|
meta["generation"] = serde_json::json!(9999);
|
||||||
|
let tampered = serde_json::to_vec_pretty(&meta).expect("failed to serialize");
|
||||||
|
fs::write(&meta_path, &tampered).expect("failed to write tampered meta");
|
||||||
|
|
||||||
|
// Read should fail — generation mismatch
|
||||||
|
let result = helpers::run_cmd(&env, &["list", "petstore", "--robot"]);
|
||||||
|
result.failure();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Leftover .tmp files from a crash should not interfere with normal operation.
|
||||||
|
#[test]
|
||||||
|
fn test_leftover_tmp_files_harmless() {
|
||||||
|
let env = helpers::TestEnv::new();
|
||||||
|
setup_with_cached_spec(&env);
|
||||||
|
|
||||||
|
let alias_dir = env.cache_dir.join("petstore");
|
||||||
|
|
||||||
|
// Create leftover .tmp files (simulating interrupted write)
|
||||||
|
fs::write(alias_dir.join("raw.json.tmp"), b"partial data").expect("failed to create tmp file");
|
||||||
|
fs::write(alias_dir.join("index.json.tmp"), b"partial index")
|
||||||
|
.expect("failed to create tmp file");
|
||||||
|
|
||||||
|
// Normal operations should still work
|
||||||
|
let result = helpers::run_cmd(&env, &["list", "petstore", "--robot"]);
|
||||||
|
result.success();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A completely empty alias directory (no files at all) should be treated as not found.
|
||||||
|
#[test]
|
||||||
|
fn test_empty_alias_directory() {
|
||||||
|
let env = helpers::TestEnv::new();
|
||||||
|
|
||||||
|
// Create an empty directory that looks like an alias
|
||||||
|
let empty_alias_dir = env.cache_dir.join("ghost-alias");
|
||||||
|
fs::create_dir_all(&empty_alias_dir).expect("failed to create dir");
|
||||||
|
|
||||||
|
// Should fail — no meta.json
|
||||||
|
let result = helpers::run_cmd(&env, &["list", "ghost-alias", "--robot"]);
|
||||||
|
result.failure();
|
||||||
|
}
|
||||||
170
tests/lock_contention_test.rs
Normal file
170
tests/lock_contention_test.rs
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
//! Lock contention tests for cache write safety.
|
||||||
|
//!
|
||||||
|
//! Verifies that concurrent access to the same alias doesn't cause corruption
|
||||||
|
//! or panics. Uses multiple threads (not processes) for simplicity.
|
||||||
|
|
||||||
|
#![allow(deprecated)] // assert_cmd::Command::cargo_bin deprecation
|
||||||
|
|
||||||
|
mod helpers;
|
||||||
|
|
||||||
|
use std::sync::{Arc, Barrier};
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
/// Multiple threads fetching the same alias concurrently should all succeed
|
||||||
|
/// or receive CACHE_LOCKED errors — never corruption.
|
||||||
|
///
|
||||||
|
/// After all threads complete, the final cache state should be valid.
|
||||||
|
#[test]
|
||||||
|
fn test_concurrent_fetch_no_corruption() {
|
||||||
|
let env = helpers::TestEnv::new();
|
||||||
|
let fixture_path = helpers::fixture_path("petstore.json");
|
||||||
|
let fixture_str = fixture_path.to_str().expect("fixture path not UTF-8");
|
||||||
|
|
||||||
|
// First, do an initial fetch so the alias exists
|
||||||
|
helpers::run_cmd(&env, &["fetch", fixture_str, "--alias", "petstore"]).success();
|
||||||
|
|
||||||
|
let thread_count = 8;
|
||||||
|
let barrier = Arc::new(Barrier::new(thread_count));
|
||||||
|
let home_dir = env.home_dir.clone();
|
||||||
|
let fixture = fixture_str.to_string();
|
||||||
|
|
||||||
|
let handles: Vec<_> = (0..thread_count)
|
||||||
|
.map(|_| {
|
||||||
|
let barrier = Arc::clone(&barrier);
|
||||||
|
let home = home_dir.clone();
|
||||||
|
let fix = fixture.clone();
|
||||||
|
|
||||||
|
thread::spawn(move || {
|
||||||
|
// Wait for all threads to be ready
|
||||||
|
barrier.wait();
|
||||||
|
|
||||||
|
// All threads try to re-fetch the same alias with --force
|
||||||
|
let output = assert_cmd::Command::cargo_bin("swagger-cli")
|
||||||
|
.expect("binary not found")
|
||||||
|
.env("SWAGGER_CLI_HOME", &home)
|
||||||
|
.args(["fetch", &fix, "--alias", "petstore", "--force"])
|
||||||
|
.output()
|
||||||
|
.expect("failed to execute");
|
||||||
|
|
||||||
|
output.status.code().unwrap_or(-1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let exit_codes: Vec<i32> = handles.into_iter().map(|h| h.join().unwrap()).collect();
|
||||||
|
|
||||||
|
// All should either succeed (0) or get CACHE_LOCKED (exit code 9)
|
||||||
|
for (i, code) in exit_codes.iter().enumerate() {
|
||||||
|
assert!(
|
||||||
|
*code == 0 || *code == 9,
|
||||||
|
"thread {i} exited with unexpected code {code} (expected 0 or 9)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// At least one should succeed
|
||||||
|
assert!(
|
||||||
|
exit_codes.contains(&0),
|
||||||
|
"at least one thread should succeed"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Final state should be valid — list should work
|
||||||
|
let result = helpers::run_cmd(&env, &["list", "petstore", "--robot"]);
|
||||||
|
result.success();
|
||||||
|
|
||||||
|
// Doctor should report healthy
|
||||||
|
let result = helpers::run_cmd(&env, &["doctor", "--robot"]);
|
||||||
|
let a = result.success();
|
||||||
|
let json = helpers::parse_robot_json(&a.get_output().stdout);
|
||||||
|
// Doctor output should indicate health
|
||||||
|
assert!(
|
||||||
|
json["data"]["health"].as_str().is_some(),
|
||||||
|
"doctor should report health status"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Multiple threads listing the same alias concurrently should all succeed.
|
||||||
|
/// Reads are not locked (only writes acquire the lock).
|
||||||
|
#[test]
|
||||||
|
fn test_concurrent_reads_no_contention() {
|
||||||
|
let env = helpers::TestEnv::new();
|
||||||
|
helpers::fetch_fixture(&env, "petstore.json", "petstore");
|
||||||
|
|
||||||
|
let thread_count = 16;
|
||||||
|
let barrier = Arc::new(Barrier::new(thread_count));
|
||||||
|
let home_dir = env.home_dir.clone();
|
||||||
|
|
||||||
|
let handles: Vec<_> = (0..thread_count)
|
||||||
|
.map(|_| {
|
||||||
|
let barrier = Arc::clone(&barrier);
|
||||||
|
let home = home_dir.clone();
|
||||||
|
|
||||||
|
thread::spawn(move || {
|
||||||
|
barrier.wait();
|
||||||
|
|
||||||
|
let output = assert_cmd::Command::cargo_bin("swagger-cli")
|
||||||
|
.expect("binary not found")
|
||||||
|
.env("SWAGGER_CLI_HOME", &home)
|
||||||
|
.args(["list", "petstore", "--robot"])
|
||||||
|
.output()
|
||||||
|
.expect("failed to execute");
|
||||||
|
|
||||||
|
output.status.code().unwrap_or(-1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let exit_codes: Vec<i32> = handles.into_iter().map(|h| h.join().unwrap()).collect();
|
||||||
|
|
||||||
|
// ALL reads should succeed (no locking on reads)
|
||||||
|
for (i, code) in exit_codes.iter().enumerate() {
|
||||||
|
assert_eq!(*code, 0, "thread {i} read failed with exit code {code}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Concurrent writes to DIFFERENT aliases should never interfere with each other.
|
||||||
|
#[test]
|
||||||
|
fn test_concurrent_different_aliases() {
|
||||||
|
let env = helpers::TestEnv::new();
|
||||||
|
let fixture_path = helpers::fixture_path("petstore.json");
|
||||||
|
let fixture_str = fixture_path.to_str().expect("fixture path not UTF-8");
|
||||||
|
|
||||||
|
let aliases = ["alpha", "bravo", "charlie", "delta"];
|
||||||
|
let barrier = Arc::new(Barrier::new(aliases.len()));
|
||||||
|
let home_dir = env.home_dir.clone();
|
||||||
|
let fixture = fixture_str.to_string();
|
||||||
|
|
||||||
|
let handles: Vec<_> = aliases
|
||||||
|
.iter()
|
||||||
|
.map(|alias| {
|
||||||
|
let barrier = Arc::clone(&barrier);
|
||||||
|
let home = home_dir.clone();
|
||||||
|
let fix = fixture.clone();
|
||||||
|
let a = alias.to_string();
|
||||||
|
|
||||||
|
thread::spawn(move || {
|
||||||
|
barrier.wait();
|
||||||
|
|
||||||
|
let output = assert_cmd::Command::cargo_bin("swagger-cli")
|
||||||
|
.expect("binary not found")
|
||||||
|
.env("SWAGGER_CLI_HOME", &home)
|
||||||
|
.args(["fetch", &fix, "--alias", &a])
|
||||||
|
.output()
|
||||||
|
.expect("failed to execute");
|
||||||
|
|
||||||
|
(a, output.status.code().unwrap_or(-1))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let results: Vec<(String, i32)> = handles.into_iter().map(|h| h.join().unwrap()).collect();
|
||||||
|
|
||||||
|
// All should succeed — different aliases means different lock files
|
||||||
|
for (alias, code) in &results {
|
||||||
|
assert_eq!(*code, 0, "fetch to alias '{alias}' failed with code {code}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// All aliases should be listable
|
||||||
|
for alias in &aliases {
|
||||||
|
helpers::run_cmd(&env, &["list", alias, "--robot"]).success();
|
||||||
|
}
|
||||||
|
}
|
||||||
133
tests/property_test.rs
Normal file
133
tests/property_test.rs
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
//! Property-based tests for deterministic behavior.
|
||||||
|
//!
|
||||||
|
//! Uses proptest to verify that hashing, JSON serialization, and spec construction
|
||||||
|
//! produce deterministic results regardless of input data.
|
||||||
|
|
||||||
|
use proptest::prelude::*;
|
||||||
|
use serde_json::json;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
// The SHA-256 hash function is deterministic: same bytes always produce the same hash.
|
||||||
|
proptest! {
|
||||||
|
#[test]
|
||||||
|
fn hash_deterministic(data in proptest::collection::vec(any::<u8>(), 0..10000)) {
|
||||||
|
let mut h1 = Sha256::new();
|
||||||
|
h1.update(&data);
|
||||||
|
let r1 = format!("{:x}", h1.finalize());
|
||||||
|
|
||||||
|
let mut h2 = Sha256::new();
|
||||||
|
h2.update(&data);
|
||||||
|
let r2 = format!("{:x}", h2.finalize());
|
||||||
|
|
||||||
|
prop_assert_eq!(r1, r2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two specs with the same paths (inserted in different order) should produce
|
||||||
|
// identical canonical JSON after a parse-serialize roundtrip through serde_json::Value.
|
||||||
|
//
|
||||||
|
// serde_json::Value uses BTreeMap for objects, so key ordering is deterministic
|
||||||
|
// regardless of insertion order.
|
||||||
|
proptest! {
|
||||||
|
#![proptest_config(ProptestConfig::with_cases(20))]
|
||||||
|
#[test]
|
||||||
|
fn index_ordering_deterministic(
|
||||||
|
// Use proptest to pick a permutation index
|
||||||
|
perm_seed in 0..40320u32, // 8! = 40320
|
||||||
|
) {
|
||||||
|
let paths: Vec<(&str, &str, &str)> = vec![
|
||||||
|
("/pets", "get", "List pets"),
|
||||||
|
("/pets", "post", "Create pet"),
|
||||||
|
("/pets/{petId}", "get", "Get pet"),
|
||||||
|
("/pets/{petId}", "delete", "Delete pet"),
|
||||||
|
("/users", "get", "List users"),
|
||||||
|
("/users/{userId}", "get", "Get user"),
|
||||||
|
("/orders", "get", "List orders"),
|
||||||
|
("/orders", "post", "Create order"),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Build the spec with original order
|
||||||
|
let spec1 = build_spec_from_paths(&paths);
|
||||||
|
|
||||||
|
// Generate a deterministic permutation from the seed
|
||||||
|
let shuffled = permute(&paths, perm_seed);
|
||||||
|
let spec2 = build_spec_from_paths(&shuffled);
|
||||||
|
|
||||||
|
// After roundtripping through serde_json::Value (BTreeMap ordering),
|
||||||
|
// both should produce identical canonical JSON.
|
||||||
|
let val1: serde_json::Value = serde_json::from_str(&serde_json::to_string(&spec1).unwrap()).unwrap();
|
||||||
|
let val2: serde_json::Value = serde_json::from_str(&serde_json::to_string(&spec2).unwrap()).unwrap();
|
||||||
|
let canonical1 = serde_json::to_string(&val1).unwrap();
|
||||||
|
let canonical2 = serde_json::to_string(&val2).unwrap();
|
||||||
|
|
||||||
|
prop_assert_eq!(canonical1, canonical2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deterministic permutation using factorial number system (no rand dependency).
|
||||||
|
fn permute<T: Clone>(items: &[T], mut seed: u32) -> Vec<T> {
|
||||||
|
let mut remaining: Vec<T> = items.to_vec();
|
||||||
|
let mut result = Vec::with_capacity(items.len());
|
||||||
|
|
||||||
|
for i in (1..=items.len()).rev() {
|
||||||
|
let idx = (seed % i as u32) as usize;
|
||||||
|
seed /= i as u32;
|
||||||
|
result.push(remaining.remove(idx));
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a minimal OpenAPI spec from a list of (path, method, summary) tuples.
|
||||||
|
fn build_spec_from_paths(paths: &[(&str, &str, &str)]) -> serde_json::Value {
|
||||||
|
let mut path_map = serde_json::Map::new();
|
||||||
|
|
||||||
|
for (path, method, summary) in paths {
|
||||||
|
let path_entry = path_map
|
||||||
|
.entry(path.to_string())
|
||||||
|
.or_insert_with(|| json!({}));
|
||||||
|
path_entry[method] = json!({
|
||||||
|
"summary": summary,
|
||||||
|
"responses": { "200": { "description": "OK" } }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
json!({
|
||||||
|
"openapi": "3.0.3",
|
||||||
|
"info": { "title": "Test", "version": "1.0.0" },
|
||||||
|
"paths": path_map
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that JSON canonical form is stable across parse-serialize cycles.
|
||||||
|
proptest! {
|
||||||
|
#[test]
|
||||||
|
fn json_roundtrip_stable(
|
||||||
|
key1 in "[a-z]{1,10}",
|
||||||
|
key2 in "[a-z]{1,10}",
|
||||||
|
val1 in any::<i64>(),
|
||||||
|
val2 in any::<i64>(),
|
||||||
|
) {
|
||||||
|
let obj = json!({ key1.clone(): val1, key2.clone(): val2 });
|
||||||
|
let s1 = serde_json::to_string(&obj).unwrap();
|
||||||
|
let reparsed: serde_json::Value = serde_json::from_str(&s1).unwrap();
|
||||||
|
let s2 = serde_json::to_string(&reparsed).unwrap();
|
||||||
|
|
||||||
|
// Parse -> serialize should be idempotent
|
||||||
|
prop_assert_eq!(s1, s2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash output format is always "sha256:" followed by 64 hex chars.
|
||||||
|
proptest! {
|
||||||
|
#[test]
|
||||||
|
fn hash_format_consistent(data in proptest::collection::vec(any::<u8>(), 0..1000)) {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(&data);
|
||||||
|
let hash = format!("sha256:{:x}", hasher.finalize());
|
||||||
|
|
||||||
|
prop_assert!(hash.starts_with("sha256:"));
|
||||||
|
// SHA-256 hex is 64 chars
|
||||||
|
prop_assert_eq!(hash.len(), 7 + 64); // "sha256:" + 64 hex digits
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user