Skip to content

Commit f494ab5

Browse files
fix expanded-corpus generation: media types, validation patterns, paths, allOf aliases
Probe-driven fixes from running 241 previously-failing APIs.guru specs (AWS, Adyen, apisetu.gov.in, api.video, ...) through client and server generation. 210/240 now generate cleanly (was ~2%): - allOf [$ref, {description}] self-references are type aliases again; recursive expansion overflowed the stack on AWS-style specs. - application/pdf responses classify as Binary; application/xml and +xml suffixed types classify as Text on both response and request sides. - best_content() falls back to character-data media (text/xml etc.) instead of leaving XML-only request bodies unsupported. - Validation pattern normalization: Java POSIX classes (\p{Print}, \p{Alpha}, ...) translate to ASCII ranges, ECMA \uXXXX escapes to Rust \u{XXXX}, legacy octal escapes to \xNN. Patterns that still cannot compile offline (look-around, backreferences) degrade to "no pattern check" instead of failing generation. - AWS x-pattern extension promotes to the standard pattern keyword. - Validation bundle component keys are prefixed so a schema named like a JSON Schema keyword (`id`) survives meta-schema validation. - AWS query markers (/tags/{arn}#tagKeys) strip the #fragment before route generation; webhook synthetic paths gain their leading `/`. - Server code qualifies non-canonical schema names (not-found) with their canonical Rust identifiers via rust_type_name(). Remaining known gaps (fail loudly, not silently wrong): form-style object-array query items (AWS query protocol wire shape is undefined by OpenAPI), multipart server extraction, YAML tab-indentation specs, and one PathItem-as-string spec.
1 parent 4ec3798 commit f494ab5

10 files changed

Lines changed: 665 additions & 115 deletions

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: 59 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -705,6 +705,19 @@ pub fn merge_schema_extensions(
705705
Ok(result)
706706
}
707707

708+
/// AWS-style specs append query markers to their path templates
709+
/// (`/tags/{resourceArn}#tagKeys`, `/2015-02-01/resource-tags/{ResourceId}#tagKeys`).
710+
/// The fragment is not part of the route — those values are declared as
711+
/// ordinary query parameters on the operation — so strip it before the path
712+
/// reaches route generation. Axum (and every HTTP router) matches on the path
713+
/// component only.
714+
fn normalize_operation_path(path: &str) -> String {
715+
match path.split_once('#') {
716+
Some((route, _fragment)) if route.starts_with('/') => route.to_string(),
717+
_ => path.to_string(),
718+
}
719+
}
720+
708721
/// Load an extension file and parse it into the JSON representation used by
709722
/// the analyzer. YAML extensions follow the same conversion policy as YAML
710723
/// OpenAPI documents; every other extension is parsed as JSON.
@@ -2395,17 +2408,46 @@ impl SchemaAnalyzer {
23952408
all_of_schemas: &[Schema],
23962409
dependencies: &mut HashSet<String>,
23972410
) -> 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-
}
2411+
// A reference plus annotation-only siblings is still a direct type
2412+
// alias. AWS-style specs frequently encode property descriptions as
2413+
// `allOf: [$ref, { description: ... }]`; recursively expanding a
2414+
// self-reference in that shape can otherwise recurse forever.
2415+
let referenced_targets = all_of_schemas
2416+
.iter()
2417+
.filter_map(|schema| schema.reference())
2418+
.filter_map(|reference| self.extract_schema_name(reference))
2419+
.collect::<Vec<_>>();
2420+
let only_reference_and_annotations = all_of_schemas.iter().all(|schema| {
2421+
if schema.reference().is_some() {
2422+
return true;
24082423
}
2424+
serde_json::to_value(schema)
2425+
.ok()
2426+
.and_then(|value| value.as_object().cloned())
2427+
.is_some_and(|object| {
2428+
object.keys().all(|key| {
2429+
matches!(
2430+
key.as_str(),
2431+
"title"
2432+
| "description"
2433+
| "deprecated"
2434+
| "readOnly"
2435+
| "writeOnly"
2436+
| "examples"
2437+
| "example"
2438+
| "externalDocs"
2439+
| "xml"
2440+
| "$comment"
2441+
) || key.starts_with("x-")
2442+
})
2443+
})
2444+
});
2445+
if referenced_targets.len() == 1 && only_reference_and_annotations {
2446+
let target = referenced_targets[0];
2447+
dependencies.insert(target.to_string());
2448+
return Ok(SchemaType::Reference {
2449+
target: target.to_string(),
2450+
});
24092451
}
24102452

24112453
// AllOf represents schema composition - merge all schemas into one
@@ -4335,7 +4377,7 @@ impl SchemaAnalyzer {
43354377
// dispatcher.
43364378
if let Some(webhooks) = &spec.webhooks {
43374379
for (name, path_item) in webhooks {
4338-
let synthetic_path = format!("__webhook__/{name}");
4380+
let synthetic_path = format!("/__webhook__/{name}");
43394381
self.ingest_path_item_operations(
43404382
&synthetic_path,
43414383
path_item,
@@ -4514,7 +4556,7 @@ impl SchemaAnalyzer {
45144556
let mut op_info = OperationInfo {
45154557
operation_id: operation_id.to_string(),
45164558
method: method.to_uppercase(),
4517-
path: path.to_string(),
4559+
path: normalize_operation_path(path),
45184560
summary: operation.summary.clone(),
45194561
description: operation.description.clone(),
45204562
request_body: None,
@@ -4596,7 +4638,11 @@ impl SchemaAnalyzer {
45964638
media_type: content_type.to_string(),
45974639
})
45984640
}
4599-
} else if media_type_essence(content_type).eq_ignore_ascii_case("text/plain") {
4641+
} else if crate::openapi::is_text_media_type(content_type) {
4642+
// Any character-data media type (text/plain, text/xml,
4643+
// application/xml, +xml suffixed) is buffered and handed
4644+
// to the handler as a lossless UTF-8 String; the server
4645+
// never parses the payload.
46004646
Some(RequestBodyContent::TextPlain {
46014647
media_type: content_type.to_string(),
46024648
})

src/generator.rs

Lines changed: 64 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,69 @@ pub fn default_type_mappings() -> BTreeMap<String, String> {
264264
mappings
265265
}
266266

267+
/// Convert an OpenAPI schema name to the canonical Rust model identifier.
268+
///
269+
/// Every code-generation surface must use this helper rather than parsing raw
270+
/// component keys as Rust types; otherwise names such as `not-found` panic and
271+
/// names such as `inline_response_200` refer to models that were never emitted.
272+
pub(crate) fn rust_type_name(s: &str) -> String {
273+
let mut result = String::new();
274+
let mut next_upper = true;
275+
276+
for c in s.chars() {
277+
match c {
278+
'a'..='z' => {
279+
result.push(if next_upper {
280+
c.to_ascii_uppercase()
281+
} else {
282+
c
283+
});
284+
next_upper = false;
285+
}
286+
'A'..='Z' | '0'..='9' => {
287+
result.push(c);
288+
next_upper = false;
289+
}
290+
_ => next_upper = true,
291+
}
292+
}
293+
294+
if result.is_empty() {
295+
result = "Type".to_string();
296+
}
297+
if result.chars().next().is_some_and(|c| c.is_ascii_digit()) {
298+
result = format!("Type{result}");
299+
}
300+
if matches!(
301+
result.as_str(),
302+
"Result"
303+
| "Option"
304+
| "Box"
305+
| "Vec"
306+
| "String"
307+
| "Some"
308+
| "None"
309+
| "Ok"
310+
| "Err"
311+
| "Default"
312+
| "Clone"
313+
| "Debug"
314+
| "Send"
315+
| "Sync"
316+
| "Sized"
317+
| "Iterator"
318+
| "From"
319+
| "Into"
320+
| "TryFrom"
321+
| "TryInto"
322+
| "AsRef"
323+
| "AsMut"
324+
) {
325+
result.push_str("Type");
326+
}
327+
result
328+
}
329+
267330
/// Represents a generated file
268331
#[derive(Debug, Clone)]
269332
pub struct GeneratedFile {
@@ -3024,95 +3087,7 @@ impl CodeGenerator {
30243087
}
30253088

30263089
pub(crate) fn to_rust_type_name(&self, s: &str) -> String {
3027-
// Convert string to valid Rust type name (PascalCase)
3028-
let mut result = String::new();
3029-
let mut next_upper = true;
3030-
let mut prev_was_lower = false;
3031-
3032-
for c in s.chars() {
3033-
match c {
3034-
'a'..='z' => {
3035-
if next_upper {
3036-
result.push(c.to_ascii_uppercase());
3037-
next_upper = false;
3038-
} else {
3039-
result.push(c);
3040-
}
3041-
prev_was_lower = true;
3042-
}
3043-
'A'..='Z' => {
3044-
result.push(c);
3045-
next_upper = false;
3046-
prev_was_lower = false;
3047-
}
3048-
'0'..='9' => {
3049-
// If previous was lowercase letter and this is start of a number sequence,
3050-
// make it uppercase to improve readability (e.g., Tool20241022 instead of Tool20241022)
3051-
if prev_was_lower && !result.chars().last().unwrap_or(' ').is_ascii_digit() {
3052-
// This is fine as-is, the number follows naturally
3053-
}
3054-
result.push(c);
3055-
next_upper = false;
3056-
prev_was_lower = false;
3057-
}
3058-
'_' | '-' | '.' | ' ' => {
3059-
// Skip underscore/separator and make next char uppercase
3060-
next_upper = true;
3061-
prev_was_lower = false;
3062-
}
3063-
_ => {
3064-
// Other special characters - treat as word boundary
3065-
next_upper = true;
3066-
prev_was_lower = false;
3067-
}
3068-
}
3069-
}
3070-
3071-
// Handle empty result
3072-
if result.is_empty() {
3073-
result = "Type".to_string();
3074-
}
3075-
3076-
// Ensure type name starts with a letter (not a number)
3077-
if result.chars().next().is_some_and(|c| c.is_ascii_digit()) {
3078-
result = format!("Type{result}");
3079-
}
3080-
3081-
// Avoid masking ubiquitous std types and traits. cloudflare has a
3082-
// schema literally named `Result`, gcore has `Default`; emitting
3083-
// `pub enum Result { ... }` shadows std::result::Result and breaks
3084-
// every method's `-> Result<T, ApiOpError<...>>`. Same for impls
3085-
// like `impl Default for HttpClient { ... }` when `Default` resolves
3086-
// to the local type alias.
3087-
if matches!(
3088-
result.as_str(),
3089-
"Result"
3090-
| "Option"
3091-
| "Box"
3092-
| "Vec"
3093-
| "String"
3094-
| "Some"
3095-
| "None"
3096-
| "Ok"
3097-
| "Err"
3098-
| "Default"
3099-
| "Clone"
3100-
| "Debug"
3101-
| "Send"
3102-
| "Sync"
3103-
| "Sized"
3104-
| "Iterator"
3105-
| "From"
3106-
| "Into"
3107-
| "TryFrom"
3108-
| "TryInto"
3109-
| "AsRef"
3110-
| "AsMut"
3111-
) {
3112-
result.push_str("Type");
3113-
}
3114-
3115-
result
3090+
rust_type_name(s)
31163091
}
31173092

31183093
fn to_rust_field_name(&self, s: &str) -> String {

src/openapi.rs

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1079,11 +1079,24 @@ pub fn is_event_stream_media_type(ct: &str) -> bool {
10791079
}
10801080

10811081
/// Returns true for non-SSE media types in the `text` top-level family.
1082+
///
1083+
/// Structured text formats in the `application` family whose instances are
1084+
/// UTF-8/UTF-16 character data — XML and its `+xml` suffix variants (RFC 7303,
1085+
/// RFC 6839) — are buffered and emitted as text as well; bytes are never
1086+
/// XML-parsed by the generated server, so a plain `String` body preserves
1087+
/// the payload losslessly.
10821088
pub fn is_text_media_type(ct: &str) -> bool {
10831089
let Some((top_level, subtype)) = media_type_essence(ct).split_once('/') else {
10841090
return false;
10851091
};
1086-
top_level.eq_ignore_ascii_case("text") && !subtype.is_empty() && !is_event_stream_media_type(ct)
1092+
if top_level.eq_ignore_ascii_case("text")
1093+
&& !subtype.is_empty()
1094+
&& !is_event_stream_media_type(ct)
1095+
{
1096+
return true;
1097+
}
1098+
top_level.eq_ignore_ascii_case("application")
1099+
&& (subtype.eq_ignore_ascii_case("xml") || subtype.to_ascii_lowercase().ends_with("+xml"))
10871100
}
10881101

10891102
/// Returns true for OpenAPI media ranges with a wildcard subtype.
@@ -1132,6 +1145,7 @@ pub fn is_binary_media_type(ct: &str, schema: Option<&Schema>) -> bool {
11321145
}
11331146
if essence.eq_ignore_ascii_case("application/octet-stream")
11341147
|| essence.eq_ignore_ascii_case("application/zip")
1148+
|| essence.eq_ignore_ascii_case("application/pdf")
11351149
{
11361150
return true;
11371151
}
@@ -1222,6 +1236,11 @@ impl RequestBody {
12221236
return Some((ct.as_str(), media_type.schema.as_ref()));
12231237
}
12241238
}
1239+
// Character-data fallbacks (text/xml, application/xml, +xml suffixed)
1240+
// are buffered as UTF-8 text like text/plain.
1241+
if let Some((ct, media_type)) = content.iter().find(|(ct, _)| is_text_media_type(ct)) {
1242+
return Some((ct.as_str(), media_type.schema.as_ref()));
1243+
}
12251244
content
12261245
.iter()
12271246
// A request media range is not a concrete Content-Type value. The
@@ -1552,13 +1571,28 @@ mod tests {
15521571
#[test]
15531572
fn response_media_classifier_leaves_ambiguous_formats_unsupported() {
15541573
let string_schema: Schema = serde_json::from_value(json!({ "type": "string" })).unwrap();
1555-
for media_type in ["application/xml", "application/pdf", "not-a-media-type"] {
1574+
for media_type in ["application/x-unknown", "not-a-media-type"] {
15561575
assert_eq!(
15571576
classify_response_media_type(media_type, Some(&string_schema)),
15581577
ResponseMediaKind::Unsupported,
15591578
"{media_type}"
15601579
);
15611580
}
1581+
// PDF bodies are raw bytes; XML bodies are character data. Both are
1582+
// pass-through lossless for a server that never parses the payload,
1583+
// so they classify instead of failing generation.
1584+
assert_eq!(
1585+
classify_response_media_type("application/pdf", Some(&string_schema)),
1586+
ResponseMediaKind::Binary
1587+
);
1588+
assert_eq!(
1589+
classify_response_media_type("application/xml", Some(&string_schema)),
1590+
ResponseMediaKind::Text
1591+
);
1592+
assert_eq!(
1593+
classify_response_media_type("application/atom+xml", Some(&string_schema)),
1594+
ResponseMediaKind::Text
1595+
);
15621596
assert!(!is_binary_media_type("text/plain", None));
15631597
}
15641598

0 commit comments

Comments
 (0)