Skip to content

Commit c76c2ee

Browse files
committed
Fix rel issue
1 parent c4e1056 commit c76c2ee

13 files changed

Lines changed: 1101 additions & 3 deletions

File tree

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: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +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_schema_ref_override,
15+
extract_transparent,
1416
extract_skip, rename_field, strip_raw_prefix_owned,
1517
},
1618
type_schema::parse_type_to_schema_ref,
@@ -41,6 +43,45 @@ pub fn parse_struct_to_schema(
4143
// Extract struct-level doc comment for schema description
4244
let struct_description = extract_doc_comment(&struct_item.attrs);
4345

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

@@ -245,6 +286,40 @@ mod tests {
245286
assert!(schema.required.is_none());
246287
}
247288

289+
#[test]
290+
fn test_parse_struct_to_schema_serde_transparent_named_wrapper_uses_inner_schema() {
291+
let struct_item: syn::ItemStruct = syn::parse_str(
292+
r#"
293+
#[serde(transparent)]
294+
struct Wrapper {
295+
value: Box<String>,
296+
}
297+
"#,
298+
)
299+
.unwrap();
300+
301+
let schema = parse_struct_to_schema(&struct_item, &HashSet::new(), &HashMap::new());
302+
assert_eq!(schema.schema_type, Some(SchemaType::String));
303+
assert!(schema.properties.is_none());
304+
}
305+
306+
#[test]
307+
fn test_parse_struct_to_schema_schema_ref_override() {
308+
let struct_item: syn::ItemStruct = syn::parse_str(
309+
r#"
310+
#[schema(ref = "UserSchema", nullable)]
311+
struct Wrapper {
312+
value: Option<String>,
313+
}
314+
"#,
315+
)
316+
.unwrap();
317+
318+
let schema = parse_struct_to_schema(&struct_item, &HashSet::new(), &HashMap::new());
319+
assert_eq!(schema.ref_path.as_deref(), Some("#/components/schemas/UserSchema"));
320+
assert_eq!(schema.nullable, Some(true));
321+
}
322+
248323
// Test struct with skip field
249324
#[test]
250325
fn test_parse_struct_to_schema_with_skip_field() {

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

Lines changed: 14 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>

crates/vespera_macro/src/schema_impl.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,26 @@ pub fn process_derive_schema(
8181

8282
// Schema-derived types appear in OpenAPI spec (include_in_openapi: true)
8383
let mut metadata = StructMetadata::new(schema_name, quote::quote!(#input).to_string());
84+
if input.attrs.iter().any(|attr| attr.path().is_ident("schema")) {
85+
let mut has_ref_override = false;
86+
for attr in &input.attrs {
87+
if !attr.path().is_ident("schema") {
88+
continue;
89+
}
90+
let _ = attr.parse_nested_meta(|meta| {
91+
if meta.path.is_ident("ref") {
92+
has_ref_override = true;
93+
}
94+
Ok(())
95+
});
96+
if has_ref_override {
97+
break;
98+
}
99+
}
100+
if has_ref_override {
101+
metadata.include_in_openapi = false;
102+
}
103+
}
84104
metadata.field_defaults = field_defaults;
85105
(metadata, proc_macro2::TokenStream::new())
86106
}

0 commit comments

Comments
 (0)