Skip to content

Commit e863824

Browse files
committed
Improve OpenAPI 3.2.0 compliance
1 parent a51ac04 commit e863824

File tree

15 files changed

+1261
-117
lines changed

15 files changed

+1261
-117
lines changed

core/src/contract_test_generator.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,10 +123,11 @@ fn generate_test_fn(
123123
code.push_str(&strategy.test_assertion());
124124
code.push('\n');
125125

126-
// 5. Schema Validation (Commented out placeholder usually)
126+
// 5. Schema Validation
127+
// Active validation now enabled via strategy helper
127128
code.push_str(" // 5. Schema Validation\n");
128129
code.push_str(&format!(
129-
" // validate_response(resp, \"{}\", \"{}\").await;\n",
130+
" validate_response(resp, \"{}\", \"{}\").await;\n",
130131
route.method.to_uppercase(),
131132
route.path
132133
));
@@ -195,6 +196,8 @@ mod tests {
195196
assert!(code.contains("const OPENAPI_PATH: &str = \"tests/openapi.yaml\";"));
196197
// Check factory call
197198
assert!(code.contains("crate::create_app(App::new())"));
199+
// Check active validation call is present and uncommented
200+
assert!(code.contains("validate_response(resp, \"GET\", \"/users/{id}\").await;"));
198201
}
199202

200203
#[test]
@@ -244,6 +247,7 @@ mod tests {
244247
request_body: Some(RequestBodyDefinition {
245248
ty: "Item".into(),
246249
format: BodyFormat::Json,
250+
encoding: None,
247251
}),
248252
security: vec![],
249253
response_type: None,

core/src/handler_generator/extractors.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ mod tests {
139139
request_body: Some(RequestBodyDefinition {
140140
ty: "SearchFilter".into(),
141141
format: BodyFormat::Json,
142+
encoding: None,
142143
}),
143144
security: vec![],
144145
response_type: None,
@@ -170,6 +171,9 @@ mod tests {
170171
};
171172
let strategy = ActixStrategy;
172173
let code = update_handler_module("", &[route], &strategy).unwrap();
173-
assert!(code.contains("_auth: web::ReqData<ApiKey>"));
174+
175+
// Updated expectation: ActixStrategy now prefixes with security::
176+
// e.g. _auth: web::ReqData<security::ApiKey>
177+
assert!(code.contains("_auth: web::ReqData<security::ApiKey>"));
174178
}
175179
}

core/src/oas/models.rs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
//! # OpenAPI Models
44
//!
5-
//! definitions for Intermediate Representation of OpenAPI elements.
5+
//! definition of Intermediate Representation (IR) structures for parsed OpenAPI elements.
66
//!
77
//! These structs are used to transport parsed data from the YAML spec
88
//! into the code generation strategies.
@@ -67,10 +67,14 @@ pub struct SecurityRequirement {
6767
/// Definition of a request body type and format.
6868
#[derive(Debug, Clone, PartialEq)]
6969
pub struct RequestBodyDefinition {
70-
/// The Rust type name (e.g. "CreateUserRequest")
70+
/// The Rust type name (e.g. "CreateUserRequest").
7171
pub ty: String,
72-
/// The format of the body (JSON, Form, etc.)
72+
/// The format of the body (JSON, Form, etc.).
7373
pub format: BodyFormat,
74+
/// Multipart/Form Encoding details.
75+
/// Maps property name -> Content-Type (e.g. "profileImage" -> "image/png").
76+
/// Only populated if format is Multipart or Form.
77+
pub encoding: Option<std::collections::HashMap<String, String>>,
7478
}
7579

7680
/// Supported body content types.
@@ -80,7 +84,7 @@ pub enum BodyFormat {
8084
Json,
8185
/// application/x-www-form-urlencoded
8286
Form,
83-
/// multipart/form-data
87+
/// multipart/form-data or multipart/mixed
8488
Multipart,
8589
}
8690

core/src/oas/resolver/body.rs

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@
33
//! # Body Resolution
44
//!
55
//! Logic for extracting request body types from OpenAPI definitions.
6+
//! Support for OAS 3.2 `encoding` definitions in multipart and url-encoded forms.
67
78
use crate::error::AppResult;
89
use crate::oas::models::{BodyFormat, RequestBodyDefinition};
910
use crate::oas::resolver::types::map_schema_to_rust_type;
11+
use std::collections::HashMap;
12+
use utoipa::openapi::encoding::Encoding;
1013
use utoipa::openapi::request_body::RequestBody;
1114
use utoipa::openapi::RefOr;
1215

@@ -19,38 +22,151 @@ pub fn extract_request_body_type(
1922
RefOr::Ref(_) => return Ok(None),
2023
};
2124

25+
// 1. JSON
2226
if let Some(media) = content.get("application/json") {
2327
if let Some(schema_ref) = &media.schema {
2428
let type_str = map_schema_to_rust_type(schema_ref, true)?;
2529
return Ok(Some(RequestBodyDefinition {
2630
ty: type_str,
2731
format: BodyFormat::Json,
32+
encoding: None,
2833
}));
2934
}
3035
}
3136

37+
// 2. Form URL Encoded
3238
if let Some(media) = content.get("application/x-www-form-urlencoded") {
3339
if let Some(schema_ref) = &media.schema {
3440
let type_str = map_schema_to_rust_type(schema_ref, true)?;
41+
let encoding = extract_encoding_map(&media.encoding);
42+
3543
return Ok(Some(RequestBodyDefinition {
3644
ty: type_str,
3745
format: BodyFormat::Form,
46+
encoding,
3847
}));
3948
}
4049
}
4150

42-
if let Some(media) = content.get("multipart/form-data") {
51+
// 3. Multipart
52+
// OAS 3.2 allows multipart/form-data, multipart/mixed, etc.
53+
// We check for any key starting with "multipart/"
54+
if let Some((_, media)) = content.iter().find(|(k, _)| k.starts_with("multipart/")) {
4355
let type_str = if let Some(schema_ref) = &media.schema {
4456
map_schema_to_rust_type(schema_ref, true)?
4557
} else {
4658
"Multipart".to_string()
4759
};
4860

61+
let encoding = extract_encoding_map(&media.encoding);
62+
4963
return Ok(Some(RequestBodyDefinition {
5064
ty: type_str,
5165
format: BodyFormat::Multipart,
66+
encoding,
5267
}));
5368
}
5469

5570
Ok(None)
5671
}
72+
73+
/// Helper to extract encoding map (property -> content-type).
74+
fn extract_encoding_map(
75+
encoding: &std::collections::BTreeMap<String, Encoding>,
76+
) -> Option<HashMap<String, String>> {
77+
if encoding.is_empty() {
78+
return None;
79+
}
80+
81+
let mut map = HashMap::new();
82+
for (prop, enc) in encoding {
83+
// According to OAS 3.2: contentType is string.
84+
// We capture it to allow specific part handling in Strategies.
85+
if let Some(ct) = &enc.content_type {
86+
map.insert(prop.clone(), ct.clone());
87+
}
88+
}
89+
90+
if map.is_empty() {
91+
None
92+
} else {
93+
Some(map)
94+
}
95+
}
96+
97+
#[cfg(test)]
98+
mod tests {
99+
use super::*;
100+
use utoipa::openapi::encoding::Encoding;
101+
use utoipa::openapi::request_body::RequestBodyBuilder;
102+
use utoipa::openapi::Content;
103+
104+
#[test]
105+
fn test_extract_json_body() {
106+
let body = RequestBodyBuilder::new()
107+
.content(
108+
"application/json",
109+
Content::new(Some(RefOr::Ref(utoipa::openapi::Ref::new(
110+
"#/components/schemas/User",
111+
)))),
112+
)
113+
.build();
114+
115+
let def = extract_request_body_type(&RefOr::T(body)).unwrap().unwrap();
116+
assert_eq!(def.ty, "User");
117+
assert_eq!(def.format, BodyFormat::Json);
118+
assert!(def.encoding.is_none());
119+
}
120+
121+
#[test]
122+
fn test_extract_multipart_with_encoding() {
123+
// Encoding::builder().content_type(...) takes Option<String>
124+
let png_encoding = Encoding::builder()
125+
.content_type(Some("image/png".to_string()))
126+
.build();
127+
let json_encoding = Encoding::builder()
128+
.content_type(Some("application/json".to_string()))
129+
.build();
130+
131+
// ContentBuilder::encoding takes (name, encoding) one by one
132+
let media = Content::builder()
133+
.schema(Some(RefOr::Ref(utoipa::openapi::Ref::new(
134+
"#/components/schemas/Upload",
135+
))))
136+
.encoding("profileImage", png_encoding)
137+
.encoding("metadata", json_encoding)
138+
.build();
139+
140+
let body = RequestBodyBuilder::new()
141+
.content("multipart/form-data", media)
142+
.build();
143+
144+
let def = extract_request_body_type(&RefOr::T(body)).unwrap().unwrap();
145+
146+
assert_eq!(def.ty, "Upload");
147+
assert_eq!(def.format, BodyFormat::Multipart);
148+
149+
let enc = def.encoding.unwrap();
150+
assert_eq!(enc.get("profileImage"), Some(&"image/png".to_string()));
151+
assert_eq!(enc.get("metadata"), Some(&"application/json".to_string()));
152+
}
153+
154+
#[test]
155+
fn test_extract_form_no_encoding() {
156+
let request_body = RequestBodyBuilder::new()
157+
.content(
158+
"application/x-www-form-urlencoded",
159+
Content::new(Some(RefOr::Ref(utoipa::openapi::Ref::new(
160+
"#/components/schemas/Login",
161+
)))),
162+
)
163+
.build();
164+
165+
let def = extract_request_body_type(&RefOr::T(request_body))
166+
.unwrap()
167+
.unwrap();
168+
assert_eq!(def.ty, "Login");
169+
assert_eq!(def.format, BodyFormat::Form);
170+
assert!(def.encoding.is_none());
171+
}
172+
}

core/src/oas/resolver/params.rs

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
use crate::error::AppResult;
99
use crate::oas::models::{ParamSource, ParamStyle, RouteParam};
1010
use crate::oas::resolver::types::map_schema_to_rust_type;
11+
use crate::oas::routes::shims::ShimComponents;
1112
use serde::{Deserialize, Serialize};
12-
use serde_json::Value;
1313
use std::fmt;
1414
use utoipa::openapi::{schema::Schema, RefOr};
1515

@@ -57,7 +57,7 @@ impl fmt::Debug for ShimParameter {
5757
/// Resolves a list of OpenAPI parameters into internal `RouteParam` structs.
5858
pub fn resolve_parameters(
5959
params: &[RefOr<ShimParameter>],
60-
components: Option<&Value>,
60+
components: Option<&ShimComponents>,
6161
) -> AppResult<Vec<RouteParam>> {
6262
let mut result = Vec::new();
6363
for param_or_ref in params {
@@ -77,12 +77,13 @@ pub fn resolve_parameters(
7777
/// Helper to resolve a `Ref` to its target Parameter definition.
7878
fn resolve_parameter_ref(
7979
r: &utoipa::openapi::Ref,
80-
components: Option<&Value>,
80+
components: Option<&ShimComponents>,
8181
) -> Option<ShimParameter> {
8282
let ref_name = r.ref_location.split('/').next_back()?;
8383

8484
if let Some(comps) = components {
85-
if let Some(param_json) = comps.get("parameters").and_then(|p| p.get(ref_name)) {
85+
// Note: Generic components are now in `extra`.
86+
if let Some(param_json) = comps.extra.get("parameters").and_then(|p| p.get(ref_name)) {
8687
if let Ok(param) = serde_json::from_value::<ShimParameter>(param_json.clone()) {
8788
return Some(param);
8889
}
@@ -283,6 +284,8 @@ mod tests {
283284

284285
#[test]
285286
fn test_resolve_reusable_parameters() {
287+
// New structure requires generic components to be in 'extra' for legacy resolution.
288+
// ShimComponents handles this via flattening.
286289
let components_json = serde_json::json!({
287290
"parameters": {
288291
"limitParam": {
@@ -295,11 +298,13 @@ mod tests {
295298
}
296299
});
297300

301+
let components: ShimComponents = serde_json::from_value(components_json).unwrap();
302+
298303
let op_params = vec![RefOr::Ref(utoipa::openapi::Ref::new(
299304
"#/components/parameters/limitParam",
300305
))];
301306

302-
let resolved = resolve_parameters(&op_params, Some(&components_json)).unwrap();
307+
let resolved = resolve_parameters(&op_params, Some(&components)).unwrap();
303308

304309
assert_eq!(resolved.len(), 1);
305310
assert_eq!(resolved[0].name, "limit");

core/src/oas/resolver/responses.rs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@
66
77
use crate::error::AppResult;
88
use crate::oas::resolver::types::map_schema_to_rust_type;
9-
use serde_json::Value;
9+
use crate::oas::routes::shims::ShimComponents;
1010
use utoipa::openapi::{RefOr, Responses};
1111

1212
/// Extracts the success response type (200 OK or 201 Created).
1313
pub fn extract_response_success_type(
1414
responses: &Responses,
15-
components: Option<&Value>,
15+
components: Option<&ShimComponents>,
1616
) -> AppResult<Option<String>> {
1717
// Check 200 then 201
1818
let success = responses
@@ -41,11 +41,12 @@ pub fn extract_response_success_type(
4141

4242
fn resolve_response_from_components(
4343
r: &utoipa::openapi::Ref,
44-
components: Option<&Value>,
44+
components: Option<&ShimComponents>,
4545
) -> Option<utoipa::openapi::Response> {
4646
let ref_name = r.ref_location.split('/').next_back()?;
4747
if let Some(comps) = components {
48-
if let Some(resp_json) = comps.get("responses").and_then(|r| r.get(ref_name)) {
48+
// Access via 'extra' flattening for generic items
49+
if let Some(resp_json) = comps.extra.get("responses").and_then(|r| r.get(ref_name)) {
4950
if let Ok(resp) = serde_json::from_value::<utoipa::openapi::Response>(resp_json.clone())
5051
{
5152
return Some(resp);

core/src/oas/routes/builder.rs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,7 @@ use crate::oas::resolver::{
1212
};
1313
use crate::oas::routes::callbacks::{extract_callback_operations, resolve_callback_object};
1414
use crate::oas::routes::naming::{derive_handler_name, to_snake_case};
15-
use crate::oas::routes::shims::{ShimOperation, ShimPathItem};
16-
use serde_json::Value;
15+
use crate::oas::routes::shims::{ShimComponents, ShimOperation, ShimPathItem};
1716
use std::collections::HashMap;
1817

1918
/// Helper to iterate methods in a ShimPathItem and extract all operations as Routes.
@@ -30,7 +29,7 @@ pub fn parse_path_item(
3029
path_or_name: &str,
3130
path_item: ShimPathItem,
3231
kind: RouteKind,
33-
components: Option<&Value>,
32+
components: Option<&ShimComponents>,
3433
) -> AppResult<()> {
3534
// Handle common parameters defined at PathItem level.
3635
let common_params_list = path_item.parameters.as_deref().unwrap_or(&[]);
@@ -68,7 +67,7 @@ fn build_route(
6867
op: ShimOperation,
6968
common_params: &[RouteParam],
7069
kind: RouteKind,
71-
components: Option<&Value>,
70+
components: Option<&ShimComponents>,
7271
) -> AppResult<ParsedRoute> {
7372
// 1. Handler Name
7473
let handler_name = if let Some(op_id) = &op.operation_id {

0 commit comments

Comments
 (0)