diff --git a/examples/server-anthropic-messages/Cargo.toml b/examples/server-anthropic-messages/Cargo.toml index 9c3e810..b7947c7 100644 --- a/examples/server-anthropic-messages/Cargo.toml +++ b/examples/server-anthropic-messages/Cargo.toml @@ -22,5 +22,6 @@ futures-util = "0.3" # Pulled in by the generated types — see src/gen/REQUIRED_DEPS.toml # after running `openapi-to-rust generate`. base64 = "0.22" +bytes = { version = "1", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] } url = "2" diff --git a/src/analysis.rs b/src/analysis.rs index ccdc746..5eb7a3b 100644 --- a/src/analysis.rs +++ b/src/analysis.rs @@ -392,7 +392,12 @@ pub enum RequestBodyContent { #[serde(skip)] validation_schema: Value, }, - Multipart, + Multipart { + schema_name: String, + media_type: String, + #[serde(skip)] + validation_schema: Value, + }, OctetStream { media_type: String, }, @@ -417,11 +422,10 @@ impl RequestBodyContent { /// Get the schema name if this content type has one pub fn schema_name(&self) -> Option<&str> { match self { - Self::Json { schema_name, .. } | Self::FormUrlEncoded { schema_name, .. } => { - Some(schema_name) - } - Self::Multipart - | Self::OctetStream { .. } + Self::Json { schema_name, .. } + | Self::FormUrlEncoded { schema_name, .. } + | Self::Multipart { schema_name, .. } => Some(schema_name), + Self::OctetStream { .. } | Self::Binary { .. } | Self::TextPlain { .. } | Self::SchemaLess { .. } @@ -519,6 +523,14 @@ pub enum QuerySerialization { /// each property is its own pair — `?color=red&size=big`. The parameter /// name never appears in the query string (RFC 6570 form-explosion). FormExplodedObject, + /// AWS query-protocol form explosion for an object containing arrays: + /// `Parameter.Prop.1=value` or `Parameter.Prop.1.Leaf=value`. Unlike + /// ordinary RFC 6570 form explosion, AWS service models retain the outer + /// parameter wire name; client and server generation intentionally mirror + /// that protocol-specific representation. + FormExplodedNestedObject { + properties: Vec, + }, /// style=form + explode=false object: one comma-joined key,value list — /// `?filter=color,red,size,big`. FormObject, @@ -531,6 +543,9 @@ pub enum QuerySerialization { /// style=form + explode=false array: one comma-joined pair — /// `?tags=a,b,c`. Parameter typed `Vec`. FormArray { item_type: ArrayItemType }, + /// Header `style=simple, explode=false` array: one physical header value + /// containing comma-separated scalar items. + SimpleHeaderArray { item_type: ArrayItemType }, /// A complex query shape whose wire representation is undefined by /// OpenAPI or not implemented symmetrically. Clients retain the explicit /// opaque-string escape hatch; server generation rejects it with this @@ -557,10 +572,43 @@ pub enum ArrayItemType { /// keys without re-resolving the schema. FlatStructRef { schema_name: String, - property_names: Vec, + properties: Vec, + }, + /// A referenced structure with scalar properties plus arrays whose items + /// are scalar or flat structures. This is the deepest unambiguous shape + /// used by AWS query protocols (`param.N.Prop.M.Leaf=value`). + NestedStructRef { + schema_name: String, + properties: Vec, }, } +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +pub struct QueryStructProperty { + pub wire_name: String, + pub required: bool, + pub value_type: QueryStructPropertyType, +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +pub enum QueryStructPropertyType { + Scalar(QueryScalarType), + Array { + item_type: ArrayItemType, + }, + Object { + properties: Vec, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +pub enum QueryScalarType { + String, + Integer, + Number, + Boolean, +} + impl Default for DependencyGraph { fn default() -> Self { Self::new() @@ -1025,6 +1073,13 @@ pub struct SchemaAnalyzer { } impl SchemaAnalyzer { + fn uses_aws_query_conventions(&self) -> bool { + self.openapi_spec + .pointer("/info/x-providerName") + .and_then(Value::as_str) + .is_some_and(|provider| provider.eq_ignore_ascii_case("amazonaws.com")) + } + /// Construct an analyzer with a default [`TypeMapper`]. Pre-Q2.0 /// callers (tests, simple bins) use this and get bit-identical /// behavior to the pre-refactor code. @@ -4678,7 +4733,27 @@ impl SchemaAnalyzer { } else if media_type_essence(content_type) .eq_ignore_ascii_case("multipart/form-data") { - Some(RequestBodyContent::Multipart) + match maybe_schema { + Some(schema) => { + let validation_schema = self + .raw_request_body_schema(raw_operation.as_ref(), content_type) + .unwrap_or( + serde_json::to_value(schema) + .map_err(GeneratorError::ParseError)?, + ); + Some( + self.resolve_or_inline_schema(schema, operation_id, "Request") + .map(|schema_name| RequestBodyContent::Multipart { + schema_name, + media_type: content_type.to_string(), + validation_schema, + })?, + ) + } + None => Some(RequestBodyContent::SchemaLess { + media_type: content_type.to_string(), + }), + } } else if is_binary_media_type(content_type, maybe_schema) { if media_type_essence(content_type) .eq_ignore_ascii_case("application/octet-stream") @@ -5278,6 +5353,9 @@ impl SchemaAnalyzer { // per spec (issue #27). deepObject is only defined with explode=true; // an explicit explode=false there is undefined and keeps the fallback. let is_query = location == "query"; + let is_simple_header = location == "header" + && matches!(param.style.as_deref(), None | Some("simple")) + && param.explode != Some(true); let form_style = matches!(param.style.as_deref(), None | Some("form")); let form_exploded = form_style && param.explode.unwrap_or(true); let deep_object = @@ -5309,9 +5387,20 @@ impl SchemaAnalyzer { && self.referenced_schema_is_object(name) { schema_ref = Some(name.to_string()); - query_serialization = object_serialization.clone(); - } else if is_query - && form_style + query_serialization = if form_exploded && self.uses_aws_query_conventions() + { + match self.referenced_array_struct_item_type(name, 1) { + Some(ArrayItemType::NestedStructRef { properties, .. }) => { + Some(QuerySerialization::FormExplodedNestedObject { + properties, + }) + } + _ => object_serialization.clone(), + } + } else { + object_serialization.clone() + }; + } else if (is_query && form_style || is_simple_header) && let Some(item_type) = self.referenced_array_param_item_type(name) { // A parameter may reference a reusable array schema @@ -5320,7 +5409,9 @@ impl SchemaAnalyzer { // public parameter type to the same Vec used by // inline arrays. schema_ref = Some(name.to_string()); - query_serialization = Some(if form_exploded { + query_serialization = Some(if is_simple_header { + QuerySerialization::SimpleHeaderArray { item_type } + } else if form_exploded { QuerySerialization::FormExplodedArray { item_type } } else { QuerySerialization::FormArray { item_type } @@ -5337,10 +5428,18 @@ impl SchemaAnalyzer { let synthetic_name = format!("{op_pascal}{param_pascal}"); let mut deps = HashSet::new(); self.add_inline_schema(&synthetic_name, schema, &mut deps)?; - schema_ref = Some(synthetic_name); - query_serialization = object_serialization.clone(); - } else if is_query - && form_style + schema_ref = Some(synthetic_name.clone()); + query_serialization = if form_exploded && self.uses_aws_query_conventions() { + match self.referenced_array_struct_item_type(&synthetic_name, 1) { + Some(ArrayItemType::NestedStructRef { properties, .. }) => { + Some(QuerySerialization::FormExplodedNestedObject { properties }) + } + _ => object_serialization.clone(), + } + } else { + object_serialization.clone() + }; + } else if (is_query && form_style || is_simple_header) && matches!( schema.schema_type(), Some(crate::openapi::SchemaType::Array) @@ -5354,7 +5453,9 @@ impl SchemaAnalyzer { // is the authoritative Vec projection. Arrays whose items // don't type (objects, nested arrays) fall through to the // explicit unsupported shape below. - query_serialization = Some(if form_exploded { + query_serialization = Some(if is_simple_header { + QuerySerialization::SimpleHeaderArray { item_type } + } else if form_exploded { QuerySerialization::FormExplodedArray { item_type } } else { QuerySerialization::FormArray { item_type } @@ -5436,7 +5537,7 @@ impl SchemaAnalyzer { )) } else if is_array && form_style { Some( - "form array query parameters require scalar or string-enum items" + "form array query parameter exceeds the supported nesting bound or contains a non-scalar leaf; supported shapes are scalar arrays, arrays of flat scalar objects, and one nested scalar-object array" .to_string(), ) } else if is_array { @@ -5491,7 +5592,7 @@ impl SchemaAnalyzer { let name = self.extract_schema_name(ref_str)?; return self .referenced_array_scalar_item_type(name) - .or_else(|| self.referenced_array_flat_struct_item_type(name)); + .or_else(|| self.referenced_array_struct_item_type(name, 1)); } let format = unwrapped.details().format.clone(); let scalar = match unwrapped.schema_type()? { @@ -5519,43 +5620,164 @@ impl SchemaAnalyzer { } fn analyzed_array_item_type(&self, item_type: &SchemaType) -> Option { + self.analyzed_array_item_type_at_depth(item_type, 1) + } + + /// 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_struct_item_type( + &self, + name: &str, + nested_array_depth: usize, + ) -> Option { + let resolved = self.resolve_cached_schema(name)?; + let SchemaType::Object { + properties, + required, + additional_properties, + } = &resolved.schema_type + else { + return None; + }; + if properties.is_empty() + || !matches!(additional_properties, ObjectAdditionalProperties::Forbidden) + { + return None; + } + let mut projected = Vec::with_capacity(properties.len()); + let mut has_array = false; + for (wire_name, property) in properties { + let value_type = if let Some(scalar) = self.query_scalar_type(&property.schema_type) { + QueryStructPropertyType::Scalar(scalar) + } else { + if nested_array_depth == 0 { + return None; + } + if let Some(array) = self.resolve_query_array_type(&property.schema_type) { + let item_type = + self.analyzed_array_item_type_at_depth(array, nested_array_depth - 1)?; + if matches!(item_type, ArrayItemType::NestedStructRef { .. }) { + return None; + } + has_array = true; + QueryStructPropertyType::Array { item_type } + } else { + has_array = true; + QueryStructPropertyType::Object { + properties: self.query_flat_object_properties(&property.schema_type)?, + } + } + }; + projected.push(QueryStructProperty { + wire_name: wire_name.clone(), + required: required.contains(wire_name), + value_type, + }); + } + if has_array { + Some(ArrayItemType::NestedStructRef { + schema_name: name.to_string(), + properties: projected, + }) + } else { + Some(ArrayItemType::FlatStructRef { + schema_name: name.to_string(), + properties: projected, + }) + } + } + + fn analyzed_array_item_type_at_depth( + &self, + item_type: &SchemaType, + nested_array_depth: usize, + ) -> Option { match item_type { SchemaType::Primitive { rust_type, .. } => { Some(ArrayItemType::Scalar(rust_type.clone())) } SchemaType::Reference { target } => self .referenced_array_scalar_item_type(target) - .or_else(|| self.referenced_array_flat_struct_item_type(target)), + .or_else(|| self.referenced_array_struct_item_type(target, nested_array_depth)), _ => 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 { + fn resolve_query_array_type<'a>( + &'a self, + schema_type: &'a SchemaType, + ) -> Option<&'a SchemaType> { + match schema_type { + SchemaType::Array { item_type } => Some(item_type), + SchemaType::Reference { target } => { + let resolved = self.resolve_cached_schema(target)?; + let SchemaType::Array { item_type } = &resolved.schema_type else { + return None; + }; + Some(item_type) + } + _ => None, + } + } + + fn query_flat_object_properties( + &self, + schema_type: &SchemaType, + ) -> Option> { + let schema_type = match schema_type { + SchemaType::Reference { target } => &self.resolve_cached_schema(target)?.schema_type, + other => other, + }; + let SchemaType::Object { + properties, + required, + additional_properties, + } = schema_type + else { return None; }; - if properties.is_empty() { + if properties.is_empty() + || !matches!(additional_properties, ObjectAdditionalProperties::Forbidden) + { 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() + properties + .iter() + .map(|(wire_name, property)| { + Some(QueryStructProperty { + wire_name: wire_name.clone(), + required: required.contains(wire_name), + value_type: QueryStructPropertyType::Scalar( + self.query_scalar_type(&property.schema_type)?, + ), + }) + }) + .collect() + } + + fn query_scalar_type(&self, schema_type: &SchemaType) -> Option { + match schema_type { + SchemaType::Primitive { rust_type, .. } => match rust_type.as_str() { + "String" => Some(QueryScalarType::String), + "bool" => Some(QueryScalarType::Boolean), + value if value.starts_with('i') || value.starts_with('u') => { + Some(QueryScalarType::Integer) } - _ => false, - }); - all_scalar.then(|| ArrayItemType::FlatStructRef { - schema_name: name.to_string(), - property_names: properties.keys().cloned().collect(), - }) + value if value.starts_with('f') => Some(QueryScalarType::Number), + "serde_json::Value" => None, + _ => Some(QueryScalarType::String), + }, + SchemaType::StringEnum { .. } | SchemaType::ExtensibleEnum { .. } => { + Some(QueryScalarType::String) + } + SchemaType::Reference { target } => { + let resolved = self.resolve_cached_schema(target)?; + self.query_scalar_type(&resolved.schema_type) + } + _ => None, + } } /// Resolve a referenced array item through any alias chain while diff --git a/src/client_generator.rs b/src/client_generator.rs index 441ad75..a1929b9 100644 --- a/src/client_generator.rs +++ b/src/client_generator.rs @@ -168,6 +168,14 @@ struct BodyFieldPlan { access_path: Vec, } +#[derive(Clone, Copy)] +enum MultipartClientFieldKind { + RawBytes, + Base64, + Base64UrlUnpadded, + Text, +} + #[derive(Clone, Copy)] enum ClientSuccessBody<'a> { Json(&'a str), @@ -914,6 +922,7 @@ impl CodeGenerator { Some( crate::analysis::QuerySerialization::FormExplodedArray { .. } | crate::analysis::QuerySerialization::FormArray { .. } + | crate::analysis::QuerySerialization::SimpleHeaderArray { .. }, ) ) && Self::param_uses_as_ref_str(parameter) } @@ -928,17 +937,10 @@ impl CodeGenerator { let request_body = operation.request_body.as_ref()?; let (body_name, body_ident) = match request_body { RequestBodyContent::Json { schema_name, .. } - | RequestBodyContent::FormUrlEncoded { schema_name, .. } => { + | RequestBodyContent::FormUrlEncoded { schema_name, .. } + | RequestBodyContent::Multipart { schema_name, .. } => { (schema_name.as_str(), format_ident!("request")) } - RequestBodyContent::Multipart => { - return Some(BodyModelPlan { - body_ident: format_ident!("form"), - body_type: quote! { reqwest::multipart::Form }, - required_construction: RequiredBodyConstruction::Whole, - optional_fields: Vec::new(), - }); - } RequestBodyContent::OctetStream { .. } | RequestBodyContent::Binary { .. } | RequestBodyContent::Unsupported { .. } => { @@ -1334,7 +1336,7 @@ impl CodeGenerator { let http_method_call = self.http_method_call(op); let path = &op.path; let request_param = self.generate_request_param(op); - let request_body = self.generate_request_body(op); + let request_body = self.generate_request_body(op, analysis); let query_params = self.generate_query_params(op); let header_params = self.generate_header_params(op); let cookie_params = self.generate_cookie_params(op); @@ -1475,6 +1477,27 @@ impl CodeGenerator { let param_name_snake = self.param_ident_str(param); let param_ident = Self::to_field_ident(¶m_name_snake); let header_name = ¶m.name; + if matches!( + param.query_serialization, + Some(crate::analysis::QuerySerialization::SimpleHeaderArray { .. }) + ) { + let encode = quote! { + v.iter().map(::std::string::ToString::to_string).collect::>().join(",") + }; + if param.required { + emit.push(quote! { + let v = #param_ident; + req = req.header(#header_name, #encode); + }); + } else { + emit.push(quote! { + if let Some(v) = #param_ident { + req = req.header(#header_name, #encode); + } + }); + } + continue; + } if param.required { if Self::param_uses_as_ref_str(param) { emit.push(quote! { @@ -1567,6 +1590,142 @@ impl CodeGenerator { let param_key = ¶m.name; match ¶m.query_serialization { + Some(QuerySerialization::FormExplodedNestedObject { properties }) => { + let emit_properties = properties.iter().map(|property| { + let wire_name = property.wire_name.as_str(); + let field_ident = CodeGenerator::to_field_ident( + &self.to_rust_field_name(wire_name), + ); + match &property.value_type { + crate::analysis::QueryStructPropertyType::Scalar(_) => quote! { + let value = serde_json::to_value(&v.#field_ident) + .map_err(HttpError::serialization_error)?; + if !value.is_null() { + let value = match value { + serde_json::Value::String(value) => value, + serde_json::Value::Bool(value) => value.to_string(), + serde_json::Value::Number(value) => value.to_string(), + _ => return Err(HttpError::serialization_error( + format!("query field `{}` did not serialize as a scalar", #wire_name) + ).into()), + }; + nested_params.push((format!("{}.{}", #param_key, #wire_name), value)); + } + }, + crate::analysis::QueryStructPropertyType::Object { properties } => { + let leaves = properties.iter().map(|property| property.wire_name.clone()).collect::>(); + quote! { + let value = serde_json::to_value(&v.#field_ident) + .map_err(HttpError::serialization_error)?; + if !value.is_null() { + let serde_json::Value::Object(object) = value else { + return Err(HttpError::serialization_error(format!("query field `{}` did not serialize as an object", #wire_name)).into()); + }; + if object.is_empty() { + nested_params.push((format!("{}.{}[]", #param_key, #wire_name), String::new())); + } else { + for leaf in [#(#leaves),*] { + let Some(value) = object.get(leaf) else { continue }; + if value.is_null() { continue; } + let value = match value { + serde_json::Value::String(value) => value.clone(), + serde_json::Value::Bool(value) => value.to_string(), + serde_json::Value::Number(value) => value.to_string(), + _ => return Err(HttpError::serialization_error(format!("query field `{}` contained a non-scalar leaf", #wire_name)).into()), + }; + nested_params.push((format!("{}.{}.{}", #param_key, #wire_name, leaf), value)); + } + } + } + } + } + crate::analysis::QueryStructPropertyType::Array { item_type } => { + let nested_properties = match item_type { + crate::analysis::ArrayItemType::FlatStructRef { properties, .. } => Some( + properties.iter().map(|property| property.wire_name.clone()).collect::>() + ), + _ => None, + }; + if let Some(nested_properties) = nested_properties { + quote! { + let values = serde_json::to_value(&v.#field_ident) + .map_err(HttpError::serialization_error)?; + if !values.is_null() { + let serde_json::Value::Array(values) = values else { + return Err(HttpError::serialization_error( + format!("query field `{}` did not serialize as an array", #wire_name) + ).into()); + }; + if values.is_empty() { + nested_params.push((format!("{}.{}[]", #param_key, #wire_name), String::new())); + } + for (index, value) in values.into_iter().enumerate() { + let serde_json::Value::Object(object) = value else { + return Err(HttpError::serialization_error( + format!("query field `{}` contained a non-object item", #wire_name) + ).into()); + }; + for leaf in [#(#nested_properties),*] { + let Some(value) = object.get(leaf) else { continue }; + if value.is_null() { continue; } + let value = match value { + serde_json::Value::String(value) => value.clone(), + serde_json::Value::Bool(value) => value.to_string(), + serde_json::Value::Number(value) => value.to_string(), + _ => return Err(HttpError::serialization_error( + format!("query field `{}` contained a non-scalar leaf", #wire_name) + ).into()), + }; + nested_params.push((format!("{}.{}.{}.{}", #param_key, #wire_name, index + 1, leaf), value)); + } + } + } + } + } else { + quote! { + let values = serde_json::to_value(&v.#field_ident) + .map_err(HttpError::serialization_error)?; + if !values.is_null() { + let serde_json::Value::Array(values) = values else { + return Err(HttpError::serialization_error( + format!("query field `{}` did not serialize as an array", #wire_name) + ).into()); + }; + if values.is_empty() { + nested_params.push((format!("{}.{}[]", #param_key, #wire_name), String::new())); + } + for (index, value) in values.into_iter().enumerate() { + let value = match value { + serde_json::Value::String(value) => value, + serde_json::Value::Bool(value) => value.to_string(), + serde_json::Value::Number(value) => value.to_string(), + _ => return Err(HttpError::serialization_error( + format!("query field `{}` contained a non-scalar item", #wire_name) + ).into()), + }; + nested_params.push((format!("{}.{}.{}", #param_key, #wire_name, index + 1), value)); + } + } + } + } + } + } + }).collect::>(); + let apply = quote! { + let mut nested_params: Vec<(String, String)> = Vec::new(); + #(#emit_properties)* + if nested_params.is_empty() { + nested_params.push((format!("{}[]", #param_key), String::new())); + } + req = req.query(&nested_params); + }; + if param.required { + req_appends.push(quote! {{ let v = #param_name; #apply }}); + } else { + req_appends.push(quote! { if let Some(v) = #param_name { #apply } }); + } + continue; + } Some(QuerySerialization::FormExplodedObject) => { // Issue #27: reqwest serializes the struct through // serde_urlencoded, so each property becomes its own @@ -1706,34 +1865,159 @@ impl CodeGenerator { 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()), + let struct_properties = match item_type { + crate::analysis::ArrayItemType::FlatStructRef { properties, .. } + | crate::analysis::ArrayItemType::NestedStructRef { properties, .. } => { + Some(properties.clone()) + } _ => None, }; - let emit_items = if let Some(property_names) = flat_struct { - let pushes = property_names + let emit_items = if let Some(properties) = struct_properties { + let pushes = properties .iter() - .map(|wire_name| { + .map(|property| { + let wire_name = &property.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(), - )); + match &property.value_type { + crate::analysis::QueryStructPropertyType::Scalar(_) => quote! { + let value = serde_json::to_value(&item.#field_ident) + .map_err(HttpError::serialization_error)?; + if !value.is_null() { + let value = match value { + serde_json::Value::String(value) => value, + serde_json::Value::Bool(value) => value.to_string(), + serde_json::Value::Number(value) => value.to_string(), + _ => return Err(HttpError::serialization_error( + format!("query field `{}.{}` did not serialize as a scalar", #param_key, #wire_name) + ).into()), + }; + query_params.push(( + format!("{}.{}.{}", #param_key, index, #wire_name), + value, + )); + } + }, + crate::analysis::QueryStructPropertyType::Object { properties } => { + let leaves = properties.iter().map(|property| property.wire_name.clone()).collect::>(); + quote! { + let value = serde_json::to_value(&item.#field_ident) + .map_err(HttpError::serialization_error)?; + if !value.is_null() { + let serde_json::Value::Object(object) = value else { + return Err(HttpError::serialization_error(format!("query field `{}.{}` did not serialize as an object", #param_key, #wire_name)).into()); + }; + if object.is_empty() { + query_params.push((format!("{}.{}.{}[]", #param_key, index, #wire_name), String::new())); + } + for leaf in [#(#leaves),*] { + let Some(value) = object.get(leaf) else { continue }; + if value.is_null() { continue; } + let value = match value { + serde_json::Value::String(value) => value.clone(), + serde_json::Value::Bool(value) => value.to_string(), + serde_json::Value::Number(value) => value.to_string(), + _ => return Err(HttpError::serialization_error(format!("query field `{}.{}` contained a non-scalar leaf", #param_key, #wire_name)).into()), + }; + query_params.push((format!("{}.{}.{}.{}", #param_key, index, #wire_name, leaf), value)); + } + } + } + } + crate::analysis::QueryStructPropertyType::Array { item_type } => { + let nested_properties = match item_type { + crate::analysis::ArrayItemType::FlatStructRef { properties, .. } => { + Some(properties.iter().map(|property| property.wire_name.clone()).collect::>()) + } + _ => None, + }; + if let Some(nested_properties) = nested_properties { + quote! { + let values = serde_json::to_value(&item.#field_ident) + .map_err(HttpError::serialization_error)?; + if !values.is_null() { + let serde_json::Value::Array(values) = values else { + return Err(HttpError::serialization_error( + format!("query field `{}.{}` did not serialize as an array", #param_key, #wire_name) + ).into()); + }; + if values.is_empty() { + query_params.push((format!("{}.{}.{}[]", #param_key, index, #wire_name), String::new())); + } + for (nested_index, value) in values.into_iter().enumerate() { + let serde_json::Value::Object(object) = value else { + return Err(HttpError::serialization_error( + format!("query field `{}.{}` contained a non-object item", #param_key, #wire_name) + ).into()); + }; + if object.is_empty() { + query_params.push((format!("{}.{}.{}.{}[]", #param_key, index, #wire_name, nested_index + 1), String::new())); + } + for nested_wire_name in [#(#nested_properties),*] { + let Some(value) = object.get(nested_wire_name) else { continue }; + if value.is_null() { continue; } + let value = match value { + serde_json::Value::String(value) => value.clone(), + serde_json::Value::Bool(value) => value.to_string(), + serde_json::Value::Number(value) => value.to_string(), + _ => return Err(HttpError::serialization_error( + format!("query field `{}.{}` contained a non-scalar leaf", #param_key, #wire_name) + ).into()), + }; + query_params.push(( + format!("{}.{}.{}.{}.{}", #param_key, index, #wire_name, nested_index + 1, nested_wire_name), + value, + )); + } + } + } + } + } else { + quote! { + let values = serde_json::to_value(&item.#field_ident) + .map_err(HttpError::serialization_error)?; + if !values.is_null() { + let serde_json::Value::Array(values) = values else { + return Err(HttpError::serialization_error( + format!("query field `{}.{}` did not serialize as an array", #param_key, #wire_name) + ).into()); + }; + if values.is_empty() { + query_params.push((format!("{}.{}.{}[]", #param_key, index, #wire_name), String::new())); + } + for (nested_index, value) in values.into_iter().enumerate() { + let value = match value { + serde_json::Value::String(value) => value, + serde_json::Value::Bool(value) => value.to_string(), + serde_json::Value::Number(value) => value.to_string(), + _ => return Err(HttpError::serialization_error( + format!("query field `{}.{}` contained a non-scalar item", #param_key, #wire_name) + ).into()), + }; + query_params.push(( + format!("{}.{}.{}.{}", #param_key, index, #wire_name, nested_index + 1), + value, + )); + } + } + } + } + } } }) .collect::>(); quote! { for (index, item) in v.iter().enumerate() { let index = index + 1; + let item_start_len = query_params.len(); #(#pushes)* + if query_params.len() == item_start_len { + query_params.push((format!("{}.{}[]", #param_key, index), String::new())); + } } } } else { @@ -1816,7 +2100,10 @@ impl CodeGenerator { } continue; } - Some(QuerySerialization::Unsupported { .. }) => {} + Some( + QuerySerialization::Unsupported { .. } + | QuerySerialization::SimpleHeaderArray { .. }, + ) => {} None => {} } @@ -2051,13 +2338,14 @@ impl CodeGenerator { let required = op.request_body_required; let body_type = match rb { RequestBodyContent::Json { schema_name, .. } - | RequestBodyContent::FormUrlEncoded { schema_name, .. } => { + | RequestBodyContent::FormUrlEncoded { schema_name, .. } + | RequestBodyContent::Multipart { schema_name, .. } => { let rust_type_name = self.to_rust_type_name(schema_name); let request_ident = syn::Ident::new(&rust_type_name, proc_macro2::Span::call_site()); quote! { #request_ident } } - RequestBodyContent::Multipart => quote! { reqwest::multipart::Form }, + RequestBodyContent::OctetStream { .. } | RequestBodyContent::Binary { .. } => { quote! { Vec } } @@ -2068,7 +2356,6 @@ impl CodeGenerator { ), }; let body_ident = match rb { - RequestBodyContent::Multipart => quote! { form }, RequestBodyContent::OctetStream { .. } | RequestBodyContent::Binary { .. } | RequestBodyContent::TextPlain { .. } @@ -2116,7 +2403,8 @@ impl CodeGenerator { // (cloudflare has enum schemas like `resource-sharing_resource_type`). if let Some( QuerySerialization::FormExplodedArray { item_type } - | QuerySerialization::FormArray { item_type }, + | QuerySerialization::FormArray { item_type } + | QuerySerialization::SimpleHeaderArray { item_type }, ) = ¶m.query_serialization { use crate::analysis::ArrayItemType; @@ -2133,6 +2421,11 @@ impl CodeGenerator { syn::parse_str(&rust_name) .unwrap_or_else(|_| panic!("invalid struct item type `{rust_name}`")) } + ArrayItemType::NestedStructRef { schema_name, .. } => { + let rust_name = self.to_rust_type_name(schema_name); + syn::parse_str(&rust_name) + .unwrap_or_else(|_| panic!("invalid nested struct item type `{rust_name}`")) + } }; return quote! { Vec<#item_ty> }; } @@ -2160,12 +2453,212 @@ impl CodeGenerator { param.schema_ref.is_none() && param.rust_type == "String" } + fn resolve_multipart_wire_schema<'a>( + schema: &'a serde_json::Value, + analysis: &'a SchemaAnalysis, + visited: &mut std::collections::HashSet, + ) -> Option<&'a serde_json::Value> { + let Some(reference) = schema.get("$ref").and_then(serde_json::Value::as_str) else { + return Some(schema); + }; + let name = reference.strip_prefix("#/components/schemas/")?; + if !visited.insert(name.to_string()) { + return None; + } + let resolved = analysis.validation_context.component_schemas.get(name)?; + Self::resolve_multipart_wire_schema(resolved, analysis, visited) + } + + fn multipart_client_field_kind( + schema_type: &crate::analysis::SchemaType, + analysis: &SchemaAnalysis, + visited: &mut std::collections::HashSet, + ) -> Option { + match schema_type { + crate::analysis::SchemaType::Primitive { + rust_type, + serde_with, + } => { + let rust_type = rust_type.replace(' ', ""); + if rust_type == "bytes::Bytes" { + Some(MultipartClientFieldKind::RawBytes) + } else if serde_with + .as_deref() + .is_some_and(|codec| codec.contains("base64_url")) + { + Some(MultipartClientFieldKind::Base64UrlUnpadded) + } else if serde_with + .as_deref() + .is_some_and(|codec| codec.contains("base64")) + || rust_type == "Vec" + { + Some(MultipartClientFieldKind::Base64) + } else { + Some(MultipartClientFieldKind::Text) + } + } + crate::analysis::SchemaType::StringEnum { .. } + | crate::analysis::SchemaType::ExtensibleEnum { .. } => { + Some(MultipartClientFieldKind::Text) + } + crate::analysis::SchemaType::Reference { target } => { + if !visited.insert(target.clone()) { + return None; + } + analysis.schemas.get(target).and_then(|schema| { + Self::multipart_client_field_kind(&schema.schema_type, analysis, visited) + }) + } + _ => None, + } + } + + fn generate_typed_multipart_form( + &self, + schema_name: &str, + validation_schema: &serde_json::Value, + analysis: &SchemaAnalysis, + ) -> TokenStream { + use crate::analysis::{ObjectAdditionalProperties, SchemaType}; + + let Some((resolved_name, resolved_schema)) = + self.resolve_reference_schema(schema_name, analysis) + else { + let message = format!( + "multipart request schema `{schema_name}` could not be resolved during generation" + ); + return quote! { + return Err(HttpError::Config(#message.to_string()).into()); + }; + }; + let SchemaType::Object { + properties, + required, + additional_properties, + } = &resolved_schema.schema_type + else { + let message = + format!("multipart request schema `{schema_name}` must resolve to an object"); + return quote! { + return Err(HttpError::Config(#message.to_string()).into()); + }; + }; + let wire_schema = Self::resolve_multipart_wire_schema( + validation_schema, + analysis, + &mut std::collections::HashSet::new(), + ); + let wire_properties = wire_schema + .and_then(|schema| schema.get("properties")) + .and_then(serde_json::Value::as_object); + let fields = self.emitted_object_properties( + resolved_name, + properties, + required, + additional_properties, + analysis, + None, + ); + if matches!( + additional_properties, + ObjectAdditionalProperties::Typed { .. } + ) { + let message = format!( + "multipart request schema `{schema_name}` cannot contain typed additional properties" + ); + return quote! { + return Err(HttpError::Config(#message.to_string()).into()); + }; + } + + let mut parts = Vec::new(); + for field in fields { + let wire_name = field.wire_name; + let ident = field.ident; + let wire_format = wire_properties + .and_then(|properties| properties.get(wire_name)) + .and_then(|schema| { + Self::resolve_multipart_wire_schema( + schema, + analysis, + &mut std::collections::HashSet::new(), + ) + }) + .and_then(|schema| schema.get("format")) + .and_then(serde_json::Value::as_str); + let kind = if wire_format == Some("binary") { + match self.config().types.binary { + crate::type_mapping::BinaryStrategy::String => MultipartClientFieldKind::Text, + crate::type_mapping::BinaryStrategy::Bytes + | crate::type_mapping::BinaryStrategy::VecU8 => { + MultipartClientFieldKind::RawBytes + } + } + } else if let Some(kind) = Self::multipart_client_field_kind( + &field.property.schema_type, + analysis, + &mut std::collections::HashSet::new(), + ) { + kind + } else { + let message = + format!("multipart field `{wire_name}` must be binary or a scalar text field"); + return quote! { + return Err(HttpError::Config(#message.to_string()).into()); + }; + }; + let add_value = match kind { + MultipartClientFieldKind::RawBytes => quote! { + form = form.part( + #wire_name, + reqwest::multipart::Part::bytes(value.to_vec()), + ); + }, + MultipartClientFieldKind::Base64 => quote! { + use base64::Engine as _; + form = form.text( + #wire_name, + base64::engine::general_purpose::STANDARD.encode(value), + ); + }, + MultipartClientFieldKind::Base64UrlUnpadded => quote! { + use base64::Engine as _; + form = form.text( + #wire_name, + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(value), + ); + }, + MultipartClientFieldKind::Text => quote! { + form = form.text(#wire_name, value.to_string()); + }, + }; + parts.push(if field.is_required { + quote! { + let value = &request.#ident; + #add_value + } + } else { + quote! { + if let Some(value) = &request.#ident { + #add_value + } + } + }); + } + + quote! { + let mut form = reqwest::multipart::Form::new(); + #(#parts)* + req = req.multipart(form); + } + } + /// Generate request body serialization based on content type /// Emit statements that mutate `req` to apply the request body. Returns /// explicit zero-length framing for bodyless POST, PUT, and PATCH requests. /// Optional bodies (T11) gate the application on `Some(_)`; required bodies /// apply unconditionally. - fn generate_request_body(&self, op: &OperationInfo) -> TokenStream { + fn generate_request_body(&self, op: &OperationInfo, analysis: &SchemaAnalysis) -> TokenStream { let empty_request_framing = Self::generate_empty_request_framing(op); let Some(rb) = op.request_body.as_ref() else { return empty_request_framing; @@ -2189,11 +2682,13 @@ impl CodeGenerator { .header("content-type", #media_type); }, ), - RequestBodyContent::Multipart => ( - quote! { form }, - quote! { - req = req.multipart(form); - }, + RequestBodyContent::Multipart { + schema_name, + validation_schema, + .. + } => ( + quote! { request }, + self.generate_typed_multipart_form(schema_name, validation_schema, analysis), ), RequestBodyContent::OctetStream { media_type } => ( quote! { body }, diff --git a/src/registry_generator.rs b/src/registry_generator.rs index bfcae40..f3798df 100644 --- a/src/registry_generator.rs +++ b/src/registry_generator.rs @@ -283,9 +283,10 @@ impl CodeGenerator { quote! { BodyContentType::FormUrlEncoded }, quote! { Some(#schema_name) }, ), - RequestBodyContent::Multipart => { - (quote! { BodyContentType::Multipart }, quote! { None }) - } + RequestBodyContent::Multipart { schema_name, .. } => ( + quote! { BodyContentType::Multipart }, + quote! { Some(#schema_name) }, + ), RequestBodyContent::OctetStream { .. } => { (quote! { BodyContentType::OctetStream }, quote! { None }) } diff --git a/src/server/codegen.rs b/src/server/codegen.rs index e1dba0e..7021396 100644 --- a/src/server/codegen.rs +++ b/src/server/codegen.rs @@ -21,6 +21,23 @@ use quote::{format_ident, quote}; use std::collections::BTreeMap; use std::path::PathBuf; +#[derive(Clone, Copy)] +enum MultipartFieldKind { + Binary, + String, + Integer, + UnsignedInteger, + Number, + Boolean, +} + +struct MultipartFieldPlan { + wire_name: String, + field_ident: syn::Ident, + required: bool, + kind: MultipartFieldKind, +} + /// Compute the set of schema names transitively reachable from the /// request/response/parameter shapes of the given operations. /// @@ -78,6 +95,9 @@ pub fn reachable_schemas_with_roots( } | QuerySerialization::FormArray { item_type: crate::analysis::ArrayItemType::SchemaRef(name), + } + | QuerySerialization::SimpleHeaderArray { + item_type: crate::analysis::ArrayItemType::SchemaRef(name), }, ) = &p.query_serialization { @@ -85,10 +105,19 @@ pub fn reachable_schemas_with_roots( } if let Some( QuerySerialization::FormExplodedArray { - item_type: crate::analysis::ArrayItemType::FlatStructRef { schema_name, .. }, + item_type: + crate::analysis::ArrayItemType::FlatStructRef { schema_name, .. } + | crate::analysis::ArrayItemType::NestedStructRef { schema_name, .. }, } | QuerySerialization::FormArray { - item_type: crate::analysis::ArrayItemType::FlatStructRef { schema_name, .. }, + item_type: + crate::analysis::ArrayItemType::FlatStructRef { schema_name, .. } + | crate::analysis::ArrayItemType::NestedStructRef { schema_name, .. }, + } + | QuerySerialization::SimpleHeaderArray { + item_type: + crate::analysis::ArrayItemType::FlatStructRef { schema_name, .. } + | crate::analysis::ArrayItemType::NestedStructRef { schema_name, .. }, }, ) = &p.query_serialization { @@ -267,6 +296,109 @@ pub struct ServerCodegen<'a> { } impl<'a> ServerCodegen<'a> { + fn resolve_multipart_schema<'b>( + &'b self, + schema: &'b serde_json::Value, + ) -> Result<&'b serde_json::Value, ServerCodegenError> { + self.resolve_multipart_schema_inner(schema, &mut std::collections::HashSet::new()) + } + + fn resolve_multipart_schema_inner<'b>( + &'b self, + schema: &'b serde_json::Value, + visited: &mut std::collections::HashSet, + ) -> Result<&'b serde_json::Value, ServerCodegenError> { + let Some(reference) = schema.get("$ref").and_then(serde_json::Value::as_str) else { + return Ok(schema); + }; + let Some(name) = reference.strip_prefix("#/components/schemas/") else { + return Ok(schema); + }; + if !visited.insert(name.to_string()) { + return Err(ServerCodegenError::Validation(format!( + "multipart schema reference cycle includes `{name}`" + ))); + } + let resolved = self + .analysis + .validation_context + .component_schemas + .get(name) + .ok_or_else(|| { + ServerCodegenError::Validation(format!( + "multipart schema reference `{reference}` could not be resolved" + )) + })?; + self.resolve_multipart_schema_inner(resolved, visited) + } + + fn multipart_field_plans( + &self, + schema: &serde_json::Value, + ) -> Result, ServerCodegenError> { + let schema = self.resolve_multipart_schema(schema)?; + let properties = schema + .get("properties") + .and_then(serde_json::Value::as_object) + .ok_or_else(|| { + ServerCodegenError::Validation( + "multipart request schema must be a flat object with declared properties" + .into(), + ) + })?; + if schema + .get("additionalProperties") + .is_some_and(|value| value != &serde_json::Value::Bool(false)) + { + return Err(ServerCodegenError::Validation( + "multipart request schema cannot use additionalProperties".into(), + )); + } + let required = schema + .get("required") + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + .filter_map(serde_json::Value::as_str) + .collect::>(); + properties + .iter() + .map(|(wire_name, property)| { + let property = self.resolve_multipart_schema(property)?; + let kind = match ( + property.get("type").and_then(serde_json::Value::as_str), + property.get("format").and_then(serde_json::Value::as_str), + ) { + (Some("string"), Some("binary")) => match self.config.types.binary { + crate::type_mapping::BinaryStrategy::String => MultipartFieldKind::String, + crate::type_mapping::BinaryStrategy::Bytes + | crate::type_mapping::BinaryStrategy::VecU8 => MultipartFieldKind::Binary, + }, + (Some("string"), _) => MultipartFieldKind::String, + (Some("integer"), Some("uint32" | "uint64" | "uint")) + if self.config.types.unsigned => + { + MultipartFieldKind::UnsignedInteger + } + (Some("integer"), _) => MultipartFieldKind::Integer, + (Some("number"), _) => MultipartFieldKind::Number, + (Some("boolean"), _) => MultipartFieldKind::Boolean, + _ => { + return Err(ServerCodegenError::Validation(format!( + "multipart field `{wire_name}` must be binary or a scalar text field" + ))); + } + }; + Ok(MultipartFieldPlan { + wire_name: wire_name.clone(), + field_ident: CodeGenerator::to_field_ident(&wire_name.to_snake_case()), + required: required.contains(wire_name.as_str()), + kind, + }) + }) + .collect() + } + pub fn new( config: &'a GeneratorConfig, analysis: &'a SchemaAnalysis, @@ -360,6 +492,7 @@ impl<'a> ServerCodegen<'a> { .collect::>()?; validate_tag_identifier_collisions(&ops)?; validate_custom_method_route_groups(&ops)?; + validate_normalized_route_collisions(&ops)?; self.validate_query_parameters(&ops)?; self.validate_supported_server_inputs(&ops)?; self.validate_supported_server_outputs(&ops)?; @@ -396,6 +529,12 @@ impl<'a> ServerCodegen<'a> { Some(RequestBodyContent::TextPlain { .. }) ) }); + let has_embedded_path_affixes = ops.iter().any(|operation| { + operation.parameters.iter().any(|parameter| { + parameter.location == "path" + && path_parameter_affixes(&operation.path, ¶meter.name).is_some() + }) + }); let validation_bundle = if self.server.validation.enabled { Some( @@ -413,7 +552,8 @@ impl<'a> ServerCodegen<'a> { }; let transport_validation = has_binary_body || has_text_body; - let validation_module_enabled = validation_bundle.is_some() || transport_validation; + let validation_module_enabled = + validation_bundle.is_some() || transport_validation || has_embedded_path_affixes; let api_rs = self.emit_api(&groups, &response_enum_names); let errors_rs = self.emit_errors(&ops, validation_module_enabled, &response_enum_names); let router_rs = self.emit_router(&groups, validation_bundle.as_ref())?; @@ -447,7 +587,7 @@ impl<'a> ServerCodegen<'a> { has_text_body, )), }); - } else if transport_validation { + } else if transport_validation || has_embedded_path_affixes { files.push(GeneratedFile { path: PathBuf::from("server").join("validation.rs"), content: format_or_raw(super::validation::emit_transport_validation_module( @@ -580,6 +720,10 @@ impl<'a> ServerCodegen<'a> { }); } if matches!(parameter.location.as_str(), "path" | "header" | "cookie") + && !matches!( + parameter.query_serialization, + Some(QuerySerialization::SimpleHeaderArray { .. }) + ) && parameter .validation_schema .as_ref() @@ -596,12 +740,10 @@ impl<'a> ServerCodegen<'a> { Some(RequestBodyContent::FormUrlEncoded { .. }) => { self.form_field_names(operation)?; } - Some(RequestBodyContent::Multipart) => { - return Err(ServerCodegenError::UnsupportedRequestBody { - operation_id: operation.operation_id.clone(), - media_type: "multipart/form-data".to_string(), - reason: "typed multipart server extraction is not implemented".to_string(), - }); + Some(RequestBodyContent::Multipart { + validation_schema, .. + }) => { + self.multipart_field_plans(validation_schema)?; } Some(RequestBodyContent::SchemaLess { media_type }) => { return Err(ServerCodegenError::UnsupportedRequestBody { @@ -910,9 +1052,13 @@ impl<'a> ServerCodegen<'a> { vec![parameter.name.clone()] } } + Some(QuerySerialization::FormExplodedNestedObject { .. }) => { + vec![parameter.name.clone()] + } Some( QuerySerialization::FormExplodedArray { .. } - | QuerySerialization::FormArray { .. }, + | QuerySerialization::FormArray { .. } + | QuerySerialization::SimpleHeaderArray { .. }, ) | None => vec![parameter.name.clone()], }; @@ -920,6 +1066,7 @@ impl<'a> ServerCodegen<'a> { ¶meter.query_serialization, Some( QuerySerialization::FormExplodedObject + | QuerySerialization::FormExplodedNestedObject { .. } | QuerySerialization::FormObject | QuerySerialization::DeepObject | QuerySerialization::FormExplodedArray { .. } @@ -1218,6 +1365,7 @@ impl<'a> ServerCodegen<'a> { .collect::>()?; let doc = format!(" Build an axum::Router for the `{trait_ident}` trait."); + let max_body_bytes = self.server.validation.max_body_bytes; Ok(quote! { #[doc = #doc] @@ -1227,6 +1375,7 @@ impl<'a> ServerCodegen<'a> { { ::axum::Router::new() #(#routes)* + .layer(::axum::extract::DefaultBodyLimit::max(#max_body_bytes)) .with_state(api) } @@ -1258,8 +1407,11 @@ impl<'a> ServerCodegen<'a> { .iter() .filter(|p| p.location == "path") .collect(); + let path_has_affixes = path_params + .iter() + .any(|parameter| path_parameter_affixes(&op.path, ¶meter.name).is_some()); let mut path_decode = TokenStream::new(); - if !path_params.is_empty() && validation_bundle.is_some() { + if !path_params.is_empty() && (validation_bundle.is_some() || path_has_affixes) { extractors.push(quote! { __path_result: ::std::result::Result< ::axum::extract::Path<::std::collections::HashMap>, @@ -1272,25 +1424,51 @@ impl<'a> ServerCodegen<'a> { let ty = self.query_parameter_type(parameter); let wire = parameter.name.as_str(); let location = parameter_location("path", wire); - let target = self - .validation_target(validation_bundle, op, "path", Some(wire))? - .ok_or_else(|| { - ServerCodegenError::Validation(format!( - "validation target unexpectedly disabled for operation `{}` path `{wire}`", - op.operation_id - )) - })?; + let target = self.validation_target(validation_bundle, op, "path", Some(wire))?; let string_wire = self.parameter_schema_is_string(parameter); - decoders.push(quote! { - let #ident: #ty = match __path_values.remove(#wire) { - Some(raw) => match super::validation::decode_parameter( + let strip_affixes = match path_parameter_affixes(&op.path, wire) { + Some((prefix, suffix)) => quote! { + let raw = match raw + .strip_prefix(#prefix) + .and_then(|value| value.strip_suffix(#suffix)) + { + Some(value) => value.to_string(), + None => return ::axum::response::IntoResponse::into_response( + ::axum::http::StatusCode::NOT_FOUND + ), + }; + }, + None => TokenStream::new(), + }; + let decode = if let Some(target) = target { + quote! { + match super::validation::decode_parameter( &raw, #target, #location, #string_wire, ) { Ok(value) => value, Err(rejection) => return ::axum::response::IntoResponse::into_response(rejection), + } + } + } else { + quote! { + match ::serde_json::from_value(::serde_json::Value::String(raw.clone())) + .or_else(|_| ::serde_json::from_str(&raw)) + { + Ok(value) => value, + Err(_) => return ::axum::response::IntoResponse::into_response( + ::axum::http::StatusCode::BAD_REQUEST + ), + } + } + }; + decoders.push(quote! { + let #ident: #ty = match __path_values.remove(#wire) { + Some(raw) => { + #strip_affixes + #decode }, None => return ::axum::response::IntoResponse::into_response( - super::validation::generated_contract_error() + ::axum::http::StatusCode::INTERNAL_SERVER_ERROR ), }; }); @@ -1300,7 +1478,7 @@ impl<'a> ServerCodegen<'a> { let ::axum::extract::Path(mut __path_values) = match __path_result { Ok(path) => path, Err(_) => return ::axum::response::IntoResponse::into_response( - super::validation::malformed_parameter("/path") + ::axum::http::StatusCode::BAD_REQUEST ), }; #(#decoders)* @@ -1463,6 +1641,84 @@ impl<'a> ServerCodegen<'a> { let location = parameter_location("header", wire); let target = self.validation_target(validation_bundle, op, "header", Some(wire))?; let string_wire = self.parameter_schema_is_string(p); + if matches!( + p.query_serialization, + Some(QuerySerialization::SimpleHeaderArray { .. }) + ) { + let decode_array = quote! { + raw.split(',') + .map(|item| __decode_query_scalar(item, #wire)) + .collect::<::std::result::Result<#ty, _>>() + }; + if p.required { + parameter_decode_checks.push(quote! { + let mut __values = __headers.get_all(#wire).iter(); + let #ident: #ty = match (__values.next(), __values.next()) { + (Some(value), None) => match value.to_str() { + Ok(raw) => match #decode_array { + Ok(value) => value, + Err(_) => return ::axum::response::IntoResponse::into_response( + super::validation::malformed_parameter(#location) + ), + }, + Err(_) => return ::axum::response::IntoResponse::into_response( + super::validation::malformed_parameter(#location) + ), + }, + (None, _) => return ::axum::response::IntoResponse::into_response( + super::validation::missing_parameter(#location) + ), + _ => return ::axum::response::IntoResponse::into_response( + super::validation::malformed_parameter(#location) + ), + }; + }); + if let Some(target) = target { + parameter_decode_checks.push(quote! { + if let Err(rejection) = super::validation::validate_parameter( + #target, #location, &#ident, + ) { + return ::axum::response::IntoResponse::into_response(rejection); + } + }); + } + call_args.push(quote! { #ident }); + } else { + parameter_decode_checks.push(quote! { + let mut __values = __headers.get_all(#wire).iter(); + let #ident: ::std::option::Option<#ty> = match (__values.next(), __values.next()) { + (Some(value), None) => match value.to_str() { + Ok(raw) => match #decode_array { + Ok(value) => Some(value), + Err(_) => return ::axum::response::IntoResponse::into_response( + super::validation::malformed_parameter(#location) + ), + }, + Err(_) => return ::axum::response::IntoResponse::into_response( + super::validation::malformed_parameter(#location) + ), + }, + (None, _) => None, + _ => return ::axum::response::IntoResponse::into_response( + super::validation::malformed_parameter(#location) + ), + }; + }); + if let Some(target) = target { + parameter_decode_checks.push(quote! { + if let Some(value) = &#ident { + if let Err(rejection) = super::validation::validate_parameter( + #target, #location, value, + ) { + return ::axum::response::IntoResponse::into_response(rejection); + } + } + }); + } + call_args.push(quote! { #ident }); + } + continue; + } if p.required { if let Some(target) = target { parameter_decode_checks.push(quote! { @@ -1602,6 +1858,8 @@ impl<'a> ServerCodegen<'a> { &op.request_body, Some(RequestBodyContent::FormUrlEncoded { .. }) ) && validation_bundle.is_some(); + let typed_multipart = + matches!(&op.request_body, Some(RequestBodyContent::Multipart { .. })); if let Some((decoder, media_type)) = transport_body { extractors.push(quote! { __request: ::axum::extract::Request }); let required = op.request_body_required; @@ -1629,6 +1887,234 @@ impl<'a> ServerCodegen<'a> { }); } call_args.push(quote! { body }); + } else if typed_multipart { + extractors.push(quote! { __request: ::axum::extract::Request }); + let Some(RequestBodyContent::Multipart { + validation_schema, .. + }) = &op.request_body + else { + return Err(ServerCodegenError::Internal( + "typed multipart body lost its schema".to_string(), + )); + }; + let plans = self.multipart_field_plans(validation_schema)?; + let multipart_validation = self + .validation_target(validation_bundle, op, "body", None)? + .map(|target| { + quote! { + if let Err(rejection) = super::validation::validate_parameter( + #target, + "/body", + &::serde_json::Value::Object(validation_object), + ) { + return ::axum::response::IntoResponse::into_response(rejection); + } + } + }) + .unwrap_or_default(); + let mut binary_locals = Vec::new(); + let mut binary_validation_arms = Vec::new(); + let mut binary_patches = Vec::new(); + for plan in plans + .iter() + .filter(|plan| matches!(plan.kind, MultipartFieldKind::Binary)) + { + let wire_name = &plan.wire_name; + let field_ident = &plan.field_ident; + let slot = format_ident!("__multipart_binary_{}", field_ident); + binary_locals.push(quote! { + let mut #slot: ::std::option::Option<::bytes::Bytes> = None; + }); + binary_validation_arms.push(quote! { + #wire_name => ::serde_json::Value::String( + "x".repeat(#slot.as_ref().map_or(0, ::bytes::Bytes::len)) + ), + }); + binary_patches.push(match (self.config.types.binary, plan.required) { + (crate::type_mapping::BinaryStrategy::Bytes, true) => quote! { + body.#field_ident = match #slot { + Some(bytes) => bytes, + None => return ::axum::response::IntoResponse::into_response( + (::axum::http::StatusCode::UNPROCESSABLE_ENTITY, "missing required multipart field") + ), + }; + }, + (crate::type_mapping::BinaryStrategy::Bytes, false) => quote! { + body.#field_ident = #slot; + }, + (crate::type_mapping::BinaryStrategy::VecU8, true) => quote! { + body.#field_ident = match #slot { + Some(bytes) => bytes.to_vec(), + None => return ::axum::response::IntoResponse::into_response( + (::axum::http::StatusCode::UNPROCESSABLE_ENTITY, "missing required multipart field") + ), + }; + }, + (crate::type_mapping::BinaryStrategy::VecU8, false) => quote! { + body.#field_ident = #slot.map(|bytes| bytes.to_vec()); + }, + (crate::type_mapping::BinaryStrategy::String, _) => unreachable!( + "string-backed binary fields must use multipart text extraction" + ), + }); + } + let mut field_arms = Vec::new(); + for plan in plans { + let wire_name = plan.wire_name; + let binary_slot = format_ident!("__multipart_binary_{}", plan.field_ident); + let decode = match plan.kind { + MultipartFieldKind::Binary => quote! { + let bytes = match field.bytes().await { + Ok(bytes) => bytes, + Err(_) => return ::axum::response::IntoResponse::into_response( + (::axum::http::StatusCode::BAD_REQUEST, "invalid multipart field") + ), + }; + #binary_slot = Some(bytes); + ::serde_json::Value::Array(Vec::new()) + }, + MultipartFieldKind::String => quote! { + match field.text().await { + Ok(text) => ::serde_json::Value::String(text), + Err(_) => return ::axum::response::IntoResponse::into_response( + (::axum::http::StatusCode::BAD_REQUEST, "invalid multipart text field") + ), + } + }, + MultipartFieldKind::Integer => quote! { + let text = match field.text().await { + Ok(text) => text, + Err(_) => return ::axum::response::IntoResponse::into_response( + (::axum::http::StatusCode::BAD_REQUEST, "invalid multipart integer field") + ), + }; + match text.parse::() { + Ok(value) => ::serde_json::Value::Number(value.into()), + Err(_) => return ::axum::response::IntoResponse::into_response( + (::axum::http::StatusCode::BAD_REQUEST, "invalid multipart integer field") + ), + } + }, + MultipartFieldKind::UnsignedInteger => quote! { + let text = match field.text().await { + Ok(text) => text, + Err(_) => return ::axum::response::IntoResponse::into_response( + (::axum::http::StatusCode::BAD_REQUEST, "invalid multipart unsigned integer field") + ), + }; + match text.parse::() { + Ok(value) => ::serde_json::Value::Number(value.into()), + Err(_) => return ::axum::response::IntoResponse::into_response( + (::axum::http::StatusCode::BAD_REQUEST, "invalid multipart unsigned integer field") + ), + } + }, + MultipartFieldKind::Number => quote! { + let text = match field.text().await { + Ok(text) => text, + Err(_) => return ::axum::response::IntoResponse::into_response( + (::axum::http::StatusCode::BAD_REQUEST, "invalid multipart number field") + ), + }; + match text.parse::().ok().and_then(::serde_json::Number::from_f64) { + Some(value) => ::serde_json::Value::Number(value), + None => return ::axum::response::IntoResponse::into_response( + (::axum::http::StatusCode::BAD_REQUEST, "invalid multipart number field") + ), + } + }, + MultipartFieldKind::Boolean => quote! { + let text = match field.text().await { + Ok(text) => text, + Err(_) => return ::axum::response::IntoResponse::into_response( + (::axum::http::StatusCode::BAD_REQUEST, "invalid multipart boolean field") + ), + }; + match text.parse::() { + Ok(value) => ::serde_json::Value::Bool(value), + Err(_) => return ::axum::response::IntoResponse::into_response( + (::axum::http::StatusCode::BAD_REQUEST, "invalid multipart boolean field") + ), + } + }, + }; + field_arms.push(quote! { + #wire_name => { #decode } + }); + } + let required = op.request_body_required; + body_decode = quote! { + let __has_multipart_content_type = __request.headers() + .get(::axum::http::header::CONTENT_TYPE) + .is_some(); + let body: ::std::option::Option<#body_ty_tokens> = + if !__has_multipart_content_type && !#required { + None + } else { + let mut multipart = match <::axum::extract::Multipart as ::axum::extract::FromRequest>::from_request(__request, &api).await { + Ok(multipart) => multipart, + Err(_) => return ::axum::response::IntoResponse::into_response( + (::axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE, "invalid multipart request") + ), + }; + let mut object = ::serde_json::Map::new(); + let mut validation_object = ::serde_json::Map::new(); + #(#binary_locals)* + loop { + let field = match multipart.next_field().await { + Ok(Some(field)) => field, + Ok(None) => break, + Err(_) => return ::axum::response::IntoResponse::into_response( + (::axum::http::StatusCode::BAD_REQUEST, "malformed multipart request") + ), + }; + let name = match field.name() { + Some(name) => name.to_string(), + None => return ::axum::response::IntoResponse::into_response( + (::axum::http::StatusCode::BAD_REQUEST, "multipart field has no name") + ), + }; + if object.contains_key(&name) { + return ::axum::response::IntoResponse::into_response( + (::axum::http::StatusCode::BAD_REQUEST, "duplicate multipart field") + ); + } + let value = match name.as_str() { + #(#field_arms,)* + _ => return ::axum::response::IntoResponse::into_response( + (::axum::http::StatusCode::UNPROCESSABLE_ENTITY, "unknown multipart field") + ), + }; + let validation_value = match name.as_str() { + #(#binary_validation_arms)* + _ => value.clone(), + }; + object.insert(name.clone(), value); + validation_object.insert(name, validation_value); + } + #multipart_validation + match ::serde_json::from_value::<#body_ty_tokens>(::serde_json::Value::Object(object)) { + Ok(mut body) => { + #(#binary_patches)* + Some(body) + }, + Err(_) => return ::axum::response::IntoResponse::into_response( + (::axum::http::StatusCode::UNPROCESSABLE_ENTITY, "invalid multipart body") + ), + } + }; + }; + if required { + body_decode.extend(quote! { + let body = match body { + Some(body) => body, + None => return ::axum::response::IntoResponse::into_response( + (::axum::http::StatusCode::UNPROCESSABLE_ENTITY, "missing required multipart body") + ), + }; + }); + } + call_args.push(quote! { body }); } else if validated_json { extractors.push(quote! { __request: ::axum::extract::Request }); let Some(RequestBodyContent::Json { media_type, .. }) = &op.request_body else { @@ -1960,10 +2446,13 @@ impl<'a> ServerCodegen<'a> { let decoder = match ¶meter.query_serialization { Some(QuerySerialization::FormExplodedArray { item_type }) => { - if let crate::analysis::ArrayItemType::FlatStructRef { - property_names, .. - } = item_type + if let crate::analysis::ArrayItemType::FlatStructRef { properties, .. } = + item_type { + let property_names = properties + .iter() + .map(|property| property.wire_name.clone()) + .collect::>(); // 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. @@ -1971,16 +2460,21 @@ impl<'a> ServerCodegen<'a> { 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 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 }; + if let Some(index) = rest.strip_suffix("[]").and_then(|index| index.parse::().ok()) { + groups.entry(index).or_default(); + continue; + } let Some((index, property)) = rest.split_once('.') else { continue }; + let Ok(index) = index.parse::() else { continue }; if !allowed.contains(&property) { continue; } groups - .entry(index.to_string()) + .entry(index) .or_default() .insert(property.to_string(), ::serde_json::Value::String(value.clone())); } @@ -2006,6 +2500,238 @@ impl<'a> ServerCodegen<'a> { } }; } + } else if let crate::analysis::ArrayItemType::NestedStructRef { + properties, + .. + } = item_type + { + let scalar_json = |kind: crate::analysis::QueryScalarType| match kind { + crate::analysis::QueryScalarType::String => { + quote! { ::serde_json::Value::String(value.clone()) } + } + crate::analysis::QueryScalarType::Boolean => quote! { + ::serde_json::Value::Bool(value.parse::().map_err(|error| { + format!("invalid boolean query value `{value}`: {error}") + })?) + }, + crate::analysis::QueryScalarType::Integer + | crate::analysis::QueryScalarType::Number => quote! { + match ::serde_json::from_str::<::serde_json::Value>(value) + .map_err(|error| format!("invalid numeric query value `{value}`: {error}"))? + { + parsed @ ::serde_json::Value::Number(_) => parsed, + _ => return Err(format!("invalid numeric query value `{value}`")), + } + }, + }; + let property_decoders = properties + .iter() + .enumerate() + .map(|(property_index, property)| { + let property_name = property.wire_name.as_str(); + match &property.value_type { + crate::analysis::QueryStructPropertyType::Scalar(kind) => { + let parsed = scalar_json(*kind); + quote! { + for (key, value) in &__pairs { + let Some(rest) = key.strip_prefix(prefix) else { continue }; + let Some((index, tail)) = rest.split_once('.') else { continue }; + let Ok(index) = index.parse::() else { continue }; + if tail != #property_name { continue; } + let parsed = #parsed; + groups.entry(index).or_default() + .insert(#property_name.to_string(), parsed); + } + } + } + crate::analysis::QueryStructPropertyType::Object { properties } => { + let nested_groups = format_ident!("nested_objects_{property_index}"); + let leaf_parsers = properties.iter().map(|leaf_property| { + let leaf = leaf_property.wire_name.as_str(); + let crate::analysis::QueryStructPropertyType::Scalar(kind) = leaf_property.value_type else { + unreachable!("flat query object cannot contain nested values"); + }; + let parsed = scalar_json(kind); + quote! { #leaf => #parsed, } + }).collect::>(); + quote! { + let mut #nested_groups: ::std::collections::BTreeMap> = ::std::collections::BTreeMap::new(); + for (key, value) in &__pairs { + let Some(rest) = key.strip_prefix(prefix) else { continue }; + let Some((index, tail)) = rest.split_once('.') else { continue }; + let Ok(index) = index.parse::() else { continue }; + if tail == concat!(#property_name, "[]") { + groups.entry(index).or_default().insert( + #property_name.to_string(), + ::serde_json::Value::Object(::serde_json::Map::new()), + ); + continue; + } + let Some(leaf) = tail.strip_prefix(concat!(#property_name, ".")) else { continue }; + let parsed = match leaf { #(#leaf_parsers)* _ => continue }; + #nested_groups.entry(index).or_default().insert(leaf.to_string(), parsed); + } + for (index, value) in #nested_groups { + groups.entry(index).or_default().insert(#property_name.to_string(), ::serde_json::Value::Object(value)); + } + } + } + crate::analysis::QueryStructPropertyType::Array { item_type } => { + let nested_groups = format_ident!("nested_groups_{property_index}"); + match item_type { + crate::analysis::ArrayItemType::Scalar(rust_type) => { + let kind = if rust_type == "String" { + crate::analysis::QueryScalarType::String + } else if rust_type == "bool" { + crate::analysis::QueryScalarType::Boolean + } else if rust_type.starts_with('i') || rust_type.starts_with('u') { + crate::analysis::QueryScalarType::Integer + } else { + crate::analysis::QueryScalarType::Number + }; + let parsed = scalar_json(kind); + quote! { + let mut #nested_groups: ::std::collections::BTreeMap> = ::std::collections::BTreeMap::new(); + for (key, value) in &__pairs { + let Some(rest) = key.strip_prefix(prefix) else { continue }; + let Some((index, tail)) = rest.split_once('.') else { continue }; + let Ok(index) = index.parse::() else { continue }; + if tail == concat!(#property_name, "[]") { + groups.entry(index).or_default().insert( + #property_name.to_string(), + ::serde_json::Value::Array(Vec::new()), + ); + continue; + } + let Some(nested_index) = tail.strip_prefix(concat!(#property_name, ".")) else { continue }; + let Ok(nested_index) = nested_index.parse::() else { continue }; + let parsed = #parsed; + #nested_groups.entry(index).or_default().insert(nested_index, parsed); + } + for (index, values) in #nested_groups { + groups.entry(index).or_default().insert( + #property_name.to_string(), + ::serde_json::Value::Array(values.into_values().collect()), + ); + } + } + } + crate::analysis::ArrayItemType::SchemaRef(_) => quote! { + let mut #nested_groups: ::std::collections::BTreeMap> = ::std::collections::BTreeMap::new(); + for (key, value) in &__pairs { + let Some(rest) = key.strip_prefix(prefix) else { continue }; + let Some((index, tail)) = rest.split_once('.') else { continue }; + let Ok(index) = index.parse::() else { continue }; + if tail == concat!(#property_name, "[]") { + groups.entry(index).or_default().insert( + #property_name.to_string(), + ::serde_json::Value::Array(Vec::new()), + ); + continue; + } + let Some(nested_index) = tail.strip_prefix(concat!(#property_name, ".")) else { continue }; + let Ok(nested_index) = nested_index.parse::() else { continue }; + #nested_groups.entry(index).or_default().insert( + nested_index, + ::serde_json::Value::String(value.clone()), + ); + } + for (index, values) in #nested_groups { + groups.entry(index).or_default().insert( + #property_name.to_string(), + ::serde_json::Value::Array(values.into_values().collect()), + ); + } + }, + crate::analysis::ArrayItemType::FlatStructRef { properties, .. } => { + let allowed = properties.iter().map(|property| property.wire_name.clone()).collect::>(); + let leaf_kinds = properties.iter().map(|property| match property.value_type { + crate::analysis::QueryStructPropertyType::Scalar(kind) => kind, + crate::analysis::QueryStructPropertyType::Array { .. } + | crate::analysis::QueryStructPropertyType::Object { .. } => unreachable!("flat query struct cannot contain nested values"), + }).collect::>(); + let leaf_parsers = allowed.iter().zip(leaf_kinds).map(|(leaf, kind)| { + let parsed = scalar_json(kind); + quote! { + #leaf => #parsed, + } + }).collect::>(); + quote! { + let mut #nested_groups: ::std::collections::BTreeMap>> = ::std::collections::BTreeMap::new(); + for (key, value) in &__pairs { + let Some(rest) = key.strip_prefix(prefix) else { continue }; + let Some((index, tail)) = rest.split_once('.') else { continue }; + let Ok(index) = index.parse::() else { continue }; + if tail == concat!(#property_name, "[]") { + groups.entry(index).or_default().insert( + #property_name.to_string(), + ::serde_json::Value::Array(Vec::new()), + ); + continue; + } + let Some(tail) = tail.strip_prefix(concat!(#property_name, ".")) else { continue }; + if let Some(nested_index) = tail.strip_suffix("[]").and_then(|index| index.parse::().ok()) { + #nested_groups.entry(index).or_default().entry(nested_index).or_default(); + continue; + } + let Some((nested_index, leaf)) = tail.split_once('.') else { continue }; + let Ok(nested_index) = nested_index.parse::() else { continue }; + let parsed = match leaf { + #(#leaf_parsers)* + _ => continue, + }; + #nested_groups.entry(index).or_default() + .entry(nested_index).or_default() + .insert(leaf.to_string(), parsed); + } + for (index, values) in #nested_groups { + groups.entry(index).or_default().insert( + #property_name.to_string(), + ::serde_json::Value::Array(values.into_values().map(::serde_json::Value::Object).collect()), + ); + } + } + } + crate::analysis::ArrayItemType::NestedStructRef { .. } => unreachable!("analysis rejects query nesting deeper than two levels"), + } + } + } + }) + .collect::>(); + 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(); + for (key, _) in &__pairs { + let Some(rest) = key.strip_prefix(prefix) else { continue }; + if let Some(index) = rest.strip_suffix("[]").and_then(|index| index.parse::().ok()) { + groups.entry(index).or_default(); + } + } + #(#property_decoders)* + 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 nested query structure for `{}`: {error}", #wire_name))?, + ); + } + Some(values) + } + }; + } } else { quote! { let #field_ident = { @@ -2056,6 +2782,160 @@ impl<'a> ServerCodegen<'a> { (None, false) => None, }; }, + Some(QuerySerialization::FormExplodedNestedObject { properties }) => { + let scalar_json = |kind: crate::analysis::QueryScalarType| match kind { + crate::analysis::QueryScalarType::String => { + quote! { ::serde_json::Value::String(value.clone()) } + } + crate::analysis::QueryScalarType::Boolean => quote! { + ::serde_json::Value::Bool(value.parse::().map_err(|error| format!("invalid boolean query value `{value}`: {error}"))?) + }, + crate::analysis::QueryScalarType::Integer + | crate::analysis::QueryScalarType::Number => quote! { + match ::serde_json::from_str::<::serde_json::Value>(value) + .map_err(|error| format!("invalid numeric query value `{value}`: {error}"))? + { + parsed @ ::serde_json::Value::Number(_) => parsed, + _ => return Err(format!("invalid numeric query value `{value}`")), + } + }, + }; + let property_decoders = properties.iter().enumerate().map(|(property_index, property)| { + let property_name = property.wire_name.as_str(); + match &property.value_type { + crate::analysis::QueryStructPropertyType::Scalar(kind) => { + let parsed = scalar_json(*kind); + quote! { + if let Some((_, value)) = __pairs.iter().find(|(key, _)| key == concat!(#wire_name, ".", #property_name)) { + let parsed = #parsed; + object.insert(#property_name.to_string(), parsed); + } + } + } + crate::analysis::QueryStructPropertyType::Object { properties } => { + let property_wire_name = format!("{wire_name}.{property_name}"); + let leaf_parsers = properties.iter().map(|leaf_property| { + let leaf = leaf_property.wire_name.as_str(); + let crate::analysis::QueryStructPropertyType::Scalar(kind) = leaf_property.value_type else { + unreachable!("flat query object cannot contain nested values"); + }; + let parsed = scalar_json(kind); + quote! { #leaf => #parsed, } + }).collect::>(); + quote! { + let mut nested = ::serde_json::Map::new(); + for (key, value) in &__pairs { + let Some(leaf) = key.strip_prefix(concat!(#wire_name, ".", #property_name, ".")) else { continue }; + let parsed = match leaf { #(#leaf_parsers)* _ => continue }; + nested.insert(leaf.to_string(), parsed); + } + let nested_empty_marker = __query_empty_marker(&__pairs, #property_wire_name)?; + if nested_empty_marker && !nested.is_empty() { + return Err(format!("query object `{}` cannot combine properties with its empty marker", #property_wire_name)); + } + if nested_empty_marker || !nested.is_empty() { + object.insert(#property_name.to_string(), ::serde_json::Value::Object(nested)); + } + } + } + crate::analysis::QueryStructPropertyType::Array { item_type } => { + let values_ident = format_ident!("property_values_{property_index}"); + let property_wire_name = format!("{wire_name}.{property_name}"); + match item_type { + crate::analysis::ArrayItemType::Scalar(rust_type) => { + let kind = if rust_type == "String" { + crate::analysis::QueryScalarType::String + } else if rust_type == "bool" { + crate::analysis::QueryScalarType::Boolean + } else if rust_type.starts_with('i') || rust_type.starts_with('u') { + crate::analysis::QueryScalarType::Integer + } else { + crate::analysis::QueryScalarType::Number + }; + let parsed = scalar_json(kind); + quote! { + let mut #values_ident: ::std::collections::BTreeMap = ::std::collections::BTreeMap::new(); + for (key, value) in &__pairs { + let Some(index) = key.strip_prefix(concat!(#wire_name, ".", #property_name, ".")) else { continue }; + let Ok(index) = index.parse::() else { continue }; + let parsed = #parsed; + #values_ident.insert(index, parsed); + } + let property_empty_marker = __query_empty_marker(&__pairs, #property_wire_name)?; + if property_empty_marker && !#values_ident.is_empty() { + return Err(format!("query array `{}` cannot combine values with its empty marker", #property_wire_name)); + } + if property_empty_marker || !#values_ident.is_empty() { + object.insert(#property_name.to_string(), ::serde_json::Value::Array(#values_ident.into_values().collect())); + } + } + } + crate::analysis::ArrayItemType::SchemaRef(_) => quote! { + let mut #values_ident: ::std::collections::BTreeMap = ::std::collections::BTreeMap::new(); + for (key, value) in &__pairs { + let Some(index) = key.strip_prefix(concat!(#wire_name, ".", #property_name, ".")) else { continue }; + let Ok(index) = index.parse::() else { continue }; + #values_ident.insert(index, ::serde_json::Value::String(value.clone())); + } + let property_empty_marker = __query_empty_marker(&__pairs, #property_wire_name)?; + if property_empty_marker && !#values_ident.is_empty() { + return Err(format!("query array `{}` cannot combine values with its empty marker", #property_wire_name)); + } + if property_empty_marker || !#values_ident.is_empty() { + object.insert(#property_name.to_string(), ::serde_json::Value::Array(#values_ident.into_values().collect())); + } + }, + crate::analysis::ArrayItemType::FlatStructRef { properties, .. } => { + let leaf_parsers = properties.iter().map(|leaf_property| { + let leaf = leaf_property.wire_name.as_str(); + let crate::analysis::QueryStructPropertyType::Scalar(kind) = leaf_property.value_type else { + unreachable!("flat query struct cannot contain arrays"); + }; + let parsed = scalar_json(kind); + quote! { #leaf => #parsed, } + }).collect::>(); + quote! { + let mut #values_ident: ::std::collections::BTreeMap> = ::std::collections::BTreeMap::new(); + for (key, value) in &__pairs { + let Some(tail) = key.strip_prefix(concat!(#wire_name, ".", #property_name, ".")) else { continue }; + let Some((index, leaf)) = tail.split_once('.') else { continue }; + let Ok(index) = index.parse::() else { continue }; + let parsed = match leaf { #(#leaf_parsers)* _ => continue }; + #values_ident.entry(index).or_default().insert(leaf.to_string(), parsed); + } + let property_empty_marker = __query_empty_marker(&__pairs, #property_wire_name)?; + if property_empty_marker && !#values_ident.is_empty() { + return Err(format!("query array `{}` cannot combine values with its empty marker", #property_wire_name)); + } + if property_empty_marker || !#values_ident.is_empty() { + object.insert(#property_name.to_string(), ::serde_json::Value::Array( + #values_ident.into_values().map(::serde_json::Value::Object).collect() + )); + } + } + } + crate::analysis::ArrayItemType::NestedStructRef { .. } => unreachable!("analysis rejects query nesting deeper than two levels"), + } + } + } + }).collect::>(); + quote! { + let #field_ident = { + let empty_marker = __query_empty_marker(&__pairs, #wire_name)?; + let mut object = ::serde_json::Map::new(); + #(#property_decoders)* + if empty_marker && !object.is_empty() { + return Err(format!("query object `{}` cannot combine properties with its empty marker", #wire_name)); + } + if object.is_empty() && !empty_marker { + None + } else { + Some(::serde_json::from_value(::serde_json::Value::Object(object)) + .map_err(|error| format!("invalid nested query object for `{}`: {error}", #wire_name))?) + } + }; + } + } Some(QuerySerialization::FormExplodedObject) => { let property_names = self .query_object_properties(parameter) @@ -2193,7 +3073,11 @@ impl<'a> ServerCodegen<'a> { }; } } - Some(QuerySerialization::Unsupported { .. }) | None => quote! { + Some( + QuerySerialization::Unsupported { .. } + | QuerySerialization::SimpleHeaderArray { .. }, + ) + | None => quote! { let #field_ident = __query_one(&__pairs, #wire_name)? .map(|raw| __decode_query_scalar(&raw, #wire_name)) .transpose()?; @@ -2701,6 +3585,22 @@ fn axum_custom_route( (route, dispatcher_fn) } +fn path_parameter_affixes<'a>(path: &'a str, parameter_name: &str) -> Option<(&'a str, &'a str)> { + let marker = format!("{{{parameter_name}}}"); + for segment in path.split('/') { + let Some(start) = segment.find(&marker) else { + continue; + }; + let prefix = &segment[..start]; + let suffix = &segment[start + marker.len()..]; + if prefix.is_empty() && suffix.is_empty() { + return None; + } + return Some((prefix, suffix)); + } + None +} + /// Validate and convert an OpenAPI path template into Axum 0.8 route syntax. /// /// Both formats use `{parameter}` for a dynamic segment. Axum only supports a @@ -2716,8 +3616,10 @@ fn openapi_to_axum_path(path: &str) -> Result { if !path.starts_with('/') { return Err(invalid("paths must start with `/`")); } + let mut route_segments = Vec::new(); for segment in path.split('/').skip(1) { if segment.is_empty() { + route_segments.push(String::new()); continue; } if segment.starts_with(':') || segment.starts_with('*') { @@ -2729,29 +3631,44 @@ fn openapi_to_axum_path(path: &str) -> Result { let has_open = segment.contains('{'); let has_close = segment.contains('}'); if has_open || has_close { - let Some(name) = segment - .strip_prefix('{') - .and_then(|value| value.strip_suffix('}')) - else { + let Some(open) = segment.find('{') else { + return Err(invalid( + "path parameter has a closing brace without an opening brace", + )); + }; + let Some(relative_close) = segment[open + 1..].find('}') else { return Err(invalid( - "path parameters must occupy a complete segment such as `{pet_id}`", + "path parameter has an opening brace without a closing brace", )); }; + let close = open + 1 + relative_close; + let name = &segment[open + 1..close]; + let prefix = &segment[..open]; + let suffix = &segment[close + 1..]; if name.is_empty() || name.contains(['{', '}']) { return Err(invalid( "path parameter names must be non-empty and cannot contain braces", )); } + if prefix.contains(['{', '}']) || suffix.contains(['{', '}']) { + return Err(invalid( + "embedded path segments may contain exactly one parameter", + )); + } + route_segments.push(format!("{{{name}}}")); + } else { + route_segments.push(segment.to_string()); } } - Ok(path.to_string()) + Ok(format!("/{}", route_segments.join("/"))) } fn body_type(op: &OperationInfo) -> Option { match &op.request_body { Some(RequestBodyContent::Json { schema_name, .. }) - | Some(RequestBodyContent::FormUrlEncoded { schema_name, .. }) => Some(schema_name.clone()), + | Some(RequestBodyContent::FormUrlEncoded { schema_name, .. }) + | Some(RequestBodyContent::Multipart { schema_name, .. }) => Some(schema_name.clone()), Some(RequestBodyContent::OctetStream { .. } | RequestBodyContent::Binary { .. }) => { Some("bytes::Bytes".to_string()) } @@ -2812,6 +3729,41 @@ fn validate_custom_method_route_groups(ops: &[&OperationInfo]) -> Result<(), Ser Ok(()) } +fn canonical_axum_route_shape(path: &str) -> String { + path.split('/') + .map(|segment| { + if segment.starts_with('{') && segment.ends_with('}') { + "{}" + } else { + segment + } + }) + .collect::>() + .join("/") +} + +fn validate_normalized_route_collisions(ops: &[&OperationInfo]) -> Result<(), ServerCodegenError> { + let mut seen: BTreeMap<(String, String), &str> = BTreeMap::new(); + for op in ops { + let normalized = openapi_to_axum_path(&op.path)?; + let key = ( + canonical_axum_route_shape(&normalized), + op.method.to_ascii_uppercase(), + ); + if let Some(previous) = seen.insert(key, &op.path) + && previous != op.path + { + return Err(ServerCodegenError::InvalidRoutePath { + path: op.path.clone(), + reason: format!( + "normalizes to the same Axum route and method as `{previous}`; embedded path affixes must remain unambiguous" + ), + }); + } + } + Ok(()) +} + fn trait_ident_for_tag(tag: &str) -> syn::Ident { let pascal = tag.to_pascal_case(); let base = if pascal.is_empty() { @@ -2954,6 +3906,24 @@ mod tests { assert_eq!(id.to_string(), "ResponsesApi"); } + #[test] + fn embedded_path_affix_route_collisions_are_rejected() { + let json = OperationInfo { + operation_id: "json".into(), + method: "get".into(), + path: "/specs/{id}.json".into(), + ..Default::default() + }; + let yaml = OperationInfo { + operation_id: "yaml".into(), + method: "get".into(), + path: "/specs/{name}.yaml".into(), + ..Default::default() + }; + let error = validate_normalized_route_collisions(&[&json, &yaml]).unwrap_err(); + assert!(error.to_string().contains("same Axum route"), "{error}"); + } + #[test] fn untagged_falls_back_to_server_api() { let id = trait_ident_for_tag(""); @@ -3018,13 +3988,30 @@ mod tests { "/pets/{pet_id}" ); assert_eq!(openapi_to_axum_path("/").unwrap(), "/"); + assert_eq!( + openapi_to_axum_path("/specs/{provider}/{api}.json").unwrap(), + "/specs/{provider}/{api}" + ); + } + + #[test] + fn embedded_path_parameter_affixes_are_preserved_for_extraction() { + assert_eq!( + path_parameter_affixes("/specs/{provider}/{api}.json", "api"), + Some(("", ".json")) + ); + assert_eq!( + path_parameter_affixes("/{provider}.json", "provider"), + Some(("", ".json")) + ); + assert_eq!(path_parameter_affixes("/pets/{id}", "id"), None); } #[test] fn malformed_or_unsupported_route_templates_are_rejected() { for path in [ "pets/{pet_id}", - "/pets/{pet_id}.json", + "/pets/{first}-{second}", "/pets/{}", "/pets/:id", ] { diff --git a/src/server/validation.rs b/src/server/validation.rs index c2734d5..98499cc 100644 --- a/src/server/validation.rs +++ b/src/server/validation.rs @@ -111,6 +111,9 @@ pub(crate) fn prepare_validation_bundle( } | RequestBodyContent::FormUrlEncoded { validation_schema, .. + } + | RequestBodyContent::Multipart { + validation_schema, .. } => Some(validation_schema), _ => None, }; diff --git a/src/type_mapping.rs b/src/type_mapping.rs index 5f8f0a6..a2226c5 100644 --- a/src/type_mapping.rs +++ b/src/type_mapping.rs @@ -373,6 +373,9 @@ pub fn collect_generated_dep_requirements<'a>( } if uses("axum::") { let mut features = vec!["json"]; + if uses("axum::extract::Multipart") { + features.push("multipart"); + } if uses("axum::response::sse::") { features.push("tokio"); } diff --git a/tests/generation_requirements_test.rs b/tests/generation_requirements_test.rs index c3e9852..4f6c115 100644 --- a/tests/generation_requirements_test.rs +++ b/tests/generation_requirements_test.rs @@ -1,6 +1,6 @@ -use openapi_to_rust::config::ServerSection; +use openapi_to_rust::config::{ServerSection, ServerValidationSection}; use openapi_to_rust::streaming::{StreamingConfig, StreamingEndpoint}; -use openapi_to_rust::type_mapping::{DurationStrategy, TypeMappingConfig}; +use openapi_to_rust::type_mapping::{BinaryStrategy, DurationStrategy, TypeMappingConfig}; use openapi_to_rust::{CodeGenerator, GeneratorConfig, RetryConfig, SchemaAnalyzer, TypeMapper}; use serde_json::json; use std::collections::BTreeSet; @@ -49,9 +49,15 @@ fn requirements_spec() -> serde_json::Value { "responses": { "204": { "description": "ok" } } } }, - "/upload": { + "/upload/{id}.json": { "post": { "operationId": "uploadPayload", + "parameters": [{ + "name": "id", + "in": "path", + "required": true, + "schema": { "type": "string" } + }], "requestBody": { "required": true, "content": { @@ -266,6 +272,62 @@ fn disabled_sse_feature_does_not_emit_streaming_code_or_dependencies() { assert!(!dependency_names(&result).contains("futures-util")); } +#[test] +fn multipart_server_enables_axum_multipart_feature() { + let result = compile_case( + "multipart-server", + GeneratorConfig { + enable_async_client: false, + enable_sse_client: false, + tracing_enabled: false, + server: Some(ServerSection { + framework: "axum".into(), + operations: vec!["uploadPayload".into()], + prune_models: false, + validation: Default::default(), + }), + ..Default::default() + }, + ); + let axum = result + .required_deps + .iter() + .find(|dependency| dependency.crate_name == "axum") + .expect("axum dependency"); + assert_eq!(axum.features, vec!["json", "multipart"]); +} + +#[test] +fn multipart_client_and_server_compile_for_every_binary_strategy() { + for (name, binary) in [ + ("multipart-binary-bytes", BinaryStrategy::Bytes), + ("multipart-binary-vec", BinaryStrategy::VecU8), + ("multipart-binary-string", BinaryStrategy::String), + ] { + compile_case( + name, + GeneratorConfig { + enable_sse_client: false, + tracing_enabled: false, + types: TypeMappingConfig { + binary, + ..Default::default() + }, + server: Some(ServerSection { + framework: "axum".into(), + operations: vec!["uploadPayload".into()], + prune_models: false, + validation: ServerValidationSection { + enabled: false, + ..Default::default() + }, + }), + ..Default::default() + }, + ); + } +} + #[test] fn every_generation_mode_compiles_from_its_exact_dependency_fragment() { let types = compile_case( diff --git a/tests/operation_extraction_test.rs b/tests/operation_extraction_test.rs index 3e07705..8bfc596 100644 --- a/tests/operation_extraction_test.rs +++ b/tests/operation_extraction_test.rs @@ -424,11 +424,22 @@ fn test_extract_multipart_body() { .operations .get("uploadFile") .expect("uploadFile operation not found"); - assert!(op.request_body.is_some()); - assert!(matches!( - op.request_body.as_ref().unwrap(), - RequestBodyContent::Multipart - )); + let request_body = op.request_body.as_ref().unwrap(); + let RequestBodyContent::Multipart { + schema_name, + media_type, + validation_schema, + } = request_body + else { + panic!("expected typed multipart request body, got {request_body:?}"); + }; + assert_eq!(schema_name, "UploadFileRequest"); + assert_eq!(media_type, "multipart/form-data"); + assert_eq!( + validation_schema.pointer("/properties/file/format"), + Some(&serde_json::Value::String("binary".to_string())) + ); + assert_eq!(request_body.schema_name(), Some("UploadFileRequest")); } #[test] diff --git a/tests/operation_generation_test.rs b/tests/operation_generation_test.rs index c72d731..f69280b 100644 --- a/tests/operation_generation_test.rs +++ b/tests/operation_generation_test.rs @@ -1,5 +1,7 @@ +use openapi_to_rust::SchemaAnalyzer; use openapi_to_rust::analysis::{OperationInfo, RequestBodyContent, SchemaAnalysis}; use openapi_to_rust::generator::{CodeGenerator, GeneratorConfig}; +use serde_json::json; use std::collections::BTreeMap; fn create_test_config() -> GeneratorConfig { @@ -815,28 +817,34 @@ fn test_generate_multipart_operation() { let config = create_test_config(); let generator = CodeGenerator::new(config); - let operation = OperationInfo { - operation_id: "uploadFile".to_string(), - method: "POST".to_string(), - path: "/upload".to_string(), - summary: None, - description: None, - request_body: Some(RequestBodyContent::Multipart), - response_schemas: BTreeMap::new(), - parameters: vec![], - request_body_required: true, - supports_streaming: false, - stream_parameter: None, - tags: Vec::new(), - }; - - let analysis = create_test_analysis_with_operations(vec![operation]); + let analysis = SchemaAnalyzer::new(json!({ + "openapi": "3.1.0", + "info": { "title": "multipart", "version": "1" }, + "paths": { "/upload": { "post": { + "operationId": "uploadFile", + "requestBody": { "required": true, "content": { + "multipart/form-data": { "schema": { + "type": "object", + "required": ["file", "count"], + "properties": { + "file": { "type": "string", "format": "binary" }, + "count": { "type": "integer" }, + "display-name": { "type": "string" } + } + }} + }}, + "responses": { "204": { "description": "ok" } } + }}} + })) + .unwrap() + .analyze() + .unwrap(); let result = generator.generate_operation_methods(&analysis); let result_str = result.to_string(); - // Verify parameter is reqwest multipart form - assert!(result_str.contains("form : reqwest :: multipart :: Form")); - // Verify .multipart(form) call + assert!(result_str.contains("request : UploadFileRequest")); + assert!(result_str.contains("Part :: bytes")); + assert!(result_str.contains("\"display-name\"")); assert!(result_str.contains(". multipart (form)")); } diff --git a/tests/server_body_validation_test.rs b/tests/server_body_validation_test.rs index 9f591e8..4643a1b 100644 --- a/tests/server_body_validation_test.rs +++ b/tests/server_body_validation_test.rs @@ -122,6 +122,98 @@ fn xml_request_media_is_accepted_as_text_during_server_generation() { .expect("application/xml request bodies are text-decodable"); } +#[test] +fn typed_multipart_request_generates_axum_extraction() { + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "multipart body", "version": "1" }, + "paths": { "/upload": { "post": { + "operationId": "uploadFile", + "requestBody": { "required": true, "content": { + "multipart/form-data": { "schema": { + "type": "object", + "additionalProperties": false, + "required": ["file", "count", "enabled"], + "properties": { + "file": { "type": "string", "format": "binary" }, + "count": { "type": "integer", "format": "uint64", "minimum": 1 }, + "enabled": { "type": "boolean" }, + "display-name": { "type": "string", "minLength": 2 } + } + }} + }}, + "responses": { "204": { "description": "accepted" } } + }}} + }); + let analysis = SchemaAnalyzer::new(spec).unwrap().analyze().unwrap(); + let server = ServerSection { + framework: "axum".into(), + operations: vec!["uploadFile".into()], + prune_models: true, + validation: Default::default(), + }; + let config = GeneratorConfig { + server: Some(server.clone()), + ..Default::default() + }; + + let generated = ServerCodegen::new(&config, &analysis, &server) + .generate() + .expect("flat typed multipart schemas should generate"); + let api = &generated + .iter() + .find(|file| file.path.ends_with("api.rs")) + .expect("api.rs") + .content; + let router = &generated + .iter() + .find(|file| file.path.ends_with("router.rs")) + .expect("router.rs") + .content; + assert!(api.contains("UploadFileRequest"), "{api}"); + assert!(router.contains("::axum::extract::Multipart"), "{router}"); + assert!(router.contains("DefaultBodyLimit::max"), "{router}"); + assert!(router.contains("display-name"), "{router}"); + assert!(router.contains("parse::"), "{router}"); + assert!(router.contains("__multipart_binary_file"), "{router}"); + assert!(!router.contains("bytes.iter()"), "{router}"); +} + +#[test] +fn typed_multipart_additional_properties_are_rejected() { + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "typed multipart map", "version": "1" }, + "paths": { "/upload": { "post": { + "operationId": "uploadMap", + "requestBody": { "required": true, "content": { + "multipart/form-data": { "schema": { + "type": "object", + "properties": { "name": { "type": "string" } }, + "additionalProperties": { "type": "string" } + }} + }}, + "responses": { "204": { "description": "accepted" } } + }}} + }); + let analysis = SchemaAnalyzer::new(spec).unwrap().analyze().unwrap(); + let server = ServerSection { + framework: "axum".into(), + operations: vec!["uploadMap".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("additionalProperties"), "{error}"); +} + #[test] fn unsupported_request_media_is_rejected_during_server_generation() { let spec = json!({ @@ -155,6 +247,41 @@ fn unsupported_request_media_is_rejected_during_server_generation() { assert!(error.contains("application/x-proprietary"), "{error}"); } +#[test] +fn cyclic_multipart_aliases_are_rejected_without_recursing() { + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "cyclic multipart", "version": "1" }, + "paths": { "/upload": { "post": { + "operationId": "uploadCycle", + "requestBody": { "required": true, "content": { + "multipart/form-data": { "schema": { "$ref": "#/components/schemas/A" } } + }}, + "responses": { "204": { "description": "unused" } } + }}}, + "components": { "schemas": { + "A": { "$ref": "#/components/schemas/B" }, + "B": { "$ref": "#/components/schemas/A" } + }} + }); + let analysis = SchemaAnalyzer::new(spec).unwrap().analyze().unwrap(); + let server = ServerSection { + framework: "axum".into(), + operations: vec!["uploadCycle".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("reference cycle"), "{error}"); +} + #[test] fn schema_less_request_content_is_rejected_during_server_generation() { let spec = json!({ diff --git a/tests/server_multipart_roundtrip_test.rs b/tests/server_multipart_roundtrip_test.rs new file mode 100644 index 0000000..daee6e5 --- /dev/null +++ b/tests/server_multipart_roundtrip_test.rs @@ -0,0 +1,193 @@ +use openapi_to_rust::config::{ServerSection, ServerValidationSection}; +use openapi_to_rust::{CodeGenerator, GeneratorConfig, SchemaAnalyzer}; +use serde_json::json; +use std::process::Command; + +#[test] +fn generated_client_and_server_round_trip_typed_multipart() { + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "multipart roundtrip", "version": "1" }, + "paths": { "/upload": { "post": { + "operationId": "uploadFile", + "requestBody": { "required": true, "content": { + "multipart/form-data": { "schema": { + "type": "object", + "additionalProperties": false, + "required": ["file", "count", "enabled"], + "properties": { + "file": { "type": "string", "format": "binary" }, + "count": { "type": "integer", "format": "uint64", "minimum": 1 }, + "enabled": { "type": "boolean" }, + "display-name": { "type": "string", "minLength": 2 } + } + }} + }}, + "responses": { "204": { "description": "accepted" } } + }}} + }); + let temp = tempfile::TempDir::new().expect("temp crate"); + let output_dir = temp.path().join("src/generated"); + let config = GeneratorConfig { + output_dir: output_dir.clone(), + module_name: "multipart_roundtrip".into(), + enable_async_client: true, + tracing_enabled: false, + server: Some(ServerSection { + framework: "axum".into(), + operations: vec!["uploadFile".into()], + prune_models: true, + validation: ServerValidationSection { + enabled: true, + max_body_bytes: 1024, + max_errors: 4, + }, + }), + ..Default::default() + }; + let mut analysis = SchemaAnalyzer::new(spec).unwrap().analyze().unwrap(); + let generator = CodeGenerator::new(config); + let result = generator.generate_all(&mut analysis).expect("generation"); + generator + .write_files(&result) + .expect("write generated files"); + + let deps = std::fs::read_to_string(output_dir.join("REQUIRED_DEPS.toml")).unwrap(); + std::fs::create_dir_all(temp.path().join("src")).unwrap(); + std::fs::write( + temp.path().join("Cargo.toml"), + format!( + "[package]\nname = \"generated-multipart-roundtrip\"\nversion = \"0.0.0\"\nedition = \"2024\"\npublish = false\n\n{deps}\n[dev-dependencies]\naxum = {{ version = \"0.8\", features = [\"tokio\", \"http1\", \"multipart\"] }}\ntokio = {{ version = \"1\", features = [\"macros\", \"rt-multi-thread\", \"net\", \"sync\"] }}\n" + ), + ) + .unwrap(); + std::fs::write( + temp.path().join("src/lib.rs"), + r#"pub mod generated; + +#[cfg(test)] +mod tests { + use super::generated::*; + use tokio::sync::mpsc::{unbounded_channel, UnboundedSender}; + + #[derive(Clone)] + struct Api { + captured: UnboundedSender<(Vec, u64, bool, Option)>, + } + + #[async_trait::async_trait] + impl ServerApi for Api { + async fn upload_file(&self, body: UploadFileRequest) -> UploadFileResponse { + self.captured.send(( + body.file.to_vec(), + body.count, + body.enabled, + body.display_name, + )).unwrap(); + UploadFileResponse::NoContent + } + } + + #[tokio::test] + async fn multipart_contract() { + let (tx, mut rx) = unbounded_channel(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, server_api_router(Api { captured: tx })).await.unwrap(); + }); + let base_url = format!("http://{address}"); + let client = HttpClient::new().with_base_url(base_url.clone()); + let payload = vec![0, 1, 2, 255]; + let request = UploadFileRequest { + file: bytes::Bytes::from(payload.clone()), + count: 9_223_372_036_854_775_808_u64, + enabled: true, + display_name: Some("demo".into()), + }; + client.upload_file(request).await.unwrap(); + assert_eq!( + rx.recv().await.unwrap(), + ( + payload, + 9_223_372_036_854_775_808_u64, + true, + Some("demo".into()) + ) + ); + + let invalid_typed = reqwest::multipart::Form::new() + .part("file", reqwest::multipart::Part::bytes(vec![1])) + .text("count", "0") + .text("enabled", "true") + .text("display-name", "x"); + let response = reqwest::Client::new() + .post(format!("{base_url}/upload")) + .multipart(invalid_typed) + .send().await.unwrap(); + assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST); + + let duplicate = reqwest::multipart::Form::new() + .text("file", "a") + .text("file", "b") + .text("count", "7") + .text("enabled", "true"); + let response = reqwest::Client::new() + .post(format!("{base_url}/upload")) + .multipart(duplicate) + .send().await.unwrap(); + assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST); + + let constraint_violation = reqwest::multipart::Form::new() + .part("file", reqwest::multipart::Part::bytes(vec![1, 2, 3])) + .text("count", "0") + .text("enabled", "true") + .text("display-name", "x"); + let response = reqwest::Client::new() + .post(format!("http://{address}/upload")) + .multipart(constraint_violation) + .send() + .await + .unwrap(); + assert_eq!( + response.status(), + reqwest::StatusCode::UNPROCESSABLE_ENTITY + ); + + let oversized = reqwest::multipart::Form::new() + .part("file", reqwest::multipart::Part::bytes(vec![7; 2048])) + .text("count", "7") + .text("enabled", "true"); + let response = reqwest::Client::new() + .post(format!("{base_url}/upload")) + .multipart(oversized) + .send().await.unwrap(); + assert_eq!(response.status(), reqwest::StatusCode::PAYLOAD_TOO_LARGE); + + server.abort(); + } +} +"#, + ) + .unwrap(); + + let output = Command::new("cargo") + .args([ + "test", + "--lib", + "multipart_contract", + "--", + "--exact", + "--nocapture", + ]) + .current_dir(temp.path()) + .env("CARGO_TARGET_DIR", "target/generated-multipart-roundtrip") + .output() + .expect("scratch cargo test"); + assert!( + output.status.success(), + "generated multipart roundtrip failed:\n{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/tests/server_parameter_media_validation_test.rs b/tests/server_parameter_media_validation_test.rs index 286e883..745d1a7 100644 --- a/tests/server_parameter_media_validation_test.rs +++ b/tests/server_parameter_media_validation_test.rs @@ -197,6 +197,68 @@ mod tests { ); } +#[test] +fn simple_array_header_is_typed_for_client_and_server() { + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "array header", "version": "1" }, + "paths": { "/attributes": { "get": { + "operationId": "getAttributes", + "parameters": [{ + "name": "x-object-attributes", + "in": "header", + "required": true, + "schema": { + "type": "array", + "items": { "$ref": "#/components/schemas/ObjectAttribute" } + } + }], + "responses": { "204": { "description": "ok" } } + }}}, + "components": { "schemas": { + "ObjectAttribute": { + "type": "string", + "enum": ["ETag", "ObjectSize"] + } + }} + }); + let mut analysis = SchemaAnalyzer::new(spec).unwrap().analyze().unwrap(); + let server = ServerSection { + framework: "axum".into(), + operations: vec!["getAttributes".into()], + prune_models: true, + validation: Default::default(), + }; + let config = GeneratorConfig { + server: Some(server.clone()), + ..Default::default() + }; + let files = ServerCodegen::new(&config, &analysis, &server) + .generate() + .expect("simple array headers should generate"); + let joined = files + .into_iter() + .map(|file| file.content) + .collect::(); + assert!(joined.contains("Vec"), "{joined}"); + assert!(joined.contains("split(',')"), "{joined}"); + + let generator = CodeGenerator::new(config); + let generated = generator.generate_all(&mut analysis).unwrap(); + let client = generated + .files + .iter() + .find(|file| file.path.ends_with("client.rs")) + .expect("generated client"); + assert!( + client + .content + .contains("x_object_attributes: Vec"), + "{}", + client.content + ); +} + #[test] fn raw_parameter_schema_preserves_large_integer_constraints() { let maximum = 9_007_199_254_740_993_u64; diff --git a/tests/server_query_roundtrip_test.rs b/tests/server_query_roundtrip_test.rs index 390184d..95f06e7 100644 --- a/tests/server_query_roundtrip_test.rs +++ b/tests/server_query_roundtrip_test.rs @@ -4,6 +4,7 @@ //! a generated `HttpClient` sends requests to a real generated Axum router and //! the trait implementation observes the original typed values. +use openapi_to_rust::analysis::QuerySerialization; use openapi_to_rust::config::ServerSection; use openapi_to_rust::server::codegen::ServerCodegen; use openapi_to_rust::{CodeGenerator, GeneratorConfig, SchemaAnalyzer}; @@ -13,7 +14,7 @@ use std::process::Command; fn round_trip_spec() -> Value { json!({ "openapi": "3.1.0", - "info": { "title": "query round trip", "version": "1.0.0" }, + "info": { "title": "query round trip", "version": "1.0.0", "x-providerName": "amazonaws.com" }, "paths": { "/round-trip/{scope}": { "get": { @@ -63,6 +64,13 @@ fn round_trip_spec() -> Value { } } }, + { + "name": "export_configuration", + "in": "query", + "style": "form", + "explode": true, + "schema": { "$ref": "#/components/schemas/ExportConfiguration" } + }, { "name": "compact", "in": "query", @@ -117,6 +125,16 @@ fn round_trip_spec() -> Value { "type": "array", "items": { "$ref": "#/components/schemas/Tag" } } + }, + { + "name": "tag_specifications", + "in": "query", + "style": "form", + "explode": true, + "schema": { + "type": "array", + "items": { "$ref": "#/components/schemas/TagSpecification" } + } } ], "responses": { "204": { "description": "captured" } } @@ -140,12 +158,111 @@ fn round_trip_spec() -> Value { "Key": { "type": "string" }, "Value": { "type": "string" } } + }, + "ResourceType": { + "type": "string", + "enum": ["instance", "volume"] + }, + "TagSpecification": { + "type": "object", + "required": ["Type", "Tags"], + "properties": { + "Type": { "$ref": "#/components/schemas/ResourceType" }, + "Tags": { + "type": "array", + "items": { "$ref": "#/components/schemas/Tag" } + } + } + }, + "ExportConfiguration": { + "type": "object", + "required": ["EnableLogTypes", "Settings"], + "properties": { + "EnableLogTypes": { + "type": "array", + "items": { "type": "string" } + }, + "Settings": { "$ref": "#/components/schemas/ExportSettings" } + } + }, + "ExportSettings": { + "type": "object", + "required": ["Enabled"], + "properties": { "Enabled": { "type": "boolean" } } } } } }) } +#[test] +fn nested_aws_query_shape_requires_explicit_provider_metadata() { + let mut spec = round_trip_spec(); + spec.pointer_mut("/info") + .and_then(Value::as_object_mut) + .unwrap() + .remove("x-providerName"); + let analysis = SchemaAnalyzer::new(spec).unwrap().analyze().unwrap(); + let parameter = analysis.operations["queryRoundTrip"] + .parameters + .iter() + .find(|parameter| parameter.name == "export_configuration") + .unwrap(); + assert!(matches!( + parameter.query_serialization, + Some(QuerySerialization::FormExplodedObject) + )); +} + +#[test] +fn query_shapes_beyond_the_explicit_nesting_bound_are_rejected() { + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "too deep query", "version": "1", "x-providerName": "amazonaws.com" }, + "paths": { "/too-deep": { "get": { + "operationId": "tooDeep", + "parameters": [{ + "name": "items", + "in": "query", + "style": "form", + "explode": true, + "schema": { + "type": "array", + "items": { "$ref": "#/components/schemas/Outer" } + } + }], + "responses": { "204": { "description": "ok" } } + }}}, + "components": { "schemas": { + "Outer": { + "type": "object", + "properties": { + "Middle": { + "type": "array", + "items": { "$ref": "#/components/schemas/Middle" } + } + } + }, + "Middle": { + "type": "object", + "properties": { + "Deeper": { + "type": "array", + "items": { "type": "string" } + } + } + } + }} + }); + let analysis = SchemaAnalyzer::new(spec).unwrap().analyze().unwrap(); + let parameter = &analysis.operations["tooDeep"].parameters[0]; + assert!(matches!( + ¶meter.query_serialization, + Some(QuerySerialization::Unsupported { reason }) + if reason.contains("supported nesting bound") + )); +} + #[test] fn generated_client_and_server_round_trip_typed_query_parameters() { let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); @@ -234,11 +351,13 @@ mod tests { active: Option, required_expanded: QueryRoundTripRequiredExpanded, optional_expanded: Option, + export_configuration: Option, compact: QueryRoundTripCompact, deep_filter: Option, ids: Vec, scores: Option>, tags: Option>, + tag_specifications: Option>, ) -> QueryRoundTripResponse { self.captured .send(json!({ @@ -247,11 +366,13 @@ mod tests { "active": active, "required_expanded": required_expanded, "optional_expanded": optional_expanded, + "export_configuration": export_configuration, "compact": compact, "deep_filter": deep_filter, "ids": ids, "scores": scores, "tags": tags, + "tag_specifications": tag_specifications, })) .unwrap(); QueryRoundTripResponse::NoContent @@ -286,6 +407,10 @@ mod tests { term: Some("rust sdk".into()), archived: Some(false), }), + Some(ExportConfiguration { + enable_log_types: vec!["audit".into(), "profiler".into()], + settings: ExportSettings { enabled: true }, + }), QueryRoundTripCompact { kind: Some("public".into()), count: Some(3), @@ -296,16 +421,14 @@ 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(), - }, - ]), + Some((1..=10).map(|index| Tag { + key: format!("key-{index}"), + value: format!("value-{index}"), + }).collect()), + Some(vec![TagSpecification { + r#type: ResourceType::Instance, + tags: Vec::new(), + }]), ) .await .unwrap(); @@ -324,14 +447,22 @@ mod tests { "active": true, "required_expanded": { "color": "red", "min_count": 2 }, "optional_expanded": { "term": "rust sdk", "archived": false }, + "export_configuration": { + "EnableLogTypes": ["audit", "profiler"], + "Settings": { "Enabled": true } + }, "compact": { "kind": "public", "count": 3 }, "deep_filter": { "owner": "alice/bob", "open": true }, "ids": [11, 12], "scores": [8, 9], - "tags": [ - { "Key": "env", "Value": "prod" }, - { "Key": "team", "Value": "sdk" } - ], + "tags": (1..=10).map(|index| json!({ + "Key": format!("key-{index}"), + "Value": format!("value-{index}"), + })).collect::>(), + "tag_specifications": [{ + "Type": "instance", + "Tags": [] + }], }) ); @@ -342,11 +473,16 @@ mod tests { None, QueryRoundTripRequiredExpanded::default(), Some(QueryRoundTripOptionalExpanded::default()), + Some(ExportConfiguration { + enable_log_types: Vec::new(), + settings: ExportSettings { enabled: false }, + }), QueryRoundTripCompact::default(), Some(QueryRoundTripDeepFilter::default()), Vec::new(), Some(Vec::new()), Some(Vec::new()), + Some(Vec::new()), ) .await .unwrap(); @@ -365,11 +501,16 @@ mod tests { "active": null, "required_expanded": {}, "optional_expanded": {}, + "export_configuration": { + "EnableLogTypes": [], + "Settings": { "Enabled": false } + }, "compact": {}, "deep_filter": {}, "ids": [], "scores": [], "tags": [], + "tag_specifications": [], }) ); @@ -390,6 +531,7 @@ mod tests { None, QueryRoundTripRequiredExpanded::default(), None, + None, QueryRoundTripCompact { kind: Some("public,private".into()), count: None, @@ -398,6 +540,7 @@ mod tests { vec![1], None, None, + None, ) .await .unwrap_err(); @@ -437,7 +580,7 @@ fn bad_query_spec(parameter: Value, extra_parameter: Option) -> Value { parameters.extend(extra_parameter); json!({ "openapi": "3.1.0", - "info": { "title": "bad query", "version": "1.0.0" }, + "info": { "title": "bad query", "version": "1.0.0", "x-providerName": "amazonaws.com" }, "paths": { "/bad": { "get": { "operationId": "badQuery", @@ -504,7 +647,15 @@ fn unsupported_query_wire_shapes_fail_server_generation_with_context() { "schema": { "type": "object", "properties": { - "nested": { "type": "object", "properties": { "name": { "type": "string" } } } + "nested": { + "type": "object", + "properties": { + "deeper": { + "type": "object", + "properties": { "name": { "type": "string" } } + } + } + } } } }),