Skip to content

Commit 0de71f8

Browse files
committed
Add min
1 parent ce93799 commit 0de71f8

12 files changed

Lines changed: 281 additions & 199 deletions

File tree

crates/vespera_core/src/schema.rs

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,27 @@ pub enum StringFormat {
7575
IpV6,
7676
}
7777

78+
/// Serialize `Option<f64>` as integer when the value has no fractional part.
79+
///
80+
/// Ensures OpenAPI JSON uses `0` instead of `0.0` for integer constraints like
81+
/// `minimum`/`maximum`, matching the convention that integer type bounds are integers.
82+
#[allow(clippy::ref_option)] // serde serialize_with mandates &Option<T> signature
83+
fn serialize_number_constraint<S>(value: &Option<f64>, serializer: S) -> Result<S::Ok, S::Error>
84+
where
85+
S: serde::Serializer,
86+
{
87+
match value {
88+
Some(v) if v.fract() == 0.0 => {
89+
// Practical OpenAPI constraints are well within i64 range
90+
#[allow(clippy::cast_possible_truncation)]
91+
let int_val = *v as i64;
92+
serializer.serialize_some(&int_val)
93+
}
94+
Some(v) => serializer.serialize_some(v),
95+
None => serializer.serialize_none(),
96+
}
97+
}
98+
7899
/// JSON Schema definition
79100
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
80101
#[serde(rename_all = "camelCase")]
@@ -108,10 +129,16 @@ pub struct Schema {
108129

109130
// Number constraints
110131
/// Minimum value
111-
#[serde(skip_serializing_if = "Option::is_none")]
132+
#[serde(
133+
skip_serializing_if = "Option::is_none",
134+
serialize_with = "serialize_number_constraint"
135+
)]
112136
pub minimum: Option<f64>,
113137
/// Maximum value
114-
#[serde(skip_serializing_if = "Option::is_none")]
138+
#[serde(
139+
skip_serializing_if = "Option::is_none",
140+
serialize_with = "serialize_number_constraint"
141+
)]
115142
pub maximum: Option<f64>,
116143
/// Exclusive minimum
117144
#[serde(skip_serializing_if = "Option::is_none")]
@@ -120,7 +147,10 @@ pub struct Schema {
120147
#[serde(skip_serializing_if = "Option::is_none")]
121148
pub exclusive_maximum: Option<bool>,
122149
/// Multiple of
123-
#[serde(skip_serializing_if = "Option::is_none")]
150+
#[serde(
151+
skip_serializing_if = "Option::is_none",
152+
serialize_with = "serialize_number_constraint"
153+
)]
124154
pub multiple_of: Option<f64>,
125155

126156
// String constraints

crates/vespera_macro/src/collector.rs

Lines changed: 31 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
//! Collector for routes and structs
22
3+
use std::collections::HashMap;
34
use std::path::Path;
45

56
use syn::Item;
@@ -11,10 +12,17 @@ use crate::{
1112
route::{extract_doc_comment, extract_route_info},
1213
};
1314

14-
/// Collect routes and structs from a folder
15+
/// Collect routes and structs from a folder.
16+
///
17+
/// Returns the metadata AND the parsed file ASTs, so downstream consumers
18+
/// (e.g., `openapi_generator`) can reuse them without re-reading files from disk.
1519
#[allow(clippy::option_if_let_else)]
16-
pub fn collect_metadata(folder_path: &Path, folder_name: &str) -> MacroResult<CollectedMetadata> {
20+
pub fn collect_metadata(
21+
folder_path: &Path,
22+
folder_name: &str,
23+
) -> MacroResult<(CollectedMetadata, HashMap<String, syn::File>)> {
1724
let mut metadata = CollectedMetadata::new();
25+
let mut file_asts = HashMap::new();
1826

1927
let files = collect_files(folder_path).map_err(|e| err_call_site(format!("vespera! macro: failed to scan route folder '{}': {}. Verify the folder exists and is readable.", folder_path.display(), e)))?;
2028

@@ -33,6 +41,10 @@ pub fn collect_metadata(folder_path: &Path, folder_name: &str) -> MacroResult<Co
3341

3442
let file_ast = syn::parse_file(&content).map_err(|e| err_call_site(format!("vespera! macro: syntax error in '{}': {}. Fix the Rust syntax errors in this file.", file.display(), e)))?;
3543

44+
// Store file AST for downstream reuse (keyed by display path to match RouteMetadata.file_path)
45+
let file_path_key = file.display().to_string();
46+
file_asts.insert(file_path_key, file_ast.clone());
47+
3648
// Get module path
3749
let segments = file
3850
.strip_prefix(folder_path)
@@ -91,7 +103,7 @@ pub fn collect_metadata(folder_path: &Path, folder_name: &str) -> MacroResult<Co
91103
}
92104
}
93105

94-
Ok(metadata)
106+
Ok((metadata, file_asts))
95107
}
96108

97109
#[cfg(test)]
@@ -117,7 +129,7 @@ mod tests {
117129
let temp_dir = TempDir::new().expect("Failed to create temp dir");
118130
let folder_name = "routes";
119131

120-
let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap();
132+
let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name).unwrap();
121133

122134
assert!(metadata.routes.is_empty());
123135
assert!(metadata.structs.is_empty());
@@ -236,7 +248,7 @@ pub fn get_users() -> String {
236248
create_temp_file(&temp_dir, filename, content);
237249
}
238250

239-
let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap();
251+
let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name).unwrap();
240252

241253
let route = &metadata.routes[0];
242254
assert_eq!(route.method, expected_method);
@@ -259,7 +271,7 @@ pub fn get_users() -> String {
259271
let temp_dir = TempDir::new().expect("Failed to create temp dir");
260272
let folder_name = "routes";
261273

262-
let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap();
274+
let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name).unwrap();
263275

264276
assert_eq!(metadata.routes.len(), 0);
265277

@@ -282,7 +294,7 @@ pub struct User {
282294
",
283295
);
284296

285-
let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap();
297+
let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name).unwrap();
286298

287299
assert_eq!(metadata.routes.len(), 0);
288300
assert_eq!(metadata.structs.len(), 0);
@@ -314,7 +326,7 @@ pub fn get_user() -> User {
314326
"#,
315327
);
316328

317-
let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap();
329+
let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name).unwrap();
318330

319331
assert_eq!(metadata.routes.len(), 1);
320332

@@ -356,7 +368,7 @@ pub fn get_posts() -> String {
356368
"#,
357369
);
358370

359-
let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap();
371+
let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name).unwrap();
360372

361373
assert_eq!(metadata.routes.len(), 3);
362374
assert_eq!(metadata.structs.len(), 0);
@@ -407,7 +419,7 @@ pub struct Post {
407419
",
408420
);
409421

410-
let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap();
422+
let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name).unwrap();
411423

412424
assert_eq!(metadata.routes.len(), 0);
413425

@@ -430,7 +442,7 @@ pub fn index() -> String {
430442
"#,
431443
);
432444

433-
let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap();
445+
let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name).unwrap();
434446

435447
assert_eq!(metadata.routes.len(), 1);
436448
let route = &metadata.routes[0];
@@ -457,7 +469,7 @@ pub fn get_users() -> String {
457469
"#,
458470
);
459471

460-
let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap();
472+
let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name).unwrap();
461473

462474
assert_eq!(metadata.routes.len(), 1);
463475
let route = &metadata.routes[0];
@@ -486,7 +498,7 @@ pub fn get_users() -> String {
486498

487499
create_temp_file(&temp_dir, "readme.md", "# Readme");
488500

489-
let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap();
501+
let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name).unwrap();
490502

491503
// Only .rs file should be processed
492504
assert_eq!(metadata.routes.len(), 1);
@@ -513,7 +525,7 @@ pub fn get_users() -> String {
513525

514526
create_temp_file(&temp_dir, "invalid.rs", "invalid rust syntax {");
515527

516-
let metadata = collect_metadata(temp_dir.path(), folder_name);
528+
let metadata = collect_metadata(temp_dir.path(), folder_name).map(|(m, _)| m);
517529

518530
// Only valid file should be processed
519531
assert!(metadata.is_err());
@@ -537,7 +549,7 @@ pub fn get_users() -> String {
537549
"#,
538550
);
539551

540-
let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap();
552+
let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name).unwrap();
541553

542554
assert_eq!(metadata.routes.len(), 1);
543555
let route = &metadata.routes[0];
@@ -584,7 +596,7 @@ pub fn options_handler() -> String { "options".to_string() }
584596
"#,
585597
);
586598

587-
let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap();
599+
let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name).unwrap();
588600

589601
assert_eq!(metadata.routes.len(), 7);
590602

@@ -766,7 +778,7 @@ pub fn get_users() -> String {
766778
);
767779

768780
// Collect metadata from the subdirectory
769-
let metadata = collect_metadata(&sub_dir, folder_name).unwrap();
781+
let (metadata, _file_asts) = collect_metadata(&sub_dir, folder_name).unwrap();
770782

771783
// Should collect the route (strip_prefix succeeds in normal cases)
772784
assert_eq!(metadata.routes.len(), 1);
@@ -794,7 +806,7 @@ pub struct User {
794806
",
795807
);
796808

797-
let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap();
809+
let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name).unwrap();
798810

799811
// Struct without Schema derive should not be collected
800812
assert_eq!(metadata.structs.len(), 0);
@@ -820,7 +832,7 @@ pub struct User {
820832
",
821833
);
822834

823-
let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap();
835+
let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name).unwrap();
824836

825837
// Struct with only Debug/Clone derive (no Schema) should not be collected
826838
assert_eq!(metadata.structs.len(), 0);

0 commit comments

Comments
 (0)