From 90a00a24e83fd1534758e0c562c2eb1e9f268b0f Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 4 Feb 2026 03:15:49 +0900 Subject: [PATCH 1/3] Support description --- .../axum-example/models/user.vespertide.json | 11 +++++---- examples/axum-example/openapi.json | 24 +++++++++++++++---- examples/axum-example/src/models/comment.rs | 2 +- examples/axum-example/src/models/memo.rs | 8 ++----- examples/axum-example/src/models/user.rs | 11 +++++---- examples/axum-example/src/routes/enums.rs | 1 + .../snapshots/integration_test__openapi.snap | 24 +++++++++++++++---- 7 files changed, 55 insertions(+), 26 deletions(-) diff --git a/examples/axum-example/models/user.vespertide.json b/examples/axum-example/models/user.vespertide.json index e08d176..c6072e8 100644 --- a/examples/axum-example/models/user.vespertide.json +++ b/examples/axum-example/models/user.vespertide.json @@ -1,11 +1,12 @@ { "$schema": "https://raw.githubusercontent.com/dev-five-git/vespertide/refs/heads/main/schemas/model.schema.json", "name": "user", + "description": "User model", "columns": [ - { "name": "id", "type": "integer", "nullable": false, "primary_key": { "auto_increment": true } }, - { "name": "email", "type": "text", "nullable": false, "unique": true, "index": true }, - { "name": "name", "type": { "kind": "varchar", "length": 100 }, "nullable": false }, - { "name": "created_at", "type": "timestamptz", "nullable": false, "default": "NOW()" }, - { "name": "updated_at", "type": "timestamptz", "nullable": false, "default": "NOW()" } + { "name": "id", "type": "integer", "nullable": false, "primary_key": { "auto_increment": true }, "comment": "User ID" }, + { "name": "email", "type": "text", "nullable": false, "unique": true, "index": true, "comment": "User email" }, + { "name": "name", "type": { "kind": "varchar", "length": 100 }, "nullable": false, "comment": "User name" }, + { "name": "created_at", "type": "timestamptz", "nullable": false, "default": "NOW()", "comment": "Created at" }, + { "name": "updated_at", "type": "timestamptz", "nullable": false, "default": "NOW()", "comment": "Updated at" } ] } diff --git a/examples/axum-example/openapi.json b/examples/axum-example/openapi.json index a87fac7..68db711 100644 --- a/examples/axum-example/openapi.json +++ b/examples/axum-example/openapi.json @@ -1612,6 +1612,7 @@ }, "CreateUserRequest": { "type": "object", + "description": "Full user model with all fields", "properties": { "email": { "type": "string" @@ -1627,6 +1628,7 @@ }, "CreateUserWithMeta": { "type": "object", + "description": "Full user model with all fields", "properties": { "createdAt": { "type": "string", @@ -1657,6 +1659,7 @@ ] }, "Enum2": { + "description": "Enum2 Description", "oneOf": [ { "type": "object", @@ -2404,6 +2407,7 @@ }, "User": { "type": "object", + "description": "Full user model with all fields", "properties": { "email": { "type": "string" @@ -2413,6 +2417,7 @@ }, "internal_score": { "type": "integer", + "description": "Internal field - should be omitted in public APIs", "nullable": true }, "name": { @@ -2427,6 +2432,7 @@ }, "UserDTO": { "type": "object", + "description": "Full user model with all fields", "properties": { "id": { "type": "integer" @@ -2442,6 +2448,7 @@ }, "UserPublicResponse": { "type": "object", + "description": "Full user model with all fields", "properties": { "email": { "type": "string" @@ -2461,23 +2468,29 @@ }, "UserSchema": { "type": "object", + "description": "User model", "properties": { "createdAt": { "type": "string", - "format": "date-time" + "format": "date-time", + "description": "Created at" }, "email": { - "type": "string" + "type": "string", + "description": "User email" }, "id": { - "type": "integer" + "type": "integer", + "description": "User ID" }, "name": { - "type": "string" + "type": "string", + "description": "User name" }, "updatedAt": { "type": "string", - "format": "date-time" + "format": "date-time", + "description": "Updated at" } }, "required": [ @@ -2490,6 +2503,7 @@ }, "UserSummary": { "type": "object", + "description": "Full user model with all fields", "properties": { "id": { "type": "integer" diff --git a/examples/axum-example/src/models/comment.rs b/examples/axum-example/src/models/comment.rs index b63bac7..ec4eb85 100644 --- a/examples/axum-example/src/models/comment.rs +++ b/examples/axum-example/src/models/comment.rs @@ -21,9 +21,9 @@ pub struct Model { pub memo: HasOne, } -vespera::schema_type!(Schema from Model, name = "CommentSchema"); // Index definitions (SeaORM uses Statement builders externally) // (unnamed) on [user_id] // (unnamed) on [memo_id] +vespera::schema_type!(Schema from Model, name = "CommentSchema"); impl ActiveModelBehavior for ActiveModel {} diff --git a/examples/axum-example/src/models/memo.rs b/examples/axum-example/src/models/memo.rs index 0d2ca09..0c89451 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,10 +35,8 @@ pub struct Model { pub comments: HasMany, } -// Schema WITH user relation - has async from_model(model, db) method -// Circular refs auto-handled: when loading user, its memos field is set to vec![] -vespera::schema_type!(Schema from Model, name = "MemoSchema"); // Index definitions (SeaORM uses Statement builders externally) // (unnamed) on [user_id] +vespera::schema_type!(Schema from Model, name = "MemoSchema"); impl ActiveModelBehavior for ActiveModel {} diff --git a/examples/axum-example/src/models/user.rs b/examples/axum-example/src/models/user.rs index ea58935..25b6b3d 100644 --- a/examples/axum-example/src/models/user.rs +++ b/examples/axum-example/src/models/user.rs @@ -1,16 +1,22 @@ use sea_orm::entity::prelude::*; +/// User model #[sea_orm::model] #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "user")] pub struct Model { + /// User ID #[sea_orm(primary_key)] pub id: i32, + /// User email #[sea_orm(unique)] pub email: String, + /// User name pub name: String, + /// Created at #[sea_orm(default_value = "NOW()")] pub created_at: DateTimeWithTimeZone, + /// Updated at #[sea_orm(default_value = "NOW()")] pub updated_at: DateTimeWithTimeZone, #[sea_orm(has_many)] @@ -19,11 +25,8 @@ pub struct Model { pub memos: HasMany, } -// Schema WITH memos relation - circular refs are auto-handled -// When embedded in MemoSchema.user, the memos field will be defaulted to vec![] -// Custom OpenAPI name: "UserSchema" -vespera::schema_type!(Schema from Model, name = "UserSchema"); // Index definitions (SeaORM uses Statement builders externally) // (unnamed) on [email] +vespera::schema_type!(Schema from Model, name = "UserSchema"); impl ActiveModelBehavior for ActiveModel {} diff --git a/examples/axum-example/src/routes/enums.rs b/examples/axum-example/src/routes/enums.rs index 3e10f8f..ed92df6 100644 --- a/examples/axum-example/src/routes/enums.rs +++ b/examples/axum-example/src/routes/enums.rs @@ -17,6 +17,7 @@ pub async fn enum_endpoint() -> Json { Json(Enum::A) } +/// Enum2 Description #[derive(Serialize, Deserialize, Schema)] pub enum Enum2 { A(String), diff --git a/examples/axum-example/tests/snapshots/integration_test__openapi.snap b/examples/axum-example/tests/snapshots/integration_test__openapi.snap index 8bdf828..3ced8ab 100644 --- a/examples/axum-example/tests/snapshots/integration_test__openapi.snap +++ b/examples/axum-example/tests/snapshots/integration_test__openapi.snap @@ -1616,6 +1616,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" }, "CreateUserRequest": { "type": "object", + "description": "Full user model with all fields", "properties": { "email": { "type": "string" @@ -1631,6 +1632,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" }, "CreateUserWithMeta": { "type": "object", + "description": "Full user model with all fields", "properties": { "createdAt": { "type": "string", @@ -1661,6 +1663,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" ] }, "Enum2": { + "description": "Enum2 Description", "oneOf": [ { "type": "object", @@ -2408,6 +2411,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" }, "User": { "type": "object", + "description": "Full user model with all fields", "properties": { "email": { "type": "string" @@ -2417,6 +2421,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" }, "internal_score": { "type": "integer", + "description": "Internal field - should be omitted in public APIs", "nullable": true }, "name": { @@ -2431,6 +2436,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" }, "UserDTO": { "type": "object", + "description": "Full user model with all fields", "properties": { "id": { "type": "integer" @@ -2446,6 +2452,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" }, "UserPublicResponse": { "type": "object", + "description": "Full user model with all fields", "properties": { "email": { "type": "string" @@ -2465,23 +2472,29 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" }, "UserSchema": { "type": "object", + "description": "User model", "properties": { "createdAt": { "type": "string", - "format": "date-time" + "format": "date-time", + "description": "Created at" }, "email": { - "type": "string" + "type": "string", + "description": "User email" }, "id": { - "type": "integer" + "type": "integer", + "description": "User ID" }, "name": { - "type": "string" + "type": "string", + "description": "User name" }, "updatedAt": { "type": "string", - "format": "date-time" + "format": "date-time", + "description": "Updated at" } }, "required": [ @@ -2494,6 +2507,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" }, "UserSummary": { "type": "object", + "description": "Full user model with all fields", "properties": { "id": { "type": "integer" From 0ffbc178dde43ebdc40e0452b82cc47f995c47ad Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 4 Feb 2026 03:22:01 +0900 Subject: [PATCH 2/3] Support description --- .../changepack_log_1DpbP4f1cc9lsoConhMuB.json | 1 + Cargo.lock | 6 +- crates/vespera_core/src/schema.rs | 2 +- crates/vespera_macro/src/parser/schema.rs | 92 ++++++++++++++++++- crates/vespera_macro/src/schema_macro.rs | 37 ++++++-- openapi.json | 24 ++++- 6 files changed, 142 insertions(+), 20 deletions(-) create mode 100644 .changepacks/changepack_log_1DpbP4f1cc9lsoConhMuB.json diff --git a/.changepacks/changepack_log_1DpbP4f1cc9lsoConhMuB.json b/.changepacks/changepack_log_1DpbP4f1cc9lsoConhMuB.json new file mode 100644 index 0000000..7e998ff --- /dev/null +++ b/.changepacks/changepack_log_1DpbP4f1cc9lsoConhMuB.json @@ -0,0 +1 @@ +{"changes":{"crates/vespera_macro/Cargo.toml":"Patch","crates/vespera/Cargo.toml":"Patch","crates/vespera_core/Cargo.toml":"Patch"},"note":"Support description","date":"2026-02-03T18:15:43.339445900Z"} \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index aa82f54..b32fd71 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3073,7 +3073,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vespera" -version = "0.1.27" +version = "0.1.28" dependencies = [ "axum", "axum-extra", @@ -3087,7 +3087,7 @@ dependencies = [ [[package]] name = "vespera_core" -version = "0.1.27" +version = "0.1.28" dependencies = [ "rstest", "serde", @@ -3096,7 +3096,7 @@ dependencies = [ [[package]] name = "vespera_macro" -version = "0.1.27" +version = "0.1.28" dependencies = [ "anyhow", "insta", diff --git a/crates/vespera_core/src/schema.rs b/crates/vespera_core/src/schema.rs index d1b9330..2e4d964 100644 --- a/crates/vespera_core/src/schema.rs +++ b/crates/vespera_core/src/schema.rs @@ -74,7 +74,7 @@ pub enum StringFormat { } /// JSON Schema definition -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Schema { /// Schema reference ($ref) - if present, other fields are ignored diff --git a/crates/vespera_macro/src/parser/schema.rs b/crates/vespera_macro/src/parser/schema.rs index 867d863..f8fd371 100644 --- a/crates/vespera_macro/src/parser/schema.rs +++ b/crates/vespera_macro/src/parser/schema.rs @@ -3,6 +3,33 @@ use std::collections::{BTreeMap, HashMap}; use syn::{Fields, Type}; use vespera_core::schema::{Reference, Schema, SchemaRef, SchemaType}; +/// Extract doc comments from attributes. +/// Returns concatenated doc comment string or None if no doc comments. +pub fn extract_doc_comment(attrs: &[syn::Attribute]) -> Option { + let mut doc_lines = Vec::new(); + + for attr in attrs { + if attr.path().is_ident("doc") + && let syn::Meta::NameValue(meta_nv) = &attr.meta + && let syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(lit_str), + .. + }) = &meta_nv.value + { + let line = lit_str.value(); + // Trim leading space that rustdoc adds + let trimmed = line.strip_prefix(' ').unwrap_or(&line); + doc_lines.push(trimmed.to_string()); + } + } + + if doc_lines.is_empty() { + None + } else { + Some(doc_lines.join("\n")) + } +} + /// Strips the `r#` prefix from raw identifiers. /// E.g., `r#type` becomes `type`. pub fn strip_raw_prefix(ident: &str) -> &str { @@ -437,6 +464,9 @@ pub fn parse_enum_to_schema( known_schemas: &HashMap, struct_definitions: &HashMap, ) -> Schema { + // Extract enum-level doc comment for schema description + let enum_description = extract_doc_comment(&enum_item.attrs); + // Extract rename_all attribute from enum let rename_all = extract_rename_all(&enum_item.attrs); @@ -466,6 +496,7 @@ pub fn parse_enum_to_schema( Schema { schema_type: Some(SchemaType::String), + description: enum_description, r#enum: if enum_values.is_empty() { None } else { @@ -488,10 +519,14 @@ pub fn parse_enum_to_schema( rename_field(&variant_name, rename_all.as_deref()) }; + // Extract variant-level doc comment + let variant_description = extract_doc_comment(&variant.attrs); + let variant_schema = match &variant.fields { syn::Fields::Unit => { // Unit variant: {"const": "VariantName"} Schema { + description: variant_description, r#enum: Some(vec![serde_json::Value::String(variant_key)]), ..Schema::string() } @@ -510,6 +545,7 @@ pub fn parse_enum_to_schema( properties.insert(variant_key.clone(), inner_schema); Schema { + description: variant_description.clone(), properties: Some(properties), required: Some(vec![variant_key]), ..Schema::object() @@ -546,6 +582,7 @@ pub fn parse_enum_to_schema( ); Schema { + description: variant_description.clone(), properties: Some(properties), required: Some(vec![variant_key]), ..Schema::object() @@ -577,9 +614,31 @@ pub fn parse_enum_to_schema( }; let field_type = &field.ty; - let schema_ref = + let mut schema_ref = parse_type_to_schema_ref(field_type, known_schemas, struct_definitions); + // Extract doc comment from field and set as description + if let Some(doc) = extract_doc_comment(&field.attrs) { + match &mut schema_ref { + SchemaRef::Inline(schema) => { + schema.description = Some(doc); + } + SchemaRef::Ref(_) => { + let ref_schema = std::mem::replace( + &mut schema_ref, + SchemaRef::Inline(Box::new(Schema::object())), + ); + if let SchemaRef::Ref(reference) = ref_schema { + schema_ref = SchemaRef::Inline(Box::new(Schema { + description: Some(doc), + all_of: Some(vec![SchemaRef::Ref(reference)]), + ..Default::default() + })); + } + } + } + } + variant_properties.insert(field_name.clone(), schema_ref); // Check if field is Option @@ -621,6 +680,7 @@ pub fn parse_enum_to_schema( ); Schema { + description: variant_description, properties: Some(properties), required: Some(vec![variant_key]), ..Schema::object() @@ -633,6 +693,7 @@ pub fn parse_enum_to_schema( Schema { schema_type: None, // oneOf doesn't have a single type + description: enum_description, one_of: if one_of_schemas.is_empty() { None } else { @@ -651,6 +712,9 @@ pub fn parse_struct_to_schema( let mut properties = BTreeMap::new(); let mut required = Vec::new(); + // Extract struct-level doc comment for schema description + let struct_description = extract_doc_comment(&struct_item.attrs); + // Extract rename_all attribute from struct let rename_all = extract_rename_all(&struct_item.attrs); @@ -681,6 +745,31 @@ pub fn parse_struct_to_schema( let mut schema_ref = parse_type_to_schema_ref(field_type, known_schemas, struct_definitions); + // Extract doc comment from field and set as description + if let Some(doc) = extract_doc_comment(&field.attrs) { + match &mut schema_ref { + SchemaRef::Inline(schema) => { + schema.description = Some(doc); + } + SchemaRef::Ref(_) => { + // For $ref schemas, we need to wrap in an allOf to add description + // OpenAPI 3.1 allows siblings to $ref, so we can add description directly + // by converting to inline schema with description + allOf[$ref] + let ref_schema = std::mem::replace( + &mut schema_ref, + SchemaRef::Inline(Box::new(Schema::object())), + ); + if let SchemaRef::Ref(reference) = ref_schema { + schema_ref = SchemaRef::Inline(Box::new(Schema { + description: Some(doc), + all_of: Some(vec![SchemaRef::Ref(reference)]), + ..Default::default() + })); + } + } + } + } + // Check for default attribute let has_default = extract_default(&field.attrs).is_some(); @@ -727,6 +816,7 @@ pub fn parse_struct_to_schema( Schema { schema_type: Some(SchemaType::Object), + description: struct_description, properties: if properties.is_empty() { None } else { diff --git a/crates/vespera_macro/src/schema_macro.rs b/crates/vespera_macro/src/schema_macro.rs index e956658..309f0b4 100644 --- a/crates/vespera_macro/src/schema_macro.rs +++ b/crates/vespera_macro/src/schema_macro.rs @@ -1396,11 +1396,11 @@ fn generate_inline_relation_type( continue; } - // Keep only serde attributes - let serde_attrs: Vec = field + // Keep serde and doc attributes + let kept_attrs: Vec = field .attrs .iter() - .filter(|attr| attr.path().is_ident("serde")) + .filter(|attr| attr.path().is_ident("serde") || attr.path().is_ident("doc")) .cloned() .collect(); @@ -1408,7 +1408,7 @@ fn generate_inline_relation_type( fields.push(InlineField { name: field_ident.clone(), ty: quote::quote!(#field_ty), - attrs: serde_attrs, + attrs: kept_attrs, }); } } @@ -1472,11 +1472,11 @@ fn generate_inline_relation_type_no_relations( continue; } - // Keep only serde attributes - let serde_attrs: Vec = field + // Keep serde and doc attributes + let kept_attrs: Vec = field .attrs .iter() - .filter(|attr| attr.path().is_ident("serde")) + .filter(|attr| attr.path().is_ident("serde") || attr.path().is_ident("doc")) .cloned() .collect(); @@ -1484,7 +1484,7 @@ fn generate_inline_relation_type_no_relations( fields.push(InlineField { name: field_ident.clone(), ty: quote::quote!(#field_ty), - attrs: serde_attrs, + attrs: kept_attrs, }); } } @@ -2683,6 +2683,13 @@ pub fn generate_schema_type_code( }) .collect(); + // Extract doc comments from source struct to carry over to generated struct + let struct_doc_attrs: Vec<_> = parsed_struct + .attrs + .iter() + .filter(|attr| attr.path().is_ident("doc")) + .collect(); + // Determine the rename_all strategy: // 1. If input.rename_all is specified, use it // 2. Else if source has rename_all, use it @@ -2838,7 +2845,7 @@ pub fn generate_schema_type_code( let vis = &field.vis; let source_field_ident = field.ident.clone().unwrap(); - // Filter field attributes: only keep serde attributes, remove sea_orm and others + // Filter field attributes: keep serde and doc attributes, remove sea_orm and others // This is important when using schema_type! with models from other files // that may have ORM-specific attributes we don't want in the generated struct let serde_field_attrs: Vec<_> = field @@ -2847,6 +2854,13 @@ pub fn generate_schema_type_code( .filter(|attr| attr.path().is_ident("serde")) .collect(); + // Extract doc attributes to carry over comments to the generated struct + let doc_attrs: Vec<_> = field + .attrs + .iter() + .filter(|attr| attr.path().is_ident("doc")) + .collect(); + // Check if field should be renamed if let Some(new_name) = rename_map.get(&rust_field_name) { // Create new identifier for the field @@ -2874,6 +2888,7 @@ pub fn generate_schema_type_code( extract_field_rename(&field.attrs).unwrap_or_else(|| rust_field_name.clone()); field_tokens.push(quote! { + #(#doc_attrs)* #(#filtered_attrs)* #[serde(rename = #json_name)] #vis #new_field_ident: #field_ty @@ -2887,10 +2902,11 @@ pub fn generate_schema_type_code( is_relation, )); } else { - // No rename, keep field with only serde attrs + // No rename, keep field with serde and doc attrs let field_ident = field.ident.clone().unwrap(); field_tokens.push(quote! { + #(#doc_attrs)* #(#serde_field_attrs)* #vis #field_ident: #field_ty }); @@ -2989,6 +3005,7 @@ pub fn generate_schema_type_code( // Inline types for circular relation references #(#inline_type_definitions)* + #(#struct_doc_attrs)* #[derive(serde::Serialize, serde::Deserialize, #clone_derive #schema_derive)] #schema_name_attr #[serde(rename_all = #effective_rename_all)] diff --git a/openapi.json b/openapi.json index a87fac7..68db711 100644 --- a/openapi.json +++ b/openapi.json @@ -1612,6 +1612,7 @@ }, "CreateUserRequest": { "type": "object", + "description": "Full user model with all fields", "properties": { "email": { "type": "string" @@ -1627,6 +1628,7 @@ }, "CreateUserWithMeta": { "type": "object", + "description": "Full user model with all fields", "properties": { "createdAt": { "type": "string", @@ -1657,6 +1659,7 @@ ] }, "Enum2": { + "description": "Enum2 Description", "oneOf": [ { "type": "object", @@ -2404,6 +2407,7 @@ }, "User": { "type": "object", + "description": "Full user model with all fields", "properties": { "email": { "type": "string" @@ -2413,6 +2417,7 @@ }, "internal_score": { "type": "integer", + "description": "Internal field - should be omitted in public APIs", "nullable": true }, "name": { @@ -2427,6 +2432,7 @@ }, "UserDTO": { "type": "object", + "description": "Full user model with all fields", "properties": { "id": { "type": "integer" @@ -2442,6 +2448,7 @@ }, "UserPublicResponse": { "type": "object", + "description": "Full user model with all fields", "properties": { "email": { "type": "string" @@ -2461,23 +2468,29 @@ }, "UserSchema": { "type": "object", + "description": "User model", "properties": { "createdAt": { "type": "string", - "format": "date-time" + "format": "date-time", + "description": "Created at" }, "email": { - "type": "string" + "type": "string", + "description": "User email" }, "id": { - "type": "integer" + "type": "integer", + "description": "User ID" }, "name": { - "type": "string" + "type": "string", + "description": "User name" }, "updatedAt": { "type": "string", - "format": "date-time" + "format": "date-time", + "description": "Updated at" } }, "required": [ @@ -2490,6 +2503,7 @@ }, "UserSummary": { "type": "object", + "description": "Full user model with all fields", "properties": { "id": { "type": "integer" From 20cef62739831d99cd437b39159e3d020d06aa19 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 4 Feb 2026 03:32:36 +0900 Subject: [PATCH 3/3] Support description --- crates/vespera_macro/src/lib.rs | 82 ++++++++ crates/vespera_macro/src/parser/schema.rs | 214 +++++++++++++++++++ crates/vespera_macro/src/schema_macro.rs | 220 ++++++++++++++++---- examples/axum-example/src/models/comment.rs | 1 - examples/axum-example/src/models/memo.rs | 5 +- examples/axum-example/src/models/user.rs | 1 - 6 files changed, 479 insertions(+), 44 deletions(-) diff --git a/crates/vespera_macro/src/lib.rs b/crates/vespera_macro/src/lib.rs index ee4f171..bc198c1 100644 --- a/crates/vespera_macro/src/lib.rs +++ b/crates/vespera_macro/src/lib.rs @@ -2058,4 +2058,86 @@ pub fn get_users() -> String { // It should return a PathBuf (either from src/nonexistent... or just the folder name) assert!(result.to_string_lossy().contains("nonexistent_folder_xyz")); } + + // ========== Tests for extract_schema_name_attr ========== + + #[test] + fn test_extract_schema_name_attr_with_name() { + let attrs: Vec = syn::parse_quote! { + #[schema(name = "CustomName")] + }; + let result = extract_schema_name_attr(&attrs); + assert_eq!(result, Some("CustomName".to_string())); + } + + #[test] + fn test_extract_schema_name_attr_without_name() { + let attrs: Vec = syn::parse_quote! { + #[derive(Debug)] + }; + let result = extract_schema_name_attr(&attrs); + assert_eq!(result, None); + } + + #[test] + fn test_extract_schema_name_attr_empty_schema() { + let attrs: Vec = syn::parse_quote! { + #[schema] + }; + let result = extract_schema_name_attr(&attrs); + assert_eq!(result, None); + } + + #[test] + fn test_extract_schema_name_attr_with_other_attrs() { + let attrs: Vec = syn::parse_quote! { + #[derive(Clone)] + #[schema(name = "MySchema")] + #[serde(rename_all = "camelCase")] + }; + let result = extract_schema_name_attr(&attrs); + assert_eq!(result, Some("MySchema".to_string())); + } + + // ========== Tests for process_derive_schema ========== + + #[test] + fn test_process_derive_schema_simple() { + let input: syn::DeriveInput = syn::parse_quote! { + struct User { + id: i32, + name: String, + } + }; + let (metadata, tokens) = process_derive_schema(&input); + assert_eq!(metadata.name, "User"); + assert!(metadata.definition.contains("User")); + let tokens_str = tokens.to_string(); + assert!(tokens_str.contains("SchemaBuilder")); + } + + #[test] + fn test_process_derive_schema_with_custom_name() { + let input: syn::DeriveInput = syn::parse_quote! { + #[schema(name = "CustomUserSchema")] + struct User { + id: i32, + } + }; + let (metadata, _) = process_derive_schema(&input); + assert_eq!(metadata.name, "CustomUserSchema"); + } + + #[test] + fn test_process_derive_schema_with_generics() { + let input: syn::DeriveInput = syn::parse_quote! { + struct Container { + value: T, + } + }; + let (metadata, tokens) = process_derive_schema(&input); + assert_eq!(metadata.name, "Container"); + let tokens_str = tokens.to_string(); + assert!(tokens_str.contains("< T >") || tokens_str.contains("")); + } } diff --git a/crates/vespera_macro/src/parser/schema.rs b/crates/vespera_macro/src/parser/schema.rs index f8fd371..e940360 100644 --- a/crates/vespera_macro/src/parser/schema.rs +++ b/crates/vespera_macro/src/parser/schema.rs @@ -2937,4 +2937,218 @@ mod tests { let result = rename_field("get_user_by_id", Some("camelCase")); assert_eq!(result, "getUserById"); } + + // Tests for extract_doc_comment function + #[test] + fn test_extract_doc_comment_single_line() { + let attrs: Vec = syn::parse_quote! { + #[doc = " This is a doc comment"] + }; + let result = extract_doc_comment(&attrs); + assert_eq!(result, Some("This is a doc comment".to_string())); + } + + #[test] + fn test_extract_doc_comment_multi_line() { + let attrs: Vec = syn::parse_quote! { + #[doc = " First line"] + #[doc = " Second line"] + #[doc = " Third line"] + }; + let result = extract_doc_comment(&attrs); + assert_eq!( + result, + Some("First line\nSecond line\nThird line".to_string()) + ); + } + + #[test] + fn test_extract_doc_comment_no_leading_space() { + let attrs: Vec = syn::parse_quote! { + #[doc = "No leading space"] + }; + let result = extract_doc_comment(&attrs); + assert_eq!(result, Some("No leading space".to_string())); + } + + #[test] + fn test_extract_doc_comment_empty() { + let attrs: Vec = vec![]; + let result = extract_doc_comment(&attrs); + assert_eq!(result, None); + } + + #[test] + fn test_extract_doc_comment_with_non_doc_attrs() { + let attrs: Vec = syn::parse_quote! { + #[derive(Debug)] + #[doc = " The doc comment"] + #[serde(rename = "test")] + }; + let result = extract_doc_comment(&attrs); + assert_eq!(result, Some("The doc comment".to_string())); + } + + // Tests for extract_schema_name_from_entity function + #[test] + fn test_extract_schema_name_from_entity_super_path() { + let ty: Type = syn::parse_str("super::user::Entity").unwrap(); + let result = extract_schema_name_from_entity(&ty); + assert_eq!(result, Some("User".to_string())); + } + + #[test] + fn test_extract_schema_name_from_entity_crate_path() { + let ty: Type = syn::parse_str("crate::models::memo::Entity").unwrap(); + let result = extract_schema_name_from_entity(&ty); + assert_eq!(result, Some("Memo".to_string())); + } + + #[test] + fn test_extract_schema_name_from_entity_not_entity() { + let ty: Type = syn::parse_str("crate::models::user::Model").unwrap(); + let result = extract_schema_name_from_entity(&ty); + assert_eq!(result, None); + } + + #[test] + fn test_extract_schema_name_from_entity_single_segment() { + let ty: Type = syn::parse_str("Entity").unwrap(); + let result = extract_schema_name_from_entity(&ty); + assert_eq!(result, None); + } + + #[test] + fn test_extract_schema_name_from_entity_non_path_type() { + let ty: Type = syn::parse_str("&str").unwrap(); + let result = extract_schema_name_from_entity(&ty); + assert_eq!(result, None); + } + + #[test] + fn test_extract_schema_name_from_entity_empty_module_name() { + // Tests the branch where module name has no characters (edge case) + let ty: Type = syn::parse_str("super::some_module::Entity").unwrap(); + let result = extract_schema_name_from_entity(&ty); + assert_eq!(result, Some("Some_module".to_string())); + } + + // Tests for enum with doc comments on variants + #[test] + fn test_parse_enum_to_schema_with_variant_descriptions() { + let enum_src = r#" + /// Enum description + enum Status { + /// Active variant + Active, + /// Inactive variant + Inactive, + } + "#; + let enum_item: syn::ItemEnum = syn::parse_str(enum_src).unwrap(); + let schema = parse_enum_to_schema(&enum_item, &HashMap::new(), &HashMap::new()); + assert_eq!(schema.description, Some("Enum description".to_string())); + } + + #[test] + fn test_parse_enum_to_schema_data_variant_with_description() { + let enum_src = r#" + /// Data enum + enum Event { + /// Text event description + Text(String), + /// Number event description + Number(i32), + } + "#; + let enum_item: syn::ItemEnum = syn::parse_str(enum_src).unwrap(); + let schema = parse_enum_to_schema(&enum_item, &HashMap::new(), &HashMap::new()); + assert_eq!(schema.description, Some("Data enum".to_string())); + assert!(schema.one_of.is_some()); + let one_of = schema.one_of.unwrap(); + assert_eq!(one_of.len(), 2); + // Check first variant has description + if let SchemaRef::Inline(variant_schema) = &one_of[0] { + assert_eq!( + variant_schema.description, + Some("Text event description".to_string()) + ); + } + } + + #[test] + fn test_parse_enum_to_schema_struct_variant_with_field_docs() { + let enum_src = r#" + enum Event { + /// Record variant + Record { + /// The value field + value: i32, + /// The name field + name: String, + }, + } + "#; + let enum_item: syn::ItemEnum = syn::parse_str(enum_src).unwrap(); + let schema = parse_enum_to_schema(&enum_item, &HashMap::new(), &HashMap::new()); + assert!(schema.one_of.is_some()); + let one_of = schema.one_of.unwrap(); + if let SchemaRef::Inline(variant_schema) = &one_of[0] { + assert_eq!( + variant_schema.description, + Some("Record variant".to_string()) + ); + } + } + + // Tests for struct with doc comments + #[test] + fn test_parse_struct_to_schema_with_description() { + let struct_src = r#" + /// User struct description + struct User { + /// User ID + id: i32, + /// User name + name: String, + } + "#; + let struct_item: syn::ItemStruct = syn::parse_str(struct_src).unwrap(); + let schema = parse_struct_to_schema(&struct_item, &HashMap::new(), &HashMap::new()); + assert_eq!( + schema.description, + Some("User struct description".to_string()) + ); + // Check field descriptions + let props = schema.properties.unwrap(); + if let SchemaRef::Inline(id_schema) = props.get("id").unwrap() { + assert_eq!(id_schema.description, Some("User ID".to_string())); + } + if let SchemaRef::Inline(name_schema) = props.get("name").unwrap() { + assert_eq!(name_schema.description, Some("User name".to_string())); + } + } + + #[test] + fn test_parse_struct_to_schema_field_with_ref_and_description() { + let struct_src = r#" + struct Container { + /// The user reference + user: User, + } + "#; + let struct_item: syn::ItemStruct = syn::parse_str(struct_src).unwrap(); + let mut known = HashMap::new(); + known.insert("User".to_string(), "struct User { id: i32 }".to_string()); + let schema = parse_struct_to_schema(&struct_item, &known, &HashMap::new()); + let props = schema.properties.unwrap(); + // Field with $ref and description should use allOf + if let SchemaRef::Inline(user_schema) = props.get("user").unwrap() { + assert_eq!( + user_schema.description, + Some("The user reference".to_string()) + ); + assert!(user_schema.all_of.is_some()); + } + } } diff --git a/crates/vespera_macro/src/schema_macro.rs b/crates/vespera_macro/src/schema_macro.rs index 309f0b4..d1a78cf 100644 --- a/crates/vespera_macro/src/schema_macro.rs +++ b/crates/vespera_macro/src/schema_macro.rs @@ -102,18 +102,7 @@ pub fn generate_schema_code( let type_name = extract_type_name(&input.ty)?; // Find struct definition in storage - let struct_def = schema_storage - .iter() - .find(|s| s.name == type_name) - .ok_or_else(|| { - syn::Error::new_spanned( - &input.ty, - format!( - "type `{}` not found. Make sure it has #[derive(Schema)] before this macro invocation", - type_name - ), - ) - })?; + let struct_def = schema_storage.iter().find(|s| s.name == type_name).ok_or_else(|| syn::Error::new_spanned(&input.ty, format!("type `{}` not found. Make sure it has #[derive(Schema)] before this macro invocation", type_name)))?; // Parse the struct definition let parsed_struct: syn::ItemStruct = syn::parse_str(&struct_def.definition).map_err(|e| { @@ -2244,19 +2233,12 @@ fn generate_from_model_with_relations( let related_model_from_file = find_struct_from_schema_path(&model_path_str); // Get the definition string - let related_def_str = related_model_from_file - .as_ref() - .map(|s| s.definition.as_str()) - .unwrap_or(""); + let related_def_str = related_model_from_file.as_ref().map(|s| s.definition.as_str()).unwrap_or(""); // 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, - ); + let circular_fields = detect_circular_fields(new_type_name.to_string().as_str(), source_module_path, related_def_str); let has_circular = !circular_fields.is_empty(); @@ -2264,12 +2246,7 @@ fn generate_from_model_with_relations( // instead of the original schema path if let Some((ref inline_type_name, ref included_fields)) = rel.inline_type_info { // Use inline type construction - let inline_construct = generate_inline_type_construction( - inline_type_name, - included_fields, - related_def_str, - "r", - ); + let inline_construct = generate_inline_type_construction(inline_type_name, included_fields, related_def_str, "r"); match rel.relation_type.as_str() { "HasOne" | "BelongsTo" => { @@ -2301,12 +2278,7 @@ 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)) @@ -2367,12 +2339,7 @@ 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() } @@ -2386,7 +2353,7 @@ fn generate_from_model_with_relations( let inline_construct = generate_inline_struct_construction( schema_path, related_def_str, - &[], // no circular fields to exclude + &[], // no circular fields to exclude "r", ); quote! { @@ -3844,4 +3811,177 @@ mod tests { assert_eq!(input.pick.unwrap(), vec!["id", "name"]); assert_eq!(input.rename_all.as_deref(), Some("snake_case")); } + + // ========================================================================= + // Tests for helper functions + // ========================================================================= + + #[test] + fn test_is_qualified_path_simple() { + let ty: syn::Type = syn::parse_str("User").unwrap(); + assert!(!is_qualified_path(&ty)); + } + + #[test] + fn test_is_qualified_path_crate_path() { + let ty: syn::Type = syn::parse_str("crate::models::User").unwrap(); + assert!(is_qualified_path(&ty)); + } + + #[test] + fn test_is_qualified_path_non_path_type() { + let ty: syn::Type = syn::parse_str("&str").unwrap(); + assert!(!is_qualified_path(&ty)); + } + + #[test] + fn test_is_seaorm_relation_type_has_one() { + let ty: syn::Type = syn::parse_str("HasOne").unwrap(); + assert!(is_seaorm_relation_type(&ty)); + } + + #[test] + fn test_is_seaorm_relation_type_has_many() { + let ty: syn::Type = syn::parse_str("HasMany").unwrap(); + assert!(is_seaorm_relation_type(&ty)); + } + + #[test] + fn test_is_seaorm_relation_type_belongs_to() { + let ty: syn::Type = syn::parse_str("BelongsTo").unwrap(); + assert!(is_seaorm_relation_type(&ty)); + } + + #[test] + fn test_is_seaorm_relation_type_regular_type() { + let ty: syn::Type = syn::parse_str("String").unwrap(); + assert!(!is_seaorm_relation_type(&ty)); + } + + #[test] + fn test_is_seaorm_relation_type_non_path() { + let ty: syn::Type = syn::parse_str("&str").unwrap(); + assert!(!is_seaorm_relation_type(&ty)); + } + + #[test] + fn test_is_seaorm_model_with_sea_orm_attr() { + let struct_item: syn::ItemStruct = syn::parse_str( + r#" + #[sea_orm(table_name = "users")] + struct Model { + id: i32, + } + "#, + ) + .unwrap(); + assert!(is_seaorm_model(&struct_item)); + } + + #[test] + fn test_is_seaorm_model_with_qualified_attr() { + let struct_item: syn::ItemStruct = syn::parse_str( + r#" + #[sea_orm::model] + struct Model { + id: i32, + } + "#, + ) + .unwrap(); + assert!(is_seaorm_model(&struct_item)); + } + + #[test] + fn test_is_seaorm_model_regular_struct() { + let struct_item: syn::ItemStruct = syn::parse_str( + r#" + #[derive(Debug)] + struct User { + id: i32, + } + "#, + ) + .unwrap(); + assert!(!is_seaorm_model(&struct_item)); + } + + #[test] + fn test_parse_schema_input_trailing_comma() { + // Test that trailing comma is handled + let tokens = quote::quote!(User, omit = ["password"],); + let input: SchemaInput = syn::parse2(tokens).unwrap(); + assert_eq!(input.omit.unwrap(), vec!["password"]); + } + + #[test] + fn test_parse_schema_input_unknown_param() { + let tokens = quote::quote!(User, unknown = ["a"]); + let result: syn::Result = syn::parse2(tokens); + assert!(result.is_err()); + if let Err(e) = result { + assert!(e.to_string().contains("unknown parameter")); + } + } + + #[test] + fn test_parse_schema_type_input_with_ignore() { + let tokens = quote::quote!(NewType from User, ignore); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + assert!(input.ignore_schema); + } + + #[test] + fn test_parse_schema_type_input_with_name() { + let tokens = quote::quote!(NewType from User, name = "CustomName"); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + assert_eq!(input.schema_name.as_deref(), Some("CustomName")); + } + + #[test] + fn test_parse_schema_type_input_with_name_and_ignore() { + let tokens = quote::quote!(NewType from User, name = "CustomName", ignore); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + assert_eq!(input.schema_name.as_deref(), Some("CustomName")); + assert!(input.ignore_schema); + } + + // Test doc comment preservation in schema_type + #[test] + fn test_generate_schema_type_code_preserves_struct_doc() { + let input = SchemaTypeInput { + new_type: syn::Ident::new("NewUser", proc_macro2::Span::call_site()), + source_type: syn::parse_str("User").unwrap(), + omit: None, + pick: None, + rename: None, + add: None, + derive_clone: true, + partial: None, + schema_name: None, + ignore_schema: false, + rename_all: None, + }; + // Create a struct with doc comments + let struct_def = StructMetadata { + name: "User".to_string(), + definition: r#" + /// User struct documentation + pub struct User { + /// The user ID + pub id: i32, + /// The user name + pub name: String, + } + "# + .to_string(), + include_in_openapi: true, + }; + let result = generate_schema_type_code(&input, &[struct_def]); + assert!(result.is_ok()); + let (tokens, _) = result.unwrap(); + let tokens_str = tokens.to_string(); + // Should contain doc comments + assert!(tokens_str.contains("User struct documentation") || tokens_str.contains("doc")); + } } diff --git a/examples/axum-example/src/models/comment.rs b/examples/axum-example/src/models/comment.rs index ec4eb85..b22ea67 100644 --- a/examples/axum-example/src/models/comment.rs +++ b/examples/axum-example/src/models/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/memo.rs b/examples/axum-example/src/models/memo.rs index 0c89451..cbc555b 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 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/user.rs b/examples/axum-example/src/models/user.rs index 25b6b3d..f4e4204 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 memos: HasMany, } - // Index definitions (SeaORM uses Statement builders externally) // (unnamed) on [email] vespera::schema_type!(Schema from Model, name = "UserSchema");