Skip to content

Commit 5b69988

Browse files
committed
Support cookie
1 parent ee9bc1f commit 5b69988

8 files changed

Lines changed: 398 additions & 36 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/is_keyword_type.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use syn::{Type, TypePath};
33
pub enum KeywordType {
44
HeaderMap,
55
StatusCode,
6+
CookieJar,
67
Result,
78
}
89

@@ -11,6 +12,7 @@ impl KeywordType {
1112
match self {
1213
Self::HeaderMap => "HeaderMap",
1314
Self::StatusCode => "StatusCode",
15+
Self::CookieJar => "CookieJar",
1416
Self::Result => "Result",
1517
}
1618
}
@@ -42,9 +44,11 @@ mod tests {
4244
#[rstest]
4345
#[case("HeaderMap", KeywordType::HeaderMap, true)]
4446
#[case("StatusCode", KeywordType::StatusCode, true)]
47+
#[case("CookieJar", KeywordType::CookieJar, true)]
4548
#[case("String", KeywordType::HeaderMap, false)]
4649
#[case("axum::http::HeaderMap", KeywordType::HeaderMap, true)]
4750
#[case("axum::http::StatusCode", KeywordType::StatusCode, true)]
51+
#[case("axum_extra::extract::cookie::CookieJar", KeywordType::CookieJar, true)]
4852
#[case("Result", KeywordType::Result, true)]
4953
#[case("Result<String, String>", KeywordType::Result, true)]
5054
#[case("!", KeywordType::Result, false)]

crates/vespera_macro/src/parser/response.rs

Lines changed: 137 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -77,12 +77,28 @@ fn extract_status_code_tuple(err_ty: &Type) -> Option<(u16, Type)> {
7777
}
7878
}
7979

80+
/// Check if a type is a non-body response type (metadata only).
81+
/// These types contribute to the HTTP response (status, headers, cookies)
82+
/// but do not form the response body.
83+
fn is_non_body_type(ty: &Type) -> bool {
84+
is_keyword_type(ty, &KeywordType::StatusCode)
85+
|| is_keyword_type(ty, &KeywordType::HeaderMap)
86+
|| is_keyword_type(ty, &KeywordType::CookieJar)
87+
}
88+
8089
/// Extract payload type from an Ok tuple and track if headers exist.
81-
/// The last element of the tuple is always treated as the response body.
90+
/// Non-body types (`StatusCode`, `HeaderMap`, `CookieJar`) are filtered out.
91+
/// The last remaining element is treated as the response body.
8292
/// Any presence of `HeaderMap` in the tuple marks headers as present.
8393
fn extract_ok_payload_and_headers(ok_ty: &Type) -> (Type, Option<HashMap<String, Header>>) {
8494
if let Type::Tuple(tuple) = ok_ty {
85-
let payload_ty = tuple.elems.last().map(|ty| unwrap_json(ty).clone());
95+
// Find the body type: last element that is NOT a non-body type
96+
let payload_ty = tuple
97+
.elems
98+
.iter()
99+
.rev()
100+
.find(|ty| !is_non_body_type(ty))
101+
.map(|ty| unwrap_json(ty).clone());
86102

87103
if let Some(payload_ty) = payload_ty {
88104
let headers = if tuple
@@ -127,27 +143,34 @@ pub fn parse_return_type(
127143
if let Some((ok_ty, err_ty)) = extract_result_types(ty) {
128144
// Handle success response (200)
129145
let (ok_payload_ty, ok_headers) = extract_ok_payload_and_headers(&ok_ty);
130-
let ok_schema = parse_type_to_schema_ref_with_schemas(
131-
&ok_payload_ty,
132-
known_schemas,
133-
struct_definitions,
134-
);
135-
let mut ok_content = BTreeMap::new();
136-
ok_content.insert(
137-
"application/json".to_string(),
138-
MediaType {
139-
schema: Some(ok_schema),
140-
example: None,
141-
examples: None,
142-
},
143-
);
146+
147+
// StatusCode alone means no response body — just the HTTP status code
148+
let ok_content = if is_keyword_type(&ok_payload_ty, &KeywordType::StatusCode) {
149+
None
150+
} else {
151+
let ok_schema = parse_type_to_schema_ref_with_schemas(
152+
&ok_payload_ty,
153+
known_schemas,
154+
struct_definitions,
155+
);
156+
let mut content = BTreeMap::new();
157+
content.insert(
158+
"application/json".to_string(),
159+
MediaType {
160+
schema: Some(ok_schema),
161+
example: None,
162+
examples: None,
163+
},
164+
);
165+
Some(content)
166+
};
144167

145168
responses.insert(
146169
"200".to_string(),
147170
Response {
148171
description: "Successful response".to_string(),
149172
headers: ok_headers,
150-
content: Some(ok_content),
173+
content: ok_content,
151174
},
152175
);
153176

@@ -210,27 +233,34 @@ pub fn parse_return_type(
210233
// Not a Result type - regular response
211234
// Unwrap Json<T> if present
212235
let unwrapped_ty = unwrap_json(ty);
213-
let schema = parse_type_to_schema_ref_with_schemas(
214-
unwrapped_ty,
215-
known_schemas,
216-
struct_definitions,
217-
);
218-
let mut content = BTreeMap::new();
219-
content.insert(
220-
"application/json".to_string(),
221-
MediaType {
222-
schema: Some(schema),
223-
example: None,
224-
examples: None,
225-
},
226-
);
236+
237+
// StatusCode alone means no response body
238+
let content = if is_keyword_type(unwrapped_ty, &KeywordType::StatusCode) {
239+
None
240+
} else {
241+
let schema = parse_type_to_schema_ref_with_schemas(
242+
unwrapped_ty,
243+
known_schemas,
244+
struct_definitions,
245+
);
246+
let mut c = BTreeMap::new();
247+
c.insert(
248+
"application/json".to_string(),
249+
MediaType {
250+
schema: Some(schema),
251+
example: None,
252+
examples: None,
253+
},
254+
);
255+
Some(c)
256+
};
227257

228258
responses.insert(
229259
"200".to_string(),
230260
Response {
231261
description: "Successful response".to_string(),
232262
headers: None,
233-
content: Some(content),
263+
content,
234264
},
235265
);
236266
}
@@ -381,6 +411,27 @@ mod tests {
381411
Some(ExpectedResponse { status: "400", schema: ExpectedSchema { schema_type: SchemaType::Integer, nullable: false, items_schema_type: None } }),
382412
None
383413
)]
414+
// StatusCode as the sole Ok response type → no content (empty body)
415+
#[case(
416+
"-> Result<StatusCode, (StatusCode, String)>",
417+
None,
418+
Some(ExpectedResponse { status: "400", schema: ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None } }),
419+
None
420+
)]
421+
// CookieJar in Ok tuple → body is Json<String>, CookieJar filtered out
422+
#[case(
423+
"-> Result<(CookieJar, Json<String>), (StatusCode, String)>",
424+
Some(ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None }),
425+
Some(ExpectedResponse { status: "400", schema: ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None } }),
426+
None
427+
)]
428+
// CookieJar + StatusCode in Ok tuple → body is last non-metadata element
429+
#[case(
430+
"-> Result<(StatusCode, CookieJar, Json<i32>), String>",
431+
Some(ExpectedSchema { schema_type: SchemaType::Integer, nullable: false, items_schema_type: None }),
432+
Some(ExpectedResponse { status: "400", schema: ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None } }),
433+
None
434+
)]
384435
fn test_parse_return_type(
385436
#[case] return_type_str: &str,
386437
#[case] ok_expectation: Option<ExpectedSchema>,
@@ -610,4 +661,58 @@ mod tests {
610661
// Headers should be None
611662
assert!(ok_response.headers.is_none());
612663
}
664+
665+
// ======== CookieJar tuple extraction tests ========
666+
667+
#[test]
668+
fn test_extract_ok_payload_and_headers_cookie_jar_tuple() {
669+
// (CookieJar, Json<String>) → payload should be String, CookieJar filtered
670+
let ty: syn::Type = syn::parse_str("(CookieJar, Json<String>)").unwrap();
671+
let (payload, headers) = extract_ok_payload_and_headers(&ty);
672+
673+
if let syn::Type::Path(type_path) = &payload {
674+
assert_eq!(
675+
type_path.path.segments.last().unwrap().ident.to_string(),
676+
"String"
677+
);
678+
} else {
679+
panic!("Expected Path type for payload");
680+
}
681+
assert!(headers.is_none());
682+
}
683+
684+
#[test]
685+
fn test_extract_ok_payload_and_headers_cookie_jar_with_status_code() {
686+
// (StatusCode, CookieJar, Json<i32>) → payload should be i32
687+
let ty: syn::Type = syn::parse_str("(StatusCode, CookieJar, Json<i32>)").unwrap();
688+
let (payload, headers) = extract_ok_payload_and_headers(&ty);
689+
690+
if let syn::Type::Path(type_path) = &payload {
691+
assert_eq!(
692+
type_path.path.segments.last().unwrap().ident.to_string(),
693+
"i32"
694+
);
695+
} else {
696+
panic!("Expected Path type for payload");
697+
}
698+
assert!(headers.is_none());
699+
}
700+
701+
#[test]
702+
fn test_is_non_body_type() {
703+
let status: syn::Type = syn::parse_str("StatusCode").unwrap();
704+
assert!(is_non_body_type(&status));
705+
706+
let header_map: syn::Type = syn::parse_str("HeaderMap").unwrap();
707+
assert!(is_non_body_type(&header_map));
708+
709+
let cookie_jar: syn::Type = syn::parse_str("CookieJar").unwrap();
710+
assert!(is_non_body_type(&cookie_jar));
711+
712+
let string: syn::Type = syn::parse_str("String").unwrap();
713+
assert!(!is_non_body_type(&string));
714+
715+
let json: syn::Type = syn::parse_str("Json<String>").unwrap();
716+
assert!(!is_non_body_type(&json));
717+
}
613718
}

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

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ fn parse_type_impl(
218218

219219
// Handle primitive types
220220
match ident_str.as_str() {
221-
"i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" => {
221+
"i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" | "StatusCode" => {
222222
SchemaRef::Inline(Box::new(Schema::integer()))
223223
}
224224
"f32" | "f64" => SchemaRef::Inline(Box::new(Schema::number())),
@@ -366,6 +366,10 @@ fn parse_type_impl(
366366
// Handle &T, &mut T, etc. — goes through depth guard via public entry point
367367
parse_type_to_schema_ref(&type_ref.elem, known_schemas, struct_definitions)
368368
}
369+
// () unit type → null (e.g. Json<()> serializes to JSON null)
370+
Type::Tuple(tuple) if tuple.elems.is_empty() => {
371+
SchemaRef::Inline(Box::new(Schema::new(SchemaType::Null)))
372+
}
369373
_ => SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object))),
370374
}
371375
}
@@ -1071,6 +1075,31 @@ mod tests {
10711075
}
10721076
}
10731077

1078+
// ========== Coverage: StatusCode → integer ==========
1079+
1080+
#[test]
1081+
fn test_parse_type_status_code_integer() {
1082+
let ty: Type = syn::parse_str("StatusCode").unwrap();
1083+
let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new());
1084+
if let SchemaRef::Inline(schema) = schema_ref {
1085+
assert_eq!(schema.schema_type, Some(SchemaType::Integer));
1086+
} else {
1087+
panic!("Expected inline schema for StatusCode");
1088+
}
1089+
}
1090+
1091+
#[test]
1092+
fn test_parse_type_qualified_status_code_integer() {
1093+
// axum::http::StatusCode should also map to integer (last segment matching)
1094+
let ty: Type = syn::parse_str("axum::http::StatusCode").unwrap();
1095+
let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new());
1096+
if let SchemaRef::Inline(schema) = schema_ref {
1097+
assert_eq!(schema.schema_type, Some(SchemaType::Integer));
1098+
} else {
1099+
panic!("Expected inline schema for axum::http::StatusCode");
1100+
}
1101+
}
1102+
10741103
// ========== Coverage: non-generic wrapper types without angle brackets ==========
10751104

10761105
#[rstest]

0 commit comments

Comments
 (0)