diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index a959a9b..3b541de 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,4 +1,6 @@ +{"id":"openapi-generator-5q8","title":"DateStrategy::Time emits broken serde codec for time::Date/time::Time (GH #25)","description":"GitHub issue gpu-cli/openapi-to-rust#25: fields with format: date/time under DateStrategy::Time get #[serde(with = \"time::serde::iso8601\")], but that module only supports OffsetDateTime — generated code fails to compile. Fix: emit time::serde::format_description! helper codec modules (time_date_format / time_time_format) into generated code, and correct the time dep requirement features (serde alone doesn't even enable rfc3339).","notes":"Fixed in PR https://github.com/gpu-cli/openapi-to-rust/pull/26 (branch worktree-issue-25-time-date-serde). Close when PR merges.","status":"in_progress","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-11T18:36:28Z","created_by":"James Lal","updated_at":"2026-07-11T18:49:02Z","started_at":"2026-07-11T18:36:45Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"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-cwd","title":"Config docs say [generator.types] but parser reads top-level [types] — documented block is silently ignored","description":"README (§'[generator.types] — typed-scalar strategy per format', also [generator.types.strategies]) and the doc comment on ConfigFile::types say the type-mapping block lives at [generator.types], but ConfigFile deserializes it from top-level [types]. A TOML file using the documented path is silently ignored (unknown fields aren't denied), so users think they enabled e.g. date = \"time\" but get chrono defaults. Found while testing GH #25. Fix: either move the field under GeneratorSection or fix README + doc comment, and consider deny_unknown_fields or a validation warning.","status":"open","priority":2,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-11T18:47:50Z","created_by":"James Lal","updated_at":"2026-07-11T18:47:50Z","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":"closed","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:29:45Z","started_at":"2026-07-11T03:12:21Z","closed_at":"2026-07-11T03:29:45Z","close_reason":"Fixed in PR #24, merged to main, released as v0.5.2 (crates.io + GitHub release). GH issue #23 commented.","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} diff --git a/src/generator.rs b/src/generator.rs index 98f8759..673b486 100644 --- a/src/generator.rs +++ b/src/generator.rs @@ -460,6 +460,47 @@ impl CodeGenerator { TokenStream::new() }; + // `time::Date` / `time::Time` have no built-in serde codec + // in the `time` crate (`time::serde::iso8601` is + // OffsetDateTime-only — GH #25), so declare one per type via + // the `format_description!` macro. It expands to a module + // (with an `::option` submodule) referenced from fields as + // `#[serde(with = "time_date_format")]` etc. + let time_date_helper = if analysis + .used_type_features + .contains(crate::type_mapping::TypeFeature::TimeDate) + { + quote! { + time::serde::format_description!( + time_date_format, + Date, + "[year]-[month]-[day]" + ); + } + } else { + TokenStream::new() + }; + + // RFC 3339 partial-time. `[optional [...]]` groups always + // format their contents, so whole seconds serialize with a + // trailing ".0" — in exchange, parsing accepts inputs both + // with and without fractional seconds. + let time_time_helper = if analysis + .used_type_features + .contains(crate::type_mapping::TypeFeature::TimeTime) + { + quote! { + time::serde::format_description!( + version = 2, + time_time_format, + Time, + "[hour]:[minute]:[second][optional [.[subsecond]]]" + ); + } + } else { + TokenStream::new() + }; + // Generate file with imports and types (no module wrapper). let generated = quote! { //! Generated types from OpenAPI specification @@ -476,6 +517,10 @@ impl CodeGenerator { #base64_helper + #time_date_helper + + #time_time_helper + #type_definitions }; @@ -1888,6 +1933,13 @@ impl CodeGenerator { codec.clone() }; attrs.push(quote! { with = #codec_path }); + // A `with` codec disables serde's implicit + // missing-field → None handling for Option fields + // (serde-rs/serde#2878); without `default` a request + // that simply omits the field fails to deserialize. + if is_option_wrapped { + attrs.push(quote! { default }); + } } if attrs.is_empty() { diff --git a/src/type_mapping.rs b/src/type_mapping.rs index 16b69e6..eb61581 100644 --- a/src/type_mapping.rs +++ b/src/type_mapping.rs @@ -87,6 +87,13 @@ impl MappedType { pub enum TypeFeature { Chrono, Time, + /// `time::Date` via the generated `time_date_format` codec. + /// Tracked separately from [`TypeFeature::Time`] so the + /// generator only emits the `time::serde::format_description!` + /// helper when a `format: date` field actually exists. + TimeDate, + /// `time::Time` via the generated `time_time_format` codec. + TimeTime, Iso8601, Uuid, Bytes, @@ -102,7 +109,22 @@ impl TypeFeature { pub fn dep_requirement(self) -> DepRequirement { match self { Self::Chrono => DepRequirement::new("chrono", "0.4").with_features(&["serde"]), - Self::Time => DepRequirement::new("time", "0.3").with_features(&["serde"]), + // `serde` alone doesn't enable `time::serde::rfc3339`; + // the codec modules are gated on formatting/parsing. + Self::Time => DepRequirement::new("time", "0.3").with_features(&[ + "serde", + "formatting", + "parsing", + ]), + // `macros` on top: Date/Time have no built-in serde + // codec, so the generated code declares one via + // `time::serde::format_description!`. + Self::TimeDate | Self::TimeTime => DepRequirement::new("time", "0.3").with_features(&[ + "serde", + "formatting", + "parsing", + "macros", + ]), Self::Iso8601 => DepRequirement::new("iso8601", "0.6"), Self::Uuid => DepRequirement::new("uuid", "1").with_features(&["serde", "v4"]), Self::Bytes => DepRequirement::new("bytes", "1").with_features(&["serde"]), @@ -189,8 +211,23 @@ pub fn render_required_deps_toml(deps: &[DepRequirement]) -> Option { pub fn collect_dep_requirements(used: &UsedFeatures) -> Vec { let mut deps: Vec = used.iter().map(|f| f.dep_requirement()).collect(); deps.sort_by_key(|d| d.crate_name); - deps.dedup_by_key(|d| d.crate_name); - deps + // Several features can point at the same crate with different + // feature lists (e.g. Time vs TimeDate both need `time`); union + // the features so the single emitted line satisfies all of them. + let mut merged: Vec = Vec::new(); + for dep in deps { + match merged.last_mut() { + Some(last) if last.crate_name == dep.crate_name => { + for feat in dep.features { + if !last.features.contains(&feat) { + last.features.push(feat); + } + } + } + _ => merged.push(dep), + } + } + merged } /// Tracks which optional crates the generator emitted code for. @@ -655,8 +692,12 @@ impl TypeMapper { MappedType::with_feature("chrono::NaiveDate", TypeFeature::Chrono) } DateStrategy::Time => { - self.record(TypeFeature::Time); - MappedType::with_codec("time::Date", "time::serde::iso8601", TypeFeature::Time) + self.record(TypeFeature::TimeDate); + // `time::serde::iso8601` only supports + // OffsetDateTime; `time_date_format` is a codec + // module the generator emits into the file via + // `time::serde::format_description!` (GH #25). + MappedType::with_codec("time::Date", "time_date_format", TypeFeature::TimeDate) } } } @@ -669,8 +710,10 @@ impl TypeMapper { MappedType::with_feature("chrono::NaiveTime", TypeFeature::Chrono) } DateStrategy::Time => { - self.record(TypeFeature::Time); - MappedType::with_codec("time::Time", "time::serde::iso8601", TypeFeature::Time) + self.record(TypeFeature::TimeTime); + // Same story as `time::Date`: no built-in codec, so + // the generator emits `time_time_format`. + MappedType::with_codec("time::Time", "time_time_format", TypeFeature::TimeTime) } } } diff --git a/tests/typed_scalars_test.rs b/tests/typed_scalars_test.rs index 4f54193..e052285 100644 --- a/tests/typed_scalars_test.rs +++ b/tests/typed_scalars_test.rs @@ -306,3 +306,153 @@ fn no_format_property_remains_string() { "string with no format must remain String. Code:\n{code}" ); } + +// ===================================================================== +// GH #25: DateStrategy::Time for `format: date` / `format: time`. +// `time::serde::iso8601` only supports OffsetDateTime, so the +// generator must emit its own codec modules via +// `time::serde::format_description!` instead. +// ===================================================================== + +fn time_strategy_mapper() -> TypeMapper { + use openapi_to_rust::type_mapping::DateStrategy; + TypeMapper::new(TypeMappingConfig { + date_time: DateStrategy::Time, + date: DateStrategy::Time, + time: DateStrategy::Time, + ..TypeMappingConfig::default() + }) +} + +/// The exact shape from GH #25: an *optional* `format: date` field. +fn spec_with_optional_format(format: &str) -> serde_json::Value { + json!({ + "openapi": "3.1.0", + "info": { "title": "fmt", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Sample": { + "type": "object", + "properties": { + "value": { "type": "string", "format": format } + } + } + } + } + }) +} + +#[test] +fn date_with_time_strategy_emits_generated_codec() { + let code = generate(spec_with_format("date"), time_strategy_mapper()); + assert!( + code.contains("pub value: time::Date"), + "date should map to time::Date under DateStrategy::Time. Code:\n{code}" + ); + assert!( + code.contains(r#"with = "time_date_format""#), + "required date field should use the generated codec. Code:\n{code}" + ); + assert!( + code.contains("time::serde::format_description!"), + "the codec module declaration must be emitted. Code:\n{code}" + ); + assert!( + !code.contains("time::serde::iso8601"), + "iso8601 codec is OffsetDateTime-only and must not appear (GH #25). Code:\n{code}" + ); +} + +#[test] +fn optional_date_with_time_strategy_uses_option_codec() { + let code = generate(spec_with_optional_format("date"), time_strategy_mapper()); + assert!( + code.contains("pub value: Option"), + "optional date should be Option. Code:\n{code}" + ); + assert!( + code.contains(r#"with = "time_date_format::option""#), + "optional date field should use the ::option submodule. Code:\n{code}" + ); +} + +#[test] +fn time_with_time_strategy_emits_generated_codec() { + let code = generate(spec_with_optional_format("time"), time_strategy_mapper()); + assert!( + code.contains("pub value: Option"), + "optional time should be Option. Code:\n{code}" + ); + assert!( + code.contains(r#"with = "time_time_format::option""#), + "optional time field should use the ::option submodule. Code:\n{code}" + ); + assert!( + code.contains("time_time_format"), + "the time codec module declaration must be emitted. Code:\n{code}" + ); +} + +#[test] +fn date_time_with_time_strategy_does_not_emit_codec_modules() { + // OffsetDateTime has a built-in rfc3339 codec; the generated + // format_description modules are gated on date/time usage. + let code = generate(spec_with_format("date-time"), time_strategy_mapper()); + assert!( + code.contains(r#"with = "time::serde::rfc3339""#), + "date-time keeps the built-in rfc3339 codec. Code:\n{code}" + ); + assert!( + !code.contains("format_description!"), + "no codec module should be emitted without date/time fields. Code:\n{code}" + ); +} + +#[test] +fn time_dep_requirement_includes_macro_features() { + // The generated format_description! invocation needs `macros`; + // even rfc3339 needs formatting+parsing. A bare `serde` feature + // produces code that doesn't compile. + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "fmt", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Sample": { + "type": "object", + "required": ["a"], + "properties": { + "a": { "type": "string", "format": "date-time" }, + "b": { "type": "string", "format": "date" } + } + } + } + } + }); + let mut analyzer = + SchemaAnalyzer::with_type_mapper(spec, time_strategy_mapper()).expect("analyzer"); + let mut analysis = analyzer.analyze().expect("analyze"); + let cfg = GeneratorConfig { + module_name: "sample".into(), + ..Default::default() + }; + let codegen = CodeGenerator::new(cfg); + let result = codegen.generate_all(&mut analysis).expect("generate_all"); + + // One merged `time` line carrying the union of required features. + let time_deps: Vec<_> = result + .required_deps + .iter() + .filter(|d| d.crate_name == "time") + .collect(); + assert_eq!(time_deps.len(), 1, "deps: {:?}", result.required_deps); + for feat in ["serde", "formatting", "parsing", "macros"] { + assert!( + time_deps[0].features.contains(&feat), + "time dep must include feature {feat}. Got: {:?}", + time_deps[0].features + ); + } +}