Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,23 @@ jobs:
env:
RUSTDOCFLAGS: -D warnings

# Regression guard: generate clients for our reference specs (Anthropic +
# OpenAI) and `cargo check` the result. Catches breakage where a generator
# change still passes unit tests but emits invalid Rust against real-world
# OAS documents. See scripts/spec-compile.sh.
# Regression guard: generate clients for a curated list of real-world specs
# and `cargo check` the result. Catches breakage where a generator change
# still passes unit tests but emits invalid Rust against real-world OAS
# documents. See scripts/spec-compile.sh.
#
# The list is the "gold" subset that currently compiles cleanly. Local
# `scripts/spec-compile.sh` (no args) runs against all of `specs/`; we
# don't gate CI on the full corpus because many of the 50+ specs currently
# surface unfixed generator bugs (tracked in #14).
spec-compile:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- run: scripts/spec-compile.sh
- run: |
scripts/spec-compile.sh \
anthropic asana browserbase cartesia cerebras coda coingecko \
digitalocean groq imagekit launchdarkly meta-llama openai \
resend runway spotify terminal-shop twilio val-town writer
147 changes: 98 additions & 49 deletions scripts/spec-compile.sh
Original file line number Diff line number Diff line change
@@ -1,28 +1,24 @@
#!/usr/bin/env bash
# Smoke-test that generated clients for our reference specs compile cleanly.
# Each spec listed below produces a separate scratch crate; we run the
# `openapi-to-rust` generator into it and then `cargo check`. Any
# regression here means a real-world spec stops compiling.
# Smoke-test that generated clients for every spec under specs/ compile cleanly.
#
# Auto-discovers specs/*.yaml and specs/*.json. Each spec produces a separate
# scratch crate; we run the `openapi-to-rust` generator into it and then
# `cargo check`. Any regression here means a real-world spec stops compiling.
#
# Usage:
# scripts/spec-compile.sh # run all specs in SPECS
# scripts/spec-compile.sh anthropic openai # run a subset
# scripts/spec-compile.sh # all specs in specs/
# scripts/spec-compile.sh anthropic openai # subset by name
# SPEC_COMPILE_LIMIT=5 scripts/spec-compile.sh # first 5 only (CI smoke)
#
# Env:
# SPEC_COMPILE_KEEP=1 keep the scratch directory under tmp/spec-compile/
# SPEC_COMPILE_OFFLINE=1 pass --offline to cargo invocations
# SPEC_COMPILE_KEEP=1 keep tmp/spec-compile/<name>/ on success
# SPEC_COMPILE_OFFLINE=1 pass --offline to cargo invocations
# SPEC_COMPILE_LIMIT=N process only the first N alphabetically-sorted specs
# SPEC_COMPILE_PARSE_ONLY=1 skip cargo check; only verify the generator
# parses+emits without errors. Faster.
set -euo pipefail
cd "$(dirname "$0")/.."

# (spec_name, spec_path, base_url, auth_type, auth_header)
SPECS=(
"anthropic|specs/anthropic.yaml|https://api.anthropic.com|ApiKey|x-api-key"
"openai|specs/openai.yaml|https://api.openai.com/v1|Bearer|Authorization"
)

# If args are given, treat them as a whitelist of spec names.
WANT=("$@")

OFFLINE=""
if [ "${SPEC_COMPILE_OFFLINE:-}" = "1" ]; then
OFFLINE="--offline"
Expand All @@ -32,22 +28,59 @@ echo "[spec-compile] building openapi-to-rust binary..."
cargo build --bin openapi-to-rust $OFFLINE >/dev/null

GEN_BIN="$(pwd)/target/debug/openapi-to-rust"
WORKSPACE="$(pwd)"

ROOT="$(pwd)/tmp/spec-compile"
ROOT="$WORKSPACE/tmp/spec-compile"
rm -rf "$ROOT"
mkdir -p "$ROOT"

failed=()
for entry in "${SPECS[@]}"; do
IFS='|' read -r name spec_path base_url auth_type auth_header <<<"$entry"
# Discover specs. Sort for deterministic output.
mapfile -t ALL_SPECS < <(find specs -maxdepth 1 -type f \( -name "*.yaml" -o -name "*.json" \) | sort)

# Filter by command-line whitelist.
WANT=("$@")
SPECS=()
for spec in "${ALL_SPECS[@]}"; do
name="$(basename "$spec")"
name="${name%.*}"
if [ ${#WANT[@]} -gt 0 ]; then
skip=1
for w in "${WANT[@]}"; do [ "$w" = "$name" ] && skip=0; done
[ $skip -eq 1 ] && continue
keep=0
for w in "${WANT[@]}"; do [ "$w" = "$name" ] && keep=1; done
[ $keep -eq 0 ] && continue
fi
SPECS+=("$name|$spec")
done

if [ -n "${SPEC_COMPILE_LIMIT:-}" ]; then
SPECS=("${SPECS[@]:0:$SPEC_COMPILE_LIMIT}")
fi

if [ ${#SPECS[@]} -eq 0 ]; then
echo "[spec-compile] no specs matched"
exit 0
fi

echo "[spec-compile] running ${#SPECS[@]} spec(s)"
echo

passed=()
failed_gen=()
failed_check=()
skipped=()
for entry in "${SPECS[@]}"; do
IFS='|' read -r name spec_path <<<"$entry"

printf "%-30s " "$name"

# Skip Swagger 2.0 specs — out of scope for this generator. Detect either
# `"swagger": "2.0"` (JSON) or `swagger: "2.0"` / `swagger: 2.0` (YAML).
if grep -qE '("swagger"\s*:|swagger\s*:)\s*"?2\.' "$spec_path" 2>/dev/null \
&& ! grep -qE '("openapi"\s*:|openapi\s*:)' "$spec_path" 2>/dev/null; then
echo "SKIP (Swagger 2.0)"
skipped+=("$name")
continue
fi

echo
echo "==> $name (spec: $spec_path)"
dir="$ROOT/$name"
mkdir -p "$dir/src/generated"

Expand Down Expand Up @@ -75,43 +108,59 @@ EOF
pub mod generated;
EOF

# Sanitize module name (replace - with _).
module_name="$(echo "$name" | tr '-' '_')"

cat >"$dir/openapi-to-rust.toml" <<EOF
[generator]
spec_path = "$(pwd)/$spec_path"
spec_path = "$WORKSPACE/$spec_path"
output_dir = "src/generated"
module_name = "$name"
module_name = "$module_name"

[features]
enable_async_client = true

[http_client]
base_url = "$base_url"
base_url = "https://example.invalid"
timeout_seconds = 60

[http_client.auth]
type = "$auth_type"
header_name = "$auth_header"
EOF

(
cd "$dir"
"$GEN_BIN" generate --config openapi-to-rust.toml >/dev/null
if ! cargo check $OFFLINE 2>&1 | tail -200; then
echo "[spec-compile] $name FAILED to compile" >&2
exit 1
fi
) || failed+=("$name")
# Generator step
log="$dir/generate.log"
if ! ( cd "$dir" && "$GEN_BIN" generate --config openapi-to-rust.toml ) >"$log" 2>&1; then
echo "GEN-FAIL"
failed_gen+=("$name")
continue
fi

if [ "${SPEC_COMPILE_PARSE_ONLY:-}" = "1" ]; then
echo "GEN-OK"
passed+=("$name")
[ "${SPEC_COMPILE_KEEP:-}" != "1" ] && rm -rf "$dir"
continue
fi

# Cargo check step
log="$dir/check.log"
if ! ( cd "$dir" && cargo check $OFFLINE ) >"$log" 2>&1; then
err_count=$(grep -cE "^error" "$log" || true)
echo "CHECK-FAIL ($err_count errs)"
failed_check+=("$name")
continue
fi

echo "PASS"
passed+=("$name")
[ "${SPEC_COMPILE_KEEP:-}" != "1" ] && rm -rf "$dir"
done

if [ "${SPEC_COMPILE_KEEP:-}" != "1" ]; then
rm -rf "$ROOT"
fi
echo
echo "[spec-compile] summary: ${#passed[@]} passed, ${#failed_gen[@]} gen-failed, ${#failed_check[@]} check-failed, ${#skipped[@]} skipped"
[ ${#failed_gen[@]} -gt 0 ] && echo " gen-fail: ${failed_gen[*]}"
[ ${#failed_check[@]} -gt 0 ] && echo " check-fail: ${failed_check[*]}"
[ ${#skipped[@]} -gt 0 ] && echo " skipped: ${skipped[*]}"

if [ ${#failed[@]} -gt 0 ]; then
echo
echo "[spec-compile] FAILED: ${failed[*]}" >&2
if [ ${#failed_gen[@]} -gt 0 ] || [ ${#failed_check[@]} -gt 0 ]; then
exit 1
fi

echo
echo "[spec-compile] ✅ all specs compiled cleanly"
33 changes: 23 additions & 10 deletions src/analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3566,12 +3566,33 @@ impl SchemaAnalyzer {
analysis: &mut SchemaAnalysis,
) -> Result<()> {
for (method, operation) in path_item.operations() {
// Generate operation ID if missing
let operation_id = operation
// Generate operation ID if missing.
let raw_operation_id = operation
.operation_id
.clone()
.unwrap_or_else(|| Self::generate_operation_id(method, path));

// T6: detect operationId collisions. Per the OAS spec these MUST
// be unique, but real-world specs (arcade, cal-com, telnyx,
// val-town, …) frequently aren't. Auto-disambiguate by suffixing
// with the method, then a counter, and warn.
let operation_id = if analysis.operations.contains_key(&raw_operation_id) {
let method_lower = method.to_lowercase();
let mut candidate = format!("{}_{}", raw_operation_id, method_lower);
let mut suffix = 2;
while analysis.operations.contains_key(&candidate) {
candidate = format!("{}_{}_{}", raw_operation_id, method_lower, suffix);
suffix += 1;
}
eprintln!(
"⚠️ duplicate operationId `{}` at `{} {}` — disambiguated to `{}`",
raw_operation_id, method, path, candidate
);
candidate
} else {
raw_operation_id
};

let op_info = self.analyze_single_operation(
&operation_id,
method,
Expand All @@ -3580,14 +3601,6 @@ impl SchemaAnalyzer {
path_item.parameters.as_ref(),
analysis,
)?;
// T6: detect operationId collisions instead of silently overwriting.
if let Some(existing) = analysis.operations.get(&operation_id) {
return Err(GeneratorError::InvalidSchema(format!(
"duplicate operationId `{}` — first at `{} {}`, then at `{} {}`. \
OpenAPI requires operationId to be unique across the document.",
operation_id, existing.method, existing.path, method, path
)));
}
analysis.operations.insert(operation_id, op_info);
}
Ok(())
Expand Down
31 changes: 31 additions & 0 deletions src/bin/openapi-to-rust.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,37 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
json_from_str_lossy(&spec_content)?
};

// Version gate: surface unsupported OAS major.minor early.
let oas_version = spec_value
.get("openapi")
.and_then(|v| v.as_str())
.unwrap_or("");
match openapi_to_rust::cli::parse_oas_version(oas_version) {
Some((3, 0)) | Some((3, 1)) => {}
Some((3, 2)) => {
eprintln!("⚠️ OpenAPI {oas_version}: 3.2 is experimentally supported.");
}
Some((major, minor)) => {
eprintln!(
"❌ Unsupported OpenAPI version: {major}.{minor} ({oas_version:?}). \
This generator targets 3.0.x, 3.1.x, and (experimentally) 3.2.x. \
Swagger 2.0 and OAS 1.x are not supported."
);
std::process::exit(1);
}
None => {
let hint = if spec_value.get("swagger").is_some() {
" (looks like Swagger 2.0 — out of scope)"
} else {
""
};
eprintln!(
"❌ Missing or unrecognised `openapi` field{hint}. Expected something like \"3.1.0\", got: {oas_version:?}"
);
std::process::exit(1);
}
}

// Analyze schemas (with extensions if configured)
println!("🔍 Analyzing schemas...");
let mut analyzer = if generator_config.schema_extensions.is_empty() {
Expand Down
2 changes: 1 addition & 1 deletion src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ async fn load_spec(input: &str, verbose: bool) -> Result<String, Box<dyn std::er

/// Parse the `openapi` version string into (major, minor). Tolerates patch and
/// build-metadata suffixes. Returns None for unrecognised input.
fn parse_oas_version(s: &str) -> Option<(u32, u32)> {
pub fn parse_oas_version(s: &str) -> Option<(u32, u32)> {
let mut parts = s.split('.');
let major = parts.next()?.parse().ok()?;
let minor_raw = parts.next()?;
Expand Down
28 changes: 26 additions & 2 deletions src/client_generator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1312,9 +1312,33 @@ impl CodeGenerator {
}
}

/// Sanitize a parameter name by escaping Rust reserved keywords with raw identifiers
/// Sanitize a parameter name by escaping Rust reserved keywords with raw
/// identifiers and disambiguating Twilio-style suffix operators
/// (`StartTime`, `StartTime<`, `StartTime>` would otherwise all snake-
/// case to `start_time`).
fn sanitize_param_name(&self, name: &str) -> String {
let snake_case = name.to_snake_case();
// Disambiguate before stripping. `<`, `>`, `<=`, `>=` are common in
// filter-style query params; map them to `_lt` / `_gt` etc. so the
// Rust ident is unique while the wire-level param name stays the
// original string elsewhere in the codegen.
let suffix = if name.ends_with("<=") {
"_lte"
} else if name.ends_with(">=") {
"_gte"
} else if name.ends_with('<') {
"_lt"
} else if name.ends_with('>') {
"_gt"
} else {
""
};
let stripped = name.trim_end_matches(['<', '>', '=']);
let mut snake_case = stripped.to_snake_case();
snake_case.push_str(suffix);

if matches!(snake_case.as_str(), "self" | "super" | "crate" | "Self") {
return format!("{snake_case}_param");
}
if Self::is_rust_keyword(&snake_case) {
format!("r#{snake_case}")
} else {
Expand Down
Loading
Loading