diff --git a/Cargo.toml b/Cargo.toml index a3540ca..e28fb89 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,7 +63,7 @@ tower-sessions = { version = "0.15", optional = true } validator = { version = "0.21", features = ["derive"], optional = true } garde = { version = "0.23", features = ["derive", "email"], optional = true } -ts-rs = { version = "12", optional = true } +ts-rs = { version = "11", optional = true } inventory = { version = "0.3", optional = true } [dev-dependencies] diff --git a/docs/props.md b/docs/props.md index ce77b2f..be0c3d2 100644 --- a/docs/props.md +++ b/docs/props.md @@ -179,3 +179,26 @@ let cfg = InertiaConfig::new().preserve_big_integers(true); ``` Integers inside the safe range stay normal numbers, so the same prop can arrive as a `number` or a `bigint`. Flash data gets the same treatment. + +## Application HTTP errors (fork) + +Use `try_prop` and `try_optional` when the application's error implements Axum's +`IntoResponse`. Unlike `Prop::try_new`, these preserve its status, headers, and body. +Authorization of the route should run before declaring the response. + +```rust,ignore +inertia.render("Estates/Show", json!({})) + .try_prop("estate", move || async move { load_overview(actor, id).await }) + .try_optional("contact_picker", move || async move { search_contacts(actor, query).await }) +``` + +`Prop::try_response(loader)` supplies the same behavior with composable modifiers. +`Prop::try_scroll(loader)` / `response.try_scroll(key, loader)` resolve the page +value and `ScrollMetadata` together. Neither the loader nor its metadata runs +when the prop is excluded. Optional props require an explicit `only` request; +an `except`-only request does not load them. + +The fork retains `SharedPropsData`: its `.prop(key, Prop)` uses the same resolver +as page props. A page's explicit value or closure overrides a shared prop with +the same key. `.once_as()` and `.lazy()` are conveniences for scoped remembered +props and on-demand props. diff --git a/docs/upgrading.md b/docs/upgrading.md index 35cc63a..513e71c 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -54,16 +54,12 @@ Flash data is now the top-level `flash` field of the page object. This is what t No change on the Rust side: `with_flash` works as before. -## `ts-rs` 12 +## TypeScript bindings in this fork -If you use the `ts` feature, update `ts-rs`: - -```diff -- ts-rs = "11" -+ ts-rs = "12" -``` - -Then generate the bindings again. The generated `PageObject` and `Flash` types changed. +This fork retains `ts-rs` 11 to match its consuming application packages. It also +retains `register_page!(Props, "Page", shared = SharedProps)` and `register_type!`. +Upstream's ordinary closure props, composable `Prop`, and protocol changes are +used directly; the old scroll response helpers and `reset_merge` are removed. ## Route paths diff --git a/examples/axum-react-todo/Cargo.toml b/examples/axum-react-todo/Cargo.toml index 9497ee8..83e8308 100644 --- a/examples/axum-react-todo/Cargo.toml +++ b/examples/axum-react-todo/Cargo.toml @@ -17,4 +17,4 @@ tower = "0.5" tracing = "0.1" tracing-subscriber = "0.3" validator = { version = "0.21", features = ["derive"] } -ts-rs = "12" +ts-rs = "11" diff --git a/src/adapters/axum/form.rs b/src/adapters/axum/form.rs index 5a15417..568377d 100644 --- a/src/adapters/axum/form.rs +++ b/src/adapters/axum/form.rs @@ -101,8 +101,9 @@ where .await .map_err(|e| InertiaFormRejection::MultipartTransport(e.to_string()))?; let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes); - map.insert( - name, + insert_multipart_value( + &mut map, + &name, serde_json::json!({ "__veer_uploaded_file__": true, "filename": filename, @@ -115,13 +116,52 @@ where .text() .await .map_err(|e| InertiaFormRejection::MultipartTransport(e.to_string()))?; - map.insert(name, serde_json::Value::String(text)); + insert_multipart_value(&mut map, &name, serde_json::Value::String(text)); } } Ok(serde_json::Value::Object(map)) } +#[cfg(feature = "multipart")] +fn insert_multipart_value( + map: &mut serde_json::Map, + field_name: &str, + value: serde_json::Value, +) { + let Some((name, index)) = multipart_array_field(field_name) else { + map.insert(field_name.to_string(), value); + return; + }; + + let values = map + .entry(name.to_string()) + .or_insert_with(|| serde_json::Value::Array(Vec::new())); + let serde_json::Value::Array(values) = values else { + return; + }; + + if let Some(index) = index { + values.resize(index + 1, serde_json::Value::Null); + values[index] = value; + } else { + values.push(value); + } +} + +#[cfg(feature = "multipart")] +fn multipart_array_field(field_name: &str) -> Option<(&str, Option)> { + let (name, index) = field_name.rsplit_once('[')?; + let index = index.strip_suffix(']')?; + if name.is_empty() { + return None; + } + if index.is_empty() { + return Some((name, None)); + } + Some((name, Some(index.parse().ok()?))) +} + /// Rejection from [`InertiaForm`]. Renders as a `4xx` HTTP response. #[derive(Debug)] pub enum InertiaFormRejection { diff --git a/src/adapters/axum/response.rs b/src/adapters/axum/response.rs index 16c800c..d1d0825 100644 --- a/src/adapters/axum/response.rs +++ b/src/adapters/axum/response.rs @@ -103,10 +103,12 @@ pub(crate) async fn finalize( } // Shared props, plus the always-present `errors`. - let mut shared = match &cfg.shared { - Some(s) => serialize_tag_aware(&s.shared(req_info).await).unwrap_or_default(), - None => Default::default(), + let shared_data = match &cfg.shared { + Some(s) => s.shared(req_info).await, + None => crate::SharedPropsData::new(json!({})), }; + let shared_closures = shared_data.props; + let mut shared = serialize_tag_aware(&shared_data.value).unwrap_or_default(); if !shared.value.is_object() { shared.value = Value::Object(Map::new()); } @@ -139,6 +141,13 @@ pub(crate) async fn finalize( Default::default() }); + for (key, prop) in shared_closures { + shared_keys.push(key.clone()); + if !builder.props.contains_key(&key) && base.value.get(&key).is_none() { + builder.props.insert(key, prop); + } + } + // Shared once props. A handler prop with the same key wins. for (key, f) in &cfg.shared_once { shared_keys.push(key.clone()); @@ -164,6 +173,9 @@ pub(crate) async fn finalize( let resolved = match resolved { Ok(resolved) => resolved, Err(error) => { + if let crate::props::prop::LoadError::Response(response) = error.error { + return failed(response, per).await; + } // The error comes from application code at run time (a database // error, for example), so it does not go in the body. tracing::error!(%error, "veer: prop failed to resolve"); diff --git a/src/adapters/axum/router.rs b/src/adapters/axum/router.rs index 7ad074f..73ebdd8 100644 --- a/src/adapters/axum/router.rs +++ b/src/adapters/axum/router.rs @@ -133,7 +133,7 @@ where /// Same-path multi-method calls (e.g. GET and POST on `/todos`) are /// merged into one [`MethodRouter`] — no extra ceremony required. pub fn named_route( - mut self, + self, method: Method, name: &'static str, path: &'static str, @@ -143,13 +143,26 @@ where H: Handler, T: 'static, { - let mr = method.into_router(handler); + self.named_method_router(method, name, path, method.into_router(handler)) + } + + /// Mount a preconfigured named method router and record it for TypeScript bindings. + /// + /// This is useful when one generated route needs an Axum-specific setting such as + /// a larger request body limit. + pub fn named_method_router( + mut self, + method: Method, + name: &'static str, + path: &'static str, + method_router: MethodRouter, + ) -> Self { match self.routes.swap_remove(path) { Some(prev) => { - self.routes.insert(path, prev.merge(mr)); + self.routes.insert(path, prev.merge(method_router)); } None => { - self.routes.insert(path, mr); + self.routes.insert(path, method_router); } } self.names.push(NamedRoute { diff --git a/src/bindings/mod.rs b/src/bindings/mod.rs index 06736e3..45bda65 100644 --- a/src/bindings/mod.rs +++ b/src/bindings/mod.rs @@ -93,6 +93,19 @@ pub trait InertiaPageProps { /// ``` #[macro_export] macro_rules! register_page { + ($ty:ty, $component:literal, shared = $shared:ty) => { + impl $crate::bindings::InertiaPageProps for $ty { + const COMPONENT: &'static str = $component; + } + $crate::__private::inventory::submit! { + $crate::bindings::PageEntry { + component: $component, + ts_name: || <$ty as $crate::__private::ts_rs::TS>::ident(), + collect_decls: |out| $crate::bindings::collect_page_decls::<$ty, $shared>(out), + } + } + }; + ($ty:ty, $component:literal, $closure_ty:ty) => { impl $crate::bindings::InertiaPageProps for $ty { const COMPONENT: &'static str = $component; @@ -101,11 +114,10 @@ macro_rules! register_page { $crate::bindings::PageEntry { component: $component, ts_name: || { - let cfg = $crate::__private::ts_rs::Config::from_env(); format!( "{} & {}", - <$ty as $crate::__private::ts_rs::TS>::ident(&cfg), - <$closure_ty as $crate::__private::ts_rs::TS>::ident(&cfg), + <$ty as $crate::__private::ts_rs::TS>::ident(), + <$closure_ty as $crate::__private::ts_rs::TS>::ident(), ) }, collect_decls: |out| { @@ -123,9 +135,7 @@ macro_rules! register_page { $crate::bindings::PageEntry { component: $component, ts_name: || { - <$ty as $crate::__private::ts_rs::TS>::ident( - &$crate::__private::ts_rs::Config::from_env(), - ) + <$ty as $crate::__private::ts_rs::TS>::ident() }, collect_decls: |out| $crate::bindings::collect_decls::<$ty>(out), } @@ -133,6 +143,22 @@ macro_rules! register_page { }; } +/// Register a Rust type for TypeScript generation without associating it with +/// an Inertia page component. +/// +/// Use this for typed action payloads and other frontend contracts that are +/// not reachable through a registered page's props. +#[macro_export] +macro_rules! register_type { + ($ty:ty) => { + $crate::__private::inventory::submit! { + $crate::bindings::TypeEntry { + collect_decls: |out| $crate::bindings::collect_decls::<$ty>(out), + } + } + }; +} + /// An entry submitted by `#[derive(InertiaPage)]` into the link-time registry. /// /// You normally never construct this by hand. @@ -148,6 +174,16 @@ pub struct PageEntry { inventory::collect!(PageEntry); +/// A standalone TypeScript binding submitted through [`register_type!`](crate::register_type). +/// +/// You normally never construct this by hand. +pub struct TypeEntry { + /// Populates the output with this type and its transitive dependencies. + pub collect_decls: fn(&mut HashMap), +} + +inventory::collect!(TypeEntry); + /// Walk a TS-derived type and all its dependencies, populating `out` with /// `(ts_name, decl)` pairs. Used internally by the derive-emitted /// `collect_decls` closures. @@ -160,8 +196,7 @@ pub fn collect_decls(out: &mut HashMap(out: &mut HashMap( + out: &mut HashMap, +) { + collect_decls::(out); + collect_decls::(out); + let own_props = T::inline(); + // ts-rs uses an empty index signature for empty Rust structs. Intersecting + // that with shared fields would forbid those fields and break Vue's compiler. + let contract = if own_props == "Record" { + S::ident() + } else { + format!("{} & {}", S::ident(), own_props) + }; + out.insert( + TypeId::of::(), + (T::ident(), format!("type {} = {};", T::ident(), contract)), + ); +} + /// Errors that can occur while emitting the bindings file. #[derive(Debug, thiserror::Error)] pub enum GenerateError { @@ -405,6 +460,9 @@ fn render_prop_decls_and_pages() -> String { pages.insert(entry.component, (entry.ts_name)()); } + for entry in inventory::iter::() { + (entry.collect_decls)(&mut decls); + } let mut ordered: Vec<(String, String)> = decls .into_values() .filter(|(name, _)| !name.is_empty()) @@ -512,3 +570,41 @@ mod tests { std::fs::remove_dir_all(&dir).unwrap(); } } +#[cfg(test)] +mod shared_contract_tests { + use super::*; + #[derive(serde::Serialize, ts_rs::TS)] + pub struct Shared { + pub user: String, + } + #[derive(serde::Serialize, ts_rs::TS)] + pub struct Page { + pub title: String, + } + + #[derive(serde::Serialize, ts_rs::TS)] + struct EmptyPage {} + + #[test] + fn shared_only_page_has_no_empty_index_signature() { + let mut declarations = HashMap::new(); + collect_page_decls::(&mut declarations); + assert_eq!( + declarations[&TypeId::of::()].1, + "type EmptyPage = Shared;" + ); + } + + #[test] + fn shared_page_contract_collects_both_types() { + let mut declarations = HashMap::new(); + collect_page_decls::(&mut declarations); + assert_eq!( + declarations[&TypeId::of::()].1, + "type Page = Shared & { title: string, };" + ); + assert!(declarations[&TypeId::of::()] + .1 + .contains("user: string")); + } +} diff --git a/src/config.rs b/src/config.rs index f28e2c7..55f9910 100644 --- a/src/config.rs +++ b/src/config.rs @@ -257,6 +257,6 @@ mod tests { let req = RequestInfo::from_parts(http::Method::GET, "/".into(), &http::HeaderMap::new()) .with_extensions(Arc::new(extensions)); let shared = c.shared.unwrap().shared(&req).await; - assert_eq!(shared, serde_json::json!({ "user": "ada" })); + assert_eq!(shared.value, serde_json::json!({ "user": "ada" })); } } diff --git a/src/lib.rs b/src/lib.rs index 02f15ea..3b3d063 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -112,7 +112,7 @@ pub use root_view::{ MinimalRootView, RootView, RootViewContext, ViteManifest, ViteManifestError, ViteRootView, }; pub use session::{Flash, SessionStore}; -pub use shared::SharedProps; +pub use shared::{SharedProps, SharedPropsData}; pub use ssr::{SsrClient, SsrPayload}; #[cfg(feature = "axum")] diff --git a/src/props/always.rs b/src/props/always.rs index 81c8b36..071cc07 100644 --- a/src/props/always.rs +++ b/src/props/always.rs @@ -34,17 +34,17 @@ impl Serialize for Always { impl ts_rs::TS for Always { type WithoutGenerics = ::WithoutGenerics; type OptionInnerType = ::OptionInnerType; - fn ident(cfg: &ts_rs::Config) -> String { - ::ident(cfg) + fn ident() -> String { + ::ident() } - fn name(cfg: &ts_rs::Config) -> String { - ::name(cfg) + fn name() -> String { + ::name() } - fn inline(cfg: &ts_rs::Config) -> String { - ::inline(cfg) + fn inline() -> String { + ::inline() } - fn inline_flattened(cfg: &ts_rs::Config) -> String { - ::inline_flattened(cfg) + fn inline_flattened() -> String { + ::inline_flattened() } fn visit_dependencies(v: &mut impl ts_rs::TypeVisitor) where @@ -58,11 +58,11 @@ impl ts_rs::TS for Always { { ::visit_generics(v); } - fn decl(cfg: &ts_rs::Config) -> String { - ::decl(cfg) + fn decl() -> String { + ::decl() } - fn decl_concrete(cfg: &ts_rs::Config) -> String { - ::decl_concrete(cfg) + fn decl_concrete() -> String { + ::decl_concrete() } fn output_path() -> Option { ::output_path() diff --git a/src/props/merge.rs b/src/props/merge.rs index 458901f..6cbedad 100644 --- a/src/props/merge.rs +++ b/src/props/merge.rs @@ -36,17 +36,17 @@ impl Serialize for Merge { impl ts_rs::TS for Merge { type WithoutGenerics = ::WithoutGenerics; type OptionInnerType = ::OptionInnerType; - fn ident(cfg: &ts_rs::Config) -> String { - ::ident(cfg) + fn ident() -> String { + ::ident() } - fn name(cfg: &ts_rs::Config) -> String { - ::name(cfg) + fn name() -> String { + ::name() } - fn inline(cfg: &ts_rs::Config) -> String { - ::inline(cfg) + fn inline() -> String { + ::inline() } - fn inline_flattened(cfg: &ts_rs::Config) -> String { - ::inline_flattened(cfg) + fn inline_flattened() -> String { + ::inline_flattened() } fn visit_dependencies(v: &mut impl ts_rs::TypeVisitor) where @@ -60,11 +60,11 @@ impl ts_rs::TS for Merge { { ::visit_generics(v); } - fn decl(cfg: &ts_rs::Config) -> String { - ::decl(cfg) + fn decl() -> String { + ::decl() } - fn decl_concrete(cfg: &ts_rs::Config) -> String { - ::decl_concrete(cfg) + fn decl_concrete() -> String { + ::decl_concrete() } fn output_path() -> Option { ::output_path() diff --git a/src/props/prop.rs b/src/props/prop.rs index 608a2d3..f28226c 100644 --- a/src/props/prop.rs +++ b/src/props/prop.rs @@ -23,8 +23,20 @@ pub(crate) fn to_json(value: T) -> Value { }) } +/// Failure produced by a prop loader. +#[derive(Debug, thiserror::Error)] +pub enum LoadError { + /// An ordinary loader failure, rendered as a server error. + #[error("{0}")] + Message(String), + /// An application error response, preserved by the Axum adapter. + #[cfg(feature = "axum")] + #[error("application response: {}", .0.status())] + Response(axum::response::Response), +} + type Loader = - Box Pin> + Send>> + Send>; + Box Pin> + Send>> + Send>; /// When a prop is resolved on a full (non-partial) visit. #[derive(Debug, Clone, PartialEq, Eq)] @@ -170,12 +182,56 @@ impl Prop { value: to_json(value), scroll: None, }), - Err(e) => Err(e.to_string()), + Err(e) => Err(LoadError::Message(e.to_string())), } }) })) } + /// A fallible prop whose error is an application HTTP response. + #[cfg(feature = "axum")] + pub fn try_response(f: F) -> Self + where + F: FnOnce() -> Fut + Send + 'static, + Fut: Future> + Send + 'static, + T: Serialize, + E: axum::response::IntoResponse, + { + Self::from_loader(Box::new(|| { + Box::pin(async { + f().await + .map(|value| Loaded { + value: to_json(value), + scroll: None, + }) + .map_err(|e| LoadError::Response(e.into_response())) + }) + })) + } + + /// A fallible scroll loader that preserves application HTTP errors. + #[cfg(feature = "axum")] + pub fn try_scroll(f: F) -> Self + where + F: FnOnce() -> Fut + Send + 'static, + Fut: Future> + Send + 'static, + T: Serialize, + E: axum::response::IntoResponse, + { + let mut prop = Self::from_loader(Box::new(|| { + Box::pin(async { + f().await + .map(|(value, metadata)| Loaded { + value: to_json(value), + scroll: Some(metadata), + }) + .map_err(|e| LoadError::Response(e.into_response())) + }) + })); + prop.scroll_wrapper = Some("data".into()); + prop + } + /// An infinite-scroll prop. The closure returns the page value (an object /// that holds the items under the wrapper key, `data` by default) and its /// cursor metadata. diff --git a/src/props/resolver.rs b/src/props/resolver.rs index 73650a7..024d434 100644 --- a/src/props/resolver.rs +++ b/src/props/resolver.rs @@ -36,13 +36,13 @@ pub struct ResolvedProps { } /// A [`Prop::try_new`] closure failed and the prop has no [`Prop::rescue`]. -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -#[error("prop `{prop}` failed to resolve: {message}")] +#[derive(Debug, thiserror::Error)] +#[error("prop `{prop}` failed to resolve: {error}")] pub struct PropError { /// The prop key. pub prop: String, /// The error that the closure returned. - pub message: String, + pub error: super::prop::LoadError, } /// Merge labels attached by path through the response builder. @@ -400,7 +400,7 @@ pub async fn resolve(input: ResolveInput<'_>) -> Result) -> Result return Err(PropError { prop: key, message }), + Err(error) => return Err(PropError { prop: key, error }), } } @@ -601,7 +601,7 @@ mod tests { vec![("lazy", value(json!(3)).optional())], ) .await; - assert_eq!(r.props, json!({"a": 1, "lazy": 3})); + assert_eq!(r.props, json!({"a": 1})); } #[tokio::test] diff --git a/src/response.rs b/src/response.rs index 2cdda95..bb2b816 100644 --- a/src/response.rs +++ b/src/response.rs @@ -76,6 +76,42 @@ impl InertiaResponse { self } + /// Attach an ordinary fallible prop, preserving application HTTP errors. + #[cfg(feature = "axum")] + pub fn try_prop(self, key: impl Into, f: F) -> Self + where + F: FnOnce() -> Fut + Send + 'static, + Fut: Future> + Send + 'static, + T: serde::Serialize, + E: axum::response::IntoResponse, + { + self.prop(key, Prop::try_response(f)) + } + + /// Attach an optional fallible prop, preserving application HTTP errors. + #[cfg(feature = "axum")] + pub fn try_optional(self, key: impl Into, f: F) -> Self + where + F: FnOnce() -> Fut + Send + 'static, + Fut: Future> + Send + 'static, + T: serde::Serialize, + E: axum::response::IntoResponse, + { + self.prop(key, Prop::try_response(f).optional()) + } + + /// Load a page and its scroll metadata only when selected. + #[cfg(feature = "axum")] + pub fn try_scroll(self, key: impl Into, f: F) -> Self + where + F: FnOnce() -> Fut + Send + 'static, + Fut: Future> + Send + 'static, + T: serde::Serialize, + E: axum::response::IntoResponse, + { + self.prop(key, Prop::try_scroll(f)) + } + /// Attach a lazy prop (default-excluded; included only on partial reload that names it). /// /// Inertia v3 calls this concept "optional". `lazy()` is the preferred method name in diff --git a/src/shared.rs b/src/shared.rs index 8d15f9b..351d6d7 100644 --- a/src/shared.rs +++ b/src/shared.rs @@ -3,43 +3,102 @@ use crate::request::RequestInfo; use async_trait::async_trait; use serde_json::Value; +use std::collections::HashMap; use std::future::Future; use std::sync::Arc; +/// Shared values and on-demand resolvers, merged underneath page props. +pub struct SharedPropsData { + pub(crate) value: Value, + pub(crate) props: HashMap, +} + +impl SharedPropsData { + /// Start with eagerly resolved shared values. + pub fn new(value: Value) -> Self { + Self { + value, + props: HashMap::new(), + } + } + /// Attach a closure prop using the same semantics as page props. + pub fn prop(mut self, key: impl Into, prop: crate::Prop) -> Self { + self.props.insert(key.into(), prop); + self + } + /// Remember a shared prop across visits. + pub fn once(self, key: impl Into, f: F) -> Self + where + F: FnOnce() -> Fut + Send + 'static, + Fut: Future + Send + 'static, + { + self.prop(key, crate::Prop::new(f).once()) + } + /// Remember a shared prop under a scoped cache key. + pub fn once_as(self, key: impl Into, cache_key: impl Into, f: F) -> Self + where + F: FnOnce() -> Fut + Send + 'static, + Fut: Future + Send + 'static, + { + self.prop(key, crate::Prop::new(f).once_as(cache_key)) + } + /// Include a shared prop only when explicitly requested. + pub fn lazy(self, key: impl Into, f: F) -> Self + where + F: FnOnce() -> Fut + Send + 'static, + Fut: Future + Send + 'static, + { + self.prop(key, crate::Prop::new(f).optional()) + } +} + +impl From for SharedPropsData { + fn from(value: Value) -> Self { + Self::new(value) + } +} + /// Resolves shared props on each request. +/// +/// Middleware-installed request values, such as a `tower_sessions::Session`, +/// can be read through [`RequestInfo::extension`]. #[async_trait] pub trait SharedProps: Send + Sync { /// Produce the shared props for this request. - async fn shared(&self, req: &RequestInfo) -> Value; + async fn shared(&self, req: &RequestInfo) -> SharedPropsData; } -/// Adapter for `Fn(&RequestInfo) -> impl Future`. -pub struct FnSharedProps(pub F); - #[async_trait] -impl SharedProps for FnSharedProps +impl

SharedProps for Arc

where - F: Fn(&RequestInfo) -> Fut + Send + Sync, - Fut: Future + Send, + P: SharedProps + ?Sized, { - async fn shared(&self, req: &RequestInfo) -> Value { - (self.0)(req).await + async fn shared(&self, req: &RequestInfo) -> SharedPropsData { + self.as_ref().shared(req).await } } +/// Adapter for a resolver returning JSON values or [`SharedPropsData`]. +pub struct FnSharedProps(pub F); + #[async_trait] -impl SharedProps for Arc { - async fn shared(&self, req: &RequestInfo) -> Value { - (**self).shared(req).await +impl SharedProps for FnSharedProps +where + F: Fn(&RequestInfo) -> Fut + Send + Sync, + Fut: Future + Send, + R: Into + Send, +{ + async fn shared(&self, req: &RequestInfo) -> SharedPropsData { + (self.0)(req).await.into() } } -/// Helper to wrap a closure as a boxed `SharedProps` trait object. Pass the -/// result to [`crate::InertiaConfig::shared`]. -pub fn shared_props_fn(f: F) -> Arc +/// Helper to wrap a closure as a boxed [`SharedProps`] resolver. +pub fn shared_props_fn(f: F) -> Arc where F: Fn(&RequestInfo) -> Fut + Send + Sync + 'static, - Fut: Future + Send + 'static, + Fut: Future + Send + 'static, + R: Into + Send, { Arc::new(FnSharedProps(f)) } @@ -54,8 +113,6 @@ mod tests { async fn fn_shared_props_works() { let s = shared_props_fn(|_r| async { json!({"x": 1}) }); let r = RequestInfo::from_parts(http::Method::GET, "/".into(), &HeaderMap::new()); - assert_eq!(s.shared(&r).await, json!({"x": 1})); - // The helper's result goes straight into the config. - let _ = crate::InertiaConfig::new().shared(s); + assert_eq!(s.shared(&r).await.value, json!({"x": 1})); } } diff --git a/tests/axum_integration.rs b/tests/axum_integration.rs index 48d411e..554a602 100644 --- a/tests/axum_integration.rs +++ b/tests/axum_integration.rs @@ -2,13 +2,15 @@ mod common; use axum::{ routing::{get, post}, - Router, + Extension, Router, }; use common::*; use http_body_util::BodyExt; use serde_json::json; use tower::ServiceExt; -use veer::{Inertia, InertiaConfig, InertiaLayer, MinimalRootView}; +#[cfg(feature = "tower-sessions")] +use tower_sessions::{MemoryStore, Session, SessionManagerLayer}; +use veer::{shared::shared_props_fn, Inertia, InertiaConfig, InertiaLayer, MinimalRootView}; fn app(config: InertiaConfig) -> Router { Router::new() @@ -133,3 +135,107 @@ async fn csr_only_returns_json_for_plain_get() { "application/json" ); } + +#[tokio::test] +async fn shared_props_can_read_request_extensions() { + #[derive(Clone)] + struct SessionUser(&'static str); + + let cfg = InertiaConfig::new() + .version(|| "v1".into()) + .shared(shared_props_fn(|request| { + let user = request.extension::().map(|user| user.0); + async move { json!({"auth": {"user": user}}) } + })); + let app = app(cfg).layer(Extension(SessionUser("Ada"))); + + let response = app.oneshot(req_inertia("GET", "/", "v1")).await.unwrap(); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let page: serde_json::Value = serde_json::from_slice(&body).unwrap(); + + assert_eq!(page["props"]["auth"]["user"], "Ada"); +} + +#[cfg(feature = "tower-sessions")] +#[tokio::test] +async fn shared_props_can_read_tower_session() { + let cfg = InertiaConfig::new() + .version(|| "v1".into()) + .shared(shared_props_fn(|request| { + let session = request.extension::().cloned(); + async move { + let user = match session { + Some(session) => session.get::("user").await.unwrap(), + None => None, + }; + json!({"auth": {"user": user}}) + } + })); + let app = Router::new() + .route( + "/", + get(|inertia: Inertia, session: Session| async move { + session.insert("user", "Ada").await.unwrap(); + inertia.render("Home", json!({"msg": "hello"})) + }), + ) + .layer(InertiaLayer::new(cfg)) + .layer(SessionManagerLayer::new(MemoryStore::default()).with_secure(false)); + + let response = app.oneshot(req_inertia("GET", "/", "v1")).await.unwrap(); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let page: serde_json::Value = serde_json::from_slice(&body).unwrap(); + + assert_eq!(page["props"]["auth"]["user"], "Ada"); +} + +#[tokio::test] +async fn shared_lazy_props_execute_only_when_requested_and_page_props_win() { + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }; + let calls = Arc::new(AtomicUsize::new(0)); + let shared_calls = calls.clone(); + let cfg = InertiaConfig::new() + .version(|| "v1".into()) + .shared(shared_props_fn(move |_| { + let calls = shared_calls.clone(); + async move { + veer::SharedPropsData::new(json!({"count": 3})) + .lazy("notifications", move || async move { + calls.fetch_add(1, Ordering::SeqCst); + json!(["notice"]) + }) + .lazy("msg", || async { panic!("page values must win") }) + .lazy("stats", || async { panic!("page lazy values must win") }) + } + })); + let app = app(cfg); + for (path, component, only, expected_calls) in [ + ("/", "Home", "", 0), + ("/", "Home", "count", 0), + ("/", "WrongComponent", "notifications", 0), + ("/", "Home", "notifications", 1), + ("/", "Home", "msg", 1), + ("/with-lazy", "WithLazy", "stats", 1), + ] { + let mut request = req_inertia("GET", path, "v1"); + if !only.is_empty() { + request + .headers_mut() + .insert("x-inertia-partial-component", component.parse().unwrap()); + request + .headers_mut() + .insert("x-inertia-partial-data", only.parse().unwrap()); + } + let response = app.clone().oneshot(request).await.unwrap(); + assert_eq!(response.status(), 200); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let page: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(calls.load(Ordering::SeqCst), expected_calls); + if component == "Home" && only == "notifications" { + assert_eq!(page["props"]["notifications"], json!(["notice"])); + } + } +} diff --git a/tests/infinite_scroll.rs b/tests/infinite_scroll.rs new file mode 100644 index 0000000..4d1ffc8 --- /dev/null +++ b/tests/infinite_scroll.rs @@ -0,0 +1,108 @@ +use axum::{body::Body, routing::get, Router}; +use http::{HeaderValue, Request}; +use http_body_util::BodyExt; +use serde_json::{json, Value}; +use tower::ServiceExt; +use veer::{Inertia, InertiaConfig, InertiaLayer, ScrollMetadata}; + +async fn response(headers: &[(&str, &str)]) -> Value { + let app = Router::new() + .route( + "/", + get(|inertia: Inertia| async move { + inertia.render("Users", json!({"title": "Users"})).prop( + "users", + veer::Prop::scroll(|| async { + ( + json!({"data": [{"id": 3}], "total": 5}), + ScrollMetadata::paged("page", 2, true), + ) + }) + .match_on("data.id"), + ) + }), + ) + .layer(InertiaLayer::new( + InertiaConfig::new().version(|| "v1".into()), + )); + let mut request = Request::builder() + .uri("/") + .header("x-inertia", "true") + .header("x-inertia-version", "v1") + .body(Body::empty()) + .unwrap(); + for (name, value) in headers { + request.headers_mut().insert( + http::header::HeaderName::from_bytes(name.as_bytes()).unwrap(), + HeaderValue::from_str(value).unwrap(), + ); + } + let response = app.oneshot(request).await.unwrap(); + assert!(response.status().is_success()); + serde_json::from_slice(&response.into_body().collect().await.unwrap().to_bytes()).unwrap() +} + +#[tokio::test] +async fn scroll_metadata_and_append_path_are_emitted() { + let page = response(&[]).await; + assert_eq!(page["mergeProps"], json!(["users.data"])); + assert_eq!(page["matchPropsOn"], json!(["users.data.id"])); + assert_eq!( + page["scrollProps"]["users"], + json!({ + "pageName": "page", "currentPage": 2, "previousPage": 1, "nextPage": 3, "reset": false + }) + ); + assert_eq!(page["props"]["users"]["data"], json!([{"id": 3}])); +} + +#[tokio::test] +async fn scroll_previous_pages_prepend() { + let page = response(&[ + ("x-inertia-partial-component", "Users"), + ("x-inertia-partial-data", "users"), + ("x-inertia-infinite-scroll-merge-intent", "prepend"), + ]) + .await; + assert_eq!(page["prependProps"], json!(["users.data"])); + assert_eq!(page["matchPropsOn"], json!(["users.data.id"])); + assert!(page.get("mergeProps").is_none()); +} + +#[tokio::test] +async fn resetting_scroll_replaces_data_instead_of_merging() { + let page = response(&[ + ("x-inertia-partial-component", "Users"), + ("x-inertia-partial-data", "users"), + ("x-inertia-reset", "users"), + ("x-inertia-infinite-scroll-merge-intent", "prepend"), + ]) + .await; + assert_eq!(page["scrollProps"]["users"]["reset"], true); + assert!(page.get("matchPropsOn").is_none()); + assert!(page.get("mergeProps").is_none()); + assert!(page.get("prependProps").is_none()); +} + +#[tokio::test] +async fn excluded_scroll_props_have_no_metadata_or_merge_path() { + let page = response(&[ + ("x-inertia-partial-component", "Users"), + ("x-inertia-partial-data", "title"), + ]) + .await; + assert!(page["props"].get("users").is_none()); + assert!(page.get("scrollProps").is_none()); + assert!(page.get("mergeProps").is_none()); +} + +#[test] +fn cursor_boundaries_serialize_as_strings_or_null() { + let metadata = ScrollMetadata::new("cursor") + .current("current") + .next("next"); + let value = serde_json::to_value(metadata).unwrap(); + assert_eq!(value["currentPage"], "current"); + assert_eq!(value["nextPage"], "next"); + assert_eq!(value["previousPage"], Value::Null); +} diff --git a/tests/selected_props.rs b/tests/selected_props.rs new file mode 100644 index 0000000..0a1e30c --- /dev/null +++ b/tests/selected_props.rs @@ -0,0 +1,166 @@ +use axum::{ + body::Body, + http::{Request, StatusCode}, + routing::get, + Router, +}; +use http_body_util::BodyExt; +use serde_json::{json, Value}; +use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, +}; +use tower::ServiceExt; +use veer::{Inertia, InertiaConfig, InertiaLayer, Prop, ScrollMetadata, SharedPropsData}; + +#[tokio::test] +async fn selected_props_evaluate_only_the_requested_loaders() { + for (component, only, except, expected) in [ + ("", "", "", [1, 0, 1]), + ("Page", "picker", "", [0, 1, 0]), + ("Page", "picker,rows", "", [0, 1, 1]), + ("Page", "", "rows", [1, 0, 0]), + ("Page", "picker,rows", "picker", [0, 0, 1]), + ("Other", "picker", "", [1, 0, 1]), + ] { + let counts = Arc::new([ + AtomicUsize::new(0), + AtomicUsize::new(0), + AtomicUsize::new(0), + ]); + let state = counts.clone(); + let app = Router::new() + .route( + "/", + get(move |inertia: Inertia| { + let counts = state.clone(); + async move { + let regular = counts.clone(); + let optional = counts.clone(); + inertia + .render("Page", json!({})) + .try_prop("details", move || async move { + regular[0].fetch_add(1, Ordering::SeqCst); + Ok::<_, StatusCode>(json!({"name":"Ada"})) + }) + .try_optional("picker", move || async move { + optional[1].fetch_add(1, Ordering::SeqCst); + Ok::<_, StatusCode>(vec![1]) + }) + .prop( + "rows", + Prop::try_scroll(move || async move { + counts[2].fetch_add(1, Ordering::SeqCst); + Ok::<_, StatusCode>(( + json!({"data":[{"id":1}]}), + ScrollMetadata::paged("page", 1, false), + )) + }) + .match_on("data.id"), + ) + } + }), + ) + .layer(InertiaLayer::new(InertiaConfig::new())); + let request = Request::builder() + .uri("/") + .header("x-inertia", "true") + .header("x-inertia-version", "1") + .header("x-inertia-partial-component", component) + .header("x-inertia-partial-data", only) + .header("x-inertia-partial-except", except) + .body(Body::empty()) + .unwrap(); + let response = app.oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body: Value = + serde_json::from_slice(&response.into_body().collect().await.unwrap().to_bytes()) + .unwrap(); + for (index, expected) in expected.into_iter().enumerate() { + assert_eq!( + counts[index].load(Ordering::SeqCst), + expected, + "{component} only={only} except={except}" + ); + } + assert_eq!(body.get("scrollProps").is_some(), expected[2] == 1); + } +} + +#[tokio::test] +async fn selected_props_preserve_application_error_responses() { + let app = Router::new() + .route( + "/", + get(|inertia: Inertia| async move { + inertia + .render("Page", json!({})) + .try_optional("picker", || async { + Err::(( + StatusCode::SERVICE_UNAVAILABLE, + [("retry-after", "10")], + "provider unavailable", + )) + }) + }), + ) + .layer(InertiaLayer::new(InertiaConfig::new())); + let response = app + .oneshot( + Request::builder() + .uri("/") + .header("x-inertia", "true") + .header("x-inertia-version", "1") + .header("x-inertia-partial-component", "Page") + .header("x-inertia-partial-data", "picker") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(response.headers()["retry-after"], "10"); + assert_eq!( + response.into_body().collect().await.unwrap().to_bytes(), + "provider unavailable" + ); +} + +#[tokio::test] +async fn selected_props_page_overrides_shared_loaders() { + let config = InertiaConfig::new().shared(veer::shared::shared_props_fn(|_| async { + SharedPropsData::new(json!({})) + .prop( + "title", + Prop::new::<_, _, ()>(|| async { panic!("overridden shared loader") }), + ) + .prop("picker", Prop::new(|| async { vec![1] }).optional()) + })); + let app = Router::new() + .route( + "/", + get(|inertia: Inertia| async move { + inertia + .render("Page", json!({})) + .prop("title", Prop::new(|| async { "page title" })) + }), + ) + .layer(InertiaLayer::new(config)); + let response = app + .oneshot( + Request::builder() + .uri("/") + .header("x-inertia", "true") + .header("x-inertia-version", "1") + .header("x-inertia-partial-component", "Page") + .header("x-inertia-partial-data", "title,picker") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let body: Value = + serde_json::from_slice(&response.into_body().collect().await.unwrap().to_bytes()).unwrap(); + assert_eq!(body["props"]["title"], "page title"); + assert_eq!(body["props"]["picker"], json!([1])); +} diff --git a/tests/spec_coverage.rs b/tests/spec_coverage.rs index fb3075b..93e41f0 100644 --- a/tests/spec_coverage.rs +++ b/tests/spec_coverage.rs @@ -681,6 +681,69 @@ mod multipart_tests { assert_eq!(page["props"]["uploaded_size"], 13); } + #[tokio::test] + async fn multipart_with_indexed_files_deserializes_into_vec() { + #[derive(Deserialize)] + struct UploadDocuments { + files: Vec, + } + + let cfg = InertiaConfig::new().version(|| "v1".into()); + let app = Router::new() + .route( + "/documents", + post( + |i: Inertia, InertiaForm(form): InertiaForm| async move { + i.render( + "documents/created", + json!({ + "filenames": form.files.into_iter().map(|file| file.filename).collect::>(), + }), + ) + }, + ), + ) + .layer(InertiaLayer::new(cfg)); + + let boundary = "veer-test-indexed-files"; + let body = multipart_body( + boundary, + &[ + Part::File { + name: "files[0]", + filename: "first.pdf", + content_type: "application/pdf", + bytes: b"first", + }, + Part::File { + name: "files[1]", + filename: "second.pdf", + content_type: "application/pdf", + bytes: b"second", + }, + ], + ); + let request = http::Request::builder() + .method("POST") + .uri("/documents") + .header( + "content-type", + format!("multipart/form-data; boundary={boundary}"), + ) + .header("x-inertia", "true") + .header("x-inertia-version", "v1") + .body(axum::body::Body::from(body)) + .unwrap(); + let response = app.oneshot(request).await.unwrap(); + + assert_eq!(response.status(), 200); + let page = body_json(response).await; + assert_eq!( + page["props"]["filenames"], + json!(["first.pdf", "second.pdf"]) + ); + } + #[tokio::test] async fn multipart_text_only_still_works() { // A multipart submission with no file fields should still deserialize