Skip to content

Commit e9a9f63

Browse files
committed
nested generic
1 parent ff878c4 commit e9a9f63

6 files changed

Lines changed: 502 additions & 29 deletions

File tree

Cargo.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/vespera_macro/src/parser/schema.rs

Lines changed: 101 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -699,35 +699,111 @@ pub fn parse_struct_to_schema(
699699
}
700700

701701
fn substitute_type(ty: &Type, generic_params: &[String], concrete_types: &[&Type]) -> Type {
702-
// Check if this is a generic parameter
703-
if let Type::Path(type_path) = ty
704-
&& let Some(segment) = type_path.path.segments.last()
705-
{
706-
let ident_str = segment.ident.to_string();
707-
if generic_params.contains(&ident_str) && segment.arguments.is_none() {
708-
// Find the index and substitute
709-
if let Some(index) = generic_params.iter().position(|p| p == &ident_str)
710-
&& let Some(concrete_ty) = concrete_types.get(index)
711-
{
712-
return (*concrete_ty).clone();
702+
match ty {
703+
Type::Path(type_path) => {
704+
let path = &type_path.path;
705+
if path.segments.is_empty() {
706+
return ty.clone();
713707
}
714-
}
715-
}
716708

717-
// For complex types, use quote! to regenerate with substitutions
718-
let tokens = quote::quote! { #ty };
719-
let mut new_tokens = tokens.to_string();
709+
// Check if this is a direct generic parameter (e.g., just "T" with no arguments)
710+
if path.segments.len() == 1 {
711+
let segment = &path.segments[0];
712+
let ident_str = segment.ident.to_string();
713+
714+
if let syn::PathArguments::None = &segment.arguments {
715+
// Direct generic parameter substitution
716+
if let Some(index) = generic_params.iter().position(|p| p == &ident_str) {
717+
if let Some(concrete_ty) = concrete_types.get(index) {
718+
return (*concrete_ty).clone();
719+
}
720+
}
721+
}
722+
}
720723

721-
// Replace generic parameter names with concrete types
722-
for (param, concrete_ty) in generic_params.iter().zip(concrete_types.iter()) {
723-
// Replace standalone generic parameter (not part of another identifier)
724-
let pattern = format!(r"\b{}\b", param);
725-
let replacement = quote::quote! { #concrete_ty }.to_string();
726-
new_tokens = new_tokens.replace(&pattern, &replacement);
727-
}
724+
// For types with generic arguments (e.g., Vec<T>, Option<T>, HashMap<K, V>),
725+
// recursively substitute the type arguments
726+
let mut new_segments = syn::punctuated::Punctuated::new();
727+
for segment in &path.segments {
728+
let new_arguments = match &segment.arguments {
729+
syn::PathArguments::AngleBracketed(args) => {
730+
let mut new_args = syn::punctuated::Punctuated::new();
731+
for arg in &args.args {
732+
let new_arg = match arg {
733+
syn::GenericArgument::Type(inner_ty) => {
734+
syn::GenericArgument::Type(substitute_type(
735+
inner_ty,
736+
generic_params,
737+
concrete_types,
738+
))
739+
}
740+
other => other.clone(),
741+
};
742+
new_args.push(new_arg);
743+
}
744+
syn::PathArguments::AngleBracketed(syn::AngleBracketedGenericArguments {
745+
colon2_token: args.colon2_token,
746+
lt_token: args.lt_token,
747+
args: new_args,
748+
gt_token: args.gt_token,
749+
})
750+
}
751+
other => other.clone(),
752+
};
728753

729-
// Parse the substituted type
730-
syn::parse_str::<Type>(&new_tokens).unwrap_or_else(|_| ty.clone())
754+
new_segments.push(syn::PathSegment {
755+
ident: segment.ident.clone(),
756+
arguments: new_arguments,
757+
});
758+
}
759+
760+
Type::Path(syn::TypePath {
761+
qself: type_path.qself.clone(),
762+
path: syn::Path {
763+
leading_colon: path.leading_colon,
764+
segments: new_segments,
765+
},
766+
})
767+
}
768+
Type::Reference(type_ref) => {
769+
// Handle &T, &mut T
770+
Type::Reference(syn::TypeReference {
771+
and_token: type_ref.and_token,
772+
lifetime: type_ref.lifetime.clone(),
773+
mutability: type_ref.mutability,
774+
elem: Box::new(substitute_type(&type_ref.elem, generic_params, concrete_types)),
775+
})
776+
}
777+
Type::Slice(type_slice) => {
778+
// Handle [T]
779+
Type::Slice(syn::TypeSlice {
780+
bracket_token: type_slice.bracket_token,
781+
elem: Box::new(substitute_type(&type_slice.elem, generic_params, concrete_types)),
782+
})
783+
}
784+
Type::Array(type_array) => {
785+
// Handle [T; N]
786+
Type::Array(syn::TypeArray {
787+
bracket_token: type_array.bracket_token,
788+
elem: Box::new(substitute_type(&type_array.elem, generic_params, concrete_types)),
789+
semi_token: type_array.semi_token,
790+
len: type_array.len.clone(),
791+
})
792+
}
793+
Type::Tuple(type_tuple) => {
794+
// Handle (T1, T2, ...)
795+
let new_elems = type_tuple
796+
.elems
797+
.iter()
798+
.map(|elem| substitute_type(elem, generic_params, concrete_types))
799+
.collect();
800+
Type::Tuple(syn::TypeTuple {
801+
paren_token: type_tuple.paren_token,
802+
elems: new_elems,
803+
})
804+
}
805+
_ => ty.clone(),
806+
}
731807
}
732808

733809
pub(super) fn is_primitive_type(ty: &Type) -> bool {

examples/axum-example/openapi.json

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,56 @@
471471
}
472472
}
473473
},
474+
"/generic/generic5": {
475+
"get": {
476+
"operationId": "generic_endpoint5",
477+
"responses": {
478+
"200": {
479+
"description": "Successful response",
480+
"content": {
481+
"application/json": {
482+
"schema": {
483+
"type": "object",
484+
"properties": {
485+
"items": {
486+
"type": "array",
487+
"items": {
488+
"$ref": "#/components/schemas/ContactResponse"
489+
}
490+
},
491+
"page": {
492+
"type": "integer"
493+
},
494+
"size": {
495+
"type": "integer"
496+
},
497+
"totalPage": {
498+
"type": "integer"
499+
}
500+
},
501+
"required": [
502+
"items",
503+
"page",
504+
"size",
505+
"totalPage"
506+
]
507+
}
508+
}
509+
}
510+
},
511+
"400": {
512+
"description": "Error response",
513+
"content": {
514+
"application/json": {
515+
"schema": {
516+
"type": "string"
517+
}
518+
}
519+
}
520+
}
521+
}
522+
}
523+
},
474524
"/health": {
475525
"get": {
476526
"operationId": "health",
@@ -1257,6 +1307,49 @@
12571307
"nestedStructMapArray"
12581308
]
12591309
},
1310+
"ContactResponse": {
1311+
"type": "object",
1312+
"properties": {
1313+
"adminReply": {
1314+
"type": "string",
1315+
"nullable": true
1316+
},
1317+
"category": {
1318+
"type": "string",
1319+
"nullable": true
1320+
},
1321+
"content": {
1322+
"type": "string"
1323+
},
1324+
"createdAt": {
1325+
"type": "string"
1326+
},
1327+
"id": {
1328+
"type": "integer"
1329+
},
1330+
"repliedAt": {
1331+
"type": "string",
1332+
"nullable": true
1333+
},
1334+
"title": {
1335+
"type": "string"
1336+
},
1337+
"updatedAt": {
1338+
"type": "string",
1339+
"nullable": true
1340+
},
1341+
"userId": {
1342+
"type": "integer"
1343+
}
1344+
},
1345+
"required": [
1346+
"id",
1347+
"userId",
1348+
"title",
1349+
"content",
1350+
"createdAt"
1351+
]
1352+
},
12601353
"CreateUserRequest": {
12611354
"type": "object",
12621355
"properties": {
@@ -1591,6 +1684,32 @@
15911684
"age"
15921685
]
15931686
},
1687+
"PaginatedResponse": {
1688+
"type": "object",
1689+
"properties": {
1690+
"items": {
1691+
"type": "array",
1692+
"items": {
1693+
"type": "object"
1694+
}
1695+
},
1696+
"page": {
1697+
"type": "integer"
1698+
},
1699+
"size": {
1700+
"type": "integer"
1701+
},
1702+
"totalPage": {
1703+
"type": "integer"
1704+
}
1705+
},
1706+
"required": [
1707+
"items",
1708+
"page",
1709+
"size",
1710+
"totalPage"
1711+
]
1712+
},
15941713
"SignupRequest": {
15951714
"type": "object",
15961715
"properties": {

examples/axum-example/src/routes/generic.rs

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use serde::Serialize;
2-
use vespera::axum::Json;
2+
use vespera::{Schema, axum::Json};
33

44
use crate::TestStruct;
55

@@ -57,3 +57,43 @@ pub async fn generic_endpoint4() -> Json<GenericStruct2<bool, bool>> {
5757
name: "John Doe".to_string(),
5858
})
5959
}
60+
#[derive(Debug, Serialize, Schema)]
61+
#[serde(rename_all = "camelCase")]
62+
pub struct ContactResponse {
63+
pub id: i64,
64+
pub user_id: i64,
65+
pub category: Option<String>,
66+
pub title: String,
67+
pub content: String,
68+
pub admin_reply: Option<String>,
69+
pub replied_at: Option<String>,
70+
pub created_at: String,
71+
pub updated_at: Option<String>,
72+
}
73+
#[derive(Debug, Serialize, Schema)]
74+
#[serde(rename_all = "camelCase")]
75+
pub struct PaginatedResponse<T: Serialize> {
76+
pub items: Vec<T>,
77+
pub page: i32,
78+
pub size: i32,
79+
pub total_page: i32,
80+
}
81+
#[vespera::route(get, path = "/generic5")]
82+
pub async fn generic_endpoint5() -> Result<Json<PaginatedResponse<ContactResponse>>, (vespera::axum::http::StatusCode, String)> {
83+
Ok(Json(PaginatedResponse {
84+
items: vec![ContactResponse {
85+
id: 1,
86+
user_id: 1,
87+
category: Some("test".to_string()),
88+
title: "test".to_string(),
89+
content: "test".to_string(),
90+
admin_reply: None,
91+
replied_at: None,
92+
created_at: "2021-01-01".to_string(),
93+
updated_at: None,
94+
}],
95+
page: 1,
96+
size: 10,
97+
total_page: 1,
98+
}))
99+
}

0 commit comments

Comments
 (0)