From 1a6fa27d55d7778ea633a4d8ea20cfec00b518e9 Mon Sep 17 00:00:00 2001 From: James Lal Date: Thu, 7 May 2026 21:26:37 -0600 Subject: [PATCH 1/2] fix(client): emit typed enum for const/enum on parameter schemas PR #12 fixed string `const` inside schema components, but the parameter pipeline is a separate code path: a path/query parameter declared with `{ "type": "string", "const": "X" }` (or `enum: [...]`) on its inline schema still generated `impl AsRef`, letting callers pass any value even though only one (or a fixed set) is valid. Extend `ParameterInfo` with `enum_values` and detect the string-enum case in `analyze_parameter`. The client generator emits a synthetic inline enum (``) with `Display`, `AsRef`, and an `as_str()` helper, plus serde rename attrs. The existing path/query templating uses `format!("{}", v)` / `v.to_string()` for non-`String` rust_types, so Display makes it work without call-site changes. The same code path also fixes explicit `enum: [...]` on parameter schemas, which was previously generated as `impl AsRef` too. `enum_values` uses `skip_serializing_if = "Option::is_none"` so existing parameter snapshots are unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/analysis.rs | 37 +++++- src/client_generator.rs | 95 +++++++++++++- src/generator.rs | 2 +- tests/const_only_parameter_test.rs | 191 +++++++++++++++++++++++++++++ 4 files changed, 319 insertions(+), 6 deletions(-) create mode 100644 tests/const_only_parameter_test.rs diff --git a/src/analysis.rs b/src/analysis.rs index 5b2c5e9..2210cb7 100644 --- a/src/analysis.rs +++ b/src/analysis.rs @@ -157,6 +157,13 @@ pub struct ParameterInfo { pub rust_type: String, /// Description from OpenAPI spec pub description: Option, + /// String enum values when the parameter's inline schema is a string with + /// `enum` or `const`. When set, `rust_type` is the synthetic enum type + /// name (e.g. `GetItemTheConstant`) and the client generator emits an + /// inline enum so the parameter is constrained to the declared values. + /// See issue #10 follow-up. + #[serde(skip_serializing_if = "Option::is_none")] + pub enum_values: Option>, } impl Default for DependencyGraph { @@ -3634,7 +3641,7 @@ impl SchemaAnalyzer { if let Some(parameters) = &operation.parameters { for param in parameters { let resolved = self.resolve_parameter(param); - if let Some(param_info) = self.analyze_parameter(&resolved)? { + if let Some(param_info) = self.analyze_parameter(&resolved, operation_id)? { op_info.parameters.push(param_info); } } @@ -3649,7 +3656,7 @@ impl SchemaAnalyzer { .collect(); for param in path_params { let resolved = self.resolve_parameter(param); - if let Some(param_info) = self.analyze_parameter(&resolved)? { + if let Some(param_info) = self.analyze_parameter(&resolved, operation_id)? { if !existing_keys .contains(&(param_info.name.clone(), param_info.location.clone())) { @@ -3740,17 +3747,26 @@ impl SchemaAnalyzer { std::borrow::Cow::Borrowed(param) } - /// Analyze a parameter + /// Analyze a parameter. + /// + /// `operation_id` is used to generate a unique synthetic enum type name + /// when the parameter's inline schema is a string with `enum` or `const` + /// (e.g. `GetItemTheConstant`). The client generator emits the enum + /// alongside the operation methods. See issue #10 follow-up. fn analyze_parameter( &self, param: &crate::openapi::Parameter, + operation_id: &str, ) -> Result> { + use heck::ToPascalCase; + let name = param.name.as_deref().unwrap_or(""); let location = param.location.as_deref().unwrap_or(""); let required = param.required.unwrap_or(false); let mut rust_type = "String".to_string(); let mut schema_ref = None; + let mut enum_values: Option> = None; if let Some(schema) = ¶m.schema { if let Some(ref_str) = schema.reference() { @@ -3764,6 +3780,20 @@ impl SchemaAnalyzer { _ => "String", } .to_string(); + + if matches!(schema_type, crate::openapi::SchemaType::String) { + let details = schema.details(); + if details.is_string_enum() { + if let Some(values) = details.string_enum_values() { + if !values.is_empty() { + let op_pascal = operation_id.replace('.', "_").to_pascal_case(); + let param_pascal = name.to_pascal_case(); + rust_type = format!("{op_pascal}{param_pascal}"); + enum_values = Some(values); + } + } + } + } } } @@ -3774,6 +3804,7 @@ impl SchemaAnalyzer { schema_ref, rust_type, description: param.description.clone(), + enum_values, })) } } diff --git a/src/client_generator.rs b/src/client_generator.rs index 949b157..ac03bf1 100644 --- a/src/client_generator.rs +++ b/src/client_generator.rs @@ -147,11 +147,12 @@ //! 5. Handles query parameters and request bodies //! 6. Configures middleware stack based on generator config -use crate::analysis::{OperationInfo, SchemaAnalysis}; +use crate::analysis::{OperationInfo, ParameterInfo, SchemaAnalysis}; use crate::generator::CodeGenerator; use heck::ToSnakeCase; use proc_macro2::TokenStream; -use quote::quote; +use quote::{format_ident, quote}; +use std::collections::BTreeMap; impl CodeGenerator { /// Generate the HTTP client struct with middleware support @@ -374,6 +375,8 @@ impl CodeGenerator { /// response with a body schema) BEFORE the `impl HttpClient` block so the /// generated method signatures can reference them. pub fn generate_operation_methods(&self, analysis: &SchemaAnalysis) -> TokenStream { + let param_enums = self.generate_param_enum_types(analysis); + let op_error_enums: Vec = analysis .operations .values() @@ -387,6 +390,8 @@ impl CodeGenerator { .collect(); quote! { + #param_enums + #(#op_error_enums)* impl HttpClient { @@ -395,6 +400,92 @@ impl CodeGenerator { } } + /// Emit inline enum types for parameters whose schema is `type: string` + /// with `enum` or `const`. The generated enum implements `Display` so it + /// drops into the existing `format!`-based path/query templating without + /// any special-casing at the call site. See issue #10 follow-up. + fn generate_param_enum_types(&self, analysis: &SchemaAnalysis) -> TokenStream { + let mut by_name: BTreeMap = BTreeMap::new(); + for op in analysis.operations.values() { + for param in &op.parameters { + if param.enum_values.is_some() { + by_name.entry(param.rust_type.clone()).or_insert(param); + } + } + } + + if by_name.is_empty() { + return quote! {}; + } + + let defs: Vec = by_name + .values() + .map(|param| self.generate_single_param_enum(param)) + .collect(); + + quote! { #(#defs)* } + } + + fn generate_single_param_enum(&self, param: &ParameterInfo) -> TokenStream { + let Some(values) = param.enum_values.as_deref() else { + return quote! {}; + }; + + let enum_ident = format_ident!("{}", param.rust_type); + + let variants: Vec = values + .iter() + .map(|value| { + let variant_ident = format_ident!("{}", self.to_rust_enum_variant(value)); + quote! { + #[serde(rename = #value)] + #variant_ident, + } + }) + .collect(); + + let display_arms: Vec = values + .iter() + .map(|value| { + let variant_ident = format_ident!("{}", self.to_rust_enum_variant(value)); + quote! { Self::#variant_ident => #value, } + }) + .collect(); + + let doc = format!( + "Allowed values for the `{}` {} parameter.", + param.name, param.location + ); + + quote! { + #[doc = #doc] + #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize)] + pub enum #enum_ident { + #(#variants)* + } + + impl #enum_ident { + pub fn as_str(&self) -> &'static str { + match self { + #(#display_arms)* + } + } + } + + impl std::fmt::Display for #enum_ident { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } + } + + impl AsRef for #enum_ident { + fn as_ref(&self) -> &str { + self.as_str() + } + } + } + } + /// Generate the per-operation typed error enum, if the op has any non-2xx /// responses with a body schema. Returns None when the op has no declared /// error bodies — those operations use `ApiOpError` so diff --git a/src/generator.rs b/src/generator.rs index 572a7c4..b1f362e 100644 --- a/src/generator.rs +++ b/src/generator.rs @@ -1432,7 +1432,7 @@ impl CodeGenerator { } } - fn to_rust_enum_variant(&self, s: &str) -> String { + pub(crate) fn to_rust_enum_variant(&self, s: &str) -> String { // Convert string to valid Rust enum variant (PascalCase) let mut result = String::new(); let mut next_upper = true; diff --git a/tests/const_only_parameter_test.rs b/tests/const_only_parameter_test.rs new file mode 100644 index 0000000..e5ce61f --- /dev/null +++ b/tests/const_only_parameter_test.rs @@ -0,0 +1,191 @@ +//! Regression test for the issue #10 follow-up: +//! https://github.com/gpu-cli/openapi-to-rust/issues/10#issuecomment-4402973418 +//! +//! When a path/query parameter's inline schema is `{"type":"string","const":"X"}` +//! the generator should emit a single-variant enum and use it in the function +//! signature, not `impl AsRef`. Same path also fixes parameters that use +//! `enum: ["A","B"]` directly on their inline schema. + +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() +} + +#[test] +fn const_only_path_parameter_generates_single_variant_enum() { + let spec = json!({ + "openapi": "3.1.0", + "info": {"title": "T", "version": "1.0.0"}, + "components": {"schemas": {"Stub": {"type": "object", "properties": {"id": {"type": "string"}}}}}, + "paths": { + "/some/const/path/{the_constant}": { + "get": { + "operationId": "someConstOperation", + "parameters": [{ + "name": "the_constant", + "in": "path", + "required": true, + "schema": { + "type": "string", + "const": "MyConstantValue" + } + }], + "responses": {"200": {"description": "ok"}} + } + } + } + }); + + let code = generate(spec); + + assert!( + code.contains("pub enum SomeConstOperationTheConstant"), + "expected SomeConstOperationTheConstant enum to be generated; got:\n{code}" + ); + assert!( + code.contains("MyConstantValue"), + "expected MyConstantValue variant; got:\n{code}" + ); + assert!( + code.contains("the_constant : SomeConstOperationTheConstant"), + "expected typed parameter, not impl AsRef; got:\n{code}" + ); + assert!( + !code.contains("the_constant : impl AsRef < str >"), + "parameter should NOT be impl AsRef; got:\n{code}" + ); +} + +#[test] +fn enum_path_parameter_generates_multi_variant_enum() { + // The same code path also covers explicit enum-on-parameter, which was + // also previously generated as `impl AsRef`. + let spec = json!({ + "openapi": "3.1.0", + "info": {"title": "T", "version": "1.0.0"}, + "components": {"schemas": {"Stub": {"type": "object", "properties": {"id": {"type": "string"}}}}}, + "paths": { + "/items/{kind}": { + "get": { + "operationId": "getByKind", + "parameters": [{ + "name": "kind", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": ["alpha", "beta", "gamma"] + } + }], + "responses": {"200": {"description": "ok"}} + } + } + } + }); + + let code = generate(spec); + + assert!( + code.contains("pub enum GetByKindKind"), + "expected GetByKindKind enum; got:\n{code}" + ); + for variant in &["Alpha", "Beta", "Gamma"] { + assert!( + code.contains(variant), + "expected variant {variant}; got:\n{code}" + ); + } + assert!( + code.contains("kind : GetByKindKind"), + "expected typed parameter; got:\n{code}" + ); +} + +#[test] +fn const_only_query_parameter_generates_optional_enum() { + let spec = 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": "version", + "in": "query", + "required": false, + "schema": { + "type": "string", + "const": "v2" + } + }], + "responses": {"200": {"description": "ok"}} + } + } + } + }); + + let code = generate(spec); + + assert!( + code.contains("pub enum ListItemsVersion"), + "expected ListItemsVersion enum; got:\n{code}" + ); + assert!( + code.contains("version : Option < ListItemsVersion >"), + "optional query param should be Option; got:\n{code}" + ); +} + +#[test] +fn const_parameter_emits_display_and_asref_impls() { + // The path/query templating relies on Display (for format!) and AsRef + // for ergonomic use; verify both are emitted. + let spec = json!({ + "openapi": "3.1.0", + "info": {"title": "T", "version": "1.0.0"}, + "components": {"schemas": {"Stub": {"type": "object", "properties": {"id": {"type": "string"}}}}}, + "paths": { + "/p/{x}": { + "get": { + "operationId": "op", + "parameters": [{ + "name": "x", + "in": "path", + "required": true, + "schema": {"type": "string", "const": "fixed"} + }], + "responses": {"200": {"description": "ok"}} + } + } + } + }); + + let code = generate(spec); + + assert!( + code.contains("impl std :: fmt :: Display for OpX"), + "expected Display impl for the param enum; got:\n{code}" + ); + assert!( + code.contains("impl AsRef < str > for OpX"), + "expected AsRef impl for the param enum; got:\n{code}" + ); +} From 7f262ba164b153a6dcecfc305240538f3b70f099 Mon Sep 17 00:00:00 2001 From: James Lal Date: Thu, 7 May 2026 21:28:35 -0600 Subject: [PATCH 2/2] chore: bump version to 0.4.0 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 19e9c18..75daf71 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -936,7 +936,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "openapi-to-rust" -version = "0.3.0" +version = "0.4.0" dependencies = [ "clap", "heck", diff --git a/Cargo.toml b/Cargo.toml index 1340740..cfa92ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openapi-to-rust" -version = "0.3.0" +version = "0.4.0" edition = "2024" rust-version = "1.85.0" authors = ["James Lal"]