diff --git a/.changepacks/changepack_log_84xWDkggRCpzbU8VFUDhZ.json b/.changepacks/changepack_log_84xWDkggRCpzbU8VFUDhZ.json new file mode 100644 index 0000000..93d8ffc --- /dev/null +++ b/.changepacks/changepack_log_84xWDkggRCpzbU8VFUDhZ.json @@ -0,0 +1 @@ +{"changes":{"Cargo.toml":"Patch"},"note":"Support integer format, sea-orm default","date":"2026-02-18T12:19:24.863588800Z"} \ No newline at end of file diff --git a/.changepacks/changepack_log_istCnj_YAWHCFG2Y4WmgK.json b/.changepacks/changepack_log_istCnj_YAWHCFG2Y4WmgK.json new file mode 100644 index 0000000..19754b8 --- /dev/null +++ b/.changepacks/changepack_log_istCnj_YAWHCFG2Y4WmgK.json @@ -0,0 +1 @@ +{"changes":{"Cargo.toml":"Patch"},"note":"Support char","date":"2026-02-18T13:50:20.581242600Z"} \ No newline at end of file diff --git a/.changepacks/changepack_log_wLkPH1ixjpQHBc8JeR-Ur.json b/.changepacks/changepack_log_wLkPH1ixjpQHBc8JeR-Ur.json new file mode 100644 index 0000000..c767042 --- /dev/null +++ b/.changepacks/changepack_log_wLkPH1ixjpQHBc8JeR-Ur.json @@ -0,0 +1 @@ +{"changes":{"Cargo.toml":"Patch"},"note":"Support uuid format","date":"2026-02-18T14:16:15.606465600Z"} \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 3d0d754..eca143a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -177,6 +177,7 @@ dependencies = [ "third", "tokio", "tower-http", + "uuid", "vespera", ] @@ -956,6 +957,19 @@ dependencies = [ "wasip2", ] +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + [[package]] name = "glob" version = "0.3.3" @@ -1259,6 +1273,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -1294,6 +1314,8 @@ checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -1362,6 +1384,12 @@ dependencies = [ "spin", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.182" @@ -1750,6 +1778,16 @@ dependencies = [ "yansi", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.116", +] + [[package]] name = "proc-macro-crate" version = "3.4.0" @@ -3179,6 +3217,7 @@ version = "1.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" dependencies = [ + "getrandom 0.4.1", "js-sys", "serde_core", "wasm-bindgen", @@ -3198,7 +3237,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vespera" -version = "0.1.34" +version = "0.1.36" dependencies = [ "axum", "axum-extra", @@ -3214,7 +3253,7 @@ dependencies = [ [[package]] name = "vespera_core" -version = "0.1.34" +version = "0.1.36" dependencies = [ "rstest", "serde", @@ -3223,7 +3262,7 @@ dependencies = [ [[package]] name = "vespera_macro" -version = "0.1.34" +version = "0.1.36" dependencies = [ "insta", "proc-macro2", @@ -3258,7 +3297,16 @@ version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.46.0", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", ] [[package]] @@ -3312,6 +3360,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -3645,6 +3727,94 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap", + "prettyplease", + "syn 2.0.116", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.116", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "writeable" version = "0.6.2" diff --git a/crates/vespera_core/src/openapi.rs b/crates/vespera_core/src/openapi.rs index eecd51b..5c3ee25 100644 --- a/crates/vespera_core/src/openapi.rs +++ b/crates/vespera_core/src/openapi.rs @@ -48,7 +48,7 @@ pub struct License { } /// API information -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Info { /// API title @@ -192,18 +192,6 @@ impl OpenApi { } } } - - /// Merge from a JSON string. Returns error if parsing fails. - /// - /// # Errors - /// - /// Returns a `serde_json::Error` when `json_str` is not valid JSON or does not - /// deserialize into an `OpenApi` document. - pub fn merge_from_str(&mut self, json_str: &str) -> Result<(), serde_json::Error> { - let other: Self = serde_json::from_str(json_str)?; - self.merge(other); - Ok(()) - } } #[cfg(test)] @@ -245,16 +233,7 @@ mod tests { responses: BTreeMap::new(), security: None, }), - post: None, - put: None, - delete: None, - patch: None, - options: None, - head: None, - trace: None, - parameters: None, - summary: None, - description: None, + ..Default::default() } } @@ -469,35 +448,6 @@ mod tests { assert_eq!(base.tags.as_ref().unwrap().len(), 1); } - #[test] - fn test_merge_from_str() { - let mut base = create_base_openapi(); - base.paths - .insert("/users".to_string(), create_path_item("Get users")); - - let other_json = r#"{ - "openapi": "3.1.0", - "info": { "title": "Other API", "version": "2.0.0" }, - "paths": { - "/posts": { "get": { "summary": "Get posts", "responses": {} } } - } - }"#; - - let result = base.merge_from_str(other_json); - assert!(result.is_ok()); - assert!(base.paths.contains_key("/users")); - assert!(base.paths.contains_key("/posts")); - } - - #[test] - fn test_merge_from_str_invalid_json() { - let mut base = create_base_openapi(); - let invalid_json = "{ invalid json }"; - - let result = base.merge_from_str(invalid_json); - assert!(result.is_err()); - } - #[test] fn test_merge_empty_other() { let mut base = create_base_openapi(); diff --git a/crates/vespera_core/src/route.rs b/crates/vespera_core/src/route.rs index efbd18f..72caf9b 100644 --- a/crates/vespera_core/src/route.rs +++ b/crates/vespera_core/src/route.rs @@ -184,7 +184,7 @@ pub struct Operation { } /// Path Item definition (all HTTP methods for a specific path) -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PathItem { /// GET method @@ -236,32 +236,6 @@ impl PathItem { HttpMethod::Trace => self.trace = Some(operation), } } - - /// Get an operation for a specific HTTP method - #[must_use] - pub const fn get_operation(&self, method: &HttpMethod) -> Option<&Operation> { - match method { - HttpMethod::Get => self.get.as_ref(), - HttpMethod::Post => self.post.as_ref(), - HttpMethod::Put => self.put.as_ref(), - HttpMethod::Patch => self.patch.as_ref(), - HttpMethod::Delete => self.delete.as_ref(), - HttpMethod::Head => self.head.as_ref(), - HttpMethod::Options => self.options.as_ref(), - HttpMethod::Trace => self.trace.as_ref(), - } - } -} - -/// Route information (for internal use) -#[derive(Debug, Clone)] -pub struct RouteInfo { - /// HTTP method - pub method: HttpMethod, - /// Path - pub path: String, - /// Operation information - pub operation: Operation, } #[cfg(test)] @@ -336,19 +310,7 @@ mod tests { #[test] fn test_path_item_set_operation() { - let mut path_item = PathItem { - get: None, - post: None, - put: None, - patch: None, - delete: None, - head: None, - options: None, - trace: None, - parameters: None, - summary: None, - description: None, - }; + let mut path_item = PathItem::default(); let operation = Operation { operation_id: Some("test_operation".to_string()), @@ -416,92 +378,9 @@ mod tests { assert!(path_item.trace.is_some()); } - #[test] - fn test_path_item_get_operation() { - let mut path_item = PathItem { - get: None, - post: None, - put: None, - patch: None, - delete: None, - head: None, - options: None, - trace: None, - parameters: None, - summary: None, - description: None, - }; - - let operation = Operation { - operation_id: Some("test_operation".to_string()), - tags: None, - summary: None, - description: None, - parameters: None, - request_body: None, - responses: BTreeMap::new(), - security: None, - }; - - // Initially, all operations should be None - assert!(path_item.get_operation(&HttpMethod::Get).is_none()); - assert!(path_item.get_operation(&HttpMethod::Post).is_none()); - - // Set GET operation - path_item.set_operation(HttpMethod::Get, operation.clone()); - let retrieved = path_item.get_operation(&HttpMethod::Get); - assert!(retrieved.is_some()); - assert_eq!( - retrieved.unwrap().operation_id, - Some("test_operation".to_string()) - ); - - // Set POST operation - let mut operation_post = operation.clone(); - operation_post.operation_id = Some("post_operation".to_string()); - path_item.set_operation(HttpMethod::Post, operation_post); - let retrieved = path_item.get_operation(&HttpMethod::Post); - assert!(retrieved.is_some()); - assert_eq!( - retrieved.unwrap().operation_id, - Some("post_operation".to_string()) - ); - - // Test all methods - path_item.set_operation(HttpMethod::Put, operation.clone()); - assert!(path_item.get_operation(&HttpMethod::Put).is_some()); - - path_item.set_operation(HttpMethod::Patch, operation.clone()); - assert!(path_item.get_operation(&HttpMethod::Patch).is_some()); - - path_item.set_operation(HttpMethod::Delete, operation.clone()); - assert!(path_item.get_operation(&HttpMethod::Delete).is_some()); - - path_item.set_operation(HttpMethod::Head, operation.clone()); - assert!(path_item.get_operation(&HttpMethod::Head).is_some()); - - path_item.set_operation(HttpMethod::Options, operation.clone()); - assert!(path_item.get_operation(&HttpMethod::Options).is_some()); - - path_item.set_operation(HttpMethod::Trace, operation); - assert!(path_item.get_operation(&HttpMethod::Trace).is_some()); - } - #[test] fn test_path_item_set_operation_overwrites() { - let mut path_item = PathItem { - get: None, - post: None, - put: None, - patch: None, - delete: None, - head: None, - options: None, - trace: None, - parameters: None, - summary: None, - description: None, - }; + let mut path_item = PathItem::default(); let operation1 = Operation { operation_id: Some("first".to_string()), diff --git a/crates/vespera_core/src/schema.rs b/crates/vespera_core/src/schema.rs index f06e1b9..35e8d5f 100644 --- a/crates/vespera_core/src/schema.rs +++ b/crates/vespera_core/src/schema.rs @@ -48,31 +48,25 @@ pub enum SchemaType { Null, } -/// Number format -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum NumberFormat { - Float, - Double, - Int32, - Int64, -} - -/// String format -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum StringFormat { - Date, - DateTime, - Password, - Byte, - Binary, - Email, - Uuid, - Uri, - Hostname, - IpV4, - IpV6, +/// Serialize `Option` as integer when the value has no fractional part. +/// +/// Ensures OpenAPI JSON uses `0` instead of `0.0` for integer constraints like +/// `minimum`/`maximum`, matching the convention that integer type bounds are integers. +#[allow(clippy::ref_option)] // serde serialize_with mandates &Option signature +fn serialize_number_constraint(value: &Option, serializer: S) -> Result +where + S: serde::Serializer, +{ + match value { + Some(v) if v.fract() == 0.0 => { + // Practical OpenAPI constraints are well within i64 range + #[allow(clippy::cast_possible_truncation)] + let int_val = *v as i64; + serializer.serialize_some(&int_val) + } + Some(v) => serializer.serialize_some(v), + None => serializer.serialize_none(), + } } /// JSON Schema definition @@ -108,10 +102,16 @@ pub struct Schema { // Number constraints /// Minimum value - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + serialize_with = "serialize_number_constraint" + )] pub minimum: Option, /// Maximum value - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + serialize_with = "serialize_number_constraint" + )] pub maximum: Option, /// Exclusive minimum #[serde(skip_serializing_if = "Option::is_none")] @@ -120,7 +120,10 @@ pub struct Schema { #[serde(skip_serializing_if = "Option::is_none")] pub exclusive_maximum: Option, /// Multiple of - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + serialize_with = "serialize_number_constraint" + )] pub multiple_of: Option, // String constraints @@ -433,4 +436,87 @@ mod tests { let required = schema.required.expect("required should be initialized"); assert!(required.is_empty()); } + + #[test] + fn serialize_number_constraint_none_serializes_null() { + // Direct call bypasses skip_serializing_if to cover the None branch + let result = + super::serialize_number_constraint(&None, serde_json::value::Serializer).unwrap(); + assert_eq!(result, serde_json::Value::Null); + } + + #[test] + fn serialize_minimum_whole_number_as_integer() { + let schema = Schema { + minimum: Some(0.0), + ..Schema::integer() + }; + let json = serde_json::to_string(&schema).unwrap(); + // Must be "minimum":0 (integer), NOT "minimum":0.0 + assert!( + json.contains("\"minimum\":0"), + "expected integer 0, got: {json}" + ); + assert!( + !json.contains("\"minimum\":0.0"), + "must not contain 0.0: {json}" + ); + } + + #[test] + fn serialize_minimum_fractional_as_float() { + let schema = Schema { + minimum: Some(1.5), + ..Schema::number() + }; + let json = serde_json::to_string(&schema).unwrap(); + assert!( + json.contains("\"minimum\":1.5"), + "expected 1.5, got: {json}" + ); + } + + #[test] + fn serialize_minimum_none_omitted() { + let schema = Schema::integer(); + let json = serde_json::to_string(&schema).unwrap(); + assert!( + !json.contains("minimum"), + "None minimum should be omitted: {json}" + ); + } + + #[test] + fn serialize_maximum_whole_number_as_integer() { + let schema = Schema { + maximum: Some(100.0), + ..Schema::integer() + }; + let json = serde_json::to_string(&schema).unwrap(); + assert!( + json.contains("\"maximum\":100"), + "expected integer 100, got: {json}" + ); + assert!( + !json.contains("\"maximum\":100.0"), + "must not contain 100.0: {json}" + ); + } + + #[test] + fn serialize_multiple_of_whole_number_as_integer() { + let schema = Schema { + multiple_of: Some(2.0), + ..Schema::integer() + }; + let json = serde_json::to_string(&schema).unwrap(); + assert!( + json.contains("\"multipleOf\":2"), + "expected integer 2, got: {json}" + ); + assert!( + !json.contains("\"multipleOf\":2.0"), + "must not contain 2.0: {json}" + ); + } } diff --git a/crates/vespera_macro/src/collector.rs b/crates/vespera_macro/src/collector.rs index d2cd3ee..47e9116 100644 --- a/crates/vespera_macro/src/collector.rs +++ b/crates/vespera_macro/src/collector.rs @@ -1,5 +1,6 @@ //! Collector for routes and structs +use std::collections::HashMap; use std::path::Path; use syn::Item; @@ -11,10 +12,17 @@ use crate::{ route::{extract_doc_comment, extract_route_info}, }; -/// Collect routes and structs from a folder +/// Collect routes and structs from a folder. +/// +/// Returns the metadata AND the parsed file ASTs, so downstream consumers +/// (e.g., `openapi_generator`) can reuse them without re-reading files from disk. #[allow(clippy::option_if_let_else)] -pub fn collect_metadata(folder_path: &Path, folder_name: &str) -> MacroResult { +pub fn collect_metadata( + folder_path: &Path, + folder_name: &str, +) -> MacroResult<(CollectedMetadata, HashMap)> { let mut metadata = CollectedMetadata::new(); + let mut file_asts = HashMap::new(); 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)))?; @@ -33,6 +41,10 @@ pub fn collect_metadata(folder_path: &Path, folder_name: &str) -> MacroResult MacroResult String { create_temp_file(&temp_dir, filename, content); } - let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap(); + let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name).unwrap(); let route = &metadata.routes[0]; assert_eq!(route.method, expected_method); @@ -259,7 +271,7 @@ pub fn get_users() -> String { let temp_dir = TempDir::new().expect("Failed to create temp dir"); let folder_name = "routes"; - let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap(); + let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name).unwrap(); assert_eq!(metadata.routes.len(), 0); @@ -282,7 +294,7 @@ pub struct User { ", ); - let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap(); + let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name).unwrap(); assert_eq!(metadata.routes.len(), 0); assert_eq!(metadata.structs.len(), 0); @@ -314,7 +326,7 @@ pub fn get_user() -> User { "#, ); - let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap(); + let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name).unwrap(); assert_eq!(metadata.routes.len(), 1); @@ -356,7 +368,7 @@ pub fn get_posts() -> String { "#, ); - let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap(); + let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name).unwrap(); assert_eq!(metadata.routes.len(), 3); assert_eq!(metadata.structs.len(), 0); @@ -407,7 +419,7 @@ pub struct Post { ", ); - let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap(); + let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name).unwrap(); assert_eq!(metadata.routes.len(), 0); @@ -430,7 +442,7 @@ pub fn index() -> String { "#, ); - let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap(); + let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name).unwrap(); assert_eq!(metadata.routes.len(), 1); let route = &metadata.routes[0]; @@ -457,7 +469,7 @@ pub fn get_users() -> String { "#, ); - let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap(); + let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name).unwrap(); assert_eq!(metadata.routes.len(), 1); let route = &metadata.routes[0]; @@ -486,7 +498,7 @@ pub fn get_users() -> String { create_temp_file(&temp_dir, "readme.md", "# Readme"); - let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap(); + let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name).unwrap(); // Only .rs file should be processed assert_eq!(metadata.routes.len(), 1); @@ -513,7 +525,7 @@ pub fn get_users() -> String { create_temp_file(&temp_dir, "invalid.rs", "invalid rust syntax {"); - let metadata = collect_metadata(temp_dir.path(), folder_name); + let metadata = collect_metadata(temp_dir.path(), folder_name).map(|(m, _)| m); // Only valid file should be processed assert!(metadata.is_err()); @@ -537,7 +549,7 @@ pub fn get_users() -> String { "#, ); - let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap(); + let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name).unwrap(); assert_eq!(metadata.routes.len(), 1); let route = &metadata.routes[0]; @@ -584,7 +596,7 @@ pub fn options_handler() -> String { "options".to_string() } "#, ); - let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap(); + let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name).unwrap(); assert_eq!(metadata.routes.len(), 7); @@ -766,7 +778,7 @@ pub fn get_users() -> String { ); // Collect metadata from the subdirectory - let metadata = collect_metadata(&sub_dir, folder_name).unwrap(); + let (metadata, _file_asts) = collect_metadata(&sub_dir, folder_name).unwrap(); // Should collect the route (strip_prefix succeeds in normal cases) assert_eq!(metadata.routes.len(), 1); @@ -794,7 +806,7 @@ pub struct User { ", ); - let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap(); + let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name).unwrap(); // Struct without Schema derive should not be collected assert_eq!(metadata.structs.len(), 0); @@ -820,7 +832,7 @@ pub struct User { ", ); - let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap(); + let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name).unwrap(); // Struct with only Debug/Clone derive (no Schema) should not be collected assert_eq!(metadata.structs.len(), 0); diff --git a/crates/vespera_macro/src/openapi_generator.rs b/crates/vespera_macro/src/openapi_generator.rs index ef6abd2..a476c1f 100644 --- a/crates/vespera_macro/src/openapi_generator.rs +++ b/crates/vespera_macro/src/openapi_generator.rs @@ -19,20 +19,26 @@ use crate::{ schema_macro::type_utils::get_type_default as utils_get_type_default, }; -/// Generate `OpenAPI` document from collected metadata +/// Generate `OpenAPI` document from collected metadata. +/// +/// When `file_cache` is provided (from collector), skips file I/O entirely. +/// When `None`, falls back to reading files from disk (used in tests). pub fn generate_openapi_doc_with_metadata( title: Option, version: Option, servers: Option>, metadata: &CollectedMetadata, + file_cache: Option>, ) -> OpenApi { let (known_schema_names, struct_definitions) = build_schema_lookups(metadata); - let file_cache = build_file_cache(metadata); + let file_cache = file_cache.unwrap_or_else(|| build_file_cache(metadata)); let struct_file_index = build_struct_file_index(&file_cache); + let parsed_definitions = build_parsed_definitions(metadata); let schemas = parse_component_schemas( metadata, &known_schema_names, &struct_definitions, + &parsed_definitions, &file_cache, &struct_file_index, ); @@ -48,11 +54,7 @@ pub fn generate_openapi_doc_with_metadata( info: Info { title: title.unwrap_or_else(|| "API".to_string()), version: version.unwrap_or_else(|| "1.0.0".to_string()), - description: None, - terms_of_service: None, - contact: None, - license: None, - summary: None, + ..Default::default() }, servers: servers.or_else(|| { Some(vec![Server { @@ -148,6 +150,20 @@ fn build_struct_file_index(file_cache: &HashMap) -> HashMap HashMap { + let mut parsed = HashMap::with_capacity(metadata.structs.len()); + for struct_meta in &metadata.structs { + if let Ok(item) = syn::parse_str::(&struct_meta.definition) { + parsed.insert(struct_meta.name.clone(), item); + } + } + parsed +} + /// Parse struct and enum definitions into `OpenAPI` component schemas. /// /// Only includes structs where `include_in_openapi` is true @@ -160,16 +176,17 @@ fn parse_component_schemas( metadata: &CollectedMetadata, known_schema_names: &HashSet, struct_definitions: &HashMap, + parsed_definitions: &HashMap, file_cache: &HashMap, struct_file_index: &HashMap, ) -> BTreeMap { let mut schemas = BTreeMap::new(); for struct_meta in metadata.structs.iter().filter(|s| s.include_in_openapi) { - let Ok(parsed) = syn::parse_str::(&struct_meta.definition) else { + let Some(parsed) = parsed_definitions.get(&struct_meta.name) else { continue; }; - let mut schema = match &parsed { + let mut schema = match parsed { syn::Item::Struct(struct_item) => { parse_struct_to_schema(struct_item, known_schema_names, struct_definitions) } @@ -180,7 +197,7 @@ fn parse_component_schemas( }; // Process default values using cached file ASTs (O(1) lookup) - if let syn::Item::Struct(struct_item) = &parsed { + if let syn::Item::Struct(struct_item) = parsed { let file_ast = struct_file_index .get(&struct_meta.name) .and_then(|path| file_cache.get(*path)) @@ -250,19 +267,7 @@ fn build_path_items( let path_item = paths .entry(route_meta.path.clone()) - .or_insert_with(|| PathItem { - get: None, - post: None, - put: None, - patch: None, - delete: None, - head: None, - options: None, - trace: None, - parameters: None, - summary: None, - description: None, - }); + .or_insert_with(PathItem::default); path_item.set_operation(method, operation); break; @@ -273,15 +278,35 @@ fn build_path_items( (paths, all_tags) } +/// Set the default value on an inline property schema, if not already set. +/// +/// Looks up `field_name` in the properties map. If found as an inline schema +/// and the schema has no existing default, sets `value` as the default. +fn set_property_default( + properties: &mut BTreeMap, + field_name: &str, + value: serde_json::Value, +) { + use vespera_core::schema::SchemaRef; + + if let Some(SchemaRef::Inline(prop_schema)) = properties.get_mut(field_name) + && prop_schema.default.is_none() + { + prop_schema.default = Some(value); + } +} + /// Process default functions for struct fields -/// This function extracts default values from functions specified in #[serde(default = "`function_name`")] +/// This function extracts default values from: +/// 1. `#[schema(default = "value")]` attributes (generated by `schema_type!` from `sea_orm(default_value)`) +/// 2. `#[serde(default = "function_name")]` by finding the function in the file AST +/// 3. `#[serde(default)]` by using type-specific defaults fn process_default_functions( struct_item: &syn::ItemStruct, file_ast: &syn::File, schema: &mut vespera_core::schema::Schema, ) { use syn::Fields; - use vespera_core::schema::SchemaRef; // Extract rename_all from struct level let struct_rename_all = extract_rename_all(&struct_item.attrs); @@ -294,73 +319,96 @@ fn process_default_functions( // Process each field in the struct if let Fields::Named(fields_named) = &struct_item.fields { for field in &fields_named.named { - // Extract default function name + let rust_field_name = field.ident.as_ref().map_or_else( + || "unknown".to_string(), + |i| strip_raw_prefix(&i.to_string()).to_string(), + ); + let field_name = extract_field_rename(&field.attrs) + .unwrap_or_else(|| rename_field(&rust_field_name, struct_rename_all.as_deref())); + + // Priority 1: #[schema(default = "value")] from schema_type! macro + if let Some(default_str) = extract_schema_default_attr(&field.attrs) { + let value = parse_default_string_to_json_value(&default_str); + set_property_default(properties, &field_name, value); + continue; + } + + // Priority 2: #[serde(default)] / #[serde(default = "fn")] let default_info = match extract_default(&field.attrs) { Some(Some(func_name)) => func_name, // default = "function_name" Some(None) => { // Simple default (no function) - we can set type-specific defaults - let rust_field_name = field.ident.as_ref().map_or_else( - || "unknown".to_string(), - |i| strip_raw_prefix(&i.to_string()).to_string(), - ); - - let field_name = extract_field_rename(&field.attrs).unwrap_or_else(|| { - rename_field(&rust_field_name, struct_rename_all.as_deref()) - }); - - // Set type-specific default for simple default - if let Some(prop_schema_ref) = properties.get_mut(&field_name) - && let SchemaRef::Inline(prop_schema) = prop_schema_ref - && prop_schema.default.is_none() - && let Some(default_value) = utils_get_type_default(&field.ty) - { - prop_schema.default = Some(default_value); + if let Some(default_value) = utils_get_type_default(&field.ty) { + set_property_default(properties, &field_name, default_value); } continue; } None => continue, // No default attribute }; - // Find the function in the file AST - let func = find_function_in_file(file_ast, &default_info); - if let Some(func_item) = func { - // Extract default value from function body - if let Some(default_value) = extract_default_value_from_function(func_item) { - // Get the field name (with rename applied) - let rust_field_name = field.ident.as_ref().map_or_else( - || "unknown".to_string(), - |i| strip_raw_prefix(&i.to_string()).to_string(), - ); - - let field_name = extract_field_rename(&field.attrs).unwrap_or_else(|| { - rename_field(&rust_field_name, struct_rename_all.as_deref()) - }); - - // Set default value in schema - if let Some(prop_schema_ref) = properties.get_mut(&field_name) - && let SchemaRef::Inline(prop_schema) = prop_schema_ref - { - prop_schema.default = Some(default_value); - } - } + // Find the function in the file AST and extract default value + if let Some(func_item) = find_function_in_file(file_ast, &default_info) + && let Some(default_value) = extract_default_value_from_function(func_item) + { + set_property_default(properties, &field_name, default_value); } } } } +/// Extract `default` value from `#[schema(default = "...")]` field attribute. +/// +/// This attribute is generated by `schema_type!` when converting `sea_orm(default_value)`. +/// It carries the raw default value string for OpenAPI schema generation. +fn extract_schema_default_attr(attrs: &[syn::Attribute]) -> Option { + attrs + .iter() + .filter(|attr| attr.path().is_ident("schema")) + .find_map(|attr| { + let mut default_value = None; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("default") { + let value = meta.value()?; + let lit: syn::LitStr = value.parse()?; + default_value = Some(lit.value()); + } + Ok(()) + }); + default_value + }) +} + +/// Parse a default value string into the appropriate `serde_json::Value`. +/// +/// Tries to infer the JSON type: integer → number → bool → string (fallback). +fn parse_default_string_to_json_value(value: &str) -> serde_json::Value { + // Try integer first + if let Ok(n) = value.parse::() { + return serde_json::Value::Number(n.into()); + } + // Try float + if let Ok(f) = value.parse::() + && let Some(n) = serde_json::Number::from_f64(f) + { + return serde_json::Value::Number(n); + } + // Try bool + if let Ok(b) = value.parse::() { + return serde_json::Value::Bool(b); + } + // Fallback to string + serde_json::Value::String(value.to_string()) +} + /// Find a function by name in the file AST fn find_function_in_file<'a>( file_ast: &'a syn::File, function_name: &str, ) -> Option<&'a syn::ItemFn> { - for item in &file_ast.items { - if let syn::Item::Fn(fn_item) = item - && fn_item.sig.ident == function_name - { - return Some(fn_item); - } - } - None + file_ast.items.iter().find_map(|item| match item { + syn::Item::Fn(fn_item) if fn_item.sig.ident == function_name => Some(fn_item), + _ => None, + }) } /// Extract default value from function body @@ -460,7 +508,7 @@ mod tests { fn test_generate_openapi_empty_metadata() { let metadata = CollectedMetadata::new(); - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata); + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None); assert_eq!(doc.openapi, OpenApiVersion::V3_1_0); assert_eq!(doc.info.title, "API"); @@ -487,7 +535,7 @@ mod tests { ) { let metadata = CollectedMetadata::new(); - let doc = generate_openapi_doc_with_metadata(title, version, None, &metadata); + let doc = generate_openapi_doc_with_metadata(title, version, None, &metadata, None); assert_eq!(doc.info.title, expected_title); assert_eq!(doc.info.version, expected_version); @@ -518,7 +566,7 @@ pub fn get_users() -> String { description: None, }); - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata); + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None); assert!(doc.paths.contains_key("/users")); let path_item = doc.paths.get("/users").unwrap(); @@ -536,7 +584,7 @@ pub fn get_users() -> String { ..Default::default() }); - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata); + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None); assert!(doc.components.as_ref().unwrap().schemas.is_some()); let schemas = doc.components.as_ref().unwrap().schemas.as_ref().unwrap(); @@ -552,7 +600,7 @@ pub fn get_users() -> String { ..Default::default() }); - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata); + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None); assert!(doc.components.as_ref().unwrap().schemas.is_some()); let schemas = doc.components.as_ref().unwrap().schemas.as_ref().unwrap(); @@ -569,7 +617,7 @@ pub fn get_users() -> String { ..Default::default() }); - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata); + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None); assert!(doc.components.as_ref().unwrap().schemas.is_some()); let schemas = doc.components.as_ref().unwrap().schemas.as_ref().unwrap(); @@ -605,7 +653,7 @@ pub fn get_status() -> Status { description: None, }); - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata); + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None); // Check enum schema assert!(doc.components.as_ref().unwrap().schemas.is_some()); @@ -632,7 +680,7 @@ pub fn get_status() -> Status { }); // This should gracefully handle the invalid item (skip it) instead of panicking - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata); + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None); // The invalid struct definition should be skipped, resulting in no schemas assert!(doc.components.is_none() || doc.components.as_ref().unwrap().schemas.is_none()); } @@ -672,6 +720,7 @@ pub fn get_user() -> User { Some("1.0.0".to_string()), None, &metadata, + None, ); // Check struct schema @@ -727,7 +776,7 @@ pub fn create_user() -> String { description: None, }); - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata); + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None); assert_eq!(doc.paths.len(), 1); // Same path, different methods let path_item = doc.paths.get("/users").unwrap(); @@ -796,7 +845,7 @@ pub fn create_user() -> String { } // Should not panic, just skip invalid files - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata); + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None); // Check struct if expect_struct { @@ -841,7 +890,7 @@ pub fn get_users() -> String { description: Some("Get all users".to_string()), }); - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata); + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None); // Check route has description let path_item = doc.paths.get("/users").unwrap(); @@ -871,7 +920,7 @@ pub fn get_users() -> String { }, ]; - let doc = generate_openapi_doc_with_metadata(None, None, Some(servers), &metadata); + let doc = generate_openapi_doc_with_metadata(None, None, Some(servers), &metadata, None); assert!(doc.servers.is_some()); let doc_servers = doc.servers.unwrap(); @@ -1115,7 +1164,7 @@ pub fn get_user() -> User { description: None, }); - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata); + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None); // Struct should be present assert!(doc.components.as_ref().unwrap().schemas.is_some()); @@ -1161,7 +1210,7 @@ pub fn get_config() -> Config { description: None, }); - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata); + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None); assert!(doc.components.as_ref().unwrap().schemas.is_some()); let schemas = doc.components.as_ref().unwrap().schemas.as_ref().unwrap(); @@ -1232,7 +1281,7 @@ pub fn get_user() -> User { description: None, }); - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata); + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None); // Struct should be found via fallback and processed assert!(doc.components.as_ref().unwrap().schemas.is_some()); @@ -1371,7 +1420,7 @@ pub fn get_users() -> String { description: None, }); - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata); + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None); // Route with unknown HTTP method should be skipped entirely assert!( @@ -1423,7 +1472,7 @@ pub fn create_users() -> String { description: None, }); - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata); + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None); // Only the valid POST route should appear assert_eq!(doc.paths.len(), 1); @@ -1451,8 +1500,167 @@ pub fn create_users() -> String { }); // Should gracefully skip unparseable definitions - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata); + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None); // The unparseable definition should be skipped assert!(doc.components.is_none() || doc.components.as_ref().unwrap().schemas.is_none()); } + + // ======== Tests for set_property_default helper ======== + + #[test] + fn test_set_property_default_on_inline_schema() { + use vespera_core::schema::{Schema, SchemaRef}; + + let mut properties = BTreeMap::new(); + let mut schema = Schema::object(); + schema.default = None; + properties.insert("name".to_string(), SchemaRef::Inline(Box::new(schema))); + + set_property_default( + &mut properties, + "name", + serde_json::Value::String("Alice".to_string()), + ); + + if let Some(SchemaRef::Inline(prop)) = properties.get("name") { + assert_eq!( + prop.default, + Some(serde_json::Value::String("Alice".to_string())) + ); + } else { + panic!("Expected Inline schema"); + } + } + + #[test] + fn test_set_property_default_does_not_overwrite_existing() { + use vespera_core::schema::{Schema, SchemaRef}; + + let mut properties = BTreeMap::new(); + let mut schema = Schema::object(); + schema.default = Some(serde_json::Value::String("existing".to_string())); + properties.insert("name".to_string(), SchemaRef::Inline(Box::new(schema))); + + set_property_default( + &mut properties, + "name", + serde_json::Value::String("new".to_string()), + ); + + if let Some(SchemaRef::Inline(prop)) = properties.get("name") { + assert_eq!( + prop.default, + Some(serde_json::Value::String("existing".to_string())), + "Should NOT overwrite existing default" + ); + } else { + panic!("Expected Inline schema"); + } + } + + #[test] + fn test_set_property_default_skips_ref_schema() { + use vespera_core::schema::{Reference, SchemaRef}; + + let mut properties = BTreeMap::new(); + properties.insert( + "user".to_string(), + SchemaRef::Ref(Reference::schema("User")), + ); + + // Should silently no-op (Ref variants have no default field) + set_property_default( + &mut properties, + "user", + serde_json::Value::String("ignored".to_string()), + ); + + assert!( + matches!(properties.get("user"), Some(SchemaRef::Ref(_))), + "Should remain a Ref variant" + ); + } + + #[test] + fn test_set_property_default_skips_missing_property() { + let mut properties = BTreeMap::new(); + + // Should silently no-op (property doesn't exist) + set_property_default( + &mut properties, + "nonexistent", + serde_json::Value::Number(42.into()), + ); + + assert!(properties.is_empty(), "Should not insert new properties"); + } + + #[test] + fn test_extract_schema_default_attr_with_value() { + let attrs: Vec = vec![syn::parse_quote!(#[schema(default = "42")])]; + let result = extract_schema_default_attr(&attrs); + assert_eq!(result, Some("42".to_string())); + } + + #[test] + fn test_extract_schema_default_attr_no_default() { + let attrs: Vec = vec![syn::parse_quote!(#[schema(rename = "foo")])]; + let result = extract_schema_default_attr(&attrs); + assert_eq!(result, None); + } + + #[test] + fn test_extract_schema_default_attr_non_schema() { + let attrs: Vec = vec![syn::parse_quote!(#[serde(default)])]; + let result = extract_schema_default_attr(&attrs); + assert_eq!(result, None); + } + + #[test] + fn test_parse_default_string_to_json_value_integer() { + let result = parse_default_string_to_json_value("42"); + assert_eq!(result, serde_json::Value::Number(42.into())); + } + + #[test] + fn test_parse_default_string_to_json_value_float() { + let result = parse_default_string_to_json_value("2.72"); + assert_eq!(result, serde_json::json!(2.72)); + } + + #[test] + fn test_parse_default_string_to_json_value_bool() { + let result = parse_default_string_to_json_value("true"); + assert_eq!(result, serde_json::Value::Bool(true)); + } + + #[test] + fn test_parse_default_string_to_json_value_string_fallback() { + let result = parse_default_string_to_json_value("hello world"); + assert_eq!(result, serde_json::Value::String("hello world".to_string())); + } + + #[test] + fn test_process_default_functions_with_schema_default_attr() { + use vespera_core::schema::{Schema, SchemaRef}; + + let file_ast: syn::File = syn::parse_str("").unwrap(); + let struct_item: syn::ItemStruct = + syn::parse_str(r#"pub struct Test { #[schema(default = "100")] pub count: i32 }"#) + .unwrap(); + let mut schema = Schema::object(); + let props = schema.properties.get_or_insert_with(BTreeMap::new); + props.insert( + "count".to_string(), + SchemaRef::Inline(Box::new(Schema::integer())), + ); + process_default_functions(&struct_item, &file_ast, &mut schema); + if let Some(SchemaRef::Inline(prop_schema)) = + schema.properties.as_ref().unwrap().get("count") + { + assert_eq!(prop_schema.default, Some(serde_json::json!(100))); + } else { + panic!("Expected inline schema with default"); + } + } } diff --git a/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__enum_repr_tests__adjacently_tagged_snapshot@adjacently_tagged.snap b/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__enum_repr_tests__adjacently_tagged_snapshot@adjacently_tagged.snap index bba843f..0657d7a 100644 --- a/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__enum_repr_tests__adjacently_tagged_snapshot@adjacently_tagged.snap +++ b/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__enum_repr_tests__adjacently_tagged_snapshot@adjacently_tagged.snap @@ -339,7 +339,9 @@ Schema { schema_type: Some( Integer, ), - format: None, + format: Some( + "int32", + ), title: None, description: None, default: None, diff --git a/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__enum_repr_tests__externally_tagged_empty_struct_variant@externally_tagged_empty_struct.snap b/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__enum_repr_tests__externally_tagged_empty_struct_variant@externally_tagged_empty_struct.snap index 9868fc7..6e48cc6 100644 --- a/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__enum_repr_tests__externally_tagged_empty_struct_variant@externally_tagged_empty_struct.snap +++ b/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__enum_repr_tests__externally_tagged_empty_struct_variant@externally_tagged_empty_struct.snap @@ -193,7 +193,9 @@ Schema { schema_type: Some( Integer, ), - format: None, + format: Some( + "int32", + ), title: None, description: None, default: None, diff --git a/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__enum_repr_tests__internally_tagged_snapshot@internally_tagged.snap b/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__enum_repr_tests__internally_tagged_snapshot@internally_tagged.snap index 0e4c25d..a22a1c4 100644 --- a/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__enum_repr_tests__internally_tagged_snapshot@internally_tagged.snap +++ b/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__enum_repr_tests__internally_tagged_snapshot@internally_tagged.snap @@ -67,7 +67,9 @@ Schema { schema_type: Some( Integer, ), - format: None, + format: Some( + "int32", + ), title: None, description: None, default: None, @@ -260,7 +262,9 @@ Schema { schema_type: Some( Integer, ), - format: None, + format: Some( + "int32", + ), title: None, description: None, default: None, diff --git a/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__enum_repr_tests__untagged_multi_field_tuple_variant@untagged_multi_field_tuple.snap b/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__enum_repr_tests__untagged_multi_field_tuple_variant@untagged_multi_field_tuple.snap index 2e9a0da..f3ee52e 100644 --- a/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__enum_repr_tests__untagged_multi_field_tuple_variant@untagged_multi_field_tuple.snap +++ b/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__enum_repr_tests__untagged_multi_field_tuple_variant@untagged_multi_field_tuple.snap @@ -108,7 +108,9 @@ Schema { schema_type: Some( Integer, ), - format: None, + format: Some( + "int32", + ), title: None, description: None, default: None, @@ -250,7 +252,9 @@ Schema { schema_type: Some( Integer, ), - format: None, + format: Some( + "int32", + ), title: None, description: None, default: None, diff --git a/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__enum_repr_tests__untagged_snapshot@untagged.snap b/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__enum_repr_tests__untagged_snapshot@untagged.snap index 64f0b88..e4ea229 100644 --- a/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__enum_repr_tests__untagged_snapshot@untagged.snap +++ b/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__enum_repr_tests__untagged_snapshot@untagged.snap @@ -130,7 +130,9 @@ Schema { schema_type: Some( Number, ), - format: None, + format: Some( + "double", + ), title: None, description: None, default: None, diff --git a/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__parse_enum_to_schema_tuple_and_named_variants@tuple_named_named_object.snap b/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__parse_enum_to_schema_tuple_and_named_variants@tuple_named_named_object.snap index 5ab8054..f98c87a 100644 --- a/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__parse_enum_to_schema_tuple_and_named_variants@tuple_named_named_object.snap +++ b/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__parse_enum_to_schema_tuple_and_named_variants@tuple_named_named_object.snap @@ -94,7 +94,9 @@ Schema { schema_type: Some( Integer, ), - format: None, + format: Some( + "int32", + ), title: None, description: None, default: None, diff --git a/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__parse_enum_to_schema_tuple_and_named_variants@tuple_named_tuple_multi.snap b/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__parse_enum_to_schema_tuple_and_named_variants@tuple_named_tuple_multi.snap index 8127257..2144c5b 100644 --- a/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__parse_enum_to_schema_tuple_and_named_variants@tuple_named_tuple_multi.snap +++ b/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__parse_enum_to_schema_tuple_and_named_variants@tuple_named_tuple_multi.snap @@ -90,7 +90,9 @@ Schema { schema_type: Some( Integer, ), - format: None, + format: Some( + "int32", + ), title: None, description: None, default: None, diff --git a/crates/vespera_macro/src/parser/schema/type_schema.rs b/crates/vespera_macro/src/parser/schema/type_schema.rs index b94f9ec..9b0fadd 100644 --- a/crates/vespera_macro/src/parser/schema/type_schema.rs +++ b/crates/vespera_macro/src/parser/schema/type_schema.rs @@ -37,15 +37,20 @@ pub fn is_primitive_type(ty: &Type) -> bool { "i8" | "i16" | "i32" | "i64" + | "i128" + | "isize" | "u8" | "u16" | "u32" | "u64" + | "u128" + | "usize" | "f32" | "f64" | "bool" | "String" | "str" + | "Decimal" ) } else { false @@ -217,12 +222,73 @@ fn parse_type_impl( } // Handle primitive types + // For standard OpenAPI format types (i32, i64, f32, f64), use `format` + // per the OAS 3.1 Data Type Format spec. For non-standard types, fall + // back to `minimum`/`maximum` constraints. match ident_str.as_str() { - "i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" | "StatusCode" => { - SchemaRef::Inline(Box::new(Schema::integer())) - } - "f32" | "f64" => SchemaRef::Inline(Box::new(Schema::number())), + // Signed integers: use OpenAPI format registry + // https://spec.openapis.org/registry/format/index.html + "i8" => SchemaRef::Inline(Box::new(Schema { + format: Some("int8".to_string()), + ..Schema::integer() + })), + "i16" => SchemaRef::Inline(Box::new(Schema { + format: Some("int16".to_string()), + ..Schema::integer() + })), + "i32" => SchemaRef::Inline(Box::new(Schema { + format: Some("int32".to_string()), + ..Schema::integer() + })), + "i64" => SchemaRef::Inline(Box::new(Schema { + format: Some("int64".to_string()), + ..Schema::integer() + })), + // Unsigned integers: use OpenAPI format registry + "u8" => SchemaRef::Inline(Box::new(Schema { + format: Some("uint8".to_string()), + ..Schema::integer() + })), + "u16" => SchemaRef::Inline(Box::new(Schema { + format: Some("uint16".to_string()), + ..Schema::integer() + })), + "u32" => SchemaRef::Inline(Box::new(Schema { + format: Some("uint32".to_string()), + ..Schema::integer() + })), + "u64" => SchemaRef::Inline(Box::new(Schema { + format: Some("uint64".to_string()), + ..Schema::integer() + })), + // i128, isize, StatusCode: no standard format in the registry + "i128" | "isize" | "StatusCode" => SchemaRef::Inline(Box::new(Schema::integer())), + // u128, usize: unsigned with no standard format — use minimum: 0 + "u128" | "usize" => SchemaRef::Inline(Box::new(Schema { + minimum: Some(0.0), + ..Schema::integer() + })), + "f32" => SchemaRef::Inline(Box::new(Schema { + format: Some("float".to_string()), + ..Schema::number() + })), + "f64" => SchemaRef::Inline(Box::new(Schema { + format: Some("double".to_string()), + ..Schema::number() + })), + "Decimal" => SchemaRef::Inline(Box::new(Schema { + format: Some("decimal".to_string()), + ..Schema::number() + })), "bool" => SchemaRef::Inline(Box::new(Schema::boolean())), + "char" => SchemaRef::Inline(Box::new(Schema { + format: Some("char".to_string()), + ..Schema::string() + })), + "Uuid" => SchemaRef::Inline(Box::new(Schema { + format: Some("uuid".to_string()), + ..Schema::string() + })), "String" | "str" => SchemaRef::Inline(Box::new(Schema::string())), // Date-time types from chrono and time crates "DateTime" diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_path_single.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_path_single.snap index 96e9ec3..11a8ab1 100644 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_path_single.snap +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_path_single.snap @@ -17,7 +17,9 @@ expression: parameters schema_type: Some( Integer, ), - format: None, + format: Some( + "int32", + ), title: None, description: None, default: None, diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_path_tuple.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_path_tuple.snap index aba445e..2b74ca2 100644 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_path_tuple.snap +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_path_tuple.snap @@ -73,7 +73,9 @@ expression: parameters schema_type: Some( Integer, ), - format: None, + format: Some( + "int32", + ), title: None, description: None, default: None, diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_query_struct.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_query_struct.snap index e956e74..91bd9dd 100644 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_query_struct.snap +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_query_struct.snap @@ -17,7 +17,9 @@ expression: parameters schema_type: Some( Integer, ), - format: None, + format: Some( + "int32", + ), title: None, description: None, default: None, @@ -73,7 +75,9 @@ expression: parameters schema_type: Some( Integer, ), - format: None, + format: Some( + "int32", + ), title: None, description: None, default: None, diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_query_user.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_query_user.snap index d85e928..48577f1 100644 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_query_user.snap +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_query_user.snap @@ -17,7 +17,9 @@ expression: parameters schema_type: Some( Integer, ), - format: None, + format: Some( + "int32", + ), title: None, description: None, default: None, diff --git a/crates/vespera_macro/src/router_codegen.rs b/crates/vespera_macro/src/router_codegen.rs index 9370fee..4f38030 100644 --- a/crates/vespera_macro/src/router_codegen.rs +++ b/crates/vespera_macro/src/router_codegen.rs @@ -606,7 +606,7 @@ mod tests { let folder_name = "routes"; let result = generate_router_code( - &collect_metadata(temp_dir.path(), folder_name).unwrap(), + &collect_metadata(temp_dir.path(), folder_name).unwrap().0, None, None, &[], @@ -762,7 +762,7 @@ pub fn get_users() -> String { } let result = generate_router_code( - &collect_metadata(temp_dir.path(), folder_name).unwrap(), + &collect_metadata(temp_dir.path(), folder_name).unwrap().0, None, None, &[], @@ -841,7 +841,7 @@ pub fn update_user() -> String { ); let result = generate_router_code( - &collect_metadata(temp_dir.path(), folder_name).unwrap(), + &collect_metadata(temp_dir.path(), folder_name).unwrap().0, None, None, &[], @@ -895,7 +895,7 @@ pub fn create_users() -> String { ); let result = generate_router_code( - &collect_metadata(temp_dir.path(), folder_name).unwrap(), + &collect_metadata(temp_dir.path(), folder_name).unwrap().0, None, None, &[], @@ -941,7 +941,7 @@ pub fn index() -> String { ); let result = generate_router_code( - &collect_metadata(temp_dir.path(), folder_name).unwrap(), + &collect_metadata(temp_dir.path(), folder_name).unwrap().0, None, None, &[], @@ -978,7 +978,7 @@ pub fn get_users() -> String { ); let result = generate_router_code( - &collect_metadata(temp_dir.path(), folder_name).unwrap(), + &collect_metadata(temp_dir.path(), folder_name).unwrap().0, None, None, &[], @@ -1311,7 +1311,7 @@ pub fn get_users() -> String { "#, ); - let mut metadata = collect_metadata(temp_dir.path(), folder_name).unwrap(); + let (mut metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name).unwrap(); // Inject an additional route with invalid method metadata.routes.push(crate::metadata::RouteMetadata { method: "CONNECT".to_string(), diff --git a/crates/vespera_macro/src/schema_macro/circular.rs b/crates/vespera_macro/src/schema_macro/circular.rs index 52b33ce..d9dbe68 100644 --- a/crates/vespera_macro/src/schema_macro/circular.rs +++ b/crates/vespera_macro/src/schema_macro/circular.rs @@ -3,6 +3,8 @@ //! Provides functions to detect and handle circular references between //! `SeaORM` models when generating schema types. +use std::collections::HashMap; + use proc_macro2::TokenStream; use quote::quote; @@ -12,137 +14,97 @@ use super::{ }; use crate::parser::extract_skip; -/// Detect circular reference fields in a related schema. -/// -/// When generating `MemoSchema.user`, we need to check if `UserSchema` has any fields -/// that reference back to `MemoSchema` via BelongsTo/HasOne (FK-based relations). +/// Combined result of circular reference analysis. /// -/// `HasMany` relations are NOT considered circular because they are excluded by default -/// from generated schemas. +/// Produced by [`analyze_circular_refs()`] which parses a definition string once +/// and extracts all three pieces of information that would otherwise require +/// three separate parse calls. +pub struct CircularAnalysis { + /// Field names that would create circular references. + pub circular_fields: Vec, + /// Whether the model has any `BelongsTo` or `HasOne` relations (FK-based). + pub has_fk_relations: bool, + /// For each `HasOne`/`BelongsTo` field, whether the FK is required (not `Option`). + /// + /// Keyed by field name. Contains entries for ALL `HasOne`/`BelongsTo` fields, + /// not just circular ones, so callers can look up any relation field. + pub circular_field_required: HashMap, +} + +/// Analyze a struct definition for circular references, FK relations, and FK optionality +/// in a single parse + single field walk. /// -/// Returns a list of field names that would create circular references. -pub fn detect_circular_fields( - _source_schema_name: &str, - source_module_path: &[String], - related_schema_def: &str, -) -> Vec { - // Parse the related schema definition - let Ok(parsed) = syn::parse_str::(related_schema_def) else { - return Vec::new(); +/// Parses the definition string once and extracts all circular reference +/// information in a single field walk. +pub fn analyze_circular_refs(source_module_path: &[String], definition: &str) -> CircularAnalysis { + let Ok(parsed) = syn::parse_str::(definition) else { + return CircularAnalysis { + circular_fields: Vec::new(), + has_fk_relations: false, + circular_field_required: HashMap::new(), + }; }; - // Get the source module name (e.g., "memo" from ["crate", "models", "memo"]) - let source_module = source_module_path - .last() - .map_or("", std::string::String::as_str); - let syn::Fields::Named(fields_named) = &parsed.fields else { - return Vec::new(); + return CircularAnalysis { + circular_fields: Vec::new(), + has_fk_relations: false, + circular_field_required: HashMap::new(), + }; }; - fields_named - .named - .iter() - .filter_map(|field| { - let field_ident = field.ident.as_ref()?; - let field_name = field_ident.to_string(); - - // Check if this field's type references the source schema - let ty_str = quote!(#field.ty).to_string(); - - // Normalize whitespace: quote!() produces "foo :: bar" instead of "foo::bar" - // Remove all whitespace to make pattern matching reliable - let ty_str_normalized = ty_str.replace(' ', ""); - - // SKIP HasMany relations - they are excluded by default from schemas, - // so they don't create actual circular references in the output - if ty_str_normalized.contains("HasMany<") { - return None; - } - - // Check for BelongsTo/HasOne patterns that reference the source: - // - HasOne - // - BelongsTo - // - Box (already converted) - // - Option> - let is_circular = (ty_str_normalized.contains("HasOne<") - || ty_str_normalized.contains("BelongsTo<") - || ty_str_normalized.contains("Box<")) - && (ty_str_normalized.contains(&format!("{source_module}::Schema")) - || ty_str_normalized.contains(&format!("{source_module}::Entity")) - || ty_str_normalized - .contains(&format!("{}Schema", capitalize_first(source_module)))); - - is_circular.then_some(field_name) - }) - .collect() -} - -/// Check if a Model has any `BelongsTo` or `HasOne` relations (FK-based relations). -/// -/// This is used to determine if the target schema has `from_model()` method -/// (async, with DB) or simple `From` impl (sync, no DB). -/// -/// - Schemas with FK relations -> have `from_model()`, need async call -/// - Schemas without FK relations -> have `From`, can use sync conversion -pub fn has_fk_relations(model_def: &str) -> bool { - let Ok(parsed) = syn::parse_str::(model_def) else { - return false; - }; + let source_module = source_module_path + .last() + .map_or("", std::string::String::as_str); - if let syn::Fields::Named(fields_named) = &parsed.fields { - for field in &fields_named.named { - let field_ty = &field.ty; - let ty_str = quote!(#field_ty).to_string().replace(' ', ""); + let mut circular_fields = Vec::new(); + let mut has_fk = false; + let mut circular_field_required = HashMap::new(); + + for field in &fields_named.named { + // FieldsNamed guarantees all fields have identifiers + let field_ident = field.ident.as_ref().expect("named field has ident"); + let field_name = field_ident.to_string(); + let ty_str = quote!(#field.ty).to_string().replace(' ', ""); + + // --- has_fk_relations logic --- + if ty_str.contains("HasOne<") || ty_str.contains("BelongsTo<") { + has_fk = true; + + // --- is_circular_relation_required logic (for ALL FK fields) --- + let required = extract_belongs_to_from_field(&field.attrs).is_some_and(|fk| { + fields_named + .named + .iter() + .find(|f| { + f.ident.as_ref().map(std::string::ToString::to_string) == Some(fk.clone()) + }) + .is_some_and(|f| !is_option_type(&f.ty)) + }); + circular_field_required.insert(field_name.clone(), required); + } - // Check for BelongsTo or HasOne patterns - if ty_str.contains("HasOne<") || ty_str.contains("BelongsTo<") { - return true; + // --- detect_circular_fields logic --- + // Skip HasMany — they are excluded by default and don't create circular refs + if !ty_str.contains("HasMany<") { + let is_circular = (ty_str.contains("HasOne<") + || ty_str.contains("BelongsTo<") + || ty_str.contains("Box<")) + && (ty_str.contains(&format!("{source_module}::Schema")) + || ty_str.contains(&format!("{source_module}::Entity")) + || ty_str.contains(&format!("{}Schema", capitalize_first(source_module)))); + + if is_circular { + circular_fields.push(field_name); } } } - false -} - -/// Check if a circular relation field in the related schema is required (Box) or optional (Option>). -/// -/// Returns true if the circular relation is required and needs a parent stub. -pub fn is_circular_relation_required(related_model_def: &str, circular_field_name: &str) -> bool { - let Ok(parsed) = syn::parse_str::(related_model_def) else { - return false; - }; - - let syn::Fields::Named(fields_named) = &parsed.fields else { - return false; - }; - - // Find the circular field by name - let Some(field) = fields_named - .named - .iter() - .find(|f| f.ident.as_ref().is_some_and(|i| i == circular_field_name)) - else { - return false; - }; - - // Check if this is a HasOne/BelongsTo with required FK - let ty_str = quote!(#field.ty).to_string().replace(' ', ""); - if !ty_str.contains("HasOne<") && !ty_str.contains("BelongsTo<") { - return false; + CircularAnalysis { + circular_fields, + has_fk_relations: has_fk, + circular_field_required, } - - // Check FK field optionality - let Some(fk) = extract_belongs_to_from_field(&field.attrs) else { - return false; - }; - - // Find FK field and check if it's Option - fields_named - .named - .iter() - .find(|f| f.ident.as_ref().map(std::string::ToString::to_string) == Some(fk.clone())) - .is_some_and(|f| !is_option_type(&f.ty)) } /// Generate a default value for a `SeaORM` relation field in inline construction. @@ -326,7 +288,6 @@ mod tests { #[rstest] #[case( - "Memo", &["crate", "models", "memo"], r"pub struct UserSchema { pub id: i32, @@ -335,7 +296,6 @@ mod tests { vec![] // HasMany is not considered circular )] #[case( - "User", &["crate", "models", "user"], r"pub struct MemoSchema { pub id: i32, @@ -344,7 +304,6 @@ mod tests { vec!["user".to_string()] )] #[case( - "User", &["crate", "models", "user"], r"pub struct MemoSchema { pub id: i32, @@ -353,7 +312,6 @@ mod tests { vec!["user".to_string()] )] #[case( - "User", &["crate", "models", "user"], r"pub struct MemoSchema { pub id: i32, @@ -362,7 +320,6 @@ mod tests { vec!["user".to_string()] )] #[case( - "Memo", &["crate", "models", "memo"], r"pub struct UserSchema { pub id: i32, @@ -371,7 +328,6 @@ mod tests { vec![] // No circular fields )] fn test_detect_circular_fields( - #[case] source_schema_name: &str, #[case] source_module_path: &[&str], #[case] related_schema_def: &str, #[case] expected: Vec, @@ -380,27 +336,28 @@ mod tests { .iter() .map(std::string::ToString::to_string) .collect(); - let result = detect_circular_fields(source_schema_name, &module_path, related_schema_def); + let result = analyze_circular_refs(&module_path, related_schema_def).circular_fields; assert_eq!(result, expected); } #[test] fn test_detect_circular_fields_invalid_struct() { - let result = detect_circular_fields("Test", &["crate".to_string()], "not valid rust"); + let result = + analyze_circular_refs(&["crate".to_string()], "not valid rust").circular_fields; assert!(result.is_empty()); } #[test] fn test_detect_circular_fields_unnamed_fields() { - let result = detect_circular_fields( - "Test", + let result = analyze_circular_refs( &[ "crate".to_string(), "models".to_string(), "test".to_string(), ], "pub struct TupleStruct(i32, String);", - ); + ) + .circular_fields; assert!(result.is_empty()); } @@ -434,30 +391,44 @@ mod tests { false // HasMany alone doesn't count as FK relation )] fn test_has_fk_relations(#[case] model_def: &str, #[case] expected: bool) { - assert_eq!(has_fk_relations(model_def), expected); + assert_eq!( + analyze_circular_refs(&[], model_def).has_fk_relations, + expected + ); } #[test] fn test_has_fk_relations_invalid_struct() { - assert!(!has_fk_relations("not valid rust")); + assert!(!analyze_circular_refs(&[], "not valid rust").has_fk_relations); } #[test] fn test_has_fk_relations_unnamed_fields() { - assert!(!has_fk_relations("pub struct TupleStruct(i32, String);")); + assert!( + !analyze_circular_refs(&[], "pub struct TupleStruct(i32, String);").has_fk_relations + ); } #[test] fn test_is_circular_relation_required_invalid_struct() { - assert!(!is_circular_relation_required("not valid rust", "user")); + assert!( + !analyze_circular_refs(&[], "not valid rust") + .circular_field_required + .get("user") + .copied() + .unwrap_or(false) + ); } #[test] fn test_is_circular_relation_required_unnamed_fields() { - assert!(!is_circular_relation_required( - "pub struct TupleStruct(i32, String);", - "user" - )); + assert!( + !analyze_circular_refs(&[], "pub struct TupleStruct(i32, String);") + .circular_field_required + .get("user") + .copied() + .unwrap_or(false) + ); } #[test] @@ -466,7 +437,13 @@ mod tests { pub id: i32, pub name: String, }"; - assert!(!is_circular_relation_required(model_def, "nonexistent")); + assert!( + !analyze_circular_refs(&[], model_def) + .circular_field_required + .get("nonexistent") + .copied() + .unwrap_or(false) + ); } #[test] @@ -638,10 +615,10 @@ mod tests { assert!(!output.contains("memos : r . memos")); } - // Additional coverage tests for is_circular_relation_required + // Additional coverage tests for circular_field_required via analyze_circular_refs #[test] - fn test_is_circular_relation_required_has_one_with_required_fk() { + fn test_circular_field_required_has_one_with_required_fk() { // Model has HasOne relation with a required (non-Option) FK field let model_def = r#"pub struct Model { pub id: i32, @@ -650,14 +627,18 @@ mod tests { pub user: HasOne, }"#; // The FK field 'user_id' is i32 (required), so circular relation IS required - let result = is_circular_relation_required(model_def, "user"); + let result = analyze_circular_refs(&[], model_def) + .circular_field_required + .get("user") + .copied() + .unwrap_or(false); // Without proper BelongsTo attribute parsing, this returns false // because extract_belongs_to_from_field won't find the FK assert!(!result); } #[test] - fn test_is_circular_relation_required_belongs_to_with_optional_fk() { + fn test_circular_field_required_belongs_to_with_optional_fk() { // Model has BelongsTo relation with optional FK field let model_def = r#"pub struct Model { pub id: i32, @@ -666,29 +647,41 @@ mod tests { pub user: BelongsTo, }"#; // FK field is Option, so circular relation is NOT required - let result = is_circular_relation_required(model_def, "user"); + let result = analyze_circular_refs(&[], model_def) + .circular_field_required + .get("user") + .copied() + .unwrap_or(false); assert!(!result); } #[test] - fn test_is_circular_relation_required_non_relation_field() { + fn test_circular_field_required_non_relation_field() { // Field exists but is not a relation type let model_def = r"pub struct Model { pub id: i32, pub name: String, }"; - let result = is_circular_relation_required(model_def, "name"); + let result = analyze_circular_refs(&[], model_def) + .circular_field_required + .get("name") + .copied() + .unwrap_or(false); assert!(!result); } #[test] - fn test_is_circular_relation_required_field_without_ident() { + fn test_circular_field_required_field_without_ident() { // Struct with fields that have no ident (tuple-like, but in braces - edge case) let model_def = r"pub struct Model { pub id: i32, }"; // Looking for a field that doesn't match - let result = is_circular_relation_required(model_def, "nonexistent_field"); + let result = analyze_circular_refs(&[], model_def) + .circular_field_required + .get("nonexistent_field") + .copied() + .unwrap_or(false); assert!(!result); } @@ -731,20 +724,20 @@ mod tests { assert!(output.contains("user : None")); } - // Additional coverage tests for detect_circular_fields + // Additional coverage tests for circular_fields via analyze_circular_refs #[test] - fn test_detect_circular_fields_empty_module_path() { + fn test_circular_fields_empty_module_path() { // Edge case: empty module path - let result = detect_circular_fields("Test", &[], "pub struct Schema { pub id: i32 }"); + let result = + analyze_circular_refs(&[], "pub struct Schema { pub id: i32 }").circular_fields; assert!(result.is_empty()); } #[test] - fn test_detect_circular_fields_option_box_pattern() { + fn test_circular_fields_option_box_pattern() { // Test Option> pattern detection - let result = detect_circular_fields( - "Memo", + let result = analyze_circular_refs( &[ "crate".to_string(), "models".to_string(), @@ -754,15 +747,15 @@ mod tests { pub id: i32, pub memo: Option>, }", - ); + ) + .circular_fields; assert_eq!(result, vec!["memo".to_string()]); } #[test] - fn test_detect_circular_fields_schema_suffix_pattern() { + fn test_circular_fields_schema_suffix_pattern() { // Test MemoSchema suffix pattern detection - let result = detect_circular_fields( - "Memo", + let result = analyze_circular_refs( &[ "crate".to_string(), "models".to_string(), @@ -772,20 +765,21 @@ mod tests { pub id: i32, pub memo: Box, }", - ); + ) + .circular_fields; assert_eq!(result, vec!["memo".to_string()]); } #[test] - fn test_detect_circular_fields_field_without_ident() { + fn test_circular_fields_field_without_ident() { // Fields without identifiers (parsing edge case) - let result = detect_circular_fields( - "Test", + let result = analyze_circular_refs( &["crate".to_string(), "test".to_string()], r"pub struct Schema { pub id: i32, }", - ); + ) + .circular_fields; assert!(result.is_empty()); } @@ -894,7 +888,7 @@ mod tests { // Tests for FK field lookup and required relation handling #[test] - fn test_is_circular_relation_required_belongs_to_with_from_attr_required_fk() { + fn test_circular_field_required_belongs_to_with_from_attr_required_fk() { // Model has BelongsTo with sea_orm(from = "user_id") attribute and required FK let model_def = r#"pub struct Model { pub id: i32, @@ -903,12 +897,16 @@ mod tests { pub user: BelongsTo, }"#; // FK field 'user_id' is i32 (required), so should return true - let result = is_circular_relation_required(model_def, "user"); + let result = analyze_circular_refs(&[], model_def) + .circular_field_required + .get("user") + .copied() + .unwrap_or(false); assert!(result); } #[test] - fn test_is_circular_relation_required_belongs_to_with_from_attr_optional_fk() { + fn test_circular_field_required_belongs_to_with_from_attr_optional_fk() { // Model has BelongsTo with sea_orm(from = "user_id") attribute and optional FK let model_def = r#"pub struct Model { pub id: i32, @@ -917,12 +915,16 @@ mod tests { pub user: BelongsTo, }"#; // FK field 'user_id' is Option, so should return false - let result = is_circular_relation_required(model_def, "user"); + let result = analyze_circular_refs(&[], model_def) + .circular_field_required + .get("user") + .copied() + .unwrap_or(false); assert!(!result); } #[test] - fn test_is_circular_relation_required_has_one_with_from_attr_required_fk() { + fn test_circular_field_required_has_one_with_from_attr_required_fk() { // Model has HasOne with sea_orm(from = "profile_id") attribute and required FK let model_def = r#"pub struct Model { pub id: i32, @@ -931,12 +933,16 @@ mod tests { pub profile: HasOne, }"#; // FK field 'profile_id' is i64 (required), so should return true - let result = is_circular_relation_required(model_def, "profile"); + let result = analyze_circular_refs(&[], model_def) + .circular_field_required + .get("profile") + .copied() + .unwrap_or(false); assert!(result); } #[test] - fn test_is_circular_relation_required_from_attr_fk_field_not_found() { + fn test_circular_field_required_from_attr_fk_field_not_found() { // Model has from attribute but FK field doesn't exist let model_def = r#"pub struct Model { pub id: i32, @@ -944,7 +950,11 @@ mod tests { pub user: BelongsTo, }"#; // FK field doesn't exist, so should return false - let result = is_circular_relation_required(model_def, "user"); + let result = analyze_circular_refs(&[], model_def) + .circular_field_required + .get("user") + .copied() + .unwrap_or(false); assert!(!result); } diff --git a/crates/vespera_macro/src/schema_macro/codegen.rs b/crates/vespera_macro/src/schema_macro/codegen.rs index 6f054c2..feb0520 100644 --- a/crates/vespera_macro/src/schema_macro/codegen.rs +++ b/crates/vespera_macro/src/schema_macro/codegen.rs @@ -197,6 +197,18 @@ pub fn schema_to_tokens(schema: &Schema) -> TokenStream { quote! { None } }; + let minimum_tokens = if let Some(min) = schema.minimum { + quote! { Some(#min) } + } else { + quote! { None } + }; + + let maximum_tokens = if let Some(max) = schema.maximum { + quote! { Some(#max) } + } else { + quote! { None } + }; + quote! { vespera::schema::Schema { ref_path: #ref_path_tokens, @@ -206,6 +218,8 @@ pub fn schema_to_tokens(schema: &Schema) -> TokenStream { items: #items_tokens, properties: #properties_tokens, required: #required_tokens, + minimum: #minimum_tokens, + maximum: #maximum_tokens, ..vespera::schema::Schema::new(vespera::schema::SchemaType::Object) } } @@ -432,4 +446,30 @@ mod tests { assert!(output.contains("id")); assert!(output.contains("name")); } + + #[test] + fn test_schema_to_tokens_with_minimum() { + let mut schema = Schema::new(SchemaType::Integer); + schema.minimum = Some(0.0); + let tokens = schema_to_tokens(&schema); + let output = tokens.to_string(); + assert!( + output.contains("minimum"), + "should contain minimum: {output}" + ); + assert!(output.contains("Some"), "should contain Some: {output}"); + } + + #[test] + fn test_schema_to_tokens_with_maximum() { + let mut schema = Schema::new(SchemaType::Integer); + schema.maximum = Some(255.0); + let tokens = schema_to_tokens(&schema); + let output = tokens.to_string(); + assert!( + output.contains("maximum"), + "should contain maximum: {output}" + ); + assert!(output.contains("Some"), "should contain Some: {output}"); + } } diff --git a/crates/vespera_macro/src/schema_macro/from_model.rs b/crates/vespera_macro/src/schema_macro/from_model.rs index c20affa..734aae8 100644 --- a/crates/vespera_macro/src/schema_macro/from_model.rs +++ b/crates/vespera_macro/src/schema_macro/from_model.rs @@ -10,8 +10,8 @@ use syn::Type; use super::{ circular::{ - detect_circular_fields, generate_inline_struct_construction, - generate_inline_type_construction, has_fk_relations, is_circular_relation_required, + analyze_circular_refs, generate_inline_struct_construction, + generate_inline_type_construction, }, file_lookup::{find_fk_column_from_target_entity, find_struct_from_schema_path}, seaorm::RelationFieldInfo, @@ -231,15 +231,15 @@ pub fn generate_from_model_with_relations( let related_model = find_struct_from_schema_path(&model_path_str); if let Some(ref model) = related_model { - let circular_fields = detect_circular_fields( - new_type_name.to_string().as_str(), - source_module_path, - &model.definition, - ); + let analysis = analyze_circular_refs(source_module_path, &model.definition); // Check if any circular field is a required relation - circular_fields - .iter() - .any(|cf| is_circular_relation_required(&model.definition, cf)) + analysis.circular_fields.iter().any(|cf| { + analysis + .circular_field_required + .get(cf) + .copied() + .unwrap_or(false) + }) } else { false } @@ -301,11 +301,9 @@ pub fn generate_from_model_with_relations( // Get the definition string let related_def_str = related_model_from_file.as_ref().map_or("", |s| s.definition.as_str()); - // Check for circular references - // The source module path tells us what module we're in (e.g., ["crate", "models", "memo"]) - // We need to check if the related model has any relation fields pointing back to our module - let circular_fields = detect_circular_fields(new_type_name.to_string().as_str(), source_module_path, related_def_str); - + // Analyze circular references, FK relations, and FK optionality in ONE pass + let analysis = analyze_circular_refs(source_module_path, related_def_str); + let circular_fields = &analysis.circular_fields; let has_circular = !circular_fields.is_empty(); // Check if we have inline type info - if so, use the inline type @@ -344,7 +342,7 @@ pub fn generate_from_model_with_relations( "HasOne" | "BelongsTo" => { if has_circular { // Use inline construction to break circular ref - let inline_construct = generate_inline_struct_construction(schema_path, related_def_str, &circular_fields, "r"); + let inline_construct = generate_inline_struct_construction(schema_path, related_def_str, circular_fields, "r"); if rel.is_optional { quote! { #new_ident: #source_ident.map(|r| Box::new(#inline_construct)) @@ -360,8 +358,8 @@ pub fn generate_from_model_with_relations( } } } else { - // No circular ref - check if target schema has FK relations - let target_has_fk = has_fk_relations(related_def_str); + // No circular ref - use has_fk_relations from the analysis + let target_has_fk = analysis.has_fk_relations; if target_has_fk { // Target schema has FK relations -> use async from_model() @@ -405,13 +403,13 @@ pub fn generate_from_model_with_relations( // when explicitly picked. Use inline construction (no relations). if has_circular { // Use inline construction to break circular ref - let inline_construct = generate_inline_struct_construction(schema_path, related_def_str, &circular_fields, "r"); + let inline_construct = generate_inline_struct_construction(schema_path, related_def_str, circular_fields, "r"); quote! { #new_ident: #source_ident.into_iter().map(|r| #inline_construct).collect() } } else { - // No circular ref - check if target schema has FK relations - let target_has_fk = has_fk_relations(related_def_str); + // No circular ref - use has_fk_relations from the analysis + let target_has_fk = analysis.has_fk_relations; if target_has_fk { // Target has FK relations but HasMany doesn't load nested data anyway, diff --git a/crates/vespera_macro/src/schema_macro/inline_types.rs b/crates/vespera_macro/src/schema_macro/inline_types.rs index 01419b1..2bc0690 100644 --- a/crates/vespera_macro/src/schema_macro/inline_types.rs +++ b/crates/vespera_macro/src/schema_macro/inline_types.rs @@ -7,7 +7,7 @@ use proc_macro2::TokenStream; use quote::quote; use super::{ - circular::detect_circular_fields, + circular::analyze_circular_refs, file_lookup::find_model_from_schema_path, seaorm::{RelationFieldInfo, convert_type_with_chrono}, type_utils::{ @@ -82,7 +82,7 @@ pub fn generate_inline_relation_type_from_def( }; // Detect circular fields - let circular_fields = detect_circular_fields("", source_module_path, model_def); + let circular_fields = analyze_circular_refs(source_module_path, model_def).circular_fields; // If no circular fields, no need for inline type if circular_fields.is_empty() { diff --git a/crates/vespera_macro/src/schema_macro/mod.rs b/crates/vespera_macro/src/schema_macro/mod.rs index 71aafcd..e3ae68d 100644 --- a/crates/vespera_macro/src/schema_macro/mod.rs +++ b/crates/vespera_macro/src/schema_macro/mod.rs @@ -29,6 +29,7 @@ use proc_macro2::TokenStream; use quote::quote; use seaorm::{ RelationFieldInfo, convert_relation_type_to_schema_with_info, convert_type_with_chrono, + extract_sea_orm_default_value, is_sql_function_default, }; use transformation::{ build_omit_set, build_partial_config, build_pick_set, build_rename_map, determine_rename_all, @@ -47,7 +48,7 @@ use validation::{ use crate::{ metadata::StructMetadata, - parser::{extract_field_rename, strip_raw_prefix}, + parser::{extract_default, extract_field_rename, strip_raw_prefix}, }; /// Generate schema code from a struct with optional field filtering @@ -222,6 +223,8 @@ pub fn generate_schema_type_code( let mut relation_fields: Vec = Vec::new(); // Track inline types that need to be generated for circular relations let mut inline_type_definitions: Vec = Vec::new(); + // Track default value functions generated from sea_orm(default_value) + let mut default_functions: Vec = Vec::new(); if let syn::Fields::Named(fields_named) = &parsed_struct.fields { for field in &fields_named.named { @@ -405,6 +408,18 @@ pub fn generate_schema_type_code( // that may have ORM-specific attributes we don't want in the generated struct let serde_field_attrs = extract_field_serde_attrs(&field.attrs); + // Generate serde default + schema(default) from sea_orm(default_value) + // Only for non-partial, non-Option fields with literal (non-SQL-function) defaults + let (serde_default_attr, schema_default_attr) = generate_sea_orm_default_attrs( + &field.attrs, + new_type_name, + &rust_field_name, + original_ty, + &field_ty, + should_wrap_option || is_option_type(original_ty), + &mut default_functions, + ); + // Check if field should be renamed if let Some(new_name) = rename_map.get(&rust_field_name) { // Create new identifier for the field @@ -421,6 +436,8 @@ pub fn generate_schema_type_code( field_tokens.push(quote! { #(#doc_attrs)* #(#filtered_attrs)* + #serde_default_attr + #schema_default_attr #[serde(rename = #json_name)] #vis #new_field_ident: #field_ty }); @@ -439,6 +456,8 @@ pub fn generate_schema_type_code( field_tokens.push(quote! { #(#doc_attrs)* #(#serde_field_attrs)* + #serde_default_attr + #schema_default_attr #vis #field_ident: #field_ty }); @@ -568,6 +587,9 @@ pub fn generate_schema_type_code( // Inline types for circular relation references #(#inline_type_definitions)* + // Default value functions for sea_orm(default_value) fields + #(#default_functions)* + #(#struct_doc_attrs)* #[derive(serde::Serialize, serde::Deserialize, #clone_derive #schema_derive)] #schema_name_attr @@ -599,5 +621,170 @@ pub fn generate_schema_type_code( Ok((generated_tokens, metadata)) } +/// Generate `#[serde(default = "...")]` and `#[schema(default = "...")]` attributes +/// from `#[sea_orm(default_value = ...)]` on source fields. +/// +/// Returns `(serde_default_attr, schema_default_attr)` as `TokenStream`s. +/// - `serde_default_attr`: `#[serde(default = "default_structname_field")]` for deserialization +/// - `schema_default_attr`: `#[schema(default = "value")]` for OpenAPI default value +/// +/// Also generates a companion default function and appends it to `default_functions`. +/// +/// Skips serde default generation when: +/// - The field type doesn't implement `FromStr` (enums, custom types) +/// - The field already has `#[serde(default)]` +/// - The field is wrapped in `Option` (partial mode or already optional) +/// +/// Always generates `#[schema(default)]` for OpenAPI when a literal default exists. +fn generate_sea_orm_default_attrs( + original_attrs: &[syn::Attribute], + struct_name: &syn::Ident, + field_name: &str, + original_ty: &syn::Type, + field_ty: &dyn quote::ToTokens, + is_optional_or_partial: bool, + default_functions: &mut Vec, +) -> (TokenStream, TokenStream) { + // Don't generate defaults for optional/partial fields + if is_optional_or_partial { + return (quote! {}, quote! {}); + } + + // Check for sea_orm(default_value) + let Some(default_value) = extract_sea_orm_default_value(original_attrs) else { + return (quote! {}, quote! {}); + }; + + // SQL functions like NOW(), CURRENT_TIMESTAMP(), gen_random_uuid() + // can't be expressed as concrete JSON defaults, but we can make the field + // not-required by generating serde(default) with a dummy default value. + if is_sql_function_default(&default_value) { + let has_existing_serde_default = extract_default(original_attrs).is_some(); + if !has_existing_serde_default + && let Some(default_body) = sql_function_default_body(original_ty) + { + let fn_name = format!("default_{struct_name}_{field_name}"); + let fn_ident = syn::Ident::new(&fn_name, proc_macro2::Span::call_site()); + default_functions.push(quote! { + #[allow(non_snake_case)] + fn #fn_ident() -> #field_ty { + #default_body + } + }); + let serde_default_attr = quote! { #[serde(default = #fn_name)] }; + return (serde_default_attr, quote! {}); + } + return (quote! {}, quote! {}); + } + + // Generate #[schema(default = "value")] for OpenAPI (always, regardless of type support) + let schema_default_attr = quote! { #[schema(default = #default_value)] }; + + // Check if field already has serde(default) + let has_existing_serde_default = extract_default(original_attrs).is_some(); + if has_existing_serde_default { + return (quote! {}, schema_default_attr); + } + + // Only generate serde default function for types known to implement FromStr + if !is_parseable_type(original_ty) { + return (quote! {}, schema_default_attr); + } + + // Generate default function with struct-specific name to avoid collisions: + // fn default_{StructName}_{field_name}() -> Type { "value".parse().unwrap() } + let fn_name = format!("default_{struct_name}_{field_name}"); + let fn_ident = syn::Ident::new(&fn_name, proc_macro2::Span::call_site()); + + default_functions.push(quote! { + #[allow(non_snake_case)] + fn #fn_ident() -> #field_ty { + #default_value.parse().unwrap() + } + }); + + let serde_default_attr = quote! { #[serde(default = #fn_name)] }; + + (serde_default_attr, schema_default_attr) +} + +/// Generate a dummy default expression for types with SQL function defaults +/// (e.g., `gen_random_uuid()`, `NOW()`). +/// +/// Returns `Some(TokenStream)` with the default expression for supported types, +/// `None` for types where a dummy default cannot be generated. +/// +/// These defaults exist solely to satisfy `serde(default)` so the field becomes +/// not-required in OpenAPI. The actual value is irrelevant since the database +/// provides the real default. +fn sql_function_default_body(original_ty: &syn::Type) -> Option { + let syn::Type::Path(type_path) = original_ty else { + return None; + }; + let segment = type_path.path.segments.last()?; + let type_name = segment.ident.to_string(); + + match type_name.as_str() { + // Types implementing Default (returns zero/empty values) + "i8" | "i16" | "i32" | "i64" | "i128" | "isize" | "u8" | "u16" | "u32" | "u64" | "u128" + | "usize" | "f32" | "f64" | "bool" | "String" | "Decimal" | "Uuid" => { + Some(quote! { Default::default() }) + } + // SeaORM datetime types → chrono epoch + "DateTimeWithTimeZone" => Some(quote! { + vespera::chrono::DateTime::::UNIX_EPOCH.fixed_offset() + }), + "DateTimeUtc" => Some(quote! { + vespera::chrono::DateTime::::UNIX_EPOCH + }), + "DateTimeLocal" => Some(quote! { + vespera::chrono::DateTime::::UNIX_EPOCH + .with_timezone(&vespera::chrono::Local) + }), + "DateTime" | "NaiveDateTime" => Some(quote! { + vespera::chrono::NaiveDateTime::UNIX_EPOCH + }), + "NaiveDate" | "Date" => Some(quote! { + vespera::chrono::NaiveDate::from_ymd_opt(1970, 1, 1).unwrap() + }), + "NaiveTime" | "Time" => Some(quote! { + vespera::chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap() + }), + _ => None, + } +} + +/// Check if a type is known to implement `FromStr` and can use `.parse().unwrap()`. +/// +/// Returns true for primitive types, String, and Decimal. +/// Returns false for enums and unknown custom types. +fn is_parseable_type(ty: &syn::Type) -> bool { + let syn::Type::Path(type_path) = ty else { + return false; + }; + let Some(segment) = type_path.path.segments.last() else { + return false; + }; + matches!( + segment.ident.to_string().as_str(), + "i8" | "i16" + | "i32" + | "i64" + | "i128" + | "isize" + | "u8" + | "u16" + | "u32" + | "u64" + | "u128" + | "usize" + | "f32" + | "f64" + | "bool" + | "String" + | "Decimal" + ) +} + #[cfg(test)] mod tests; diff --git a/crates/vespera_macro/src/schema_macro/seaorm.rs b/crates/vespera_macro/src/schema_macro/seaorm.rs index e7075ae..7b6d556 100644 --- a/crates/vespera_macro/src/schema_macro/seaorm.rs +++ b/crates/vespera_macro/src/schema_macro/seaorm.rs @@ -229,6 +229,55 @@ pub fn extract_via_rel(attrs: &[syn::Attribute]) -> Option { }) } +/// Extract `default_value` from a `sea_orm` attribute. +/// e.g., `#[sea_orm(default_value = 0.7)]` -> `Some("0.7")` +/// e.g., `#[sea_orm(default_value = "active")]` -> `Some("active")` +pub fn extract_sea_orm_default_value(attrs: &[syn::Attribute]) -> Option { + for attr in attrs { + if !attr.path().is_ident("sea_orm") { + continue; + } + + // Use raw token string parsing to handle all literal types + // (parse_nested_meta can't easily parse non-string literals after `=`) + let syn::Meta::List(meta_list) = &attr.meta else { + continue; + }; + let tokens = meta_list.tokens.to_string(); + + if let Some(start) = tokens.find("default_value") { + let remaining = &tokens[start + "default_value".len()..]; + let remaining = remaining.trim_start(); + if let Some(after_eq) = remaining.strip_prefix('=') { + let value_str = after_eq.trim_start(); + // Extract value until comma or end of tokens + let end = value_str.find(',').unwrap_or(value_str.len()); + let raw_value = value_str[..end].trim(); + + if raw_value.is_empty() { + continue; + } + + // If quoted string, strip quotes and return inner value + if raw_value.starts_with('"') && raw_value.ends_with('"') && raw_value.len() >= 2 { + return Some(raw_value[1..raw_value.len() - 1].to_string()); + } + // Numeric, bool, or other literal — return as-is + return Some(raw_value.to_string()); + } + } + } + None +} + +/// Check if a `sea_orm(default_value)` is a SQL function (e.g., `"NOW()"`, `"CURRENT_TIMESTAMP()"`, `"UUID()"`) +/// that cannot be converted to a Rust default value. +/// +/// Detection: any value containing parentheses is treated as a SQL function call. +pub fn is_sql_function_default(value: &str) -> bool { + value.contains('(') +} + /// Check if a field in the struct is optional (Option). pub fn is_field_optional_in_struct(struct_item: &syn::ItemStruct, field_name: &str) -> bool { if let syn::Fields::Named(fields_named) = &struct_item.fields { @@ -1056,4 +1105,113 @@ mod tests { let result = extract_via_rel(&attrs); assert_eq!(result, Some("Comments".to_string())); } + + #[test] + fn test_extract_sea_orm_default_value_float() { + let attrs: Vec = vec![syn::parse_quote!( + #[sea_orm(default_value = 0.7)] + )]; + let result = extract_sea_orm_default_value(&attrs); + assert_eq!(result, Some("0.7".to_string())); + } + + #[test] + fn test_extract_sea_orm_default_value_int() { + let attrs: Vec = vec![syn::parse_quote!( + #[sea_orm(default_value = 42)] + )]; + let result = extract_sea_orm_default_value(&attrs); + assert_eq!(result, Some("42".to_string())); + } + + #[test] + fn test_extract_sea_orm_default_value_string() { + let attrs: Vec = vec![syn::parse_quote!( + #[sea_orm(default_value = "active")] + )]; + let result = extract_sea_orm_default_value(&attrs); + assert_eq!(result, Some("active".to_string())); + } + + #[test] + fn test_extract_sea_orm_default_value_bool() { + let attrs: Vec = vec![syn::parse_quote!( + #[sea_orm(default_value = true)] + )]; + let result = extract_sea_orm_default_value(&attrs); + assert_eq!(result, Some("true".to_string())); + } + + #[test] + fn test_extract_sea_orm_default_value_with_other_attrs() { + let attrs: Vec = vec![syn::parse_quote!( + #[sea_orm(column_type = "Decimal(Some((10, 2)))", default_value = 0.7)] + )]; + let result = extract_sea_orm_default_value(&attrs); + assert_eq!(result, Some("0.7".to_string())); + } + + #[test] + fn test_extract_sea_orm_default_value_none() { + let attrs: Vec = vec![syn::parse_quote!( + #[sea_orm(column_type = "Text")] + )]; + let result = extract_sea_orm_default_value(&attrs); + assert_eq!(result, None); + } + + #[test] + fn test_extract_sea_orm_default_value_non_sea_orm_attr() { + let attrs: Vec = vec![syn::parse_quote!(#[serde(default)])]; + let result = extract_sea_orm_default_value(&attrs); + assert_eq!(result, None); + } + + #[test] + fn test_extract_sea_orm_default_value_empty_attrs() { + let result = extract_sea_orm_default_value(&[]); + assert_eq!(result, None); + } + + #[test] + fn test_extract_sea_orm_default_value_non_list_meta() { + // #[sea_orm] as a path attribute (non-Meta::List) — line 222 branch + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm])]; + let result = extract_sea_orm_default_value(&attrs); + assert_eq!(result, None); + } + + #[test] + fn test_extract_sea_orm_default_value_empty_value_after_equals() { + // default_value = , (empty value) — line 236 branch + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = )])]; + let result = extract_sea_orm_default_value(&attrs); + assert_eq!(result, None); + } + + #[test] + fn test_extract_sea_orm_default_value_no_default_value_key() { + let attrs: Vec = + vec![syn::parse_quote!(#[sea_orm(primary_key, auto_increment)])]; + let result = extract_sea_orm_default_value(&attrs); + assert_eq!(result, None); + } + + // ========================================================================= + // Tests for is_sql_function_default + // ========================================================================= + + #[rstest] + #[case("NOW()", true)] + #[case("CURRENT_TIMESTAMP()", true)] + #[case("UUID()", true)] + #[case("gen_random_uuid()", true)] + #[case("0.7", false)] + #[case("42", false)] + #[case("true", false)] + #[case("draft", false)] + #[case("active", false)] + fn test_is_sql_function_default(#[case] value: &str, #[case] expected: bool) { + assert_eq!(is_sql_function_default(value), expected); + } } diff --git a/crates/vespera_macro/src/schema_macro/tests.rs b/crates/vespera_macro/src/schema_macro/tests.rs index 87ae270..b6f828d 100644 --- a/crates/vespera_macro/src/schema_macro/tests.rs +++ b/crates/vespera_macro/src/schema_macro/tests.rs @@ -244,7 +244,199 @@ fn test_generate_schema_type_code_no_from_impl_with_add() { assert!(result.is_ok()); let (tokens, _metadata) = result.unwrap(); let output = tokens.to_string(); - assert!(!output.contains("impl From")); + assert!( + output.contains("UserWithExtra"), + "expected struct UserWithExtra in output: {output}" + ); + assert!( + !output.contains("impl From"), + "expected no From impl when `add` is used: {output}" + ); +} + +// ======================== +// is_parseable_type tests +// ======================== + +#[test] +fn test_is_parseable_type_primitives() { + for ty_str in &[ + "i8", "i16", "i32", "i64", "i128", "isize", "u8", "u16", "u32", "u64", "u128", "usize", + "f32", "f64", "bool", "String", "Decimal", + ] { + let ty: syn::Type = syn::parse_str(ty_str).unwrap(); + assert!(is_parseable_type(&ty), "{ty_str} should be parseable"); + } +} + +#[test] +fn test_is_parseable_type_non_parseable() { + let ty: syn::Type = syn::parse_str("MyEnum").unwrap(); + assert!(!is_parseable_type(&ty)); +} + +#[test] +fn test_is_parseable_type_non_path() { + let ty: syn::Type = syn::parse_str("&str").unwrap(); + assert!(!is_parseable_type(&ty)); +} + +// ====================================== +// generate_sea_orm_default_attrs tests +// ====================================== + +#[test] +fn test_sea_orm_default_attrs_optional_field_skips() { + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "42")])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("i32").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = + generate_sea_orm_default_attrs(&attrs, &struct_name, "count", &ty, &ty, true, &mut fns); + assert!(serde.is_empty()); + assert!(schema.is_empty()); + assert!(fns.is_empty()); +} + +#[test] +fn test_sea_orm_default_attrs_no_default_value() { + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(primary_key)])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("i32").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = + generate_sea_orm_default_attrs(&attrs, &struct_name, "id", &ty, &ty, false, &mut fns); + assert!(serde.is_empty()); + assert!(schema.is_empty()); +} + +#[test] +fn test_sea_orm_default_attrs_sql_function_supported_type() { + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "NOW()")])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("String").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = generate_sea_orm_default_attrs( + &attrs, + &struct_name, + "created_at", + &ty, + &ty, + false, + &mut fns, + ); + // Supported type with SQL function → generates serde(default) to mark field not-required + let serde_str = serde.to_string(); + assert!( + serde_str.contains("serde"), + "should have serde default attr: {serde_str}" + ); + assert!( + serde_str.contains("default_Test_created_at"), + "should reference generated default fn: {serde_str}" + ); + // No JSON default for SQL functions (value is DB-side only) + assert!(schema.is_empty()); + // Default function was generated + assert_eq!(fns.len(), 1, "should generate one default function"); +} + +#[test] +fn test_sea_orm_default_attrs_sql_function_unsupported_type_skips() { + let attrs: Vec = + vec![syn::parse_quote!(#[sea_orm(default_value = "MY_FUNC()")])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("MyCustomType").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = generate_sea_orm_default_attrs( + &attrs, + &struct_name, + "custom_field", + &ty, + &ty, + false, + &mut fns, + ); + // Unsupported type with SQL function → still skips entirely + assert!(serde.is_empty()); + assert!(schema.is_empty()); + assert!(fns.is_empty()); +} + +#[test] +fn test_sea_orm_default_attrs_existing_serde_default() { + let attrs: Vec = vec![ + syn::parse_quote!(#[sea_orm(default_value = "42")]), + syn::parse_quote!(#[serde(default)]), + ]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("i32").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = + generate_sea_orm_default_attrs(&attrs, &struct_name, "count", &ty, &ty, false, &mut fns); + // serde attr should be empty (already has serde default) + assert!(serde.is_empty()); + // schema attr should still be generated + let schema_str = schema.to_string(); + assert!( + schema_str.contains("schema"), + "should have schema attr: {schema_str}" + ); + assert!( + fns.is_empty(), + "no default fn needed when serde(default) exists" + ); +} + +#[test] +fn test_sea_orm_default_attrs_non_parseable_type() { + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "Active")])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("MyEnum").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = + generate_sea_orm_default_attrs(&attrs, &struct_name, "status", &ty, &ty, false, &mut fns); + // serde attr empty (non-parseable type) + assert!(serde.is_empty()); + // schema attr still generated + let schema_str = schema.to_string(); + assert!( + schema_str.contains("schema"), + "should have schema attr: {schema_str}" + ); + assert!(fns.is_empty()); +} + +#[test] +fn test_sea_orm_default_attrs_full_generation() { + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "42")])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("i32").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = + generate_sea_orm_default_attrs(&attrs, &struct_name, "count", &ty, &ty, false, &mut fns); + // Both serde and schema attrs should be generated + let serde_str = serde.to_string(); + assert!( + serde_str.contains("serde"), + "should have serde attr: {serde_str}" + ); + assert!( + serde_str.contains("default_Test_count"), + "should reference generated fn: {serde_str}" + ); + let schema_str = schema.to_string(); + assert!( + schema_str.contains("schema"), + "should have schema attr: {schema_str}" + ); + // Default function should be generated + assert_eq!(fns.len(), 1, "should generate one default function"); + let fn_str = fns[0].to_string(); + assert!( + fn_str.contains("default_Test_count"), + "fn name should match: {fn_str}" + ); } #[test] @@ -279,7 +471,175 @@ fn test_generate_schema_type_code_with_partial_fields() { assert!(result.is_ok()); let (tokens, _metadata) = result.unwrap(); let output = tokens.to_string(); - assert!(output.contains("UpdateUser")); + assert!( + output.contains("UpdateUser"), + "should contain generated struct name: {output}" + ); +} + +// --- Coverage: sql_function_default_body branches --- + +#[test] +fn test_sql_function_default_non_path_type_skips() { + // Reference type (&str) is Type::Reference, not Type::Path → sql_function_default_body returns None + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "GEN()")])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("&str").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = + generate_sea_orm_default_attrs(&attrs, &struct_name, "val", &ty, &ty, false, &mut fns); + assert!(serde.is_empty()); + assert!(schema.is_empty()); + assert!(fns.is_empty()); +} + +#[test] +fn test_sql_function_default_datetime_with_timezone() { + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "NOW()")])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("DateTimeWithTimeZone").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = + generate_sea_orm_default_attrs(&attrs, &struct_name, "ts", &ty, &ty, false, &mut fns); + let serde_str = serde.to_string(); + assert!( + serde_str.contains("serde"), + "should have serde attr: {serde_str}" + ); + assert!(schema.is_empty()); + assert_eq!(fns.len(), 1); + let fn_str = fns[0].to_string(); + assert!( + fn_str.contains("UNIX_EPOCH"), + "should use epoch default: {fn_str}" + ); +} + +#[test] +fn test_sql_function_default_datetime_utc() { + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "NOW()")])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("DateTimeUtc").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = + generate_sea_orm_default_attrs(&attrs, &struct_name, "ts", &ty, &ty, false, &mut fns); + let serde_str = serde.to_string(); + assert!( + serde_str.contains("serde"), + "should have serde attr: {serde_str}" + ); + assert!(schema.is_empty()); + assert_eq!(fns.len(), 1); + let fn_str = fns[0].to_string(); + assert!( + fn_str.contains("UNIX_EPOCH"), + "should use epoch default: {fn_str}" + ); +} + +#[test] +fn test_sql_function_default_datetime_local() { + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "NOW()")])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("DateTimeLocal").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = + generate_sea_orm_default_attrs(&attrs, &struct_name, "ts", &ty, &ty, false, &mut fns); + let serde_str = serde.to_string(); + assert!( + serde_str.contains("serde"), + "should have serde attr: {serde_str}" + ); + assert!(schema.is_empty()); + assert_eq!(fns.len(), 1); + let fn_str = fns[0].to_string(); + assert!( + fn_str.contains("UNIX_EPOCH"), + "should use epoch default: {fn_str}" + ); +} + +#[test] +fn test_sql_function_default_naive_datetime() { + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "NOW()")])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("NaiveDateTime").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = + generate_sea_orm_default_attrs(&attrs, &struct_name, "ts", &ty, &ty, false, &mut fns); + let serde_str = serde.to_string(); + assert!( + serde_str.contains("serde"), + "should have serde attr: {serde_str}" + ); + assert!(schema.is_empty()); + assert_eq!(fns.len(), 1); + let fn_str = fns[0].to_string(); + assert!( + fn_str.contains("UNIX_EPOCH"), + "should use epoch default: {fn_str}" + ); +} + +#[test] +fn test_sql_function_default_naive_date() { + let attrs: Vec = + vec![syn::parse_quote!(#[sea_orm(default_value = "CURDATE()")])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("NaiveDate").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = + generate_sea_orm_default_attrs(&attrs, &struct_name, "d", &ty, &ty, false, &mut fns); + let serde_str = serde.to_string(); + assert!( + serde_str.contains("serde"), + "should have serde attr: {serde_str}" + ); + assert!(schema.is_empty()); + assert_eq!(fns.len(), 1); + let fn_str = fns[0].to_string(); + assert!( + fn_str.contains("from_ymd_opt"), + "should use ymd default: {fn_str}" + ); +} + +#[test] +fn test_sql_function_default_naive_time() { + let attrs: Vec = + vec![syn::parse_quote!(#[sea_orm(default_value = "CURTIME()")])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("NaiveTime").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = + generate_sea_orm_default_attrs(&attrs, &struct_name, "t", &ty, &ty, false, &mut fns); + let serde_str = serde.to_string(); + assert!( + serde_str.contains("serde"), + "should have serde attr: {serde_str}" + ); + assert!(schema.is_empty()); + assert_eq!(fns.len(), 1); + let fn_str = fns[0].to_string(); + assert!( + fn_str.contains("from_hms_opt"), + "should use hms default: {fn_str}" + ); +} + +// --- Coverage: is_parseable_type empty segments --- + +#[test] +fn test_is_parseable_type_empty_segments() { + // Synthetically construct a Type::Path with empty segments (impossible through parsing) + let ty = syn::Type::Path(syn::TypePath { + qself: None, + path: syn::Path { + leading_colon: None, + segments: syn::punctuated::Punctuated::new(), + }, + }); + assert!(!is_parseable_type(&ty)); } #[test] diff --git a/crates/vespera_macro/src/vespera_impl.rs b/crates/vespera_macro/src/vespera_impl.rs index 317414f..9446215 100644 --- a/crates/vespera_macro/src/vespera_impl.rs +++ b/crates/vespera_macro/src/vespera_impl.rs @@ -45,6 +45,7 @@ pub type DocsInfo = (Option<(String, String)>, Option<(String, String)>); pub fn generate_and_write_openapi( input: &ProcessedVesperaInput, metadata: &CollectedMetadata, + file_asts: HashMap, ) -> MacroResult { if input.openapi_file_names.is_empty() && input.docs_url.is_none() && input.redoc_url.is_none() { @@ -56,6 +57,7 @@ pub fn generate_and_write_openapi( input.version.clone(), input.servers.clone(), metadata, + Some(file_asts), ); // Merge specs from child apps at compile time @@ -166,10 +168,10 @@ pub fn process_vespera_macro( )); } - let mut metadata = collect_metadata(&folder_path, &processed.folder_name).map_err(|e| syn::Error::new(Span::call_site(), format!("vespera! macro: failed to scan route folder '{}'. Error: {}. Check that all .rs files have valid Rust syntax.", processed.folder_name, e)))?; + let (mut metadata, file_asts) = collect_metadata(&folder_path, &processed.folder_name).map_err(|e| syn::Error::new(Span::call_site(), format!("vespera! macro: failed to scan route folder '{}'. Error: {}. Check that all .rs files have valid Rust syntax.", processed.folder_name, e)))?; metadata.structs.extend(schema_storage.values().cloned()); - let (docs_info, redoc_info) = generate_and_write_openapi(processed, &metadata)?; + let (docs_info, redoc_info) = generate_and_write_openapi(processed, &metadata, file_asts)?; Ok(generate_router_code( &metadata, @@ -196,11 +198,12 @@ pub fn process_export_app( )); } - let mut metadata = collect_metadata(&folder_path, folder_name).map_err(|e| syn::Error::new(Span::call_site(), format!("export_app! macro: failed to scan route folder '{folder_name}'. Error: {e}. Check that all .rs files have valid Rust syntax.")))?; + let (mut metadata, file_asts) = collect_metadata(&folder_path, folder_name).map_err(|e| syn::Error::new(Span::call_site(), format!("export_app! macro: failed to scan route folder '{folder_name}'. Error: {e}. Check that all .rs files have valid Rust syntax.")))?; metadata.structs.extend(schema_storage.values().cloned()); // Generate OpenAPI spec JSON string - let openapi_doc = generate_openapi_doc_with_metadata(None, None, None, &metadata); + let openapi_doc = + generate_openapi_doc_with_metadata(None, None, None, &metadata, Some(file_asts)); let spec_json = serde_json::to_string(&openapi_doc).map_err(|e| syn::Error::new(Span::call_site(), format!("export_app! macro: failed to serialize OpenAPI spec to JSON. Error: {e}. Check that all schema types are serializable.")))?; // Write spec to temp file for compile-time merging by parent apps @@ -264,7 +267,7 @@ mod tests { merge: vec![], }; let metadata = CollectedMetadata::new(); - let result = generate_and_write_openapi(&processed, &metadata); + let result = generate_and_write_openapi(&processed, &metadata, HashMap::new()); assert!(result.is_ok()); let (docs_info, redoc_info) = result.unwrap(); assert!(docs_info.is_none()); @@ -284,7 +287,7 @@ mod tests { merge: vec![], }; let metadata = CollectedMetadata::new(); - let result = generate_and_write_openapi(&processed, &metadata); + let result = generate_and_write_openapi(&processed, &metadata, HashMap::new()); assert!(result.is_ok()); let (docs_info, redoc_info) = result.unwrap(); assert!(docs_info.is_some()); @@ -308,7 +311,7 @@ mod tests { merge: vec![], }; let metadata = CollectedMetadata::new(); - let result = generate_and_write_openapi(&processed, &metadata); + let result = generate_and_write_openapi(&processed, &metadata, HashMap::new()); assert!(result.is_ok()); let (docs_info, redoc_info) = result.unwrap(); assert!(docs_info.is_none()); @@ -330,7 +333,7 @@ mod tests { merge: vec![], }; let metadata = CollectedMetadata::new(); - let result = generate_and_write_openapi(&processed, &metadata); + let result = generate_and_write_openapi(&processed, &metadata, HashMap::new()); assert!(result.is_ok()); let (docs_info, redoc_info) = result.unwrap(); assert!(docs_info.is_some()); @@ -353,7 +356,7 @@ mod tests { merge: vec![], }; let metadata = CollectedMetadata::new(); - let result = generate_and_write_openapi(&processed, &metadata); + let result = generate_and_write_openapi(&processed, &metadata, HashMap::new()); assert!(result.is_ok()); // Verify file was written @@ -380,7 +383,7 @@ mod tests { merge: vec![], }; let metadata = CollectedMetadata::new(); - let result = generate_and_write_openapi(&processed, &metadata); + let result = generate_and_write_openapi(&processed, &metadata, HashMap::new()); assert!(result.is_ok()); // Verify nested directories and file were created @@ -652,7 +655,7 @@ mod tests { }; let metadata = CollectedMetadata::new(); // This should still work - merge logic is skipped when CARGO_MANIFEST_DIR lookup fails - let result = generate_and_write_openapi(&processed, &metadata); + let result = generate_and_write_openapi(&processed, &metadata, HashMap::new()); assert!(result.is_ok()); } @@ -687,7 +690,7 @@ mod tests { }; let metadata = CollectedMetadata::new(); - let result = generate_and_write_openapi(&processed, &metadata); + let result = generate_and_write_openapi(&processed, &metadata, HashMap::new()); // Restore CARGO_MANIFEST_DIR if let Some(old_value) = old_manifest_dir { @@ -764,7 +767,7 @@ mod tests { }; let metadata = CollectedMetadata::new(); - let result = generate_and_write_openapi(&processed, &metadata); + let result = generate_and_write_openapi(&processed, &metadata, HashMap::new()); assert!(result.is_err()); let err = result.unwrap_err().to_string(); assert!(err.contains("failed to write file")); diff --git a/examples/axum-example/Cargo.toml b/examples/axum-example/Cargo.toml index 9df9943..0b259a7 100644 --- a/examples/axum-example/Cargo.toml +++ b/examples/axum-example/Cargo.toml @@ -11,7 +11,8 @@ tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] } serde_json = "1" tower-http = { version = "0.6", features = ["cors"] } -sea-orm = { version = "^2.0.0-rc.32", features = ["sqlx-sqlite", "runtime-tokio-rustls", "macros"] } +sea-orm = { version = "^2.0.0-rc.32", features = ["sqlx-sqlite", "runtime-tokio-rustls", "macros", "with-uuid"] } +uuid = { version = "1", features = ["v4", "serde"] } axum_typed_multipart = "0.16" tempfile = "3" diff --git a/examples/axum-example/models/config.vespertide.json b/examples/axum-example/models/config.vespertide.json new file mode 100644 index 0000000..0727147 --- /dev/null +++ b/examples/axum-example/models/config.vespertide.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://raw.githubusercontent.com/dev-five-git/vespertide/refs/heads/main/schemas/model.schema.json", + "columns": [ + { + "name": "id", + "type": "big_int", + "nullable": false, + "primary_key": { "auto_increment": true } + }, + { + "name": "temperature", + "type": { "kind": "numeric", "precision": 3, "scale": 2 }, + "nullable": false, + "default": 0.7 + } + ], + "name": "config" +} \ No newline at end of file diff --git a/examples/axum-example/models/uuid_item.vespertide.json b/examples/axum-example/models/uuid_item.vespertide.json new file mode 100644 index 0000000..f1127fc --- /dev/null +++ b/examples/axum-example/models/uuid_item.vespertide.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://raw.githubusercontent.com/dev-five-git/vespertide/refs/heads/main/schemas/model.schema.json", + "name": "uuid_item", + "description": "UUID item model for testing UUID format in OpenAPI", + "columns": [ + { "name": "id", "type": "uuid", "nullable": false, "primary_key": true, "default": "gen_random_uuid()", "comment": "Item ID" }, + { "name": "name", "type": "text", "nullable": false, "comment": "Item name" }, + { "name": "external_ref", "type": "uuid", "nullable": true, "comment": "External reference UUID" }, + { "name": "created_at", "type": "timestamptz", "nullable": false, "default": "NOW()", "comment": "Created at" } + ] +} \ No newline at end of file diff --git a/examples/axum-example/openapi.json b/examples/axum-example/openapi.json index 0ead67e..903c75f 100644 --- a/examples/axum-example/openapi.json +++ b/examples/axum-example/openapi.json @@ -82,6 +82,56 @@ } } }, + "/config": { + "get": { + "operationId": "get_config", + "tags": [ + "config" + ], + "description": "Get current config", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Config" + } + } + } + } + } + }, + "patch": { + "operationId": "update_config", + "tags": [ + "config" + ], + "description": "Update config", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateConfigRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Config" + } + } + } + } + } + } + }, "/enums": { "get": { "operationId": "enum_endpoint", @@ -452,7 +502,8 @@ "in": "path", "required": true, "schema": { - "type": "integer" + "type": "integer", + "format": "int64" } } ], @@ -817,13 +868,16 @@ } }, "page": { - "type": "integer" + "type": "integer", + "format": "int32" }, "size": { - "type": "integer" + "type": "integer", + "format": "int32" }, "totalPage": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -904,7 +958,8 @@ "in": "query", "required": true, "schema": { - "type": "integer" + "type": "integer", + "format": "uint32" } }, { @@ -913,6 +968,7 @@ "required": false, "schema": { "type": "integer", + "format": "uint32", "nullable": true } } @@ -1013,7 +1069,8 @@ "in": "path", "required": true, "schema": { - "type": "integer" + "type": "integer", + "format": "int32" } } ], @@ -1040,7 +1097,8 @@ "in": "path", "required": true, "schema": { - "type": "integer" + "type": "integer", + "format": "int32" } } ], @@ -1286,7 +1344,8 @@ "in": "query", "required": true, "schema": { - "type": "integer" + "type": "integer", + "format": "uint32" } } ], @@ -1351,7 +1410,8 @@ "in": "query", "required": true, "schema": { - "type": "integer" + "type": "integer", + "format": "uint32" } } ], @@ -1428,7 +1488,8 @@ "in": "query", "required": true, "schema": { - "type": "integer" + "type": "integer", + "format": "uint32" } }, { @@ -1437,6 +1498,7 @@ "required": false, "schema": { "type": "integer", + "format": "uint32", "nullable": true } } @@ -1531,7 +1593,8 @@ "in": "path", "required": true, "schema": { - "type": "integer" + "type": "integer", + "format": "int64" } } ], @@ -1580,7 +1643,8 @@ "in": "path", "required": true, "schema": { - "type": "integer" + "type": "integer", + "format": "int64" } } ], @@ -1735,7 +1799,8 @@ "in": "path", "required": true, "schema": { - "type": "integer" + "type": "integer", + "format": "uint32" } } ], @@ -1829,7 +1894,8 @@ "in": "path", "required": true, "schema": { - "type": "integer" + "type": "integer", + "format": "uint32" } } ], @@ -1846,6 +1912,59 @@ } } } + }, + "/uuid-items": { + "get": { + "operationId": "list_uuid_items", + "tags": [ + "uuid_items" + ], + "description": "List all UUID items", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UuidItem" + } + } + } + } + } + } + }, + "post": { + "operationId": "create_uuid_item", + "tags": [ + "uuid_items" + ], + "description": "Create a new UUID item", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateUuidItemRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UuidItem" + } + } + } + } + } + } } }, "components": { @@ -1891,7 +2010,8 @@ "type": "object", "properties": { "code": { - "type": "integer" + "type": "integer", + "format": "int32" }, "message": { "type": "string" @@ -1958,7 +2078,8 @@ "type": "object", "properties": { "age": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "array": { "type": "array", @@ -2049,7 +2170,8 @@ "type": "object", "properties": { "age": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "array": { "type": "array", @@ -2136,6 +2258,75 @@ "nestedStructMapArray" ] }, + "Config": { + "type": "object", + "properties": { + "delimiter": { + "type": "string", + "format": "char", + "nullable": true + }, + "discountRate": { + "type": "number", + "format": "decimal", + "nullable": true + }, + "maxItems": { + "type": "integer", + "minimum": 0 + }, + "maxPrice": { + "type": "number", + "format": "decimal" + }, + "minPrice": { + "type": "number", + "format": "decimal" + }, + "priority": { + "type": "integer", + "format": "int32" + }, + "retryCount": { + "type": "integer", + "format": "uint8" + }, + "separator": { + "type": "string", + "format": "char" + }, + "taxRate": { + "type": "number", + "format": "decimal" + } + }, + "required": [ + "taxRate", + "minPrice", + "maxPrice", + "maxItems", + "retryCount", + "priority", + "separator" + ] + }, + "ConfigSchema": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "temperature": { + "type": "number", + "format": "decimal", + "default": 0.7 + } + }, + "required": [ + "id" + ] + }, "ContactFormRequest": { "type": "object", "properties": { @@ -2192,7 +2383,8 @@ "type": "string" }, "id": { - "type": "integer" + "type": "integer", + "format": "int64" }, "repliedAt": { "type": "string", @@ -2206,7 +2398,8 @@ "nullable": true }, "userId": { - "type": "integer" + "type": "integer", + "format": "int64" } }, "required": [ @@ -2297,6 +2490,22 @@ "requestId" ] }, + "CreateUuidItemRequest": { + "type": "object", + "properties": { + "external_ref": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "name": { + "type": "string" + } + }, + "required": [ + "name" + ] + }, "Enum": { "type": "string", "enum": [ @@ -2326,7 +2535,8 @@ "type": "object", "properties": { "age": { - "type": "integer" + "type": "integer", + "format": "int32" }, "name": { "type": "string" @@ -2346,7 +2556,8 @@ "type": "object", "properties": { "C": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -2388,7 +2599,8 @@ "type": "string" }, { - "type": "integer" + "type": "integer", + "format": "int32" } ], "minItems": 2, @@ -2527,7 +2739,8 @@ "type": "object", "properties": { "code": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "error": { "type": "string" @@ -2542,7 +2755,8 @@ "type": "object", "properties": { "code": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "error": { "type": "string" @@ -2564,7 +2778,8 @@ "type": "object", "properties": { "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "name": { "type": "string" @@ -2588,7 +2803,8 @@ "type": "object", "properties": { "id": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -2605,7 +2821,8 @@ "description": "Item was deleted", "properties": { "Deleted": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -2625,7 +2842,8 @@ "nullable": true }, "id": { - "type": "integer" + "type": "integer", + "format": "int64" }, "isActive": { "type": "boolean" @@ -2705,7 +2923,8 @@ "description": "A request message", "properties": { "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "method": { "type": "string" @@ -2728,7 +2947,8 @@ "description": "A response message", "properties": { "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "result": { "type": "string", @@ -2770,13 +2990,15 @@ "type": "object", "properties": { "age": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "name": { "type": "string" }, "optional_age": { "type": "integer", + "format": "uint32", "nullable": true } }, @@ -2796,13 +3018,15 @@ "format": "date-time" }, "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "memo": { "$ref": "#/components/schemas/MemoSchema" }, "memoId": { - "type": "integer" + "type": "integer", + "format": "int32" }, "updatedAt": { "type": "string", @@ -2812,7 +3036,8 @@ "$ref": "#/components/schemas/UserSchema" }, "userId": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -2820,8 +3045,6 @@ "userId", "memoId", "content", - "createdAt", - "updatedAt", "user", "memo" ] @@ -2837,7 +3060,8 @@ "format": "date-time" }, "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "status": { "$ref": "#/components/schemas/MemoStatus" @@ -2846,7 +3070,8 @@ "type": "string" }, "userId": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -2854,8 +3079,7 @@ "userId", "title", "content", - "status", - "createdAt" + "status" ] }, "MemoResponseComments": { @@ -2883,17 +3107,20 @@ "format": "date-time" }, "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "memoId": { - "type": "integer" + "type": "integer", + "format": "int32" }, "updatedAt": { "type": "string", "format": "date-time" }, "userId": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -2916,7 +3143,8 @@ "format": "date-time" }, "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "status": { "$ref": "#/components/schemas/MemoStatus" @@ -2928,7 +3156,8 @@ "$ref": "#/components/schemas/UserSchema" }, "userId": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -2937,7 +3166,6 @@ "title", "content", "status", - "createdAt", "user" ] }, @@ -2952,7 +3180,8 @@ "format": "date-time" }, "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "status": { "$ref": "#/components/schemas/MemoStatus" @@ -2968,7 +3197,8 @@ "$ref": "#/components/schemas/UserSchema" }, "userId": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -2977,8 +3207,6 @@ "title", "content", "status", - "createdAt", - "updatedAt", "user" ] }, @@ -2990,16 +3218,17 @@ "format": "date-time" }, "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "user_id": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ "id", - "user_id", - "created_at" + "user_id" ] }, "MemoStatus": { @@ -3020,13 +3249,16 @@ } }, "page": { - "type": "integer" + "type": "integer", + "format": "int32" }, "size": { - "type": "integer" + "type": "integer", + "format": "int32" }, "totalPage": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -3042,11 +3274,13 @@ "properties": { "page": { "type": "integer", + "format": "int32", "description": "Page number (1-indexed)", "default": 1 }, - "per_page": { + "perPage": { "type": "integer", + "format": "int32", "description": "Items per page", "default": 20 } @@ -3078,18 +3312,19 @@ "type": "object", "description": "Common metadata for responses", "properties": { - "has_more": { + "hasMore": { "type": "boolean", "description": "Whether there are more pages" }, "total": { "type": "integer", + "format": "int64", "description": "Total number of items" } }, "required": [ "total", - "has_more" + "hasMore" ] }, "SearchResponse": { @@ -3153,7 +3388,8 @@ "nullable": true }, "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "job": { "type": "string", @@ -3240,6 +3476,7 @@ }, "num": { "type": "integer", + "format": "int32", "default": 0 } }, @@ -3253,7 +3490,8 @@ "type": "object", "properties": { "age": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "name": { "type": "string" @@ -3269,6 +3507,7 @@ "properties": { "age": { "type": "integer", + "format": "uint32", "nullable": true }, "name": { @@ -3281,7 +3520,8 @@ "type": "object", "properties": { "age": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "name": { "type": "string" @@ -3314,7 +3554,8 @@ "type": "string" }, "id": { - "type": "integer" + "type": "integer", + "format": "int64" }, "isSubscribed": { "type": "boolean" @@ -3352,7 +3593,8 @@ "type": "object", "properties": { "age": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "name": { "type": "string" @@ -3367,13 +3609,15 @@ "type": "object", "properties": { "age": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "name": { "type": "string" }, "optional_age": { "type": "integer", + "format": "uint32", "nullable": true } }, @@ -3391,6 +3635,7 @@ }, { "type": "integer", + "format": "int64", "description": "A numeric value" }, { @@ -3415,6 +3660,56 @@ } ] }, + "UpdateConfigRequest": { + "type": "object", + "properties": { + "delimiter": { + "type": "string", + "format": "char", + "nullable": true + }, + "discountRate": { + "type": "number", + "format": "decimal", + "nullable": true + }, + "maxItems": { + "type": "integer", + "minimum": 0, + "nullable": true + }, + "maxPrice": { + "type": "number", + "format": "decimal", + "nullable": true + }, + "minPrice": { + "type": "number", + "format": "decimal", + "nullable": true + }, + "priority": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "retryCount": { + "type": "integer", + "format": "uint8", + "nullable": true + }, + "separator": { + "type": "string", + "format": "char", + "nullable": true + }, + "taxRate": { + "type": "number", + "format": "decimal", + "nullable": true + } + } + }, "UpdateFileUploadRequest": { "type": "object", "properties": { @@ -3449,7 +3744,8 @@ "type": "string" }, "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "title": { "type": "string" @@ -3469,10 +3765,12 @@ "type": "string" }, "id": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "internal_score": { "type": "integer", + "format": "int32", "description": "Internal field - should be omitted in public APIs", "nullable": true }, @@ -3491,7 +3789,8 @@ "description": "Full user model with all fields", "properties": { "id": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "name": { "type": "string" @@ -3510,7 +3809,8 @@ "type": "string" }, "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "name": { "type": "string" @@ -3575,7 +3875,8 @@ "type": "string" }, "id": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "name": { "type": "string" @@ -3602,6 +3903,7 @@ }, "id": { "type": "integer", + "format": "int32", "description": "User ID" }, "name": { @@ -3617,9 +3919,7 @@ "required": [ "id", "email", - "name", - "createdAt", - "updatedAt" + "name" ] }, "UserSummary": { @@ -3627,7 +3927,8 @@ "description": "Full user model with all fields", "properties": { "id": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "name": { "type": "string" @@ -3637,10 +3938,63 @@ "id", "name" ] + }, + "UuidItem": { + "type": "object", + "properties": { + "external_ref": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + }, + "UuidItemSchema": { + "type": "object", + "description": "UUID item model for testing UUID format in OpenAPI", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Created at" + }, + "externalRef": { + "type": "string", + "format": "uuid", + "description": "External reference UUID", + "nullable": true + }, + "id": { + "type": "string", + "format": "uuid", + "description": "Item ID" + }, + "name": { + "type": "string", + "description": "Item name" + } + }, + "required": [ + "name" + ] } } }, "tags": [ + { + "name": "config" + }, { "name": "error" }, @@ -3659,6 +4013,9 @@ { "name": "typed-form" }, + { + "name": "uuid_items" + }, { "name": "third" } diff --git a/examples/axum-example/src/models/config.rs b/examples/axum-example/src/models/config.rs new file mode 100644 index 0000000..be00642 --- /dev/null +++ b/examples/axum-example/src/models/config.rs @@ -0,0 +1,14 @@ +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "config")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i64, + #[sea_orm(default_value = 0.7)] + pub temperature: Decimal, +} + +vespera::schema_type!(Schema from Model, name = "ConfigSchema"); +impl ActiveModelBehavior for ActiveModel {} diff --git a/examples/axum-example/src/models/mod.rs b/examples/axum-example/src/models/mod.rs index bd2900e..02ee003 100644 --- a/examples/axum-example/src/models/mod.rs +++ b/examples/axum-example/src/models/mod.rs @@ -1,3 +1,5 @@ +pub mod config; pub mod memo; pub mod memo_comment; pub mod user; +pub mod uuid_item; diff --git a/examples/axum-example/src/models/uuid_item.rs b/examples/axum-example/src/models/uuid_item.rs new file mode 100644 index 0000000..ba5e08b --- /dev/null +++ b/examples/axum-example/src/models/uuid_item.rs @@ -0,0 +1,21 @@ +use sea_orm::entity::prelude::*; + +/// UUID item model for testing UUID format in OpenAPI +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "uuid_item")] +pub struct Model { + /// Item ID + #[sea_orm(primary_key, default_value = "gen_random_uuid()")] + pub id: Uuid, + /// Item name + pub name: String, + /// External reference UUID + pub external_ref: Option, + /// Created at + #[sea_orm(default_value = "NOW()")] + pub created_at: DateTimeWithTimeZone, +} + +vespera::schema_type!(Schema from Model, name = "UuidItemSchema"); +impl ActiveModelBehavior for ActiveModel {} diff --git a/examples/axum-example/src/routes/config.rs b/examples/axum-example/src/routes/config.rs new file mode 100644 index 0000000..80b8ee5 --- /dev/null +++ b/examples/axum-example/src/routes/config.rs @@ -0,0 +1,80 @@ +use sea_orm::prelude::Decimal; +use serde::{Deserialize, Serialize}; +use vespera::Schema; +use vespera::axum::Json; + +#[derive(Serialize, Deserialize, Schema)] +#[serde(rename_all = "camelCase")] +pub struct Config { + pub tax_rate: Decimal, + pub discount_rate: Option, + pub min_price: Decimal, + pub max_price: Decimal, + pub max_items: usize, + pub retry_count: u8, + pub priority: i32, + pub separator: char, + pub delimiter: Option, +} + +#[derive(Deserialize, Schema)] +#[serde(rename_all = "camelCase")] +pub struct UpdateConfigRequest { + pub tax_rate: Option, + pub discount_rate: Option, + pub min_price: Option, + pub max_price: Option, + pub max_items: Option, + pub retry_count: Option, + pub priority: Option, + pub separator: Option, + pub delimiter: Option, +} + +/// Get current config +#[vespera::route(get, tags = ["config"])] +pub async fn get_config() -> Json { + Json(Config { + tax_rate: Decimal::new(10, 2), + discount_rate: Some(Decimal::new(5, 2)), + min_price: Decimal::new(100, 0), + max_price: Decimal::new(10000, 0), + max_items: 100, + retry_count: 3, + priority: 0, + separator: ',', + delimiter: Some('|'), + }) +} + +/// Update config +#[vespera::route(patch, tags = ["config"])] +pub async fn update_config(Json(req): Json) -> Json { + let _ = crate::models::config::Model { + id: 1, + temperature: Decimal::new(10, 2), + }; + let current = Config { + tax_rate: Decimal::new(10, 2), + discount_rate: Some(Decimal::new(5, 2)), + min_price: Decimal::new(100, 0), + max_price: Decimal::new(10000, 0), + max_items: 100, + retry_count: 3, + priority: 0, + separator: ',', + delimiter: Some('|'), + }; + + Json(Config { + tax_rate: req.tax_rate.unwrap_or(current.tax_rate), + discount_rate: req.discount_rate.or(current.discount_rate), + min_price: req.min_price.unwrap_or(current.min_price), + max_price: req.max_price.unwrap_or(current.max_price), + max_items: req.max_items.unwrap_or(current.max_items), + retry_count: req.retry_count.unwrap_or(current.retry_count), + priority: req.priority.unwrap_or(current.priority), + separator: req.separator.unwrap_or(current.separator), + delimiter: req.delimiter.or(current.delimiter), + }) +} diff --git a/examples/axum-example/src/routes/flatten.rs b/examples/axum-example/src/routes/flatten.rs index 3eb163a..480648a 100644 --- a/examples/axum-example/src/routes/flatten.rs +++ b/examples/axum-example/src/routes/flatten.rs @@ -8,6 +8,7 @@ use vespera::{Schema, axum::Json}; /// Common pagination parameters that can be reused across requests #[derive(Debug, Clone, Serialize, Deserialize, Schema)] +#[serde(rename_all = "camelCase")] pub struct Pagination { /// Page number (1-indexed) #[serde(default = "default_page")] @@ -26,6 +27,7 @@ fn default_per_page() -> i32 { /// Common metadata for responses #[derive(Debug, Clone, Serialize, Deserialize, Schema)] +#[serde(rename_all = "camelCase")] pub struct ResponseMeta { /// Total number of items pub total: i64, @@ -37,6 +39,7 @@ pub struct ResponseMeta { /// /// The pagination fields (page, per_page) are merged into this struct's JSON representation. #[derive(Debug, Clone, Serialize, Deserialize, Schema)] +#[serde(rename_all = "camelCase")] pub struct UserListRequest { /// Filter users by name (optional) pub filter: Option, @@ -54,6 +57,7 @@ fn default_sort() -> String { /// Simple user representation #[derive(Debug, Clone, Serialize, Deserialize, Schema)] +#[serde(rename_all = "camelCase")] pub struct UserItem { pub id: i32, pub name: String, @@ -64,6 +68,7 @@ pub struct UserItem { /// /// The response meta fields (total, has_more) are merged into this struct's JSON representation. #[derive(Debug, Clone, Serialize, Deserialize, Schema)] +#[serde(rename_all = "camelCase")] pub struct UserListResponse { /// List of users pub data: Vec, @@ -102,6 +107,7 @@ pub async fn list_users(Json(req): Json) -> Json, diff --git a/examples/axum-example/src/routes/mod.rs b/examples/axum-example/src/routes/mod.rs index d1469c4..7e919c9 100644 --- a/examples/axum-example/src/routes/mod.rs +++ b/examples/axum-example/src/routes/mod.rs @@ -9,6 +9,7 @@ use vespera::{ use crate::TestStruct; +pub mod config; pub mod enums; pub mod error; pub mod flatten; @@ -21,6 +22,7 @@ pub mod path; pub mod typed_form; pub mod typed_header; pub mod users; +pub mod uuid_items; /// Health check endpoint #[vespera::route(get)] diff --git a/examples/axum-example/src/routes/uuid_items.rs b/examples/axum-example/src/routes/uuid_items.rs new file mode 100644 index 0000000..eeb8096 --- /dev/null +++ b/examples/axum-example/src/routes/uuid_items.rs @@ -0,0 +1,43 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; +use vespera::Schema; +use vespera::axum::Json; + +#[derive(Serialize, Deserialize, Schema)] +pub struct UuidItem { + pub id: Uuid, + pub name: String, + pub external_ref: Option, +} + +#[derive(Deserialize, Schema)] +pub struct CreateUuidItemRequest { + pub name: String, + pub external_ref: Option, +} + +/// List all UUID items +#[vespera::route(get, tags = ["uuid_items"])] +pub async fn list_uuid_items() -> Json> { + let _ = crate::models::uuid_item::Model { + id: Uuid::new_v4(), + name: "example".to_string(), + external_ref: Some(Uuid::new_v4()), + created_at: Default::default(), + }; + Json(vec![UuidItem { + id: Uuid::new_v4(), + name: "example".to_string(), + external_ref: Some(Uuid::new_v4()), + }]) +} + +/// Create a new UUID item +#[vespera::route(post, tags = ["uuid_items"])] +pub async fn create_uuid_item(Json(req): Json) -> Json { + Json(UuidItem { + id: Uuid::new_v4(), + name: req.name, + external_ref: req.external_ref, + }) +} diff --git a/examples/axum-example/tests/snapshots/integration_test__openapi.snap b/examples/axum-example/tests/snapshots/integration_test__openapi.snap index 5fce0eb..7b9f831 100644 --- a/examples/axum-example/tests/snapshots/integration_test__openapi.snap +++ b/examples/axum-example/tests/snapshots/integration_test__openapi.snap @@ -86,6 +86,56 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" } } }, + "/config": { + "get": { + "operationId": "get_config", + "tags": [ + "config" + ], + "description": "Get current config", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Config" + } + } + } + } + } + }, + "patch": { + "operationId": "update_config", + "tags": [ + "config" + ], + "description": "Update config", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateConfigRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Config" + } + } + } + } + } + } + }, "/enums": { "get": { "operationId": "enum_endpoint", @@ -456,7 +506,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "in": "path", "required": true, "schema": { - "type": "integer" + "type": "integer", + "format": "int64" } } ], @@ -821,13 +872,16 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" } }, "page": { - "type": "integer" + "type": "integer", + "format": "int32" }, "size": { - "type": "integer" + "type": "integer", + "format": "int32" }, "totalPage": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -908,7 +962,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "in": "query", "required": true, "schema": { - "type": "integer" + "type": "integer", + "format": "uint32" } }, { @@ -917,6 +972,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "required": false, "schema": { "type": "integer", + "format": "uint32", "nullable": true } } @@ -1017,7 +1073,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "in": "path", "required": true, "schema": { - "type": "integer" + "type": "integer", + "format": "int32" } } ], @@ -1044,7 +1101,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "in": "path", "required": true, "schema": { - "type": "integer" + "type": "integer", + "format": "int32" } } ], @@ -1290,7 +1348,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "in": "query", "required": true, "schema": { - "type": "integer" + "type": "integer", + "format": "uint32" } } ], @@ -1355,7 +1414,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "in": "query", "required": true, "schema": { - "type": "integer" + "type": "integer", + "format": "uint32" } } ], @@ -1432,7 +1492,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "in": "query", "required": true, "schema": { - "type": "integer" + "type": "integer", + "format": "uint32" } }, { @@ -1441,6 +1502,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "required": false, "schema": { "type": "integer", + "format": "uint32", "nullable": true } } @@ -1535,7 +1597,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "in": "path", "required": true, "schema": { - "type": "integer" + "type": "integer", + "format": "int64" } } ], @@ -1584,7 +1647,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "in": "path", "required": true, "schema": { - "type": "integer" + "type": "integer", + "format": "int64" } } ], @@ -1739,7 +1803,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "in": "path", "required": true, "schema": { - "type": "integer" + "type": "integer", + "format": "uint32" } } ], @@ -1833,7 +1898,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "in": "path", "required": true, "schema": { - "type": "integer" + "type": "integer", + "format": "uint32" } } ], @@ -1850,6 +1916,59 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" } } } + }, + "/uuid-items": { + "get": { + "operationId": "list_uuid_items", + "tags": [ + "uuid_items" + ], + "description": "List all UUID items", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UuidItem" + } + } + } + } + } + } + }, + "post": { + "operationId": "create_uuid_item", + "tags": [ + "uuid_items" + ], + "description": "Create a new UUID item", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateUuidItemRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UuidItem" + } + } + } + } + } + } } }, "components": { @@ -1895,7 +2014,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "object", "properties": { "code": { - "type": "integer" + "type": "integer", + "format": "int32" }, "message": { "type": "string" @@ -1962,7 +2082,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "object", "properties": { "age": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "array": { "type": "array", @@ -2053,7 +2174,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "object", "properties": { "age": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "array": { "type": "array", @@ -2140,6 +2262,75 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "nestedStructMapArray" ] }, + "Config": { + "type": "object", + "properties": { + "delimiter": { + "type": "string", + "format": "char", + "nullable": true + }, + "discountRate": { + "type": "number", + "format": "decimal", + "nullable": true + }, + "maxItems": { + "type": "integer", + "minimum": 0 + }, + "maxPrice": { + "type": "number", + "format": "decimal" + }, + "minPrice": { + "type": "number", + "format": "decimal" + }, + "priority": { + "type": "integer", + "format": "int32" + }, + "retryCount": { + "type": "integer", + "format": "uint8" + }, + "separator": { + "type": "string", + "format": "char" + }, + "taxRate": { + "type": "number", + "format": "decimal" + } + }, + "required": [ + "taxRate", + "minPrice", + "maxPrice", + "maxItems", + "retryCount", + "priority", + "separator" + ] + }, + "ConfigSchema": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "temperature": { + "type": "number", + "format": "decimal", + "default": 0.7 + } + }, + "required": [ + "id" + ] + }, "ContactFormRequest": { "type": "object", "properties": { @@ -2196,7 +2387,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "string" }, "id": { - "type": "integer" + "type": "integer", + "format": "int64" }, "repliedAt": { "type": "string", @@ -2210,7 +2402,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "nullable": true }, "userId": { - "type": "integer" + "type": "integer", + "format": "int64" } }, "required": [ @@ -2301,6 +2494,22 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "requestId" ] }, + "CreateUuidItemRequest": { + "type": "object", + "properties": { + "external_ref": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "name": { + "type": "string" + } + }, + "required": [ + "name" + ] + }, "Enum": { "type": "string", "enum": [ @@ -2330,7 +2539,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "object", "properties": { "age": { - "type": "integer" + "type": "integer", + "format": "int32" }, "name": { "type": "string" @@ -2350,7 +2560,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "object", "properties": { "C": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -2392,7 +2603,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "string" }, { - "type": "integer" + "type": "integer", + "format": "int32" } ], "minItems": 2, @@ -2531,7 +2743,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "object", "properties": { "code": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "error": { "type": "string" @@ -2546,7 +2759,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "object", "properties": { "code": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "error": { "type": "string" @@ -2568,7 +2782,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "object", "properties": { "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "name": { "type": "string" @@ -2592,7 +2807,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "object", "properties": { "id": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -2609,7 +2825,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "description": "Item was deleted", "properties": { "Deleted": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -2629,7 +2846,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "nullable": true }, "id": { - "type": "integer" + "type": "integer", + "format": "int64" }, "isActive": { "type": "boolean" @@ -2709,7 +2927,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "description": "A request message", "properties": { "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "method": { "type": "string" @@ -2732,7 +2951,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "description": "A response message", "properties": { "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "result": { "type": "string", @@ -2774,13 +2994,15 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "object", "properties": { "age": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "name": { "type": "string" }, "optional_age": { "type": "integer", + "format": "uint32", "nullable": true } }, @@ -2800,13 +3022,15 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "format": "date-time" }, "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "memo": { "$ref": "#/components/schemas/MemoSchema" }, "memoId": { - "type": "integer" + "type": "integer", + "format": "int32" }, "updatedAt": { "type": "string", @@ -2816,7 +3040,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "$ref": "#/components/schemas/UserSchema" }, "userId": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -2824,8 +3049,6 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "userId", "memoId", "content", - "createdAt", - "updatedAt", "user", "memo" ] @@ -2841,7 +3064,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "format": "date-time" }, "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "status": { "$ref": "#/components/schemas/MemoStatus" @@ -2850,7 +3074,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "string" }, "userId": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -2858,8 +3083,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "userId", "title", "content", - "status", - "createdAt" + "status" ] }, "MemoResponseComments": { @@ -2887,17 +3111,20 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "format": "date-time" }, "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "memoId": { - "type": "integer" + "type": "integer", + "format": "int32" }, "updatedAt": { "type": "string", "format": "date-time" }, "userId": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -2920,7 +3147,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "format": "date-time" }, "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "status": { "$ref": "#/components/schemas/MemoStatus" @@ -2932,7 +3160,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "$ref": "#/components/schemas/UserSchema" }, "userId": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -2941,7 +3170,6 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "title", "content", "status", - "createdAt", "user" ] }, @@ -2956,7 +3184,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "format": "date-time" }, "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "status": { "$ref": "#/components/schemas/MemoStatus" @@ -2972,7 +3201,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "$ref": "#/components/schemas/UserSchema" }, "userId": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -2981,8 +3211,6 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "title", "content", "status", - "createdAt", - "updatedAt", "user" ] }, @@ -2994,16 +3222,17 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "format": "date-time" }, "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "user_id": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ "id", - "user_id", - "created_at" + "user_id" ] }, "MemoStatus": { @@ -3024,13 +3253,16 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" } }, "page": { - "type": "integer" + "type": "integer", + "format": "int32" }, "size": { - "type": "integer" + "type": "integer", + "format": "int32" }, "totalPage": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -3046,11 +3278,13 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "properties": { "page": { "type": "integer", + "format": "int32", "description": "Page number (1-indexed)", "default": 1 }, - "per_page": { + "perPage": { "type": "integer", + "format": "int32", "description": "Items per page", "default": 20 } @@ -3082,18 +3316,19 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "object", "description": "Common metadata for responses", "properties": { - "has_more": { + "hasMore": { "type": "boolean", "description": "Whether there are more pages" }, "total": { "type": "integer", + "format": "int64", "description": "Total number of items" } }, "required": [ "total", - "has_more" + "hasMore" ] }, "SearchResponse": { @@ -3157,7 +3392,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "nullable": true }, "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "job": { "type": "string", @@ -3244,6 +3480,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" }, "num": { "type": "integer", + "format": "int32", "default": 0 } }, @@ -3257,7 +3494,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "object", "properties": { "age": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "name": { "type": "string" @@ -3273,6 +3511,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "properties": { "age": { "type": "integer", + "format": "uint32", "nullable": true }, "name": { @@ -3285,7 +3524,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "object", "properties": { "age": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "name": { "type": "string" @@ -3318,7 +3558,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "string" }, "id": { - "type": "integer" + "type": "integer", + "format": "int64" }, "isSubscribed": { "type": "boolean" @@ -3356,7 +3597,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "object", "properties": { "age": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "name": { "type": "string" @@ -3371,13 +3613,15 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "object", "properties": { "age": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "name": { "type": "string" }, "optional_age": { "type": "integer", + "format": "uint32", "nullable": true } }, @@ -3395,6 +3639,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" }, { "type": "integer", + "format": "int64", "description": "A numeric value" }, { @@ -3419,6 +3664,56 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" } ] }, + "UpdateConfigRequest": { + "type": "object", + "properties": { + "delimiter": { + "type": "string", + "format": "char", + "nullable": true + }, + "discountRate": { + "type": "number", + "format": "decimal", + "nullable": true + }, + "maxItems": { + "type": "integer", + "minimum": 0, + "nullable": true + }, + "maxPrice": { + "type": "number", + "format": "decimal", + "nullable": true + }, + "minPrice": { + "type": "number", + "format": "decimal", + "nullable": true + }, + "priority": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "retryCount": { + "type": "integer", + "format": "uint8", + "nullable": true + }, + "separator": { + "type": "string", + "format": "char", + "nullable": true + }, + "taxRate": { + "type": "number", + "format": "decimal", + "nullable": true + } + } + }, "UpdateFileUploadRequest": { "type": "object", "properties": { @@ -3453,7 +3748,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "string" }, "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "title": { "type": "string" @@ -3473,10 +3769,12 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "string" }, "id": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "internal_score": { "type": "integer", + "format": "int32", "description": "Internal field - should be omitted in public APIs", "nullable": true }, @@ -3495,7 +3793,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "description": "Full user model with all fields", "properties": { "id": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "name": { "type": "string" @@ -3514,7 +3813,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "string" }, "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "name": { "type": "string" @@ -3579,7 +3879,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "string" }, "id": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "name": { "type": "string" @@ -3606,6 +3907,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" }, "id": { "type": "integer", + "format": "int32", "description": "User ID" }, "name": { @@ -3621,9 +3923,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "required": [ "id", "email", - "name", - "createdAt", - "updatedAt" + "name" ] }, "UserSummary": { @@ -3631,7 +3931,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "description": "Full user model with all fields", "properties": { "id": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "name": { "type": "string" @@ -3641,10 +3942,63 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "id", "name" ] + }, + "UuidItem": { + "type": "object", + "properties": { + "external_ref": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + }, + "UuidItemSchema": { + "type": "object", + "description": "UUID item model for testing UUID format in OpenAPI", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Created at" + }, + "externalRef": { + "type": "string", + "format": "uuid", + "description": "External reference UUID", + "nullable": true + }, + "id": { + "type": "string", + "format": "uuid", + "description": "Item ID" + }, + "name": { + "type": "string", + "description": "Item name" + } + }, + "required": [ + "name" + ] } } }, "tags": [ + { + "name": "config" + }, { "name": "error" }, @@ -3663,6 +4017,9 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" { "name": "typed-form" }, + { + "name": "uuid_items" + }, { "name": "third" } diff --git a/openapi.json b/openapi.json index 0ead67e..903c75f 100644 --- a/openapi.json +++ b/openapi.json @@ -82,6 +82,56 @@ } } }, + "/config": { + "get": { + "operationId": "get_config", + "tags": [ + "config" + ], + "description": "Get current config", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Config" + } + } + } + } + } + }, + "patch": { + "operationId": "update_config", + "tags": [ + "config" + ], + "description": "Update config", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateConfigRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Config" + } + } + } + } + } + } + }, "/enums": { "get": { "operationId": "enum_endpoint", @@ -452,7 +502,8 @@ "in": "path", "required": true, "schema": { - "type": "integer" + "type": "integer", + "format": "int64" } } ], @@ -817,13 +868,16 @@ } }, "page": { - "type": "integer" + "type": "integer", + "format": "int32" }, "size": { - "type": "integer" + "type": "integer", + "format": "int32" }, "totalPage": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -904,7 +958,8 @@ "in": "query", "required": true, "schema": { - "type": "integer" + "type": "integer", + "format": "uint32" } }, { @@ -913,6 +968,7 @@ "required": false, "schema": { "type": "integer", + "format": "uint32", "nullable": true } } @@ -1013,7 +1069,8 @@ "in": "path", "required": true, "schema": { - "type": "integer" + "type": "integer", + "format": "int32" } } ], @@ -1040,7 +1097,8 @@ "in": "path", "required": true, "schema": { - "type": "integer" + "type": "integer", + "format": "int32" } } ], @@ -1286,7 +1344,8 @@ "in": "query", "required": true, "schema": { - "type": "integer" + "type": "integer", + "format": "uint32" } } ], @@ -1351,7 +1410,8 @@ "in": "query", "required": true, "schema": { - "type": "integer" + "type": "integer", + "format": "uint32" } } ], @@ -1428,7 +1488,8 @@ "in": "query", "required": true, "schema": { - "type": "integer" + "type": "integer", + "format": "uint32" } }, { @@ -1437,6 +1498,7 @@ "required": false, "schema": { "type": "integer", + "format": "uint32", "nullable": true } } @@ -1531,7 +1593,8 @@ "in": "path", "required": true, "schema": { - "type": "integer" + "type": "integer", + "format": "int64" } } ], @@ -1580,7 +1643,8 @@ "in": "path", "required": true, "schema": { - "type": "integer" + "type": "integer", + "format": "int64" } } ], @@ -1735,7 +1799,8 @@ "in": "path", "required": true, "schema": { - "type": "integer" + "type": "integer", + "format": "uint32" } } ], @@ -1829,7 +1894,8 @@ "in": "path", "required": true, "schema": { - "type": "integer" + "type": "integer", + "format": "uint32" } } ], @@ -1846,6 +1912,59 @@ } } } + }, + "/uuid-items": { + "get": { + "operationId": "list_uuid_items", + "tags": [ + "uuid_items" + ], + "description": "List all UUID items", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UuidItem" + } + } + } + } + } + } + }, + "post": { + "operationId": "create_uuid_item", + "tags": [ + "uuid_items" + ], + "description": "Create a new UUID item", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateUuidItemRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UuidItem" + } + } + } + } + } + } } }, "components": { @@ -1891,7 +2010,8 @@ "type": "object", "properties": { "code": { - "type": "integer" + "type": "integer", + "format": "int32" }, "message": { "type": "string" @@ -1958,7 +2078,8 @@ "type": "object", "properties": { "age": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "array": { "type": "array", @@ -2049,7 +2170,8 @@ "type": "object", "properties": { "age": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "array": { "type": "array", @@ -2136,6 +2258,75 @@ "nestedStructMapArray" ] }, + "Config": { + "type": "object", + "properties": { + "delimiter": { + "type": "string", + "format": "char", + "nullable": true + }, + "discountRate": { + "type": "number", + "format": "decimal", + "nullable": true + }, + "maxItems": { + "type": "integer", + "minimum": 0 + }, + "maxPrice": { + "type": "number", + "format": "decimal" + }, + "minPrice": { + "type": "number", + "format": "decimal" + }, + "priority": { + "type": "integer", + "format": "int32" + }, + "retryCount": { + "type": "integer", + "format": "uint8" + }, + "separator": { + "type": "string", + "format": "char" + }, + "taxRate": { + "type": "number", + "format": "decimal" + } + }, + "required": [ + "taxRate", + "minPrice", + "maxPrice", + "maxItems", + "retryCount", + "priority", + "separator" + ] + }, + "ConfigSchema": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "temperature": { + "type": "number", + "format": "decimal", + "default": 0.7 + } + }, + "required": [ + "id" + ] + }, "ContactFormRequest": { "type": "object", "properties": { @@ -2192,7 +2383,8 @@ "type": "string" }, "id": { - "type": "integer" + "type": "integer", + "format": "int64" }, "repliedAt": { "type": "string", @@ -2206,7 +2398,8 @@ "nullable": true }, "userId": { - "type": "integer" + "type": "integer", + "format": "int64" } }, "required": [ @@ -2297,6 +2490,22 @@ "requestId" ] }, + "CreateUuidItemRequest": { + "type": "object", + "properties": { + "external_ref": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "name": { + "type": "string" + } + }, + "required": [ + "name" + ] + }, "Enum": { "type": "string", "enum": [ @@ -2326,7 +2535,8 @@ "type": "object", "properties": { "age": { - "type": "integer" + "type": "integer", + "format": "int32" }, "name": { "type": "string" @@ -2346,7 +2556,8 @@ "type": "object", "properties": { "C": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -2388,7 +2599,8 @@ "type": "string" }, { - "type": "integer" + "type": "integer", + "format": "int32" } ], "minItems": 2, @@ -2527,7 +2739,8 @@ "type": "object", "properties": { "code": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "error": { "type": "string" @@ -2542,7 +2755,8 @@ "type": "object", "properties": { "code": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "error": { "type": "string" @@ -2564,7 +2778,8 @@ "type": "object", "properties": { "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "name": { "type": "string" @@ -2588,7 +2803,8 @@ "type": "object", "properties": { "id": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -2605,7 +2821,8 @@ "description": "Item was deleted", "properties": { "Deleted": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -2625,7 +2842,8 @@ "nullable": true }, "id": { - "type": "integer" + "type": "integer", + "format": "int64" }, "isActive": { "type": "boolean" @@ -2705,7 +2923,8 @@ "description": "A request message", "properties": { "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "method": { "type": "string" @@ -2728,7 +2947,8 @@ "description": "A response message", "properties": { "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "result": { "type": "string", @@ -2770,13 +2990,15 @@ "type": "object", "properties": { "age": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "name": { "type": "string" }, "optional_age": { "type": "integer", + "format": "uint32", "nullable": true } }, @@ -2796,13 +3018,15 @@ "format": "date-time" }, "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "memo": { "$ref": "#/components/schemas/MemoSchema" }, "memoId": { - "type": "integer" + "type": "integer", + "format": "int32" }, "updatedAt": { "type": "string", @@ -2812,7 +3036,8 @@ "$ref": "#/components/schemas/UserSchema" }, "userId": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -2820,8 +3045,6 @@ "userId", "memoId", "content", - "createdAt", - "updatedAt", "user", "memo" ] @@ -2837,7 +3060,8 @@ "format": "date-time" }, "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "status": { "$ref": "#/components/schemas/MemoStatus" @@ -2846,7 +3070,8 @@ "type": "string" }, "userId": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -2854,8 +3079,7 @@ "userId", "title", "content", - "status", - "createdAt" + "status" ] }, "MemoResponseComments": { @@ -2883,17 +3107,20 @@ "format": "date-time" }, "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "memoId": { - "type": "integer" + "type": "integer", + "format": "int32" }, "updatedAt": { "type": "string", "format": "date-time" }, "userId": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -2916,7 +3143,8 @@ "format": "date-time" }, "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "status": { "$ref": "#/components/schemas/MemoStatus" @@ -2928,7 +3156,8 @@ "$ref": "#/components/schemas/UserSchema" }, "userId": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -2937,7 +3166,6 @@ "title", "content", "status", - "createdAt", "user" ] }, @@ -2952,7 +3180,8 @@ "format": "date-time" }, "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "status": { "$ref": "#/components/schemas/MemoStatus" @@ -2968,7 +3197,8 @@ "$ref": "#/components/schemas/UserSchema" }, "userId": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -2977,8 +3207,6 @@ "title", "content", "status", - "createdAt", - "updatedAt", "user" ] }, @@ -2990,16 +3218,17 @@ "format": "date-time" }, "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "user_id": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ "id", - "user_id", - "created_at" + "user_id" ] }, "MemoStatus": { @@ -3020,13 +3249,16 @@ } }, "page": { - "type": "integer" + "type": "integer", + "format": "int32" }, "size": { - "type": "integer" + "type": "integer", + "format": "int32" }, "totalPage": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -3042,11 +3274,13 @@ "properties": { "page": { "type": "integer", + "format": "int32", "description": "Page number (1-indexed)", "default": 1 }, - "per_page": { + "perPage": { "type": "integer", + "format": "int32", "description": "Items per page", "default": 20 } @@ -3078,18 +3312,19 @@ "type": "object", "description": "Common metadata for responses", "properties": { - "has_more": { + "hasMore": { "type": "boolean", "description": "Whether there are more pages" }, "total": { "type": "integer", + "format": "int64", "description": "Total number of items" } }, "required": [ "total", - "has_more" + "hasMore" ] }, "SearchResponse": { @@ -3153,7 +3388,8 @@ "nullable": true }, "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "job": { "type": "string", @@ -3240,6 +3476,7 @@ }, "num": { "type": "integer", + "format": "int32", "default": 0 } }, @@ -3253,7 +3490,8 @@ "type": "object", "properties": { "age": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "name": { "type": "string" @@ -3269,6 +3507,7 @@ "properties": { "age": { "type": "integer", + "format": "uint32", "nullable": true }, "name": { @@ -3281,7 +3520,8 @@ "type": "object", "properties": { "age": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "name": { "type": "string" @@ -3314,7 +3554,8 @@ "type": "string" }, "id": { - "type": "integer" + "type": "integer", + "format": "int64" }, "isSubscribed": { "type": "boolean" @@ -3352,7 +3593,8 @@ "type": "object", "properties": { "age": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "name": { "type": "string" @@ -3367,13 +3609,15 @@ "type": "object", "properties": { "age": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "name": { "type": "string" }, "optional_age": { "type": "integer", + "format": "uint32", "nullable": true } }, @@ -3391,6 +3635,7 @@ }, { "type": "integer", + "format": "int64", "description": "A numeric value" }, { @@ -3415,6 +3660,56 @@ } ] }, + "UpdateConfigRequest": { + "type": "object", + "properties": { + "delimiter": { + "type": "string", + "format": "char", + "nullable": true + }, + "discountRate": { + "type": "number", + "format": "decimal", + "nullable": true + }, + "maxItems": { + "type": "integer", + "minimum": 0, + "nullable": true + }, + "maxPrice": { + "type": "number", + "format": "decimal", + "nullable": true + }, + "minPrice": { + "type": "number", + "format": "decimal", + "nullable": true + }, + "priority": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "retryCount": { + "type": "integer", + "format": "uint8", + "nullable": true + }, + "separator": { + "type": "string", + "format": "char", + "nullable": true + }, + "taxRate": { + "type": "number", + "format": "decimal", + "nullable": true + } + } + }, "UpdateFileUploadRequest": { "type": "object", "properties": { @@ -3449,7 +3744,8 @@ "type": "string" }, "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "title": { "type": "string" @@ -3469,10 +3765,12 @@ "type": "string" }, "id": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "internal_score": { "type": "integer", + "format": "int32", "description": "Internal field - should be omitted in public APIs", "nullable": true }, @@ -3491,7 +3789,8 @@ "description": "Full user model with all fields", "properties": { "id": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "name": { "type": "string" @@ -3510,7 +3809,8 @@ "type": "string" }, "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "name": { "type": "string" @@ -3575,7 +3875,8 @@ "type": "string" }, "id": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "name": { "type": "string" @@ -3602,6 +3903,7 @@ }, "id": { "type": "integer", + "format": "int32", "description": "User ID" }, "name": { @@ -3617,9 +3919,7 @@ "required": [ "id", "email", - "name", - "createdAt", - "updatedAt" + "name" ] }, "UserSummary": { @@ -3627,7 +3927,8 @@ "description": "Full user model with all fields", "properties": { "id": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "name": { "type": "string" @@ -3637,10 +3938,63 @@ "id", "name" ] + }, + "UuidItem": { + "type": "object", + "properties": { + "external_ref": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + }, + "UuidItemSchema": { + "type": "object", + "description": "UUID item model for testing UUID format in OpenAPI", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Created at" + }, + "externalRef": { + "type": "string", + "format": "uuid", + "description": "External reference UUID", + "nullable": true + }, + "id": { + "type": "string", + "format": "uuid", + "description": "Item ID" + }, + "name": { + "type": "string", + "description": "Item name" + } + }, + "required": [ + "name" + ] } } }, "tags": [ + { + "name": "config" + }, { "name": "error" }, @@ -3659,6 +4013,9 @@ { "name": "typed-form" }, + { + "name": "uuid_items" + }, { "name": "third" }