Skip to content

Commit 22b29d9

Browse files
Merge pull request #16 from gpu-cli/feat/spec-compile-ci
fix(generator): make Anthropic + OpenAI clients compile + add CI regression guard
2 parents 88b357f + f5b5773 commit 22b29d9

33 files changed

Lines changed: 1193 additions & 59 deletions

File tree

.github/workflows/ci.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,15 @@ jobs:
4646
- run: cargo doc --no-deps --all-features
4747
env:
4848
RUSTDOCFLAGS: -D warnings
49+
50+
# Regression guard: generate clients for our reference specs (Anthropic +
51+
# OpenAI) and `cargo check` the result. Catches breakage where a generator
52+
# change still passes unit tests but emits invalid Rust against real-world
53+
# OAS documents. See scripts/spec-compile.sh.
54+
spec-compile:
55+
runs-on: ubuntu-latest
56+
steps:
57+
- uses: actions/checkout@v4
58+
- uses: dtolnay/rust-toolchain@stable
59+
- uses: Swatinem/rust-cache@v2
60+
- run: scripts/spec-compile.sh

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
/target
2+
**/target/
3+
# Build outputs from spec-compile.sh and ad-hoc generator runs.
4+
/tmp/spec-compile/
5+
/tmp/gen-anthropic/
6+
/tmp/gen-openai/
27
*.swp
38
*.swo
49
*~

scripts/spec-compile.sh

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
#!/usr/bin/env bash
2+
# Smoke-test that generated clients for our reference specs compile cleanly.
3+
# Each spec listed below produces a separate scratch crate; we run the
4+
# `openapi-to-rust` generator into it and then `cargo check`. Any
5+
# regression here means a real-world spec stops compiling.
6+
#
7+
# Usage:
8+
# scripts/spec-compile.sh # run all specs in SPECS
9+
# scripts/spec-compile.sh anthropic openai # run a subset
10+
#
11+
# Env:
12+
# SPEC_COMPILE_KEEP=1 keep the scratch directory under tmp/spec-compile/
13+
# SPEC_COMPILE_OFFLINE=1 pass --offline to cargo invocations
14+
set -euo pipefail
15+
cd "$(dirname "$0")/.."
16+
17+
# (spec_name, spec_path, base_url, auth_type, auth_header)
18+
SPECS=(
19+
"anthropic|specs/anthropic.yaml|https://api.anthropic.com|ApiKey|x-api-key"
20+
"openai|specs/openai.yaml|https://api.openai.com/v1|Bearer|Authorization"
21+
)
22+
23+
# If args are given, treat them as a whitelist of spec names.
24+
WANT=("$@")
25+
26+
OFFLINE=""
27+
if [ "${SPEC_COMPILE_OFFLINE:-}" = "1" ]; then
28+
OFFLINE="--offline"
29+
fi
30+
31+
echo "[spec-compile] building openapi-to-rust binary..."
32+
cargo build --bin openapi-to-rust $OFFLINE >/dev/null
33+
34+
GEN_BIN="$(pwd)/target/debug/openapi-to-rust"
35+
36+
ROOT="$(pwd)/tmp/spec-compile"
37+
rm -rf "$ROOT"
38+
mkdir -p "$ROOT"
39+
40+
failed=()
41+
for entry in "${SPECS[@]}"; do
42+
IFS='|' read -r name spec_path base_url auth_type auth_header <<<"$entry"
43+
if [ ${#WANT[@]} -gt 0 ]; then
44+
skip=1
45+
for w in "${WANT[@]}"; do [ "$w" = "$name" ] && skip=0; done
46+
[ $skip -eq 1 ] && continue
47+
fi
48+
49+
echo
50+
echo "==> $name (spec: $spec_path)"
51+
dir="$ROOT/$name"
52+
mkdir -p "$dir/src/generated"
53+
54+
cat >"$dir/Cargo.toml" <<EOF
55+
[package]
56+
name = "spec-compile-$name"
57+
version = "0.0.0"
58+
edition = "2024"
59+
publish = false
60+
61+
[dependencies]
62+
serde = { version = "1.0", features = ["derive"] }
63+
serde_json = "1.0"
64+
serde_urlencoded = "0.7"
65+
reqwest = { version = "0.12", features = ["json", "stream", "multipart"] }
66+
reqwest-middleware = { version = "0.4", features = ["multipart"] }
67+
reqwest-retry = "0.7"
68+
reqwest-tracing = "0.5"
69+
thiserror = "1"
70+
url = "2"
71+
EOF
72+
73+
cat >"$dir/src/lib.rs" <<EOF
74+
#![allow(dead_code, unused_imports, clippy::all)]
75+
pub mod generated;
76+
EOF
77+
78+
cat >"$dir/openapi-to-rust.toml" <<EOF
79+
[generator]
80+
spec_path = "$(pwd)/$spec_path"
81+
output_dir = "src/generated"
82+
module_name = "$name"
83+
84+
[features]
85+
enable_async_client = true
86+
87+
[http_client]
88+
base_url = "$base_url"
89+
timeout_seconds = 60
90+
91+
[http_client.auth]
92+
type = "$auth_type"
93+
header_name = "$auth_header"
94+
EOF
95+
96+
(
97+
cd "$dir"
98+
"$GEN_BIN" generate --config openapi-to-rust.toml >/dev/null
99+
if ! cargo check $OFFLINE 2>&1 | tail -200; then
100+
echo "[spec-compile] $name FAILED to compile" >&2
101+
exit 1
102+
fi
103+
) || failed+=("$name")
104+
done
105+
106+
if [ "${SPEC_COMPILE_KEEP:-}" != "1" ]; then
107+
rm -rf "$ROOT"
108+
fi
109+
110+
if [ ${#failed[@]} -gt 0 ]; then
111+
echo
112+
echo "[spec-compile] FAILED: ${failed[*]}" >&2
113+
exit 1
114+
fi
115+
116+
echo
117+
echo "[spec-compile] ✅ all specs compiled cleanly"

src/analysis.rs

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3860,6 +3860,30 @@ impl SchemaAnalyzer {
38603860
/// when the parameter's inline schema is a string with `enum` or `const`
38613861
/// (e.g. `GetItemTheConstant`). The client generator emits the enum
38623862
/// alongside the operation methods. See issue #10 follow-up.
3863+
/// Look up `#/components/schemas/{name}` in the raw OpenAPI document and
3864+
/// decide whether it's a string with enum values. Used by analyze_parameter
3865+
/// (T10) so that only string-enum refs flow through to the codegen-typed
3866+
/// parameter path; struct/object refs stay as `String` until we have
3867+
/// proper deepObject / form-style query serialization (T14).
3868+
fn referenced_schema_is_string_enum(&self, name: &str) -> bool {
3869+
let Some(schema_value) = self
3870+
.openapi_spec
3871+
.get("components")
3872+
.and_then(|c| c.get("schemas"))
3873+
.and_then(|s| s.get(name))
3874+
else {
3875+
return false;
3876+
};
3877+
let is_string_type = schema_value
3878+
.get("type")
3879+
.and_then(|v| v.as_str())
3880+
.map(|s| s == "string")
3881+
.unwrap_or(false);
3882+
let has_enum_or_const =
3883+
schema_value.get("enum").is_some() || schema_value.get("const").is_some();
3884+
is_string_type && has_enum_or_const
3885+
}
3886+
38633887
fn analyze_parameter(
38643888
&self,
38653889
param: &crate::openapi::Parameter,
@@ -3877,7 +3901,17 @@ impl SchemaAnalyzer {
38773901

38783902
if let Some(schema) = &param.schema {
38793903
if let Some(ref_str) = schema.reference() {
3880-
schema_ref = self.extract_schema_name(ref_str).map(|s| s.to_string());
3904+
// T10: keep the resolved type when the target is a string-enum
3905+
// (then `Display`/`as_str` are emitted, see generate_string_enum).
3906+
// For struct/object refs we fall back to `String` here — those
3907+
// need deepObject / form / serde_urlencoded handling that's
3908+
// not yet generated; emitting the typed name would produce
3909+
// `(struct).to_string()` and not compile.
3910+
if let Some(name) = self.extract_schema_name(ref_str) {
3911+
if self.referenced_schema_is_string_enum(name) {
3912+
schema_ref = Some(name.to_string());
3913+
}
3914+
}
38813915
} else if let Some(schema_type) = schema.schema_type() {
38823916
rust_type = match schema_type {
38833917
crate::openapi::SchemaType::Boolean => "bool",

src/client_generator.rs

Lines changed: 65 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -611,8 +611,8 @@ impl CodeGenerator {
611611
) -> Result<#response_type, ApiOpError<#op_error_type>> {
612612
#url_construction
613613

614-
let mut req = #http_method_call
615-
#request_body;
614+
let mut req = #http_method_call;
615+
#request_body
616616

617617
#query_params
618618
#header_params
@@ -706,7 +706,7 @@ impl CodeGenerator {
706706
let param_ident = Self::to_field_ident(&param_name_snake);
707707
let header_name = &param.name;
708708
if param.required {
709-
if param.rust_type == "String" {
709+
if Self::param_uses_as_ref_str(param) {
710710
emit.push(quote! {
711711
req = req.header(#header_name, #param_ident.as_ref());
712712
});
@@ -715,7 +715,7 @@ impl CodeGenerator {
715715
req = req.header(#header_name, #param_ident.to_string());
716716
});
717717
}
718-
} else if param.rust_type == "String" {
718+
} else if Self::param_uses_as_ref_str(param) {
719719
emit.push(quote! {
720720
if let Some(v) = #param_ident {
721721
req = req.header(#header_name, v.as_ref());
@@ -758,7 +758,7 @@ impl CodeGenerator {
758758

759759
if param.required {
760760
// Required parameters: always add
761-
if param.rust_type == "String" {
761+
if Self::param_uses_as_ref_str(param) {
762762
param_building.push(quote! {
763763
query_params.push((#param_key, #param_name.as_ref().to_string()));
764764
});
@@ -769,7 +769,7 @@ impl CodeGenerator {
769769
}
770770
} else {
771771
// Optional parameters: add only if Some
772-
if param.rust_type == "String" {
772+
if Self::param_uses_as_ref_str(param) {
773773
param_building.push(quote! {
774774
if let Some(v) = #param_name {
775775
query_params.push((#param_key, v.as_ref().to_string()));
@@ -993,43 +993,72 @@ impl CodeGenerator {
993993
}
994994
}
995995

996+
/// True when the parameter's compile-time type is `impl AsRef<str>` and
997+
/// we should call `.as_ref()` on it before stringifying. False for any
998+
/// $ref-resolved type (T10) or non-String primitive — those just call
999+
/// `.to_string()`.
1000+
fn param_uses_as_ref_str(param: &crate::analysis::ParameterInfo) -> bool {
1001+
param.schema_ref.is_none() && param.rust_type == "String"
1002+
}
1003+
9961004
/// Generate request body serialization based on content type
1005+
/// Emit statements that mutate `req` to apply the request body. Returns
1006+
/// `quote!{}` if the operation has no body. Optional bodies (T11) gate the
1007+
/// application on `Some(_)`; required bodies apply unconditionally.
9971008
fn generate_request_body(&self, op: &OperationInfo) -> TokenStream {
998-
if let Some(ref rb) = op.request_body {
999-
use crate::analysis::RequestBodyContent;
1000-
match rb {
1001-
RequestBodyContent::Json { .. } => {
1002-
quote! {
1009+
let Some(rb) = op.request_body.as_ref() else {
1010+
return quote! {};
1011+
};
1012+
use crate::analysis::RequestBodyContent;
1013+
let required = op.request_body_required;
1014+
let (ident, apply): (TokenStream, TokenStream) = match rb {
1015+
RequestBodyContent::Json { .. } => (
1016+
quote! { request },
1017+
quote! {
1018+
req = req
10031019
.body(serde_json::to_vec(&request).map_err(HttpError::serialization_error)?)
1004-
.header("content-type", "application/json")
1005-
}
1006-
}
1007-
RequestBodyContent::FormUrlEncoded { .. } => {
1008-
quote! {
1020+
.header("content-type", "application/json");
1021+
},
1022+
),
1023+
RequestBodyContent::FormUrlEncoded { .. } => (
1024+
quote! { request },
1025+
quote! {
1026+
req = req
10091027
.body(serde_urlencoded::to_string(&request).map_err(HttpError::serialization_error)?)
1010-
.header("content-type", "application/x-www-form-urlencoded")
1011-
}
1012-
}
1013-
RequestBodyContent::Multipart => {
1014-
quote! {
1015-
.multipart(form)
1016-
}
1017-
}
1018-
RequestBodyContent::OctetStream => {
1019-
quote! {
1028+
.header("content-type", "application/x-www-form-urlencoded");
1029+
},
1030+
),
1031+
RequestBodyContent::Multipart => (
1032+
quote! { form },
1033+
quote! {
1034+
req = req.multipart(form);
1035+
},
1036+
),
1037+
RequestBodyContent::OctetStream => (
1038+
quote! { body },
1039+
quote! {
1040+
req = req
10201041
.body(body)
1021-
.header("content-type", "application/octet-stream")
1022-
}
1023-
}
1024-
RequestBodyContent::TextPlain => {
1025-
quote! {
1042+
.header("content-type", "application/octet-stream");
1043+
},
1044+
),
1045+
RequestBodyContent::TextPlain => (
1046+
quote! { body },
1047+
quote! {
1048+
req = req
10261049
.body(body)
1027-
.header("content-type", "text/plain")
1028-
}
1050+
.header("content-type", "text/plain");
1051+
},
1052+
),
1053+
};
1054+
if required {
1055+
apply
1056+
} else {
1057+
quote! {
1058+
if let Some(#ident) = #ident {
1059+
#apply
10291060
}
10301061
}
1031-
} else {
1032-
quote! {}
10331062
}
10341063
}
10351064

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

1261-
if param.rust_type == "String" {
1290+
if Self::param_uses_as_ref_str(param) {
12621291
format_args.push(quote! {
12631292
__pct_encode_path_segment(#param_ident.as_ref())
12641293
});

0 commit comments

Comments
 (0)