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_macro-parser-round7.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"changes":{"crates/vespera/Cargo.toml":"Patch","crates/vespera_core/Cargo.toml":"Patch","crates/vespera_macro/Cargo.toml":"Patch","crates/vespera_inprocess/Cargo.toml":"Patch","crates/vespera_jni/Cargo.toml":"Patch","libs/vespera-bridge/build.gradle.kts":"Patch","libs/vespera-bridge-gradle-plugin/build.gradle.kts":"Patch"},"note":"Fix round-7 macro parser findings: serde(transparent) Query/Path structs follow serde's transparent-field rule (PhantomData and serde(default) fields excluded) and tuple structs, generic value_type substitution keeps commas inside angle brackets (HashMap<String, T>), request-view recursive generic instances use a .Input suffix that never collides with a type named *Input, two flattened maps write anyOf (not allOf) in the response view, a NoContent error side is labelled as a successful 204, a bare argument of unknown type keeps the string path placeholder, and doc and test assertion fixes","date":"2026-11-15T00:00:00.000Z"}
Original file line number Diff line number Diff line change
Expand Up @@ -100,13 +100,13 @@ expression: "serde_json::to_string_pretty(&components).unwrap()"
"children"
]
},
"Tree_i32Input": {
"Tree_i32.Input": {
"type": "object",
"properties": {
"children": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Tree_i32Input"
"$ref": "#/components/schemas/Tree_i32.Input"
}
},
"value": {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,16 @@
---
source: crates/vespera_macro/src/parser/operation/tests.rs
expression: "params.iter().map(|p|\n(p.name.as_str(), p.r#in, param_schema_type(p))).collect::<Vec<_>>()"
expression: "params.iter().map(|p|\n{\n (p.name.as_str(), p.r#in, serde_json::to_string(&p.schema).unwrap(),)\n}).collect::<Vec<_>>()"
---
[
(
"org",
Path,
None,
"{\"type\":\"string\"}",
),
(
"repo",
Path,
Some(
String,
),
"{\"type\":\"string\"}",
),
]
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
---
source: crates/vespera_macro/src/parser/operation/tests.rs
expression: "params.iter().map(|p|\n(p.name.as_str(), p.r#in, param_schema_type(p))).collect::<Vec<_>>()"
expression: "params.iter().map(|p|\n{\n (p.name.as_str(), p.r#in, serde_json::to_string(&p.schema).unwrap(),)\n}).collect::<Vec<_>>()"
---
[
(
"org",
Path,
Some(
Integer,
),
"{\"type\":\"integer\",\"format\":\"int64\"}",
),
]
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
---
source: crates/vespera_macro/src/parser/operation/tests.rs
expression: "params.iter().map(|p|\n(p.name.as_str(), p.r#in, param_schema_type(p))).collect::<Vec<_>>()"
expression: "params.iter().map(|p|\n{\n (p.name.as_str(), p.r#in, serde_json::to_string(&p.schema).unwrap(),)\n}).collect::<Vec<_>>()"
---
[
(
"org",
Path,
Some(
String,
),
"{\"type\":\"string\"}",
),
]
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ expression: "serde_json::to_string_pretty(&summary).unwrap()"
"parameters": [
{
"in": "query",
"name": "kind",
"name": "filter",
"required": true,
"schema": {
"$ref": "#/components/schemas/Kind"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ expression: "serde_json::to_string_pretty(&summary).unwrap()"
"parameters": [
{
"in": "query",
"name": "kind",
"name": "filter",
"required": false,
"schema": {
"$ref": "#/components/schemas/Kind"
Expand Down
88 changes: 55 additions & 33 deletions crates/vespera_macro/src/parser/operation/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,18 +74,13 @@ fn assert_body(op: &Operation, expected: Option<&ExpectedBody>) {
None => assert!(op.request_body.is_none()),
Some(exp) => {
let body = op.request_body.as_ref().expect("request body expected");
let media = body
.content
.get(exp.content_type)
.or_else(|| {
// allow fallback to the only available content type if expected is absent
if body.content.len() == 1 {
body.content.values().next()
} else {
None
}
})
.expect("expected content type");
let media = body.content.get(exp.content_type).unwrap_or_else(|| {
panic!(
"expected {}, got {:?}",
exp.content_type,
body.content.keys()
)
});
if let Some(schema_ty) = &exp.schema {
match media.schema.as_ref().expect("schema expected") {
SchemaRef::Inline(schema) => {
Expand All @@ -111,22 +106,30 @@ fn assert_params(op: &Operation, expected: &[ExpectedParam]) {
}
}

/// Exactly the `expected` statuses are documented (no stray inferred one).
fn assert_responses(op: &Operation, expected: &[ExpectedResp]) {
assert_eq!(
op.responses.keys().collect::<Vec<_>>(),
expected.iter().map(|exp| exp.status).collect::<Vec<_>>()
);
for exp in expected {
let resp = op.responses.get(exp.status).expect("response missing");
let resp = &op.responses[exp.status];
// `schema: None` = a description-only response (no body).
let Some(schema_ty) = &exp.schema else {
assert!(resp.content.is_none(), "{}: {:?}", exp.status, resp.content);
continue;
};
let media = resp
.content
.as_ref()
.and_then(|c| c.get("application/json"))
.or_else(|| resp.content.as_ref().and_then(|c| c.get("text/plain")))
.expect("media type missing");
if let Some(schema_ty) = &exp.schema {
match media.schema.as_ref().expect("schema expected") {
SchemaRef::Inline(schema) => {
assert_eq!(schema.schema_type, Some(*schema_ty));
}
SchemaRef::Ref(_) => panic!("expected inline schema"),
match media.schema.as_ref().expect("schema expected") {
SchemaRef::Inline(schema) => {
assert_eq!(schema.schema_type, Some(*schema_ty));
}
SchemaRef::Ref(_) => panic!("expected inline schema"),
}
}
}
Expand Down Expand Up @@ -320,13 +323,18 @@ fn test_build_operation_summary_and_deprecated() {
Some(ExpectedBody { content_type: "text/plain", schema: Some(SchemaType::String) }),
vec![ExpectedResp { status: "200", schema: Some(SchemaType::String) }]
)]
// A declared status on a handler without an error body is still documented,
// description only.
#[case(
"fn no_error_extra() -> String",
"/plain",
Some(&[500u16][..]),
vec![],
None,
vec![ExpectedResp { status: "200", schema: Some(SchemaType::String) }]
vec![
ExpectedResp { status: "200", schema: Some(SchemaType::String) },
ExpectedResp { status: "500", schema: None },
]
)]
#[case(
"fn create() -> Result<String, String>",
Expand Down Expand Up @@ -391,6 +399,16 @@ fn typed_responses_use_schema_refs_and_override_error_status() {
}
SchemaRef::Inline(_) => panic!("typed response must use schema ref"),
}
// Replaced, not merged with the inferred `text/plain` error body.
assert_eq!(
response
.content
.as_ref()
.unwrap()
.keys()
.collect::<Vec<_>>(),
["application/json"]
);
assert!(op.responses.contains_key("500"));
}

Expand Down Expand Up @@ -699,10 +717,10 @@ fn test_non_path_extractor_with_query() {
None,
);

// Test: Query params should be extended to parameters
// But QueryParams is not in known_schemas/struct_definitions so it won't appear
// The key is that it doesn't treat Query as a Path extractor (line 85 returns false)
assert!(op.request_body.is_none()); // Query is not a body
// QueryParams is not in known_schemas/struct_definitions, so it documents
// no parameter (and `/search` has no segment to type); Query is no body.
assert!(op.parameters.is_none(), "{:?}", op.parameters);
assert!(op.request_body.is_none());
}

#[test]
Expand Down Expand Up @@ -825,14 +843,12 @@ fn test_non_path_extractor_generates_params_and_extends() {
OperationRouteConfig::default(),
);

// Query is not Path (line 85 returns false)
// parse_function_parameter returns Some for Query<SearchParams>
// Line 89: parameters.extend(params)
// TypedHeader also generates a header parameter
assert!(op.parameters.is_some());
let params = op.parameters.unwrap();
// Should have query param(s) and header param
assert!(!params.is_empty());
// `Query<SearchParams>` documents its field `q`. `TypedHeader<Authorization>`
// documents nothing: OpenAPI forbids an `Authorization` header parameter
// (it belongs to a security scheme).
let params = op.parameters.expect("query parameters");
let params: Vec<_> = params.iter().map(|p| (p.name.as_str(), p.r#in)).collect();
assert_eq!(params, [("q", ParameterLocation::Query)]);
}

#[test]
Expand Down Expand Up @@ -922,7 +938,13 @@ fn path_parameters_are_deduplicated(#[case] snapshot: &str, #[case] sig: &str, #
snapshot,
params
.iter()
.map(|p| (p.name.as_str(), p.r#in, param_schema_type(p)))
.map(|p| {
(
p.name.as_str(),
p.r#in,
serde_json::to_string(&p.schema).unwrap(),
)
})
.collect::<Vec<_>>()
);
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! Request extractors behind `Result<E, Rejection>` (the handler takes the
//! rejection itself) document exactly like the bare extractor — still
//! required, unlike `Option<E>` — and `Path<(..)>` tuples longer than the
//! rejection itself) document the same body / parameters as the bare
//! extractor — still required, unlike `Option<E>`; no `422`, since the
//! handler owns the rejection — and `Path<(..)>` tuples longer than the
//! route's own parameters (a `Router::nest("/{tenant}", ..)` prefix fills
//! the leading elements) type the route's segments from the END.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,9 @@ fn operation(sig: &str, path: &str) -> Operation {
"async fn f(OptionalQuery(q): axum_extra::extract::OptionalQuery<Filter>)",
"/x"
)]
#[case::optional_query_enum("optional_query_enum", "async fn f(kind: OptionalQuery<Kind>)", "/x")]
// 2: `WithRejection<E, R>` / `Cached<E>` document `E`.
#[case::optional_query_enum("optional_query_enum", "async fn f(filter: OptionalQuery<Kind>)", "/x")]
// 2: `WithRejection<E, R>` / `Cached<E>` document `E`'s body / parameters
// (`WithRejection` remaps the rejection, so a `Validated` `E` adds no 422).
#[case::with_rejection_json(
"with_rejection_json",
"async fn f(WithRejection(Json(b), _): WithRejection<Json<CreateUser>, ApiError>)",
Expand Down Expand Up @@ -121,7 +122,9 @@ fn operation(sig: &str, path: &str) -> Operation {
#[case::skipped_struct("skipped_struct", "async fn f(skipped: Query<Skipped>)", "/x")]
#[case::flatten_map_only("flatten_map_only", "async fn f(rest: Query<CatchAll>)", "/x")]
#[case::tuple_struct("tuple_struct", "async fn f(w: Query<Wrapper>)", "/x")]
#[case::known_enum("known_enum", "async fn f(kind: Query<Kind>)", "/x")]
// A known non-struct (an internally tagged enum) is one parameter named
// after the binding (`filter`, not the enum's tag `kind`).
#[case::known_enum("known_enum", "async fn f(filter: Query<Kind>)", "/x")]
// 9: `Path<HashMap<..>>` / `Path<Vec<..>>` type each segment.
#[case::path_map("path_map", "async fn f(p: Path<HashMap<String, u64>>)", "/x/{id}")]
#[case::path_map_multi(
Expand Down
3 changes: 2 additions & 1 deletion crates/vespera_macro/src/parser/parameters/header.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,8 @@ fn parse_header(
}

/// Wire names of the well-known `headers::*` typed headers. Any other type is
/// treated as a custom header and named after its Rust binding instead.
/// treated as a custom header named after its kebab-cased type name instead
/// (`XRequestId` → `x-request-id`; see [`typed_header_parameter`]).
fn known_typed_header_name(ident: &str) -> Option<&'static str> {
Some(match ident {
"Authorization" => "Authorization",
Expand Down
54 changes: 32 additions & 22 deletions crates/vespera_macro/src/parser/parameters/path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use vespera_core::{

use super::shared::{
MAX_CONVERSION_DEPTH, deserialized_instead, input_rename_all, named_struct_fields,
serde_field_name,
serde_field_name, struct_definition,
};
use crate::{
parser::schema::{
Expand Down Expand Up @@ -111,21 +111,25 @@ fn path_parameters_from(
.collect();
}

if let Some(struct_item) = named_struct_fields(inner_ty, struct_definitions) {
// Container `#[serde(from = "X")]` / `try_from` (or `#[schema(value_type
// = X)]`), or `#[serde(transparent)]`: the segments are deserialized
// as `X` / the single field's type, not as these fields.
if depth < MAX_CONVERSION_DEPTH
&& let Some(source) = deserialized_instead(&struct_item)
{
return path_parameters_from(
&source,
path_params,
known_schemas,
struct_definitions,
depth + 1,
);
}
let struct_item = struct_definition(inner_ty, struct_definitions);
// Container `#[serde(from = "X")]` / `try_from` (or `#[schema(value_type
// = X)]`), or `#[serde(transparent)]` (named or tuple struct): the
// segments are deserialized as `X` / the transparent field's type, not
// as these fields.
if depth < MAX_CONVERSION_DEPTH
&& let Some(source) = struct_item.as_ref().and_then(deserialized_instead)
{
return path_parameters_from(
&source,
path_params,
known_schemas,
struct_definitions,
depth + 1,
);
}
if let Some(struct_item) =
struct_item.filter(|struct_item| matches!(struct_item.fields, syn::Fields::Named(_)))
{
return path_params
.iter()
.map(|name| {
Expand Down Expand Up @@ -260,12 +264,18 @@ pub(super) fn parse_bare_path_parameter(
known_schemas: &HashSet<&str>,
struct_definitions: &HashMap<&str, &str>,
) -> Option<Vec<Parameter>> {
path_param_set.contains(param_name).then(|| {
vec![path_parameter(
param_name,
parse_type_to_schema_ref(ty, known_schemas, struct_definitions),
)]
})
if !path_param_set.contains(param_name) {
return None;
}
let schema = parse_type_to_schema_ref(ty, known_schemas, struct_definitions);
// A type with no known shape (a custom extractor such as `org: OrgCtx`)
// says nothing about the segment: keep its `{type: string}` placeholder
// rather than widening it to any value (`{}`).
if matches!(&schema, SchemaRef::Inline(inline) if serde_json::to_string(inline).is_ok_and(|json| json == "{}"))
{
return None;
}
Some(vec![path_parameter(param_name, schema)])
}

#[cfg(test)]
Expand Down
Loading
Loading