Skip to content

Commit 7a2d752

Browse files
Merge pull request #42 from gpu-cli/fix/float-precision-and-param-enum-varnames
fix: float precision and parameter-level x-enum-varnames
2 parents 062d330 + 8087dd7 commit 7a2d752

15 files changed

Lines changed: 376 additions & 12 deletions

.beads/issues.jsonl

Lines changed: 6 additions & 5 deletions
Large diffs are not rendered by default.

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,22 @@ when correcting output that was wrong or incomplete on the wire.
1717
SSE variant names and require a runtime status for wildcard/default variants.
1818
This is a source-breaking correction for existing server trait implementations.
1919

20+
### Changed
21+
22+
- `format: float` now maps to `f64` instead of `f32`. JSON carries no binary32,
23+
so the declared format describes the server's storage rather than the
24+
transport: a value sent as `0.03` survives in `f64` but becomes
25+
`0.029999999329447746` through `f32`, which matters when the field is money.
26+
Set `float_precision = "f32"` under `[generator.types]` to map strictly by
27+
declared format. `--types-conservative` keeps the literal `f32` mapping.
28+
2029
### Fixed
2130

31+
- Parameter-level inline enums honor `x-enum-varnames`. Schema-level enums
32+
already did, so the same enum produced different Rust variant names depending
33+
on whether it lived in `components.schemas` or on a parameter. A varnames
34+
array whose length disagrees with `enum` is ignored rather than applied to a
35+
prefix.
2236
- Properties that are both `required` and nullable via OpenAPI 3.1's
2337
`type: ["X", "null"]` now generate `Option<T>` instead of a bare `T`, in plain
2438
object schemas and in `allOf`-composed ones alike. Previously such a client

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -642,11 +642,18 @@ binary = "bytes" # bytes (default) | vec_u8 | string
642642
uuid = "uuid" # uuid (default) | string
643643
byte = "base64" # base64 (default) | base64_url_unpadded | vec_u8 | string
644644
unsigned = true # uint32/uint64 -> u32/u64
645+
float_precision = "f64" # f64 (default) | f32 — see below
645646

646647
[generator.types.shape]
647648
additional_properties_typed = true
648649
```
649650

651+
**`format: float` maps to `f64`, not `f32`.** JSON carries no binary32, so the
652+
declared format describes the server's storage rather than the transport. A
653+
price sent on the wire as `0.03` parses losslessly into `f64`, but through
654+
`f32` it becomes `0.029999999329447746` — a real hazard when the field is
655+
money. Set `float_precision = "f32"` to map strictly by declared format.
656+
650657
## Testing
651658

652659
```bash

scripts/spec-compile.sh

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@
1717
# SPEC_COMPILE_LIMIT=N process only the first N alphabetically-sorted specs
1818
# SPEC_COMPILE_PARSE_ONLY=1 skip cargo check; only verify the generator
1919
# parses+emits without errors. Faster.
20+
# SPEC_COMPILE_FORCE_CHECK=1 also cargo check the specs in
21+
# GENERATE_ONLY_SPECS (see below), which are
22+
# skipped by default because their generated
23+
# crate exceeds CI runner memory.
2024
# SPEC_COMPILE_TARGET_DIR=path shared cargo target dir for the scratch
2125
# workspace (default tmp/spec-compile-target).
2226
# Dependency artifacts (reqwest, chrono, …)
@@ -78,11 +82,40 @@ fi
7882
echo "[spec-compile] running ${#SPECS[@]} spec(s)"
7983
echo
8084

85+
# Specs whose generated crate is too large to `cargo check` inside a standard
86+
# CI runner. They are still generated (which catches the majority of generator
87+
# defects); only the compile step is skipped, and the summary reports them
88+
# separately so a green run is never mistaken for full verification.
89+
#
90+
# Set SPEC_COMPILE_FORCE_CHECK=1 to check them anyway on a machine with the
91+
# headroom. Measured peaks, `cargo check`, single rustc process:
92+
# microsoft-graph 2.4M lines generated ~14.3 GB RSS (16,153 operations)
93+
# A GitHub-hosted ubuntu-latest runner has 16 GB total, so it is killed with
94+
# SIGTERM partway through. Raising cargo parallelism does not help — the memory
95+
# is one rustc type-checking one crate.
96+
GENERATE_ONLY_SPECS=("microsoft-graph")
97+
98+
is_generate_only() {
99+
[ "${SPEC_COMPILE_FORCE_CHECK:-}" = "1" ] && return 1
100+
for entry in "${GENERATE_ONLY_SPECS[@]}"; do
101+
[ "$entry" = "$1" ] && return 0
102+
done
103+
return 1
104+
}
105+
106+
generate_only_reason() {
107+
case "$1" in
108+
microsoft-graph) echo "~14.3 GB RSS, exceeds CI runner memory" ;;
109+
*) echo "exceeds CI runner resources" ;;
110+
esac
111+
}
112+
81113
# ---- Phase 1: generate a scratch crate per spec -------------------------
82114
passed=()
83115
failed_gen=()
84116
failed_check=()
85117
skipped=()
118+
generate_only=()
86119
gen_ok=()
87120
for entry in "${SPECS[@]}"; do
88121
IFS='|' read -r name spec_path <<<"$entry"
@@ -163,6 +196,11 @@ elif [ ${#gen_ok[@]} -gt 0 ]; then
163196
echo
164197
echo "[spec-compile] cargo check (${#gen_ok[@]} isolated manifest(s))..."
165198
for name in "${gen_ok[@]}"; do
199+
if is_generate_only "$name"; then
200+
printf "%-30s GEN-ONLY (cargo check skipped: %s)\n" "$name" "$(generate_only_reason "$name")"
201+
generate_only+=("$name")
202+
continue
203+
fi
166204
log="$ROOT/$name/check.log"
167205
if ( cd "$ROOT/$name" && CARGO_TARGET_DIR="$SCRATCH_TARGET" cargo check $OFFLINE ) >"$log" 2>&1; then
168206
printf "%-30s PASS\n" "$name"
@@ -177,12 +215,21 @@ elif [ ${#gen_ok[@]} -gt 0 ]; then
177215
fi
178216

179217
echo
180-
echo "[spec-compile] summary: ${#passed[@]} passed, ${#failed_gen[@]} gen-failed, ${#failed_check[@]} check-failed, ${#skipped[@]} skipped"
218+
echo "[spec-compile] summary: ${#passed[@]} passed, ${#failed_gen[@]} gen-failed, ${#failed_check[@]} check-failed, ${#generate_only[@]} generate-only, ${#skipped[@]} skipped"
181219
[ ${#failed_gen[@]} -gt 0 ] && echo " gen-fail: ${failed_gen[*]}"
182220
[ ${#failed_check[@]} -gt 0 ] && echo " check-fail: ${failed_check[*]}"
183221
[ ${#skipped[@]} -gt 0 ] && echo " skipped: ${skipped[*]}"
222+
if [ ${#generate_only[@]} -gt 0 ]; then
223+
echo " generate-only (NOT compile-verified): ${generate_only[*]}"
224+
echo " ^ these generated cleanly but were never compiled. Run them locally"
225+
echo " on a machine with enough RAM: scripts/spec-compile.sh ${generate_only[*]}"
226+
fi
184227

185228
if [ ${#failed_gen[@]} -gt 0 ] || [ ${#failed_check[@]} -gt 0 ]; then
186229
exit 1
187230
fi
188-
echo "[spec-compile] ✅ all specs compiled cleanly"
231+
if [ ${#generate_only[@]} -gt 0 ]; then
232+
echo "[spec-compile] ✅ ${#passed[@]} spec(s) compiled cleanly; ${#generate_only[@]} generated but not compiled"
233+
else
234+
echo "[spec-compile] ✅ all specs compiled cleanly"
235+
fi

src/analysis.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,13 @@ pub struct ParameterInfo {
447447
/// See issue #10 follow-up.
448448
#[serde(skip_serializing_if = "Option::is_none")]
449449
pub enum_values: Option<Vec<String>>,
450+
/// `x-enum-varnames` declared on the parameter's inline enum schema, when
451+
/// present and the same length as `enum_values`. Schema-level enums already
452+
/// honor this vendor extension through `SchemaAnalysis::enum_extensions`;
453+
/// parameter enums are inline and have no analyzed-schema name to key on,
454+
/// so their names ride along here instead.
455+
#[serde(skip_serializing_if = "Option::is_none")]
456+
pub enum_varnames: Option<Vec<String>>,
450457
/// Disambiguated Rust ident assigned by the analyzer at the operation
451458
/// scope. When two parameters in the same operation sanitize to the same
452459
/// snake_case name (e.g. `exclude_ids` + `exclude-ids` in vercel,
@@ -4734,6 +4741,7 @@ impl SchemaAnalyzer {
47344741
rust_type: "String".to_string(),
47354742
description: None,
47364743
enum_values: None,
4744+
enum_varnames: None,
47374745
rust_ident: None,
47384746
query_serialization: None,
47394747
validation_schema: None,
@@ -4986,6 +4994,7 @@ impl SchemaAnalyzer {
49864994
let mut rust_type = "String".to_string();
49874995
let mut schema_ref = None;
49884996
let mut enum_values: Option<Vec<String>> = None;
4997+
let mut enum_varnames: Option<Vec<String>> = None;
49894998
let mut query_serialization: Option<QuerySerialization> = None;
49904999

49915000
// OAS 3.x style/explode resolution for `in: query`. Defaults are
@@ -5102,6 +5111,21 @@ impl SchemaAnalyzer {
51025111
let op_pascal = operation_id.replace('.', "_").to_pascal_case();
51035112
let param_pascal = name.to_pascal_case();
51045113
rust_type = format!("{op_pascal}{param_pascal}");
5114+
// Honor `x-enum-varnames` here the same way
5115+
// schema-level enums do. A mismatched length is
5116+
// ambiguous about which value each name refers
5117+
// to, so drop it rather than guess.
5118+
enum_varnames = details
5119+
.extra
5120+
.get("x-enum-varnames")
5121+
.and_then(Value::as_array)
5122+
.map(|raw| {
5123+
raw.iter()
5124+
.filter_map(Value::as_str)
5125+
.map(str::to_owned)
5126+
.collect::<Vec<_>>()
5127+
})
5128+
.filter(|names| names.len() == values.len());
51055129
enum_values = Some(values);
51065130
}
51075131
}
@@ -5167,6 +5191,7 @@ impl SchemaAnalyzer {
51675191
rust_type,
51685192
description: param.description.clone(),
51695193
enum_values,
5194+
enum_varnames,
51705195
rust_ident: None,
51715196
query_serialization,
51725197
validation_schema,

src/client_generator.rs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1110,10 +1110,23 @@ impl CodeGenerator {
11101110
// while keeping each `serde(rename)` pointing at the original
11111111
// wire string.
11121112
let mut used: std::collections::HashSet<String> = std::collections::HashSet::new();
1113+
// `x-enum-varnames` wins over the naming heuristic when the spec
1114+
// supplies it — the whole point of the extension is that the author
1115+
// knows better than a transformation of the wire string. Schema-level
1116+
// enums already honored it; parameter enums did not, so the same spec
1117+
// produced different variant names depending on where its enum lived.
1118+
// Suffix disambiguation still applies, since nothing stops a spec from
1119+
// declaring two names that collide once converted to an identifier.
11131120
let variant_names: Vec<String> = values
11141121
.iter()
1115-
.map(|value| {
1116-
let base = self.to_rust_enum_variant(value);
1122+
.enumerate()
1123+
.map(|(index, value)| {
1124+
let base = param
1125+
.enum_varnames
1126+
.as_ref()
1127+
.and_then(|names| names.get(index))
1128+
.map(|name| self.to_rust_enum_variant(name))
1129+
.unwrap_or_else(|| self.to_rust_enum_variant(value));
11171130
let mut chosen = base.clone();
11181131
let mut suffix = 2;
11191132
while !used.insert(chosen.clone()) {

src/generator.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1091,6 +1091,15 @@ impl CodeGenerator {
10911091

10921092
/// Decode the generated RFC 9457 validation-problem profile
10931093
/// without replacing a documented per-operation error in `typed`.
1094+
///
1095+
/// Returns `None` unless the response's `Content-Type` is
1096+
/// `application/problem+json`, which is how RFC 9457 identifies
1097+
/// a problem document. Most third-party APIs return their
1098+
/// errors as plain `application/json`, so this yields `None`
1099+
/// against them by design — use `typed` for a documented
1100+
/// per-operation error body, or `body` for the raw payload.
1101+
/// Servers generated by this tool always emit the problem
1102+
/// media type, so this succeeds against them.
10941103
pub fn problem_details(
10951104
&self,
10961105
) -> Option<openapi_to_rust_problem::ProblemDetails> {

src/server/validation.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -916,6 +916,7 @@ mod tests {
916916
rust_type: "String".to_string(),
917917
description: None,
918918
enum_values: None,
919+
enum_varnames: None,
919920
rust_ident: None,
920921
query_serialization: None,
921922
validation_schema: Some(json!({"$ref": "#/components/schemas/Payload"})),
@@ -968,6 +969,7 @@ mod tests {
968969
rust_type: "serde_json::Value".to_string(),
969970
description: None,
970971
enum_values: None,
972+
enum_varnames: None,
971973
rust_ident: None,
972974
query_serialization: None,
973975
validation_schema: Some(json!({"const": literal})),
@@ -1010,6 +1012,7 @@ mod tests {
10101012
rust_type: "String".to_string(),
10111013
description: None,
10121014
enum_values: None,
1015+
enum_varnames: None,
10131016
rust_ident: None,
10141017
query_serialization: None,
10151018
validation_schema: Some(json!({"type": "string", "maxLength": 4})),
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
source: src/test_helpers.rs
3+
expression: "&generated_code"
4+
---
5+
//! Generated types from OpenAPI specification
6+
//!
7+
//! This file contains all the generated types for the API.
8+
//! Do not edit manually - regenerate using the appropriate script.
9+
#![allow(clippy::large_enum_variant)]
10+
#![allow(clippy::format_in_format_args)]
11+
#![allow(clippy::let_unit_value)]
12+
#![allow(unreachable_patterns)]
13+
use serde::{Deserialize, Serialize};
14+
pub type ListInstancesResponse = String;
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
source: src/test_helpers.rs
3+
expression: "&generated_code"
4+
---
5+
//! Generated types from OpenAPI specification
6+
//!
7+
//! This file contains all the generated types for the API.
8+
//! Do not edit manually - regenerate using the appropriate script.
9+
#![allow(clippy::large_enum_variant)]
10+
#![allow(clippy::format_in_format_args)]
11+
#![allow(clippy::let_unit_value)]
12+
#![allow(unreachable_patterns)]
13+
use serde::{Deserialize, Serialize};
14+
pub type ListInstancesResponse = String;

0 commit comments

Comments
 (0)