Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
9367fd5
fix(macro): accept Local NOW() defaults in schema_type! DTOs
owjs3901 Nov 15, 2026
778ad6a
fix(macro): resolve repeated inline-mod names with real span lines
owjs3901 Nov 15, 2026
17a8233
fix(macro): schema! defaults and generic multipart Default bounds
owjs3901 Nov 15, 2026
076043e
test(macro): assert schema_type! derive lists and From impls properly
owjs3901 Nov 15, 2026
c301f13
fix(macro): only drop stale storage entries inside rust-analyzer
owjs3901 Nov 15, 2026
0327810
fix(macro): reject duplicate export_app! names within a crate
owjs3901 Nov 15, 2026
1eae592
test(macro): emulate rust-analyzer for the stale-entry filter test
owjs3901 Nov 15, 2026
5934952
fix(macro): garde validation follows serde skip/flatten and documente…
owjs3901 Nov 15, 2026
4f28a64
fix(macro): document defaults on $ref properties
owjs3901 Nov 15, 2026
fd0292b
fix(macro): renamed self-references resolve to their own component
owjs3901 Nov 15, 2026
9e1c82d
fix(macro): valid rename suggestions and per-flow OAuth2 URL fields
owjs3901 Nov 15, 2026
df1d55f
fix(macro): raw module cfgs, Path<()>, and IDE inline-module staleness
owjs3901 Nov 15, 2026
d56309c
fix(macro): self use-aliases and merge conflicts checked before writi…
owjs3901 Nov 15, 2026
ed0cc49
fix(macro): fingerprint-less sidecars fall back to the content hash
owjs3901 Nov 15, 2026
38f6491
fix(macro): resolve more literal default forms
owjs3901 Nov 15, 2026
efb1644
fix(macro): escaped braces and percent escapes share one route shape
owjs3901 Nov 15, 2026
e4aad33
fix(macro): cron scan only claims vespera's cron, and only with the f…
owjs3901 Nov 15, 2026
f2a7d13
fix(example): format negative money amounts
owjs3901 Nov 15, 2026
7d43651
fix(scripts): benchmark the bridge JAR of the tree's own version
owjs3901 Nov 15, 2026
db8e79a
docs(macro): error module lists only its real helpers
owjs3901 Nov 15, 2026
a273b7b
test: tighten assertions that could not fail
owjs3901 Nov 15, 2026
cbd0e20
chore: changepack for macro round-7 fixes
owjs3901 Nov 15, 2026
75fce60
Merge remote-tracking branch 'origin/main' into fix/macro-rest-round7
owjs3901 Nov 15, 2026
cce2daf
chore(macro): clippy fixes
owjs3901 Nov 15, 2026
f9203b3
test(macro): cover the new default, cron import and export-site branches
owjs3901 Nov 15, 2026
5bad17e
Merge branch 'main' into fix/macro-rest-round7
owjs3901 Nov 15, 2026
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-rest-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":"vespera_macro fixes: schema_type! of a SeaORM Local column with a SQL-function default (NOW()) compiles again; same-named types in sibling inline modules of one file resolve to the right module for schema! / schema_type! / default functions; schema_type! carries serde default values into the derived component; Multipart derives on generic types add the Default bounds their defaults need; a stale route or cron registration is dropped only inside rust-analyzer; two export_app! of one crate with the same name are a compile error; derived garde validation skips serde-skipped fields, does not nest flattened field paths, peels nested Option, keeps bounds beyond 2^53 exact (and loosened to the documented f64 so runtime and OpenAPI agree) and rejects non-finite bounds; a default on a $ref property is documented as a sibling; more literal defaults are resolved (negative numbers, to_owned / into / to_string, String::new / Vec::new, JSON array / object strings); a renamed self-referencing type resolves to its own component; invalid route file name suggestions are valid identifiers; OAuth2 flows reject URL fields they do not take; raw-identifier modules, Path<()> and inline modules in rust-analyzer resolve; use x::{self} aliases resolve for merge; merge route conflicts are checked before any spec file is written; a sidecar spec without a fingerprint falls back to its content hash; escaped braces and percent escapes are one route shape for overlap checks; the cron module-tree scan runs only with the cron feature and ignores a cron attribute imported from another crate.","date":"2026-11-15T00:00:00.000Z"}
80 changes: 80 additions & 0 deletions crates/vespera/tests/derive_garde_wire.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
//! `#[derive(Schema)]` validation follows what serde reads from the wire:
//!
//! - a `#[serde(skip)]` / `skip_deserializing` field is never validated
//! (serde fills its default, so its rule used to reject every request);
//! - a `#[serde(flatten)]` + `#[schema(dive)]` field reports the flattened
//! keys at the parent's level (`limit`, not `page.limit`);
//! - every `Option` layer is peeled for an integer `range` (a fractional
//! bound rounds inward), and a type alias keeps its integer bound exact;
//! - an integer bound the `f64` OpenAPI value cannot carry accepts the
//! advertised value too.
#![cfg(feature = "validation")]

use ::vespera::__validation::garde::Validate;
use ::vespera::Schema;

fn paths(value: &impl Validate<Context = ()>) -> Vec<String> {
value.validate().map_or_else(
|report| report.iter().map(|(path, _)| path.to_string()).collect(),
|()| Vec::new(),
)
}

#[derive(Schema, serde::Deserialize, Default)]
pub struct Page {
#[schema(minimum = 1, maximum = 100)]
pub limit: u32,
}

pub type Count = u64;

#[derive(Schema, serde::Deserialize)]
pub struct Search {
#[serde(flatten)]
#[schema(dive)]
pub page: Page,
#[serde(skip)]
#[schema(min_length = 1)]
pub cache: String,
#[serde(skip_deserializing)]
#[schema(minimum = 5)]
pub hits: u32,
#[schema(minimum = 0.5)]
pub ratio: Option<Option<i32>>,
#[schema(maximum = 3, value_type = u64)]
pub count: Count,
#[schema(minimum = 9_007_199_254_740_993_i64)]
pub big: i64,
}

fn search(json: &str) -> Search {
serde_json::from_str(json).unwrap()
}

#[test]
fn validation_follows_the_wire() {
let valid = search(r#"{"limit": 10, "count": 3, "big": 9007199254740993}"#);
assert!(paths(&valid).is_empty(), "{:?}", paths(&valid));

// The flattened key reports at the parent's level.
let flat = search(r#"{"limit": 0, "count": 3, "big": 9007199254740993}"#);
assert_eq!(paths(&flat), ["limit"]);

// `minimum = 0.5` on `Option<Option<i32>>` admits 1, not 0.
let ratio = |r: i32| {
search(&format!(
r#"{{"limit": 1, "ratio": {r}, "count": 0, "big": 9007199254740993}}"#
))
};
assert_eq!(paths(&ratio(0)), ["ratio"]);
assert_eq!(paths(&ratio(1)), Vec::<String>::new());

// The alias keeps its exact integer bound.
let count = search(r#"{"limit": 1, "count": 4, "big": 9007199254740993}"#);
assert_eq!(paths(&count), ["count"]);

// 2^53 + 1 is advertised as 2^53, which is accepted; below it is not.
let big = |b: i64| search(&format!(r#"{{"limit": 1, "count": 0, "big": {b}}}"#));
assert_eq!(paths(&big(9_007_199_254_740_992)), Vec::<String>::new());
assert_eq!(paths(&big(9_007_199_254_740_991)), ["big"]);
}
27 changes: 27 additions & 0 deletions crates/vespera/tests/multipart_serde_defaults.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,33 @@ async fn multipart_schema_type_keeps_the_source_defaults_skip_and_strict() {
assert_eq!(patch.tags, ["fallback"]);
}

/// Generic structs get serde's `Default` bounds: `T: Default` for a field
/// default of type `T` (also a skipped one), `Self: Default` for a container
/// default.
#[derive(Multipart, Debug)]
pub struct GenericField<T, U> {
#[serde(default)]
pub n: T,
#[serde(skip)]
pub cache: U,
}

#[derive(Multipart, Debug, Default)]
#[serde(default)]
pub struct GenericContainer<T> {
pub n: T,
}

#[tokio::test]
async fn generic_defaults_get_default_bounds() {
let field: GenericField<u32, u8> = parse(&[]).await.unwrap();
assert_eq!((field.n, field.cache), (0, 0));
let container: GenericContainer<u32> = parse(&[("n", "4")]).await.unwrap();
assert_eq!(container.n, 4);
let container: GenericContainer<u32> = parse(&[]).await.unwrap();
assert_eq!(container.n, 0);
}

#[test]
fn serde_schema_type_of_a_default_fn_source_compiles_and_defaults() {
let view: SourceView = serde_json::from_str("{}").unwrap();
Expand Down
44 changes: 44 additions & 0 deletions crates/vespera/tests/schema_repeated_inline_names.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
//! Two inline `mod`s of one file each define a `#[derive(Schema)]` struct of
//! the same name whose `#[serde(default = "..")]` names a function of its own
//! module: the derive picks its module by the struct's line, which needs the
//! file re-parsed with real span positions (inside the compiler every
//! re-parsed token points at the call site, so no candidate matched and the
//! default was "not statically determinable" — a compile error).
#![deny(warnings)]

mod first {
use serde::{Deserialize, Serialize};
use vespera::Schema;

fn limit() -> u32 {
10
}

#[derive(Serialize, Deserialize, Clone, Schema)]
pub struct Page {
#[serde(default = "limit")]
pub limit: u32,
}
}

mod second {
use serde::{Deserialize, Serialize};
use vespera::Schema;

fn limit() -> u32 {
20
}

#[derive(Serialize, Deserialize, Clone, Schema)]
pub struct Page {
#[serde(default = "limit")]
pub limit: u32,
}
}

#[test]
fn each_page_resolves_its_own_default_fn() {
let first: first::Page = serde_json::from_str("{}").unwrap();
let second: second::Page = serde_json::from_str("{}").unwrap();
assert_eq!((first.limit, second.limit), (10, 20));
}
50 changes: 40 additions & 10 deletions crates/vespera_macro/src/collector/module_scope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,9 @@ pub(super) fn module_cfg_predicates(folder_path: &Path, segments: &[String]) ->
continue;
};
for item in &file.items {
// `pub mod r#type;` declares the `type.rs` segment.
if let Item::Mod(module) = item
&& module.ident == segment.as_str()
&& crate::parser::strip_raw_prefix_owned(module.ident.to_string()) == *segment
{
predicates.extend(cfg_predicates(&module.attrs));
}
Expand Down Expand Up @@ -115,23 +116,29 @@ pub(super) fn inline_mod_route_error(file: &Path, (module, handler): &(String, S
/// `(module, handler)` of a fast-path `file`'s stored handler declared inside
/// an inline module. `#[route]` recorded where each handler sits, so a known
/// inline one is rejected without re-parsing; only handlers whose scope could
/// not be located fall back to the source scan.
/// not be located fall back to the source scan. Inside rust-analyzer every
/// handler takes the source scan: the storage still holds the entry of a
/// handler since moved out of its inline module (a different scope, so it
/// was never replaced), which would keep the error alive.
pub(super) fn stored_routes_inline_module(
file: &Path,
stored_routes: &[&crate::route_impl::StoredRouteInfo],
) -> Option<(String, String)> {
if let Some(found) = stored_routes.iter().find_map(|stored| {
stored
.inline_module
.as_ref()
.filter(|module| !module.is_empty())
.map(|module| (module.clone(), stored.fn_name.clone()))
}) {
let in_ide = crate::macro_storage::in_long_lived_server();
if !in_ide
&& let Some(found) = stored_routes.iter().find_map(|stored| {
stored
.inline_module
.as_ref()
.filter(|module| !module.is_empty())
.map(|module| (module.clone(), stored.fn_name.clone()))
})
{
return Some(found);
}
let stored_names: Vec<&str> = stored_routes
.iter()
.filter(|stored| stored.inline_module.is_none())
.filter(|stored| in_ide || stored.inline_module.is_none())
.map(|stored| stored.fn_name.as_str())
.collect();
if stored_names.is_empty() {
Expand Down Expand Up @@ -173,6 +180,7 @@ mod tests {
#[case::two_cfgs("#[cfg(unix)] #[cfg(test)] #[cfg] mod admin;", &["admin"], &["unix", "test"])]
#[case::other_module_gated("#[cfg(test)] mod tests; mod admin;", &["admin"], &[])]
#[case::folder_root(r#"#[cfg(feature = "x")] mod admin;"#, &[], &[])]
#[case::raw_keyword_module("#[cfg(unix)] pub mod r#type;", &["type"], &["unix"])]
fn module_declaration_cfg(
#[case] mod_rs: &str,
#[case] segments: &[&str],
Expand Down Expand Up @@ -261,6 +269,28 @@ mod tests {
stored_inline_mod_route(&shadowed, &["list", "create"]),
Some(("b".to_string(), "create".to_string()))
);
// rust-analyzer still stores `moved` in `mod old` after it moved to
// the top level: the IDE re-reads the file instead of trusting it.
let moved = dir.path().join("moved.rs");
std::fs::write(&moved, "#[route(get)] pub async fn moved() {}").unwrap();
file_cache::bump_epoch();
let stale = crate::route_impl::StoredRouteInfo {
fn_name: "moved".to_string(),
inline_module: Some("old".to_string()),
..Default::default()
};
let current = crate::route_impl::StoredRouteInfo {
inline_module: Some(String::new()),
..stale.clone()
};
assert!(stored_routes_inline_module(&moved, &[&stale, &current]).is_some());
{
let _ide = crate::macro_storage::test_ide::force();
assert_eq!(
stored_routes_inline_module(&moved, &[&stale, &current]),
None
);
}
let message = inline_mod_route_error(&file, &("v2".to_string(), "list".to_string()));
assert!(
message.contains("inline module `mod v2 { .. }`"),
Expand Down
5 changes: 2 additions & 3 deletions crates/vespera_macro/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,8 @@
//!
//! # Key Functions
//!
//! - [`err_call_site`] - Create an error at the macro call site
//! - [`err_spanned`] - Create an error at a specific AST node location
//! - [`IntoSynError`] - Convert other error types to `syn::Error`
//! - [`err_call_site`] - Create an error at the macro call site; spanned
//! errors use `syn::Error::new_spanned` directly
//!
//! # Example
//!
Expand Down
Loading
Loading