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_X9THHaPHgqrHfTl_qknWR.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"changes":{"crates/vespera_core/Cargo.toml":"Patch","crates/vespera_macro/Cargo.toml":"Patch","crates/vespera/Cargo.toml":"Patch"},"note":"Support serde rename","date":"2025-12-01T09:40:49.467853200Z"}
36 changes: 34 additions & 2 deletions crates/vespera_macro/src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,33 @@ fn extract_rename_all(attrs: &[syn::Attribute]) -> Option<String> {
None
}

/// Extract rename attribute from field attributes
/// Handles #[serde(rename = "newName")]
fn extract_field_rename(attrs: &[syn::Attribute]) -> Option<String> {
for attr in attrs {
if attr.path().is_ident("serde") {
// Try to parse as Meta::List first
if let syn::Meta::List(meta_list) = &attr.meta {
let tokens = meta_list.tokens.to_string();

// Look for rename = "..." pattern
if let Some(start) = tokens.find("rename") {
let remaining = &tokens[start + "rename".len()..];
if let Some(equals_pos) = remaining.find('=') {
let value_part = &remaining[equals_pos + 1..].trim();
// Extract string value (remove quotes)
if value_part.starts_with('"') && value_part.ends_with('"') {
let value = &value_part[1..value_part.len() - 1];
return Some(value.to_string());
}
}
}
}
}
}
None
}

/// Convert field name according to rename_all rule
fn rename_field(field_name: &str, rename_all: Option<&str>) -> String {
match rename_all {
Expand Down Expand Up @@ -282,8 +309,13 @@ pub fn parse_struct_to_schema(
.map(|i| i.to_string())
.unwrap_or_else(|| "unknown".to_string());

// Apply rename_all transformation if present
let field_name = rename_field(&rust_field_name, rename_all.as_deref());
// Check for field-level rename attribute first (takes precedence)
let field_name = if let Some(renamed) = extract_field_rename(&field.attrs) {
renamed
} else {
// Apply rename_all transformation if present
rename_field(&rust_field_name, rename_all.as_deref())
};

let field_type = &field.ty;
let schema_ref = parse_type_to_schema_ref(field_type, known_schemas);
Expand Down
1 change: 0 additions & 1 deletion crates/vespera_macro/src/route/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
mod utils;

pub use utils::*;

95 changes: 95 additions & 0 deletions examples/axum-example/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,43 @@
}
}
},
"/foo/foo": {
"post": {
"operationId": "signup",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SignupRequest"
}
}
}
},
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SignupResponse"
}
}
}
},
"400": {
"description": "Error response",
"content": {
"application/json": {
"schema": {
"type": "string"
}
}
}
}
}
}
},
"/health": {
"get": {
"operationId": "health",
Expand Down Expand Up @@ -606,6 +643,64 @@
"code"
]
},
"SignupRequest": {
"type": "object",
"properties": {
"email": {
"type": "string"
},
"password": {
"type": "string"
}
},
"required": [
"email",
"password"
]
},
"SignupResponse": {
"type": "object",
"properties": {
"birthday": {
"type": "string",
"nullable": true
},
"createdAt": {
"type": "string"
},
"email": {
"type": "string"
},
"gender": {
"type": "string",
"nullable": true
},
"id": {
"type": "integer"
},
"job": {
"type": "string",
"nullable": true
},
"name": {
"type": "string"
},
"nickname": {
"type": "string",
"nullable": true
},
"phoneNumber23": {
"type": "string"
}
},
"required": [
"id",
"email",
"name",
"phoneNumber23",
"createdAt"
]
},
"StructBody": {
"type": "object",
"properties": {
Expand Down
10 changes: 9 additions & 1 deletion examples/axum-example/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
mod routes;

use std::sync::Arc;

use vespera::{axum, vespera};

/// Create the application router for testing
pub fn create_app() -> axum::Router {
vespera!()
vespera!().with_state(Arc::new(AppState {
config: "test".to_string(),
}))
}

pub struct AppState {
pub config: String,
}
50 changes: 50 additions & 0 deletions examples/axum-example/src/routes/foo/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use vespera::{
Schema,
axum::{Json, extract::State},
};

use crate::AppState;

#[derive(Serialize, Deserialize, Schema)]
pub struct SignupRequest {
pub email: String,
pub password: String,
}

#[derive(Serialize, Deserialize, Clone, Schema)]
#[serde(rename_all = "camelCase")]
pub struct SignupResponse {
pub id: i32,
pub email: String,
pub name: String,
#[serde(rename = "phoneNumber23")]
pub phone_number: String,
pub nickname: Option<String>,
pub birthday: Option<String>,
pub gender: Option<String>,
pub job: Option<String>,
#[serde(rename = "createdAt")]
pub created_at: String,
}

#[vespera::route(post, path = "/foo")]
pub async fn signup(
State(app_state): State<Arc<AppState>>,
Json(request): Json<SignupRequest>,
) -> Result<Json<SignupResponse>, String> {
println!("app_state: {:?}", app_state.config);
let response = SignupResponse {
id: 1,
email: request.email,
name: "John Doe".to_string(),
phone_number: "1234567890".to_string(),
nickname: Some("John".to_string()),
birthday: Some("1990-01-01".to_string()),
gender: Some("male".to_string()),
job: Some("engineer".to_string()),
created_at: "2021-01-01".to_string(),
};
Ok(Json(response))
}
1 change: 1 addition & 0 deletions examples/axum-example/src/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use vespera::{
};

pub mod error;
pub mod foo;
pub mod health;
pub mod path;
pub mod users;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,43 @@ expression: openapi
}
}
},
"/foo/foo": {
"post": {
"operationId": "signup",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SignupRequest"
}
}
}
},
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SignupResponse"
}
}
}
},
"400": {
"description": "Error response",
"content": {
"application/json": {
"schema": {
"type": "string"
}
}
}
}
}
}
},
"/health": {
"get": {
"operationId": "health",
Expand Down Expand Up @@ -610,6 +647,64 @@ expression: openapi
"code"
]
},
"SignupRequest": {
"type": "object",
"properties": {
"email": {
"type": "string"
},
"password": {
"type": "string"
}
},
"required": [
"email",
"password"
]
},
"SignupResponse": {
"type": "object",
"properties": {
"birthday": {
"type": "string",
"nullable": true
},
"createdAt": {
"type": "string"
},
"email": {
"type": "string"
},
"gender": {
"type": "string",
"nullable": true
},
"id": {
"type": "integer"
},
"job": {
"type": "string",
"nullable": true
},
"name": {
"type": "string"
},
"nickname": {
"type": "string",
"nullable": true
},
"phoneNumber23": {
"type": "string"
}
},
"required": [
"id",
"email",
"name",
"phoneNumber23",
"createdAt"
]
},
"StructBody": {
"type": "object",
"properties": {
Expand Down