diff --git a/.changepacks/changepack_log_8A_RBxjcC8HHy6dQ0mbNa.json b/.changepacks/changepack_log_8A_RBxjcC8HHy6dQ0mbNa.json new file mode 100644 index 0000000..334d6c0 --- /dev/null +++ b/.changepacks/changepack_log_8A_RBxjcC8HHy6dQ0mbNa.json @@ -0,0 +1 @@ +{"changes":{"Cargo.toml":"Patch"},"note":"Fix rel issue","date":"2026-04-21T10:25:46.888127400Z"} \ No newline at end of file diff --git a/README.md b/README.md index 5ce926f..b332d37 100644 --- a/README.md +++ b/README.md @@ -441,6 +441,57 @@ vespera::schema_type!(Schema from Model, name = "MemoSchema"); **Circular Reference Handling:** When schemas reference each other (e.g., User ↔ Memo), the macro automatically detects and handles circular references by inlining fields to prevent infinite recursion. +### Same-File Relation Adapters + +For response DTOs that live in the same route file, `schema_type!` can now keep the handler code unchanged even when a SeaORM relation should be exposed through a custom local DTO. + +Example: + +```rust +#[derive(Serialize, vespera::Schema)] +#[serde(rename_all = "camelCase")] +pub struct UserInArticle { + pub id: Uuid, + pub name: String, + pub email: String, + pub profile_image: Option, +} + +#[derive(Serialize, vespera::Schema)] +#[serde(rename_all = "camelCase")] +pub struct CategoryInArticle { + pub id: i64, + pub name: String, + pub parent_category_id: Option, + pub is_active: bool, + pub is_menu: bool, +} + +schema_type!( + ArticleResponse from crate::models::article::Model, + add = [("article_review_users": Vec)] +); + +// Existing handler code stays valid. +Ok(ArticleResponse { + user: user.into(), + category: category.into(), + article_review_users, + .. +}) +``` + +How it works: + +- `schema_type!` looks for same-file DTOs named `{RelationNamePascal}In{ResponseBase}` + - `user` on `ArticleResponse` → `UserInArticle` + - `category` on `ArticleResponse` → `CategoryInArticle` +- It generates local compile adapters so `Option.into()` works unchanged in the handler +- Those adapters stay internal to Rust typing +- OpenAPI does **not** expose the generated adapter wrapper names; the spec still points at the original related schemas (`UserSchema`, `CategorySchema`) + +Use this when you want route-local response DTOs for single-value relations (`HasOne` / `BelongsTo`) without rewriting the route construction logic. + ### Multipart Mode Generate `Multipart` structs from existing types using the `multipart` keyword: diff --git a/SKILL.md b/SKILL.md index 31bc95b..4a07865 100644 --- a/SKILL.md +++ b/SKILL.md @@ -397,6 +397,55 @@ vespera::schema_type!(Schema from Model, name = "MemoSchema"); **Required Logic:** `required` is determined **solely by nullability** (`Option`). Fields with `#[serde(default)]` or `#[serde(skip_serializing_if)]` are still `required` unless they are `Option`. +### Same-File Relation Adapters + +When a route file defines a local response DTO for a relation, Vespera can preserve unchanged handler code while still generating the right OpenAPI. + +Example: + +```rust +#[derive(Serialize, vespera::Schema)] +#[serde(rename_all = "camelCase")] +pub struct UserInArticle { + pub id: Uuid, + pub name: String, + pub email: String, + pub profile_image: Option, +} + +#[derive(Serialize, vespera::Schema)] +#[serde(rename_all = "camelCase")] +pub struct CategoryInArticle { + pub id: i64, + pub name: String, + pub parent_category_id: Option, + pub is_active: bool, + pub is_menu: bool, +} + +schema_type!( + ArticleResponse from crate::models::article::Model, + add = [("article_review_users": Vec)] +); + +Ok(ArticleResponse { + user: user.into(), + category: category.into(), + article_review_users, + .. +}) +``` + +Rules: + +- Only applies to single-value relations (`HasOne` / `BelongsTo`) +- The local DTO name must follow `{RelationNamePascal}In{ResponseBase}` + - `user` on `ArticleResponse` → `UserInArticle` + - `category` on `ArticleResponse` → `CategoryInArticle` +- Vespera generates local compile adapters so `Option.into()` works without changing the route +- The adapter wrapper is hidden from OpenAPI; the spec still references the original related schema (`UserSchema`, `CategorySchema`) +- `HasMany` relations remain excluded by default unless explicitly `pick`ed or `add`ed + ### Complete Example ```rust diff --git a/crates/vespera_macro/src/parser/schema/serde_attrs.rs b/crates/vespera_macro/src/parser/schema/serde_attrs.rs index 3750bb7..f6d154f 100644 --- a/crates/vespera_macro/src/parser/schema/serde_attrs.rs +++ b/crates/vespera_macro/src/parser/schema/serde_attrs.rs @@ -158,6 +158,48 @@ pub fn extract_rename_all(attrs: &[syn::Attribute]) -> Option { None } +/// Extract whether `#[serde(transparent)]` is present on a struct. +pub fn extract_transparent(attrs: &[syn::Attribute]) -> bool { + attrs.iter().any(|attr| { + if !attr.path().is_ident("serde") { + return false; + } + + let mut is_transparent = false; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("transparent") { + is_transparent = true; + } + Ok(()) + }); + is_transparent + }) +} + +/// Extract `#[schema(ref = "Name", nullable)]` override from a struct. +pub fn extract_schema_ref_override(attrs: &[syn::Attribute]) -> Option<(String, bool)> { + attrs.iter().find_map(|attr| { + if !attr.path().is_ident("schema") { + return None; + } + + let mut ref_name = None; + let mut nullable = false; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("ref") { + let value = meta.value()?; + let lit: syn::LitStr = value.parse()?; + ref_name = Some(lit.value()); + } else if meta.path.is_ident("nullable") { + nullable = true; + } + Ok(()) + }); + + ref_name.map(|name| (name, nullable)) + }) +} + pub fn extract_field_rename(attrs: &[syn::Attribute]) -> Option { // First check serde attrs (higher priority) for attr in attrs { diff --git a/crates/vespera_macro/src/parser/schema/struct_schema.rs b/crates/vespera_macro/src/parser/schema/struct_schema.rs index 7622cd3..8785a38 100644 --- a/crates/vespera_macro/src/parser/schema/struct_schema.rs +++ b/crates/vespera_macro/src/parser/schema/struct_schema.rs @@ -11,7 +11,8 @@ use vespera_core::schema::{Schema, SchemaRef, SchemaType}; use super::{ serde_attrs::{ extract_doc_comment, extract_field_rename, extract_flatten, extract_rename_all, - extract_skip, rename_field, strip_raw_prefix_owned, + extract_schema_ref_override, extract_skip, extract_transparent, rename_field, + strip_raw_prefix_owned, }, type_schema::parse_type_to_schema_ref, }; @@ -41,6 +42,45 @@ pub fn parse_struct_to_schema( // Extract struct-level doc comment for schema description let struct_description = extract_doc_comment(&struct_item.attrs); + if let Some((schema_name, nullable)) = extract_schema_ref_override(&struct_item.attrs) { + return Schema { + ref_path: Some(format!("#/components/schemas/{schema_name}")), + nullable: nullable.then_some(true), + description: struct_description, + ..Default::default() + }; + } + + // Transparent single-field wrappers should use the inner field schema directly. + if extract_transparent(&struct_item.attrs) { + let inner_field_ty = match &struct_item.fields { + Fields::Named(fields_named) if fields_named.named.len() == 1 => { + fields_named.named.first().map(|field| &field.ty) + } + Fields::Unnamed(fields_unnamed) if fields_unnamed.unnamed.len() == 1 => { + fields_unnamed.unnamed.first().map(|field| &field.ty) + } + _ => None, + }; + + if let Some(field_ty) = inner_field_ty { + let schema_ref = parse_type_to_schema_ref(field_ty, known_schemas, struct_definitions); + return match schema_ref { + SchemaRef::Inline(mut schema) => { + if schema.description.is_none() { + schema.description = struct_description; + } + *schema + } + SchemaRef::Ref(reference) => Schema { + description: struct_description, + all_of: Some(vec![SchemaRef::Ref(reference)]), + ..Default::default() + }, + }; + } + } + // Extract rename_all attribute from struct let rename_all = extract_rename_all(&struct_item.attrs); @@ -245,6 +285,43 @@ mod tests { assert!(schema.required.is_none()); } + #[test] + fn test_parse_struct_to_schema_serde_transparent_named_wrapper_uses_inner_schema() { + let struct_item: syn::ItemStruct = syn::parse_str( + r" + #[serde(transparent)] + struct Wrapper { + value: Box, + } + ", + ) + .unwrap(); + + let schema = parse_struct_to_schema(&struct_item, &HashSet::new(), &HashMap::new()); + assert_eq!(schema.schema_type, Some(SchemaType::String)); + assert!(schema.properties.is_none()); + } + + #[test] + fn test_parse_struct_to_schema_schema_ref_override() { + let struct_item: syn::ItemStruct = syn::parse_str( + r#" + #[schema(ref = "UserSchema", nullable)] + struct Wrapper { + value: Option, + } + "#, + ) + .unwrap(); + + let schema = parse_struct_to_schema(&struct_item, &HashSet::new(), &HashMap::new()); + assert_eq!( + schema.ref_path.as_deref(), + Some("#/components/schemas/UserSchema") + ); + assert_eq!(schema.nullable, Some(true)); + } + // Test struct with skip field #[test] fn test_parse_struct_to_schema_with_skip_field() { @@ -475,4 +552,49 @@ mod tests { ); assert!(schema.properties.is_some()); } + + #[test] + fn test_parse_struct_to_schema_transparent_tuple_wrapper_uses_ref_schema() { + let struct_item: syn::ItemStruct = syn::parse_str( + r" + #[serde(transparent)] + struct Wrapper(User); + ", + ) + .unwrap(); + + let mut struct_defs = HashMap::new(); + struct_defs.insert("User".to_string(), "struct User { id: i32 }".to_string()); + let mut known = HashSet::new(); + known.insert("User".to_string()); + + let schema = parse_struct_to_schema(&struct_item, &known, &struct_defs); + assert!(schema.all_of.is_some()); + let all_of = schema.all_of.unwrap(); + assert_eq!(all_of.len(), 1); + match &all_of[0] { + SchemaRef::Ref(reference) => { + assert_eq!(reference.ref_path, "#/components/schemas/User"); + } + SchemaRef::Inline(_) => { + panic!("expected $ref wrapper for transparent tuple known schema") + } + } + } + + #[test] + fn test_parse_struct_to_schema_transparent_multi_field_tuple_falls_back() { + let struct_item: syn::ItemStruct = syn::parse_str( + r" + #[serde(transparent)] + struct Wrapper(String, String); + ", + ) + .unwrap(); + + let schema = parse_struct_to_schema(&struct_item, &HashSet::new(), &HashMap::new()); + assert_eq!(schema.schema_type, Some(SchemaType::Object)); + assert!(schema.properties.is_none()); + assert!(schema.all_of.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 bb43199..707e7c3 100644 --- a/crates/vespera_macro/src/parser/schema/type_schema.rs +++ b/crates/vespera_macro/src/parser/schema/type_schema.rs @@ -21,7 +21,7 @@ thread_local! { use super::{ generics::substitute_type, - serde_attrs::{capitalize_first, extract_schema_name_from_entity}, + serde_attrs::{capitalize_first, extract_schema_name_from_entity, extract_schema_ref_override}, struct_schema::parse_struct_to_schema, }; @@ -317,6 +317,19 @@ fn parse_type_impl( }; if known_schemas.contains(&resolved_name) { + if let Some(def) = struct_definitions.get(&resolved_name) + && let Ok(parsed_struct) = syn::parse_str::(def) + && let Some((schema_name, nullable)) = + extract_schema_ref_override(&parsed_struct.attrs) + { + return SchemaRef::Inline(Box::new(Schema { + ref_path: Some(format!("#/components/schemas/{schema_name}")), + schema_type: None, + nullable: nullable.then_some(true), + ..Schema::new(SchemaType::Object) + })); + } + // Check if this is a generic type with type parameters if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { // This is a concrete generic type like GenericStruct @@ -1457,4 +1470,36 @@ mod tests { let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); assert!(matches!(schema_ref, SchemaRef::Inline(_))); } + + #[test] + fn test_known_schema_ref_override_returns_inline_ref_schema() { + let mut known = HashSet::new(); + known.insert("UserSchema".to_string()); + + let mut defs = HashMap::new(); + defs.insert( + "UserSchema".to_string(), + r#" + #[schema(ref = "ExternalUser", nullable)] + struct UserSchema { + id: i32, + } + "# + .to_string(), + ); + + let ty: Type = syn::parse_str("UserSchema").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &known, &defs); + + match schema_ref { + SchemaRef::Inline(schema) => { + assert_eq!( + schema.ref_path.as_deref(), + Some("#/components/schemas/ExternalUser") + ); + assert_eq!(schema.nullable, Some(true)); + } + SchemaRef::Ref(_) => panic!("expected inline schema ref override"), + } + } } diff --git a/crates/vespera_macro/src/schema_impl.rs b/crates/vespera_macro/src/schema_impl.rs index 05bc9b2..81c6ec8 100644 --- a/crates/vespera_macro/src/schema_impl.rs +++ b/crates/vespera_macro/src/schema_impl.rs @@ -81,6 +81,30 @@ pub fn process_derive_schema( // Schema-derived types appear in OpenAPI spec (include_in_openapi: true) let mut metadata = StructMetadata::new(schema_name, quote::quote!(#input).to_string()); + if input + .attrs + .iter() + .any(|attr| attr.path().is_ident("schema")) + { + let mut has_ref_override = false; + for attr in &input.attrs { + if !attr.path().is_ident("schema") { + continue; + } + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("ref") { + has_ref_override = true; + } + Ok(()) + }); + if has_ref_override { + break; + } + } + if has_ref_override { + metadata.include_in_openapi = false; + } + } metadata.field_defaults = field_defaults; (metadata, proc_macro2::TokenStream::new()) } @@ -606,4 +630,20 @@ struct Config { let defaults = extract_field_defaults_from_path(&input, Path::new("/dummy.rs")); assert!(defaults.is_empty(), "Enum should return empty defaults"); } + + #[test] + fn test_process_derive_schema_ref_override_excludes_openapi() { + let input: syn::DeriveInput = syn::parse_quote! { + #[derive(Clone)] + #[schema(ref = "ExternalUser")] + struct UserSchema { + id: i32, + } + }; + + let (metadata, tokens) = process_derive_schema(&input); + assert_eq!(metadata.name, "UserSchema"); + assert!(!metadata.include_in_openapi); + assert!(tokens.is_empty()); + } } diff --git a/crates/vespera_macro/src/schema_macro/mod.rs b/crates/vespera_macro/src/schema_macro/mod.rs index 13ed25b..59d230e 100644 --- a/crates/vespera_macro/src/schema_macro/mod.rs +++ b/crates/vespera_macro/src/schema_macro/mod.rs @@ -41,8 +41,8 @@ use transformation::{ should_wrap_in_option, }; use type_utils::{ - extract_module_path, extract_type_name, is_option_type, is_qualified_path, is_seaorm_model, - is_seaorm_relation_type, + capitalize_first, extract_module_path, extract_type_name, is_option_type, is_qualified_path, + is_seaorm_model, is_seaorm_relation_type, snake_to_pascal_case, }; use validation::{ extract_source_field_names, validate_omit_fields, validate_partial_fields, @@ -54,6 +54,298 @@ use crate::{ parser::{extract_default, extract_field_rename, strip_raw_prefix_owned}, }; +#[cfg(test)] +struct __VesperaSameFileLookupFixture { + value: i32, +} + +fn derive_response_base_name(name: &str) -> String { + for suffix in ["Response", "Request", "Schema"] { + if let Some(stripped) = name.strip_suffix(suffix) + && !stripped.is_empty() + { + return stripped.to_string(); + } + } + name.to_string() +} + +fn find_same_file_struct_metadata( + struct_name: &str, + schema_storage: &HashMap, +) -> Option { + if let Some(metadata) = schema_storage.get(struct_name) { + return Some(metadata.clone()); + } + + let file_path = proc_macro2::Span::call_site().local_file(); + #[cfg(test)] + let file_path = file_path.or_else(|| { + Some( + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("src") + .join("schema_macro") + .join("mod.rs"), + ) + }); + let file_path = file_path?; + let definition = file_cache::get_struct_definition(&file_path, struct_name)?; + Some(StructMetadata::new(struct_name.to_string(), definition)) +} + +fn related_model_type_from_schema_path(schema_path: &TokenStream) -> Option { + let schema_path_str = schema_path.to_string().replace("Schema", "Model"); + syn::parse_str(&schema_path_str).ok() +} + +fn schema_component_name_from_path(schema_path: &TokenStream) -> String { + let segments: Vec = schema_path + .to_string() + .split("::") + .map(|segment| segment.trim().to_string()) + .collect(); + + if segments.last().is_some_and(|segment| segment == "Schema") && segments.len() > 1 { + format!("{}Schema", capitalize_first(&segments[segments.len() - 2])) + } else { + segments + .last() + .cloned() + .unwrap_or_else(|| "Schema".to_string()) + } +} + +fn has_derive(struct_item: &syn::ItemStruct, derive_name: &str) -> bool { + struct_item.attrs.iter().any(|attr| { + if !attr.path().is_ident("derive") { + return false; + } + + let mut found = false; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident(derive_name) { + found = true; + } + Ok(()) + }); + found + }) +} + +fn build_named_struct_field_assignments( + struct_item: &syn::ItemStruct, + source_expr: &TokenStream, +) -> syn::Result> { + let syn::Fields::Named(fields_named) = &struct_item.fields else { + return Err(syn::Error::new_spanned( + struct_item, + "same-file relation override DTO must be a named-field struct", + )); + }; + + let assignments = fields_named + .named + .iter() + .filter_map(|field| { + field.ident.as_ref().map(|ident| { + quote! { #ident: #source_expr . #ident.clone() } + }) + }) + .collect(); + + Ok(assignments) +} + +fn build_proxy_fields(struct_item: &syn::ItemStruct) -> syn::Result> { + let syn::Fields::Named(fields_named) = &struct_item.fields else { + return Err(syn::Error::new_spanned( + struct_item, + "same-file relation override DTO must be a named-field struct", + )); + }; + + let fields = fields_named + .named + .iter() + .filter_map(|field| { + field.ident.as_ref().map(|ident| { + let ty = &field.ty; + let attrs: Vec<_> = field + .attrs + .iter() + .filter(|attr| attr.path().is_ident("serde") || attr.path().is_ident("doc")) + .collect(); + quote! { + #(#attrs)* + #ident: #ty + } + }) + }) + .collect(); + + Ok(fields) +} + +fn build_proxy_to_dto_assignments(struct_item: &syn::ItemStruct) -> syn::Result> { + let syn::Fields::Named(fields_named) = &struct_item.fields else { + return Err(syn::Error::new_spanned( + struct_item, + "same-file relation override DTO must be a named-field struct", + )); + }; + + let assignments = fields_named + .named + .iter() + .filter_map(|field| { + field + .ident + .as_ref() + .map(|ident| quote! { #ident: proxy.#ident }) + }) + .collect(); + + Ok(assignments) +} + +fn build_clone_assignments(struct_item: &syn::ItemStruct) -> syn::Result> { + let syn::Fields::Named(fields_named) = &struct_item.fields else { + return Err(syn::Error::new_spanned( + struct_item, + "same-file relation override DTO must be a named-field struct", + )); + }; + + let assignments = fields_named + .named + .iter() + .filter_map(|field| { + field.ident.as_ref().map(|ident| { + quote! { #ident: self.#ident.clone() } + }) + }) + .collect(); + + Ok(assignments) +} + +fn maybe_generate_same_file_relation_override( + new_type_name: &syn::Ident, + field_name: &str, + rel_info: &RelationFieldInfo, + schema_storage: &HashMap, +) -> syn::Result> { + let response_base = derive_response_base_name(&new_type_name.to_string()); + let dto_name = format!("{}In{}", snake_to_pascal_case(field_name), response_base); + let Some(dto_meta) = find_same_file_struct_metadata(&dto_name, schema_storage) else { + return Ok(None); + }; + + let dto_struct: syn::ItemStruct = file_cache::parse_struct_cached(&dto_meta.definition) + .map_err(|e| syn::Error::new(proc_macro2::Span::call_site(), e.to_string()))?; + let dto_ident = syn::Ident::new(&dto_name, proc_macro2::Span::call_site()); + let wrapper_ident = syn::Ident::new( + &format!( + "__Vespera{}{}Relation", + new_type_name, + snake_to_pascal_case(field_name) + ), + proc_macro2::Span::call_site(), + ); + let proxy_ident = syn::Ident::new( + &format!( + "__Vespera{}{}Proxy", + new_type_name, + snake_to_pascal_case(field_name) + ), + proc_macro2::Span::call_site(), + ); + let schema_ref_name = schema_component_name_from_path(&rel_info.schema_path); + + let dto_serde_attrs: Vec<_> = dto_struct + .attrs + .iter() + .filter(|attr| attr.path().is_ident("serde")) + .collect(); + let dto_doc_attrs: Vec<_> = dto_struct + .attrs + .iter() + .filter(|attr| attr.path().is_ident("doc")) + .collect(); + + let proxy_fields = build_proxy_fields(&dto_struct)?; + let proxy_to_dto = build_proxy_to_dto_assignments(&dto_struct)?; + let clone_assignments = build_clone_assignments(&dto_struct)?; + let Some(model_ty) = related_model_type_from_schema_path(&rel_info.schema_path) else { + return Ok(None); + }; + let source_expr = quote! { source }; + let from_model_assignments = build_named_struct_field_assignments(&dto_struct, &source_expr)?; + + let mut helper_tokens = Vec::new(); + + if !has_derive(&dto_struct, "Clone") { + helper_tokens.push(quote! { + impl Clone for #dto_ident { + fn clone(&self) -> Self { + Self { + #(#clone_assignments),* + } + } + } + }); + } + + if !has_derive(&dto_struct, "Deserialize") { + helper_tokens.push(quote! { + #[derive(serde::Deserialize)] + #(#dto_serde_attrs)* + struct #proxy_ident { + #(#proxy_fields),* + } + + impl<'de> serde::Deserialize<'de> for #dto_ident { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let proxy = #proxy_ident::deserialize(deserializer)?; + Ok(Self { + #(#proxy_to_dto),* + }) + } + } + }); + } + + helper_tokens.push(quote! { + impl From<#model_ty> for #dto_ident { + fn from(source: #model_ty) -> Self { + Self { + #(#from_model_assignments),* + } + } + } + + #(#dto_doc_attrs)* + #[derive(serde::Serialize, serde::Deserialize, Clone, vespera::Schema)] + #[serde(transparent)] + #[schema(ref = #schema_ref_name, nullable)] + struct #wrapper_ident(pub Option<#dto_ident>); + + impl From> for #wrapper_ident { + fn from(source: Option<#model_ty>) -> Self { + Self(source.map(Into::into)) + } + } + }); + + Ok(Some(( + quote! { #wrapper_ident }, + quote! { #(#helper_tokens)* }, + ))) +} + /// Generate schema code from a struct with optional field filtering pub fn generate_schema_code( input: &SchemaInput, @@ -230,6 +522,8 @@ pub fn generate_schema_type_code( 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(); + // Track same-file relation override helpers + let mut relation_override_helpers: Vec = Vec::new(); if let syn::Fields::Named(fields_named) = &parsed_struct.fields { for field in &fields_named.named { @@ -316,6 +610,18 @@ pub fn generate_schema_type_code( } } else { // BelongsTo/HasOne: Include by default + if input.add.is_some() + && let Some((override_field_ty, helper_tokens)) = + maybe_generate_same_file_relation_override( + new_type_name, + &rust_field_name, + &rel_info, + schema_storage, + )? + { + relation_override_helpers.push(helper_tokens); + (Box::new(override_field_ty), Some(rel_info)) + } else // Check for circular references and potentially use inline type if let Some(inline_type) = generate_inline_relation_type( new_type_name, @@ -600,6 +906,9 @@ pub fn generate_schema_type_code( // Inline types for circular relation references #(#inline_type_definitions)* + // Same-file relation override helpers + #(#relation_override_helpers)* + // Default value functions for sea_orm(default_value) fields #(#default_functions)* diff --git a/crates/vespera_macro/src/schema_macro/tests.rs b/crates/vespera_macro/src/schema_macro/tests.rs index 4e94bac..57b06f7 100644 --- a/crates/vespera_macro/src/schema_macro/tests.rs +++ b/crates/vespera_macro/src/schema_macro/tests.rs @@ -212,6 +212,85 @@ fn test_generate_schema_type_code_with_add() { assert!(output.contains("extra")); } +#[test] +fn test_generate_schema_type_code_relation_fields_can_be_omitted_and_readded_with_custom_types() { + let storage = to_storage(vec![create_test_struct_metadata( + "Model", + r#"#[sea_orm(table_name = "article")] + pub struct Model { + pub id: i64, + pub title: String, + pub user: HasOne, + pub category: HasOne, + pub article_review_users: HasMany + }"#, + )]); + + let tokens = quote!( + ArticleResponse from Model, + omit = ["user", "category", "article_review_users"], + add = [ + ("user": Option), + ("category": Option), + ("article_review_users": Vec) + ] + ); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + assert!(output.contains("pub user : Option < UserInArticle >")); + assert!(output.contains("pub category : Option < CategoryInArticle >")); + assert!(output.contains("pub article_review_users : Vec < ArticleReviewUserInArticle >")); + assert!(!output.contains("Box < Schema >")); + assert!(!output.contains("impl From")); +} + +#[test] +fn test_generate_schema_type_code_same_file_relation_adapters_for_add_mode() { + let storage = to_storage(vec![ + create_test_struct_metadata( + "Model", + r#"#[sea_orm(table_name = "article")] + pub struct Model { + pub id: i64, + pub title: String, + pub user: HasOne, + pub category: HasOne, + pub article_review_users: HasMany + }"#, + ), + create_test_struct_metadata( + "UserInArticle", + "struct UserInArticle { id: i32, name: String }", + ), + create_test_struct_metadata( + "CategoryInArticle", + "struct CategoryInArticle { id: i64, name: String }", + ), + ]); + + let tokens = quote!( + ArticleResponse from Model, + add = [("article_review_users": Vec)] + ); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + assert!(output.contains("pub user : __VesperaArticleResponseUserRelation")); + assert!(output.contains("pub category : __VesperaArticleResponseCategoryRelation")); + assert!(output.contains("impl From < Option <")); + assert!(output.contains("for __VesperaArticleResponseUserRelation")); + assert!(output.contains("for __VesperaArticleResponseCategoryRelation")); + assert!(output.contains("impl Clone for UserInArticle")); + assert!(output.contains("impl Clone for CategoryInArticle")); +} + #[test] fn test_generate_schema_type_code_generates_from_impl() { let storage = to_storage(vec![create_test_struct_metadata( @@ -1537,6 +1616,177 @@ pub struct Model { assert!(output.contains("name")); } +#[test] +fn test_derive_response_base_name_handles_known_suffixes_and_fallback() { + assert_eq!(derive_response_base_name("UserResponse"), "User"); + assert_eq!(derive_response_base_name("UserRequest"), "User"); + assert_eq!(derive_response_base_name("UserSchema"), "User"); + assert_eq!(derive_response_base_name("User"), "User"); +} + +#[test] +fn test_find_same_file_struct_metadata_reads_test_fixture_from_current_module() { + let storage: HashMap = HashMap::new(); + let metadata = find_same_file_struct_metadata("__VesperaSameFileLookupFixture", &storage) + .expect("fixture should be found in schema_macro/mod.rs"); + + assert_eq!(metadata.name, "__VesperaSameFileLookupFixture"); + assert!( + metadata + .definition + .contains("__VesperaSameFileLookupFixture") + ); + assert!(metadata.definition.contains("value")); +} + +#[test] +fn test_has_derive_ignores_non_derive_attrs_and_detects_requested_derive() { + let struct_item: syn::ItemStruct = syn::parse_str( + r#" + #[serde(rename_all = "camelCase")] + #[derive(Clone, Debug)] + struct Sample { + value: i32, + } + "#, + ) + .unwrap(); + + assert!(has_derive(&struct_item, "Clone")); + assert!(!has_derive(&struct_item, "Deserialize")); +} + +#[test] +fn test_build_named_struct_field_assignments_rejects_tuple_structs() { + let struct_item: syn::ItemStruct = syn::parse_str("struct TupleDto(String);").unwrap(); + let source_expr = quote!(source); + let error = build_named_struct_field_assignments(&struct_item, &source_expr).unwrap_err(); + assert!(error.to_string().contains("named-field struct")); +} + +#[test] +fn test_build_proxy_fields_rejects_tuple_structs() { + let struct_item: syn::ItemStruct = syn::parse_str("struct TupleDto(String);").unwrap(); + let error = build_proxy_fields(&struct_item).unwrap_err(); + assert!(error.to_string().contains("named-field struct")); +} + +#[test] +fn test_build_proxy_to_dto_assignments_rejects_tuple_structs() { + let struct_item: syn::ItemStruct = syn::parse_str("struct TupleDto(String);").unwrap(); + let error = build_proxy_to_dto_assignments(&struct_item).unwrap_err(); + assert!(error.to_string().contains("named-field struct")); +} + +#[test] +fn test_build_clone_assignments_rejects_tuple_structs() { + let struct_item: syn::ItemStruct = syn::parse_str("struct TupleDto(String);").unwrap(); + let error = build_clone_assignments(&struct_item).unwrap_err(); + assert!(error.to_string().contains("named-field struct")); +} + +#[test] +fn test_maybe_generate_same_file_relation_override_returns_none_when_dto_is_missing() { + let rel_info = RelationFieldInfo { + field_name: syn::Ident::new("user", proc_macro2::Span::call_site()), + relation_type: "HasOne".to_string(), + schema_path: quote!(crate::models::user::Schema), + is_optional: true, + inline_type_info: None, + relation_enum: None, + fk_column: None, + via_rel: None, + }; + + let storage: HashMap = HashMap::new(); + let new_type_name = syn::Ident::new("ArticleResponse", proc_macro2::Span::call_site()); + + let result = + maybe_generate_same_file_relation_override(&new_type_name, "user", &rel_info, &storage) + .expect("missing dto should not error"); + assert!(result.is_none()); +} + +#[test] +fn test_maybe_generate_same_file_relation_override_returns_none_for_invalid_model_type() { + let rel_info = RelationFieldInfo { + field_name: syn::Ident::new("user", proc_macro2::Span::call_site()), + relation_type: "HasOne".to_string(), + schema_path: quote!(?), + is_optional: true, + inline_type_info: None, + relation_enum: None, + fk_column: None, + via_rel: None, + }; + + let storage = to_storage(vec![create_test_struct_metadata( + "UserInArticle", + "struct UserInArticle { id: i32 }", + )]); + let new_type_name = syn::Ident::new("ArticleResponse", proc_macro2::Span::call_site()); + + let result = + maybe_generate_same_file_relation_override(&new_type_name, "user", &rel_info, &storage) + .expect("invalid model type should not error"); + assert!(result.is_none()); +} + +#[test] +fn test_generate_schema_type_code_normal_mode_relation_rename_and_custom_name() { + let storage = to_storage(vec![create_test_struct_metadata( + "Model", + r#"#[sea_orm(table_name = "articles")] + pub struct Model { + pub id: i32, + pub name: String, + pub owner: HasOne + }"#, + )]); + + let tokens = quote!( + ArticleResponse from Model, + name = "CustomArticleSchema", + rename = [("name", "display_name")] + ); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, metadata) = result.unwrap(); + let output = tokens.to_string(); + assert!(output.contains("display_name")); + assert!(output.contains("owner")); + assert!(output.contains("Clone")); + assert!(output.contains("CustomArticleSchema")); + assert_eq!(metadata.unwrap().name, "CustomArticleSchema"); +} + +#[test] +fn test_generate_schema_type_code_multipart_with_add_and_custom_name() { + let storage = to_storage(vec![create_test_struct_metadata( + "Upload", + "pub struct Upload { pub id: i32, pub name: String }", + )]); + + let tokens = quote!( + UploadForm from Upload, + multipart, + name = "UploadFormSchema", + add = [("extra": String)] + ); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, metadata) = result.unwrap(); + let output = tokens.to_string(); + assert!(output.contains("vespera :: Multipart")); + assert!(output.contains("extra")); + assert!(output.contains("UploadFormSchema")); + assert_eq!(metadata.unwrap().name, "UploadFormSchema"); +} + // ============================================================ // Tests for BelongsTo/HasOne circular reference inline types // ============================================================ diff --git a/crates/vespera_macro/src/schema_macro/type_utils.rs b/crates/vespera_macro/src/schema_macro/type_utils.rs index 903bee6..b7189ed 100644 --- a/crates/vespera_macro/src/schema_macro/type_utils.rs +++ b/crates/vespera_macro/src/schema_macro/type_utils.rs @@ -265,10 +265,12 @@ pub fn resolve_type_to_absolute_path(ty: &Type, source_module_path: &[String]) - return quote! { #(#rendered_segments)::* }; } - // Get the single segment - let Some(segment) = type_path.path.segments.first() else { - return quote! { #ty }; - }; + // Safe after the empty-path early return above. + let segment = type_path + .path + .segments + .first() + .expect("type path should have at least one segment"); let ident_str = segment.ident.to_string(); let args = render_path_arguments(&segment.arguments, source_module_path); @@ -645,6 +647,15 @@ mod tests { assert_eq!(output.trim(), "String"); } + #[test] + fn test_resolve_type_to_absolute_path_known_type_with_generic_args() { + let ty: syn::Type = syn::parse_str("Option").unwrap(); + let module_path = vec!["crate".to_string(), "models".to_string()]; + let tokens = resolve_type_to_absolute_path(&ty, &module_path); + let output = tokens.to_string(); + assert_eq!(output.trim(), "Option < String >"); + } + #[test] fn test_resolve_type_to_absolute_path_decimal() { let ty: syn::Type = syn::parse_str("Decimal").unwrap(); @@ -859,4 +870,62 @@ mod tests { let ty: syn::Type = syn::parse_str("Vec>").unwrap(); assert!(is_primitive_like(&ty)); } + + #[test] + fn test_normalize_known_type_in_generic_non_path_and_empty_path() { + let ref_ty: syn::Type = syn::parse_str("&str").unwrap(); + assert_eq!( + normalize_known_type_in_generic(&ref_ty, &[]).to_string(), + quote!(&str).to_string() + ); + + let empty_ty = empty_type_path(); + assert_eq!( + normalize_known_type_in_generic(&empty_ty, &[]).to_string(), + quote!(#empty_ty).to_string() + ); + } + + #[test] + fn test_normalize_known_type_in_generic_preserves_qualified_paths_and_leading_colon() { + let ty: syn::Type = syn::parse_str("::crate::models::CustomType").unwrap(); + let output = normalize_known_type_in_generic(&ty, &[]).to_string(); + assert!(output.contains(":: crate :: models :: CustomType")); + } + + #[test] + fn test_normalize_known_type_in_generic_preserves_qualified_paths_without_leading_colon() { + let ty: syn::Type = syn::parse_str("crate::models::CustomType").unwrap(); + let output = normalize_known_type_in_generic(&ty, &[]).to_string(); + assert!(output.contains("crate :: models :: CustomType")); + } + + #[test] + fn test_render_path_arguments_handles_lifetime_and_parenthesized_args() { + let lifetime_ty: syn::Type = syn::parse_str("Borrowed<'a>").unwrap(); + let lifetime_args = match lifetime_ty { + syn::Type::Path(type_path) => type_path.path.segments.last().unwrap().arguments.clone(), + _ => panic!("expected path type"), + }; + assert_eq!( + render_path_arguments(&lifetime_args, &[]).to_string(), + "< 'a >" + ); + + let fn_args = PathArguments::Parenthesized(syn::parse_quote!((i32) -> String)); + let fn_output = render_path_arguments(&fn_args, &[]).to_string(); + assert!(fn_output.contains("(i32)")); + assert!(fn_output.contains("-> String")); + } + + #[test] + fn test_resolve_type_to_absolute_path_leading_colon_and_empty_path() { + let ty: syn::Type = syn::parse_str("::crate::models::User").unwrap(); + let tokens = resolve_type_to_absolute_path(&ty, &["ignored".to_string()]); + assert!(tokens.to_string().contains(":: crate :: models :: User")); + + let empty_ty = empty_type_path(); + let tokens = resolve_type_to_absolute_path(&empty_ty, &["crate".to_string()]); + assert!(tokens.to_string().trim().is_empty()); + } } diff --git a/examples/axum-example/openapi.json b/examples/axum-example/openapi.json index df9656c..bc03522 100644 --- a/examples/axum-example/openapi.json +++ b/examples/axum-example/openapi.json @@ -1088,6 +1088,34 @@ } } }, + "/memos/{id}/detail": { + "get": { + "operationId": "get_memo_detail", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemoDetailResponse" + } + } + } + } + } + } + }, "/memos/{id}/rel": { "get": { "operationId": "get_memo_rel", @@ -3037,6 +3065,27 @@ "age" ] }, + "MemoCommentInMemoDetail": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "id": { + "type": "integer", + "format": "int32" + }, + "memoId": { + "type": "integer", + "format": "int32" + } + }, + "required": [ + "id", + "memoId", + "content" + ] + }, "MemoCommentSchema": { "type": "object", "properties": { @@ -3084,6 +3133,60 @@ "memo" ] }, + "MemoDetailResponse": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "default": "1970-01-01T00:00:00+00:00" + }, + "id": { + "type": "integer", + "format": "int32", + "default": 0 + }, + "memoComments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MemoCommentInMemoDetail" + } + }, + "status": { + "$ref": "#/components/schemas/MemoStatus" + }, + "title": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "default": "1970-01-01T00:00:00+00:00" + }, + "user": { + "$ref": "#/components/schemas/UserSchema", + "nullable": true + }, + "userId": { + "type": "integer", + "format": "int32" + } + }, + "required": [ + "id", + "userId", + "title", + "content", + "status", + "createdAt", + "updatedAt", + "user", + "memoComments" + ] + }, "MemoResponse": { "type": "object", "properties": { @@ -3911,6 +4014,26 @@ "name" ] }, + "UserInMemoDetail": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "email", + "name" + ] + }, "UserItem": { "type": "object", "description": "Simple user representation", diff --git a/examples/axum-example/src/routes/memos.rs b/examples/axum-example/src/routes/memos.rs index 05c4257..d820563 100644 --- a/examples/axum-example/src/routes/memos.rs +++ b/examples/axum-example/src/routes/memos.rs @@ -44,6 +44,27 @@ schema_type!(MemoResponseComments from crate::models::memo::Model, pick = ["memo // Test rename_all override: use snake_case instead of default camelCase schema_type!(MemoSnakeCase from crate::models::memo::Model, pick = ["id", "user_id", "created_at"], rename_all = "snake_case"); +#[derive(serde::Serialize, vespera::Schema)] +#[serde(rename_all = "camelCase")] +pub struct UserInMemoDetail { + pub id: i32, + pub email: String, + pub name: String, +} + +#[derive(serde::Serialize, serde::Deserialize, Clone, vespera::Schema)] +#[serde(rename_all = "camelCase")] +pub struct MemoCommentInMemoDetail { + pub id: i32, + pub memo_id: i32, + pub content: String, +} + +schema_type!( + MemoDetailResponse from crate::models::memo::Model, + add = [("memo_comments": Vec)] +); + /// Create a new memo #[vespera::route(post)] pub async fn create_memo(Json(req): Json) -> Json { @@ -110,6 +131,45 @@ pub async fn get_memo_rel( ) } +#[vespera::route(get, path = "/{id}/detail")] +pub async fn get_memo_detail(Path(id): Path) -> Json { + let now: vespera::chrono::DateTime = + vespera::chrono::Utc::now().fixed_offset(); + let memo = crate::models::memo::Model { + id, + user_id: 7, + title: "Detailed Memo".to_string(), + content: "Detail content".to_string(), + status: crate::models::memo::MemoStatus::Published, + created_at: now, + updated_at: now, + }; + let user = Some(crate::models::user::Model { + id: 7, + email: "memo@example.com".to_string(), + name: "Memo User".to_string(), + created_at: now, + updated_at: now, + }); + let memo_comments = vec![MemoCommentInMemoDetail { + id: 100, + memo_id: id, + content: "Looks good".to_string(), + }]; + + Json(MemoDetailResponse { + id: memo.id, + user_id: memo.user_id, + title: memo.title, + content: memo.content, + status: memo.status, + created_at: memo.created_at, + updated_at: memo.updated_at, + user: user.into(), + memo_comments, + }) +} + /// Get memo response format #[vespera::route(get, path = "/format")] pub async fn get_memo_format() -> &'static str { diff --git a/examples/axum-example/tests/integration_test.rs b/examples/axum-example/tests/integration_test.rs index 661e21b..3755e45 100644 --- a/examples/axum-example/tests/integration_test.rs +++ b/examples/axum-example/tests/integration_test.rs @@ -731,6 +731,30 @@ async fn test_memo_update_with_added_id_field() { assert_eq!(result["id"], 42, "id should be present (added field)"); } +#[tokio::test] +async fn test_memo_detail_same_file_relation_adapter_runtime_shape() { + let app = create_app().await; + let server = TestServer::new(app); + + let response = server.get("/memos/9/detail").await; + + response.assert_status_ok(); + let result: serde_json::Value = response.json(); + + assert_eq!(result["id"], 9); + assert_eq!(result["title"], "Detailed Memo"); + assert_eq!(result["user"]["id"], 7); + assert_eq!(result["user"]["email"], "memo@example.com"); + assert_eq!(result["user"]["name"], "Memo User"); + assert!(result["user"].get("createdAt").is_none()); + assert!(result["user"].get("updatedAt").is_none()); + + let comments = result["memoComments"].as_array().unwrap(); + assert_eq!(comments.len(), 1); + assert_eq!(comments[0]["memoId"], 9); + assert_eq!(comments[0]["content"], "Looks good"); +} + // Tests for TypedMultipart (Multipart) request body extraction #[tokio::test] @@ -977,6 +1001,39 @@ async fn test_openapi_contains_typed_form_routes() { ); } +#[tokio::test] +async fn test_openapi_memo_detail_same_file_relation_adapter_schema() { + let openapi_content = std::fs::read_to_string("openapi.json").unwrap(); + let openapi: serde_json::Value = serde_json::from_str(&openapi_content).unwrap(); + + let paths = openapi.get("paths").unwrap(); + let schemas = openapi + .get("components") + .and_then(|c| c.get("schemas")) + .unwrap(); + + assert!( + paths.get("/memos/{id}/detail").is_some(), + "Missing /memos/{{id}}/detail route in OpenAPI spec" + ); + + let memo_detail = &schemas["MemoDetailResponse"]; + assert_eq!( + memo_detail["properties"]["user"]["$ref"], + "#/components/schemas/UserSchema" + ); + assert_eq!( + memo_detail["properties"]["memoComments"]["items"]["$ref"], + "#/components/schemas/MemoCommentInMemoDetail" + ); + assert!( + schemas + .get("__VesperaMemoDetailResponseUserRelation") + .is_none(), + "Internal relation adapter should not appear in OpenAPI components" + ); +} + #[tokio::test] async fn test_openapi_contains_typed_form_schemas() { let openapi_content = std::fs::read_to_string("openapi.json").unwrap(); diff --git a/examples/axum-example/tests/snapshots/integration_test__openapi.snap b/examples/axum-example/tests/snapshots/integration_test__openapi.snap index b7d5c7b..f6739f8 100644 --- a/examples/axum-example/tests/snapshots/integration_test__openapi.snap +++ b/examples/axum-example/tests/snapshots/integration_test__openapi.snap @@ -1,5 +1,6 @@ --- source: examples/axum-example/tests/integration_test.rs +assertion_line: 413 expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" --- { @@ -1092,6 +1093,34 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" } } }, + "/memos/{id}/detail": { + "get": { + "operationId": "get_memo_detail", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemoDetailResponse" + } + } + } + } + } + } + }, "/memos/{id}/rel": { "get": { "operationId": "get_memo_rel", @@ -3041,6 +3070,27 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "age" ] }, + "MemoCommentInMemoDetail": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "id": { + "type": "integer", + "format": "int32" + }, + "memoId": { + "type": "integer", + "format": "int32" + } + }, + "required": [ + "id", + "memoId", + "content" + ] + }, "MemoCommentSchema": { "type": "object", "properties": { @@ -3088,6 +3138,60 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "memo" ] }, + "MemoDetailResponse": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "default": "1970-01-01T00:00:00+00:00" + }, + "id": { + "type": "integer", + "format": "int32", + "default": 0 + }, + "memoComments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MemoCommentInMemoDetail" + } + }, + "status": { + "$ref": "#/components/schemas/MemoStatus" + }, + "title": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "default": "1970-01-01T00:00:00+00:00" + }, + "user": { + "$ref": "#/components/schemas/UserSchema", + "nullable": true + }, + "userId": { + "type": "integer", + "format": "int32" + } + }, + "required": [ + "id", + "userId", + "title", + "content", + "status", + "createdAt", + "updatedAt", + "user", + "memoComments" + ] + }, "MemoResponse": { "type": "object", "properties": { @@ -3915,6 +4019,26 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "name" ] }, + "UserInMemoDetail": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "email", + "name" + ] + }, "UserItem": { "type": "object", "description": "Simple user representation", diff --git a/openapi.json b/openapi.json index df9656c..bc03522 100644 --- a/openapi.json +++ b/openapi.json @@ -1088,6 +1088,34 @@ } } }, + "/memos/{id}/detail": { + "get": { + "operationId": "get_memo_detail", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemoDetailResponse" + } + } + } + } + } + } + }, "/memos/{id}/rel": { "get": { "operationId": "get_memo_rel", @@ -3037,6 +3065,27 @@ "age" ] }, + "MemoCommentInMemoDetail": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "id": { + "type": "integer", + "format": "int32" + }, + "memoId": { + "type": "integer", + "format": "int32" + } + }, + "required": [ + "id", + "memoId", + "content" + ] + }, "MemoCommentSchema": { "type": "object", "properties": { @@ -3084,6 +3133,60 @@ "memo" ] }, + "MemoDetailResponse": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "default": "1970-01-01T00:00:00+00:00" + }, + "id": { + "type": "integer", + "format": "int32", + "default": 0 + }, + "memoComments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MemoCommentInMemoDetail" + } + }, + "status": { + "$ref": "#/components/schemas/MemoStatus" + }, + "title": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "default": "1970-01-01T00:00:00+00:00" + }, + "user": { + "$ref": "#/components/schemas/UserSchema", + "nullable": true + }, + "userId": { + "type": "integer", + "format": "int32" + } + }, + "required": [ + "id", + "userId", + "title", + "content", + "status", + "createdAt", + "updatedAt", + "user", + "memoComments" + ] + }, "MemoResponse": { "type": "object", "properties": { @@ -3911,6 +4014,26 @@ "name" ] }, + "UserInMemoDetail": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "email", + "name" + ] + }, "UserItem": { "type": "object", "description": "Simple user representation",