diff --git a/.beads/.auto-import-issues.jsonl b/.beads/.auto-import-issues.jsonl new file mode 100644 index 0000000..beec93e --- /dev/null +++ b/.beads/.auto-import-issues.jsonl @@ -0,0 +1 @@ +{"size":188191,"mtime_ns":1785274297058637994} \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index bd468b7..3b5343f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1002,6 +1002,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", + "regex", "reqwest", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 92495d9..c6ef985 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,6 +51,7 @@ toml_edit = "0.22" specta = { version = "2.0.0-rc", features = ["derive"], optional = true } heck = "0.5" jsonschema = { version = "0.49", default-features = false } +regex = "1" [dev-dependencies] serde_yaml = "0.9" diff --git a/src/analysis.rs b/src/analysis.rs index 8396c0c..ccdc746 100644 --- a/src/analysis.rs +++ b/src/analysis.rs @@ -550,6 +550,15 @@ pub enum ArrayItemType { Scalar(String), /// The schema name of a referenced scalar alias or string enum. SchemaRef(String), + /// The schema name of a referenced *flat* structure — every property is + /// scalar. Serialized AWS query-protocol style as + /// `param.N.Prop=value` per item (e.g. `Tags.1.Key=k&Tags.1.Value=v`). + /// Carries the wire property names so client and server emit identical + /// keys without re-resolving the schema. + FlatStructRef { + schema_name: String, + property_names: Vec, + }, } impl Default for DependencyGraph { @@ -705,6 +714,63 @@ pub fn merge_schema_extensions( Ok(result) } +/// AWS-style specs append query markers to their path templates +/// (`/tags/{resourceArn}#tagKeys`, `/2015-02-01/resource-tags/{ResourceId}#tagKeys`). +/// The fragment is not part of the route — those values are declared as +/// ordinary query parameters on the operation — so strip it before the path +/// reaches route generation. Axum (and every HTTP router) matches on the path +/// component only. +fn normalize_operation_path(path: &str) -> String { + match path.split_once('#') { + Some((route, _fragment)) if route.starts_with('/') => route.to_string(), + _ => path.to_string(), + } +} + +/// See through an `allOf: [$ref, {annotation}]` wrapper around a schema, the +/// same shape `analyze_all_of` treats as a type alias. Returns the sole +/// reference target's schema when every other member is annotation-only; +/// otherwise the schema itself. +fn unwrap_annotation_allof(schema: &crate::openapi::Schema) -> &crate::openapi::Schema { + let crate::openapi::Schema::AllOf { all_of, .. } = schema else { + return schema; + }; + let mut references = all_of.iter().filter(|s| s.reference().is_some()); + let (Some(first), None) = (references.next(), references.next()) else { + return schema; + }; + let others_annotation_only = all_of.iter().all(|member| { + if member.reference().is_some() { + return true; + } + serde_json::to_value(member) + .ok() + .and_then(|value| value.as_object().cloned()) + .is_some_and(|object| { + object.keys().all(|key| { + matches!( + key.as_str(), + "title" + | "description" + | "deprecated" + | "readOnly" + | "writeOnly" + | "examples" + | "example" + | "externalDocs" + | "xml" + | "$comment" + ) || key.starts_with("x-") + }) + }) + }); + if others_annotation_only { + first + } else { + schema + } +} + /// Load an extension file and parse it into the JSON representation used by /// the analyzer. YAML extensions follow the same conversion policy as YAML /// OpenAPI documents; every other extension is parsed as JSON. @@ -2395,17 +2461,46 @@ impl SchemaAnalyzer { all_of_schemas: &[Schema], dependencies: &mut HashSet, ) -> Result { - // Special case: if allOf contains only a single reference, treat it as a direct type alias - // This handles patterns like: "allOf": [{"$ref": "#/components/schemas/Usage"}] - if all_of_schemas.len() == 1 { - if let Schema::Reference { reference, .. } = &all_of_schemas[0] { - if let Some(target) = self.extract_schema_name(reference) { - dependencies.insert(target.to_string()); - return Ok(SchemaType::Reference { - target: target.to_string(), - }); - } + // A reference plus annotation-only siblings is still a direct type + // alias. AWS-style specs frequently encode property descriptions as + // `allOf: [$ref, { description: ... }]`; recursively expanding a + // self-reference in that shape can otherwise recurse forever. + let referenced_targets = all_of_schemas + .iter() + .filter_map(|schema| schema.reference()) + .filter_map(|reference| self.extract_schema_name(reference)) + .collect::>(); + let only_reference_and_annotations = all_of_schemas.iter().all(|schema| { + if schema.reference().is_some() { + return true; } + serde_json::to_value(schema) + .ok() + .and_then(|value| value.as_object().cloned()) + .is_some_and(|object| { + object.keys().all(|key| { + matches!( + key.as_str(), + "title" + | "description" + | "deprecated" + | "readOnly" + | "writeOnly" + | "examples" + | "example" + | "externalDocs" + | "xml" + | "$comment" + ) || key.starts_with("x-") + }) + }) + }); + if referenced_targets.len() == 1 && only_reference_and_annotations { + let target = referenced_targets[0]; + dependencies.insert(target.to_string()); + return Ok(SchemaType::Reference { + target: target.to_string(), + }); } // AllOf represents schema composition - merge all schemas into one @@ -4335,7 +4430,7 @@ impl SchemaAnalyzer { // dispatcher. if let Some(webhooks) = &spec.webhooks { for (name, path_item) in webhooks { - let synthetic_path = format!("__webhook__/{name}"); + let synthetic_path = format!("/__webhook__/{name}"); self.ingest_path_item_operations( &synthetic_path, path_item, @@ -4514,7 +4609,7 @@ impl SchemaAnalyzer { let mut op_info = OperationInfo { operation_id: operation_id.to_string(), method: method.to_uppercase(), - path: path.to_string(), + path: normalize_operation_path(path), summary: operation.summary.clone(), description: operation.description.clone(), request_body: None, @@ -4596,7 +4691,11 @@ impl SchemaAnalyzer { media_type: content_type.to_string(), }) } - } else if media_type_essence(content_type).eq_ignore_ascii_case("text/plain") { + } else if crate::openapi::is_text_media_type(content_type) { + // Any character-data media type (text/plain, text/xml, + // application/xml, +xml suffixed) is buffered and handed + // to the handler as a lossless UTF-8 String; the server + // never parses the payload. Some(RequestBodyContent::TextPlain { media_type: content_type.to_string(), }) @@ -5384,12 +5483,18 @@ impl SchemaAnalyzer { /// is wired for scalar params only. fn array_param_item_type(&self, schema: &crate::openapi::Schema) -> Option { let items = schema.details().items.as_deref()?; - if let Some(ref_str) = items.reference() { + // AWS query-protocol specs wrap item refs in an annotation-only allOf + // (`items: {allOf: [$ref, {xml: ...}]}`). See through the wrapper when + // every sibling is annotation-only, mirroring the type-alias rule. + let unwrapped = unwrap_annotation_allof(items); + if let Some(ref_str) = unwrapped.reference() { let name = self.extract_schema_name(ref_str)?; - return self.referenced_array_scalar_item_type(name); + return self + .referenced_array_scalar_item_type(name) + .or_else(|| self.referenced_array_flat_struct_item_type(name)); } - let format = items.details().format.clone(); - let scalar = match items.schema_type()? { + let format = unwrapped.details().format.clone(); + let scalar = match unwrapped.schema_type()? { crate::openapi::SchemaType::String => "String".to_string(), crate::openapi::SchemaType::Integer => { self.type_mapper.integer_format(format.as_deref()).rust_type @@ -5418,11 +5523,41 @@ impl SchemaAnalyzer { SchemaType::Primitive { rust_type, .. } => { Some(ArrayItemType::Scalar(rust_type.clone())) } - SchemaType::Reference { target } => self.referenced_array_scalar_item_type(target), + SchemaType::Reference { target } => self + .referenced_array_scalar_item_type(target) + .or_else(|| self.referenced_array_flat_struct_item_type(target)), _ => None, } } + /// Accept a referenced structure as a form-style array item when every + /// property is scalar (AWS query-protocol flat structures such as + /// `Tag { Key, Value }`). Nested objects, arrays, and maps are rejected + /// because the wire shape below one level is service-specific. + fn referenced_array_flat_struct_item_type(&self, name: &str) -> Option { + let resolved = self.resolve_cached_schema(name)?; + let SchemaType::Object { properties, .. } = &resolved.schema_type else { + return None; + }; + if properties.is_empty() { + return None; + } + let all_scalar = properties + .values() + .all(|property| match &property.schema_type { + SchemaType::Primitive { .. } => true, + SchemaType::StringEnum { .. } | SchemaType::ExtensibleEnum { .. } => true, + SchemaType::Reference { target } => { + self.referenced_array_scalar_item_type(target).is_some() + } + _ => false, + }); + all_scalar.then(|| ArrayItemType::FlatStructRef { + schema_name: name.to_string(), + property_names: properties.keys().cloned().collect(), + }) + } + /// Resolve a referenced array item through any alias chain while /// preserving the outer schema name used by the public `Vec` type. /// diff --git a/src/client_generator.rs b/src/client_generator.rs index abda011..441ad75 100644 --- a/src/client_generator.rs +++ b/src/client_generator.rs @@ -1703,19 +1703,56 @@ impl CodeGenerator { } continue; } - Some(QuerySerialization::FormExplodedArray { .. }) => { - // `?tags=a&tags=b` — one pair per element. + Some(QuerySerialization::FormExplodedArray { item_type }) => { + // `?tags=a&tags=b` — one pair per element; flat structures + // expand AWS query-protocol style as `?tags.1.Key=k&tags.1.Value=v`. + let flat_struct = match item_type { + crate::analysis::ArrayItemType::FlatStructRef { + property_names, .. + } => Some(property_names.clone()), + _ => None, + }; + let emit_items = if let Some(property_names) = flat_struct { + let pushes = property_names + .iter() + .map(|wire_name| { + // Wire names such as `Type` land on struct + // fields via the same keyword-escaping the + // model generator uses (`r#type`). + let field_ident = CodeGenerator::to_field_ident( + &self.to_rust_field_name(wire_name), + ); + quote! { + query_params.push(( + format!("{}.{}.{}", #param_key, index, #wire_name), + item.#field_ident.to_string(), + )); + } + }) + .collect::>(); + quote! { + for (index, item) in v.iter().enumerate() { + let index = index + 1; + #(#pushes)* + } + } + } else { + quote! { + for item in v { + query_params.push((#param_key.to_string(), item.to_string())); + } + } + }; if param.required { param_building.push(quote! { - if #param_name.is_empty() { + let v = #param_name; + if v.is_empty() { query_params.push(( format!("{}[]", #param_key), String::new(), )); } else { - for item in #param_name { - query_params.push((#param_key.to_string(), item.to_string())); - } + #emit_items } }); } else { @@ -1727,9 +1764,7 @@ impl CodeGenerator { String::new(), )); } else { - for item in v { - query_params.push((#param_key.to_string(), item.to_string())); - } + #emit_items } } }); @@ -2093,6 +2128,11 @@ impl CodeGenerator { syn::parse_str(&rust_name) .unwrap_or_else(|_| panic!("invalid schema item type `{rust_name}`")) } + ArrayItemType::FlatStructRef { schema_name, .. } => { + let rust_name = self.to_rust_type_name(schema_name); + syn::parse_str(&rust_name) + .unwrap_or_else(|_| panic!("invalid struct item type `{rust_name}`")) + } }; return quote! { Vec<#item_ty> }; } diff --git a/src/generator.rs b/src/generator.rs index 33ff607..f6da442 100644 --- a/src/generator.rs +++ b/src/generator.rs @@ -264,6 +264,69 @@ pub fn default_type_mappings() -> BTreeMap { mappings } +/// Convert an OpenAPI schema name to the canonical Rust model identifier. +/// +/// Every code-generation surface must use this helper rather than parsing raw +/// component keys as Rust types; otherwise names such as `not-found` panic and +/// names such as `inline_response_200` refer to models that were never emitted. +pub(crate) fn rust_type_name(s: &str) -> String { + let mut result = String::new(); + let mut next_upper = true; + + for c in s.chars() { + match c { + 'a'..='z' => { + result.push(if next_upper { + c.to_ascii_uppercase() + } else { + c + }); + next_upper = false; + } + 'A'..='Z' | '0'..='9' => { + result.push(c); + next_upper = false; + } + _ => next_upper = true, + } + } + + if result.is_empty() { + result = "Type".to_string(); + } + if result.chars().next().is_some_and(|c| c.is_ascii_digit()) { + result = format!("Type{result}"); + } + if matches!( + result.as_str(), + "Result" + | "Option" + | "Box" + | "Vec" + | "String" + | "Some" + | "None" + | "Ok" + | "Err" + | "Default" + | "Clone" + | "Debug" + | "Send" + | "Sync" + | "Sized" + | "Iterator" + | "From" + | "Into" + | "TryFrom" + | "TryInto" + | "AsRef" + | "AsMut" + ) { + result.push_str("Type"); + } + result +} + /// Represents a generated file #[derive(Debug, Clone)] pub struct GeneratedFile { @@ -797,7 +860,10 @@ impl CodeGenerator { #operation_methods }; - let syntax_tree = syn::parse2::(generated).map_err(|e| { + let syntax_tree = syn::parse2::(generated.clone()).map_err(|e| { + if let Ok(dump) = std::env::var("OATR_DUMP_TOKENS_ON_PARSE_ERROR") { + let _ = std::fs::write(&dump, generated.to_string()); + } GeneratorError::CodeGenError(format!("Failed to parse HTTP client code: {e}")) })?; @@ -3024,98 +3090,10 @@ impl CodeGenerator { } pub(crate) fn to_rust_type_name(&self, s: &str) -> String { - // Convert string to valid Rust type name (PascalCase) - let mut result = String::new(); - let mut next_upper = true; - let mut prev_was_lower = false; - - for c in s.chars() { - match c { - 'a'..='z' => { - if next_upper { - result.push(c.to_ascii_uppercase()); - next_upper = false; - } else { - result.push(c); - } - prev_was_lower = true; - } - 'A'..='Z' => { - result.push(c); - next_upper = false; - prev_was_lower = false; - } - '0'..='9' => { - // If previous was lowercase letter and this is start of a number sequence, - // make it uppercase to improve readability (e.g., Tool20241022 instead of Tool20241022) - if prev_was_lower && !result.chars().last().unwrap_or(' ').is_ascii_digit() { - // This is fine as-is, the number follows naturally - } - result.push(c); - next_upper = false; - prev_was_lower = false; - } - '_' | '-' | '.' | ' ' => { - // Skip underscore/separator and make next char uppercase - next_upper = true; - prev_was_lower = false; - } - _ => { - // Other special characters - treat as word boundary - next_upper = true; - prev_was_lower = false; - } - } - } - - // Handle empty result - if result.is_empty() { - result = "Type".to_string(); - } - - // Ensure type name starts with a letter (not a number) - if result.chars().next().is_some_and(|c| c.is_ascii_digit()) { - result = format!("Type{result}"); - } - - // Avoid masking ubiquitous std types and traits. cloudflare has a - // schema literally named `Result`, gcore has `Default`; emitting - // `pub enum Result { ... }` shadows std::result::Result and breaks - // every method's `-> Result>`. Same for impls - // like `impl Default for HttpClient { ... }` when `Default` resolves - // to the local type alias. - if matches!( - result.as_str(), - "Result" - | "Option" - | "Box" - | "Vec" - | "String" - | "Some" - | "None" - | "Ok" - | "Err" - | "Default" - | "Clone" - | "Debug" - | "Send" - | "Sync" - | "Sized" - | "Iterator" - | "From" - | "Into" - | "TryFrom" - | "TryInto" - | "AsRef" - | "AsMut" - ) { - result.push_str("Type"); - } - - result + rust_type_name(s) } - fn to_rust_field_name(&self, s: &str) -> String { + pub(crate) fn to_rust_field_name(&self, s: &str) -> String { // Track sign / leading-non-alpha so e.g. `+1` and `-1` produce // distinct field names instead of both collapsing to `field_1` // (observed in github.json's reactions schemas). diff --git a/src/openapi.rs b/src/openapi.rs index 62ef429..368fb32 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -12,7 +12,7 @@ pub struct OpenApiSpec { pub json_schema_dialect: Option, #[serde(default)] pub servers: Option>, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_lenient_path_map")] pub paths: Option>, #[serde(default)] pub webhooks: Option>, @@ -31,6 +31,32 @@ pub struct OpenApiSpec { pub extensions: Extensions, } +/// Deserialize the `paths` map while skipping entries that are not Path Item +/// Objects. Some real-world specs (apicurio) park extension values such as +/// `x-codegen-contextRoot: "/apis/registry/v2"` directly inside `paths`; +/// OpenAPI allows arbitrary `x-` extensions here, so drop non-object entries +/// that begin with `x-` instead of rejecting the whole document. +fn deserialize_lenient_path_map<'de, D>( + deserializer: D, +) -> Result>, D::Error> +where + D: serde::Deserializer<'de>, +{ + let raw = Option::>::deserialize(deserializer)?; + let Some(entries) = raw else { + return Ok(None); + }; + let mut paths = BTreeMap::new(); + for (key, value) in entries { + if value.is_object() || !key.starts_with("x-") { + let item = + serde_json::from_value::(value).map_err(serde::de::Error::custom)?; + paths.insert(key, item); + } + } + Ok(Some(paths)) +} + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Info { pub title: String, @@ -1079,11 +1105,28 @@ pub fn is_event_stream_media_type(ct: &str) -> bool { } /// Returns true for non-SSE media types in the `text` top-level family. +/// +/// Structured text formats in the `application` family whose instances are +/// UTF-8/UTF-16 character data — XML and its `+xml` suffix variants (RFC 7303, +/// RFC 6839) — are buffered and emitted as text as well; bytes are never +/// XML-parsed by the generated server, so a plain `String` body preserves +/// the payload losslessly. pub fn is_text_media_type(ct: &str) -> bool { let Some((top_level, subtype)) = media_type_essence(ct).split_once('/') else { return false; }; - top_level.eq_ignore_ascii_case("text") && !subtype.is_empty() && !is_event_stream_media_type(ct) + if top_level.eq_ignore_ascii_case("text") + && !subtype.is_empty() + && !is_event_stream_media_type(ct) + { + return true; + } + top_level.eq_ignore_ascii_case("application") + && (subtype.eq_ignore_ascii_case("xml") + || subtype.to_ascii_lowercase().ends_with("+xml") + // JWT (RFC 7519) compact serializations are ASCII text: three + // base64url segments joined by dots. + || subtype.eq_ignore_ascii_case("jwt")) } /// Returns true for OpenAPI media ranges with a wildcard subtype. @@ -1132,6 +1175,7 @@ pub fn is_binary_media_type(ct: &str, schema: Option<&Schema>) -> bool { } if essence.eq_ignore_ascii_case("application/octet-stream") || essence.eq_ignore_ascii_case("application/zip") + || essence.eq_ignore_ascii_case("application/pdf") { return true; } @@ -1222,6 +1266,11 @@ impl RequestBody { return Some((ct.as_str(), media_type.schema.as_ref())); } } + // Character-data fallbacks (text/xml, application/xml, +xml suffixed) + // are buffered as UTF-8 text like text/plain. + if let Some((ct, media_type)) = content.iter().find(|(ct, _)| is_text_media_type(ct)) { + return Some((ct.as_str(), media_type.schema.as_ref())); + } content .iter() // A request media range is not a concrete Content-Type value. The @@ -1316,6 +1365,29 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn paths_map_skips_extension_scalars() { + // apicurio registry: an `x-codegen-contextRoot` scalar sits inside + // `paths`; the document must still parse with the extension dropped. + let spec: OpenApiSpec = serde_json::from_value(json!({ + "openapi": "3.0.0", + "info": { "title": "lenient paths", "version": "1" }, + "paths": { + "x-codegen-contextRoot": "/apis/registry/v2", + "/items": { + "get": { + "operationId": "listItems", + "responses": { "204": { "description": "ok" } } + } + } + } + })) + .unwrap(); + let paths = spec.paths.unwrap(); + assert!(paths.contains_key("/items")); + assert!(!paths.contains_key("x-codegen-contextRoot")); + } + #[test] fn test_parse_simple_object_schema() { let schema_json = json!({ @@ -1552,13 +1624,33 @@ mod tests { #[test] fn response_media_classifier_leaves_ambiguous_formats_unsupported() { let string_schema: Schema = serde_json::from_value(json!({ "type": "string" })).unwrap(); - for media_type in ["application/xml", "application/pdf", "not-a-media-type"] { + for media_type in ["application/x-unknown", "not-a-media-type"] { assert_eq!( classify_response_media_type(media_type, Some(&string_schema)), ResponseMediaKind::Unsupported, "{media_type}" ); } + // PDF bodies are raw bytes; XML bodies are character data. Both are + // pass-through lossless for a server that never parses the payload, + // so they classify instead of failing generation. + assert_eq!( + classify_response_media_type("application/pdf", Some(&string_schema)), + ResponseMediaKind::Binary + ); + assert_eq!( + classify_response_media_type("application/xml", Some(&string_schema)), + ResponseMediaKind::Text + ); + assert_eq!( + classify_response_media_type("application/atom+xml", Some(&string_schema)), + ResponseMediaKind::Text + ); + // JWT compact serializations (RFC 7519) are ASCII text. + assert_eq!( + classify_response_media_type("application/jwt", Some(&string_schema)), + ResponseMediaKind::Text + ); assert!(!is_binary_media_type("text/plain", None)); } diff --git a/src/server/codegen.rs b/src/server/codegen.rs index 4373a47..e1dba0e 100644 --- a/src/server/codegen.rs +++ b/src/server/codegen.rs @@ -12,7 +12,7 @@ use crate::analysis::{ ParameterInfo, QuerySerialization, RequestBodyContent, SchemaAnalysis, SchemaType, }; use crate::config::ServerSection; -use crate::generator::{CodeGenerator, GeneratedFile, GeneratorConfig}; +use crate::generator::{CodeGenerator, GeneratedFile, GeneratorConfig, rust_type_name}; use super::{OperationIndex, Selector}; use heck::{ToPascalCase, ToSnakeCase}; @@ -83,6 +83,17 @@ pub fn reachable_schemas_with_roots( { seed(name, &mut queue, &mut keep); } + if let Some( + QuerySerialization::FormExplodedArray { + item_type: crate::analysis::ArrayItemType::FlatStructRef { schema_name, .. }, + } + | QuerySerialization::FormArray { + item_type: crate::analysis::ArrayItemType::FlatStructRef { schema_name, .. }, + }, + ) = &p.query_serialization + { + seed(schema_name, &mut queue, &mut keep); + } } } for root in extra_roots { @@ -288,6 +299,22 @@ impl<'a> ServerCodegen<'a> { .unwrap_or_default() } + /// Resolve a model type reference for emitted server modules. Names that + /// canonicalize to a different Rust identifier (e.g. `not-found`) cannot + /// be parsed from the raw component key and must be qualified explicitly; + /// already-canonical names resolve through the module's + /// `use super::super::types::*` glob like every other generated reference. + fn model_type(&self, ty: &str) -> TokenStream { + if self.analysis.schemas.contains_key(ty) { + let canonical = rust_type_name(ty); + if canonical != ty { + let ident = format_ident!("{}", canonical); + return quote! { super::super::types::#ident }; + } + } + parse_type(ty) + } + /// Resolve selectors and emit `server/{mod,api,errors}.rs`. pub fn generate(&self) -> Result, ServerCodegenError> { if self.server.operations.is_empty() { @@ -486,9 +513,9 @@ impl<'a> ServerCodegen<'a> { !matches!( schema.get("type").and_then(serde_json::Value::as_str), Some("array" | "object") - ) && !schema.get("oneOf").is_some() - && !schema.get("anyOf").is_some() - && !schema.get("allOf").is_some() + ) && schema.get("oneOf").is_none() + && schema.get("anyOf").is_none() + && schema.get("allOf").is_none() } fn form_field_names( @@ -1556,7 +1583,7 @@ impl<'a> ServerCodegen<'a> { let mut body_decode = TokenStream::new(); let body_ty_opt = body_type(op); if let Some(body_ty) = &body_ty_opt { - let body_ty_tokens = parse_type(body_ty); + let body_ty_tokens = self.model_type(body_ty); let transport_body = match &op.request_body { Some(RequestBodyContent::OctetStream { media_type }) => { Some((format_ident!("decode_binary_body"), media_type.clone())) @@ -1882,7 +1909,7 @@ impl<'a> ServerCodegen<'a> { } } if let Some(body) = body_type(op) { - let body_ty = parse_type(&body); + let body_ty = self.model_type(&body); if op.request_body_required { params.push(quote! { body: #body_ty }); } else { @@ -1932,33 +1959,83 @@ impl<'a> ServerCodegen<'a> { field_idents.push(field_ident.clone()); let decoder = match ¶meter.query_serialization { - Some(QuerySerialization::FormExplodedArray { .. }) => quote! { - let #field_ident = { - let empty_marker = __query_empty_marker(&__pairs, #wire_name)?; - let raw_values: Vec<&str> = __pairs - .iter() - .filter(|(key, _)| key == #wire_name) - .map(|(_, value)| value.as_str()) - .collect(); - if empty_marker && !raw_values.is_empty() { - return Err(format!( - "query array `{}` cannot combine values with its empty marker", - #wire_name, - )); + Some(QuerySerialization::FormExplodedArray { item_type }) => { + if let crate::analysis::ArrayItemType::FlatStructRef { + property_names, .. + } = item_type + { + // AWS query-protocol flat structures arrive as + // `param.N.Prop=value`; group by N and decode each + // group as one JSON object so serde fills the struct. + quote! { + let #field_ident = { + let empty_marker = __query_empty_marker(&__pairs, #wire_name)?; + let prefix = concat!(#wire_name, "."); + let mut groups: ::std::collections::BTreeMap> = ::std::collections::BTreeMap::new(); + let allowed = [#(#property_names),*]; + for (key, value) in &__pairs { + let Some(rest) = key.strip_prefix(prefix) else { continue }; + let Some((index, property)) = rest.split_once('.') else { continue }; + if !allowed.contains(&property) { + continue; + } + groups + .entry(index.to_string()) + .or_default() + .insert(property.to_string(), ::serde_json::Value::String(value.clone())); + } + if empty_marker && !groups.is_empty() { + return Err(format!( + "query array `{}` cannot combine values with its empty marker", + #wire_name, + )); + } + if empty_marker { + Some(Vec::new()) + } else if groups.is_empty() { + None + } else { + let mut values = Vec::with_capacity(groups.len()); + for (_, object) in groups { + values.push( + ::serde_json::from_value(::serde_json::Value::Object(object)) + .map_err(|error| format!("invalid query structure for `{}`: {error}", #wire_name))?, + ); + } + Some(values) + } + }; } - if empty_marker { - Some(Vec::new()) - } else if raw_values.is_empty() { - None - } else { - let mut values = Vec::with_capacity(raw_values.len()); - for raw in raw_values { - values.push(__decode_query_scalar(raw, #wire_name)?); - } - Some(values) + } else { + quote! { + let #field_ident = { + let empty_marker = __query_empty_marker(&__pairs, #wire_name)?; + let raw_values: Vec<&str> = __pairs + .iter() + .filter(|(key, _)| key == #wire_name) + .map(|(_, value)| value.as_str()) + .collect(); + if empty_marker && !raw_values.is_empty() { + return Err(format!( + "query array `{}` cannot combine values with its empty marker", + #wire_name, + )); + } + if empty_marker { + Some(Vec::new()) + } else if raw_values.is_empty() { + None + } else { + let mut values = Vec::with_capacity(raw_values.len()); + for raw in raw_values { + values.push(__decode_query_scalar(raw, #wire_name)?); + } + Some(values) + } + }; } - }; - }, + } + } Some(QuerySerialization::FormArray { .. }) => quote! { let #field_ident = match ( __query_one(&__pairs, #wire_name)?, @@ -2350,7 +2427,7 @@ impl<'a> ServerCodegen<'a> { schema_name, media_type, } => ( - parse_type(&schema_name), + self.model_type(&schema_name), quote! { Json(body) }, media_type, false, diff --git a/src/server/validation.rs b/src/server/validation.rs index c4e056f..c2734d5 100644 --- a/src/server/validation.rs +++ b/src/server/validation.rs @@ -167,7 +167,7 @@ pub(crate) fn prepare_validation_bundle( &mut component_queue, &mut queued_components, )?; - components.insert(name, schema); + components.insert(component_bundle_key(&name), schema); } let mut definitions = Map::new(); @@ -309,6 +309,48 @@ fn normalize_schema(value: &Value, draft: ValidationDraft) -> Value { } } } + // AWS-authored specs widely carry the constraint as `x-pattern` (an + // OpenAPI extension) rather than the JSON Schema `pattern` keyword. The + // embedded validator compiles a pure JSON Schema document, where unknown + // keywords fail meta-schema validation, so promote the extension before + // compiling. A real `pattern` keyword always wins. + if !schema.contains_key("pattern") + && let Some(x_pattern) = schema.remove("x-pattern") + && let Value::String(x_pattern) = x_pattern + { + schema.insert("pattern".to_string(), Value::String(x_pattern)); + } + if let Some(Value::String(pattern)) = schema.get_mut("pattern") { + let normalized = normalize_pattern(pattern); + if pattern_compiles_offline(&normalized) { + *pattern = normalized; + } else { + // The offline validator uses Rust's linear-time `regex` engine, + // which by design rejects look-around, backreferences, and other + // exponential features. A pattern constraint that cannot compile + // must degrade to "no pattern check" instead of failing code + // generation for the whole spec. The document is compiled against + // the JSON Schema meta-schema, where unknown keywords are errors, + // so the original expression cannot be preserved in-band. + schema.remove("pattern"); + } + } + if let Some(Value::Object(patterns)) = schema.get_mut("patternProperties") { + let old_patterns = std::mem::take(patterns); + let mut rewritten = Map::new(); + for (pattern, child) in old_patterns { + let normalized = normalize_pattern(&pattern); + if pattern_compiles_offline(&normalized) { + rewritten.insert(normalized, child); + } else { + // Same degradation as `pattern`: keep the entry reachable but + // under an always-matching key so instance documents still + // validate against the subschema. + rewritten.insert(".*".to_string(), child); + } + } + *patterns = rewritten; + } if draft == ValidationDraft::Draft4 { let nullable = schema @@ -324,6 +366,169 @@ fn normalize_schema(value: &Value, draft: ValidationDraft) -> Value { Value::Object(schema) } +/// Normalize an OpenAPI `pattern` (ECMA-262 / Java-flavoured) into a pattern +/// Rust's linear-time `regex` engine can compile offline. +fn normalize_pattern(pattern: &str) -> String { + let pattern = normalize_java_posix_classes(pattern); + let pattern = normalize_ecma_unicode_escapes(&pattern); + normalize_ecma_octal_escapes(&pattern) +} + +/// Returns true when Rust's `regex` engine (the offline validator's backend) +/// accepts the normalized pattern. Look-around, backreferences, and other +/// exponential-time constructs are intentionally unsupported by that engine. +fn pattern_compiles_offline(pattern: &str) -> bool { + regex::Regex::new(pattern).is_ok() +} + +/// ECMA-262 allows four-digit Unicode escapes `\uXXXX`; Rust's `regex` crate +/// only accepts the braced form `\u{XXXX}`. Translate the unbraced form while +/// leaving already-braced escapes and escaped backslashes untouched. +fn normalize_ecma_unicode_escapes(pattern: &str) -> String { + let bytes = pattern.as_bytes(); + let mut normalized = String::with_capacity(pattern.len()); + let mut index = 0; + while index < bytes.len() { + let preceding_backslashes = bytes[..index] + .iter() + .rev() + .take_while(|byte| **byte == b'\\') + .count(); + let is_escaped = preceding_backslashes % 2 == 1; + if bytes[index] == b'\\' + && !is_escaped + && bytes.get(index + 1) == Some(&b'u') + && bytes.get(index + 2) != Some(&b'{') + && index + 5 < bytes.len() + && bytes[index + 2..index + 6] + .iter() + .all(|b| b.is_ascii_hexdigit()) + { + normalized.push_str("\\u{"); + normalized.push_str(&pattern[index + 2..index + 6]); + normalized.push('}'); + index += 6; + } else { + let Some(ch) = pattern[index..].chars().next() else { + break; + }; + normalized.push(ch); + index += ch.len_utf8(); + } + } + normalized +} + +/// OpenAPI 3.x declares patterns as ECMA-262, but many real-world specs +/// (notably AWS) author them with Java's `java.util.regex` syntax. Rust's +/// `regex` crate rejects Java's ASCII POSIX classes (`\p{Print}`, `\p{Alpha}`, +/// ...) and the catch-all `\p{all}`. Translate them to equivalent ASCII forms; +/// Java's POSIX classes are ASCII-only unless UNICODE_CHARACTER_CLASS is set, +/// so ASCII ranges preserve the original semantics. Rust's engine already +/// supports the `&&` class-intersection operator these are usually paired +/// with, and the standard Unicode classes (`\p{L}`, `\p{N}`, ...) pass through +/// unchanged. +fn normalize_java_posix_classes(pattern: &str) -> String { + const JAVA_POSIX_CLASSES: [(&str, &str); 10] = [ + ("Alnum", "A-Za-z0-9"), + ("Alpha", "A-Za-z"), + ("ASCII", "\\x00-\\x7F"), + ("Cntrl", "\\x00-\\x1F\\x7F"), + ("Graph", "!-~"), + ("Lower", "a-z"), + ("Print", " -~"), + ("Punct", "!-/:-@\\[-`{-~"), + ("Upper", "A-Z"), + ("XDigit", "0-9A-Fa-f"), + ]; + let bytes = pattern.as_bytes(); + let mut normalized = String::with_capacity(pattern.len()); + let mut index = 0; + while index < bytes.len() { + let preceding_backslashes = bytes[..index] + .iter() + .rev() + .take_while(|byte| **byte == b'\\') + .count(); + let is_escaped = preceding_backslashes % 2 == 1; + if bytes[index] == b'\\' && !is_escaped && bytes.get(index + 1) == Some(&b'p') { + let brace = bytes.get(index + 2); + if brace == Some(&b'{') { + if let Some(end) = pattern[index + 3..].find('}') { + let class_name = &pattern[index + 3..index + 3 + end]; + if let Some((_, replacement)) = JAVA_POSIX_CLASSES + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case(class_name)) + { + // Inside a character class the range form slots in + // directly; outside one it needs its own brackets. + normalized.push_str(&format!("[{replacement}]")); + index += 3 + end + 1; + continue; + } + if class_name.eq_ignore_ascii_case("all") { + normalized.push_str("[\\s\\S]"); + index += 3 + end + 1; + continue; + } + } + } + } + let Some(ch) = pattern[index..].chars().next() else { + break; + }; + normalized.push(ch); + index += ch.len_utf8(); + } + normalized +} + +/// Rust's linear-time regex engine intentionally rejects legacy ECMAScript +/// octal escapes such as `\000`, while OpenAPI 3.x patterns use ECMA-262 +/// syntax and real specifications still contain those escapes. Translate the +/// unambiguous three-digit byte form before compiling the offline validator. +fn normalize_ecma_octal_escapes(pattern: &str) -> String { + let bytes = pattern.as_bytes(); + let mut normalized = String::with_capacity(pattern.len()); + let mut index = 0; + while index < bytes.len() { + let preceding_backslashes = bytes[..index] + .iter() + .rev() + .take_while(|byte| **byte == b'\\') + .count(); + if bytes[index] == b'\\' + && preceding_backslashes % 2 == 0 + && index + 3 < bytes.len() + && matches!(bytes[index + 1], b'0'..=b'3') + && matches!(bytes[index + 2], b'0'..=b'7') + && matches!(bytes[index + 3], b'0'..=b'7') + { + let value = (bytes[index + 1] - b'0') * 64 + + (bytes[index + 2] - b'0') * 8 + + (bytes[index + 3] - b'0'); + normalized.push_str(&format!("\\x{value:02X}")); + index += 4; + } else { + let Some(ch) = pattern[index..].chars().next() else { + break; + }; + normalized.push(ch); + index += ch.len_utf8(); + } + } + normalized +} + +/// Component keys in the embedded validation bundle are prefixed so a +/// component *named* like a JSON Schema keyword (`id`, `$ref`, `type`, ...) +/// cannot be mistaken for that keyword by the meta-schema. AWS's DataPipeline +/// spec really does name a schema `id`, which draft-4 then reads as a schema +/// identifier and rejects for not being a URI string. +fn component_bundle_key(name: &str) -> String { + format!("component_{name}") +} + fn rewrite_references( value: &mut Value, context: &str, @@ -341,7 +546,7 @@ fn rewrite_references( } *reference = format!( "{BUNDLE_ID}#/{definitions_key}/components/{}", - escape_pointer_token(&name) + escape_pointer_token(&component_bundle_key(&name)) ); } else if reference.starts_with(BUNDLE_ID) || !reference.starts_with('#') { return Err(ValidationPreparationError::UnsupportedReference { @@ -1187,6 +1392,120 @@ mod tests { assert_eq!(unescape_pointer_token("a~0~1b"), "a~/b"); } + #[test] + fn draft4_ecma_octal_pattern_is_compiled_at_exported_pointer() { + let context = ValidationContext { + openapi_version: "3.0.0".to_string(), + ..Default::default() + }; + let operation = OperationInfo { + operation_id: "awsStylePattern".to_string(), + parameters: vec![ParameterInfo { + name: "stream".to_string(), + location: "query".to_string(), + required: true, + schema_ref: None, + rust_type: "String".to_string(), + description: None, + enum_values: None, + enum_varnames: None, + rust_ident: None, + query_serialization: None, + validation_schema: Some(json!({ + "type": "string", + "pattern": "[^/:|\\000-\\037]+" + })), + }], + ..Default::default() + }; + + let bundle = prepare_validation_bundle(&context, &[&operation]).unwrap(); + let target = bundle + .target_for("awsStylePattern", "query", Some("stream")) + .unwrap(); + let document: Value = serde_json::from_str(&bundle.document_json).unwrap(); + let validators = jsonschema::options() + .with_draft(jsonschema::Draft::Draft4) + .with_pattern_options(jsonschema::PatternOptions::regex()) + .build_map(&document) + .unwrap(); + let validator = validators.get(&target.pointer).unwrap(); + assert!(validator.is_valid(&json!("migration-stream"))); + assert!(!validator.is_valid(&json!("\n"))); + } + + #[test] + fn java_posix_classes_and_intersection_compile_offline() { + // AWS-style patterns: Java `\p{Print}` POSIX class paired with the + // `&&[^...]` intersection operator. The referent component must + // compile so the pure-$ref target alias (`#/definitions/targets/v0`) + // is present in the validator map. + let context = ValidationContext { + openapi_version: "3.0.0".to_string(), + component_schemas: BTreeMap::from([( + "ScalingPlanName".to_string(), + json!({ + "type": "string", + "pattern": "[\\p{Print}&&[^|:/]]+", + "minLength": 1, + "maxLength": 128 + }), + )]), + ..Default::default() + }; + let operation = OperationInfo { + operation_id: "createScalingPlan".to_string(), + request_body: Some(RequestBodyContent::Json { + schema_name: "CreateScalingPlanRequest".to_string(), + media_type: "application/json".to_string(), + validation_schema: json!({"$ref": "#/components/schemas/ScalingPlanName"}), + }), + ..Default::default() + }; + + let bundle = prepare_validation_bundle(&context, &[&operation]).unwrap(); + let target = bundle + .target_for("createScalingPlan", "body", None) + .unwrap(); + let document: Value = serde_json::from_str(&bundle.document_json).unwrap(); + let validators = jsonschema::options() + .with_draft(jsonschema::Draft::Draft4) + .with_pattern_options(jsonschema::PatternOptions::regex()) + .build_map(&document) + .unwrap(); + let validator = validators.get(&target.pointer).unwrap(); + assert!(validator.is_valid(&json!("my-plan 1"))); + // JSON Schema patterns are unanchored searches, so a string that is + // *entirely* excluded characters must fail, while any string with at + // least one allowed character passes. + assert!(!validator.is_valid(&json!("|:/"))); + assert!(!validator.is_valid(&json!("|||"))); + } + + #[test] + fn normalize_pattern_translates_java_posix_classes() { + assert_eq!(normalize_pattern("\\p{Alpha}+"), "[A-Za-z]+"); + assert_eq!( + normalize_pattern("[\\p{Print}&&[^|:/]]+"), + "[[ -~]&&[^|:/]]+" + ); + assert_eq!(normalize_pattern("\\p{all}"), "[\\s\\S]"); + // Unicode classes pass through untouched. + assert_eq!(normalize_pattern("\\p{L}\\p{N}"), "\\p{L}\\p{N}"); + // An escaped backslash before `\p` is literal text, not a class. + assert_eq!(normalize_pattern("\\\\p{Alpha}"), "\\\\p{Alpha}"); + } + + #[test] + fn normalize_pattern_translates_ecma_unicode_escapes() { + assert_eq!(normalize_pattern("\\u0021-\\u007F"), "\\u{0021}-\\u{007F}"); + // Already-braced forms and non-hex tails pass through untouched. + assert_eq!(normalize_pattern("\\u{21}"), "\\u{21}"); + assert_eq!(normalize_pattern("\\u00ZZ"), "\\u00ZZ"); + // An escaped backslash keeps the sequence literal. + assert_eq!(normalize_pattern("\\\\u0021"), "\\\\u0021"); + } + #[test] fn constrained_inline_target_is_compiled_at_exported_pointer() { let context = ValidationContext { diff --git a/src/snapshots/openapi_to_rust__test_helpers__recursive_annotated_allof.snap b/src/snapshots/openapi_to_rust__test_helpers__recursive_annotated_allof.snap new file mode 100644 index 0000000..b85e00b --- /dev/null +++ b/src/snapshots/openapi_to_rust__test_helpers__recursive_annotated_allof.snap @@ -0,0 +1,18 @@ +--- +source: src/test_helpers.rs +expression: "&generated_code" +--- +//! Generated types from OpenAPI specification +//! +//! This file contains all the generated types for the API. +//! Do not edit manually - regenerate using the appropriate script. +#![allow(clippy::large_enum_variant)] +#![allow(clippy::format_in_format_args)] +#![allow(clippy::let_unit_value)] +#![allow(unreachable_patterns)] +use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Deserialize, Serialize, Default)] +pub struct Expression { + #[serde(skip_serializing_if = "Option::is_none")] + pub not: Option>, +} diff --git a/src/spec_source.rs b/src/spec_source.rs index ef8db9d..5537440 100644 --- a/src/spec_source.rs +++ b/src/spec_source.rs @@ -112,8 +112,22 @@ pub fn parse_spec( /// serde_yaml::Value and convert to serde_json::Value manually. pub fn yaml_to_json_value(content: &str) -> Result> { let preprocessed = sanitize_large_yaml_integers(content); - let yaml_value: serde_yaml::Value = serde_yaml::from_str(&preprocessed)?; - Ok(yaml_value_to_json(yaml_value)) + match serde_yaml::from_str::(&preprocessed) { + Ok(yaml_value) => Ok(yaml_value_to_json(yaml_value)), + Err(error) => { + // Real-world specs (Adyen, Amadeus) carry literal tab characters + // inside block-scalar prose. YAML 1.2 forbids tabs for + // indentation, and serde_yaml rejects the document outright even + // when the tab is content. Retry once with tabs sanitized + // line-wise so block-scalar indentation survives. + if error.to_string().contains("tab character") { + let expanded = expand_yaml_tabs(&preprocessed); + let yaml_value: serde_yaml::Value = serde_yaml::from_str(&expanded)?; + return Ok(yaml_value_to_json(yaml_value)); + } + Err(error.into()) + } + } } /// Parse JSON with lossy number handling: numbers that overflow i64/u64 are stored as f64. @@ -173,6 +187,26 @@ fn yaml_value_to_json(yaml: serde_yaml::Value) -> serde_json::Value { } } +/// Sanitize tab characters so serde_yaml accepts the document: a tab on an +/// otherwise whitespace-only line is dropped entirely (blank lines carry no +/// indentation semantics in block scalars), while tabs adjacent to content +/// become a single space. This keeps block-scalar indent auto-detection +/// consistent with sibling prose lines regardless of tab-stop assumptions. +fn expand_yaml_tabs(content: &str) -> String { + content + .lines() + .map(|line| { + if line.chars().all(|ch| ch == ' ' || ch == '\t') { + // Whitespace-only line: drop tabs, keep the spaces. + line.replace('\t', "") + } else { + line.replace('\t', " ") + } + }) + .collect::>() + .join("\n") +} + /// Preprocess YAML content to convert integers that exceed i64/u64 range to float notation. /// serde_yaml 0.9 cannot parse integers larger than u64::MAX or smaller than i64::MIN, /// so we find bare integer values on YAML lines and append `.0` if they overflow. @@ -269,3 +303,32 @@ pub fn validate_oas_document(value: &serde_json::Value) -> Result } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn yaml_with_block_scalar_tabs_parses_after_sanitization() { + let spec = "openapi: 3.0.0\ninfo:\n title: tabs\n version: '1'\npaths:\n /thing:\n get:\n description: |-\n \t\n Date and time of travel.\n * Encoding: ASCII\n operationId: getThing\n responses:\n '204':\n description: ok\n"; + let value = yaml_to_json_value(spec).expect("tabbed block scalar parses"); + assert_eq!( + value["paths"]["/thing"]["get"]["operationId"], + serde_json::json!("getThing") + ); + let description = value["paths"]["/thing"]["get"]["description"] + .as_str() + .expect("description is a string"); + assert!( + description.contains("Date and time of travel."), + "{description}" + ); + } + + #[test] + fn expand_yaml_tabs_drops_whitespace_only_line_tabs() { + assert_eq!(expand_yaml_tabs(" \t"), " "); + assert_eq!(expand_yaml_tabs("a\tb"), "a b"); + assert_eq!(expand_yaml_tabs("no tabs"), "no tabs"); + } +} diff --git a/tests/exploded_query_params_test.rs b/tests/exploded_query_params_test.rs index 9ee3eb8..4dfc556 100644 --- a/tests/exploded_query_params_test.rs +++ b/tests/exploded_query_params_test.rs @@ -279,8 +279,8 @@ fn form_exploded_array_types_integer_items_through_type_mapper() { "required int32 array should be a bare Vec; got:\n{code}" ); assert!( - code.contains("for item in ids"), - "required exploded array iterates the argument directly; got:\n{code}" + code.contains("for item in v"), + "required exploded array iterates the bound value; got:\n{code}" ); } diff --git a/tests/server_body_validation_test.rs b/tests/server_body_validation_test.rs index d0d4b4d..9f591e8 100644 --- a/tests/server_body_validation_test.rs +++ b/tests/server_body_validation_test.rs @@ -91,10 +91,10 @@ fn programmatic_validation_limits_are_checked_at_generation_boundary() { } #[test] -fn unsupported_request_media_is_rejected_during_server_generation() { +fn xml_request_media_is_accepted_as_text_during_server_generation() { let spec = json!({ "openapi": "3.1.0", - "info": { "title": "unsupported body", "version": "1" }, + "info": { "title": "xml body", "version": "1" }, "paths": { "/xml": { "post": { "operationId": "postXml", "requestBody": { "required": true, "content": { @@ -115,12 +115,44 @@ fn unsupported_request_media_is_rejected_during_server_generation() { server: Some(server.clone()), ..Default::default() }; + // XML bodies are character data: the handler receives them as a lossless + // UTF-8 String, so generation must succeed. + ServerCodegen::new(&config, &analysis, &server) + .generate() + .expect("application/xml request bodies are text-decodable"); +} + +#[test] +fn unsupported_request_media_is_rejected_during_server_generation() { + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "unsupported body", "version": "1" }, + "paths": { "/custom": { "post": { + "operationId": "postCustom", + "requestBody": { "required": true, "content": { + "application/x-proprietary": { "schema": { "type": "string" } } + }}, + "responses": { "204": { "description": "unused" } } + }}} + }); + let mut analyzer = SchemaAnalyzer::new(spec).unwrap(); + let analysis = analyzer.analyze().unwrap(); + let server = ServerSection { + framework: "axum".into(), + operations: vec!["postCustom".into()], + prune_models: true, + validation: Default::default(), + }; + let config = GeneratorConfig { + server: Some(server.clone()), + ..Default::default() + }; let error = ServerCodegen::new(&config, &analysis, &server) .generate() .unwrap_err() .to_string(); - assert!(error.contains("postXml"), "{error}"); - assert!(error.contains("application/xml"), "{error}"); + assert!(error.contains("postCustom"), "{error}"); + assert!(error.contains("application/x-proprietary"), "{error}"); } #[test] diff --git a/tests/server_query_roundtrip_test.rs b/tests/server_query_roundtrip_test.rs index a06a36d..390184d 100644 --- a/tests/server_query_roundtrip_test.rs +++ b/tests/server_query_roundtrip_test.rs @@ -107,6 +107,16 @@ fn round_trip_spec() -> Value { "style": "form", "explode": false, "schema": { "$ref": "#/components/schemas/ScoresAlias" } + }, + { + "name": "tags", + "in": "query", + "style": "form", + "explode": true, + "schema": { + "type": "array", + "items": { "$ref": "#/components/schemas/Tag" } + } } ], "responses": { "204": { "description": "captured" } } @@ -122,7 +132,15 @@ fn round_trip_spec() -> Value { }, "QueryId": { "type": "integer", "format": "int64" }, "PublicScore": { "$ref": "#/components/schemas/Score" }, - "Score": { "type": "integer", "format": "int32" } + "Score": { "type": "integer", "format": "int32" }, + "Tag": { + "type": "object", + "required": ["Key", "Value"], + "properties": { + "Key": { "type": "string" }, + "Value": { "type": "string" } + } + } } } }) @@ -220,6 +238,7 @@ mod tests { deep_filter: Option, ids: Vec, scores: Option>, + tags: Option>, ) -> QueryRoundTripResponse { self.captured .send(json!({ @@ -232,6 +251,7 @@ mod tests { "deep_filter": deep_filter, "ids": ids, "scores": scores, + "tags": tags, })) .unwrap(); QueryRoundTripResponse::NoContent @@ -276,6 +296,16 @@ mod tests { }), vec![11, 12], Some(vec![8, 9]), + Some(vec![ + Tag { + key: "env".into(), + value: "prod".into(), + }, + Tag { + key: "team".into(), + value: "sdk".into(), + }, + ]), ) .await .unwrap(); @@ -298,6 +328,10 @@ mod tests { "deep_filter": { "owner": "alice/bob", "open": true }, "ids": [11, 12], "scores": [8, 9], + "tags": [ + { "Key": "env", "Value": "prod" }, + { "Key": "team", "Value": "sdk" } + ], }) ); @@ -312,6 +346,7 @@ mod tests { Some(QueryRoundTripDeepFilter::default()), Vec::new(), Some(Vec::new()), + Some(Vec::new()), ) .await .unwrap(); @@ -334,6 +369,7 @@ mod tests { "deep_filter": {}, "ids": [], "scores": [], + "tags": [], }) ); @@ -361,6 +397,7 @@ mod tests { None, vec![1], None, + None, ) .await .unwrap_err(); diff --git a/tests/server_response_semantics_test.rs b/tests/server_response_semantics_test.rs index 5818b17..c2b783f 100644 --- a/tests/server_response_semantics_test.rs +++ b/tests/server_response_semantics_test.rs @@ -233,6 +233,98 @@ fn cyclic_response_reference_chain_is_rejected() { assert!(error.contains("response reference"), "{error}"); } +#[test] +fn raw_component_names_use_canonical_model_types_in_server_code() { + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "raw component names", "version": "1.0.0" }, + "paths": { + "/videos/{videoId}": { "delete": { + "operationId": "deleteVideo", + "tags": ["Videos"], + "parameters": [{ + "name": "videoId", + "in": "path", + "required": true, + "schema": { "type": "string" } + }], + "responses": { + "404": { + "description": "not found", + "content": { "application/json": { + "schema": { "$ref": "#/components/schemas/not-found" } + }} + } + } + }}, + "/topups": { "post": { + "operationId": "createTopup", + "tags": ["Videos"], + "requestBody": { + "required": true, + "content": { "application/json": { + "schema": { "$ref": "#/components/schemas/topupRequest" } + }} + }, + "responses": { "204": { "description": "accepted" } } + }}, + "/inline": { "get": { + "operationId": "get", + "responses": { "200": { + "description": "inline success", + "content": { "application/json": { + "schema": { "type": "string" } + }} + }} + }} + }, + "components": { "schemas": { + "not-found": { + "type": "object", + "properties": { "title": { "type": "string" } } + }, + "topupRequest": { + "type": "object", + "properties": { "amount": { "type": "integer" } } + } + }} + }); + let mut analysis = SchemaAnalyzer::new(spec).unwrap().analyze().unwrap(); + let generator = CodeGenerator::new(GeneratorConfig { + enable_async_client: false, + server: Some(ServerSection { + framework: "axum".into(), + operations: vec!["deleteVideo".into(), "createTopup".into(), "get".into()], + prune_models: true, + validation: ServerValidationSection { + enabled: false, + ..Default::default() + }, + }), + ..Default::default() + }); + + let result = generator.generate_all(&mut analysis).unwrap(); + let generated = |path: &str| { + &result + .files + .iter() + .find(|file| file.path == std::path::Path::new(path)) + .unwrap_or_else(|| panic!("missing generated {path}")) + .content + }; + let errors = generated("server/errors.rs"); + let api = generated("server/api.rs"); + // Non-canonical component names (`not-found`, `topupRequest`) must be + // qualified with their canonical Rust identifiers; names that are already + // canonical (`GetResponse`) resolve through the types glob import. + assert!(errors.contains("super::super::types::NotFound"), "{errors}"); + assert!(errors.contains("Ok(GetResponse)"), "{errors}"); + assert!(api.contains("super::super::types::TopupRequest"), "{api}"); + syn::parse_file(errors).expect("generated server errors must parse"); + syn::parse_file(api).expect("generated server api must parse"); +} + #[test] fn generated_responses_preserve_status_ranges_media_and_sse_status() { let temp = tempfile::TempDir::new().unwrap(); @@ -470,7 +562,8 @@ fn supported_json_representation_allows_unsupported_alternatives() { response.body, Some(openapi_to_rust::analysis::OperationResponseBody::Json { .. }) )); - assert_eq!(response.unsupported_media_types, ["application/xml"]); + // XML is a supported text representation, so nothing is unsupported. + assert!(response.unsupported_media_types.is_empty()); let server = ServerSection { framework: "axum".into(), operations: vec!["getReport".into()], diff --git a/tests/single_reference_allof_tests.rs b/tests/single_reference_allof_tests.rs index 6623803..37e7087 100644 --- a/tests/single_reference_allof_tests.rs +++ b/tests/single_reference_allof_tests.rs @@ -217,3 +217,33 @@ fn test_mixed_allof_composition_vs_single_reference() { "Multi-schema allOf should flatten all properties into one struct" ); } + +#[test] +fn reference_with_annotation_sibling_preserves_recursive_model_reference() { + let spec = json!({ + "openapi": "3.0.0", + "info": {"title": "recursive filter", "version": "1.0"}, + "components": { "schemas": { + "Expression": { + "type": "object", + "properties": { + "not": { "allOf": [ + { "$ref": "#/components/schemas/Expression" }, + { "description": "Negate this expression" } + ]} + } + } + }} + }); + + let result = test_generation("recursive_annotated_allof", spec).expect("Generation failed"); + let expression = result + .split("pub struct Expression") + .nth(1) + .expect("Expression model") + .split('}') + .next() + .unwrap(); + assert!(expression.contains("Expression"), "{expression}"); + assert!(!expression.contains("serde_json::Value"), "{expression}"); +}