Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .beads/issues.jsonl
Original file line number Diff line number Diff line change
@@ -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}
Expand Down
52 changes: 52 additions & 0 deletions src/generator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -476,6 +517,10 @@ impl CodeGenerator {

#base64_helper

#time_date_helper

#time_time_helper

#type_definitions
};

Expand Down Expand Up @@ -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() {
Expand Down
57 changes: 50 additions & 7 deletions src/type_mapping.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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"]),
Expand Down Expand Up @@ -189,8 +211,23 @@ pub fn render_required_deps_toml(deps: &[DepRequirement]) -> Option<String> {
pub fn collect_dep_requirements(used: &UsedFeatures) -> Vec<DepRequirement> {
let mut deps: Vec<DepRequirement> = 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<DepRequirement> = 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.
Expand Down Expand Up @@ -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)
}
}
}
Expand All @@ -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)
}
}
}
Expand Down
Loading
Loading