From 3bc13ee4bebab40b53237c6333eeb89bd79dffa7 Mon Sep 17 00:00:00 2001 From: devfive Date: Sun, 30 Aug 2026 02:27:51 +0900 Subject: [PATCH 1/2] Add export app route and schema prefix --- .../changepack_log_export-app-prefix.json | 1 + crates/vespera_macro/src/lib.rs | 11 +- crates/vespera_macro/src/metadata.rs | 7 + .../openapi_generator/component_schemas.rs | 3 + crates/vespera_macro/src/router_codegen.rs | 4 +- .../src/router_codegen/export.rs | 434 +++++++++++++++++- crates/vespera_macro/src/schema_impl.rs | 4 + crates/vespera_macro/src/schema_impl/tests.rs | 14 + .../src/schema_macro/defaults/tests.rs | 1 + .../vespera_macro/src/vespera_impl/cache.rs | 21 +- .../src/vespera_impl/orchestrator.rs | 46 +- .../src/vespera_impl/orchestrator/tests.rs | 9 + 12 files changed, 531 insertions(+), 24 deletions(-) create mode 100644 .changepacks/changepack_log_export-app-prefix.json diff --git a/.changepacks/changepack_log_export-app-prefix.json b/.changepacks/changepack_log_export-app-prefix.json new file mode 100644 index 00000000..f923e606 --- /dev/null +++ b/.changepacks/changepack_log_export-app-prefix.json @@ -0,0 +1 @@ +{"changes":{"crates/vespera_macro/Cargo.toml":"Minor"},"note":"Add an explicit export_app! prefix option that namespaces routes, OpenAPI paths, and generated component schemas.","date":"2026-08-29T17:10:35.446Z"} diff --git a/crates/vespera_macro/src/lib.rs b/crates/vespera_macro/src/lib.rs index b574c80e..314cdb06 100644 --- a/crates/vespera_macro/src/lib.rs +++ b/crates/vespera_macro/src/lib.rs @@ -365,8 +365,13 @@ pub fn vespera(input: TokenStream) -> TokenStream { /// // Simple - uses "routes" folder by default /// vespera::export_app!(MyApp); /// -/// // Custom directory +/// // Custom directory and explicit public route prefix /// vespera::export_app!(MyApp, dir = "api"); +/// vespera::export_app!(MyPlugin, prefix = "/api/media-library"); +/// // The latter emits routes under /api/media-library and namespaces generated +/// // component schemas (for example Item -> MediaLibraryItem). A conventional +/// // leading /api is omitted from the PascalCase schema namespace. An explicit +/// // #[schema(name = "SharedThing")] remains global and is not namespaced. /// /// // Generates: /// // pub struct MyApp; @@ -381,7 +386,7 @@ pub fn vespera(input: TokenStream) -> TokenStream { pub fn export_app(input: TokenStream) -> TokenStream { schema_macro::file_cache::bump_epoch(); - let ExportAppInput { name, dir } = syn::parse_macro_input!(input as ExportAppInput); + let ExportAppInput { name, dir, prefix } = syn::parse_macro_input!(input as ExportAppInput); // Capture the `dir = "..."` literal span (or the macro call site when // `dir` is omitted) before `dir` is consumed below, so a "route folder // not found" diagnostic points at the offending argument. @@ -392,6 +397,7 @@ pub fn export_app(input: TokenStream) -> TokenStream { .map(|d| d.value()) .or_else(|| std::env::var("VESPERA_DIR").ok()) .unwrap_or_else(|| "routes".to_string()); + let prefix = prefix.map_or_else(String::new, |value| value.value()); let schema_storage = schema_impl::current_crate_schemas(); let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") else { return syn::Error::new(proc_macro2::Span::call_site(), "export_app! macro: CARGO_MANIFEST_DIR is not set. This macro must be used within a cargo build.").to_compile_error().into(); @@ -405,6 +411,7 @@ pub fn export_app(input: TokenStream) -> TokenStream { &schema_storage, &manifest_dir, &route_storage, + &prefix, folder_span, ) { Ok(tokens) => tokens.into(), diff --git a/crates/vespera_macro/src/metadata.rs b/crates/vespera_macro/src/metadata.rs index ccdbae59..478876f7 100644 --- a/crates/vespera_macro/src/metadata.rs +++ b/crates/vespera_macro/src/metadata.rs @@ -78,6 +78,10 @@ pub struct StructMetadata { /// - false: from cross-file lookup - only for `schema_type`! source, NOT in openapi.json #[serde(default = "default_include_in_openapi")] pub include_in_openapi: bool, + /// Whether the public component name came from `#[schema(name = "...")]`. + /// Explicit names are global and opt out of export prefix namespacing. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub explicit_schema_name: bool, /// Pre-extracted default values for fields with `#[serde(default = "fn_name")]`. /// Key: Rust field name, Value: extracted default value. /// Populated by `#[derive(Schema)]` to avoid AST re-parsing in `vespera!()`. @@ -102,6 +106,7 @@ impl Default for StructMetadata { name: String::new(), definition: String::new(), include_in_openapi: true, + explicit_schema_name: false, field_defaults: BTreeMap::new(), source_identity: None, } @@ -115,6 +120,7 @@ impl StructMetadata { name, definition, include_in_openapi: true, + explicit_schema_name: false, field_defaults: BTreeMap::new(), source_identity: None, } @@ -126,6 +132,7 @@ impl StructMetadata { name, definition, include_in_openapi: false, + explicit_schema_name: false, field_defaults: BTreeMap::new(), source_identity: None, } diff --git a/crates/vespera_macro/src/openapi_generator/component_schemas.rs b/crates/vespera_macro/src/openapi_generator/component_schemas.rs index f9effb3b..de480bf2 100644 --- a/crates/vespera_macro/src/openapi_generator/component_schemas.rs +++ b/crates/vespera_macro/src/openapi_generator/component_schemas.rs @@ -237,6 +237,7 @@ mod tests { name: "Hidden".to_string(), definition: "struct Hidden { id: i32 }".to_string(), include_in_openapi: false, + explicit_schema_name: false, field_defaults: BTreeMap::new(), source_identity: None, }); @@ -285,6 +286,7 @@ mod tests { name: "Invalid".to_string(), definition: "struct { invalid syntax {{{{".to_string(), include_in_openapi: true, + explicit_schema_name: false, field_defaults: BTreeMap::new(), source_identity: None, }); @@ -461,6 +463,7 @@ pub fn get_config() -> Config { Config { count: 0, name: String::new() } } name: "Config".to_string(), definition: "struct Config { count: i32, name: String }".to_string(), include_in_openapi: true, + explicit_schema_name: false, field_defaults: BTreeMap::from([ ("count".to_string(), json!(42)), ("name".to_string(), json!("default_name")), diff --git a/crates/vespera_macro/src/router_codegen.rs b/crates/vespera_macro/src/router_codegen.rs index b1629a16..43ec4f71 100644 --- a/crates/vespera_macro/src/router_codegen.rs +++ b/crates/vespera_macro/src/router_codegen.rs @@ -8,6 +8,8 @@ mod export; mod generator; mod input; -pub use export::ExportAppInput; +pub use export::{ + ExportAppInput, apply_export_prefix, namespace_export_schemas, schema_namespace_from_prefix, +}; pub use generator::generate_router_code; pub use input::{AutoRouterInput, ProcessedVesperaInput, process_vespera_input}; diff --git a/crates/vespera_macro/src/router_codegen/export.rs b/crates/vespera_macro/src/router_codegen/export.rs index e3aaf763..ddc99160 100644 --- a/crates/vespera_macro/src/router_codegen/export.rs +++ b/crates/vespera_macro/src/router_codegen/export.rs @@ -1,14 +1,20 @@ +use std::collections::{BTreeMap, HashSet}; + use syn::{ LitStr, parse::{Parse, ParseStream}, }; +use crate::metadata::CollectedMetadata; + /// Input for `export_app`! macro pub struct ExportAppInput { /// App name (struct name to generate) pub name: syn::Ident, /// Route directory pub dir: Option, + /// Explicit public base path for every exported route + pub prefix: Option, } impl Parse for ExportAppInput { @@ -16,6 +22,7 @@ impl Parse for ExportAppInput { let name: syn::Ident = input.parse()?; let mut dir = None; + let mut prefix = None; // Parse optional comma and arguments while input.peek(syn::Token![,]) { @@ -42,16 +49,196 @@ impl Parse for ExportAppInput { input.parse::()?; dir = Some(input.parse()?); } + "prefix" => { + if prefix.is_some() { + return Err(syn::Error::new( + ident.span(), + "duplicate field `prefix` in export_app! macro", + )); + } + input.parse::()?; + let literal: LitStr = input.parse()?; + prefix = Some(normalize_prefix(&literal)?); + } _ => { return Err(syn::Error::new( ident.span(), - format!("unknown field: `{ident_str}`. Expected `dir`"), + format!("unknown field: `{ident_str}`. Expected `dir` or `prefix`"), )); } } } - Ok(Self { name, dir }) + Ok(Self { name, dir, prefix }) + } +} + +/// Normalize an explicit export prefix while retaining the literal's span for +/// compile-time diagnostics. Empty and root-only prefixes mean "no prefix". +fn normalize_prefix(prefix: &LitStr) -> syn::Result { + let raw = prefix.value(); + if raw.chars().any(char::is_whitespace) || raw.contains(['?', '#']) { + return Err(syn::Error::new( + prefix.span(), + "export_app! macro: `prefix` must be a URL path without whitespace, a query, or a fragment", + )); + } + + let with_leading_slash = if raw.is_empty() || raw.starts_with('/') { + raw + } else { + format!("/{raw}") + }; + let normalized = with_leading_slash.trim_end_matches('/'); + if normalized.contains("//") { + return Err(syn::Error::new( + prefix.span(), + "export_app! macro: `prefix` must not contain empty path segments (`//`)", + )); + } + if !normalized.is_empty() && schema_namespace_from_prefix(normalized).is_empty() { + return Err(syn::Error::new( + prefix.span(), + "export_app! macro: `prefix` must contain at least one alphanumeric character", + )); + } + + Ok(LitStr::new(normalized, prefix.span())) +} + +/// Derive a deterministic PascalCase component namespace from a normalized +/// route prefix. A conventional leading `/api` segment is omitted when more +/// specific segments follow (`/api/media-library` -> `MediaLibrary`). +pub fn schema_namespace_from_prefix(prefix: &str) -> String { + let segments: Vec<&str> = prefix + .trim_matches('/') + .split('/') + .filter(|segment| !segment.is_empty()) + .collect(); + let start = usize::from(segments.len() > 1 && segments[0].eq_ignore_ascii_case("api")); + let mut namespace = String::new(); + for word in segments[start..] + .iter() + .flat_map(|segment| segment.split(|ch: char| !ch.is_alphanumeric())) + .filter(|word| !word.is_empty()) + { + let mut chars = word.chars(); + if let Some(first) = chars.next() { + namespace.extend(first.to_uppercase()); + namespace.extend(chars); + } + } + namespace +} + +/// Apply the normalized prefix to collected route metadata exactly once. +/// Both router generation and OpenAPI generation consume this same metadata. +pub fn apply_export_prefix(metadata: &mut CollectedMetadata, prefix: &str) { + if prefix.is_empty() { + return; + } + + for route in &mut metadata.routes { + route.path = if route.path == "/" { + prefix.to_owned() + } else { + format!("{prefix}{}", route.path) + }; + } +} + +/// Namespace generated component names and their references for one exported +/// app. Author-declared `#[schema(name = "...")]` names remain global. +pub fn namespace_export_schemas( + openapi: &mut vespera_core::OpenApi, + metadata: &CollectedMetadata, + namespace: &str, +) -> syn::Result<()> { + if namespace.is_empty() { + return Ok(()); + } + + let explicit_names: HashSet<&str> = metadata + .structs + .iter() + .filter(|schema| schema.explicit_schema_name) + .map(|schema| schema.name.as_str()) + .collect(); + let mut value = serde_json::to_value(&*openapi).map_err(|error| { + syn::Error::new( + proc_macro2::Span::call_site(), + format!("export_app! macro: failed to namespace OpenAPI schemas: {error}"), + ) + })?; + let Some(schemas) = value + .pointer_mut("/components/schemas") + .and_then(serde_json::Value::as_object_mut) + else { + return Ok(()); + }; + + let mut renames = BTreeMap::new(); + for name in schemas + .keys() + .filter(|name| !explicit_names.contains(name.as_str())) + { + renames.insert(name.clone(), format!("{namespace}{name}")); + } + for (old_name, new_name) in &renames { + if schemas.contains_key(new_name) && !renames.contains_key(new_name) && old_name != new_name + { + return Err(syn::Error::new( + proc_macro2::Span::call_site(), + format!( + "export_app! macro: schema namespace `{namespace}` maps `{old_name}` to existing component `{new_name}`" + ), + )); + } + } + for (old_name, new_name) in &renames { + if old_name != new_name + && let Some(schema) = schemas.remove(old_name) + { + schemas.insert(new_name.clone(), schema); + } + } + rewrite_schema_refs(&mut value, &renames); + *openapi = serde_json::from_value(value).map_err(|error| { + syn::Error::new( + proc_macro2::Span::call_site(), + format!("export_app! macro: failed to rebuild namespaced OpenAPI spec: {error}"), + ) + })?; + Ok(()) +} + +fn rewrite_schema_refs(value: &mut serde_json::Value, renames: &BTreeMap) { + match value { + serde_json::Value::Object(object) => { + if let Some(serde_json::Value::String(reference)) = object.get_mut("$ref") + && let Some(target) = reference.strip_prefix("#/components/schemas/") + { + let (name, suffix) = target + .split_once('/') + .map_or((target, ""), |(name, suffix)| (name, suffix)); + if let Some(new_name) = renames.get(name) { + *reference = if suffix.is_empty() { + format!("#/components/schemas/{new_name}") + } else { + format!("#/components/schemas/{new_name}/{suffix}") + }; + } + } + for child in object.values_mut() { + rewrite_schema_refs(child, renames); + } + } + serde_json::Value::Array(values) => { + for child in values { + rewrite_schema_refs(child, renames); + } + } + _ => {} } } @@ -65,6 +252,7 @@ mod tests { let input: ExportAppInput = syn::parse2(tokens).unwrap(); assert_eq!(input.name.to_string(), "MyApp"); assert!(input.dir.is_none()); + assert!(input.prefix.is_none()); } #[test] @@ -116,4 +304,246 @@ mod tests { .contains("duplicate field `dir`") ); } + + #[test] + fn test_export_app_input_with_normalized_prefix() { + let input: ExportAppInput = + syn::parse2(quote::quote!(MyApp, prefix = "api/media-library/")).unwrap(); + + assert_eq!(input.prefix.unwrap().value(), "/api/media-library"); + } + + #[test] + fn test_export_app_input_duplicate_prefix() { + let result: syn::Result = + syn::parse2(quote::quote!(MyApp, prefix = "/api", prefix = "/other")); + + assert!( + result + .err() + .unwrap() + .to_string() + .contains("duplicate field `prefix`") + ); + } + + #[test] + fn test_export_app_input_empty_and_root_prefix_are_noops() { + for tokens in [ + quote::quote!(MyApp, prefix = ""), + quote::quote!(MyApp, prefix = "/"), + ] { + let input: ExportAppInput = syn::parse2(tokens).unwrap(); + assert_eq!(input.prefix.unwrap().value(), ""); + } + } + + #[test] + fn test_export_app_input_rejects_invalid_prefix() { + let result: syn::Result = + syn::parse2(quote::quote!(MyApp, prefix = "/api?version=1")); + + assert!( + result + .err() + .unwrap() + .to_string() + .contains("must be a URL path") + ); + } + + fn route_metadata(path: &str) -> crate::metadata::RouteMetadata { + crate::metadata::RouteMetadata { + method: "get".to_string(), + path: path.to_string(), + function_name: "get_user".to_string(), + module_path: "routes::users".to_string(), + file_path: "users.rs".to_string(), + success_status: None, + error_status: None, + typed_responses: None, + tags: None, + security: None, + headers: Vec::new(), + operation_id: None, + summary: None, + request_example: None, + response_example: None, + deprecated: false, + description: None, + } + } + + #[test] + fn prefix_keeps_router_and_openapi_paths_identical_with_parameter() { + let mut metadata = CollectedMetadata::new(); + metadata.routes.push(route_metadata("/users/{user_id}")); + apply_export_prefix(&mut metadata, "/api/media-library"); + + let router = + crate::router_codegen::generate_router_code(&metadata, None, None, None, &[], &[]) + .to_string(); + let file_cache = std::collections::HashMap::from([( + "users.rs".to_string(), + syn::parse_file("pub async fn get_user() {}").unwrap(), + )]); + let openapi = crate::openapi_generator::generate_openapi_doc_with_metadata( + None, + None, + None, + None, + &metadata, + Some(file_cache), + &[], + ); + + let expected = "/api/media-library/users/{user_id}"; + assert_eq!(metadata.routes[0].path, expected); + assert!(router.contains(expected)); + assert!(openapi.paths.contains_key(expected)); + } + + #[test] + fn no_prefix_leaves_route_metadata_unchanged() { + let mut metadata = CollectedMetadata::new(); + metadata.routes.push(route_metadata("/users/{user_id}")); + let before = serde_json::to_vec(&metadata).unwrap(); + + apply_export_prefix(&mut metadata, ""); + + assert_eq!(serde_json::to_vec(&metadata).unwrap(), before); + } + + fn schema_metadata( + name: &str, + definition: &str, + explicit_schema_name: bool, + ) -> crate::metadata::StructMetadata { + crate::metadata::StructMetadata { + name: name.to_string(), + definition: definition.to_string(), + explicit_schema_name, + ..Default::default() + } + } + + fn schema_doc(path: &str, item_definition: &str) -> (CollectedMetadata, vespera_core::OpenApi) { + let mut metadata = CollectedMetadata::new(); + metadata.routes.push(route_metadata(path)); + metadata + .structs + .push(schema_metadata("Item", item_definition, false)); + metadata.structs.push(schema_metadata( + "SharedThing", + "struct SharedThing { code: String }", + true, + )); + let file_cache = std::collections::HashMap::from([( + "users.rs".to_string(), + syn::parse_file("pub async fn get_user() -> Item { todo!() }").unwrap(), + )]); + let openapi = crate::openapi_generator::generate_openapi_doc_with_metadata( + None, + None, + None, + None, + &metadata, + Some(file_cache), + &[], + ); + (metadata, openapi) + } + + fn schema_refs(value: &serde_json::Value, refs: &mut Vec) { + match value { + serde_json::Value::Object(object) => { + if let Some(serde_json::Value::String(reference)) = object.get("$ref") { + refs.push(reference.clone()); + } + for child in object.values() { + schema_refs(child, refs); + } + } + serde_json::Value::Array(values) => { + for child in values { + schema_refs(child, refs); + } + } + _ => {} + } + } + + fn assert_all_component_refs_resolve(openapi: &vespera_core::OpenApi) { + let value = serde_json::to_value(openapi).unwrap(); + let schemas = value + .pointer("/components/schemas") + .and_then(serde_json::Value::as_object) + .unwrap(); + let mut refs = Vec::new(); + schema_refs(&value, &mut refs); + for reference in refs { + if let Some(target) = reference.strip_prefix("#/components/schemas/") { + let name = target.split('/').next().unwrap(); + assert!( + schemas.contains_key(name), + "dangling schema ref: {reference}" + ); + } + } + } + + #[test] + fn prefix_namespaces_generated_schemas_and_preserves_explicit_names() { + let (metadata, mut openapi) = schema_doc("/items", "struct Item { id: i32 }"); + + let namespace = schema_namespace_from_prefix("/api/media-library"); + namespace_export_schemas(&mut openapi, &metadata, &namespace).unwrap(); + + let schemas = openapi + .components + .as_ref() + .and_then(|components| components.schemas.as_ref()) + .unwrap(); + assert_eq!(namespace, "MediaLibrary"); + assert!(schemas.contains_key("MediaLibraryItem")); + assert!(!schemas.contains_key("Item")); + assert!(schemas.contains_key("SharedThing")); + assert_all_component_refs_resolve(&openapi); + let json = serde_json::to_string(&openapi).unwrap(); + assert!(json.contains("#/components/schemas/MediaLibraryItem")); + } + + #[test] + fn differently_prefixed_apps_merge_distinct_schemas_and_refs() { + let (first_metadata, mut first) = + schema_doc("/api/media/items", "struct Item { media_id: i32 }"); + namespace_export_schemas(&mut first, &first_metadata, "Media").unwrap(); + let (second_metadata, mut second) = + schema_doc("/api/catalog/items", "struct Item { sku: String }"); + namespace_export_schemas(&mut second, &second_metadata, "Catalog").unwrap(); + + first.merge(second); + + let schemas = first + .components + .as_ref() + .and_then(|components| components.schemas.as_ref()) + .unwrap(); + assert!(schemas.contains_key("MediaItem")); + assert!(schemas.contains_key("CatalogItem")); + assert_all_component_refs_resolve(&first); + let json = serde_json::to_string(&first).unwrap(); + assert!(json.contains("#/components/schemas/MediaItem")); + assert!(json.contains("#/components/schemas/CatalogItem")); + } + + #[test] + fn empty_namespace_leaves_components_and_refs_byte_identical() { + let (metadata, mut openapi) = schema_doc("/items", "struct Item { id: i32 }"); + let before = serde_json::to_vec(&openapi).unwrap(); + + namespace_export_schemas(&mut openapi, &metadata, "").unwrap(); + + assert_eq!(serde_json::to_vec(&openapi).unwrap(), before); + } } diff --git a/crates/vespera_macro/src/schema_impl.rs b/crates/vespera_macro/src/schema_impl.rs index 438401f5..f1ef5511 100644 --- a/crates/vespera_macro/src/schema_impl.rs +++ b/crates/vespera_macro/src/schema_impl.rs @@ -232,6 +232,7 @@ pub fn process_derive_schema( // Check for custom schema settings from #[schema(...)] attributes in one pass. let schema_attr = collect_schema_attribute_summary(&input.attrs); + let has_explicit_schema_name = schema_attr.name.is_some(); let schema_name = schema_attr.name.unwrap_or_else(|| name.to_string()); // Extract default values from serde(default = "fn_name") attributes at derive time. @@ -249,6 +250,9 @@ pub fn process_derive_schema( // Schema-derived types appear in OpenAPI spec (include_in_openapi: true) let mut metadata = build_struct_metadata(input, schema_name, call_site_file.as_deref()); + if has_explicit_schema_name { + metadata.explicit_schema_name = true; + } if schema_attr.has_ref_override { metadata.include_in_openapi = false; } diff --git a/crates/vespera_macro/src/schema_impl/tests.rs b/crates/vespera_macro/src/schema_impl/tests.rs index 22a40c2f..ce9f9519 100644 --- a/crates/vespera_macro/src/schema_impl/tests.rs +++ b/crates/vespera_macro/src/schema_impl/tests.rs @@ -78,6 +78,20 @@ fn test_extract_schema_name_attr_with_name() { assert_eq!(result, Some("CustomName".to_string())); } +#[test] +fn explicit_schema_name_is_retained_in_metadata() { + let input: syn::DeriveInput = syn::parse_quote! { + #[schema(name = "SharedThing")] + struct Item { id: i32 } + }; + + let (metadata, _) = process_derive_schema(&input); + + let metadata = metadata.expect("derive metadata"); + assert_eq!(metadata.name, "SharedThing"); + assert!(metadata.explicit_schema_name); +} + #[test] fn test_extract_schema_name_attr_without_name() { let attrs: Vec = syn::parse_quote! { diff --git a/crates/vespera_macro/src/schema_macro/defaults/tests.rs b/crates/vespera_macro/src/schema_macro/defaults/tests.rs index 9ab05177..254acfa1 100644 --- a/crates/vespera_macro/src/schema_macro/defaults/tests.rs +++ b/crates/vespera_macro/src/schema_macro/defaults/tests.rs @@ -642,6 +642,7 @@ fn test_generate_schema_type_code_preserves_struct_doc() { " .to_string(), include_in_openapi: true, + explicit_schema_name: false, field_defaults: std::collections::BTreeMap::new(), source_identity: None, }; diff --git a/crates/vespera_macro/src/vespera_impl/cache.rs b/crates/vespera_macro/src/vespera_impl/cache.rs index 7eec2120..f8afe6f6 100644 --- a/crates/vespera_macro/src/vespera_impl/cache.rs +++ b/crates/vespera_macro/src/vespera_impl/cache.rs @@ -307,11 +307,12 @@ fn hash_security_scheme(scheme: &SecurityScheme, hasher: &mut impl Hasher) { } /// Compute a deterministic hash for `export_app!` inputs. -pub(super) fn compute_export_config_hash(app_name: &str, folder_name: &str) -> u64 { +pub(super) fn compute_export_config_hash(app_name: &str, folder_name: &str, prefix: &str) -> u64 { let mut hasher = std::collections::hash_map::DefaultHasher::new(); "export_app:v1".hash(&mut hasher); app_name.hash(&mut hasher); folder_name.hash(&mut hasher); + prefix.hash(&mut hasher); hasher.finish() } @@ -334,14 +335,18 @@ pub(super) fn get_cache_path() -> std::path::PathBuf { } /// Get the path to this crate/app/folder's `export_app!` route cache file. -pub(super) fn get_export_cache_path(app_name: &str, folder_name: &str) -> std::path::PathBuf { +pub(super) fn get_export_cache_path( + app_name: &str, + folder_name: &str, + prefix: &str, +) -> std::path::PathBuf { let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default(); let manifest_path = Path::new(&manifest_dir); find_target_dir(manifest_path).join("vespera").join(format!( "export-routes-{}-{}-{:016x}.cache", current_crate_tag(), app_name, - compute_export_config_hash(app_name, folder_name) + compute_export_config_hash(app_name, folder_name, prefix) )) } @@ -620,10 +625,14 @@ mod tests { #[test] fn export_config_hash_is_namespaced_by_app_and_folder() { - let base = compute_export_config_hash("ThirdApp", "routes"); + let base = compute_export_config_hash("ThirdApp", "routes", ""); - assert_ne!(base, compute_export_config_hash("AdminApp", "routes")); - assert_ne!(base, compute_export_config_hash("ThirdApp", "api")); + assert_ne!(base, compute_export_config_hash("AdminApp", "routes", "")); + assert_ne!(base, compute_export_config_hash("ThirdApp", "api", "")); + assert_ne!( + base, + compute_export_config_hash("ThirdApp", "routes", "/api") + ); } #[test] diff --git a/crates/vespera_macro/src/vespera_impl/orchestrator.rs b/crates/vespera_macro/src/vespera_impl/orchestrator.rs index 9a5fc663..76f4ed15 100644 --- a/crates/vespera_macro/src/vespera_impl/orchestrator.rs +++ b/crates/vespera_macro/src/vespera_impl/orchestrator.rs @@ -9,7 +9,10 @@ use quote::quote; use crate::{ metadata::{CollectedMetadata, StructMetadata}, route_impl::StoredRouteInfo, - router_codegen::{ProcessedVesperaInput, generate_router_code}, + router_codegen::{ + ProcessedVesperaInput, apply_export_prefix, generate_router_code, namespace_export_schemas, + schema_namespace_from_prefix, + }, }; use super::{ @@ -302,6 +305,27 @@ fn cron_module_path(relative: &str) -> String { module_path } +fn namespace_and_serialize_export_openapi( + openapi: &mut vespera_core::OpenApi, + metadata: &CollectedMetadata, + prefix: &str, +) -> syn::Result { + let schema_namespace = schema_namespace_from_prefix(prefix); + namespace_export_schemas(openapi, metadata, &schema_namespace)?; + serde_json::to_string(openapi).map_err(|error| syn::Error::new(Span::call_site(), format!("export_app! macro: failed to serialize OpenAPI spec to JSON. Error: {error}. Check that all schema types are serializable."))) +} + +fn finish_export_metadata( + mut metadata: CollectedMetadata, + schema_storage: &HashMap, + route_storage: &[StoredRouteInfo], + prefix: &str, +) -> syn::Result { + finalize_metadata(&mut metadata, schema_storage, route_storage, "export_app!")?; + apply_export_prefix(&mut metadata, prefix); + Ok(metadata) +} + /// Process `export_app` macro - extracted for testability pub fn process_export_app( name: &syn::Ident, @@ -309,6 +333,7 @@ pub fn process_export_app( schema_storage: &HashMap, manifest_dir: &str, route_storage: &[StoredRouteInfo], + prefix: &str, folder_span: Span, ) -> syn::Result { let profile_start = if std::env::var("VESPERA_PROFILE").is_ok() { @@ -332,12 +357,12 @@ pub fn process_export_app( let target_dir = find_target_dir(manifest_path); let vespera_dir = target_dir.join("vespera"); let spec_file = vespera_dir.join(format!("{app_name}.openapi.json")); - let cache_path = get_export_cache_path(&app_name, folder_name); + let cache_path = get_export_cache_path(&app_name, folder_name, prefix); let scanned = crate::collector::scan_route_folder(&folder_path) .map_err(|e| syn::Error::new(Span::call_site(), format!("export_app! macro: {e}")))?; let fingerprints = crate::collector::fingerprints_from_scan(&scanned); let schema_hash = compute_schema_hash(schema_storage); - let config_hash = compute_export_config_hash(&app_name, folder_name); + let config_hash = compute_export_config_hash(&app_name, folder_name, prefix); let macro_version = env!("CARGO_PKG_VERSION").to_string(); let macro_dev_fingerprint = compute_macro_dev_fingerprint(); let cache_key = CacheKey { @@ -348,8 +373,6 @@ pub fn process_export_app( config_hash, }; let cached = read_cache(&cache_path); - // Shared header clauses come from `is_fresh`; only the `export_app!`-specific - // spec sidecar check stays here. let cache_hit = cached.as_ref().is_some_and(|c| { c.is_fresh(&cache_key) && sidecar_matches(&spec_file, c.spec_json_hash, c.spec_json_fingerprint) @@ -362,20 +385,17 @@ pub fn process_export_app( // each branch returns the already-extended `CollectedMetadata` and the // duplicated outer pass is removed. let metadata = if let (true, Some(cache)) = (cache_hit, cached) { - let mut metadata = cache.metadata; - finalize_metadata(&mut metadata, schema_storage, route_storage, "export_app!")?; - metadata + finish_export_metadata(cache.metadata, schema_storage, route_storage, prefix)? } else { let (mut metadata, file_asts) = crate::collector::collect_metadata_from_files(scanned.iter().map(|(path, _)| path.as_path()), &folder_path, folder_name, route_storage).map_err(|e| syn::Error::new(Span::call_site(), format!("export_app! macro: failed to scan route folder '{folder_name}'. Error: {e}. Check that all .rs files have valid Rust syntax.")))?; let cache_metadata = metadata.clone(); - finalize_metadata(&mut metadata, schema_storage, route_storage, "export_app!")?; + metadata = finish_export_metadata(metadata, schema_storage, route_storage, prefix)?; // B2: same-file extractor structs without `#[derive(Schema)]` would be // silently dropped from the spec — reject them at compile time. crate::parser::validate_schema_backed_extractors_with_cache(&metadata, &file_asts)?; - // Generate OpenAPI spec JSON string - let openapi_doc = crate::openapi_generator::try_generate_openapi_doc_with_metadata( + let mut openapi_doc = crate::openapi_generator::try_generate_openapi_doc_with_metadata( None, None, None, @@ -384,7 +404,8 @@ pub fn process_export_app( Some(file_asts), route_storage, )?; - let spec_json = serde_json::to_string(&openapi_doc).map_err(|e| syn::Error::new(Span::call_site(), format!("export_app! macro: failed to serialize OpenAPI spec to JSON. Error: {e}. Check that all schema types are serializable.")))?; + let spec_json = + namespace_and_serialize_export_openapi(&mut openapi_doc, &metadata, prefix)?; // Write spec to temp file for compile-time merging by parent apps std::fs::create_dir_all(&vespera_dir).map_err(|e| syn::Error::new(Span::call_site(), format!("export_app! macro: failed to create build cache directory '{}'. Error: {}. Ensure the target directory is writable.", vespera_dir.display(), e)))?; @@ -410,7 +431,6 @@ pub fn process_export_app( }; let spec_path_str = crate::file_utils::path_to_include_str_literal(&spec_file); - // Generate router code (without docs routes, no merge) let router_code = generate_router_code(&metadata, None, None, None, &[], &[]); let result = Ok(quote! { diff --git a/crates/vespera_macro/src/vespera_impl/orchestrator/tests.rs b/crates/vespera_macro/src/vespera_impl/orchestrator/tests.rs index 764071a0..6f6cdfd8 100644 --- a/crates/vespera_macro/src/vespera_impl/orchestrator/tests.rs +++ b/crates/vespera_macro/src/vespera_impl/orchestrator/tests.rs @@ -229,6 +229,7 @@ fn test_process_export_app_folder_not_found() { &HashMap::new(), &temp_dir.path().to_string_lossy(), &[], + "", Span::call_site(), ); assert!(result.is_err()); @@ -253,6 +254,7 @@ fn test_process_export_app_with_empty_folder() { &HashMap::new(), &temp_dir.path().to_string_lossy(), &[], + "", Span::call_site(), ); // We only care about exercising the code path @@ -283,6 +285,7 @@ fn test_process_export_app_with_schema_storage() { &schema_storage, &temp_dir.path().to_string_lossy(), &[], + "", Span::call_site(), ); // Exercises the schema_storage.extend path @@ -306,6 +309,7 @@ fn test_process_export_app_collect_metadata_error() { &HashMap::new(), &temp_dir.path().to_string_lossy(), &[], + "", Span::call_site(), ); @@ -336,6 +340,7 @@ fn test_process_export_app_create_dir_error() { &HashMap::new(), &temp_dir.path().to_string_lossy(), &[], + "", Span::call_site(), ); @@ -368,6 +373,7 @@ fn test_process_export_app_write_spec_error() { &HashMap::new(), &temp_dir.path().to_string_lossy(), &[], + "", Span::call_site(), ); @@ -456,6 +462,7 @@ fn test_process_export_app_with_profiling() { &HashMap::new(), &temp_dir.path().to_string_lossy(), &[], + "", Span::call_site(), ); @@ -574,6 +581,7 @@ fn process_export_app_reuses_fresh_cache_and_sidecar() { &HashMap::new(), &temp.path().to_string_lossy(), &[], + "", Span::call_site(), ) .expect("first expansion populates cache"); @@ -583,6 +591,7 @@ fn process_export_app_reuses_fresh_cache_and_sidecar() { &HashMap::new(), &temp.path().to_string_lossy(), &[], + "", Span::call_site(), ) .expect("second expansion reuses cache"); From 9210943d2da3ad646414ac2acbe659d18d38dc71 Mon Sep 17 00:00:00 2001 From: devfive Date: Sun, 30 Aug 2026 15:01:38 +0900 Subject: [PATCH 2/2] Release all Vespera packages as minor --- .changepacks/changepack_log_export-app-prefix.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changepacks/changepack_log_export-app-prefix.json b/.changepacks/changepack_log_export-app-prefix.json index f923e606..ee74680f 100644 --- a/.changepacks/changepack_log_export-app-prefix.json +++ b/.changepacks/changepack_log_export-app-prefix.json @@ -1 +1 @@ -{"changes":{"crates/vespera_macro/Cargo.toml":"Minor"},"note":"Add an explicit export_app! prefix option that namespaces routes, OpenAPI paths, and generated component schemas.","date":"2026-08-29T17:10:35.446Z"} +{"changes":{"Cargo.toml":"Minor","libs/vespera-bridge/build.gradle.kts":"Minor","libs/vespera-bridge-gradle-plugin/build.gradle.kts":"Minor"},"note":"Add an explicit export_app! prefix option that namespaces routes, OpenAPI paths, and generated component schemas.","date":"2026-08-29T17:10:35.446Z"}