Skip to content

Commit 65c6d46

Browse files
fix(params): honour integer/number format for query & path parameters
`analyze_parameter` hardcoded `Integer => i64` and `Number => f64`, ignoring the schema `format` and bypassing the `TypeMapper` that the schema-property path already routes through. A `format: int32` query parameter therefore always rendered as `i64`, and no `[type_mappings]`/strategy config could override it because the mapper was never consulted. Route parameter integer/number types through `TypeMapper::integer_format`/`number_format`, matching the property path: `int32 -> i32`, `int64 -> i64`, `float -> f32`, `double -> f64`, formatless integers stay `i64`, and config now applies to parameters. Adds tests/parameter_integer_format_test.rs covering int32/int64/ formatless integer and float/double query parameters. Fixes #23 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1a30922 commit 65c6d46

3 files changed

Lines changed: 140 additions & 7 deletions

File tree

‎.beads/issues.jsonl‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
{"id":"openapi-generator-dpd","title":"Hybrid string-or-object discriminated unions deserialize-fail","description":"When an anyOf/oneOf union contains a discriminator AND a non-object branch (e.g. string-enum like ToolChoiceOptions), the generator emits a tagged enum that cannot deserialize the string form. Real-world hit: OpenAI ToolChoiceParam returns 'auto' in Response.tool_choice, but generated type is #[serde(tag=\"type\")] enum with no untagged String variant. Need to fall back to #[serde(untagged)] when the union mixes string/scalar branches with tagged-object branches, OR add a String fallback variant before the tagged variants.","notes":"Live repro: `ToolChoiceParam` from openai.yaml line 52518. anyOf has 8 branches; the first (`ToolChoiceOptions`) is a string-enum (\"none\"|\"auto\"|\"required\"), the rest are objects with discriminator propertyName=type. Generator emits `#[serde(tag=\"type\")] enum ToolChoiceParam { ToolChoiceOptions(ToolChoiceOptions), ... }` which cannot deserialize the string \"auto\" because serde tries to read a \"type\" field from a JSON string. Fix: when an anyOf/oneOf branch is a non-object schema (string/number/etc), the generator must emit `#[serde(untagged)]` with the scalar branch first OR add a String variant before the tagged variants. Hit on real OpenAI Responses API `Response.tool_choice` field.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-10T23:47:35Z","created_by":"James Lal","updated_at":"2026-05-11T00:12:46Z","started_at":"2026-05-10T23:54:10Z","closed_at":"2026-05-11T00:12:46Z","close_reason":"Fixed in src/analysis.rs: (dpd) analyze_oneof_union now downgrades to untagged when any branch is non-object — verified live against OpenAI Response.tool_choice='auto' which now deserializes as ToolChoiceParam::ToolChoiceOptions(Auto). (bgo) merge_schema_into_properties now ORs in is_nullable_pattern() for allOf-merged props — verified live against OpenAI Response.incomplete_details which is now Option\u003cResponseIncompleteDetails\u003e and deserializes null cleanly. All 4 smoke tests (OpenAI+Anthropic, sync+stream) pass.","dependency_count":0,"dependent_count":0,"comment_count":0}
2+
{"id":"openapi-generator-76h","title":"Add ByteStrategy::Base64UrlUnpadded for format: byte","description":"Add a new ByteStrategy variant emitting RFC 7515 §2 url-safe unpadded base64 in the inlined base64_serde helper. Needed by gpu-cli portal-schema work (JWK/DPoP thumbprints, sealing pubkeys) so format: byte fields can round-trip without falling back to plain String + spec-side documentation. Spec-global config knob, not per-field.","status":"closed","priority":2,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-16T17:58:36Z","created_by":"James Lal","updated_at":"2026-05-16T18:29:10Z","started_at":"2026-05-16T17:58:50Z","closed_at":"2026-05-16T18:29:10Z","close_reason":"Shipped on worktree-base64url-unpadded (commit afc8c88). 16 typed_scalars_test, 23 type_mapping unit tests, 51-binary cargo test suite all green; clippy --lib clean.","dependency_count":0,"dependent_count":0,"comment_count":0}
23
{"id":"openapi-generator-9ek","title":"[Server P6] Snapshot tests + integration smoke test against openai.yaml","description":"End-to-end validation of server codegen.\n\nSnapshot coverage:\n- createChatCompletion alone (canonical case, both non-streaming and SSE variants)\n- createEmbedding alone (single non-streaming op, different tag)\n- tag:Files (multi-op single tag)\n- Mixed: createChatCompletion + tag:Embeddings (multi-tag router)\n\nFor each: snapshot the full generated tree (server/api.rs, server/router.rs, server/errors.rs, models.rs).\n\nIntegration smoke test:\n- Compile a small consumer crate under tests/conformance/server/ that has a hand-written impl ChatApi for TestState returning canned responses.\n- Boot the router on a random port via tokio::spawn.\n- Hit POST /v1/chat/completions with a sample body, assert response shape.\n- Hit with stream=true, assert SSE event sequence.\n\nDoc updates:\n- README section 'Server codegen' with the 3-command DevX loop (list → add → generate).\n- Example in examples/openai_server.rs showing the user-side impl + main().","design":"Tests live alongside existing snapshot tests. Snapshots in src/snapshots/. Integration test uses reqwest (already a dep) to hit the in-process server.","acceptance_criteria":"- [ ] insta snapshots for all four scenarios above, reviewed and accepted.\n- [ ] Integration smoke test passes in CI.\n- [ ] Regenerating after a spec rename produces a clear error (manually tested).\n- [ ] README and example present.","notes":"Canonical test cases (per user direction 2026-05-10):\n- OpenAI Responses API: operationId 'createResponse' in specs/openai.yaml (POST /v1/responses). Streaming via stream:true in body.\n- Anthropic Messages API: operationId 'messages_post' in specs/anthropic.yaml (POST /v1/messages). Streaming via stream:true in body.\n\nBoth are the production drivers for server codegen — every phase should keep these two working as the bar for 'done'.","status":"open","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-11T01:00:24Z","created_by":"James Lal","updated_at":"2026-05-11T01:44:50Z","dependencies":[{"issue_id":"openapi-generator-9ek","depends_on_id":"openapi-generator-cdx","type":"blocks","created_at":"2026-05-10T19:00:37Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
34
{"id":"openapi-generator-cdx","title":"[Server P5] Router factory + extractors + model pruning","description":"Wire the traits from P4 to an Axum Router and prune models.\n\nRouter factory (combined when ops span multiple tags):\n pub fn router\u003cC, E\u003e(chat: C, embeddings: E) -\u003e axum::Router\n where C: ChatApi + Clone, E: EmbeddingsApi + Clone, ...\n\nSingle tag collapses to single generic. Per-operation Axum handler wraps trait call, performs JSON/path/query extraction.\n\nExtractors:\n- BearerAuth (when securityScheme=bearer is used by picked ops)\n- ApiKeyHeader (when securityScheme=apiKey in header)\n- Typed wrappers for required headers/query/path parameters\n\nModel pruning:\n- Compute transitive reachability from picked operations' request/response types.\n- Emit only reachable schemas in models.rs (the rest are not generated for server even if client gen would emit them).\n- If client gen is ALSO enabled, models.rs is the union of both reachability sets.\n\nStreaming:\n- text/event-stream responses → variant carries Sse\u003cBoxStream\u003c'static, Result\u003cEvent, Infallible\u003e\u003e\u003e.\n- Conditional streaming (stream:bool in body) → both Ok(...) and OkStream(Sse\u003c...\u003e) variants.","design":"Extends src/server_generator.rs with router emission. Reuses streaming.rs SSE infrastructure. Model pruning reuses (or factors out) the reachability walk already in client_generator.rs. New files emitted: generated/server/{mod,api,router,extractors,errors}.rs.","acceptance_criteria":"- [ ] Generated router compiles and serves picked endpoints.\n- [ ] Path/query/header parameters extracted with correct types.\n- [ ] Streaming endpoint (createChatCompletion with stream=true) returns SSE.\n- [ ] models.rs contains only types reachable from picked ops when only server gen is enabled.\n- [ ] Round-trip: hit the endpoint with a request body, get back the expected response shape.\n- [ ] No unused imports or dead code warnings in generated server/.","notes":"P5 partial — router factory emitted in P4 commit. Remaining for full P5: typed extractors for query/header params, multi-trait combined router, model pruning to picked-ops reachability. Current state generates working code for canonical specs (createResponse + messages_post) end-to-end.","status":"open","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-05-11T01:00:16Z","created_by":"James Lal","updated_at":"2026-05-11T02:41:31Z","dependencies":[{"issue_id":"openapi-generator-cdx","depends_on_id":"openapi-generator-jih","type":"blocks","created_at":"2026-05-10T19:00:37Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
45
{"id":"openapi-generator-jih","title":"[Server P4] Trait + typed response-enum emitter","description":"Emit the trait-and-response-enum core of server codegen.\n\nFor each picked operation:\n- A trait method on the per-tag trait (or single ServerApi trait if untagged).\n- A response enum: one variant per documented status code, variant payload = documented body type.\n- An IntoResponse impl mapping each variant → (StatusCode, Json).\n\nTrait shape:\n #[axum::async_trait]\n pub trait ChatApi: Send + Sync + 'static {\n async fn create_chat_completion(\n \u0026self,\n auth: BearerAuth,\n body: CreateChatCompletionRequest,\n ) -\u003e CreateChatCompletionResponse;\n }\n\nResponse enum:\n pub enum CreateChatCompletionResponse {\n Ok(ChatCompletionResponse),\n BadRequest(ErrorResponse),\n TooManyRequests(ErrorResponse),\n }\n\nTrait grouping: one trait per tag in operation order. Operations without a tag land on a default 'ServerApi' trait.","design":"New module src/server_generator.rs paralleling client_generator.rs. Reuses type_mapping.rs for body/parameter types. Reuses analysis.rs response metadata. Status-code → variant name mapping via heck (200 Ok, 400 BadRequest, 404 NotFound, default Default, etc.).","acceptance_criteria":"- [ ] Generated trait method signature matches design.\n- [ ] Response enum has one variant per status documented in the operation's responses map.\n- [ ] Default response (responses.default) → variant 'Default'.\n- [ ] IntoResponse impl returns correct StatusCode + Json body per variant.\n- [ ] No #[validate(...)] attrs, no validator dep usage (project rule).\n- [ ] Compiles against axum 0.7 (or 0.8 if upgrading) and serde_json.\n- [ ] Snapshot test for trait+enums emitted from createChatCompletion.","status":"closed","priority":2,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-11T01:00:09Z","created_by":"James Lal","updated_at":"2026-05-11T02:41:23Z","started_at":"2026-05-11T02:13:54Z","closed_at":"2026-05-11T02:41:23Z","close_reason":"P4 trait+response-enum emitter + P5-lite router factory shipped together. Emits server/{mod,api,errors,router}.rs. Trait per tag with snake_case methods; typed response enums with status-code variants + IntoResponse impl; SSE OkStream variant when supports_streaming; ServerEventStream alias for the SSE payload. Router factory takes T: Trait+Clone, wires routes via axum::routing::*, handles JSON+Path extraction. Both example crates exercise unary+SSE branches; cargo test passes for both.","dependencies":[{"issue_id":"openapi-generator-jih","depends_on_id":"openapi-generator-lyo","type":"blocks","created_at":"2026-05-10T19:00:36Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}

‎src/analysis.rs‎

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4561,14 +4561,23 @@ impl SchemaAnalyzer {
45614561
}
45624562
}
45634563
} else if let Some(schema_type) = schema.schema_type() {
4564+
// Route integer/number through the same TypeMapper the schema
4565+
// property path uses (see analyze_property), so `format: int32`
4566+
// yields `i32` and `[type_mappings]`/strategy config applies to
4567+
// parameters too. Hardcoding `i64`/`f64` here previously made
4568+
// `format` and config impossible to honour for query/path params.
4569+
let format = schema.details().format.clone();
45644570
rust_type = match schema_type {
4565-
crate::openapi::SchemaType::Boolean => "bool",
4566-
crate::openapi::SchemaType::Integer => "i64",
4567-
crate::openapi::SchemaType::Number => "f64",
4568-
crate::openapi::SchemaType::String => "String",
4569-
_ => "String",
4570-
}
4571-
.to_string();
4571+
crate::openapi::SchemaType::Boolean => "bool".to_string(),
4572+
crate::openapi::SchemaType::Integer => {
4573+
self.type_mapper.integer_format(format.as_deref()).rust_type
4574+
}
4575+
crate::openapi::SchemaType::Number => {
4576+
self.type_mapper.number_format(format.as_deref()).rust_type
4577+
}
4578+
crate::openapi::SchemaType::String => "String".to_string(),
4579+
_ => "String".to_string(),
4580+
};
45724581

45734582
if matches!(schema_type, crate::openapi::SchemaType::String) {
45744583
let details = schema.details();
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
//! Regression test for https://github.com/gpu-cli/openapi-to-rust/issues/23
2+
//!
3+
//! Query/path parameters previously ignored the schema `format`: `analyze_parameter`
4+
//! hardcoded `integer => i64` and `number => f64`, bypassing the `TypeMapper` that
5+
//! the schema-property path already uses. As a result a `format: int32` query
6+
//! parameter always rendered as `i64` and no `[type_mappings]`/strategy config could
7+
//! change it. Parameters must resolve their scalar type through the same mapper as
8+
//! properties.
9+
10+
use openapi_to_rust::{CodeGenerator, GeneratorConfig, analysis::SchemaAnalyzer};
11+
use serde_json::json;
12+
use std::path::PathBuf;
13+
14+
fn config() -> GeneratorConfig {
15+
GeneratorConfig {
16+
spec_path: PathBuf::from("test.json"),
17+
output_dir: PathBuf::from("test_output"),
18+
module_name: "test".to_string(),
19+
enable_async_client: true,
20+
..Default::default()
21+
}
22+
}
23+
24+
fn generate(spec: serde_json::Value) -> String {
25+
let mut analyzer = SchemaAnalyzer::new(spec).expect("analyzer construction");
26+
let analysis = analyzer.analyze().expect("analysis");
27+
let generator = CodeGenerator::new(config());
28+
generator.generate_operation_methods(&analysis).to_string()
29+
}
30+
31+
fn spec() -> serde_json::Value {
32+
json!({
33+
"openapi": "3.1.0",
34+
"info": {"title": "T", "version": "1.0.0"},
35+
"components": {"schemas": {"Stub": {"type": "object", "properties": {"id": {"type": "string"}}}}},
36+
"paths": {
37+
"/items": {
38+
"get": {
39+
"operationId": "listItems",
40+
"parameters": [
41+
{
42+
"name": "limit",
43+
"in": "query",
44+
"required": true,
45+
"schema": {
46+
"type": "integer",
47+
"format": "int32",
48+
"default": 20,
49+
"maximum": 100,
50+
"minimum": 1
51+
}
52+
},
53+
{
54+
"name": "offset",
55+
"in": "query",
56+
"required": true,
57+
"schema": {"type": "integer", "format": "int64"}
58+
},
59+
{
60+
"name": "cursor",
61+
"in": "query",
62+
"required": true,
63+
"schema": {"type": "integer"}
64+
},
65+
{
66+
"name": "ratio",
67+
"in": "query",
68+
"required": true,
69+
"schema": {"type": "number", "format": "float"}
70+
},
71+
{
72+
"name": "weight",
73+
"in": "query",
74+
"required": true,
75+
"schema": {"type": "number", "format": "double"}
76+
}
77+
],
78+
"responses": {"200": {"description": "ok"}}
79+
}
80+
}
81+
}
82+
})
83+
}
84+
85+
#[test]
86+
fn int32_query_parameter_resolves_to_i32() {
87+
let code = generate(spec());
88+
assert!(
89+
code.contains("limit : i32"),
90+
"int32 query parameter should render as i32; got:\n{code}"
91+
);
92+
assert!(
93+
!code.contains("limit : i64"),
94+
"int32 query parameter must NOT fall back to i64; got:\n{code}"
95+
);
96+
}
97+
98+
#[test]
99+
fn int64_and_formatless_integer_parameters_stay_i64() {
100+
let code = generate(spec());
101+
assert!(
102+
code.contains("offset : i64"),
103+
"int64 query parameter should render as i64; got:\n{code}"
104+
);
105+
// No format defaults to i64, matching the schema-property path.
106+
assert!(
107+
code.contains("cursor : i64"),
108+
"formatless integer query parameter should default to i64; got:\n{code}"
109+
);
110+
}
111+
112+
#[test]
113+
fn number_format_parameters_resolve_through_type_mapper() {
114+
let code = generate(spec());
115+
assert!(
116+
code.contains("ratio : f32"),
117+
"float query parameter should render as f32; got:\n{code}"
118+
);
119+
assert!(
120+
code.contains("weight : f64"),
121+
"double query parameter should render as f64; got:\n{code}"
122+
);
123+
}

0 commit comments

Comments
 (0)