Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changepacks/changepack_log_8A_RBxjcC8HHy6dQ0mbNa.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"changes":{"Cargo.toml":"Patch"},"note":"Fix rel issue","date":"2026-04-21T10:25:46.888127400Z"}
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
}

#[derive(Serialize, vespera::Schema)]
#[serde(rename_all = "camelCase")]
pub struct CategoryInArticle {
pub id: i64,
pub name: String,
pub parent_category_id: Option<i64>,
pub is_active: bool,
pub is_menu: bool,
}

schema_type!(
ArticleResponse from crate::models::article::Model,
add = [("article_review_users": Vec<ArticleReviewUserInArticle>)]
);

// 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<Model>.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:
Expand Down
49 changes: 49 additions & 0 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,55 @@ vespera::schema_type!(Schema from Model, name = "MemoSchema");

**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>`.

### 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<String>,
}

#[derive(Serialize, vespera::Schema)]
#[serde(rename_all = "camelCase")]
pub struct CategoryInArticle {
pub id: i64,
pub name: String,
pub parent_category_id: Option<i64>,
pub is_active: bool,
pub is_menu: bool,
}

schema_type!(
ArticleResponse from crate::models::article::Model,
add = [("article_review_users": Vec<ArticleReviewUserInArticle>)]
);

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<Model>.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
Expand Down
42 changes: 42 additions & 0 deletions crates/vespera_macro/src/parser/schema/serde_attrs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,48 @@ pub fn extract_rename_all(attrs: &[syn::Attribute]) -> Option<String> {
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<String> {
// First check serde attrs (higher priority)
for attr in attrs {
Expand Down
124 changes: 123 additions & 1 deletion crates/vespera_macro/src/parser/schema/struct_schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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<String>,
}
",
)
.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<String>,
}
"#,
)
.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() {
Expand Down Expand Up @@ -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());
}
}
47 changes: 46 additions & 1 deletion crates/vespera_macro/src/parser/schema/type_schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down Expand Up @@ -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::<syn::ItemStruct>(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<String>
Expand Down Expand Up @@ -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"),
}
}
}
Loading