diff --git a/.changepacks/changepack_log__lvEoWS1MePQxAPdPCmeG.json b/.changepacks/changepack_log__lvEoWS1MePQxAPdPCmeG.json new file mode 100644 index 00000000..47fef206 --- /dev/null +++ b/.changepacks/changepack_log__lvEoWS1MePQxAPdPCmeG.json @@ -0,0 +1 @@ +{"changes":{"crates/vespera/Cargo.toml":"Patch","crates/vespera_core/Cargo.toml":"Patch","crates/vespera_macro/Cargo.toml":"Patch"},"note":"Fix response issue with HeaderMap","date":"2025-12-08T11:21:07.742369Z"} \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index d298ea13..7f8a6f98 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1501,7 +1501,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vespera" -version = "0.1.9" +version = "0.1.11" dependencies = [ "axum", "vespera_core", @@ -1510,7 +1510,7 @@ dependencies = [ [[package]] name = "vespera_core" -version = "0.1.9" +version = "0.1.11" dependencies = [ "rstest", "serde", @@ -1519,7 +1519,7 @@ dependencies = [ [[package]] name = "vespera_macro" -version = "0.1.9" +version = "0.1.11" dependencies = [ "anyhow", "proc-macro2", diff --git a/crates/vespera_macro/src/parser.rs b/crates/vespera_macro/src/parser.rs index 12966dbb..d498d79b 100644 --- a/crates/vespera_macro/src/parser.rs +++ b/crates/vespera_macro/src/parser.rs @@ -3,7 +3,7 @@ use std::collections::{BTreeMap, HashMap}; use syn::{Fields, FnArg, Pat, PatType, ReturnType, Type}; use vespera_core::{ - route::{MediaType, Operation, Parameter, ParameterLocation, RequestBody, Response}, + route::{Header, MediaType, Operation, Parameter, ParameterLocation, RequestBody, Response}, schema::{Reference, Schema, SchemaRef, SchemaType}, }; @@ -1264,6 +1264,39 @@ fn extract_status_code_tuple(err_ty: &Type) -> Option<(u16, Type)> { None } +/// Check whether the provided type is a HeaderMap +fn is_header_map_type(ty: &Type) -> bool { + if let Type::Path(type_path) = ty { + let path = &type_path.path; + if path.segments.is_empty() { + return false; + } + return path.segments.iter().any(|s| s.ident == "HeaderMap"); + } + false +} + +/// Extract payload type from an Ok tuple and track if headers exist. +/// The last element of the tuple is always treated as the response body. +/// Any presence of HeaderMap in the tuple marks headers as present. +fn extract_ok_payload_and_headers(ok_ty: &Type) -> (Type, Option>) { + if let Type::Tuple(tuple) = ok_ty { + let payload_ty = tuple.elems.last().map(|ty| unwrap_json(ty).clone()); + let has_headers = tuple.elems.iter().any(is_header_map_type); + + if let Some(payload_ty) = payload_ty { + let headers = if has_headers { + Some(HashMap::new()) + } else { + None + }; + return (payload_ty, headers); + } + } + + (ok_ty.clone(), None) +} + /// Analyze return type and convert to Responses map pub fn parse_return_type( return_type: &ReturnType, @@ -1288,8 +1321,9 @@ pub fn parse_return_type( // Check if it's a Result if let Some((ok_ty, err_ty)) = extract_result_types(ty) { // Handle success response (200) + let (ok_payload_ty, ok_headers) = extract_ok_payload_and_headers(&ok_ty); let ok_schema = parse_type_to_schema_ref_with_schemas( - &ok_ty, + &ok_payload_ty, known_schemas, struct_definitions, ); @@ -1307,7 +1341,7 @@ pub fn parse_return_type( "200".to_string(), Response { description: "Successful response".to_string(), - headers: None, + headers: ok_headers, content: Some(ok_content), }, ); @@ -1997,6 +2031,100 @@ mod tests { } } + #[test] + fn test_parse_return_type_with_header_map_tuple() { + let known_schemas = HashMap::new(); + let struct_definitions = HashMap::new(); + + let parsed: syn::Signature = + syn::parse_str("fn test() -> Result<(HeaderMap, String), String>") + .expect("Failed to parse return type"); + + let responses = parse_return_type(&parsed.output, &known_schemas, &struct_definitions); + + let ok_response = responses.get("200").expect("Ok response missing"); + let ok_content = ok_response + .content + .as_ref() + .expect("Ok content missing") + .get("application/json") + .expect("application/json missing"); + + if let SchemaRef::Inline(schema) = ok_content.schema.as_ref().unwrap() { + assert_eq!(schema.schema_type, Some(SchemaType::String)); + } else { + panic!("Expected inline String schema for Ok type"); + } + + assert!( + ok_response.headers.is_some(), + "HeaderMap should set headers" + ); + } + + #[test] + fn test_parse_return_type_with_status_and_header_map_tuple() { + let known_schemas = HashMap::new(); + let struct_definitions = HashMap::new(); + + let parsed: syn::Signature = + syn::parse_str("fn test() -> Result<(StatusCode, HeaderMap, String), String>") + .expect("Failed to parse return type"); + + let responses = parse_return_type(&parsed.output, &known_schemas, &struct_definitions); + + let ok_response = responses.get("200").expect("Ok response missing"); + let ok_content = ok_response + .content + .as_ref() + .expect("Ok content missing") + .get("application/json") + .expect("application/json missing"); + + if let SchemaRef::Inline(schema) = ok_content.schema.as_ref().unwrap() { + assert_eq!(schema.schema_type, Some(SchemaType::String)); + } else { + panic!("Expected inline String schema for Ok type"); + } + + assert!( + ok_response.headers.is_some(), + "HeaderMap should set headers" + ); + } + + #[test] + fn test_parse_return_type_with_mixed_tuple_uses_last_as_body() { + let known_schemas = HashMap::new(); + let struct_definitions = HashMap::new(); + + // Additional tuple elements before the payload should be ignored; last element is body + let parsed: syn::Signature = + syn::parse_str("fn test() -> Result<(StatusCode, HeaderMap, u32, String), String>") + .expect("Failed to parse return type"); + + let responses = parse_return_type(&parsed.output, &known_schemas, &struct_definitions); + + let ok_response = responses.get("200").expect("Ok response missing"); + let ok_content = ok_response + .content + .as_ref() + .expect("Ok content missing") + .get("application/json") + .expect("application/json missing"); + + if let SchemaRef::Inline(schema) = ok_content.schema.as_ref().unwrap() { + assert_eq!(schema.schema_type, Some(SchemaType::String)); + } else { + panic!("Expected inline String schema for Ok type"); + } + + assert!( + ok_response.headers.is_some(), + "HeaderMap should set headers" + ); + } + #[test] fn test_parse_return_type_primitive_types() { let known_schemas = HashMap::new(); diff --git a/examples/axum-example/openapi.json b/examples/axum-example/openapi.json index c475ca32..1515b9c9 100644 --- a/examples/axum-example/openapi.json +++ b/examples/axum-example/openapi.json @@ -243,6 +243,62 @@ } } }, + "/error/header-map": { + "get": { + "operationId": "header_map_endpoint", + "responses": { + "200": { + "description": "Successful response", + "headers": {}, + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse2" + } + } + } + } + } + } + }, + "/error/header-map2": { + "get": { + "operationId": "header_map_endpoint2", + "responses": { + "200": { + "description": "Successful response", + "headers": {}, + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse2" + } + } + } + } + } + } + }, "/foo/foo": { "post": { "operationId": "signup", diff --git a/examples/axum-example/src/routes/error.rs b/examples/axum-example/src/routes/error.rs index 2bea162c..4b8982a5 100644 --- a/examples/axum-example/src/routes/error.rs +++ b/examples/axum-example/src/routes/error.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use vespera::{ Schema, - axum::{Json, http::StatusCode, response::IntoResponse}, + axum::{Json, http::StatusCode, http::header::HeaderMap, response::IntoResponse}, }; #[derive(Serialize, Deserialize, Schema)] @@ -61,3 +61,18 @@ pub async fn error_endpoint_with_status_code2() -> Result<&'static str, (StatusC }, )) } + +#[vespera::route(path = "/header-map")] +pub async fn header_map_endpoint() -> Result<(HeaderMap, &'static str), ErrorResponse2> { + let headers = HeaderMap::new(); + println!("headers: {:?}", headers); + Ok((headers, "ok")) +} + +#[vespera::route(path = "/header-map2")] +pub async fn header_map_endpoint2() -> Result<(StatusCode, HeaderMap, &'static str), ErrorResponse2> +{ + let headers = HeaderMap::new(); + println!("headers: {:?}", headers); + Ok((StatusCode::INTERNAL_SERVER_ERROR, headers, "ok")) +} diff --git a/examples/axum-example/tests/snapshots/integration_test__openapi.snap b/examples/axum-example/tests/snapshots/integration_test__openapi.snap index ce96508c..f72f6fb1 100644 --- a/examples/axum-example/tests/snapshots/integration_test__openapi.snap +++ b/examples/axum-example/tests/snapshots/integration_test__openapi.snap @@ -247,6 +247,62 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" } } }, + "/error/header-map": { + "get": { + "operationId": "header_map_endpoint", + "responses": { + "200": { + "description": "Successful response", + "headers": {}, + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse2" + } + } + } + } + } + } + }, + "/error/header-map2": { + "get": { + "operationId": "header_map_endpoint2", + "responses": { + "200": { + "description": "Successful response", + "headers": {}, + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse2" + } + } + } + } + } + } + }, "/foo/foo": { "post": { "operationId": "signup",