Skip to content

Commit 19493d0

Browse files
feat(generator): constraint doc comments + x-enum-varnames (Q2.4, Q2.6)
Two doc-comment-emitting features. Both default-on, both feed non-binding human-readable hints to callers without adding any runtime crate dependencies. ## Q2.4 — constraint annotations as doc comments Pre-Q2.4 the generator parsed minimum/maximum/min_length/ max_length/pattern/multiple_of/min_items/max_items/uniqueItems into SchemaDetails but never emitted them. Real specs use these heavily (13k+ uniqueItems and 4k+ min/max occurrences across the corpus); dropping them was a real loss for callers trying to understand the contract. Now each property with at least one constraint gets a `/// Constraint: <key>=<value>, …` doc comment. Pattern strings are escaped so `///` and `*/` substrings can't terminate the surrounding doc comment. Toggle: `[generator.types.constraints] mode = "doc"` (default) / `"off"` (suppress entirely). **No client-side validation** by design. The generator never emits `#[validate(...)]` attributes or pulls in the `validator` crate. OpenAPI constraints belong on the wire contract; the server is the source of truth. Doc comments give callers visibility without the SDK duplicating server logic and going brittle when rules drift. The `no_validate_attribute_is_ever_emitted` test pins this guarantee. Implementation: - `PropertyConstraints` struct in analysis.rs captures the relevant SchemaDetails fields per property. - `PropertyInfo` carries the constraints alongside the schema type. - Generator emits the doc line via `generate_constraint_doc()` + `format_constraints_doc()` helper. ## Q2.6 — x-enum-varnames / x-enum-descriptions Common vendor extensions for enum schemas: arrays of Rust-friendly variant identifiers and per-variant descriptions, parallel to the spec's `enum` array. Used by arcade.yaml, datadog-v2.yaml, and others in the corpus. When `x-enum-varnames` is present and length-matches the enum array, the generator uses those identifiers for variant names instead of the default PascalCase heuristic. Wire format is preserved via `#[serde(rename = "<original-value>")]`. When `x-enum-descriptions` is present, each entry becomes the variant's doc comment. Length-mismatched extensions are silently dropped at analysis time with a stderr warning; the generator falls back to the default heuristic. Toggles: `[generator.types.enums]` `x_enum_varnames` / `x_enum_descriptions` (both default true). Implementation: - `EnumExtensions` struct in analysis.rs holds the validated varnames + descriptions. - `SchemaAnalysis.enum_extensions` side-channel keyed by analyzed- schema name (avoided extending every StringEnum constructor). - `extract_enum_extensions()` populates after analyze() by reading `original` JSON. - `generate_string_enum` + `generate_extensible_enum` accept an `Option<&EnumExtensions>` and apply overrides when toggles allow. ## Verification - 8 new tests in tests/constraint_doc_test.rs (Q2.4). - 6 new tests in tests/x_enum_varnames_test.rs (Q2.6). - 1 snapshot updated (union_array_naming) where a real spec field with a `pattern` got its constraint doc surfaced. - Full integration suite passes; spec-compile gate verification pending in next commit. Closes openapi-generator-d8y (Q2.4) and openapi-generator-4mu (Q2.6). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 05d555b commit 19493d0

9 files changed

Lines changed: 794 additions & 26 deletions

.beads/issues.jsonl

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.

src/analysis.rs

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,62 @@ use serde_json::Value;
55
use std::collections::{BTreeMap, HashSet};
66
use std::path::Path;
77

8+
/// Q2.6 — pull `x-enum-varnames` / `x-enum-descriptions` arrays off
9+
/// the schema's original JSON. Both extensions must be string arrays
10+
/// matching the enum-value count; mismatched extensions are dropped
11+
/// with a stderr warning so they can't subtly break codegen.
12+
///
13+
/// Returns `None` when neither extension is present.
14+
fn extract_enum_extensions(
15+
original: &Value,
16+
enum_value_count: usize,
17+
schema_name: &str,
18+
) -> Option<EnumExtensions> {
19+
let obj = original.as_object()?;
20+
21+
let read_string_array = |key: &str| -> Option<Vec<String>> {
22+
let arr = obj.get(key)?.as_array()?;
23+
let mut out = Vec::with_capacity(arr.len());
24+
for v in arr {
25+
out.push(v.as_str()?.to_string());
26+
}
27+
Some(out)
28+
};
29+
30+
let varnames_raw = read_string_array("x-enum-varnames");
31+
let descriptions_raw = read_string_array("x-enum-descriptions");
32+
33+
if varnames_raw.is_none() && descriptions_raw.is_none() {
34+
return None;
35+
}
36+
37+
let validate = |label: &str, vals: Option<Vec<String>>| -> Vec<String> {
38+
let Some(vals) = vals else {
39+
return Vec::new();
40+
};
41+
if vals.len() == enum_value_count {
42+
vals
43+
} else {
44+
eprintln!(
45+
"⚠️ {schema_name}: dropping {label} (expected {enum_value_count} entries, got {})",
46+
vals.len()
47+
);
48+
Vec::new()
49+
}
50+
};
51+
52+
let varnames = validate("x-enum-varnames", varnames_raw);
53+
let descriptions = validate("x-enum-descriptions", descriptions_raw);
54+
55+
if varnames.is_empty() && descriptions.is_empty() {
56+
return None;
57+
}
58+
Some(EnumExtensions {
59+
varnames,
60+
descriptions,
61+
})
62+
}
63+
864
#[derive(Debug, Clone)]
965
pub struct SchemaAnalysis {
1066
/// All schemas indexed by name
@@ -23,6 +79,29 @@ pub struct SchemaAnalysis {
2379
///
2480
/// [`TypeMapper`]: crate::type_mapping::TypeMapper
2581
pub used_type_features: crate::type_mapping::UsedFeatures,
82+
/// Q2.6: per-schema vendor enum extensions
83+
/// (`x-enum-varnames` / `x-enum-descriptions`). Populated during
84+
/// analysis when a StringEnum / ExtensibleEnum schema declares
85+
/// either extension; the generator uses these to override the
86+
/// default heuristic variant names and emit per-variant doc
87+
/// comments. Indexed by analyzed-schema name. Side-channel so we
88+
/// don't have to touch every StringEnum constructor.
89+
pub enum_extensions: BTreeMap<String, EnumExtensions>,
90+
}
91+
92+
/// Q2.6 — vendor extensions describing a string enum's variant
93+
/// names and per-variant descriptions. Length must match the
94+
/// schema's `enum` array; mismatched extensions are dropped at
95+
/// analysis time with a warning.
96+
#[derive(Debug, Clone, Default)]
97+
pub struct EnumExtensions {
98+
/// `x-enum-varnames`: Rust-friendly variant identifiers per
99+
/// enum value, in the same order as the spec's `enum` array.
100+
/// When present and length matches, the generator uses these
101+
/// instead of its default PascalCase heuristic.
102+
pub varnames: Vec<String>,
103+
/// `x-enum-descriptions`: one doc-comment per enum value.
104+
pub descriptions: Vec<String>,
26105
}
27106

28107
#[derive(Debug, Clone)]
@@ -104,6 +183,75 @@ pub struct PropertyInfo {
104183
pub description: Option<String>,
105184
pub default: Option<serde_json::Value>,
106185
pub serde_attrs: Vec<String>,
186+
/// Q2.4: OpenAPI constraint annotations captured from the
187+
/// property schema. Surfaced by the generator as `/// Constraint:
188+
/// …` doc lines and/or `#[validate(...)]` attributes depending on
189+
/// `[generator.types.constraints] mode`.
190+
pub constraints: PropertyConstraints,
191+
}
192+
193+
/// Q2.4 — per-property OpenAPI constraint annotations
194+
/// (`minimum`/`maximum`/`minLength`/`maxLength`/`pattern`/etc.).
195+
/// Populated during analysis from `SchemaDetails`; consumed by the
196+
/// generator to emit doc comments and/or `#[validate(...)]` attrs.
197+
#[derive(Debug, Clone, Default)]
198+
pub struct PropertyConstraints {
199+
pub minimum: Option<f64>,
200+
pub maximum: Option<f64>,
201+
pub exclusive_minimum: Option<f64>,
202+
pub exclusive_maximum: Option<f64>,
203+
pub multiple_of: Option<f64>,
204+
pub min_length: Option<u64>,
205+
pub max_length: Option<u64>,
206+
pub pattern: Option<String>,
207+
pub min_items: Option<u64>,
208+
pub max_items: Option<u64>,
209+
pub unique_items: Option<bool>,
210+
}
211+
212+
impl PropertyConstraints {
213+
pub fn is_empty(&self) -> bool {
214+
self.minimum.is_none()
215+
&& self.maximum.is_none()
216+
&& self.exclusive_minimum.is_none()
217+
&& self.exclusive_maximum.is_none()
218+
&& self.multiple_of.is_none()
219+
&& self.min_length.is_none()
220+
&& self.max_length.is_none()
221+
&& self.pattern.is_none()
222+
&& self.min_items.is_none()
223+
&& self.max_items.is_none()
224+
&& self.unique_items.is_none()
225+
}
226+
227+
/// Capture the constraint-related fields off a `SchemaDetails`.
228+
/// Exclusive bounds in OpenAPI 3.1 are numeric (`exclusiveMinimum:
229+
/// 5`); we map the OAS-3.0 boolean flag form by leaving the
230+
/// exclusive field unset and letting `minimum`/`maximum` carry it.
231+
pub fn from_schema_details(details: &crate::openapi::SchemaDetails) -> Self {
232+
use crate::openapi::ExclusiveBound;
233+
let exclusive_minimum = match &details.exclusive_minimum {
234+
Some(ExclusiveBound::Number(v)) => Some(*v),
235+
_ => None,
236+
};
237+
let exclusive_maximum = match &details.exclusive_maximum {
238+
Some(ExclusiveBound::Number(v)) => Some(*v),
239+
_ => None,
240+
};
241+
Self {
242+
minimum: details.minimum,
243+
maximum: details.maximum,
244+
exclusive_minimum,
245+
exclusive_maximum,
246+
multiple_of: details.multiple_of,
247+
min_length: details.min_length,
248+
max_length: details.max_length,
249+
pattern: details.pattern.clone(),
250+
min_items: details.min_items,
251+
max_items: details.max_items,
252+
unique_items: details.unique_items,
253+
}
254+
}
107255
}
108256

109257
#[derive(Debug, Clone)]
@@ -774,6 +922,7 @@ impl SchemaAnalyzer {
774922
},
775923
operations: BTreeMap::new(),
776924
used_type_features: crate::type_mapping::UsedFeatures::default(),
925+
enum_extensions: BTreeMap::new(),
777926
};
778927

779928
// First pass: detect patterns
@@ -861,6 +1010,23 @@ impl SchemaAnalyzer {
8611010
// (e.g. base64_serde for `format: byte`).
8621011
analysis.used_type_features = self.type_mapper.used_features();
8631012

1013+
// Q2.6: capture x-enum-varnames / x-enum-descriptions from
1014+
// each enum schema's original JSON. Side-channel keyed by
1015+
// analyzed-schema name so we don't have to extend every
1016+
// SchemaType::StringEnum constructor.
1017+
for (name, analyzed) in &analysis.schemas {
1018+
let enum_value_count = match &analyzed.schema_type {
1019+
SchemaType::StringEnum { values } => values.len(),
1020+
SchemaType::ExtensibleEnum { known_values } => known_values.len(),
1021+
_ => continue,
1022+
};
1023+
if let Some(ext) =
1024+
extract_enum_extensions(&analyzed.original, enum_value_count, name)
1025+
{
1026+
analysis.enum_extensions.insert(name.clone(), ext);
1027+
}
1028+
}
1029+
8641030
Ok(analysis)
8651031
}
8661032

@@ -1484,6 +1650,9 @@ impl SchemaAnalyzer {
14841650
description: prop_description,
14851651
default: prop_default,
14861652
serde_attrs: Vec::new(),
1653+
constraints: PropertyConstraints::from_schema_details(
1654+
prop_details,
1655+
),
14871656
},
14881657
);
14891658
continue;
@@ -1566,6 +1735,7 @@ impl SchemaAnalyzer {
15661735
description: prop_description,
15671736
default: prop_default,
15681737
serde_attrs: Vec::new(),
1738+
constraints: PropertyConstraints::from_schema_details(prop_details),
15691739
},
15701740
);
15711741
}
@@ -2231,6 +2401,7 @@ impl SchemaAnalyzer {
22312401
description: prop_details.description.clone(),
22322402
default: prop_details.default.clone(),
22332403
serde_attrs: Vec::new(),
2404+
constraints: PropertyConstraints::from_schema_details(prop_details),
22342405
},
22352406
);
22362407
}

0 commit comments

Comments
 (0)