From 40222605e164784a7ef360ba4897752d4a673ffe Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 18 Feb 2026 21:19:48 +0900 Subject: [PATCH 01/13] Support format --- .../changepack_log_84xWDkggRCpzbU8VFUDhZ.json | 1 + Cargo.lock | 6 +- crates/vespera_macro/src/openapi_generator.rs | 93 +++-- ...tly_tagged_snapshot@adjacently_tagged.snap | 4 +- ...ariant@externally_tagged_empty_struct.snap | 4 +- ...lly_tagged_snapshot@internally_tagged.snap | 8 +- ...le_variant@untagged_multi_field_tuple.snap | 8 +- ...epr_tests__untagged_snapshot@untagged.snap | 4 +- ...med_variants@tuple_named_named_object.snap | 4 +- ...amed_variants@tuple_named_tuple_multi.snap | 4 +- .../src/parser/schema/type_schema.rs | 60 ++- ...on_parameter_cases@params_path_single.snap | 4 +- ...ion_parameter_cases@params_path_tuple.snap | 4 +- ...n_parameter_cases@params_query_struct.snap | 8 +- ...ion_parameter_cases@params_query_user.snap | 4 +- .../vespera_macro/src/schema_macro/codegen.rs | 14 + crates/vespera_macro/src/schema_macro/mod.rs | 126 ++++++- .../vespera_macro/src/schema_macro/seaorm.rs | 134 +++++++ .../models/config.vespertide.json | 18 + examples/axum-example/openapi.json | 348 ++++++++++++++---- examples/axum-example/src/models/config.rs | 14 + examples/axum-example/src/models/mod.rs | 1 + examples/axum-example/src/routes/config.rs | 71 ++++ examples/axum-example/src/routes/flatten.rs | 7 + examples/axum-example/src/routes/mod.rs | 1 + .../snapshots/integration_test__openapi.snap | 348 ++++++++++++++---- openapi.json | 348 ++++++++++++++---- 27 files changed, 1415 insertions(+), 231 deletions(-) create mode 100644 .changepacks/changepack_log_84xWDkggRCpzbU8VFUDhZ.json create mode 100644 examples/axum-example/models/config.vespertide.json create mode 100644 examples/axum-example/src/models/config.rs create mode 100644 examples/axum-example/src/routes/config.rs 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/Cargo.lock b/Cargo.lock index 3d0d754..0784041 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3198,7 +3198,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vespera" -version = "0.1.34" +version = "0.1.36" dependencies = [ "axum", "axum-extra", @@ -3214,7 +3214,7 @@ dependencies = [ [[package]] name = "vespera_core" -version = "0.1.34" +version = "0.1.36" dependencies = [ "rstest", "serde", @@ -3223,7 +3223,7 @@ dependencies = [ [[package]] name = "vespera_macro" -version = "0.1.34" +version = "0.1.36" dependencies = [ "insta", "proc-macro2", diff --git a/crates/vespera_macro/src/openapi_generator.rs b/crates/vespera_macro/src/openapi_generator.rs index ef6abd2..5bb4609 100644 --- a/crates/vespera_macro/src/openapi_generator.rs +++ b/crates/vespera_macro/src/openapi_generator.rs @@ -274,7 +274,10 @@ fn build_path_items( } /// 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, @@ -294,21 +297,31 @@ 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); + if let Some(prop_schema_ref) = properties.get_mut(&field_name) + && let SchemaRef::Inline(prop_schema) = prop_schema_ref + && prop_schema.default.is_none() + { + prop_schema.default = Some(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() @@ -326,16 +339,6 @@ fn process_default_functions( 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 @@ -348,6 +351,52 @@ fn process_default_functions( } } +/// 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 { + for attr in attrs { + if attr.path().is_ident("schema") { + 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(()) + }); + if default_value.is_some() { + return default_value; + } + } + } + None +} + +/// 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, 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..449da74 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,11 +222,62 @@ 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" => { + // 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, u128, usize: no standard format in the registry + "i128" | "isize" | "u128" | "usize" => { SchemaRef::Inline(Box::new(Schema::integer())) } - "f32" | "f64" => SchemaRef::Inline(Box::new(Schema::number())), + "StatusCode" => SchemaRef::Inline(Box::new(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())), "String" | "str" => SchemaRef::Inline(Box::new(Schema::string())), // Date-time types from chrono and time crates 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/schema_macro/codegen.rs b/crates/vespera_macro/src/schema_macro/codegen.rs index 6f054c2..e7292f2 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) } } diff --git a/crates/vespera_macro/src/schema_macro/mod.rs b/crates/vespera_macro/src/schema_macro/mod.rs index 71aafcd..3bd0de6 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,107 @@ 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! {}); + }; + + // Skip SQL functions like NOW(), CURRENT_TIMESTAMP(), UUID() + if is_sql_function_default(&default_value) { + 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) +} + +/// 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..28125c6 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,89 @@ 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); + } + + // ========================================================================= + // 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/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/openapi.json b/examples/axum-example/openapi.json index 0ead67e..54e28be 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" } } ], @@ -1891,7 +1957,8 @@ "type": "object", "properties": { "code": { - "type": "integer" + "type": "integer", + "format": "int32" }, "message": { "type": "string" @@ -1958,7 +2025,8 @@ "type": "object", "properties": { "age": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "array": { "type": "array", @@ -2049,7 +2117,8 @@ "type": "object", "properties": { "age": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "array": { "type": "array", @@ -2136,6 +2205,64 @@ "nestedStructMapArray" ] }, + "Config": { + "type": "object", + "properties": { + "discountRate": { + "type": "number", + "format": "decimal", + "nullable": true + }, + "maxItems": { + "type": "integer" + }, + "maxPrice": { + "type": "number", + "format": "decimal" + }, + "minPrice": { + "type": "number", + "format": "decimal" + }, + "priority": { + "type": "integer", + "format": "int32" + }, + "retryCount": { + "type": "integer", + "format": "uint8" + }, + "taxRate": { + "type": "number", + "format": "decimal" + } + }, + "required": [ + "taxRate", + "minPrice", + "maxPrice", + "maxItems", + "retryCount", + "priority" + ] + }, + "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 +2319,8 @@ "type": "string" }, "id": { - "type": "integer" + "type": "integer", + "format": "int64" }, "repliedAt": { "type": "string", @@ -2206,7 +2334,8 @@ "nullable": true }, "userId": { - "type": "integer" + "type": "integer", + "format": "int64" } }, "required": [ @@ -2326,7 +2455,8 @@ "type": "object", "properties": { "age": { - "type": "integer" + "type": "integer", + "format": "int32" }, "name": { "type": "string" @@ -2346,7 +2476,8 @@ "type": "object", "properties": { "C": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -2388,7 +2519,8 @@ "type": "string" }, { - "type": "integer" + "type": "integer", + "format": "int32" } ], "minItems": 2, @@ -2527,7 +2659,8 @@ "type": "object", "properties": { "code": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "error": { "type": "string" @@ -2542,7 +2675,8 @@ "type": "object", "properties": { "code": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "error": { "type": "string" @@ -2564,7 +2698,8 @@ "type": "object", "properties": { "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "name": { "type": "string" @@ -2588,7 +2723,8 @@ "type": "object", "properties": { "id": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -2605,7 +2741,8 @@ "description": "Item was deleted", "properties": { "Deleted": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -2625,7 +2762,8 @@ "nullable": true }, "id": { - "type": "integer" + "type": "integer", + "format": "int64" }, "isActive": { "type": "boolean" @@ -2705,7 +2843,8 @@ "description": "A request message", "properties": { "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "method": { "type": "string" @@ -2728,7 +2867,8 @@ "description": "A response message", "properties": { "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "result": { "type": "string", @@ -2770,13 +2910,15 @@ "type": "object", "properties": { "age": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "name": { "type": "string" }, "optional_age": { "type": "integer", + "format": "uint32", "nullable": true } }, @@ -2796,13 +2938,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 +2956,8 @@ "$ref": "#/components/schemas/UserSchema" }, "userId": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -2837,7 +2982,8 @@ "format": "date-time" }, "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "status": { "$ref": "#/components/schemas/MemoStatus" @@ -2846,7 +2992,8 @@ "type": "string" }, "userId": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -2883,17 +3030,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 +3066,8 @@ "format": "date-time" }, "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "status": { "$ref": "#/components/schemas/MemoStatus" @@ -2928,7 +3079,8 @@ "$ref": "#/components/schemas/UserSchema" }, "userId": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -2952,7 +3104,8 @@ "format": "date-time" }, "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "status": { "$ref": "#/components/schemas/MemoStatus" @@ -2968,7 +3121,8 @@ "$ref": "#/components/schemas/UserSchema" }, "userId": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -2990,10 +3144,12 @@ "format": "date-time" }, "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "user_id": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -3020,13 +3176,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 +3201,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 +3239,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 +3315,8 @@ "nullable": true }, "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "job": { "type": "string", @@ -3240,6 +3403,7 @@ }, "num": { "type": "integer", + "format": "int32", "default": 0 } }, @@ -3253,7 +3417,8 @@ "type": "object", "properties": { "age": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "name": { "type": "string" @@ -3269,6 +3434,7 @@ "properties": { "age": { "type": "integer", + "format": "uint32", "nullable": true }, "name": { @@ -3281,7 +3447,8 @@ "type": "object", "properties": { "age": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "name": { "type": "string" @@ -3314,7 +3481,8 @@ "type": "string" }, "id": { - "type": "integer" + "type": "integer", + "format": "int64" }, "isSubscribed": { "type": "boolean" @@ -3352,7 +3520,8 @@ "type": "object", "properties": { "age": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "name": { "type": "string" @@ -3367,13 +3536,15 @@ "type": "object", "properties": { "age": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "name": { "type": "string" }, "optional_age": { "type": "integer", + "format": "uint32", "nullable": true } }, @@ -3391,6 +3562,7 @@ }, { "type": "integer", + "format": "int64", "description": "A numeric value" }, { @@ -3415,6 +3587,45 @@ } ] }, + "UpdateConfigRequest": { + "type": "object", + "properties": { + "discountRate": { + "type": "number", + "format": "decimal", + "nullable": true + }, + "maxItems": { + "type": "integer", + "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 + }, + "taxRate": { + "type": "number", + "format": "decimal", + "nullable": true + } + } + }, "UpdateFileUploadRequest": { "type": "object", "properties": { @@ -3449,7 +3660,8 @@ "type": "string" }, "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "title": { "type": "string" @@ -3469,10 +3681,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 +3705,8 @@ "description": "Full user model with all fields", "properties": { "id": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "name": { "type": "string" @@ -3510,7 +3725,8 @@ "type": "string" }, "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "name": { "type": "string" @@ -3575,7 +3791,8 @@ "type": "string" }, "id": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "name": { "type": "string" @@ -3602,6 +3819,7 @@ }, "id": { "type": "integer", + "format": "int32", "description": "User ID" }, "name": { @@ -3627,7 +3845,8 @@ "description": "Full user model with all fields", "properties": { "id": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "name": { "type": "string" @@ -3641,6 +3860,9 @@ } }, "tags": [ + { + "name": "config" + }, { "name": "error" }, 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..f8403b3 100644 --- a/examples/axum-example/src/models/mod.rs +++ b/examples/axum-example/src/models/mod.rs @@ -1,3 +1,4 @@ +pub mod config; pub mod memo; pub mod memo_comment; pub mod user; diff --git a/examples/axum-example/src/routes/config.rs b/examples/axum-example/src/routes/config.rs new file mode 100644 index 0000000..ea209a5 --- /dev/null +++ b/examples/axum-example/src/routes/config.rs @@ -0,0 +1,71 @@ +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, +} + +#[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, +} + +/// 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, + }) +} + +/// 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, + }; + + 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), + }) +} 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..d4e599d 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; diff --git a/examples/axum-example/tests/snapshots/integration_test__openapi.snap b/examples/axum-example/tests/snapshots/integration_test__openapi.snap index 5fce0eb..357da29 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" } } ], @@ -1895,7 +1961,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 +2029,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 +2121,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 +2209,64 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "nestedStructMapArray" ] }, + "Config": { + "type": "object", + "properties": { + "discountRate": { + "type": "number", + "format": "decimal", + "nullable": true + }, + "maxItems": { + "type": "integer" + }, + "maxPrice": { + "type": "number", + "format": "decimal" + }, + "minPrice": { + "type": "number", + "format": "decimal" + }, + "priority": { + "type": "integer", + "format": "int32" + }, + "retryCount": { + "type": "integer", + "format": "uint8" + }, + "taxRate": { + "type": "number", + "format": "decimal" + } + }, + "required": [ + "taxRate", + "minPrice", + "maxPrice", + "maxItems", + "retryCount", + "priority" + ] + }, + "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 +2323,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "string" }, "id": { - "type": "integer" + "type": "integer", + "format": "int64" }, "repliedAt": { "type": "string", @@ -2210,7 +2338,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "nullable": true }, "userId": { - "type": "integer" + "type": "integer", + "format": "int64" } }, "required": [ @@ -2330,7 +2459,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 +2480,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "object", "properties": { "C": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -2392,7 +2523,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "string" }, { - "type": "integer" + "type": "integer", + "format": "int32" } ], "minItems": 2, @@ -2531,7 +2663,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 +2679,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 +2702,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 +2727,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "object", "properties": { "id": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -2609,7 +2745,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 +2766,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "nullable": true }, "id": { - "type": "integer" + "type": "integer", + "format": "int64" }, "isActive": { "type": "boolean" @@ -2709,7 +2847,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 +2871,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 +2914,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 +2942,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 +2960,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "$ref": "#/components/schemas/UserSchema" }, "userId": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -2841,7 +2986,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 +2996,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "string" }, "userId": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -2887,17 +3034,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 +3070,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 +3083,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "$ref": "#/components/schemas/UserSchema" }, "userId": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -2956,7 +3108,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 +3125,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "$ref": "#/components/schemas/UserSchema" }, "userId": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -2994,10 +3148,12 @@ 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": [ @@ -3024,13 +3180,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 +3205,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 +3243,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 +3319,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "nullable": true }, "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "job": { "type": "string", @@ -3244,6 +3407,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" }, "num": { "type": "integer", + "format": "int32", "default": 0 } }, @@ -3257,7 +3421,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 +3438,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "properties": { "age": { "type": "integer", + "format": "uint32", "nullable": true }, "name": { @@ -3285,7 +3451,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 +3485,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "string" }, "id": { - "type": "integer" + "type": "integer", + "format": "int64" }, "isSubscribed": { "type": "boolean" @@ -3356,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" @@ -3371,13 +3540,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 +3566,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" }, { "type": "integer", + "format": "int64", "description": "A numeric value" }, { @@ -3419,6 +3591,45 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" } ] }, + "UpdateConfigRequest": { + "type": "object", + "properties": { + "discountRate": { + "type": "number", + "format": "decimal", + "nullable": true + }, + "maxItems": { + "type": "integer", + "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 + }, + "taxRate": { + "type": "number", + "format": "decimal", + "nullable": true + } + } + }, "UpdateFileUploadRequest": { "type": "object", "properties": { @@ -3453,7 +3664,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "string" }, "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "title": { "type": "string" @@ -3473,10 +3685,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 +3709,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 +3729,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "string" }, "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "name": { "type": "string" @@ -3579,7 +3795,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "string" }, "id": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "name": { "type": "string" @@ -3606,6 +3823,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" }, "id": { "type": "integer", + "format": "int32", "description": "User ID" }, "name": { @@ -3631,7 +3849,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" @@ -3645,6 +3864,9 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" } }, "tags": [ + { + "name": "config" + }, { "name": "error" }, diff --git a/openapi.json b/openapi.json index 0ead67e..54e28be 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" } } ], @@ -1891,7 +1957,8 @@ "type": "object", "properties": { "code": { - "type": "integer" + "type": "integer", + "format": "int32" }, "message": { "type": "string" @@ -1958,7 +2025,8 @@ "type": "object", "properties": { "age": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "array": { "type": "array", @@ -2049,7 +2117,8 @@ "type": "object", "properties": { "age": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "array": { "type": "array", @@ -2136,6 +2205,64 @@ "nestedStructMapArray" ] }, + "Config": { + "type": "object", + "properties": { + "discountRate": { + "type": "number", + "format": "decimal", + "nullable": true + }, + "maxItems": { + "type": "integer" + }, + "maxPrice": { + "type": "number", + "format": "decimal" + }, + "minPrice": { + "type": "number", + "format": "decimal" + }, + "priority": { + "type": "integer", + "format": "int32" + }, + "retryCount": { + "type": "integer", + "format": "uint8" + }, + "taxRate": { + "type": "number", + "format": "decimal" + } + }, + "required": [ + "taxRate", + "minPrice", + "maxPrice", + "maxItems", + "retryCount", + "priority" + ] + }, + "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 +2319,8 @@ "type": "string" }, "id": { - "type": "integer" + "type": "integer", + "format": "int64" }, "repliedAt": { "type": "string", @@ -2206,7 +2334,8 @@ "nullable": true }, "userId": { - "type": "integer" + "type": "integer", + "format": "int64" } }, "required": [ @@ -2326,7 +2455,8 @@ "type": "object", "properties": { "age": { - "type": "integer" + "type": "integer", + "format": "int32" }, "name": { "type": "string" @@ -2346,7 +2476,8 @@ "type": "object", "properties": { "C": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -2388,7 +2519,8 @@ "type": "string" }, { - "type": "integer" + "type": "integer", + "format": "int32" } ], "minItems": 2, @@ -2527,7 +2659,8 @@ "type": "object", "properties": { "code": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "error": { "type": "string" @@ -2542,7 +2675,8 @@ "type": "object", "properties": { "code": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "error": { "type": "string" @@ -2564,7 +2698,8 @@ "type": "object", "properties": { "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "name": { "type": "string" @@ -2588,7 +2723,8 @@ "type": "object", "properties": { "id": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -2605,7 +2741,8 @@ "description": "Item was deleted", "properties": { "Deleted": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -2625,7 +2762,8 @@ "nullable": true }, "id": { - "type": "integer" + "type": "integer", + "format": "int64" }, "isActive": { "type": "boolean" @@ -2705,7 +2843,8 @@ "description": "A request message", "properties": { "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "method": { "type": "string" @@ -2728,7 +2867,8 @@ "description": "A response message", "properties": { "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "result": { "type": "string", @@ -2770,13 +2910,15 @@ "type": "object", "properties": { "age": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "name": { "type": "string" }, "optional_age": { "type": "integer", + "format": "uint32", "nullable": true } }, @@ -2796,13 +2938,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 +2956,8 @@ "$ref": "#/components/schemas/UserSchema" }, "userId": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -2837,7 +2982,8 @@ "format": "date-time" }, "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "status": { "$ref": "#/components/schemas/MemoStatus" @@ -2846,7 +2992,8 @@ "type": "string" }, "userId": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -2883,17 +3030,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 +3066,8 @@ "format": "date-time" }, "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "status": { "$ref": "#/components/schemas/MemoStatus" @@ -2928,7 +3079,8 @@ "$ref": "#/components/schemas/UserSchema" }, "userId": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -2952,7 +3104,8 @@ "format": "date-time" }, "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "status": { "$ref": "#/components/schemas/MemoStatus" @@ -2968,7 +3121,8 @@ "$ref": "#/components/schemas/UserSchema" }, "userId": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -2990,10 +3144,12 @@ "format": "date-time" }, "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "user_id": { - "type": "integer" + "type": "integer", + "format": "int32" } }, "required": [ @@ -3020,13 +3176,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 +3201,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 +3239,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 +3315,8 @@ "nullable": true }, "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "job": { "type": "string", @@ -3240,6 +3403,7 @@ }, "num": { "type": "integer", + "format": "int32", "default": 0 } }, @@ -3253,7 +3417,8 @@ "type": "object", "properties": { "age": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "name": { "type": "string" @@ -3269,6 +3434,7 @@ "properties": { "age": { "type": "integer", + "format": "uint32", "nullable": true }, "name": { @@ -3281,7 +3447,8 @@ "type": "object", "properties": { "age": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "name": { "type": "string" @@ -3314,7 +3481,8 @@ "type": "string" }, "id": { - "type": "integer" + "type": "integer", + "format": "int64" }, "isSubscribed": { "type": "boolean" @@ -3352,7 +3520,8 @@ "type": "object", "properties": { "age": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "name": { "type": "string" @@ -3367,13 +3536,15 @@ "type": "object", "properties": { "age": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "name": { "type": "string" }, "optional_age": { "type": "integer", + "format": "uint32", "nullable": true } }, @@ -3391,6 +3562,7 @@ }, { "type": "integer", + "format": "int64", "description": "A numeric value" }, { @@ -3415,6 +3587,45 @@ } ] }, + "UpdateConfigRequest": { + "type": "object", + "properties": { + "discountRate": { + "type": "number", + "format": "decimal", + "nullable": true + }, + "maxItems": { + "type": "integer", + "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 + }, + "taxRate": { + "type": "number", + "format": "decimal", + "nullable": true + } + } + }, "UpdateFileUploadRequest": { "type": "object", "properties": { @@ -3449,7 +3660,8 @@ "type": "string" }, "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "title": { "type": "string" @@ -3469,10 +3681,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 +3705,8 @@ "description": "Full user model with all fields", "properties": { "id": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "name": { "type": "string" @@ -3510,7 +3725,8 @@ "type": "string" }, "id": { - "type": "integer" + "type": "integer", + "format": "int32" }, "name": { "type": "string" @@ -3575,7 +3791,8 @@ "type": "string" }, "id": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "name": { "type": "string" @@ -3602,6 +3819,7 @@ }, "id": { "type": "integer", + "format": "int32", "description": "User ID" }, "name": { @@ -3627,7 +3845,8 @@ "description": "Full user model with all fields", "properties": { "id": { - "type": "integer" + "type": "integer", + "format": "uint32" }, "name": { "type": "string" @@ -3641,6 +3860,9 @@ } }, "tags": [ + { + "name": "config" + }, { "name": "error" }, From ce9379908f959c8ada05970dbf9aefaf367a7208 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 18 Feb 2026 21:39:19 +0900 Subject: [PATCH 02/13] Add default --- crates/vespera_core/src/openapi.rs | 13 +- crates/vespera_core/src/route.rs | 44 +---- crates/vespera_macro/src/openapi_generator.rs | 187 ++++++++++++------ 3 files changed, 134 insertions(+), 110 deletions(-) diff --git a/crates/vespera_core/src/openapi.rs b/crates/vespera_core/src/openapi.rs index eecd51b..87d613b 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 @@ -245,16 +245,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() } } diff --git a/crates/vespera_core/src/route.rs b/crates/vespera_core/src/route.rs index efbd18f..38ee947 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 @@ -336,19 +336,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()), @@ -418,19 +406,7 @@ mod tests { #[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 mut path_item = PathItem::default(); let operation = Operation { operation_id: Some("test_operation".to_string()), @@ -489,19 +465,7 @@ mod tests { #[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_macro/src/openapi_generator.rs b/crates/vespera_macro/src/openapi_generator.rs index 5bb4609..a1fcf05 100644 --- a/crates/vespera_macro/src/openapi_generator.rs +++ b/crates/vespera_macro/src/openapi_generator.rs @@ -48,11 +48,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 { @@ -250,19 +246,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,6 +257,24 @@ 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: /// 1. `#[schema(default = "value")]` attributes (generated by `schema_type!` from `sea_orm(default_value)`) @@ -284,7 +286,6 @@ fn process_default_functions( 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); @@ -308,12 +309,7 @@ fn process_default_functions( // 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); - if let Some(prop_schema_ref) = properties.get_mut(&field_name) - && let SchemaRef::Inline(prop_schema) = prop_schema_ref - && prop_schema.default.is_none() - { - prop_schema.default = Some(value); - } + set_property_default(properties, &field_name, value); continue; } @@ -322,30 +318,19 @@ fn process_default_functions( Some(Some(func_name)) => func_name, // default = "function_name" Some(None) => { // Simple default (no function) - we can set type-specific defaults - 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) { - // 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); } } } @@ -356,8 +341,10 @@ fn process_default_functions( /// 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 { - for attr in attrs { - if attr.path().is_ident("schema") { + 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") { @@ -367,12 +354,8 @@ fn extract_schema_default_attr(attrs: &[syn::Attribute]) -> Option { } Ok(()) }); - if default_value.is_some() { - return default_value; - } - } - } - None + default_value + }) } /// Parse a default value string into the appropriate `serde_json::Value`. @@ -402,14 +385,10 @@ 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 @@ -1504,4 +1483,94 @@ pub fn create_users() -> String { // 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"); + } } From 0de71f8c03243e2f0e81625a6e9b666c604cf71b Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 18 Feb 2026 22:22:56 +0900 Subject: [PATCH 03/13] Add min --- crates/vespera_core/src/schema.rs | 36 ++- crates/vespera_macro/src/collector.rs | 50 ++-- crates/vespera_macro/src/openapi_generator.rs | 73 +++--- .../src/parser/schema/type_schema.rs | 12 +- crates/vespera_macro/src/router_codegen.rs | 14 +- .../src/schema_macro/circular.rs | 213 +++++++++--------- .../src/schema_macro/from_model.rs | 40 ++-- crates/vespera_macro/src/vespera_impl.rs | 29 +-- examples/axum-example/openapi.json | 4 +- examples/axum-example/src/routes/config.rs | 1 - .../snapshots/integration_test__openapi.snap | 4 +- openapi.json | 4 +- 12 files changed, 281 insertions(+), 199 deletions(-) diff --git a/crates/vespera_core/src/schema.rs b/crates/vespera_core/src/schema.rs index f06e1b9..b836e80 100644 --- a/crates/vespera_core/src/schema.rs +++ b/crates/vespera_core/src/schema.rs @@ -75,6 +75,27 @@ pub enum StringFormat { 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 #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -108,10 +129,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 +147,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 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 a1fcf05..2fb2da0 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, ); @@ -144,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 @@ -156,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) } @@ -176,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)) @@ -302,9 +323,8 @@ fn process_default_functions( || "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()) - }); + 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) { @@ -488,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"); @@ -515,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); @@ -546,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(); @@ -564,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(); @@ -580,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(); @@ -597,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(); @@ -633,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()); @@ -660,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()); } @@ -700,6 +720,7 @@ pub fn get_user() -> User { Some("1.0.0".to_string()), None, &metadata, + None, ); // Check struct schema @@ -755,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(); @@ -824,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 { @@ -869,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(); @@ -899,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(); @@ -1143,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()); @@ -1189,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(); @@ -1260,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()); @@ -1399,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!( @@ -1451,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); @@ -1479,7 +1500,7 @@ 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()); } diff --git a/crates/vespera_macro/src/parser/schema/type_schema.rs b/crates/vespera_macro/src/parser/schema/type_schema.rs index 449da74..0c2771c 100644 --- a/crates/vespera_macro/src/parser/schema/type_schema.rs +++ b/crates/vespera_macro/src/parser/schema/type_schema.rs @@ -261,11 +261,13 @@ fn parse_type_impl( format: Some("uint64".to_string()), ..Schema::integer() })), - // i128, isize, u128, usize: no standard format in the registry - "i128" | "isize" | "u128" | "usize" => { - SchemaRef::Inline(Box::new(Schema::integer())) - } - "StatusCode" => SchemaRef::Inline(Box::new(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() 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..7a69adb 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,6 +14,101 @@ use super::{ }; use crate::parser::extract_skip; +/// Combined result of circular reference analysis. +/// +/// 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. +/// +/// This consolidates the logic of [`detect_circular_fields()`], [`has_fk_relations()`], +/// and [`is_circular_relation_required()`] to avoid redundant parsing of the same +/// definition string. +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(), + }; + }; + + let syn::Fields::Named(fields_named) = &parsed.fields else { + return CircularAnalysis { + circular_fields: Vec::new(), + has_fk_relations: false, + circular_field_required: HashMap::new(), + }; + }; + + let source_module = source_module_path + .last() + .map_or("", std::string::String::as_str); + + let mut circular_fields = Vec::new(); + let mut has_fk = false; + let mut circular_field_required = HashMap::new(); + + for field in &fields_named.named { + let Some(field_ident) = field.ident.as_ref() else { + continue; + }; + 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); + } + + // --- 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); + } + } + } + + CircularAnalysis { + circular_fields, + has_fk_relations: has_fk, + circular_field_required, + } +} + /// Detect circular reference fields in a related schema. /// /// When generating `MemoSchema.user`, we need to check if `UserSchema` has any fields @@ -21,61 +118,14 @@ use crate::parser::extract_skip; /// from generated schemas. /// /// Returns a list of field names that would create circular references. +/// +/// Thin wrapper around [`analyze_circular_refs()`] for backward compatibility. 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(); - }; - - // 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(); - }; - - 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() + analyze_circular_refs(source_module_path, related_schema_def).circular_fields } /// Check if a Model has any `BelongsTo` or `HasOne` relations (FK-based relations). @@ -85,64 +135,25 @@ pub fn detect_circular_fields( /// /// - Schemas with FK relations -> have `from_model()`, need async call /// - Schemas without FK relations -> have `From`, can use sync conversion +/// +/// Thin wrapper around [`analyze_circular_refs()`] for backward compatibility. +#[cfg_attr(not(test), allow(dead_code))] pub fn has_fk_relations(model_def: &str) -> bool { - let Ok(parsed) = syn::parse_str::(model_def) else { - return false; - }; - - 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(' ', ""); - - // Check for BelongsTo or HasOne patterns - if ty_str.contains("HasOne<") || ty_str.contains("BelongsTo<") { - return true; - } - } - } - - false + analyze_circular_refs(&[], model_def).has_fk_relations } /// 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. +/// +/// Thin wrapper around [`analyze_circular_refs()`] for backward compatibility. +#[cfg_attr(not(test), allow(dead_code))] 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; - } - - // 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)) + analyze_circular_refs(&[], related_model_def) + .circular_field_required + .get(circular_field_name) + .copied() + .unwrap_or(false) } /// Generate a default value for a `SeaORM` relation field in inline construction. 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/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/openapi.json b/examples/axum-example/openapi.json index 54e28be..3b2b0ee 100644 --- a/examples/axum-example/openapi.json +++ b/examples/axum-example/openapi.json @@ -2214,7 +2214,8 @@ "nullable": true }, "maxItems": { - "type": "integer" + "type": "integer", + "minimum": 0 }, "maxPrice": { "type": "number", @@ -3597,6 +3598,7 @@ }, "maxItems": { "type": "integer", + "minimum": 0, "nullable": true }, "maxPrice": { diff --git a/examples/axum-example/src/routes/config.rs b/examples/axum-example/src/routes/config.rs index ea209a5..cf758e2 100644 --- a/examples/axum-example/src/routes/config.rs +++ b/examples/axum-example/src/routes/config.rs @@ -44,7 +44,6 @@ pub async fn get_config() -> Json { /// 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), diff --git a/examples/axum-example/tests/snapshots/integration_test__openapi.snap b/examples/axum-example/tests/snapshots/integration_test__openapi.snap index 357da29..3a9f687 100644 --- a/examples/axum-example/tests/snapshots/integration_test__openapi.snap +++ b/examples/axum-example/tests/snapshots/integration_test__openapi.snap @@ -2218,7 +2218,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "nullable": true }, "maxItems": { - "type": "integer" + "type": "integer", + "minimum": 0 }, "maxPrice": { "type": "number", @@ -3601,6 +3602,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" }, "maxItems": { "type": "integer", + "minimum": 0, "nullable": true }, "maxPrice": { diff --git a/openapi.json b/openapi.json index 54e28be..3b2b0ee 100644 --- a/openapi.json +++ b/openapi.json @@ -2214,7 +2214,8 @@ "nullable": true }, "maxItems": { - "type": "integer" + "type": "integer", + "minimum": 0 }, "maxPrice": { "type": "number", @@ -3597,6 +3598,7 @@ }, "maxItems": { "type": "integer", + "minimum": 0, "nullable": true }, "maxPrice": { From 05cbd722ff4b23d3194ac0ad9116bab5204aecbf Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 18 Feb 2026 22:44:11 +0900 Subject: [PATCH 04/13] Add testcase --- crates/vespera_core/src/schema.rs | 75 ++++++++ crates/vespera_macro/src/openapi_generator.rs | 69 ++++++++ .../vespera_macro/src/schema_macro/codegen.rs | 26 +++ .../vespera_macro/src/schema_macro/seaorm.rs | 24 +++ .../vespera_macro/src/schema_macro/tests.rs | 160 +++++++++++++++++- 5 files changed, 353 insertions(+), 1 deletion(-) diff --git a/crates/vespera_core/src/schema.rs b/crates/vespera_core/src/schema.rs index b836e80..82870ed 100644 --- a/crates/vespera_core/src/schema.rs +++ b/crates/vespera_core/src/schema.rs @@ -463,4 +463,79 @@ mod tests { let required = schema.required.expect("required should be initialized"); assert!(required.is_empty()); } + + #[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/openapi_generator.rs b/crates/vespera_macro/src/openapi_generator.rs index 2fb2da0..8c0f0c7 100644 --- a/crates/vespera_macro/src/openapi_generator.rs +++ b/crates/vespera_macro/src/openapi_generator.rs @@ -1594,4 +1594,73 @@ pub fn create_users() -> String { 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("3.14"); + assert_eq!(result, serde_json::json!(3.14)); + } + + #[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/schema_macro/codegen.rs b/crates/vespera_macro/src/schema_macro/codegen.rs index e7292f2..feb0520 100644 --- a/crates/vespera_macro/src/schema_macro/codegen.rs +++ b/crates/vespera_macro/src/schema_macro/codegen.rs @@ -446,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/seaorm.rs b/crates/vespera_macro/src/schema_macro/seaorm.rs index 28125c6..7b6d556 100644 --- a/crates/vespera_macro/src/schema_macro/seaorm.rs +++ b/crates/vespera_macro/src/schema_macro/seaorm.rs @@ -1173,6 +1173,30 @@ mod tests { 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 // ========================================================================= diff --git a/crates/vespera_macro/src/schema_macro/tests.rs b/crates/vespera_macro/src/schema_macro/tests.rs index 87ae270..db63c21 100644 --- a/crates/vespera_macro/src/schema_macro/tests.rs +++ b/crates/vespera_macro/src/schema_macro/tests.rs @@ -244,7 +244,165 @@ 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_skips() { + 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, + ); + assert!(serde.is_empty()); + assert!(schema.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] From 8dc9ccdded0b9bdb2528fd1d184e7b216f3af5e9 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 18 Feb 2026 22:48:25 +0900 Subject: [PATCH 05/13] Add testcase --- crates/vespera_macro/src/openapi_generator.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/vespera_macro/src/openapi_generator.rs b/crates/vespera_macro/src/openapi_generator.rs index 8c0f0c7..a476c1f 100644 --- a/crates/vespera_macro/src/openapi_generator.rs +++ b/crates/vespera_macro/src/openapi_generator.rs @@ -1624,8 +1624,8 @@ pub fn create_users() -> String { #[test] fn test_parse_default_string_to_json_value_float() { - let result = parse_default_string_to_json_value("3.14"); - assert_eq!(result, serde_json::json!(3.14)); + let result = parse_default_string_to_json_value("2.72"); + assert_eq!(result, serde_json::json!(2.72)); } #[test] From 5267d6e7a92dbc8d1baee0fd72ee60a25258f2a8 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 18 Feb 2026 22:50:30 +0900 Subject: [PATCH 06/13] Support char --- .changepacks/changepack_log_istCnj_YAWHCFG2Y4WmgK.json | 1 + crates/vespera_macro/src/parser/schema/type_schema.rs | 4 ++++ 2 files changed, 5 insertions(+) create mode 100644 .changepacks/changepack_log_istCnj_YAWHCFG2Y4WmgK.json 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/crates/vespera_macro/src/parser/schema/type_schema.rs b/crates/vespera_macro/src/parser/schema/type_schema.rs index 0c2771c..097ecfc 100644 --- a/crates/vespera_macro/src/parser/schema/type_schema.rs +++ b/crates/vespera_macro/src/parser/schema/type_schema.rs @@ -281,6 +281,10 @@ fn parse_type_impl( ..Schema::number() })), "bool" => SchemaRef::Inline(Box::new(Schema::boolean())), + "char" => SchemaRef::Inline(Box::new(Schema { + format: Some("char".to_string()), + ..Schema::string() + })), "String" | "str" => SchemaRef::Inline(Box::new(Schema::string())), // Date-time types from chrono and time crates "DateTime" From b0bd03bedec1f71b4e1883aed245a730c8248737 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 18 Feb 2026 22:52:18 +0900 Subject: [PATCH 07/13] Support char --- examples/axum-example/openapi.json | 22 ++++++++++++++++++- examples/axum-example/src/routes/config.rs | 10 +++++++++ .../snapshots/integration_test__openapi.snap | 22 ++++++++++++++++++- openapi.json | 22 ++++++++++++++++++- 4 files changed, 73 insertions(+), 3 deletions(-) diff --git a/examples/axum-example/openapi.json b/examples/axum-example/openapi.json index 3b2b0ee..3d75e13 100644 --- a/examples/axum-example/openapi.json +++ b/examples/axum-example/openapi.json @@ -2208,6 +2208,11 @@ "Config": { "type": "object", "properties": { + "delimiter": { + "type": "string", + "format": "char", + "nullable": true + }, "discountRate": { "type": "number", "format": "decimal", @@ -2233,6 +2238,10 @@ "type": "integer", "format": "uint8" }, + "separator": { + "type": "string", + "format": "char" + }, "taxRate": { "type": "number", "format": "decimal" @@ -2244,7 +2253,8 @@ "maxPrice", "maxItems", "retryCount", - "priority" + "priority", + "separator" ] }, "ConfigSchema": { @@ -3591,6 +3601,11 @@ "UpdateConfigRequest": { "type": "object", "properties": { + "delimiter": { + "type": "string", + "format": "char", + "nullable": true + }, "discountRate": { "type": "number", "format": "decimal", @@ -3621,6 +3636,11 @@ "format": "uint8", "nullable": true }, + "separator": { + "type": "string", + "format": "char", + "nullable": true + }, "taxRate": { "type": "number", "format": "decimal", diff --git a/examples/axum-example/src/routes/config.rs b/examples/axum-example/src/routes/config.rs index cf758e2..80b8ee5 100644 --- a/examples/axum-example/src/routes/config.rs +++ b/examples/axum-example/src/routes/config.rs @@ -13,6 +13,8 @@ pub struct Config { pub max_items: usize, pub retry_count: u8, pub priority: i32, + pub separator: char, + pub delimiter: Option, } #[derive(Deserialize, Schema)] @@ -25,6 +27,8 @@ pub struct UpdateConfigRequest { pub max_items: Option, pub retry_count: Option, pub priority: Option, + pub separator: Option, + pub delimiter: Option, } /// Get current config @@ -38,6 +42,8 @@ pub async fn get_config() -> Json { max_items: 100, retry_count: 3, priority: 0, + separator: ',', + delimiter: Some('|'), }) } @@ -56,6 +62,8 @@ pub async fn update_config(Json(req): Json) -> Json max_items: 100, retry_count: 3, priority: 0, + separator: ',', + delimiter: Some('|'), }; Json(Config { @@ -66,5 +74,7 @@ pub async fn update_config(Json(req): Json) -> Json 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/tests/snapshots/integration_test__openapi.snap b/examples/axum-example/tests/snapshots/integration_test__openapi.snap index 3a9f687..b2ebe47 100644 --- a/examples/axum-example/tests/snapshots/integration_test__openapi.snap +++ b/examples/axum-example/tests/snapshots/integration_test__openapi.snap @@ -2212,6 +2212,11 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "Config": { "type": "object", "properties": { + "delimiter": { + "type": "string", + "format": "char", + "nullable": true + }, "discountRate": { "type": "number", "format": "decimal", @@ -2237,6 +2242,10 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "integer", "format": "uint8" }, + "separator": { + "type": "string", + "format": "char" + }, "taxRate": { "type": "number", "format": "decimal" @@ -2248,7 +2257,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "maxPrice", "maxItems", "retryCount", - "priority" + "priority", + "separator" ] }, "ConfigSchema": { @@ -3595,6 +3605,11 @@ 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", @@ -3625,6 +3640,11 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "format": "uint8", "nullable": true }, + "separator": { + "type": "string", + "format": "char", + "nullable": true + }, "taxRate": { "type": "number", "format": "decimal", diff --git a/openapi.json b/openapi.json index 3b2b0ee..3d75e13 100644 --- a/openapi.json +++ b/openapi.json @@ -2208,6 +2208,11 @@ "Config": { "type": "object", "properties": { + "delimiter": { + "type": "string", + "format": "char", + "nullable": true + }, "discountRate": { "type": "number", "format": "decimal", @@ -2233,6 +2238,10 @@ "type": "integer", "format": "uint8" }, + "separator": { + "type": "string", + "format": "char" + }, "taxRate": { "type": "number", "format": "decimal" @@ -2244,7 +2253,8 @@ "maxPrice", "maxItems", "retryCount", - "priority" + "priority", + "separator" ] }, "ConfigSchema": { @@ -3591,6 +3601,11 @@ "UpdateConfigRequest": { "type": "object", "properties": { + "delimiter": { + "type": "string", + "format": "char", + "nullable": true + }, "discountRate": { "type": "number", "format": "decimal", @@ -3621,6 +3636,11 @@ "format": "uint8", "nullable": true }, + "separator": { + "type": "string", + "format": "char", + "nullable": true + }, "taxRate": { "type": "number", "format": "decimal", From 7d7087e2d75dc0ca06240d0301b18f423268c062 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 18 Feb 2026 22:57:58 +0900 Subject: [PATCH 08/13] Cleanup code --- .../src/schema_macro/circular.rs | 200 +++++++++--------- .../src/schema_macro/inline_types.rs | 6 +- 2 files changed, 101 insertions(+), 105 deletions(-) diff --git a/crates/vespera_macro/src/schema_macro/circular.rs b/crates/vespera_macro/src/schema_macro/circular.rs index 7a69adb..c764285 100644 --- a/crates/vespera_macro/src/schema_macro/circular.rs +++ b/crates/vespera_macro/src/schema_macro/circular.rs @@ -34,9 +34,8 @@ pub struct CircularAnalysis { /// Analyze a struct definition for circular references, FK relations, and FK optionality /// in a single parse + single field walk. /// -/// This consolidates the logic of [`detect_circular_fields()`], [`has_fk_relations()`], -/// and [`is_circular_relation_required()`] to avoid redundant parsing of the same -/// definition string. +/// 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 { @@ -109,53 +108,6 @@ pub fn analyze_circular_refs(source_module_path: &[String], definition: &str) -> } } -/// 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). -/// -/// `HasMany` relations are NOT considered circular because they are excluded by default -/// from generated schemas. -/// -/// Returns a list of field names that would create circular references. -/// -/// Thin wrapper around [`analyze_circular_refs()`] for backward compatibility. -pub fn detect_circular_fields( - _source_schema_name: &str, - source_module_path: &[String], - related_schema_def: &str, -) -> Vec { - analyze_circular_refs(source_module_path, related_schema_def).circular_fields -} - -/// 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 -/// -/// Thin wrapper around [`analyze_circular_refs()`] for backward compatibility. -#[cfg_attr(not(test), allow(dead_code))] -pub fn has_fk_relations(model_def: &str) -> bool { - analyze_circular_refs(&[], model_def).has_fk_relations -} - -/// 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. -/// -/// Thin wrapper around [`analyze_circular_refs()`] for backward compatibility. -#[cfg_attr(not(test), allow(dead_code))] -pub fn is_circular_relation_required(related_model_def: &str, circular_field_name: &str) -> bool { - analyze_circular_refs(&[], related_model_def) - .circular_field_required - .get(circular_field_name) - .copied() - .unwrap_or(false) -} - /// Generate a default value for a `SeaORM` relation field in inline construction. /// /// - `HasMany` -> `vec![]` @@ -337,7 +289,6 @@ mod tests { #[rstest] #[case( - "Memo", &["crate", "models", "memo"], r"pub struct UserSchema { pub id: i32, @@ -346,7 +297,6 @@ mod tests { vec![] // HasMany is not considered circular )] #[case( - "User", &["crate", "models", "user"], r"pub struct MemoSchema { pub id: i32, @@ -355,7 +305,6 @@ mod tests { vec!["user".to_string()] )] #[case( - "User", &["crate", "models", "user"], r"pub struct MemoSchema { pub id: i32, @@ -364,7 +313,6 @@ mod tests { vec!["user".to_string()] )] #[case( - "User", &["crate", "models", "user"], r"pub struct MemoSchema { pub id: i32, @@ -373,7 +321,6 @@ mod tests { vec!["user".to_string()] )] #[case( - "Memo", &["crate", "models", "memo"], r"pub struct UserSchema { pub id: i32, @@ -382,7 +329,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, @@ -391,27 +337,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()); } @@ -445,30 +392,42 @@ 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] @@ -477,7 +436,11 @@ 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] @@ -649,10 +612,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, @@ -661,14 +624,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, @@ -677,29 +644,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); } @@ -742,20 +721,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(), @@ -765,15 +744,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(), @@ -783,20 +762,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()); } @@ -905,7 +885,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, @@ -914,12 +894,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, @@ -928,12 +912,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, @@ -942,12 +930,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, @@ -955,7 +947,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/inline_types.rs b/crates/vespera_macro/src/schema_macro/inline_types.rs index 01419b1..ad82f3e 100644 --- a/crates/vespera_macro/src/schema_macro/inline_types.rs +++ b/crates/vespera_macro/src/schema_macro/inline_types.rs @@ -7,9 +7,9 @@ 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}, + seaorm::{convert_type_with_chrono, RelationFieldInfo}, type_utils::{ extract_module_path_from_schema_path, is_seaorm_relation_type, snake_to_pascal_case, }, @@ -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() { From 933e9ee683669700f6a8b15aa1e6dbc522632e77 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 18 Feb 2026 23:04:36 +0900 Subject: [PATCH 09/13] Cleanup code --- crates/vespera_core/src/openapi.rs | 41 -------------- crates/vespera_core/src/route.rs | 85 ------------------------------ crates/vespera_core/src/schema.rs | 27 ---------- 3 files changed, 153 deletions(-) diff --git a/crates/vespera_core/src/openapi.rs b/crates/vespera_core/src/openapi.rs index 87d613b..5c3ee25 100644 --- a/crates/vespera_core/src/openapi.rs +++ b/crates/vespera_core/src/openapi.rs @@ -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)] @@ -460,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 38ee947..72caf9b 100644 --- a/crates/vespera_core/src/route.rs +++ b/crates/vespera_core/src/route.rs @@ -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)] @@ -404,65 +378,6 @@ mod tests { assert!(path_item.trace.is_some()); } - #[test] - fn test_path_item_get_operation() { - let mut path_item = PathItem::default(); - - 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::default(); diff --git a/crates/vespera_core/src/schema.rs b/crates/vespera_core/src/schema.rs index 82870ed..84e2ed7 100644 --- a/crates/vespera_core/src/schema.rs +++ b/crates/vespera_core/src/schema.rs @@ -48,33 +48,6 @@ 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 From de969ed5fce7798279517ad2ab29bb708af27ee6 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 18 Feb 2026 23:16:22 +0900 Subject: [PATCH 10/13] Support uuid format --- .../changepack_log_wLkPH1ixjpQHBc8JeR-Ur.json | 1 + Cargo.lock | 172 +++++++++++++++++- .../src/parser/schema/type_schema.rs | 4 + examples/axum-example/Cargo.toml | 3 +- .../models/uuid_item.vespertide.json | 11 ++ examples/axum-example/openapi.json | 124 +++++++++++++ examples/axum-example/src/models/memo.rs | 5 +- .../axum-example/src/models/memo_comment.rs | 1 + examples/axum-example/src/models/mod.rs | 1 + examples/axum-example/src/models/user.rs | 1 + examples/axum-example/src/models/uuid_item.rs | 21 +++ examples/axum-example/src/routes/mod.rs | 1 + .../axum-example/src/routes/uuid_items.rs | 43 +++++ .../snapshots/integration_test__openapi.snap | 124 +++++++++++++ openapi.json | 124 +++++++++++++ 15 files changed, 631 insertions(+), 5 deletions(-) create mode 100644 .changepacks/changepack_log_wLkPH1ixjpQHBc8JeR-Ur.json create mode 100644 examples/axum-example/models/uuid_item.vespertide.json create mode 100644 examples/axum-example/src/models/uuid_item.rs create mode 100644 examples/axum-example/src/routes/uuid_items.rs 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 0784041..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", @@ -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_macro/src/parser/schema/type_schema.rs b/crates/vespera_macro/src/parser/schema/type_schema.rs index 097ecfc..9b0fadd 100644 --- a/crates/vespera_macro/src/parser/schema/type_schema.rs +++ b/crates/vespera_macro/src/parser/schema/type_schema.rs @@ -285,6 +285,10 @@ fn parse_type_impl( 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/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/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 3d75e13..e9ecffb 100644 --- a/examples/axum-example/openapi.json +++ b/examples/axum-example/openapi.json @@ -1912,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": { @@ -2437,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": [ @@ -3878,6 +3947,58 @@ "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": [ + "id", + "name", + "createdAt" + ] } } }, @@ -3903,6 +4024,9 @@ { "name": "typed-form" }, + { + "name": "uuid_items" + }, { "name": "third" } diff --git a/examples/axum-example/src/models/memo.rs b/examples/axum-example/src/models/memo.rs index a09aada..c13043c 100644 --- a/examples/axum-example/src/models/memo.rs +++ b/examples/axum-example/src/models/memo.rs @@ -1,9 +1,7 @@ use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; -#[derive( - Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize, vespera::Schema, -)] +#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize, vespera::Schema)] #[serde(rename_all = "camelCase")] #[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "memo_memo_status")] pub enum MemoStatus { @@ -37,6 +35,7 @@ pub struct Model { pub memo_comments: HasMany, } + // Index definitions (SeaORM uses Statement builders externally) // (unnamed) on [user_id] vespera::schema_type!(Schema from Model, name = "MemoSchema"); diff --git a/examples/axum-example/src/models/memo_comment.rs b/examples/axum-example/src/models/memo_comment.rs index 447b458..807aee5 100644 --- a/examples/axum-example/src/models/memo_comment.rs +++ b/examples/axum-example/src/models/memo_comment.rs @@ -21,6 +21,7 @@ pub struct Model { pub memo: HasOne, } + // Index definitions (SeaORM uses Statement builders externally) // (unnamed) on [user_id] // (unnamed) on [memo_id] diff --git a/examples/axum-example/src/models/mod.rs b/examples/axum-example/src/models/mod.rs index f8403b3..02ee003 100644 --- a/examples/axum-example/src/models/mod.rs +++ b/examples/axum-example/src/models/mod.rs @@ -2,3 +2,4 @@ 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/user.rs b/examples/axum-example/src/models/user.rs index ffd75e1..be28eb8 100644 --- a/examples/axum-example/src/models/user.rs +++ b/examples/axum-example/src/models/user.rs @@ -25,6 +25,7 @@ pub struct Model { pub memo_comments: HasMany, } + // Index definitions (SeaORM uses Statement builders externally) // (unnamed) on [email] vespera::schema_type!(Schema from Model, name = "UserSchema"); 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/mod.rs b/examples/axum-example/src/routes/mod.rs index d4e599d..7e919c9 100644 --- a/examples/axum-example/src/routes/mod.rs +++ b/examples/axum-example/src/routes/mod.rs @@ -22,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 b2ebe47..ad18b8c 100644 --- a/examples/axum-example/tests/snapshots/integration_test__openapi.snap +++ b/examples/axum-example/tests/snapshots/integration_test__openapi.snap @@ -1916,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": { @@ -2441,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": [ @@ -3882,6 +3951,58 @@ 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": [ + "id", + "name", + "createdAt" + ] } } }, @@ -3907,6 +4028,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 3d75e13..e9ecffb 100644 --- a/openapi.json +++ b/openapi.json @@ -1912,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": { @@ -2437,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": [ @@ -3878,6 +3947,58 @@ "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": [ + "id", + "name", + "createdAt" + ] } } }, @@ -3903,6 +4024,9 @@ { "name": "typed-form" }, + { + "name": "uuid_items" + }, { "name": "third" } From edff8765773dcb59403225a79345de1b49db8dcd Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 18 Feb 2026 23:28:08 +0900 Subject: [PATCH 11/13] Fix required logic --- crates/vespera_macro/src/schema_macro/mod.rs | 65 ++++++++++++++++++- examples/axum-example/openapi.json | 19 ++---- .../snapshots/integration_test__openapi.snap | 19 ++---- openapi.json | 19 ++---- 4 files changed, 76 insertions(+), 46 deletions(-) diff --git a/crates/vespera_macro/src/schema_macro/mod.rs b/crates/vespera_macro/src/schema_macro/mod.rs index 3bd0de6..5226425 100644 --- a/crates/vespera_macro/src/schema_macro/mod.rs +++ b/crates/vespera_macro/src/schema_macro/mod.rs @@ -655,8 +655,25 @@ fn generate_sea_orm_default_attrs( return (quote! {}, quote! {}); }; - // Skip SQL functions like NOW(), CURRENT_TIMESTAMP(), UUID() + // 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! {}); } @@ -691,6 +708,52 @@ fn generate_sea_orm_default_attrs( (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. diff --git a/examples/axum-example/openapi.json b/examples/axum-example/openapi.json index e9ecffb..903c75f 100644 --- a/examples/axum-example/openapi.json +++ b/examples/axum-example/openapi.json @@ -3045,8 +3045,6 @@ "userId", "memoId", "content", - "createdAt", - "updatedAt", "user", "memo" ] @@ -3081,8 +3079,7 @@ "userId", "title", "content", - "status", - "createdAt" + "status" ] }, "MemoResponseComments": { @@ -3169,7 +3166,6 @@ "title", "content", "status", - "createdAt", "user" ] }, @@ -3211,8 +3207,6 @@ "title", "content", "status", - "createdAt", - "updatedAt", "user" ] }, @@ -3234,8 +3228,7 @@ }, "required": [ "id", - "user_id", - "created_at" + "user_id" ] }, "MemoStatus": { @@ -3926,9 +3919,7 @@ "required": [ "id", "email", - "name", - "createdAt", - "updatedAt" + "name" ] }, "UserSummary": { @@ -3995,9 +3986,7 @@ } }, "required": [ - "id", - "name", - "createdAt" + "name" ] } } diff --git a/examples/axum-example/tests/snapshots/integration_test__openapi.snap b/examples/axum-example/tests/snapshots/integration_test__openapi.snap index ad18b8c..7b9f831 100644 --- a/examples/axum-example/tests/snapshots/integration_test__openapi.snap +++ b/examples/axum-example/tests/snapshots/integration_test__openapi.snap @@ -3049,8 +3049,6 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "userId", "memoId", "content", - "createdAt", - "updatedAt", "user", "memo" ] @@ -3085,8 +3083,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "userId", "title", "content", - "status", - "createdAt" + "status" ] }, "MemoResponseComments": { @@ -3173,7 +3170,6 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "title", "content", "status", - "createdAt", "user" ] }, @@ -3215,8 +3211,6 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "title", "content", "status", - "createdAt", - "updatedAt", "user" ] }, @@ -3238,8 +3232,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" }, "required": [ "id", - "user_id", - "created_at" + "user_id" ] }, "MemoStatus": { @@ -3930,9 +3923,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "required": [ "id", "email", - "name", - "createdAt", - "updatedAt" + "name" ] }, "UserSummary": { @@ -3999,9 +3990,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" } }, "required": [ - "id", - "name", - "createdAt" + "name" ] } } diff --git a/openapi.json b/openapi.json index e9ecffb..903c75f 100644 --- a/openapi.json +++ b/openapi.json @@ -3045,8 +3045,6 @@ "userId", "memoId", "content", - "createdAt", - "updatedAt", "user", "memo" ] @@ -3081,8 +3079,7 @@ "userId", "title", "content", - "status", - "createdAt" + "status" ] }, "MemoResponseComments": { @@ -3169,7 +3166,6 @@ "title", "content", "status", - "createdAt", "user" ] }, @@ -3211,8 +3207,6 @@ "title", "content", "status", - "createdAt", - "updatedAt", "user" ] }, @@ -3234,8 +3228,7 @@ }, "required": [ "id", - "user_id", - "created_at" + "user_id" ] }, "MemoStatus": { @@ -3926,9 +3919,7 @@ "required": [ "id", "email", - "name", - "createdAt", - "updatedAt" + "name" ] }, "UserSummary": { @@ -3995,9 +3986,7 @@ } }, "required": [ - "id", - "name", - "createdAt" + "name" ] } } From 846de4d1087bc21436147efc27a24f0b18231958 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 18 Feb 2026 23:39:41 +0900 Subject: [PATCH 12/13] Fix lint --- .../src/schema_macro/circular.rs | 24 +++++++------ .../src/schema_macro/inline_types.rs | 2 +- crates/vespera_macro/src/schema_macro/mod.rs | 4 +-- .../vespera_macro/src/schema_macro/tests.rs | 36 ++++++++++++++++++- examples/axum-example/src/models/memo.rs | 5 +-- .../axum-example/src/models/memo_comment.rs | 1 - examples/axum-example/src/models/user.rs | 1 - 7 files changed, 55 insertions(+), 18 deletions(-) diff --git a/crates/vespera_macro/src/schema_macro/circular.rs b/crates/vespera_macro/src/schema_macro/circular.rs index c764285..d5e7631 100644 --- a/crates/vespera_macro/src/schema_macro/circular.rs +++ b/crates/vespera_macro/src/schema_macro/circular.rs @@ -412,11 +412,13 @@ mod tests { #[test] fn test_is_circular_relation_required_invalid_struct() { - assert!(!analyze_circular_refs(&[], "not valid rust") - .circular_field_required - .get("user") - .copied() - .unwrap_or(false)); + assert!( + !analyze_circular_refs(&[], "not valid rust") + .circular_field_required + .get("user") + .copied() + .unwrap_or(false) + ); } #[test] @@ -436,11 +438,13 @@ mod tests { pub id: i32, pub name: String, }"; - assert!(!analyze_circular_refs(&[], model_def) - .circular_field_required - .get("nonexistent") - .copied() - .unwrap_or(false)); + assert!( + !analyze_circular_refs(&[], model_def) + .circular_field_required + .get("nonexistent") + .copied() + .unwrap_or(false) + ); } #[test] diff --git a/crates/vespera_macro/src/schema_macro/inline_types.rs b/crates/vespera_macro/src/schema_macro/inline_types.rs index ad82f3e..2bc0690 100644 --- a/crates/vespera_macro/src/schema_macro/inline_types.rs +++ b/crates/vespera_macro/src/schema_macro/inline_types.rs @@ -9,7 +9,7 @@ use quote::quote; use super::{ circular::analyze_circular_refs, file_lookup::find_model_from_schema_path, - seaorm::{convert_type_with_chrono, RelationFieldInfo}, + seaorm::{RelationFieldInfo, convert_type_with_chrono}, type_utils::{ extract_module_path_from_schema_path, is_seaorm_relation_type, snake_to_pascal_case, }, diff --git a/crates/vespera_macro/src/schema_macro/mod.rs b/crates/vespera_macro/src/schema_macro/mod.rs index 5226425..e3ae68d 100644 --- a/crates/vespera_macro/src/schema_macro/mod.rs +++ b/crates/vespera_macro/src/schema_macro/mod.rs @@ -726,8 +726,8 @@ fn sql_function_default_body(original_ty: &syn::Type) -> Option { 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" => { + "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 diff --git a/crates/vespera_macro/src/schema_macro/tests.rs b/crates/vespera_macro/src/schema_macro/tests.rs index db63c21..18a5dac 100644 --- a/crates/vespera_macro/src/schema_macro/tests.rs +++ b/crates/vespera_macro/src/schema_macro/tests.rs @@ -311,7 +311,7 @@ fn test_sea_orm_default_attrs_no_default_value() { } #[test] -fn test_sea_orm_default_attrs_sql_function_skips() { +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(); @@ -325,8 +325,42 @@ fn test_sea_orm_default_attrs_sql_function_skips() { 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] diff --git a/examples/axum-example/src/models/memo.rs b/examples/axum-example/src/models/memo.rs index c13043c..a09aada 100644 --- a/examples/axum-example/src/models/memo.rs +++ b/examples/axum-example/src/models/memo.rs @@ -1,7 +1,9 @@ use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize, vespera::Schema)] +#[derive( + Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize, vespera::Schema, +)] #[serde(rename_all = "camelCase")] #[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "memo_memo_status")] pub enum MemoStatus { @@ -35,7 +37,6 @@ pub struct Model { pub memo_comments: HasMany, } - // Index definitions (SeaORM uses Statement builders externally) // (unnamed) on [user_id] vespera::schema_type!(Schema from Model, name = "MemoSchema"); diff --git a/examples/axum-example/src/models/memo_comment.rs b/examples/axum-example/src/models/memo_comment.rs index 807aee5..447b458 100644 --- a/examples/axum-example/src/models/memo_comment.rs +++ b/examples/axum-example/src/models/memo_comment.rs @@ -21,7 +21,6 @@ pub struct Model { pub memo: HasOne, } - // Index definitions (SeaORM uses Statement builders externally) // (unnamed) on [user_id] // (unnamed) on [memo_id] diff --git a/examples/axum-example/src/models/user.rs b/examples/axum-example/src/models/user.rs index be28eb8..ffd75e1 100644 --- a/examples/axum-example/src/models/user.rs +++ b/examples/axum-example/src/models/user.rs @@ -25,7 +25,6 @@ pub struct Model { pub memo_comments: HasMany, } - // Index definitions (SeaORM uses Statement builders externally) // (unnamed) on [email] vespera::schema_type!(Schema from Model, name = "UserSchema"); From 46a4b0fadcfac46945ca86e5dc8cf240986da913 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 18 Feb 2026 23:59:24 +0900 Subject: [PATCH 13/13] Fix Add testcase --- crates/vespera_core/src/schema.rs | 8 + .../src/schema_macro/circular.rs | 5 +- .../vespera_macro/src/schema_macro/tests.rs | 170 +++++++++++++++++- 3 files changed, 179 insertions(+), 4 deletions(-) diff --git a/crates/vespera_core/src/schema.rs b/crates/vespera_core/src/schema.rs index 84e2ed7..35e8d5f 100644 --- a/crates/vespera_core/src/schema.rs +++ b/crates/vespera_core/src/schema.rs @@ -437,6 +437,14 @@ mod tests { 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 { diff --git a/crates/vespera_macro/src/schema_macro/circular.rs b/crates/vespera_macro/src/schema_macro/circular.rs index d5e7631..d9dbe68 100644 --- a/crates/vespera_macro/src/schema_macro/circular.rs +++ b/crates/vespera_macro/src/schema_macro/circular.rs @@ -62,9 +62,8 @@ pub fn analyze_circular_refs(source_module_path: &[String], definition: &str) -> let mut circular_field_required = HashMap::new(); for field in &fields_named.named { - let Some(field_ident) = field.ident.as_ref() else { - continue; - }; + // 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(' ', ""); diff --git a/crates/vespera_macro/src/schema_macro/tests.rs b/crates/vespera_macro/src/schema_macro/tests.rs index 18a5dac..b6f828d 100644 --- a/crates/vespera_macro/src/schema_macro/tests.rs +++ b/crates/vespera_macro/src/schema_macro/tests.rs @@ -471,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]