diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index a46d099..95adaed 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,4 +1,6 @@ {"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} +{"id":"openapi-generator-k07","title":"[bug] Query/path parameters ignore integer/number format (int32 -\u003e i64)","description":"analyze_parameter in src/analysis.rs hardcodes Integer =\u003e i64 and Number =\u003e f64, ignoring the schema format field. Body/schema properties correctly route through TypeMapper::integer_format/number_format, so int32 becomes i32 there, but the same format on a query/path parameter always yields i64. No type_mappings/strategies config can override it because analyze_parameter never calls TypeMapper. Fixes GitHub issue #23.","notes":"Fix in PR #24 (gpu-cli/openapi-to-rust), branch worktree-fix-i32-param-format. Root cause: analyze_parameter hardcoded Integer=\u003ei64 / Number=\u003ef64, bypassing TypeMapper. Now routes through integer_format/number_format. CI green (test/clippy/fmt/doc/spec-compile). Commented on GH issue #23. Awaiting review/merge.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-11T03:12:09Z","created_by":"James Lal","updated_at":"2026-07-11T03:24:54Z","started_at":"2026-07-11T03:12:21Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"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} {"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} {"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} {"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} diff --git a/src/analysis.rs b/src/analysis.rs index 7714b1e..d8370fa 100644 --- a/src/analysis.rs +++ b/src/analysis.rs @@ -4561,14 +4561,23 @@ impl SchemaAnalyzer { } } } else if let Some(schema_type) = schema.schema_type() { + // Route integer/number through the same TypeMapper the schema + // property path uses (see analyze_property), so `format: int32` + // yields `i32` and `[type_mappings]`/strategy config applies to + // parameters too. Hardcoding `i64`/`f64` here previously made + // `format` and config impossible to honour for query/path params. + let format = schema.details().format.clone(); rust_type = match schema_type { - crate::openapi::SchemaType::Boolean => "bool", - crate::openapi::SchemaType::Integer => "i64", - crate::openapi::SchemaType::Number => "f64", - crate::openapi::SchemaType::String => "String", - _ => "String", - } - .to_string(); + crate::openapi::SchemaType::Boolean => "bool".to_string(), + crate::openapi::SchemaType::Integer => { + self.type_mapper.integer_format(format.as_deref()).rust_type + } + crate::openapi::SchemaType::Number => { + self.type_mapper.number_format(format.as_deref()).rust_type + } + crate::openapi::SchemaType::String => "String".to_string(), + _ => "String".to_string(), + }; if matches!(schema_type, crate::openapi::SchemaType::String) { let details = schema.details(); diff --git a/tests/parameter_integer_format_test.rs b/tests/parameter_integer_format_test.rs new file mode 100644 index 0000000..6271e89 --- /dev/null +++ b/tests/parameter_integer_format_test.rs @@ -0,0 +1,123 @@ +//! Regression test for https://github.com/gpu-cli/openapi-to-rust/issues/23 +//! +//! Query/path parameters previously ignored the schema `format`: `analyze_parameter` +//! hardcoded `integer => i64` and `number => f64`, bypassing the `TypeMapper` that +//! the schema-property path already uses. As a result a `format: int32` query +//! parameter always rendered as `i64` and no `[type_mappings]`/strategy config could +//! change it. Parameters must resolve their scalar type through the same mapper as +//! properties. + +use openapi_to_rust::{CodeGenerator, GeneratorConfig, analysis::SchemaAnalyzer}; +use serde_json::json; +use std::path::PathBuf; + +fn config() -> GeneratorConfig { + GeneratorConfig { + spec_path: PathBuf::from("test.json"), + output_dir: PathBuf::from("test_output"), + module_name: "test".to_string(), + enable_async_client: true, + ..Default::default() + } +} + +fn generate(spec: serde_json::Value) -> String { + let mut analyzer = SchemaAnalyzer::new(spec).expect("analyzer construction"); + let analysis = analyzer.analyze().expect("analysis"); + let generator = CodeGenerator::new(config()); + generator.generate_operation_methods(&analysis).to_string() +} + +fn spec() -> serde_json::Value { + json!({ + "openapi": "3.1.0", + "info": {"title": "T", "version": "1.0.0"}, + "components": {"schemas": {"Stub": {"type": "object", "properties": {"id": {"type": "string"}}}}}, + "paths": { + "/items": { + "get": { + "operationId": "listItems", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "format": "int32", + "default": 20, + "maximum": 100, + "minimum": 1 + } + }, + { + "name": "offset", + "in": "query", + "required": true, + "schema": {"type": "integer", "format": "int64"} + }, + { + "name": "cursor", + "in": "query", + "required": true, + "schema": {"type": "integer"} + }, + { + "name": "ratio", + "in": "query", + "required": true, + "schema": {"type": "number", "format": "float"} + }, + { + "name": "weight", + "in": "query", + "required": true, + "schema": {"type": "number", "format": "double"} + } + ], + "responses": {"200": {"description": "ok"}} + } + } + } + }) +} + +#[test] +fn int32_query_parameter_resolves_to_i32() { + let code = generate(spec()); + assert!( + code.contains("limit : i32"), + "int32 query parameter should render as i32; got:\n{code}" + ); + assert!( + !code.contains("limit : i64"), + "int32 query parameter must NOT fall back to i64; got:\n{code}" + ); +} + +#[test] +fn int64_and_formatless_integer_parameters_stay_i64() { + let code = generate(spec()); + assert!( + code.contains("offset : i64"), + "int64 query parameter should render as i64; got:\n{code}" + ); + // No format defaults to i64, matching the schema-property path. + assert!( + code.contains("cursor : i64"), + "formatless integer query parameter should default to i64; got:\n{code}" + ); +} + +#[test] +fn number_format_parameters_resolve_through_type_mapper() { + let code = generate(spec()); + assert!( + code.contains("ratio : f32"), + "float query parameter should render as f32; got:\n{code}" + ); + assert!( + code.contains("weight : f64"), + "double query parameter should render as f64; got:\n{code}" + ); +}