Skip to content
Merged
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
12 changes: 12 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,15 @@ jobs:
- run: cargo doc --no-deps --all-features
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.
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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
/target
**/target/
# Build outputs from spec-compile.sh and ad-hoc generator runs.
/tmp/spec-compile/
/tmp/gen-anthropic/
/tmp/gen-openai/
*.swp
*.swo
*~
Expand Down
117 changes: 117 additions & 0 deletions scripts/spec-compile.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
#!/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.
#
# Usage:
# scripts/spec-compile.sh # run all specs in SPECS
# scripts/spec-compile.sh anthropic openai # run a subset
#
# Env:
# SPEC_COMPILE_KEEP=1 keep the scratch directory under tmp/spec-compile/
# SPEC_COMPILE_OFFLINE=1 pass --offline to cargo invocations
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"
fi

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"

ROOT="$(pwd)/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"
if [ ${#WANT[@]} -gt 0 ]; then
skip=1
for w in "${WANT[@]}"; do [ "$w" = "$name" ] && skip=0; done
[ $skip -eq 1 ] && continue
fi

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

cat >"$dir/Cargo.toml" <<EOF
[package]
name = "spec-compile-$name"
version = "0.0.0"
edition = "2024"
publish = false

[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_urlencoded = "0.7"
reqwest = { version = "0.12", features = ["json", "stream", "multipart"] }
reqwest-middleware = { version = "0.4", features = ["multipart"] }
reqwest-retry = "0.7"
reqwest-tracing = "0.5"
thiserror = "1"
url = "2"
EOF

cat >"$dir/src/lib.rs" <<EOF
#![allow(dead_code, unused_imports, clippy::all)]
pub mod generated;
EOF

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

[features]
enable_async_client = true

[http_client]
base_url = "$base_url"
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")
done

if [ "${SPEC_COMPILE_KEEP:-}" != "1" ]; then
rm -rf "$ROOT"
fi

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

echo
echo "[spec-compile] ✅ all specs compiled cleanly"
36 changes: 35 additions & 1 deletion src/analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3860,6 +3860,30 @@ impl SchemaAnalyzer {
/// when the parameter's inline schema is a string with `enum` or `const`
/// (e.g. `GetItemTheConstant`). The client generator emits the enum
/// alongside the operation methods. See issue #10 follow-up.
/// Look up `#/components/schemas/{name}` in the raw OpenAPI document and
/// decide whether it's a string with enum values. Used by analyze_parameter
/// (T10) so that only string-enum refs flow through to the codegen-typed
/// parameter path; struct/object refs stay as `String` until we have
/// proper deepObject / form-style query serialization (T14).
fn referenced_schema_is_string_enum(&self, name: &str) -> bool {
let Some(schema_value) = self
.openapi_spec
.get("components")
.and_then(|c| c.get("schemas"))
.and_then(|s| s.get(name))
else {
return false;
};
let is_string_type = schema_value
.get("type")
.and_then(|v| v.as_str())
.map(|s| s == "string")
.unwrap_or(false);
let has_enum_or_const =
schema_value.get("enum").is_some() || schema_value.get("const").is_some();
is_string_type && has_enum_or_const
}

fn analyze_parameter(
&self,
param: &crate::openapi::Parameter,
Expand All @@ -3877,7 +3901,17 @@ impl SchemaAnalyzer {

if let Some(schema) = &param.schema {
if let Some(ref_str) = schema.reference() {
schema_ref = self.extract_schema_name(ref_str).map(|s| s.to_string());
// T10: keep the resolved type when the target is a string-enum
// (then `Display`/`as_str` are emitted, see generate_string_enum).
// For struct/object refs we fall back to `String` here — those
// need deepObject / form / serde_urlencoded handling that's
// not yet generated; emitting the typed name would produce
// `(struct).to_string()` and not compile.
if let Some(name) = self.extract_schema_name(ref_str) {
if self.referenced_schema_is_string_enum(name) {
schema_ref = Some(name.to_string());
}
}
} else if let Some(schema_type) = schema.schema_type() {
rust_type = match schema_type {
crate::openapi::SchemaType::Boolean => "bool",
Expand Down
101 changes: 65 additions & 36 deletions src/client_generator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -611,8 +611,8 @@ impl CodeGenerator {
) -> Result<#response_type, ApiOpError<#op_error_type>> {
#url_construction

let mut req = #http_method_call
#request_body;
let mut req = #http_method_call;
#request_body

#query_params
#header_params
Expand Down Expand Up @@ -706,7 +706,7 @@ impl CodeGenerator {
let param_ident = Self::to_field_ident(&param_name_snake);
let header_name = &param.name;
if param.required {
if param.rust_type == "String" {
if Self::param_uses_as_ref_str(param) {
emit.push(quote! {
req = req.header(#header_name, #param_ident.as_ref());
});
Expand All @@ -715,7 +715,7 @@ impl CodeGenerator {
req = req.header(#header_name, #param_ident.to_string());
});
}
} else if param.rust_type == "String" {
} else if Self::param_uses_as_ref_str(param) {
emit.push(quote! {
if let Some(v) = #param_ident {
req = req.header(#header_name, v.as_ref());
Expand Down Expand Up @@ -758,7 +758,7 @@ impl CodeGenerator {

if param.required {
// Required parameters: always add
if param.rust_type == "String" {
if Self::param_uses_as_ref_str(param) {
param_building.push(quote! {
query_params.push((#param_key, #param_name.as_ref().to_string()));
});
Expand All @@ -769,7 +769,7 @@ impl CodeGenerator {
}
} else {
// Optional parameters: add only if Some
if param.rust_type == "String" {
if Self::param_uses_as_ref_str(param) {
param_building.push(quote! {
if let Some(v) = #param_name {
query_params.push((#param_key, v.as_ref().to_string()));
Expand Down Expand Up @@ -993,43 +993,72 @@ impl CodeGenerator {
}
}

/// True when the parameter's compile-time type is `impl AsRef<str>` and
/// we should call `.as_ref()` on it before stringifying. False for any
/// $ref-resolved type (T10) or non-String primitive — those just call
/// `.to_string()`.
fn param_uses_as_ref_str(param: &crate::analysis::ParameterInfo) -> bool {
param.schema_ref.is_none() && param.rust_type == "String"
}

/// Generate request body serialization based on content type
/// Emit statements that mutate `req` to apply the request body. Returns
/// `quote!{}` if the operation has no body. Optional bodies (T11) gate the
/// application on `Some(_)`; required bodies apply unconditionally.
fn generate_request_body(&self, op: &OperationInfo) -> TokenStream {
if let Some(ref rb) = op.request_body {
use crate::analysis::RequestBodyContent;
match rb {
RequestBodyContent::Json { .. } => {
quote! {
let Some(rb) = op.request_body.as_ref() else {
return quote! {};
};
use crate::analysis::RequestBodyContent;
let required = op.request_body_required;
let (ident, apply): (TokenStream, TokenStream) = match rb {
RequestBodyContent::Json { .. } => (
quote! { request },
quote! {
req = req
.body(serde_json::to_vec(&request).map_err(HttpError::serialization_error)?)
.header("content-type", "application/json")
}
}
RequestBodyContent::FormUrlEncoded { .. } => {
quote! {
.header("content-type", "application/json");
},
),
RequestBodyContent::FormUrlEncoded { .. } => (
quote! { request },
quote! {
req = req
.body(serde_urlencoded::to_string(&request).map_err(HttpError::serialization_error)?)
.header("content-type", "application/x-www-form-urlencoded")
}
}
RequestBodyContent::Multipart => {
quote! {
.multipart(form)
}
}
RequestBodyContent::OctetStream => {
quote! {
.header("content-type", "application/x-www-form-urlencoded");
},
),
RequestBodyContent::Multipart => (
quote! { form },
quote! {
req = req.multipart(form);
},
),
RequestBodyContent::OctetStream => (
quote! { body },
quote! {
req = req
.body(body)
.header("content-type", "application/octet-stream")
}
}
RequestBodyContent::TextPlain => {
quote! {
.header("content-type", "application/octet-stream");
},
),
RequestBodyContent::TextPlain => (
quote! { body },
quote! {
req = req
.body(body)
.header("content-type", "text/plain")
}
.header("content-type", "text/plain");
},
),
};
if required {
apply
} else {
quote! {
if let Some(#ident) = #ident {
#apply
}
}
} else {
quote! {}
}
}

Expand Down Expand Up @@ -1258,7 +1287,7 @@ impl CodeGenerator {
let param_name_snake = self.sanitize_param_name(&param.name);
let param_ident = Self::to_field_ident(&param_name_snake);

if param.rust_type == "String" {
if Self::param_uses_as_ref_str(param) {
format_args.push(quote! {
__pct_encode_path_segment(#param_ident.as_ref())
});
Expand Down
Loading
Loading