Skip to content

Commit f49d266

Browse files
authored
Merge pull request #125 from dev-five-git/fix-rel-issue
Fix rel issue
2 parents c4e1056 + b924a23 commit f49d266

15 files changed

Lines changed: 1473 additions & 8 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"changes":{"Cargo.toml":"Patch"},"note":"Fix rel issue","date":"2026-04-21T10:25:46.888127400Z"}

README.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,57 @@ vespera::schema_type!(Schema from Model, name = "MemoSchema");
441441

442442
**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.
443443

444+
### Same-File Relation Adapters
445+
446+
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.
447+
448+
Example:
449+
450+
```rust
451+
#[derive(Serialize, vespera::Schema)]
452+
#[serde(rename_all = "camelCase")]
453+
pub struct UserInArticle {
454+
pub id: Uuid,
455+
pub name: String,
456+
pub email: String,
457+
pub profile_image: Option<String>,
458+
}
459+
460+
#[derive(Serialize, vespera::Schema)]
461+
#[serde(rename_all = "camelCase")]
462+
pub struct CategoryInArticle {
463+
pub id: i64,
464+
pub name: String,
465+
pub parent_category_id: Option<i64>,
466+
pub is_active: bool,
467+
pub is_menu: bool,
468+
}
469+
470+
schema_type!(
471+
ArticleResponse from crate::models::article::Model,
472+
add = [("article_review_users": Vec<ArticleReviewUserInArticle>)]
473+
);
474+
475+
// Existing handler code stays valid.
476+
Ok(ArticleResponse {
477+
user: user.into(),
478+
category: category.into(),
479+
article_review_users,
480+
..
481+
})
482+
```
483+
484+
How it works:
485+
486+
- `schema_type!` looks for same-file DTOs named `{RelationNamePascal}In{ResponseBase}`
487+
- `user` on `ArticleResponse``UserInArticle`
488+
- `category` on `ArticleResponse``CategoryInArticle`
489+
- It generates local compile adapters so `Option<Model>.into()` works unchanged in the handler
490+
- Those adapters stay internal to Rust typing
491+
- OpenAPI does **not** expose the generated adapter wrapper names; the spec still points at the original related schemas (`UserSchema`, `CategorySchema`)
492+
493+
Use this when you want route-local response DTOs for single-value relations (`HasOne` / `BelongsTo`) without rewriting the route construction logic.
494+
444495
### Multipart Mode
445496

446497
Generate `Multipart` structs from existing types using the `multipart` keyword:

SKILL.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,55 @@ vespera::schema_type!(Schema from Model, name = "MemoSchema");
397397

398398
**Required Logic:** `required` is determined **solely by nullability** (`Option<T>`). Fields with `#[serde(default)]` or `#[serde(skip_serializing_if)]` are still `required` unless they are `Option<T>`.
399399

400+
### Same-File Relation Adapters
401+
402+
When a route file defines a local response DTO for a relation, Vespera can preserve unchanged handler code while still generating the right OpenAPI.
403+
404+
Example:
405+
406+
```rust
407+
#[derive(Serialize, vespera::Schema)]
408+
#[serde(rename_all = "camelCase")]
409+
pub struct UserInArticle {
410+
pub id: Uuid,
411+
pub name: String,
412+
pub email: String,
413+
pub profile_image: Option<String>,
414+
}
415+
416+
#[derive(Serialize, vespera::Schema)]
417+
#[serde(rename_all = "camelCase")]
418+
pub struct CategoryInArticle {
419+
pub id: i64,
420+
pub name: String,
421+
pub parent_category_id: Option<i64>,
422+
pub is_active: bool,
423+
pub is_menu: bool,
424+
}
425+
426+
schema_type!(
427+
ArticleResponse from crate::models::article::Model,
428+
add = [("article_review_users": Vec<ArticleReviewUserInArticle>)]
429+
);
430+
431+
Ok(ArticleResponse {
432+
user: user.into(),
433+
category: category.into(),
434+
article_review_users,
435+
..
436+
})
437+
```
438+
439+
Rules:
440+
441+
- Only applies to single-value relations (`HasOne` / `BelongsTo`)
442+
- The local DTO name must follow `{RelationNamePascal}In{ResponseBase}`
443+
- `user` on `ArticleResponse``UserInArticle`
444+
- `category` on `ArticleResponse``CategoryInArticle`
445+
- Vespera generates local compile adapters so `Option<Model>.into()` works without changing the route
446+
- The adapter wrapper is hidden from OpenAPI; the spec still references the original related schema (`UserSchema`, `CategorySchema`)
447+
- `HasMany` relations remain excluded by default unless explicitly `pick`ed or `add`ed
448+
400449
### Complete Example
401450

402451
```rust

crates/vespera_macro/src/parser/schema/serde_attrs.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,48 @@ pub fn extract_rename_all(attrs: &[syn::Attribute]) -> Option<String> {
158158
None
159159
}
160160

161+
/// Extract whether `#[serde(transparent)]` is present on a struct.
162+
pub fn extract_transparent(attrs: &[syn::Attribute]) -> bool {
163+
attrs.iter().any(|attr| {
164+
if !attr.path().is_ident("serde") {
165+
return false;
166+
}
167+
168+
let mut is_transparent = false;
169+
let _ = attr.parse_nested_meta(|meta| {
170+
if meta.path.is_ident("transparent") {
171+
is_transparent = true;
172+
}
173+
Ok(())
174+
});
175+
is_transparent
176+
})
177+
}
178+
179+
/// Extract `#[schema(ref = "Name", nullable)]` override from a struct.
180+
pub fn extract_schema_ref_override(attrs: &[syn::Attribute]) -> Option<(String, bool)> {
181+
attrs.iter().find_map(|attr| {
182+
if !attr.path().is_ident("schema") {
183+
return None;
184+
}
185+
186+
let mut ref_name = None;
187+
let mut nullable = false;
188+
let _ = attr.parse_nested_meta(|meta| {
189+
if meta.path.is_ident("ref") {
190+
let value = meta.value()?;
191+
let lit: syn::LitStr = value.parse()?;
192+
ref_name = Some(lit.value());
193+
} else if meta.path.is_ident("nullable") {
194+
nullable = true;
195+
}
196+
Ok(())
197+
});
198+
199+
ref_name.map(|name| (name, nullable))
200+
})
201+
}
202+
161203
pub fn extract_field_rename(attrs: &[syn::Attribute]) -> Option<String> {
162204
// First check serde attrs (higher priority)
163205
for attr in attrs {

crates/vespera_macro/src/parser/schema/struct_schema.rs

Lines changed: 123 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ use vespera_core::schema::{Schema, SchemaRef, SchemaType};
1111
use super::{
1212
serde_attrs::{
1313
extract_doc_comment, extract_field_rename, extract_flatten, extract_rename_all,
14-
extract_skip, rename_field, strip_raw_prefix_owned,
14+
extract_schema_ref_override, extract_skip, extract_transparent, rename_field,
15+
strip_raw_prefix_owned,
1516
},
1617
type_schema::parse_type_to_schema_ref,
1718
};
@@ -41,6 +42,45 @@ pub fn parse_struct_to_schema(
4142
// Extract struct-level doc comment for schema description
4243
let struct_description = extract_doc_comment(&struct_item.attrs);
4344

45+
if let Some((schema_name, nullable)) = extract_schema_ref_override(&struct_item.attrs) {
46+
return Schema {
47+
ref_path: Some(format!("#/components/schemas/{schema_name}")),
48+
nullable: nullable.then_some(true),
49+
description: struct_description,
50+
..Default::default()
51+
};
52+
}
53+
54+
// Transparent single-field wrappers should use the inner field schema directly.
55+
if extract_transparent(&struct_item.attrs) {
56+
let inner_field_ty = match &struct_item.fields {
57+
Fields::Named(fields_named) if fields_named.named.len() == 1 => {
58+
fields_named.named.first().map(|field| &field.ty)
59+
}
60+
Fields::Unnamed(fields_unnamed) if fields_unnamed.unnamed.len() == 1 => {
61+
fields_unnamed.unnamed.first().map(|field| &field.ty)
62+
}
63+
_ => None,
64+
};
65+
66+
if let Some(field_ty) = inner_field_ty {
67+
let schema_ref = parse_type_to_schema_ref(field_ty, known_schemas, struct_definitions);
68+
return match schema_ref {
69+
SchemaRef::Inline(mut schema) => {
70+
if schema.description.is_none() {
71+
schema.description = struct_description;
72+
}
73+
*schema
74+
}
75+
SchemaRef::Ref(reference) => Schema {
76+
description: struct_description,
77+
all_of: Some(vec![SchemaRef::Ref(reference)]),
78+
..Default::default()
79+
},
80+
};
81+
}
82+
}
83+
4484
// Extract rename_all attribute from struct
4585
let rename_all = extract_rename_all(&struct_item.attrs);
4686

@@ -245,6 +285,43 @@ mod tests {
245285
assert!(schema.required.is_none());
246286
}
247287

288+
#[test]
289+
fn test_parse_struct_to_schema_serde_transparent_named_wrapper_uses_inner_schema() {
290+
let struct_item: syn::ItemStruct = syn::parse_str(
291+
r"
292+
#[serde(transparent)]
293+
struct Wrapper {
294+
value: Box<String>,
295+
}
296+
",
297+
)
298+
.unwrap();
299+
300+
let schema = parse_struct_to_schema(&struct_item, &HashSet::new(), &HashMap::new());
301+
assert_eq!(schema.schema_type, Some(SchemaType::String));
302+
assert!(schema.properties.is_none());
303+
}
304+
305+
#[test]
306+
fn test_parse_struct_to_schema_schema_ref_override() {
307+
let struct_item: syn::ItemStruct = syn::parse_str(
308+
r#"
309+
#[schema(ref = "UserSchema", nullable)]
310+
struct Wrapper {
311+
value: Option<String>,
312+
}
313+
"#,
314+
)
315+
.unwrap();
316+
317+
let schema = parse_struct_to_schema(&struct_item, &HashSet::new(), &HashMap::new());
318+
assert_eq!(
319+
schema.ref_path.as_deref(),
320+
Some("#/components/schemas/UserSchema")
321+
);
322+
assert_eq!(schema.nullable, Some(true));
323+
}
324+
248325
// Test struct with skip field
249326
#[test]
250327
fn test_parse_struct_to_schema_with_skip_field() {
@@ -475,4 +552,49 @@ mod tests {
475552
);
476553
assert!(schema.properties.is_some());
477554
}
555+
556+
#[test]
557+
fn test_parse_struct_to_schema_transparent_tuple_wrapper_uses_ref_schema() {
558+
let struct_item: syn::ItemStruct = syn::parse_str(
559+
r"
560+
#[serde(transparent)]
561+
struct Wrapper(User);
562+
",
563+
)
564+
.unwrap();
565+
566+
let mut struct_defs = HashMap::new();
567+
struct_defs.insert("User".to_string(), "struct User { id: i32 }".to_string());
568+
let mut known = HashSet::new();
569+
known.insert("User".to_string());
570+
571+
let schema = parse_struct_to_schema(&struct_item, &known, &struct_defs);
572+
assert!(schema.all_of.is_some());
573+
let all_of = schema.all_of.unwrap();
574+
assert_eq!(all_of.len(), 1);
575+
match &all_of[0] {
576+
SchemaRef::Ref(reference) => {
577+
assert_eq!(reference.ref_path, "#/components/schemas/User");
578+
}
579+
SchemaRef::Inline(_) => {
580+
panic!("expected $ref wrapper for transparent tuple known schema")
581+
}
582+
}
583+
}
584+
585+
#[test]
586+
fn test_parse_struct_to_schema_transparent_multi_field_tuple_falls_back() {
587+
let struct_item: syn::ItemStruct = syn::parse_str(
588+
r"
589+
#[serde(transparent)]
590+
struct Wrapper(String, String);
591+
",
592+
)
593+
.unwrap();
594+
595+
let schema = parse_struct_to_schema(&struct_item, &HashSet::new(), &HashMap::new());
596+
assert_eq!(schema.schema_type, Some(SchemaType::Object));
597+
assert!(schema.properties.is_none());
598+
assert!(schema.all_of.is_none());
599+
}
478600
}

crates/vespera_macro/src/parser/schema/type_schema.rs

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ thread_local! {
2121

2222
use super::{
2323
generics::substitute_type,
24-
serde_attrs::{capitalize_first, extract_schema_name_from_entity},
24+
serde_attrs::{capitalize_first, extract_schema_name_from_entity, extract_schema_ref_override},
2525
struct_schema::parse_struct_to_schema,
2626
};
2727

@@ -317,6 +317,19 @@ fn parse_type_impl(
317317
};
318318

319319
if known_schemas.contains(&resolved_name) {
320+
if let Some(def) = struct_definitions.get(&resolved_name)
321+
&& let Ok(parsed_struct) = syn::parse_str::<syn::ItemStruct>(def)
322+
&& let Some((schema_name, nullable)) =
323+
extract_schema_ref_override(&parsed_struct.attrs)
324+
{
325+
return SchemaRef::Inline(Box::new(Schema {
326+
ref_path: Some(format!("#/components/schemas/{schema_name}")),
327+
schema_type: None,
328+
nullable: nullable.then_some(true),
329+
..Schema::new(SchemaType::Object)
330+
}));
331+
}
332+
320333
// Check if this is a generic type with type parameters
321334
if let syn::PathArguments::AngleBracketed(args) = &segment.arguments {
322335
// This is a concrete generic type like GenericStruct<String>
@@ -1457,4 +1470,36 @@ mod tests {
14571470
let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new());
14581471
assert!(matches!(schema_ref, SchemaRef::Inline(_)));
14591472
}
1473+
1474+
#[test]
1475+
fn test_known_schema_ref_override_returns_inline_ref_schema() {
1476+
let mut known = HashSet::new();
1477+
known.insert("UserSchema".to_string());
1478+
1479+
let mut defs = HashMap::new();
1480+
defs.insert(
1481+
"UserSchema".to_string(),
1482+
r#"
1483+
#[schema(ref = "ExternalUser", nullable)]
1484+
struct UserSchema {
1485+
id: i32,
1486+
}
1487+
"#
1488+
.to_string(),
1489+
);
1490+
1491+
let ty: Type = syn::parse_str("UserSchema").unwrap();
1492+
let schema_ref = parse_type_to_schema_ref(&ty, &known, &defs);
1493+
1494+
match schema_ref {
1495+
SchemaRef::Inline(schema) => {
1496+
assert_eq!(
1497+
schema.ref_path.as_deref(),
1498+
Some("#/components/schemas/ExternalUser")
1499+
);
1500+
assert_eq!(schema.nullable, Some(true));
1501+
}
1502+
SchemaRef::Ref(_) => panic!("expected inline schema ref override"),
1503+
}
1504+
}
14601505
}

0 commit comments

Comments
 (0)