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: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"]
Expand Down
37 changes: 34 additions & 3 deletions src/analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,13 @@ pub struct ParameterInfo {
pub rust_type: String,
/// Description from OpenAPI spec
pub description: Option<String>,
/// 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<Vec<String>>,
}

impl Default for DependencyGraph {
Expand Down Expand Up @@ -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);
}
}
Expand All @@ -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()))
{
Expand Down Expand Up @@ -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<Option<ParameterInfo>> {
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<Vec<String>> = None;

if let Some(schema) = &param.schema {
if let Some(ref_str) = schema.reference() {
Expand All @@ -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);
}
}
}
}
}
}

Expand All @@ -3774,6 +3804,7 @@ impl SchemaAnalyzer {
schema_ref,
rust_type,
description: param.description.clone(),
enum_values,
}))
}
}
95 changes: 93 additions & 2 deletions src/client_generator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<TokenStream> = analysis
.operations
.values()
Expand All @@ -387,6 +390,8 @@ impl CodeGenerator {
.collect();

quote! {
#param_enums

#(#op_error_enums)*

impl HttpClient {
Expand All @@ -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<String, &ParameterInfo> = 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<TokenStream> = 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<TokenStream> = 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<TokenStream> = 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<str> 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<serde_json::Value>` so
Expand Down
2 changes: 1 addition & 1 deletion src/generator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading