From f75aa5ab0df20046e61f9485c471500ae4988705 Mon Sep 17 00:00:00 2001 From: James Lal Date: Thu, 7 May 2026 17:42:11 -0600 Subject: [PATCH 1/2] fix(generator): treat string `const` as single-value enum (closes #10) A property like `{ "type": "string", "const": "X" }` previously generated `Option`, leaving the generated client able to send any string value. Treat it as a degenerate single-value enum so the generator emits a tightly-typed single-variant enum, matching how `enum: ["X"]` is already handled. Centralized in `SchemaDetails::is_string_enum` / `string_enum_values` so all four call sites (typed-string, untyped-string, property-with-context, inferred-string) pick up the change consistently. Discriminator paths are unaffected: `extract_inline_discriminator_value` reads `const_value` directly, and the variant struct's discriminator field is filtered out of the generated struct (`generator.rs:932-947`). Note: the existing orphan-enum pattern (e.g. `DogSpecies` in `discriminator_no_mapping.snap`) becomes more visible because more discriminator fields now produce single-variant enums. Tracked separately in #11. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/openapi.rs | 36 ++++++--- ...rust__test_helpers__array_union_items.snap | 12 +++ ...st__test_helpers__const_only_property.snap | 24 ++++++ ...st__test_helpers__const_only_required.snap | 23 ++++++ ...api_to_rust__test_helpers__debug_test.snap | 8 +- ...rust__test_helpers__oneof_in_property.snap | 20 ++++- ...test_helpers__type_property_only_test.snap | 8 +- tests/const_only_property_test.rs | 76 +++++++++++++++++++ 8 files changed, 195 insertions(+), 12 deletions(-) create mode 100644 src/snapshots/openapi_to_rust__test_helpers__const_only_property.snap create mode 100644 src/snapshots/openapi_to_rust__test_helpers__const_only_required.snap create mode 100644 tests/const_only_property_test.rs diff --git a/src/openapi.rs b/src/openapi.rs index 5db8e7e..22b8813 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -344,19 +344,37 @@ impl SchemaDetails { } /// Check if this is a string enum + /// + /// A standalone string `const` (no `enum` array) is treated as a + /// degenerate single-value enum so the generator emits a tightly-typed + /// single-variant enum instead of a bare `String`. See issue #10. pub fn is_string_enum(&self) -> bool { - self.enum_values.is_some() + self.enum_values.is_some() || self.const_string_value().is_some() } - /// Get enum values as strings if this is a string enum + /// Get enum values as strings if this is a string enum. + /// + /// Falls back to `[const_value]` when `enum` is absent but `const` is a + /// string, so a property like `{ "type": "string", "const": "X" }` + /// produces a single-variant enum. pub fn string_enum_values(&self) -> Option> { - self.enum_values.as_ref().map(|values| { - values - .iter() - .filter_map(|v| v.as_str()) - .map(|s| s.to_string()) - .collect() - }) + if let Some(values) = self.enum_values.as_ref() { + return Some( + values + .iter() + .filter_map(|v| v.as_str()) + .map(|s| s.to_string()) + .collect(), + ); + } + self.const_string_value().map(|s| vec![s]) + } + + fn const_string_value(&self) -> Option { + self.const_value + .as_ref() + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) } /// Check if a field is required diff --git a/src/snapshots/openapi_to_rust__test_helpers__array_union_items.snap b/src/snapshots/openapi_to_rust__test_helpers__array_union_items.snap index a0fa051..0e9a79d 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__array_union_items.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__array_union_items.snap @@ -27,7 +27,19 @@ pub enum ToolsRequestToolsItemUnion { pub struct TextTool { pub name: String, } +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] +pub enum TextToolType { + #[default] + #[serde(rename = "text")] + Text, +} #[derive(Debug, Clone, Deserialize, Serialize)] pub struct CodeTool { pub language: String, } +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] +pub enum CodeToolType { + #[default] + #[serde(rename = "code")] + Code, +} diff --git a/src/snapshots/openapi_to_rust__test_helpers__const_only_property.snap b/src/snapshots/openapi_to_rust__test_helpers__const_only_property.snap new file mode 100644 index 0000000..c2e159e --- /dev/null +++ b/src/snapshots/openapi_to_rust__test_helpers__const_only_property.snap @@ -0,0 +1,24 @@ +--- +source: src/test_helpers.rs +expression: "&generated_code" +--- +//! Generated types from OpenAPI specification +//! +//! This file contains all the generated types for the API. +//! Do not edit manually - regenerate using the appropriate script. +#![allow(clippy::large_enum_variant)] +#![allow(clippy::format_in_format_args)] +#![allow(clippy::let_unit_value)] +#![allow(unreachable_patterns)] +use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ConstModifier { + #[serde(rename = "someConstant", skip_serializing_if = "Option::is_none")] + pub some_constant: Option, +} +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] +pub enum ConstModifierSomeConstant { + #[default] + #[serde(rename = "TheOnlyValidValue")] + TheOnlyValidValue, +} diff --git a/src/snapshots/openapi_to_rust__test_helpers__const_only_required.snap b/src/snapshots/openapi_to_rust__test_helpers__const_only_required.snap new file mode 100644 index 0000000..510bea1 --- /dev/null +++ b/src/snapshots/openapi_to_rust__test_helpers__const_only_required.snap @@ -0,0 +1,23 @@ +--- +source: src/test_helpers.rs +expression: "&generated_code" +--- +//! Generated types from OpenAPI specification +//! +//! This file contains all the generated types for the API. +//! Do not edit manually - regenerate using the appropriate script. +#![allow(clippy::large_enum_variant)] +#![allow(clippy::format_in_format_args)] +#![allow(clippy::let_unit_value)] +#![allow(unreachable_patterns)] +use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RequiredConst { + pub kind: RequiredConstKind, +} +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] +pub enum RequiredConstKind { + #[default] + #[serde(rename = "fixed")] + Fixed, +} diff --git a/src/snapshots/openapi_to_rust__test_helpers__debug_test.snap b/src/snapshots/openapi_to_rust__test_helpers__debug_test.snap index f071326..c478327 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__debug_test.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__debug_test.snap @@ -13,5 +13,11 @@ expression: "&generated_code" use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Deserialize, Serialize)] pub struct TypedEvent { - pub r#type: String, + pub r#type: TypedEventType, +} +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] +pub enum TypedEventType { + #[default] + #[serde(rename = "event.created")] + EventCreated, } diff --git a/src/snapshots/openapi_to_rust__test_helpers__oneof_in_property.snap b/src/snapshots/openapi_to_rust__test_helpers__oneof_in_property.snap index f579682..030a656 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__oneof_in_property.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__oneof_in_property.snap @@ -14,12 +14,24 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ImageBlock { pub source: ImageBlockSource, - pub r#type: String, + pub r#type: ImageBlockType, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct URLImageSource { pub url: String, } +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] +pub enum URLImageSourceType { + #[default] + #[serde(rename = "url")] + Url, +} +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] +pub enum ImageBlockType { + #[default] + #[serde(rename = "image")] + Image, +} #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(tag = "type")] pub enum ImageBlockSource { @@ -32,3 +44,9 @@ pub enum ImageBlockSource { pub struct Base64ImageSource { pub data: String, } +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] +pub enum Base64ImageSourceType { + #[default] + #[serde(rename = "base64")] + Base64, +} diff --git a/src/snapshots/openapi_to_rust__test_helpers__type_property_only_test.snap b/src/snapshots/openapi_to_rust__test_helpers__type_property_only_test.snap index f071326..c478327 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__type_property_only_test.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__type_property_only_test.snap @@ -13,5 +13,11 @@ expression: "&generated_code" use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Deserialize, Serialize)] pub struct TypedEvent { - pub r#type: String, + pub r#type: TypedEventType, +} +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] +pub enum TypedEventType { + #[default] + #[serde(rename = "event.created")] + EventCreated, } diff --git a/tests/const_only_property_test.rs b/tests/const_only_property_test.rs new file mode 100644 index 0000000..9ef285e --- /dev/null +++ b/tests/const_only_property_test.rs @@ -0,0 +1,76 @@ +//! Regression test for https://github.com/gpu-cli/openapi-to-rust/issues/10 +//! +//! A property with a string `const` and no `enum` array should generate a +//! single-variant enum, not a bare `Option`. + +#[cfg(test)] +mod tests { + use openapi_to_rust::test_helpers::*; + use serde_json::json; + + #[test] + fn const_only_string_property_generates_single_variant_enum() { + let spec = minimal_spec(json!({ + "ConstModifier": { + "type": "object", + "properties": { + "someConstant": { + "type": "string", + "const": "TheOnlyValidValue" + } + } + } + })); + + let result = test_generation("const_only_property", spec).expect("Generation failed"); + + assert!( + result.contains("pub enum ConstModifierSomeConstant"), + "expected ConstModifierSomeConstant enum to be generated; got:\n{result}" + ); + assert!( + result.contains("#[serde(rename = \"TheOnlyValidValue\")]"), + "expected serde rename for the const value; got:\n{result}" + ); + assert!( + result.contains("TheOnlyValidValue,"), + "expected TheOnlyValidValue variant; got:\n{result}" + ); + + assert!( + result.contains("pub some_constant: Option"), + "field should reference the generated enum, not String; got:\n{result}" + ); + assert!( + !result.contains("pub some_constant: Option"), + "field should NOT be Option; got:\n{result}" + ); + } + + #[test] + fn required_const_only_property_is_not_optional() { + let spec = minimal_spec(json!({ + "RequiredConst": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "fixed" + } + }, + "required": ["kind"] + } + })); + + let result = test_generation("const_only_required", spec).expect("Generation failed"); + + assert!( + result.contains("pub enum RequiredConstKind"), + "expected RequiredConstKind enum; got:\n{result}" + ); + assert!( + result.contains("pub kind: RequiredConstKind"), + "required const field should be non-optional; got:\n{result}" + ); + } +} From 1576c95990a57d48bb67931acff2eb1af8f3c3d8 Mon Sep 17 00:00:00 2001 From: James Lal Date: Thu, 7 May 2026 17:44:25 -0600 Subject: [PATCH 2/2] chore: bump version to 0.3.0 Generated code for properties with a string `const` (no `enum` array) changes from `Option` to `Option<>` enum, which is a breaking change for downstream consumers constructing those types. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 53e10dd..19e9c18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -936,7 +936,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "openapi-to-rust" -version = "0.2.1" +version = "0.3.0" dependencies = [ "clap", "heck", diff --git a/Cargo.toml b/Cargo.toml index 2ef3501..1340740 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openapi-to-rust" -version = "0.2.1" +version = "0.3.0" edition = "2024" rust-version = "1.85.0" authors = ["James Lal"]