Skip to content

Commit dc2337a

Browse files
committed
Implement error status
1 parent 707ecb2 commit dc2337a

File tree

7 files changed

+291
-31
lines changed

7 files changed

+291
-31
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"changes":{"crates/vespera_core/Cargo.toml":"Patch","crates/vespera_macro/Cargo.toml":"Patch","crates/vespera/Cargo.toml":"Patch"},"note":"Implement error status","date":"2025-11-27T12:56:05.656695400Z"}

crates/vespera_macro/src/args.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
pub struct RouteArgs {
22
pub method: Option<syn::Ident>,
33
pub path: Option<syn::LitStr>,
4+
pub error_status: Option<syn::ExprArray>,
45
}
56

67
impl syn::parse::Parse for RouteArgs {
78
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
89
let mut method: Option<syn::Ident> = None;
910
let mut path: Option<syn::LitStr> = None;
11+
let mut error_status: Option<syn::ExprArray> = None;
1012

1113
// Parse comma-separated list of arguments
1214
while !input.is_empty() {
@@ -25,6 +27,11 @@ impl syn::parse::Parse for RouteArgs {
2527
let lit: syn::LitStr = input.parse()?;
2628
path = Some(lit);
2729
}
30+
"error_status" => {
31+
input.parse::<syn::Token![=]>()?;
32+
let array: syn::ExprArray = input.parse()?;
33+
error_status = Some(array);
34+
}
2835
_ => {
2936
return Err(lookahead.error());
3037
}
@@ -41,7 +48,7 @@ impl syn::parse::Parse for RouteArgs {
4148
}
4249
}
4350

44-
Ok(RouteArgs { method, path })
51+
Ok(RouteArgs { method, path, error_status })
4552
}
4653
}
4754

crates/vespera_macro/src/collector.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ pub fn collect_metadata(folder_path: &Path, folder_name: &str) -> CollectedMetad
6565
module_path: module_path.clone(),
6666
file_path: file_path.clone(),
6767
signature: quote::quote!(#fn_item).to_string(),
68+
error_status: route_info.error_status.clone(),
6869
});
6970
}
7071

crates/vespera_macro/src/metadata.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ pub struct RouteMetadata {
1717
pub file_path: String,
1818
/// Function signature (as string for serialization)
1919
pub signature: String,
20+
/// Additional error status codes from error_status attribute
21+
#[serde(skip_serializing_if = "Option::is_none")]
22+
pub error_status: Option<Vec<u16>>,
2023
}
2124

2225
/// Struct metadata

crates/vespera_macro/src/openapi_generator.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ pub fn generate_openapi_doc_with_metadata(
9797
&fn_item.sig,
9898
&route_meta.path,
9999
&known_schema_names,
100+
route_meta.error_status.as_deref(),
100101
);
101102

102103
// Get or create PathItem

crates/vespera_macro/src/parser.rs

Lines changed: 253 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -441,44 +441,228 @@ pub fn parse_request_body(
441441
}
442442
}
443443

444-
/// Analyze return type and convert to Response
444+
/// Unwrap Json<T> to get T
445+
fn unwrap_json(ty: &Type) -> &Type {
446+
if let Type::Path(type_path) = ty {
447+
let path = &type_path.path;
448+
if !path.segments.is_empty() {
449+
let segment = &path.segments[0];
450+
if segment.ident == "Json" {
451+
if let syn::PathArguments::AngleBracketed(args) = &segment.arguments {
452+
if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() {
453+
return inner_ty;
454+
}
455+
}
456+
}
457+
}
458+
}
459+
ty
460+
}
461+
462+
/// Extract Ok and Err types from Result<T, E> or Result<Json<T>, E>
463+
/// Handles both Result and std::result::Result, and unwraps references
464+
fn extract_result_types(ty: &Type) -> Option<(Type, Type)> {
465+
// First unwrap Json if present
466+
let unwrapped = unwrap_json(ty);
467+
468+
// Handle both Type::Path and Type::Reference (for &Result<...>)
469+
let result_type = match unwrapped {
470+
Type::Path(type_path) => type_path,
471+
Type::Reference(type_ref) => {
472+
// Unwrap reference and check if it's a Result
473+
if let Type::Path(type_path) = type_ref.elem.as_ref() {
474+
type_path
475+
} else {
476+
return None;
477+
}
478+
}
479+
_ => return None,
480+
};
481+
482+
let path = &result_type.path;
483+
if path.segments.is_empty() {
484+
return None;
485+
}
486+
487+
// Check if any segment is "Result" (handles both Result and std::result::Result)
488+
let is_result = path.segments.iter().any(|seg| seg.ident == "Result");
489+
490+
if is_result {
491+
// Get the last segment (Result) to check for generics
492+
if let Some(segment) = path.segments.last() {
493+
if let syn::PathArguments::AngleBracketed(args) = &segment.arguments {
494+
if args.args.len() >= 2 {
495+
if let (
496+
Some(syn::GenericArgument::Type(ok_ty)),
497+
Some(syn::GenericArgument::Type(err_ty)),
498+
) = (args.args.first(), args.args.get(1))
499+
{
500+
// Unwrap Json from Ok type if present
501+
let ok_ty_unwrapped = unwrap_json(ok_ty);
502+
return Some((ok_ty_unwrapped.clone(), err_ty.clone()));
503+
}
504+
}
505+
}
506+
}
507+
}
508+
None
509+
}
510+
511+
/// Check if error type is a tuple (StatusCode, E) or (StatusCode, Json<E>)
512+
/// Returns the error type E and a default status code (400)
513+
fn extract_status_code_tuple(err_ty: &Type) -> Option<(u16, Type)> {
514+
if let Type::Tuple(tuple) = err_ty {
515+
if tuple.elems.len() == 2 {
516+
// Check if first element is StatusCode
517+
if let Type::Path(type_path) = &tuple.elems[0] {
518+
let path = &type_path.path;
519+
if !path.segments.is_empty() {
520+
let segment = &path.segments[0];
521+
// Check if it's StatusCode (could be qualified like axum::http::StatusCode)
522+
let is_status_code = segment.ident == "StatusCode"
523+
|| (path.segments.len() > 1
524+
&& path.segments.iter().any(|s| s.ident == "StatusCode"));
525+
526+
if is_status_code {
527+
// Use 400 as default status code
528+
// The actual status code value is determined at runtime
529+
if let Some(error_type) = tuple.elems.get(1) {
530+
// Unwrap Json if present
531+
let error_type_unwrapped = unwrap_json(error_type);
532+
return Some((400, error_type_unwrapped.clone()));
533+
}
534+
}
535+
}
536+
}
537+
}
538+
}
539+
None
540+
}
541+
542+
/// Analyze return type and convert to Responses map
445543
pub fn parse_return_type(
446544
return_type: &ReturnType,
447545
known_schemas: &HashMap<String, String>,
448-
) -> Response {
449-
let schema = match return_type {
450-
ReturnType::Default => None,
451-
ReturnType::Type(_, ty) => Some(parse_type_to_schema_ref(ty, known_schemas)),
452-
};
546+
) -> BTreeMap<String, Response> {
547+
let mut responses = BTreeMap::new();
453548

454-
let mut content = BTreeMap::new();
455-
if let Some(schema) = schema {
456-
content.insert(
457-
"application/json".to_string(),
458-
MediaType {
459-
schema: Some(schema),
460-
example: None,
461-
examples: None,
462-
},
463-
);
464-
}
549+
match return_type {
550+
ReturnType::Default => {
551+
// No return type - just 200 with no content
552+
responses.insert(
553+
"200".to_string(),
554+
Response {
555+
description: "Successful response".to_string(),
556+
headers: None,
557+
content: None,
558+
},
559+
);
560+
}
561+
ReturnType::Type(_, ty) => {
562+
// Check if it's a Result<T, E>
563+
if let Some((ok_ty, err_ty)) = extract_result_types(ty) {
564+
// Handle success response (200)
565+
let ok_schema = parse_type_to_schema_ref(&ok_ty, known_schemas);
566+
let mut ok_content = BTreeMap::new();
567+
ok_content.insert(
568+
"application/json".to_string(),
569+
MediaType {
570+
schema: Some(ok_schema),
571+
example: None,
572+
examples: None,
573+
},
574+
);
465575

466-
Response {
467-
description: "Successful response".to_string(),
468-
headers: None,
469-
content: if content.is_empty() {
470-
None
471-
} else {
472-
Some(content)
473-
},
576+
responses.insert(
577+
"200".to_string(),
578+
Response {
579+
description: "Successful response".to_string(),
580+
headers: None,
581+
content: Some(ok_content),
582+
},
583+
);
584+
585+
// Handle error response
586+
// Check if error is (StatusCode, E) tuple
587+
if let Some((status_code, error_type)) = extract_status_code_tuple(&err_ty) {
588+
// Use the status code from the tuple
589+
let err_schema = parse_type_to_schema_ref(&error_type, known_schemas);
590+
let mut err_content = BTreeMap::new();
591+
err_content.insert(
592+
"application/json".to_string(),
593+
MediaType {
594+
schema: Some(err_schema),
595+
example: None,
596+
examples: None,
597+
},
598+
);
599+
600+
responses.insert(
601+
status_code.to_string(),
602+
Response {
603+
description: "Error response".to_string(),
604+
headers: None,
605+
content: Some(err_content),
606+
},
607+
);
608+
} else {
609+
// Regular error type - use default 400
610+
// Unwrap Json if present
611+
let err_ty_unwrapped = unwrap_json(&err_ty);
612+
let err_schema = parse_type_to_schema_ref(err_ty_unwrapped, known_schemas);
613+
let mut err_content = BTreeMap::new();
614+
err_content.insert(
615+
"application/json".to_string(),
616+
MediaType {
617+
schema: Some(err_schema),
618+
example: None,
619+
examples: None,
620+
},
621+
);
622+
623+
responses.insert(
624+
"400".to_string(),
625+
Response {
626+
description: "Error response".to_string(),
627+
headers: None,
628+
content: Some(err_content),
629+
},
630+
);
631+
}
632+
} else {
633+
// Not a Result type - regular response
634+
let schema = parse_type_to_schema_ref(ty, known_schemas);
635+
let mut content = BTreeMap::new();
636+
content.insert(
637+
"application/json".to_string(),
638+
MediaType {
639+
schema: Some(schema),
640+
example: None,
641+
examples: None,
642+
},
643+
);
644+
645+
responses.insert(
646+
"200".to_string(),
647+
Response {
648+
description: "Successful response".to_string(),
649+
headers: None,
650+
content: Some(content),
651+
},
652+
);
653+
}
654+
}
474655
}
656+
657+
responses
475658
}
476659

477660
/// Build Operation from function signature
478661
pub fn build_operation_from_function(
479662
sig: &syn::Signature,
480663
path: &str,
481664
known_schemas: &HashMap<String, String>,
665+
error_status: Option<&[u16]>,
482666
) -> Operation {
483667
let path_params = extract_path_parameters(path);
484668
let mut parameters = Vec::new();
@@ -494,10 +678,50 @@ pub fn build_operation_from_function(
494678
}
495679
}
496680

497-
// Parse return type
498-
let response = parse_return_type(&sig.output, known_schemas);
499-
let mut responses = BTreeMap::new();
500-
responses.insert("200".to_string(), response);
681+
// Parse return type - may return multiple responses (for Result types)
682+
let mut responses = parse_return_type(&sig.output, known_schemas);
683+
684+
// Add additional error status codes from error_status attribute
685+
if let Some(status_codes) = error_status {
686+
// Find the error response schema (usually 400 or the first error response)
687+
let error_schema = responses
688+
.iter()
689+
.find(|(code, _)| code != &&"200".to_string())
690+
.and_then(|(_, resp)| {
691+
resp.content
692+
.as_ref()?
693+
.get("application/json")?
694+
.schema
695+
.clone()
696+
});
697+
698+
if let Some(schema) = error_schema {
699+
for &status_code in status_codes {
700+
let status_str = status_code.to_string();
701+
// Only add if not already present
702+
if !responses.contains_key(&status_str) {
703+
let mut err_content = BTreeMap::new();
704+
err_content.insert(
705+
"application/json".to_string(),
706+
MediaType {
707+
schema: Some(schema.clone()),
708+
example: None,
709+
examples: None,
710+
},
711+
);
712+
713+
responses.insert(
714+
status_str,
715+
Response {
716+
description: "Error response".to_string(),
717+
headers: None,
718+
content: Some(err_content),
719+
},
720+
);
721+
}
722+
}
723+
}
724+
}
501725

502726
Operation {
503727
operation_id: Some(sig.ident.to_string()),

0 commit comments

Comments
 (0)