Skip to content

Commit 2a373f9

Browse files
Merge pull request #47 from gpu-cli/fix/corpus-generation-fixes
fix expanded-corpus generation: media types, validation patterns, paths, allOf aliases
2 parents 63545df + 713569a commit 2a373f9

16 files changed

Lines changed: 1082 additions & 165 deletions

.beads/.auto-import-issues.jsonl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"size":188191,"mtime_ns":1785274297058637994}

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ toml_edit = "0.22"
5151
specta = { version = "2.0.0-rc", features = ["derive"], optional = true }
5252
heck = "0.5"
5353
jsonschema = { version = "0.49", default-features = false }
54+
regex = "1"
5455

5556
[dev-dependencies]
5657
serde_yaml = "0.9"

src/analysis.rs

Lines changed: 153 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -550,6 +550,15 @@ pub enum ArrayItemType {
550550
Scalar(String),
551551
/// The schema name of a referenced scalar alias or string enum.
552552
SchemaRef(String),
553+
/// The schema name of a referenced *flat* structure — every property is
554+
/// scalar. Serialized AWS query-protocol style as
555+
/// `param.N.Prop=value` per item (e.g. `Tags.1.Key=k&Tags.1.Value=v`).
556+
/// Carries the wire property names so client and server emit identical
557+
/// keys without re-resolving the schema.
558+
FlatStructRef {
559+
schema_name: String,
560+
property_names: Vec<String>,
561+
},
553562
}
554563

555564
impl Default for DependencyGraph {
@@ -705,6 +714,63 @@ pub fn merge_schema_extensions(
705714
Ok(result)
706715
}
707716

717+
/// AWS-style specs append query markers to their path templates
718+
/// (`/tags/{resourceArn}#tagKeys`, `/2015-02-01/resource-tags/{ResourceId}#tagKeys`).
719+
/// The fragment is not part of the route — those values are declared as
720+
/// ordinary query parameters on the operation — so strip it before the path
721+
/// reaches route generation. Axum (and every HTTP router) matches on the path
722+
/// component only.
723+
fn normalize_operation_path(path: &str) -> String {
724+
match path.split_once('#') {
725+
Some((route, _fragment)) if route.starts_with('/') => route.to_string(),
726+
_ => path.to_string(),
727+
}
728+
}
729+
730+
/// See through an `allOf: [$ref, {annotation}]` wrapper around a schema, the
731+
/// same shape `analyze_all_of` treats as a type alias. Returns the sole
732+
/// reference target's schema when every other member is annotation-only;
733+
/// otherwise the schema itself.
734+
fn unwrap_annotation_allof(schema: &crate::openapi::Schema) -> &crate::openapi::Schema {
735+
let crate::openapi::Schema::AllOf { all_of, .. } = schema else {
736+
return schema;
737+
};
738+
let mut references = all_of.iter().filter(|s| s.reference().is_some());
739+
let (Some(first), None) = (references.next(), references.next()) else {
740+
return schema;
741+
};
742+
let others_annotation_only = all_of.iter().all(|member| {
743+
if member.reference().is_some() {
744+
return true;
745+
}
746+
serde_json::to_value(member)
747+
.ok()
748+
.and_then(|value| value.as_object().cloned())
749+
.is_some_and(|object| {
750+
object.keys().all(|key| {
751+
matches!(
752+
key.as_str(),
753+
"title"
754+
| "description"
755+
| "deprecated"
756+
| "readOnly"
757+
| "writeOnly"
758+
| "examples"
759+
| "example"
760+
| "externalDocs"
761+
| "xml"
762+
| "$comment"
763+
) || key.starts_with("x-")
764+
})
765+
})
766+
});
767+
if others_annotation_only {
768+
first
769+
} else {
770+
schema
771+
}
772+
}
773+
708774
/// Load an extension file and parse it into the JSON representation used by
709775
/// the analyzer. YAML extensions follow the same conversion policy as YAML
710776
/// OpenAPI documents; every other extension is parsed as JSON.
@@ -2395,17 +2461,46 @@ impl SchemaAnalyzer {
23952461
all_of_schemas: &[Schema],
23962462
dependencies: &mut HashSet<String>,
23972463
) -> Result<SchemaType> {
2398-
// Special case: if allOf contains only a single reference, treat it as a direct type alias
2399-
// This handles patterns like: "allOf": [{"$ref": "#/components/schemas/Usage"}]
2400-
if all_of_schemas.len() == 1 {
2401-
if let Schema::Reference { reference, .. } = &all_of_schemas[0] {
2402-
if let Some(target) = self.extract_schema_name(reference) {
2403-
dependencies.insert(target.to_string());
2404-
return Ok(SchemaType::Reference {
2405-
target: target.to_string(),
2406-
});
2407-
}
2464+
// A reference plus annotation-only siblings is still a direct type
2465+
// alias. AWS-style specs frequently encode property descriptions as
2466+
// `allOf: [$ref, { description: ... }]`; recursively expanding a
2467+
// self-reference in that shape can otherwise recurse forever.
2468+
let referenced_targets = all_of_schemas
2469+
.iter()
2470+
.filter_map(|schema| schema.reference())
2471+
.filter_map(|reference| self.extract_schema_name(reference))
2472+
.collect::<Vec<_>>();
2473+
let only_reference_and_annotations = all_of_schemas.iter().all(|schema| {
2474+
if schema.reference().is_some() {
2475+
return true;
24082476
}
2477+
serde_json::to_value(schema)
2478+
.ok()
2479+
.and_then(|value| value.as_object().cloned())
2480+
.is_some_and(|object| {
2481+
object.keys().all(|key| {
2482+
matches!(
2483+
key.as_str(),
2484+
"title"
2485+
| "description"
2486+
| "deprecated"
2487+
| "readOnly"
2488+
| "writeOnly"
2489+
| "examples"
2490+
| "example"
2491+
| "externalDocs"
2492+
| "xml"
2493+
| "$comment"
2494+
) || key.starts_with("x-")
2495+
})
2496+
})
2497+
});
2498+
if referenced_targets.len() == 1 && only_reference_and_annotations {
2499+
let target = referenced_targets[0];
2500+
dependencies.insert(target.to_string());
2501+
return Ok(SchemaType::Reference {
2502+
target: target.to_string(),
2503+
});
24092504
}
24102505

24112506
// AllOf represents schema composition - merge all schemas into one
@@ -4335,7 +4430,7 @@ impl SchemaAnalyzer {
43354430
// dispatcher.
43364431
if let Some(webhooks) = &spec.webhooks {
43374432
for (name, path_item) in webhooks {
4338-
let synthetic_path = format!("__webhook__/{name}");
4433+
let synthetic_path = format!("/__webhook__/{name}");
43394434
self.ingest_path_item_operations(
43404435
&synthetic_path,
43414436
path_item,
@@ -4514,7 +4609,7 @@ impl SchemaAnalyzer {
45144609
let mut op_info = OperationInfo {
45154610
operation_id: operation_id.to_string(),
45164611
method: method.to_uppercase(),
4517-
path: path.to_string(),
4612+
path: normalize_operation_path(path),
45184613
summary: operation.summary.clone(),
45194614
description: operation.description.clone(),
45204615
request_body: None,
@@ -4596,7 +4691,11 @@ impl SchemaAnalyzer {
45964691
media_type: content_type.to_string(),
45974692
})
45984693
}
4599-
} else if media_type_essence(content_type).eq_ignore_ascii_case("text/plain") {
4694+
} else if crate::openapi::is_text_media_type(content_type) {
4695+
// Any character-data media type (text/plain, text/xml,
4696+
// application/xml, +xml suffixed) is buffered and handed
4697+
// to the handler as a lossless UTF-8 String; the server
4698+
// never parses the payload.
46004699
Some(RequestBodyContent::TextPlain {
46014700
media_type: content_type.to_string(),
46024701
})
@@ -5384,12 +5483,18 @@ impl SchemaAnalyzer {
53845483
/// is wired for scalar params only.
53855484
fn array_param_item_type(&self, schema: &crate::openapi::Schema) -> Option<ArrayItemType> {
53865485
let items = schema.details().items.as_deref()?;
5387-
if let Some(ref_str) = items.reference() {
5486+
// AWS query-protocol specs wrap item refs in an annotation-only allOf
5487+
// (`items: {allOf: [$ref, {xml: ...}]}`). See through the wrapper when
5488+
// every sibling is annotation-only, mirroring the type-alias rule.
5489+
let unwrapped = unwrap_annotation_allof(items);
5490+
if let Some(ref_str) = unwrapped.reference() {
53885491
let name = self.extract_schema_name(ref_str)?;
5389-
return self.referenced_array_scalar_item_type(name);
5492+
return self
5493+
.referenced_array_scalar_item_type(name)
5494+
.or_else(|| self.referenced_array_flat_struct_item_type(name));
53905495
}
5391-
let format = items.details().format.clone();
5392-
let scalar = match items.schema_type()? {
5496+
let format = unwrapped.details().format.clone();
5497+
let scalar = match unwrapped.schema_type()? {
53935498
crate::openapi::SchemaType::String => "String".to_string(),
53945499
crate::openapi::SchemaType::Integer => {
53955500
self.type_mapper.integer_format(format.as_deref()).rust_type
@@ -5418,11 +5523,41 @@ impl SchemaAnalyzer {
54185523
SchemaType::Primitive { rust_type, .. } => {
54195524
Some(ArrayItemType::Scalar(rust_type.clone()))
54205525
}
5421-
SchemaType::Reference { target } => self.referenced_array_scalar_item_type(target),
5526+
SchemaType::Reference { target } => self
5527+
.referenced_array_scalar_item_type(target)
5528+
.or_else(|| self.referenced_array_flat_struct_item_type(target)),
54225529
_ => None,
54235530
}
54245531
}
54255532

5533+
/// Accept a referenced structure as a form-style array item when every
5534+
/// property is scalar (AWS query-protocol flat structures such as
5535+
/// `Tag { Key, Value }`). Nested objects, arrays, and maps are rejected
5536+
/// because the wire shape below one level is service-specific.
5537+
fn referenced_array_flat_struct_item_type(&self, name: &str) -> Option<ArrayItemType> {
5538+
let resolved = self.resolve_cached_schema(name)?;
5539+
let SchemaType::Object { properties, .. } = &resolved.schema_type else {
5540+
return None;
5541+
};
5542+
if properties.is_empty() {
5543+
return None;
5544+
}
5545+
let all_scalar = properties
5546+
.values()
5547+
.all(|property| match &property.schema_type {
5548+
SchemaType::Primitive { .. } => true,
5549+
SchemaType::StringEnum { .. } | SchemaType::ExtensibleEnum { .. } => true,
5550+
SchemaType::Reference { target } => {
5551+
self.referenced_array_scalar_item_type(target).is_some()
5552+
}
5553+
_ => false,
5554+
});
5555+
all_scalar.then(|| ArrayItemType::FlatStructRef {
5556+
schema_name: name.to_string(),
5557+
property_names: properties.keys().cloned().collect(),
5558+
})
5559+
}
5560+
54265561
/// Resolve a referenced array item through any alias chain while
54275562
/// preserving the outer schema name used by the public `Vec<T>` type.
54285563
///

src/client_generator.rs

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1703,19 +1703,56 @@ impl CodeGenerator {
17031703
}
17041704
continue;
17051705
}
1706-
Some(QuerySerialization::FormExplodedArray { .. }) => {
1707-
// `?tags=a&tags=b` — one pair per element.
1706+
Some(QuerySerialization::FormExplodedArray { item_type }) => {
1707+
// `?tags=a&tags=b` — one pair per element; flat structures
1708+
// expand AWS query-protocol style as `?tags.1.Key=k&tags.1.Value=v`.
1709+
let flat_struct = match item_type {
1710+
crate::analysis::ArrayItemType::FlatStructRef {
1711+
property_names, ..
1712+
} => Some(property_names.clone()),
1713+
_ => None,
1714+
};
1715+
let emit_items = if let Some(property_names) = flat_struct {
1716+
let pushes = property_names
1717+
.iter()
1718+
.map(|wire_name| {
1719+
// Wire names such as `Type` land on struct
1720+
// fields via the same keyword-escaping the
1721+
// model generator uses (`r#type`).
1722+
let field_ident = CodeGenerator::to_field_ident(
1723+
&self.to_rust_field_name(wire_name),
1724+
);
1725+
quote! {
1726+
query_params.push((
1727+
format!("{}.{}.{}", #param_key, index, #wire_name),
1728+
item.#field_ident.to_string(),
1729+
));
1730+
}
1731+
})
1732+
.collect::<Vec<_>>();
1733+
quote! {
1734+
for (index, item) in v.iter().enumerate() {
1735+
let index = index + 1;
1736+
#(#pushes)*
1737+
}
1738+
}
1739+
} else {
1740+
quote! {
1741+
for item in v {
1742+
query_params.push((#param_key.to_string(), item.to_string()));
1743+
}
1744+
}
1745+
};
17081746
if param.required {
17091747
param_building.push(quote! {
1710-
if #param_name.is_empty() {
1748+
let v = #param_name;
1749+
if v.is_empty() {
17111750
query_params.push((
17121751
format!("{}[]", #param_key),
17131752
String::new(),
17141753
));
17151754
} else {
1716-
for item in #param_name {
1717-
query_params.push((#param_key.to_string(), item.to_string()));
1718-
}
1755+
#emit_items
17191756
}
17201757
});
17211758
} else {
@@ -1727,9 +1764,7 @@ impl CodeGenerator {
17271764
String::new(),
17281765
));
17291766
} else {
1730-
for item in v {
1731-
query_params.push((#param_key.to_string(), item.to_string()));
1732-
}
1767+
#emit_items
17331768
}
17341769
}
17351770
});
@@ -2093,6 +2128,11 @@ impl CodeGenerator {
20932128
syn::parse_str(&rust_name)
20942129
.unwrap_or_else(|_| panic!("invalid schema item type `{rust_name}`"))
20952130
}
2131+
ArrayItemType::FlatStructRef { schema_name, .. } => {
2132+
let rust_name = self.to_rust_type_name(schema_name);
2133+
syn::parse_str(&rust_name)
2134+
.unwrap_or_else(|_| panic!("invalid struct item type `{rust_name}`"))
2135+
}
20962136
};
20972137
return quote! { Vec<#item_ty> };
20982138
}

0 commit comments

Comments
 (0)