Skip to content

Commit 1a6fa27

Browse files
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<str>`, 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 (`<OperationId><ParamName>`) with `Display`, `AsRef<str>`, 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<str>` 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) <noreply@anthropic.com>
1 parent 3e93ad3 commit 1a6fa27

4 files changed

Lines changed: 319 additions & 6 deletions

File tree

src/analysis.rs

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,13 @@ pub struct ParameterInfo {
157157
pub rust_type: String,
158158
/// Description from OpenAPI spec
159159
pub description: Option<String>,
160+
/// String enum values when the parameter's inline schema is a string with
161+
/// `enum` or `const`. When set, `rust_type` is the synthetic enum type
162+
/// name (e.g. `GetItemTheConstant`) and the client generator emits an
163+
/// inline enum so the parameter is constrained to the declared values.
164+
/// See issue #10 follow-up.
165+
#[serde(skip_serializing_if = "Option::is_none")]
166+
pub enum_values: Option<Vec<String>>,
160167
}
161168

162169
impl Default for DependencyGraph {
@@ -3634,7 +3641,7 @@ impl SchemaAnalyzer {
36343641
if let Some(parameters) = &operation.parameters {
36353642
for param in parameters {
36363643
let resolved = self.resolve_parameter(param);
3637-
if let Some(param_info) = self.analyze_parameter(&resolved)? {
3644+
if let Some(param_info) = self.analyze_parameter(&resolved, operation_id)? {
36383645
op_info.parameters.push(param_info);
36393646
}
36403647
}
@@ -3649,7 +3656,7 @@ impl SchemaAnalyzer {
36493656
.collect();
36503657
for param in path_params {
36513658
let resolved = self.resolve_parameter(param);
3652-
if let Some(param_info) = self.analyze_parameter(&resolved)? {
3659+
if let Some(param_info) = self.analyze_parameter(&resolved, operation_id)? {
36533660
if !existing_keys
36543661
.contains(&(param_info.name.clone(), param_info.location.clone()))
36553662
{
@@ -3740,17 +3747,26 @@ impl SchemaAnalyzer {
37403747
std::borrow::Cow::Borrowed(param)
37413748
}
37423749

3743-
/// Analyze a parameter
3750+
/// Analyze a parameter.
3751+
///
3752+
/// `operation_id` is used to generate a unique synthetic enum type name
3753+
/// when the parameter's inline schema is a string with `enum` or `const`
3754+
/// (e.g. `GetItemTheConstant`). The client generator emits the enum
3755+
/// alongside the operation methods. See issue #10 follow-up.
37443756
fn analyze_parameter(
37453757
&self,
37463758
param: &crate::openapi::Parameter,
3759+
operation_id: &str,
37473760
) -> Result<Option<ParameterInfo>> {
3761+
use heck::ToPascalCase;
3762+
37483763
let name = param.name.as_deref().unwrap_or("");
37493764
let location = param.location.as_deref().unwrap_or("");
37503765
let required = param.required.unwrap_or(false);
37513766

37523767
let mut rust_type = "String".to_string();
37533768
let mut schema_ref = None;
3769+
let mut enum_values: Option<Vec<String>> = None;
37543770

37553771
if let Some(schema) = &param.schema {
37563772
if let Some(ref_str) = schema.reference() {
@@ -3764,6 +3780,20 @@ impl SchemaAnalyzer {
37643780
_ => "String",
37653781
}
37663782
.to_string();
3783+
3784+
if matches!(schema_type, crate::openapi::SchemaType::String) {
3785+
let details = schema.details();
3786+
if details.is_string_enum() {
3787+
if let Some(values) = details.string_enum_values() {
3788+
if !values.is_empty() {
3789+
let op_pascal = operation_id.replace('.', "_").to_pascal_case();
3790+
let param_pascal = name.to_pascal_case();
3791+
rust_type = format!("{op_pascal}{param_pascal}");
3792+
enum_values = Some(values);
3793+
}
3794+
}
3795+
}
3796+
}
37673797
}
37683798
}
37693799

@@ -3774,6 +3804,7 @@ impl SchemaAnalyzer {
37743804
schema_ref,
37753805
rust_type,
37763806
description: param.description.clone(),
3807+
enum_values,
37773808
}))
37783809
}
37793810
}

src/client_generator.rs

Lines changed: 93 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,11 +147,12 @@
147147
//! 5. Handles query parameters and request bodies
148148
//! 6. Configures middleware stack based on generator config
149149
150-
use crate::analysis::{OperationInfo, SchemaAnalysis};
150+
use crate::analysis::{OperationInfo, ParameterInfo, SchemaAnalysis};
151151
use crate::generator::CodeGenerator;
152152
use heck::ToSnakeCase;
153153
use proc_macro2::TokenStream;
154-
use quote::quote;
154+
use quote::{format_ident, quote};
155+
use std::collections::BTreeMap;
155156

156157
impl CodeGenerator {
157158
/// Generate the HTTP client struct with middleware support
@@ -374,6 +375,8 @@ impl CodeGenerator {
374375
/// response with a body schema) BEFORE the `impl HttpClient` block so the
375376
/// generated method signatures can reference them.
376377
pub fn generate_operation_methods(&self, analysis: &SchemaAnalysis) -> TokenStream {
378+
let param_enums = self.generate_param_enum_types(analysis);
379+
377380
let op_error_enums: Vec<TokenStream> = analysis
378381
.operations
379382
.values()
@@ -387,6 +390,8 @@ impl CodeGenerator {
387390
.collect();
388391

389392
quote! {
393+
#param_enums
394+
390395
#(#op_error_enums)*
391396

392397
impl HttpClient {
@@ -395,6 +400,92 @@ impl CodeGenerator {
395400
}
396401
}
397402

403+
/// Emit inline enum types for parameters whose schema is `type: string`
404+
/// with `enum` or `const`. The generated enum implements `Display` so it
405+
/// drops into the existing `format!`-based path/query templating without
406+
/// any special-casing at the call site. See issue #10 follow-up.
407+
fn generate_param_enum_types(&self, analysis: &SchemaAnalysis) -> TokenStream {
408+
let mut by_name: BTreeMap<String, &ParameterInfo> = BTreeMap::new();
409+
for op in analysis.operations.values() {
410+
for param in &op.parameters {
411+
if param.enum_values.is_some() {
412+
by_name.entry(param.rust_type.clone()).or_insert(param);
413+
}
414+
}
415+
}
416+
417+
if by_name.is_empty() {
418+
return quote! {};
419+
}
420+
421+
let defs: Vec<TokenStream> = by_name
422+
.values()
423+
.map(|param| self.generate_single_param_enum(param))
424+
.collect();
425+
426+
quote! { #(#defs)* }
427+
}
428+
429+
fn generate_single_param_enum(&self, param: &ParameterInfo) -> TokenStream {
430+
let Some(values) = param.enum_values.as_deref() else {
431+
return quote! {};
432+
};
433+
434+
let enum_ident = format_ident!("{}", param.rust_type);
435+
436+
let variants: Vec<TokenStream> = values
437+
.iter()
438+
.map(|value| {
439+
let variant_ident = format_ident!("{}", self.to_rust_enum_variant(value));
440+
quote! {
441+
#[serde(rename = #value)]
442+
#variant_ident,
443+
}
444+
})
445+
.collect();
446+
447+
let display_arms: Vec<TokenStream> = values
448+
.iter()
449+
.map(|value| {
450+
let variant_ident = format_ident!("{}", self.to_rust_enum_variant(value));
451+
quote! { Self::#variant_ident => #value, }
452+
})
453+
.collect();
454+
455+
let doc = format!(
456+
"Allowed values for the `{}` {} parameter.",
457+
param.name, param.location
458+
);
459+
460+
quote! {
461+
#[doc = #doc]
462+
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
463+
pub enum #enum_ident {
464+
#(#variants)*
465+
}
466+
467+
impl #enum_ident {
468+
pub fn as_str(&self) -> &'static str {
469+
match self {
470+
#(#display_arms)*
471+
}
472+
}
473+
}
474+
475+
impl std::fmt::Display for #enum_ident {
476+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
477+
f.write_str(self.as_str())
478+
}
479+
}
480+
481+
impl AsRef<str> for #enum_ident {
482+
fn as_ref(&self) -> &str {
483+
self.as_str()
484+
}
485+
}
486+
}
487+
}
488+
398489
/// Generate the per-operation typed error enum, if the op has any non-2xx
399490
/// responses with a body schema. Returns None when the op has no declared
400491
/// error bodies — those operations use `ApiOpError<serde_json::Value>` so

src/generator.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1432,7 +1432,7 @@ impl CodeGenerator {
14321432
}
14331433
}
14341434

1435-
fn to_rust_enum_variant(&self, s: &str) -> String {
1435+
pub(crate) fn to_rust_enum_variant(&self, s: &str) -> String {
14361436
// Convert string to valid Rust enum variant (PascalCase)
14371437
let mut result = String::new();
14381438
let mut next_upper = true;

0 commit comments

Comments
 (0)