Skip to content

Commit fc8f75a

Browse files
committed
Improve OpenAPI 3.2.0 compliance
1 parent 3ccc572 commit fc8f75a

27 files changed

+1391
-556
lines changed

core/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,6 @@ serde_yaml = "^0.9"
2121

2222
# OpenAPI definition types
2323
utoipa = { version = "^5.4", features = ["preserve_order", "indexmap", "uuid"] }
24+
25+
# Strict RFC 3986 URI resolution
26+
url = "2.5"

core/src/codegen.rs

Lines changed: 47 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
//! into valid, compilable Rust code. It handles:
99
//! - Dependency analysis (auto-injecting imports like `Uuid`, `chrono`, `serde`).
1010
//! - Attribute injection (`derive`, `serde` options, `deprecated`).
11+
//! - Formats `discriminator` mappings into Serde attributes (`tag`, `rename`, `alias`).
1112
//! - Formatting and comments preservation including external docs links.
1213
1314
use crate::error::{AppError, AppResult};
@@ -203,6 +204,15 @@ fn generate_enum_body(en: &ParsedEnum) -> String {
203204
"",
204205
));
205206

207+
if let Some(mapping) = &en.discriminator_mapping {
208+
if !mapping.is_empty() {
209+
code.push_str("///\n/// **Discriminator Mapping:**\n");
210+
for (key, val) in mapping {
211+
code.push_str(&format!("/// * `{}` -> `{}`\n", key, val));
212+
}
213+
}
214+
}
215+
206216
if en.is_deprecated {
207217
code.push_str("#[deprecated]\n");
208218
}
@@ -293,6 +303,7 @@ fn collect_imports(model: &ParsedModel, imports: &mut BTreeSet<String>) {
293303
mod tests {
294304
use super::*;
295305
use crate::parser::{ParsedExternalDocs, ParsedField, ParsedVariant};
306+
use std::collections::BTreeMap;
296307

297308
fn field(name: &str, ty: &str) -> ParsedField {
298309
ParsedField {
@@ -358,7 +369,7 @@ mod tests {
358369
}
359370

360371
#[test]
361-
fn test_generate_enum_tagged() {
372+
fn test_generate_enum_tagged_with_alias() {
362373
let en = ParsedEnum {
363374
name: "Pet".into(),
364375
description: Some("Polymorphic pet".into()),
@@ -367,24 +378,15 @@ mod tests {
367378
untagged: false,
368379
is_deprecated: false,
369380
external_docs: None,
370-
variants: vec![
371-
ParsedVariant {
372-
name: "Cat".into(),
373-
ty: Some("CatInfo".into()),
374-
description: None,
375-
rename: Some("cat".into()),
376-
aliases: Some(vec!["kitty".into()]),
377-
is_deprecated: false,
378-
},
379-
ParsedVariant {
380-
name: "Dog".into(),
381-
ty: Some("DogInfo".into()),
382-
description: None,
383-
rename: Some("dog".into()),
384-
aliases: None,
385-
is_deprecated: false,
386-
},
387-
],
381+
variants: vec![ParsedVariant {
382+
name: "Cat".into(),
383+
ty: Some("CatInfo".into()),
384+
description: None,
385+
rename: Some("cat".into()),
386+
aliases: Some(vec!["kitty".into()]),
387+
is_deprecated: false,
388+
}],
389+
discriminator_mapping: None,
388390
};
389391

390392
let code = generate_dtos(&[ParsedModel::Enum(en)]);
@@ -395,6 +397,31 @@ mod tests {
395397
assert!(code.contains(" Cat(CatInfo),"));
396398
}
397399

400+
#[test]
401+
fn test_generate_enum_with_mapping_comments() {
402+
let mut mapping = BTreeMap::new();
403+
mapping.insert(
404+
"mapped_val".to_string(),
405+
"#/components/schemas/Mapped".to_string(),
406+
);
407+
408+
let en = ParsedEnum {
409+
name: "MappedEnum".into(),
410+
description: None,
411+
rename: None,
412+
tag: Some("kind".into()),
413+
untagged: false,
414+
is_deprecated: false,
415+
external_docs: None,
416+
variants: vec![],
417+
discriminator_mapping: Some(mapping),
418+
};
419+
420+
let code = generate_dtos(&[ParsedModel::Enum(en)]);
421+
assert!(code.contains("/// **Discriminator Mapping:**"));
422+
assert!(code.contains("/// * `mapped_val` -> `#/components/schemas/Mapped`"));
423+
}
424+
398425
#[test]
399426
fn test_generate_enum_primitive_variants() {
400427
// Test untagged enum with primitives: [String, i32]
@@ -424,6 +451,7 @@ mod tests {
424451
is_deprecated: false,
425452
},
426453
],
454+
discriminator_mapping: None,
427455
};
428456

429457
let code = generate_dtos(&[ParsedModel::Enum(en)]);

core/src/contract_test_generator.rs

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,15 @@ fn generate_test_fn(
4545
) -> String {
4646
let fn_name = format!("test_{}", route.handler_name);
4747

48-
let mut uri = route.path.clone();
48+
// Prepend base_path if it exists.
49+
// This allows tests to hit scopes like `/api/v1/users` even if the route definition is just `/users`.
50+
let full_path_template = if let Some(base) = &route.base_path {
51+
format!("{}{}", base, route.path)
52+
} else {
53+
route.path.clone()
54+
};
55+
56+
let mut uri = full_path_template.clone();
4957

5058
for param in &route.params {
5159
if param.source == ParamSource::Path {
@@ -130,6 +138,7 @@ mod tests {
130138
fn test_generate_contract_test_structure() {
131139
let routes = vec![ParsedRoute {
132140
path: "/users/{id}".into(),
141+
base_path: None,
133142
method: "GET".into(),
134143
handler_name: "get_user".into(),
135144
params: vec![crate::oas::RouteParam {
@@ -170,6 +179,7 @@ mod tests {
170179
fn test_generate_with_query_params() {
171180
let routes = vec![ParsedRoute {
172181
path: "/search".into(),
182+
base_path: None,
173183
method: "GET".into(),
174184
handler_name: "search_items".into(),
175185
params: vec![
@@ -210,6 +220,7 @@ mod tests {
210220
fn test_generate_with_body() {
211221
let routes = vec![ParsedRoute {
212222
path: "/create".into(),
223+
base_path: None,
213224
method: "POST".into(),
214225
handler_name: "create_item".into(),
215226
params: vec![],
@@ -232,4 +243,31 @@ mod tests {
232243
let code = generate_contract_tests_file(&routes, "doc.yaml", "init", &strategy).unwrap();
233244
assert!(code.contains(".set_json(serde_json::json!"));
234245
}
246+
247+
#[test]
248+
fn test_generate_with_base_path() {
249+
let routes = vec![ParsedRoute {
250+
path: "/ping".into(),
251+
base_path: Some("/api/v1".into()),
252+
method: "GET".into(),
253+
handler_name: "ping".into(),
254+
params: vec![],
255+
request_body: None,
256+
security: vec![],
257+
response_type: None,
258+
response_headers: vec![],
259+
response_links: None,
260+
kind: RouteKind::Path,
261+
callbacks: vec![],
262+
deprecated: false,
263+
external_docs: None,
264+
}];
265+
266+
let strategy = ActixStrategy;
267+
let code = generate_contract_tests_file(&routes, "doc.yaml", "init", &strategy).unwrap();
268+
// Expect URI to include base path
269+
assert!(code.contains(".uri(\"/api/v1/ping\")"));
270+
// Expect validation logic to use original template
271+
assert!(code.contains("validate_response(resp, \"GET\", \"/ping\").await;"));
272+
}
235273
}

core/src/handler_generator/builder.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ mod tests {
4848
fn test_scaffold_new_file() {
4949
let route = ParsedRoute {
5050
path: "/users".into(),
51+
base_path: None,
5152
method: "GET".into(),
5253
handler_name: "get_users".into(),
5354
params: vec![],
@@ -76,6 +77,7 @@ mod tests {
7677

7778
let route = ParsedRoute {
7879
path: "/new".into(),
80+
base_path: None,
7981
method: "POST".into(),
8082
handler_name: "new_func".into(),
8183
params: vec![],

core/src/handler_generator/extractors.rs

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,8 @@ pub(crate) fn generate_function(
7979
let extractor = match def.format {
8080
BodyFormat::Json => strategy.body_extractor(&def.ty),
8181
BodyFormat::Form => strategy.form_extractor(&def.ty),
82-
BodyFormat::Multipart => strategy.multipart_extractor(),
82+
// Pass the type to the multipart extractor
83+
BodyFormat::Multipart => strategy.multipart_extractor(&def.ty),
8384
};
8485
args.push(format!("body: {}", extractor));
8586
}
@@ -112,6 +113,7 @@ mod tests {
112113
fn test_single_path_param() {
113114
let route = ParsedRoute {
114115
path: "/users/{id}".into(),
116+
base_path: None,
115117
method: "GET".into(),
116118
handler_name: "get_user".into(),
117119
params: vec![RouteParam {
@@ -142,6 +144,7 @@ mod tests {
142144
fn test_query_and_body() {
143145
let route = ParsedRoute {
144146
path: "/search".into(),
147+
base_path: None,
145148
method: "POST".into(),
146149
handler_name: "search".into(),
147150
params: vec![RouteParam {
@@ -173,10 +176,39 @@ mod tests {
173176
assert!(code.contains("body: web::Json<SearchFilter>"));
174177
}
175178

179+
#[test]
180+
fn test_multipart_extractor_generates_typed() {
181+
let route = ParsedRoute {
182+
path: "/upload".into(),
183+
base_path: None,
184+
method: "POST".into(),
185+
handler_name: "upload_file".into(),
186+
params: vec![],
187+
request_body: Some(RequestBodyDefinition {
188+
ty: "UploadForm".into(),
189+
format: BodyFormat::Multipart,
190+
encoding: None,
191+
}),
192+
security: vec![],
193+
response_type: None,
194+
response_headers: vec![],
195+
response_links: None,
196+
kind: RouteKind::Path,
197+
callbacks: vec![],
198+
deprecated: false,
199+
external_docs: None,
200+
};
201+
202+
let strategy = ActixStrategy;
203+
let code = update_handler_module("", &[route], &strategy).unwrap();
204+
assert!(code.contains("body: actix_multipart::form::MultipartForm<UploadForm>"));
205+
}
206+
176207
#[test]
177208
fn test_oas_3_2_querystring_extractor() {
178209
let route = ParsedRoute {
179210
path: "/raw".into(),
211+
base_path: None,
180212
method: "GET".into(),
181213
handler_name: "raw_search".into(),
182214
params: vec![RouteParam {
@@ -207,13 +239,15 @@ mod tests {
207239
fn test_security_stub_gen() {
208240
let route = ParsedRoute {
209241
path: "/api".into(),
242+
base_path: None,
210243
method: "POST".into(),
211244
handler_name: "secure_ops".into(),
212245
params: vec![],
213246
request_body: None,
214247
security: vec![SecurityRequirement {
215248
scheme_name: "ApiKey".into(),
216249
scopes: vec![],
250+
scheme: None, // Simplified for this test file context
217251
}],
218252
response_type: None,
219253
response_headers: vec![],
@@ -233,6 +267,7 @@ mod tests {
233267
fn test_extractor_passes_headers_info() {
234268
let route = ParsedRoute {
235269
path: "/headers".into(),
270+
base_path: None,
236271
method: "GET".into(),
237272
handler_name: "get_headers".into(),
238273
params: vec![],

0 commit comments

Comments
 (0)