diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d37787..e7b79b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,80 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +Developer-experience changes: clear failures, less code in handlers, less setup. + +### Added + +- `Validated` (`validator` feature) and `GardeValidated` (`garde` + feature): body extractors that validate, answer Precognition requests, and + redirect back with the errors. +- `InertiaConfig::share`: shared props from a closure that returns any + `Serialize` value. +- `RequestInfo::extension` / `extensions`: shared props can read data that a + middleware put in the request extensions (the signed-in user). +- `Inertia::page(props)` (`ts` feature): the component name comes from + `register_page!`. +- `InertiaResponse::render`: a page without the `Inertia` extractor, for the + `IntoResponse` impl of an error type. `InertiaResponse::status` sets the + HTTP status of a page. +- `ViteRootView::auto`: dev mode in a debug build, production mode in a + release build. +- `RootView::version`: a production `ViteRootView` gives the manifest hash as + the default asset version. `InertiaConfig::version_str` for a constant. +- `veer::testing` (`testing` feature): `visit`, `TestPage`, `MemorySession`. +- A warning when errors or flash data are set and there is no session store. +- docs.rs shows all features, with a label on each feature-gated item. +- Guides: error pages, testing. + +### Changed + +- **Breaking:** page props that do not serialize give a `500` (the cause is in the + body in a debug build). Before, the page rendered with `null` props. +- **Breaking:** the rejection of the `Inertia` extractor is + `MissingInertiaLayer`, not `StatusCode`. Its body names the cause in a debug + build. +- **Breaking:** `ViteManifest::load` and `ViteManifest::from_str` return + `ViteManifestError`, not `String`. +- **Breaking:** `InertiaConfig::version` has no `"1"` default when the root + view has a version (see `RootView::version`). +- The mode-specific setters of `ViteRootView` (`dev_server`, `react_refresh`, + `manifest`, `asset_base`) do nothing in the other mode. Before, they + panicked. +- The bindings generator writes a file only when its content changes, and + `Split` deletes the generated files of removed controllers from its actions + subdirectory. + +### Security + +- `Inertia::back` and the empty-response redirect use only the path and query + of the `Referer`. Before, a `Referer` of another site made an open redirect. + **Breaking:** `RequestInfo::referer` is now that path, or `None`. +- A request URL that starts with `//` is read as a path of this site + (`/evil.test/x`), so that the page URL and `X-Inertia-Location` cannot point + at another site. +- `CsrfLayer` checks each method except `GET`, `HEAD`, `OPTIONS` and `TRACE`. + Before, a method other than `POST`, `PUT`, `PATCH` and `DELETE` was not + checked. +- An SSR failure with `ssr_required`, and a root view failure, give a `500` + with a generic body in a release build. Before, the error text went to the + client, and the root view error had status `200`. +- `EmbeddedAssets` answers `404` for a path with `..`, `\`, or `%`, and does + not call the resolver. It sets `X-Content-Type-Options: nosniff`. +- `CookieSessionStore` signs the cookie name with the value, so that the value + of one cookie is not valid as another cookie. Cookies from an older version + are ignored one time. +- `ViteRootView` and `MinimalRootView` escape URLs in the tags that they emit. +- The `500` bodies that veer makes are `text/plain`. + +### Fixed + +- A `500` from a failed page render (props, a prop closure, SSR, the root + view) keeps the flash data of the request for the request that follows. + Before, the data was lost. +- The `Router` documentation used the axum 0.7 path syntax (`/:id`). + ## [0.2.0] - 2026-10-03 Brings the protocol surface up to Inertia client 3.8.0 / `inertia-laravel` 3.5.1. diff --git a/Cargo.toml b/Cargo.toml index 3646017..4a4b5df 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,11 @@ csrf = ["axum", "dep:cookie", "dep:hmac", "dep:sha2", "dep:base64", "dep:getrand embed = ["axum"] devtools = ["axum"] ts = ["dep:ts-rs", "dep:inventory"] +testing = ["axum"] + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] [dependencies] serde = { version = "1.0", features = ["derive"] } diff --git a/README.md b/README.md index 4ecfb57..4e24184 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ Or with `cargo add`: cargo add veer ``` -The default feature set includes the axum adapter. See [Feature flags](#️-feature-flags) for everything else. Coming from 0.1? Read the [upgrade guide](docs/upgrading.md). +The default feature set includes the axum adapter. See [Feature flags](#️-feature-flags) for everything else. Coming from an older version? Read the [upgrade guide](docs/upgrading.md). ## 🚀 Quick Start @@ -88,7 +88,6 @@ use veer::{Inertia, InertiaConfig, InertiaLayer, MinimalRootView}; #[tokio::main] async fn main() { let cfg = InertiaConfig::new() - .version(|| "1".into()) .root_view( MinimalRootView::new() .title("Acme") @@ -124,21 +123,20 @@ inertia **Handle a form like a classic server app.** Validate, redirect, and the errors and the flash message show on the next page. → [Forms and validation](docs/forms-and-validation.md) ```rust,ignore -async fn users_store(inertia: Inertia, InertiaForm(body): InertiaForm) -> impl IntoResponse { - if let Err(errors) = body.validate() { - return inertia.with_errors(errors).redirect("/users/new"); - } +async fn users_store(inertia: Inertia, Validated(body): Validated) -> impl IntoResponse { + // Invalid input went back to the form with its errors already. create_user(body).await; - inertia.redirect("/users").with_flash("success", json!("User created")) + inertia.redirect("/users").with_flash("success", "User created") } ``` **Share data with every page.** → [Shared props](docs/props.md#shared-props) ```rust,ignore -let cfg = InertiaConfig::new().shared(shared_props_fn(|_req| async move { - json!({ "auth": { "user": current_user().await } }) -})); +let cfg = InertiaConfig::new().share(|req| { + let user = req.extension::().cloned(); // set by your auth middleware + async move { json!({ "auth": { "user": user } }) } +}); ``` **Get TypeScript types from your Rust structs.** → [TypeScript bindings](docs/typescript.md) @@ -148,6 +146,9 @@ let cfg = InertiaConfig::new().shared(shared_props_fn(|_req| async move { #[ts(export)] pub struct UsersIndexProps { pub users: Vec } veer::register_page!(UsersIndexProps, "Users/Index"); + +// The component name comes from the registration. +inertia.page(UsersIndexProps { users }) ``` ```tsx @@ -162,15 +163,17 @@ import { users, type UsersIndexProps } from "./gen"; |---|---| | [Getting started](docs/getting-started.md) | Install, first page, how a request flows, the frontend entry point | | [Props](docs/props.md) | Partial reloads, lazy / deferred / once props, merging, infinite scroll, shared props, big integers | -| [Forms and validation](docs/forms-and-validation.md) | `InertiaForm`, validation errors, error bags, flash data, Precognition, file uploads | +| [Forms and validation](docs/forms-and-validation.md) | `Validated`, `InertiaForm`, validation errors, error bags, flash data, Precognition, file uploads | | [Redirects and history](docs/redirects-and-history.md) | `redirect`, `back`, external redirects, URL fragments, history encryption | | [Sessions](docs/sessions.md) | The cookie store, `tower-sessions`, writing your own store | +| [Error pages](docs/error-pages.md) | `404` and application errors as Inertia pages | +| [Testing](docs/testing.md) | `veer::testing`: page assertions for your handlers | | [Vite, SSR and assets](docs/vite-ssr-assets.md) | `ViteRootView`, server-side rendering, embedded assets, `` elements | | [TypeScript bindings](docs/typescript.md) | Typed page props and route helpers generated from Rust | | [CSRF protection](docs/csrf.md) | `CsrfLayer` and the `XSRF-TOKEN` convention | | [DevTools](docs/devtools.md) | The recorder for the Inertia DevTools browser extension | | [Architecture](docs/architecture.md) | Crate layout, protocol coverage, extension points | -| [Upgrading from 0.1](docs/upgrading.md) | Every breaking change in 0.2 and what to do | +| [Upgrading](docs/upgrading.md) | Every breaking change of each version and what to do | API reference: [docs.rs/veer](https://docs.rs/veer). For the client side, use the [Inertia documentation](https://inertiajs.com/docs/v3). @@ -183,12 +186,13 @@ API reference: [docs.rs/veer](https://docs.rs/veer). For the client side, use th | `ssr` | off | HTTP SSR client (`reqwest`) | | `cookie-session` | off | Signed-cookie session store | | `tower-sessions` | off | Session store backed by [`tower-sessions`](https://crates.io/crates/tower-sessions) | -| `validator` | off | `IntoErrorBag` impl for `validator::ValidationErrors` | -| `garde` | off | `IntoErrorBag` impl for `garde::Report` | +| `validator` | off | `Validated` extractor + `IntoErrorBag` impl for `validator::ValidationErrors` | +| `garde` | off | `GardeValidated` extractor + `IntoErrorBag` impl for `garde::Report` | | `csrf` | off | CSRF protection (`CsrfLayer`) | | `embed` | off | Embedded-asset serving for single-binary deploys (`EmbeddedAssets`) | | `devtools` | off | Recorder + read API for the Inertia DevTools browser extension | | `ts` | off | End-to-end TypeScript bindings codegen (`ts-rs` + `inventory`) | +| `testing` | off | Test helpers (`veer::testing`) | Disabling a feature drops its transitive deps entirely. diff --git a/docs/README.md b/docs/README.md index b85086c..f118c17 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,16 +7,18 @@ Veer is the server side of the [Inertia.js v3 protocol](https://inertiajs.com/do | Guide | What it covers | |---|---| | [Getting started](getting-started.md) | Install, first page, how a request flows, the frontend entry point | -| [Upgrading from 0.1](upgrading.md) | Every breaking change in 0.2 and what to do | +| [Upgrading](upgrading.md) | Every breaking change of each version and what to do | ## Building pages | Guide | What it covers | |---|---| | [Props](props.md) | Partial reloads, lazy / deferred / once props, merging, infinite scroll, shared props, big integers | -| [Forms and validation](forms-and-validation.md) | `InertiaForm`, validation errors, error bags, flash data, Precognition, file uploads | +| [Forms and validation](forms-and-validation.md) | `Validated`, `InertiaForm`, validation errors, error bags, flash data, Precognition, file uploads | | [Redirects and history](redirects-and-history.md) | `redirect`, `back`, external redirects, URL fragments, history encryption | | [Sessions](sessions.md) | The cookie store, `tower-sessions`, writing your own store | +| [Error pages](error-pages.md) | `404` and application errors as Inertia pages | +| [Testing](testing.md) | `veer::testing`: page assertions for your handlers | ## Frontend integration diff --git a/docs/architecture.md b/docs/architecture.md index 8c926b3..1aa2581 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -43,7 +43,7 @@ The protocol core has no I/O and no framework types. `protocol::decide` is a pur | `RootView` | The HTML shell of the first page load | `MinimalRootView`, `ViteRootView`, `ClosureRootView` | | `SessionStore` | Errors and flash data between requests | `CookieSessionStore`, `TowerSessionStore` | | `SsrClient` | Server-side rendering | `HttpSsrClient` | -| `SharedProps` | Props for every page | `shared_props_fn` | +| `SharedProps` | Props for every page | `InertiaConfig::share` (closure) | | `IntoErrorBag` | Validation errors from any library | `validator`, `garde`, maps and pairs | ## Feature flags @@ -55,12 +55,13 @@ The protocol core has no I/O and no framework types. `protocol::decide` is a pur | `ssr` | off | HTTP SSR client (`reqwest`) | | `cookie-session` | off | Signed-cookie session store | | `tower-sessions` | off | Session store on [`tower-sessions`](https://crates.io/crates/tower-sessions) | -| `validator` | off | `IntoErrorBag` for `validator::ValidationErrors` | -| `garde` | off | `IntoErrorBag` for `garde::Report` | +| `validator` | off | `Validated`; `IntoErrorBag` for `validator::ValidationErrors` | +| `garde` | off | `GardeValidated`; `IntoErrorBag` for `garde::Report` | | `csrf` | off | `CsrfLayer` and `CsrfTokens` | | `embed` | off | `EmbeddedAssets` for a single-binary deploy | | `devtools` | off | Recorder for the Inertia DevTools extension | -| `ts` | off | TypeScript bindings (`ts-rs`, `inventory`) | +| `ts` | off | TypeScript bindings (`ts-rs`, `inventory`), `Inertia::page` | +| `testing` | off | `veer::testing`: helpers for tests of your handlers | A feature that is off brings in none of its dependencies. diff --git a/docs/error-pages.md b/docs/error-pages.md new file mode 100644 index 0000000..9fb74c2 --- /dev/null +++ b/docs/error-pages.md @@ -0,0 +1,77 @@ +# Error pages + +An error response that is not an Inertia page shows in a modal on the client. To show an error as a page of your app, render a component with an HTTP status. + +## Not found + +Use a fallback handler. `status` sets the HTTP status of the page response: + +```rust,ignore +use axum::http::StatusCode; +use veer::Inertia; + +async fn not_found(inertia: Inertia) -> impl IntoResponse { + inertia + .render("Error", json!({ "status": 404 })) + .status(StatusCode::NOT_FOUND) +} + +let app = router() + .fallback(not_found) + .layer(InertiaLayer::new(cfg)); // after the fallback, so that it covers it +``` + +## Application errors + +A handler that returns `Result<_, AppError>` needs `AppError: IntoResponse`, and that impl has no `Inertia` extractor. `InertiaResponse::render` makes a page without one: + +```rust,ignore +use veer::InertiaResponse; + +enum AppError { + NotFound, + Internal(anyhow::Error), +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let status = match &self { + AppError::NotFound => StatusCode::NOT_FOUND, + AppError::Internal(error) => { + tracing::error!(%error, "request failed"); + StatusCode::INTERNAL_SERVER_ERROR + } + }; + InertiaResponse::render("Error", json!({ "status": status.as_u16() })) + .status(status) + .into_response() + } +} + +async fn users_show(inertia: Inertia, Path(id): Path) -> Result { + let user = find_user(id).await?.ok_or(AppError::NotFound)?; + Ok(inertia.render("Users/Show", UsersShowProps { user })) +} +``` + +The page gets the shared props and works with SSR, like any page. The route must be inside `InertiaLayer`. + +## The component + +```tsx +// pages/Error.tsx +const titles: Record = { + 404: "Page not found", + 500: "Server error", +}; + +export default function Error({ status }: { status: number }) { + return

{titles[status] ?? "Error"}

; +} +``` + +## Other failures + +- **A panic or a `500` from another layer** is not a page. In development the modal is useful, because it shows the response. In production, keep the body free of internal data. +- **An expired CSRF token** gives `419` from [`CsrfLayer`](csrf.md), which is outside `InertiaLayer`. Handle it on the client. +- **A failed deferred prop** can be rescued, so that the page loads without it; see [Props](props.md). diff --git a/docs/forms-and-validation.md b/docs/forms-and-validation.md index 7af73a8..a7ee684 100644 --- a/docs/forms-and-validation.md +++ b/docs/forms-and-validation.md @@ -4,8 +4,10 @@ Inertia handles a form submit like a classic server-rendered app: the handler va ## A form handler +With the `validator` feature, `Validated` decodes the body and validates it before your handler runs: + ```rust,ignore -use veer::{Inertia, InertiaForm}; +use veer::{Inertia, Validated}; use validator::Validate; #[derive(serde::Deserialize, Validate)] @@ -16,6 +18,21 @@ struct NewUser { email: String, } +async fn users_store(inertia: Inertia, Validated(body): Validated) -> impl IntoResponse { + create_user(body).await; + inertia.redirect("/users").with_flash("success", "User created") +} +``` + +The handler runs only for valid input. For invalid input, `Validated` redirects back to the page that the user came from ([`back`](redirects-and-history.md)), with the errors. `back` needs the `Referer` header, which browsers send by default. If your site sets `Referrer-Policy: no-referrer`, turn on `InertiaConfig::store_previous_url(true)`, or validate by hand. It also answers [Precognition](#precognition-live-validation) requests. With the `garde` feature, use `GardeValidated` in the same way. + +`Validated` reads the body as `InertiaForm` does: `application/json`, `application/x-www-form-urlencoded`, and, with the `multipart` feature, `multipart/form-data`. One handler works for all three. + +### Validation by hand + +Use `InertiaForm` when you want control of the steps, for example to redirect to a different page or to add an error from a database check: + +```rust,ignore async fn users_store( inertia: Inertia, InertiaForm(body): InertiaForm, @@ -24,12 +41,10 @@ async fn users_store( return inertia.with_errors(errors).redirect("/users/new"); } create_user(body).await; - inertia.redirect("/users").with_flash("success", json!("User created")) + inertia.redirect("/users").with_flash("success", "User created") } ``` -`InertiaForm` reads `application/json`, `application/x-www-form-urlencoded`, and, with the `multipart` feature, `multipart/form-data`. One handler works for all three. - ## Validation errors `with_errors` takes anything that implements `IntoErrorBag`: @@ -47,16 +62,18 @@ On the next page, the errors are in `props.errors`, which is always present (`{} ## Flash data ```rust,ignore -inertia.redirect("/users").with_flash("success", json!("User created")) +inertia.redirect("/users").with_flash("success", "User created") ``` Flash data is one-shot. It is the top-level `flash` field of the page object (`usePage().flash` on the frontend), and the client does not keep it in the browser history. It survives a chain of redirects, and responses that are not pages do not use it up. -`with_flash` on a render puts the data on that page. +The value is anything that converts to JSON: a string, a number, or `json!({ … })`. `with_flash` on a render puts the data on that page. ## Precognition (live validation) -[Precognition](https://inertiajs.com/docs/v3/the-basics/forms#precognition) validates a field while the user fills in the form, with the validation rules of the server. The client sends the normal request with a `Precognition: true` header. Answer it before the action runs: +[Precognition](https://inertiajs.com/docs/v3/the-basics/forms#precognition) validates a field while the user fills in the form, with the validation rules of the server. The client sends the normal request with a `Precognition: true` header. + +`Validated` and `GardeValidated` answer these requests; your handler does not run. With `InertiaForm`, answer the request before the action runs: ```rust,ignore async fn users_store( @@ -113,7 +130,7 @@ async fn upload_avatar( InertiaForm(form): InertiaForm, ) -> impl IntoResponse { save_avatar(&form.user_id, &form.avatar.bytes, form.avatar.filename.as_deref()).await; - inertia.redirect("/profile").with_flash("success", json!("Avatar updated")) + inertia.redirect("/profile").with_flash("success", "Avatar updated") } ``` diff --git a/docs/getting-started.md b/docs/getting-started.md index 20e799f..ea41ada 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -24,7 +24,6 @@ use veer::{Inertia, InertiaConfig, InertiaLayer, MinimalRootView}; #[tokio::main] async fn main() { let cfg = InertiaConfig::new() - .version(|| "1".into()) .root_view( MinimalRootView::new() .title("Acme") @@ -44,7 +43,7 @@ async fn main() { There are three parts: -- **`InertiaConfig`** is built once at startup. It holds the asset version, the root view (the HTML shell), and optional parts: a session store, an SSR client, shared props. +- **`InertiaConfig`** is built once at startup. It holds the root view (the HTML shell), and optional parts: a session store, an SSR client, shared props. - **`Inertia`** is an axum extractor. `inertia.render(component, props)` returns an `InertiaResponse` builder. `props` is any `Serialize` value: a struct or `serde_json::json!`. - **`InertiaLayer`** does the protocol work after your handler returns. @@ -103,16 +102,23 @@ createInertiaApp({ ## Asset versioning -`InertiaConfig::version` returns a string that changes when your assets change. When a client sends an old version, veer answers `409` and the client does a full reload. With Vite, use the manifest hash: +The asset version is a string that changes when your assets change. When a client sends an old version, veer answers `409` and the client does a full reload. -```rust,ignore -let manifest = ViteManifest::load("dist/.vite/manifest.json")?; -let version = manifest.hash(); -let cfg = InertiaConfig::new().version(move || version.clone().into()); -``` +With `ViteRootView` in production mode, the version is the hash of the Vite manifest; you set nothing. Without it, the version is `"1"`. Set your own with `InertiaConfig::version_str("…")`, or with `InertiaConfig::version(|| …)` for a value that changes while the server runs. + +## When something is wrong + +Veer tells you about a wrong setup. In a debug build, the cause is in the response body; it is always in the log (`tracing`). + +| Symptom | Cause | +|---|---| +| `500`: "not inside `InertiaLayer`" | A handler uses the `Inertia` extractor, but its route has no `InertiaLayer` | +| `500`: "the props of … did not serialize" | The props value is not valid JSON, for example a map with keys that are not strings | +| Warning: "there is no session store" | A redirect has errors or flash data, but the config has no [session store](sessions.md) | ## Next steps - [Props](props.md): load data only when the page needs it. - [Forms and validation](forms-and-validation.md): handle a form submit with errors and a flash message. +- [Error pages](error-pages.md) and [Testing](testing.md). - [`examples/axum-react-todo`](../examples/axum-react-todo): a complete app. diff --git a/docs/props.md b/docs/props.md index fda784a..ce77b2f 100644 --- a/docs/props.md +++ b/docs/props.md @@ -128,18 +128,29 @@ A dot path as key puts the prop inside a nested object: `.prop("auth.permissions Shared props are sent with every page: the signed-in user, the app name, feature flags. ```rust,ignore -use veer::shared::shared_props_fn; - -let cfg = InertiaConfig::new() - .shared(shared_props_fn(|_req| async move { +let cfg = InertiaConfig::new().share(|req| { + // Put in the request extensions by your auth middleware. + let user = req.extension::().cloned(); + async move { serde_json::json!({ - "auth": { "user": current_user().await }, + "auth": { "user": user }, "app": { "name": "Acme" }, }) - })); + } +}); +``` + +The closure returns any `Serialize` value that serializes to an object, so a struct works too. It gets the `RequestInfo`: the URL, the method, the Inertia headers, and the request extensions (`req.extension::()`). + +The extensions are those that the request had when it reached `InertiaLayer`. A middleware that sets one must thus be outside it, which in axum means that its layer comes later: + +```rust,ignore +let app = router() + .layer(InertiaLayer::new(cfg)) + .layer(auth_layer); // runs first; sets `CurrentUser` ``` -A page prop with the same key wins. The page object lists the shared keys in `sharedProps`, which the client uses for instant visits. For request-specific data, implement the `SharedProps` trait; its method gets the `RequestInfo`. +A page prop with the same key wins. The page object lists the shared keys in `sharedProps`, which the client uses for instant visits. For a type of your own, implement the `SharedProps` trait and use `InertiaConfig::shared`. ## `Always` and `Merge` wrappers diff --git a/docs/redirects-and-history.md b/docs/redirects-and-history.md index 65d639e..19a0f5d 100644 --- a/docs/redirects-and-history.md +++ b/docs/redirects-and-history.md @@ -14,7 +14,7 @@ inertia.location("https://…") // leave the app `inertia.back()` redirects to, in this order: -1. the `Referer` header of the request; +1. the path and query of the `Referer` header. The scheme and the host are dropped, so `back()` cannot redirect off your site; 2. the previous URL from the session, if `InertiaConfig::store_previous_url(true)` is set; 3. `/`. diff --git a/docs/sessions.md b/docs/sessions.md index 3bdbda0..6a0a221 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -1,6 +1,6 @@ # Sessions -Veer uses a session store for data that must reach the next request: validation errors, flash data, and the previous URL. Without a store, `with_errors` and `with_flash` on a redirect have no effect. +Veer uses a session store for data that must reach the next request: validation errors, flash data, and the previous URL. Without a store, `with_errors` and `with_flash` on a redirect have no effect, and veer logs a warning. ## Cookie store diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..34d673a --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,62 @@ +# Testing + +The `testing` feature has helpers for tests of your handlers. Enable it for tests only: + +```toml +[dev-dependencies] +veer = { version = "0.2", features = ["testing"] } +tower = { version = "0.5", features = ["util"] } +``` + +## A page test + +Build your app as in `main`, send a request with `tower::ServiceExt::oneshot`, and read the page: + +```rust,ignore +use tower::ServiceExt; +use veer::testing::{visit, TestPage}; + +#[tokio::test] +async fn users_index_lists_the_users() { + let response = app().oneshot(visit("GET", "/users")).await.unwrap(); + + let page = TestPage::from_response(response).await; + assert_eq!(page.component, "Users/Index"); + assert_eq!(page.prop("users.0.name"), Some(&json!("Ada"))); + assert_eq!(page.prop("auth.user"), Some(&json!(null))); +} +``` + +- **`visit(method, uri)`** makes an Inertia request (`X-Inertia: true`) with an empty body. It sends the asset version `"1"`, which is the default. If your test config has a different version, replace the `X-Inertia-Version` header. +- **`TestPage::from_response`** reads the page object from a JSON response or from the HTML of a first page load. It panics with the status and the body if the response is not a page. +- **`page.prop(path)`** reads a prop at a dot path; a number is an array index. `page.props` and `page.raw` are the complete JSON values. + +## Forms, errors and flash data + +`MemorySession` is a session store in memory. Its clones share the data, so that you can follow a redirect or look at the data that the next request will get: + +```rust,ignore +use veer::testing::MemorySession; + +#[tokio::test] +async fn store_rejects_a_short_title() { + let session = MemorySession::default(); + let app = app(InertiaConfig::new().session(session.clone())); + + let request = Request::post("/todos") + .header("x-inertia", "true") + .header("content-type", "application/json") + .header("referer", "/todos/new") + .body(Body::from(json!({ "title": "a" }).to_string())) + .unwrap(); + let response = app.clone().oneshot(request).await.unwrap(); + + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!(response.headers()["location"], "/todos/new"); + assert!(session.flash().errors.contains_key("title")); + + // The page after the redirect has the error. + let page = TestPage::from_response(app.oneshot(visit("GET", "/todos/new")).await.unwrap()).await; + assert!(page.prop("errors.title").is_some()); +} +``` diff --git a/docs/typescript.md b/docs/typescript.md index 424b44f..34acbae 100644 --- a/docs/typescript.md +++ b/docs/typescript.md @@ -25,6 +25,14 @@ veer::register_page!(UsersIndexProps, "Users/Index"); `ts-rs` follows `#[serde(rename_all)]`, so the type and the wire format agree. +A registered struct knows its component, so a handler renders it with `inertia.page(props)` and the name is written one time: + +```rust,ignore +async fn users_index(inertia: Inertia) -> impl IntoResponse { + inertia.page(UsersIndexProps { users: load_users().await }) +} +``` + ### Closure props Props that a handler attaches as closures (`once`, `deferred`, `lazy`, `Prop::scroll`, …) are not fields of the props struct. Describe them in a second struct and pass it as the third argument: @@ -45,7 +53,7 @@ The props type of the page is then `UsersIndexProps & UsersIndexClosureProps`. T ```rust,ignore inertia - .render("Users/Index", UsersIndexProps { users }) + .page(UsersIndexProps { users }) .once("plans", || async { load_plans().await }) // Vec .deferred("stats", "default", || async { load_stats().await }) // Stats ``` @@ -149,7 +157,16 @@ veer::bindings::Split::new("./frontend/gen") **Wrappers.** `Always` and `Merge` become `T`. -**Keep the output current.** With [lefthook](https://github.com/evilmartians/lefthook): +**Keep the output current.** The simplest way is to generate when the server starts in a debug build. A file is written only when its content changes, so Vite reloads only when a type or a route changed: + +```rust,ignore +let app = router().build(); +if cfg!(debug_assertions) { + veer::bindings::generate_split("./frontend/gen")?; +} +``` + +Or generate before each commit, with [lefthook](https://github.com/evilmartians/lefthook): ```yaml pre-commit: @@ -162,4 +179,4 @@ pre-commit: In CI, run `cargo run --bin gen-bindings && git diff --exit-code`. -Files of a controller that you removed are not deleted; delete the output folder before you generate if that is important. +When you remove a controller, the generator deletes its file from the actions subdirectory. It deletes only files that have the veer header and the prefix and suffix of your configuration. With `actions_dir("")` it deletes nothing. diff --git a/docs/upgrading.md b/docs/upgrading.md index c0888f1..e8574df 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -1,4 +1,45 @@ -# Upgrading from 0.1 to 0.2 +# Upgrading + +## From 0.2 to the next version + +The next version makes failures visible and removes repeated code. Most apps compile without a change. The full list is in the [changelog](../CHANGELOG.md). + +**Changes that can need an edit** + +- **`ViteManifest::load` and `.parse()` return `ViteManifestError`**, not `String`. It implements `std::error::Error`, so `?` works with `anyhow` and `Box`. Remove a `.map_err` that you added for the `String`. +- **The rejection of the `Inertia` extractor is `MissingInertiaLayer`**, not `StatusCode`. This is important only if you name the rejection type. +- **Props that do not serialize give a `500`.** Before, the page rendered with `null` props. +- **The asset version of a production `ViteRootView` is the manifest hash** when you set no version. If you have `.version(move || hash.clone().into())`, you can remove it. + +- **`RequestInfo::referer` is the path and query of the `Referer`, or `None`.** The scheme and the host are dropped, so `back()` always stays on your site. +- **`CsrfLayer` checks each method except `GET`, `HEAD`, `OPTIONS` and `TRACE`.** +- **Flash cookies from 0.2 are ignored** after the upgrade (they live 60 seconds). + +**Recommended** + +```diff +- async fn users_store(inertia: Inertia, InertiaForm(body): InertiaForm) -> Response { +- if let Some(precognition) = inertia.precognition() { +- return precognition.respond(body.validate()); +- } +- if let Err(errors) = body.validate() { +- return inertia.with_errors(errors).redirect("/users/new").into_response(); +- } ++ async fn users_store(inertia: Inertia, Validated(body): Validated) -> impl IntoResponse { + create_user(body).await; +- inertia.redirect("/users").with_flash("success", json!("User created")).into_response() ++ inertia.redirect("/users").with_flash("success", "User created") + } +``` + +- `InertiaConfig::share(|req| async { … })` replaces `.shared(shared_props_fn(…))`. The closure can read request extensions: `req.extension::()`. +- `inertia.page(props)` replaces `inertia.render("Name", props)` for a struct with `register_page!`. +- `ViteRootView::auto(manifest_path)` replaces a hand-written switch between `dev()` and `production()`. +- `InertiaConfig::version_str("v1")` replaces `.version(|| "v1".into())`. +- Call `veer::bindings::generate_split` at startup in a debug build; see [TypeScript bindings](typescript.md#options). +- Use [`veer::testing`](testing.md) in your tests. + +## From 0.1 to 0.2 Version 0.2 brings veer to the current Inertia v3 protocol (client 3.8). Most apps need the first two changes; the others apply only if you use the feature. The full list of additions is in the [changelog](../CHANGELOG.md). diff --git a/docs/vite-ssr-assets.md b/docs/vite-ssr-assets.md index 017368a..d75356d 100644 --- a/docs/vite-ssr-assets.md +++ b/docs/vite-ssr-assets.md @@ -12,43 +12,48 @@ The **root view** is the HTML shell of the first page load. Veer has three: `ViteRootView` does what Laravel's `@vite` and `@viteReactRefresh` directives do. -**Development.** Script tags point at the Vite dev server: +**One setup for both modes.** `ViteRootView::auto` gives dev mode in a debug build and production mode in a release build: ```rust,ignore use veer::ViteRootView; let cfg = InertiaConfig::new().root_view( - ViteRootView::dev() + ViteRootView::auto("dist/.vite/manifest.json")? .title("Acme") .entry("frontend/app.tsx") - .dev_server("http://localhost:5173") - .react_refresh(true), // necessary for @vitejs/plugin-react + .react_refresh(true) // dev only; necessary for @vitejs/plugin-react + .asset_base("/build"), // production only ); + +// Serve the built files in production: +// .nest_service("/build", tower_http::services::ServeDir::new("dist")) ``` -**Production.** `vite build` writes `dist/.vite/manifest.json`. `ViteRootView::production` reads it and emits the entry script, its CSS, and `modulepreload` links for imported chunks: +The manifest is read in a release build only. A setter of one mode has no effect in the other mode. For explicit control, use the two constructors: + +**Development.** Script tags point at the Vite dev server: ```rust,ignore -use veer::{ViteManifest, ViteRootView}; +ViteRootView::dev() + .title("Acme") + .entry("frontend/app.tsx") + .dev_server("http://localhost:5173") + .react_refresh(true) +``` -let manifest = ViteManifest::load("dist/.vite/manifest.json")?; -let version = manifest.hash(); // changes when a chunk changes +**Production.** `vite build` writes `dist/.vite/manifest.json`. `ViteRootView::production` uses it to emit the entry script, its CSS, and `modulepreload` links for imported chunks: -let cfg = InertiaConfig::new() - .version(move || version.clone().into()) - .root_view( - ViteRootView::production() - .title("Acme") - .entry("frontend/app.tsx") - .manifest(manifest) - .asset_base("/build"), - ); - -// Serve the built files: -// .nest_service("/build", tower_http::services::ServeDir::new("dist")) +```rust,ignore +use veer::{ViteManifest, ViteRootView}; + +ViteRootView::production() + .title("Acme") + .entry("frontend/app.tsx") + .manifest(ViteManifest::load("dist/.vite/manifest.json")?) + .asset_base("/build") ``` -With `version` set to `manifest.hash()`, each new build makes old clients reload. You do not change a version by hand. +**Asset version.** In production mode, the hash of the manifest is the asset version, so each new build makes old clients reload. You do not set a version by hand. `InertiaConfig::version` or `version_str` overrides it. ## Server-side rendering @@ -106,10 +111,8 @@ use veer::{EmbeddedAssets, ViteManifest, ViteRootView}; struct Assets; let manifest: ViteManifest = include_str!("../dist/.vite/manifest.json").parse()?; -let version = manifest.hash(); let cfg = InertiaConfig::new() - .version(move || version.clone().into()) .root_view(ViteRootView::production().entry("frontend/app.tsx").manifest(manifest)); let app = router() diff --git a/examples/axum-react-todo/src/lib.rs b/examples/axum-react-todo/src/lib.rs index 0b239e4..0aa4f55 100644 --- a/examples/axum-react-todo/src/lib.rs +++ b/examples/axum-react-todo/src/lib.rs @@ -2,14 +2,13 @@ pub mod todos; use axum::extract::{Path, Query, State}; use axum::response::IntoResponse; -use serde_json::{json, Value}; +use serde_json::Value; use std::time::Duration; use todos::{ Feed, FeedItem, HomeProps, NewTodo, ShowcaseProps, Stats, TodoStore, TodosCreateProps, TodosIndexProps, }; -use validator::Validate; -use veer::{Inertia, InertiaForm, Method::*, Prop, ScrollMetadata}; +use veer::{Inertia, Method::*, Prop, ScrollMetadata, Validated}; /// Build the named-route table. Used by `main.rs` for serving and by /// `src/bin/gen-bindings.rs` to populate the TS bindings registry. @@ -38,12 +37,9 @@ async fn showcase( ) -> impl IntoResponse { let page = query.page.unwrap_or(1).clamp(1, 3); inertia - .render( - "showcase", - ShowcaseProps { - order_id: 900_719_925_474_099_988, - }, - ) + .page(ShowcaseProps { + order_id: 900_719_925_474_099_988, + }) .preserve_big_integers(true) .once("plans", || async { vec!["Free", "Pro", "Team"] }) .deferred("stats", "default", move || async move { @@ -78,44 +74,35 @@ async fn showcase( async fn showcase_jump(inertia: Inertia) -> impl IntoResponse { inertia .redirect("/showcase#feed") - .with_flash("success", json!("Jumped to the feed")) + .with_flash("success", "Jumped to the feed") } async fn home(inertia: Inertia) -> impl axum::response::IntoResponse { - inertia.render("home", HomeProps {}) + inertia.page(HomeProps {}) } async fn todos_index( inertia: Inertia, State(store): State, ) -> impl axum::response::IntoResponse { - inertia.render("todos/index", TodosIndexProps { todos: store.all() }) + inertia.page(TodosIndexProps { todos: store.all() }) } async fn todos_new(inertia: Inertia) -> impl axum::response::IntoResponse { - inertia.render("todos/create", TodosCreateProps {}) + inertia.page(TodosCreateProps {}) } +/// `Validated` answers live validation (Precognition) and sends invalid +/// input back to the form with the errors. This handler runs for valid input. async fn todos_create( inertia: Inertia, State(store): State, - InertiaForm(body): InertiaForm, -) -> axum::response::Response { - // Live validation (Precognition): answer and do not create the todo. - if let Some(precognition) = inertia.precognition() { - return precognition.respond(body.validate()); - } - if let Err(errors) = body.validate() { - return inertia - .with_errors(errors) - .redirect("/todos/new") - .into_response(); - } + Validated(body): Validated, +) -> impl IntoResponse { store.add(body.title); inertia .redirect("/todos") - .with_flash("success", json!("Todo created")) - .into_response() + .with_flash("success", "Todo created") } async fn todos_delete( @@ -128,5 +115,5 @@ async fn todos_delete( } else { "Todo not found" }; - inertia.redirect("/todos").with_flash("success", json!(msg)) + inertia.redirect("/todos").with_flash("success", msg) } diff --git a/examples/axum-react-todo/src/main.rs b/examples/axum-react-todo/src/main.rs index e41f8e6..f0510de 100644 --- a/examples/axum-react-todo/src/main.rs +++ b/examples/axum-react-todo/src/main.rs @@ -26,7 +26,7 @@ async fn main() { // `ViteManifest::load`). let ssr_mode = std::env::var("SSR").is_ok(); - let mut cfg = InertiaConfig::new().version(|| "dev".into()).session( + let mut cfg = InertiaConfig::new().version_str("dev").session( CookieSessionStore::new(b"01234567890123456789012345678901".to_vec()).secure(false), ); // Record requests for the Inertia DevTools browser extension (`devtools` @@ -55,8 +55,14 @@ async fn main() { // CSRF protection (demo secret — load from config/env in production). // Stacked outside InertiaLayer so it verifies before the handler runs and // issues the XSRF-TOKEN cookie the Inertia/axios client echoes back. - let app = router() - .build() + let app = router().build(); + // Keep the TypeScript bindings current while you develop. Files are + // written only when their content changes. + if cfg!(debug_assertions) { + let out = concat!(env!("CARGO_MANIFEST_DIR"), "/frontend/gen"); + veer::bindings::generate_split(out).expect("generate bindings"); + } + let app = app .with_state(store) .layer(InertiaLayer::new(cfg)) .layer(CsrfLayer::new(b"01234567890123456789012345678901".to_vec()).secure(false)); diff --git a/src/adapters/axum/csrf.rs b/src/adapters/axum/csrf.rs index cae571c..4365727 100644 --- a/src/adapters/axum/csrf.rs +++ b/src/adapters/axum/csrf.rs @@ -138,9 +138,10 @@ where .map(|c| cfg.tokens.is_valid(c)) .unwrap_or(false); - let is_mutating = matches!( + // Each method that is not safe is checked, also an unknown one. + let is_mutating = !matches!( *req.method(), - Method::POST | Method::PUT | Method::PATCH | Method::DELETE + Method::GET | Method::HEAD | Method::OPTIONS | Method::TRACE ); let excluded = path_excluded(req.uri().path(), &cfg.excludes); diff --git a/src/adapters/axum/embed.rs b/src/adapters/axum/embed.rs index 14d90de..6015441 100644 --- a/src/adapters/axum/embed.rs +++ b/src/adapters/axum/embed.rs @@ -77,7 +77,10 @@ impl EmbeddedAssets { .unwrap(); } let path = req.uri().path().trim_start_matches('/'); - match (self.resolver)(path) { + // A resolver can read from the file system (rust-embed does in a debug + // build), so a path that goes out of the folder is not passed on. + let escapes = path.contains(['\\', '\0', '%']) || path.split('/').any(|s| s == ".."); + match (!escapes).then(|| (self.resolver)(path)).flatten() { Some(bytes) => { // A bad `.mime()` override must not panic the builder; fall // back to octet-stream if it isn't a valid header value. @@ -99,6 +102,7 @@ impl EmbeddedAssets { .header(header::CONTENT_TYPE, ct) .header(header::CONTENT_LENGTH, len) .header(header::CACHE_CONTROL, "public, max-age=31536000, immutable") + .header(header::X_CONTENT_TYPE_OPTIONS, "nosniff") .body(body) .unwrap() } diff --git a/src/adapters/axum/extractor.rs b/src/adapters/axum/extractor.rs index b535eae..8c7664d 100644 --- a/src/adapters/axum/extractor.rs +++ b/src/adapters/axum/extractor.rs @@ -1,9 +1,11 @@ //! `Inertia` request extractor. +use super::response::server_error; use crate::config::InertiaConfig; use crate::inertia::Inertia; use crate::request::RequestInfo; use crate::session::Flash; +use axum::response::{IntoResponse, Response}; use axum::{extract::FromRequestParts, http::request::Parts}; use http::Extensions; use std::sync::Arc; @@ -23,29 +25,42 @@ pub(crate) struct PerRequest { pub previous_url: Option, } +/// Rejection of the [`Inertia`] extractor: the route is not inside an +/// [`InertiaLayer`](super::InertiaLayer). It is a `500`; a debug build puts +/// the cause in the response body. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MissingInertiaLayer; + +impl std::fmt::Display for MissingInertiaLayer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str( + "veer: the `Inertia` extractor ran on a route that is not inside `InertiaLayer`. \ + Add `.layer(InertiaLayer::new(config))` to your axum Router, after the routes.", + ) + } +} + +impl IntoResponse for MissingInertiaLayer { + fn into_response(self) -> Response { + server_error(&self.to_string()) + } +} + impl FromRequestParts for Inertia where S: Send + Sync, { - type Rejection = axum::http::StatusCode; + type Rejection = MissingInertiaLayer; async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { let per = parts .extensions .get::() .cloned() - .ok_or_else(|| { - tracing::error!( - "veer: Inertia extractor invoked but `InertiaLayer` is not installed on this Router. Add `.layer(InertiaLayer::new(config))` to your axum app." - ); - axum::http::StatusCode::INTERNAL_SERVER_ERROR - })?; - let url = parts - .uri - .path_and_query() - .map(|p| p.as_str().to_string()) - .unwrap_or_else(|| parts.uri.path().to_string()); - let req_info = RequestInfo::from_parts(parts.method.clone(), url, &parts.headers); + .ok_or(MissingInertiaLayer)?; + let url = RequestInfo::url_of(&parts.uri); + let req_info = RequestInfo::from_parts(parts.method.clone(), url, &parts.headers) + .with_extensions(per.req_extensions.clone()); let previous_url = per.previous_url.clone(); let mut inertia = Inertia::from_parts(per.config, req_info, (*per.flash).clone()); inertia.previous_url = previous_url; diff --git a/src/adapters/axum/layer.rs b/src/adapters/axum/layer.rs index 6f07fab..4fbc3e1 100644 --- a/src/adapters/axum/layer.rs +++ b/src/adapters/axum/layer.rs @@ -106,11 +106,7 @@ where }; // Capture data we'll need to rebuild RequestInfo for finalize. let method_for_post = parts.method.clone(); - let url_for_finalize = parts - .uri - .path_and_query() - .map(|p| p.as_str().to_string()) - .unwrap_or_else(|| parts.uri.path().to_string()); + let url_for_finalize = RequestInfo::url_of(&parts.uri); let headers_clone = parts.headers.clone(); // Snapshot extensions so session stores that piggyback on // middleware-installed handles (e.g. tower-sessions::Session) can @@ -138,7 +134,8 @@ where let mut resp = inner.call(request).await?; let req_info = - RequestInfo::from_parts(method_for_post.clone(), url_for_finalize, &headers_clone); + RequestInfo::from_parts(method_for_post.clone(), url_for_finalize, &headers_clone) + .with_extensions(extensions_snapshot.clone()); // If handler returned an Inertia marker, finalize. if let Some(marker) = resp.extensions_mut().remove::() { @@ -243,7 +240,7 @@ async fn plain_response( per: &PerRequest, req: &RequestInfo, ) -> Response { - let version = (per.config.version)(); + let version = per.config.current_version(); let is_redirect = resp.status().is_redirection(); let fragment_target = resp .headers() diff --git a/src/adapters/axum/mod.rs b/src/adapters/axum/mod.rs index 120c675..5cad043 100644 --- a/src/adapters/axum/mod.rs +++ b/src/adapters/axum/mod.rs @@ -7,17 +7,26 @@ pub mod precognition; pub mod response; pub mod router; +#[cfg(any(feature = "validator", feature = "garde"))] +pub mod validated; + #[cfg(feature = "csrf")] pub mod csrf; #[cfg(feature = "embed")] pub mod embed; +pub use extractor::MissingInertiaLayer; pub use form::{InertiaForm, InertiaFormRejection}; pub use layer::InertiaLayer; pub use precognition::Precognition; pub use router::{Method, Router}; +#[cfg(feature = "garde")] +pub use validated::GardeValidated; +#[cfg(feature = "validator")] +pub use validated::Validated; + #[cfg(feature = "csrf")] pub use csrf::CsrfLayer; diff --git a/src/adapters/axum/response.rs b/src/adapters/axum/response.rs index 047fec7..16c800c 100644 --- a/src/adapters/axum/response.rs +++ b/src/adapters/axum/response.rs @@ -45,7 +45,7 @@ pub(crate) async fn finalize( ) -> Response { let cfg = &per.config; let incoming = &*per.flash; - let version = (cfg.version)().into_owned(); + let version = cfg.current_version().into_owned(); let decision = decide(DecisionInputs { req: req_info, @@ -56,6 +56,14 @@ pub(crate) async fn finalize( let mut pending = std::mem::take(&mut builder.pending_flash); + if let Some(error) = builder.props_error.take() { + let detail = format!( + "veer: the props of `{}` did not serialize: {error}", + builder.component + ); + return failed(server_error(&detail), per).await; + } + // Control responses carry no page. The flash data that this request read // goes on to the request that follows. let control = match &decision { @@ -156,10 +164,12 @@ pub(crate) async fn finalize( let resolved = match resolved { Ok(resolved) => resolved, Err(error) => { + // 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"); let mut r = Response::new(Body::from("Internal Server Error")); *r.status_mut() = StatusCode::INTERNAL_SERVER_ERROR; - return finish_with_flash(r, pending, per).await; + return failed(r, per).await; } }; @@ -238,23 +248,26 @@ pub(crate) async fn finalize( match client.render(&page_value).await { Ok(p) => ssr_payload = Some(p), Err(e) if cfg.ssr_required => { - let mut r = Response::new(Body::from(format!("ssr failed: {e}"))); - *r.status_mut() = StatusCode::INTERNAL_SERVER_ERROR; - return finish_with_flash(r, pending, per).await; + let r = server_error(&format!("ssr failed: {e}")); + return failed(r, per).await; } Err(e) => tracing::warn!(error = ?e, "SSR failed; falling back to client render"), } } - let mut html = cfg - .root_view - .render(RootViewContext { - page_json: &escaped, - page_json_script: &script_escaped, - asset_version: &version, - ssr: ssr_payload.as_ref(), - }) - .unwrap_or_else(|e| format!("root view error: {e}")); + let rendered = cfg.root_view.render(RootViewContext { + page_json: &escaped, + page_json_script: &script_escaped, + asset_version: &version, + ssr: ssr_payload.as_ref(), + }); + let mut html = match rendered { + Ok(html) => html, + Err(e) => { + let r = server_error(&format!("root view error: {e}")); + return failed(r, per).await; + } + }; // The extension sees the first page load through the DOM only. if let Some((id, at)) = per .devtools_id @@ -275,6 +288,9 @@ pub(crate) async fn finalize( r }; + if let Some(status) = builder.status { + *response.status_mut() = status; + } if let Some(recorded) = recorded { response.extensions_mut().insert(recorded); } @@ -328,10 +344,41 @@ pub(crate) async fn finish_with_flash( session .write(response.headers_mut(), &per.req_extensions, pending) .await; + } else if !pending.is_empty() { + tracing::warn!( + "veer: flash data or validation errors were set, but there is no session store, \ + so they are lost. Set one with `InertiaConfig::session`." + ); } response } +/// Finish a `500`. The page did not render, so the flash data that this +/// request read goes on to the request that follows. +async fn failed(response: Response, per: &PerRequest) -> Response { + finish_with_flash(response, (*per.flash).clone(), per).await +} + +/// A `500` for a failure in the application's use of veer. The cause is +/// logged; a debug build also puts it in the body. +pub(crate) fn server_error(detail: &str) -> Response { + tracing::error!("{detail}"); + let body = if cfg!(debug_assertions) { + detail + } else { + "Internal Server Error" + }; + let mut r = Response::new(Body::from(body.to_owned())); + *r.status_mut() = StatusCode::INTERNAL_SERVER_ERROR; + // Plain text: the Inertia client shows a non-Inertia response in a modal, + // and the detail must not be read as HTML. + r.headers_mut().insert( + http::header::CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + ); + r +} + fn html_attr_escape(s: &str) -> String { s.replace('&', "&") .replace('<', "<") diff --git a/src/adapters/axum/router.rs b/src/adapters/axum/router.rs index f754858..7ad074f 100644 --- a/src/adapters/axum/router.rs +++ b/src/adapters/axum/router.rs @@ -16,7 +16,7 @@ //! .named_route(GET, "home", "/", home) //! .named_route(GET, "todos.index", "/todos", index) //! .named_route(POST, "todos.store", "/todos", store) -//! .named_route(DELETE, "todos.destroy", "/todos/:id", destroy) +//! .named_route(DELETE, "todos.destroy", "/todos/{id}", destroy) //! .build(); //! # } //! ``` diff --git a/src/adapters/axum/validated.rs b/src/adapters/axum/validated.rs new file mode 100644 index 0000000..7cc3395 --- /dev/null +++ b/src/adapters/axum/validated.rs @@ -0,0 +1,93 @@ +//! Body extractors that validate before the handler runs. + +use crate::adapters::axum::InertiaForm; +use crate::errors::IntoErrorBag; +use crate::inertia::Inertia; +use axum::body::Body; +use axum::extract::{FromRequest, FromRequestParts}; +use axum::http::Request; +use axum::response::{IntoResponse, Response}; +use serde::de::DeserializeOwned; + +/// Decode the body as [`InertiaForm`] does, then apply `validate`. +/// +/// A Precognition request gets its answer here. Invalid input goes back to +/// the page that the user came from, with the errors. +// `Response` is the rejection type of the extractors below, as is usual in axum. +#[allow(clippy::result_large_err)] +async fn extract( + req: Request, + state: &S, + validate: impl FnOnce(&T) -> Result<(), E>, +) -> Result +where + S: Send + Sync, + T: DeserializeOwned, + E: IntoErrorBag, +{ + let (mut parts, body) = req.into_parts(); + let inertia = Inertia::from_request_parts(&mut parts, state) + .await + .map_err(IntoResponse::into_response)?; + let InertiaForm(value) = + InertiaForm::::from_request(Request::from_parts(parts, body), state) + .await + .map_err(IntoResponse::into_response)?; + + let result = validate(&value); + if let Some(precognition) = inertia.precognition() { + return Err(precognition.respond(result)); + } + match result { + Ok(()) => Ok(value), + Err(errors) => Err(inertia.back().with_errors(errors).into_response()), + } +} + +/// An [`InertiaForm`] body that passed `validator::Validate`. +/// +/// The handler runs only for valid input that is not a Precognition request: +/// +/// ```ignore +/// async fn store(inertia: Inertia, Validated(body): Validated) -> impl IntoResponse { +/// create_user(body).await; +/// inertia.redirect("/users").with_flash("success", "User created") +/// } +/// ``` +/// +/// Invalid input redirects back ([`Inertia::back`]) with the errors, which +/// needs a session store. A Precognition request gets `204` or `422`. +#[cfg(feature = "validator")] +pub struct Validated(pub T); + +#[cfg(feature = "validator")] +impl FromRequest for Validated +where + S: Send + Sync, + T: DeserializeOwned + validator::Validate, +{ + type Rejection = Response; + + async fn from_request(req: Request, state: &S) -> Result { + extract(req, state, T::validate).await.map(Self) + } +} + +/// An [`InertiaForm`] body that passed `garde::Validate`. It behaves as +/// `Validated` does for the `validator` crate. +#[cfg(feature = "garde")] +pub struct GardeValidated(pub T); + +#[cfg(feature = "garde")] +impl FromRequest for GardeValidated +where + S: Send + Sync, + T: DeserializeOwned + garde::Validate, + T::Context: Default, +{ + type Rejection = Response; + + async fn from_request(req: Request, state: &S) -> Result { + extract(req, state, T::validate).await.map(Self) + } +} diff --git a/src/bindings/mod.rs b/src/bindings/mod.rs index f67a4fb..06736e3 100644 --- a/src/bindings/mod.rs +++ b/src/bindings/mod.rs @@ -206,11 +206,19 @@ pub fn generate(out: impl AsRef) -> Result<(), GenerateError> { if let Some(parent) = out.parent() { std::fs::create_dir_all(parent)?; } - let body = render(); - std::fs::write(out, body)?; + write_if_changed(out, &render())?; Ok(()) } +/// Write `content` only if the file does not have it already, so that a +/// file watcher (Vite) sees a change only when there is one. +fn write_if_changed(path: &Path, content: &str) -> io::Result<()> { + if std::fs::read_to_string(path).is_ok_and(|current| current == content) { + return Ok(()); + } + std::fs::write(path, content) +} + /// Shortcut for `Split::new(dir).generate()` — Wayfinder-style split output /// with default subdir (`actions/`) and no filename prefix/suffix. /// @@ -256,9 +264,11 @@ pub fn generate_split(dir: impl AsRef) -> Result<(), GenerateError> { /// # } /// ``` /// -/// Existing files inside the actions directory are NOT cleaned up. Stale -/// files can appear if you rename or remove a controller — delete the -/// directory manually before regenerating if that matters. +/// A file is written only when its content changes. The file of a controller +/// that no longer exists is deleted, if all of these are true: the actions +/// directory is a subdirectory (not `actions_dir("")`), the file has the veer +/// header and the prefix and suffix of this configuration, and at least one +/// route is registered. Other files are not touched. pub struct Split { dir: std::path::PathBuf, actions_dir: String, @@ -310,13 +320,42 @@ impl Split { let controllers = routes::render_per_controller(); + let mut written = vec![self.dir.join("index.ts")]; for (name, body) in &controllers { let mut file = String::new(); file.push_str(HEADER); file.push('\n'); file.push_str(body); let filename = format!("{}{}{}.ts", self.file_prefix, name, self.file_suffix); - std::fs::write(actions_path.join(filename), file)?; + let path = actions_path.join(filename); + write_if_changed(&path, &file)?; + written.push(path); + } + + // Remove the files of controllers that no longer exist. Only in a + // directory of its own, only names of this configuration, and not when + // no route is registered (the router was not built before this call). + let sweep = !self.actions_dir.is_empty() && !controllers.is_empty(); + for entry in sweep + .then(|| std::fs::read_dir(&actions_path)) + .transpose()? + .into_iter() + .flatten() + { + let path = entry?.path(); + let is_ours = path + .file_name() + .and_then(|name| name.to_str()) + .and_then(|name| name.strip_suffix(".ts")) + .is_some_and(|stem| { + stem.starts_with(&self.file_prefix) && stem.ends_with(&self.file_suffix) + }); + let is_stale = is_ours + && !written.contains(&path) + && std::fs::read_to_string(&path).is_ok_and(|s| s.starts_with(HEADER)); + if is_stale { + std::fs::remove_file(&path)?; + } } let mut index = String::new(); @@ -337,7 +376,7 @@ impl Split { }; let _ = writeln!(index, "export * as {name} from \"{rel}\";"); } - std::fs::write(self.dir.join("index.ts"), index)?; + write_if_changed(&self.dir.join("index.ts"), &index)?; Ok(()) } } @@ -433,3 +472,43 @@ export type ErrorBag = Record; export type Flash = Record; "#; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn split_removes_stale_generated_files_only() { + crate::bindings::register_runtime_route("sweep.index", "/sweep", "GET"); + let dir = std::env::temp_dir().join(format!("veer-split-{}", std::process::id())); + let actions = dir.join("actions"); + std::fs::create_dir_all(&actions).unwrap(); + let generated = format!("{HEADER}\nexport {{}};"); + let stale = actions.join("removed.ts"); + let other_config = actions.join("removed-controller.ts"); + let own = actions.join("helpers.ts"); + let bundle = dir.join("bundle.ts"); + std::fs::write(&stale, &generated).unwrap(); + std::fs::write(&other_config, &generated).unwrap(); + std::fs::write(&own, "export const mine = 1;").unwrap(); + std::fs::write(&bundle, &generated).unwrap(); + + Split::new(&dir).generate().unwrap(); + assert!(!stale.exists() && !other_config.exists()); + assert!(own.exists() && actions.join("sweep.ts").exists()); + + // A flat layout shares its directory with other files: no sweep. + Split::new(&dir).actions_dir("").generate().unwrap(); + assert!(bundle.exists()); + + // A suffix limits the sweep to names with that suffix. + std::fs::write(&stale, &generated).unwrap(); + std::fs::write(&other_config, &generated).unwrap(); + Split::new(&dir) + .file_suffix("-controller") + .generate() + .unwrap(); + assert!(stale.exists() && !other_config.exists()); + std::fs::remove_dir_all(&dir).unwrap(); + } +} diff --git a/src/config.rs b/src/config.rs index ff553a6..f28e2c7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -16,7 +16,7 @@ type VersionFn = Arc Cow<'static, str> + Send + Sync>; /// Top-level app config. Built once at startup, cloned by Arc into each request. #[derive(Clone)] pub struct InertiaConfig { - pub(crate) version: VersionFn, + pub(crate) version: Option, pub(crate) root_view: Arc, pub(crate) session: Option>, pub(crate) ssr: Option>, @@ -35,7 +35,7 @@ pub struct InertiaConfig { impl Default for InertiaConfig { fn default() -> Self { Self { - version: Arc::new(|| Cow::Borrowed("1")), + version: None, root_view: Arc::new(MinimalRootView::new()), session: None, ssr: None, @@ -59,15 +59,34 @@ impl InertiaConfig { Self::default() } - /// Set asset version producer. + /// Set asset version producer. Without one, the version comes from the + /// root view ([`RootView::version`]; the manifest hash for a production + /// [`crate::ViteRootView`]), and is `"1"` if the root view has none. pub fn version(mut self, f: F) -> Self where F: Fn() -> Cow<'static, str> + Send + Sync + 'static, { - self.version = Arc::new(f); + self.version = Some(Arc::new(f)); self } + /// Set a constant asset version. + pub fn version_str(self, version: impl Into>) -> Self { + let version = version.into(); + self.version(move || version.clone()) + } + + /// The asset version of this moment. + pub(crate) fn current_version(&self) -> Cow<'static, str> { + match &self.version { + Some(f) => f(), + None => self + .root_view + .version() + .map_or(Cow::Borrowed("1"), Cow::Owned), + } + } + /// Set the root view used for non-XHR responses. pub fn root_view(mut self, v: V) -> Self { self.root_view = Arc::new(v); @@ -98,12 +117,37 @@ impl InertiaConfig { self } - /// Set shared props. + /// Set shared props from a [`SharedProps`] implementation. For a closure, + /// use [`Self::share`]. pub fn shared(mut self, p: P) -> Self { self.shared = Some(Arc::new(p)); self } + /// Share props with every page. The closure runs on each page render and + /// returns any `Serialize` value that serializes to an object: + /// + /// ``` + /// # use veer::InertiaConfig; + /// # #[derive(Clone, serde::Serialize)] struct User { name: String } + /// let config = InertiaConfig::new().share(|req| { + /// // Put there by your auth middleware. + /// let user = req.extension::().cloned(); + /// async move { serde_json::json!({ "auth": { "user": user } }) } + /// }); + /// ``` + pub fn share(self, f: F) -> Self + where + F: Fn(&RequestInfo) -> Fut + Send + Sync + 'static, + Fut: Future + Send + 'static, + T: serde::Serialize, + { + self.shared(crate::shared::FnSharedProps(move |req: &RequestInfo| { + let value = f(req); + async { crate::props::prop::to_json(value.await) } + })) + } + /// Share a once prop with every page. The closure runs only when the /// client does not hold the value yet. A handler prop with the same key wins. pub fn share_once(mut self, key: impl Into, f: F) -> Self @@ -182,6 +226,37 @@ mod tests { .ssr_required(true); assert!(c.csr_only); assert!(c.ssr_required); - assert_eq!((c.version)(), "v9"); + assert_eq!(c.current_version(), "v9"); + } + + #[test] + fn version_defaults_to_the_root_view_then_to_1() { + struct Versioned; + impl RootView for Versioned { + fn render(&self, _: crate::RootViewContext<'_>) -> Result { + Ok(String::new()) + } + fn version(&self) -> Option { + Some("abc".into()) + } + } + assert_eq!(InertiaConfig::new().current_version(), "1"); + let c = InertiaConfig::new().root_view(Versioned); + assert_eq!(c.current_version(), "abc"); + assert_eq!(c.version_str("v2").current_version(), "v2"); + } + + #[tokio::test] + async fn share_reads_request_extensions() { + let c = InertiaConfig::new().share(|req| { + let user = req.extension::<&'static str>().copied(); + async move { serde_json::json!({ "user": user }) } + }); + let mut extensions = http::Extensions::new(); + extensions.insert("ada"); + 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" })); } } diff --git a/src/inertia.rs b/src/inertia.rs index 072275d..6a75383 100644 --- a/src/inertia.rs +++ b/src/inertia.rs @@ -47,17 +47,18 @@ impl Inertia { /// Render a component with strongly-typed props. #[track_caller] pub fn render(&self, component: impl Into, props: P) -> InertiaResponse { - let caller = std::panic::Location::caller(); - let value = match serde_json::to_value(&props) { - Ok(v) => v, - Err(e) => { - tracing::error!(error = %e, "veer: failed to serialize props for render; using null"); - Value::Null - } - }; - let mut response = InertiaResponse::new(component, value); - response.render_source = Some(caller); - response + InertiaResponse::render(component, props) + } + + /// Render the page that `props` belongs to. The component name comes from + /// [`register_page!`](crate::register_page), so it is written one time. + #[cfg(feature = "ts")] + #[track_caller] + pub fn page

(&self, props: P) -> InertiaResponse + where + P: Serialize + crate::bindings::InertiaPageProps, + { + InertiaResponse::render(P::COMPONENT, props) } /// Internal redirect (303 on POST/PUT/PATCH/DELETE; 302-equivalent SeeOther on GET). diff --git a/src/lib.rs b/src/lib.rs index e643668..02f15ea 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,7 +13,6 @@ //! //! # async fn run() { //! let config = InertiaConfig::new() -//! .version(|| "1".into()) //! .root_view(MinimalRootView::new().title("Acme").vite_entry("/src/main.tsx")); //! //! let app: Router = Router::new() @@ -39,6 +38,7 @@ //! | `embed` | off | Embedded-asset serving service (`EmbeddedAssets`) | //! | `devtools` | off | Recorder + read API for the Inertia DevTools browser extension | //! | `ts` | off | TypeScript bindings codegen (`ts-rs` + `inventory`) | +//! | `testing` | off | Helpers for tests of your handlers ([`testing`]) | //! //! # Architecture //! @@ -53,6 +53,7 @@ //! - `Always` and `Merge` wrappers are detected at any depth and through //! any serialization path (typed structs, `serde_json::json!`, hand-built //! `Value`s). A nested wrapper acts at its dot path (`posts.data`). +#![cfg_attr(docsrs, feature(doc_cfg))] #![warn(missing_docs)] #![warn(rust_2018_idioms)] @@ -107,16 +108,27 @@ pub use page::PageObject; pub use props::{Always, Merge, Prop, ScrollMetadata}; pub use request::RequestInfo; pub use response::InertiaResponse; -pub use root_view::{MinimalRootView, RootView, RootViewContext, ViteManifest, ViteRootView}; +pub use root_view::{ + MinimalRootView, RootView, RootViewContext, ViteManifest, ViteManifestError, ViteRootView, +}; pub use session::{Flash, SessionStore}; pub use shared::SharedProps; pub use ssr::{SsrClient, SsrPayload}; #[cfg(feature = "axum")] pub use adapters::axum::{ - InertiaForm, InertiaFormRejection, InertiaLayer, Method, Precognition, Router, + InertiaForm, InertiaFormRejection, InertiaLayer, Method, MissingInertiaLayer, Precognition, + Router, }; +#[cfg(all(feature = "axum", feature = "garde"))] +pub use adapters::axum::GardeValidated; +#[cfg(all(feature = "axum", feature = "validator"))] +pub use adapters::axum::Validated; + +#[cfg(feature = "testing")] +pub mod testing; + #[cfg(feature = "csrf")] pub use adapters::axum::CsrfLayer; diff --git a/src/request.rs b/src/request.rs index 74b9fdb..5d7302d 100644 --- a/src/request.rs +++ b/src/request.rs @@ -1,7 +1,8 @@ //! Per-request data parsed from Inertia headers. -use http::{HeaderMap, Method}; +use http::{Extensions, HeaderMap, Method}; use std::collections::HashSet; +use std::sync::Arc; /// Request information needed to drive the Inertia protocol. #[derive(Debug, Clone)] @@ -11,10 +12,9 @@ pub struct RequestInfo { pub method: Method, /// Full URL the client is currently at (path + query). pub url: String, - /// Value of the `Referer` header, if present. - /// - /// Used by [`crate::inertia::Inertia::back()`] to redirect the client to the - /// previous page. Falls back to `"/"` when absent. + /// The path and query of the `Referer` header. The scheme and the host are + /// dropped, so that [`crate::inertia::Inertia::back()`] cannot redirect to + /// another site. pub referer: Option, /// `true` iff `X-Inertia: true` was set. pub is_inertia: bool, @@ -40,6 +40,9 @@ pub struct RequestInfo { pub is_precognition: bool, /// Fields from `Precognition-Validate-Only`. Empty means all fields. pub validate_only: HashSet, + /// The request extensions, as they were when the request reached the + /// adapter. Read one with [`RequestInfo::extension`]. + pub extensions: Arc, } impl RequestInfo { @@ -62,7 +65,7 @@ impl RequestInfo { let client_version = text(&crate::headers::X_INERTIA_VERSION).map(str::to_owned); let partial_component = text(&crate::headers::X_INERTIA_PARTIAL_COMPONENT).map(str::to_owned); - let referer = text(&http::header::REFERER).map(str::to_owned); + let referer = text(&http::header::REFERER).and_then(local_path); Self { method, url, @@ -82,9 +85,30 @@ impl RequestInfo { is_prefetch: text(&crate::headers::PURPOSE) == Some("prefetch"), is_precognition: text(&crate::headers::PRECOGNITION) == Some("true"), validate_only: split_csv(headers, &crate::headers::PRECOGNITION_VALIDATE_ONLY), + extensions: Arc::default(), } } + /// The URL of a request: path and query. Leading slashes are collapsed, + /// because a browser reads `//host/path` as a URL of another site. + pub fn url_of(uri: &http::Uri) -> String { + let url = uri.path_and_query().map_or(uri.path(), |p| p.as_str()); + format!("/{}", url.trim_start_matches(['/', '\\'])) + } + + /// Attach the request extensions; used by adapters. + pub fn with_extensions(mut self, extensions: Arc) -> Self { + self.extensions = extensions; + self + } + + /// A value that a middleware put in the request extensions (the signed-in + /// user, a session handle, a locale). The middleware must run before the + /// Inertia layer: add its layer after `InertiaLayer`. + pub fn extension(&self) -> Option<&T> { + self.extensions.get() + } + /// Returns `true` if the request is a partial reload (component header set + only/except non-empty). pub fn is_partial(&self) -> bool { self.partial_component.is_some() @@ -92,11 +116,63 @@ impl RequestInfo { } } +/// The path and query of `referer`. Only the path is kept, so the result is +/// always a URL of this site. The host is not compared with the request: it +/// is not reliable behind a proxy or on HTTP/2, and the path alone cannot +/// point at another site. +fn local_path(referer: &str) -> Option { + let path = if referer.starts_with('/') { + referer.to_owned() + } else { + let uri: http::Uri = referer.parse().ok()?; + // An absolute URL has a scheme; anything else is not a Referer. + uri.scheme()?; + uri.path_and_query().map_or("/", |p| p.as_str()).to_owned() + }; + // `//host` and `/\host` are URLs of another site for a browser. + let is_local = path.starts_with('/') && !path[1..].starts_with(['/', '\\']); + is_local.then_some(path) +} + #[cfg(test)] mod tests { use super::*; use http::HeaderValue; + #[test] + fn referer_keeps_only_a_local_path() { + let referer = |value: &str| { + let mut h = HeaderMap::new(); + h.insert("referer", hv(value)); + RequestInfo::from_parts(Method::POST, "/".into(), &h).referer + }; + assert_eq!(referer("/form?a=1").as_deref(), Some("/form?a=1")); + assert_eq!( + referer("https://app.test:3000/form?a=1").as_deref(), + Some("/form?a=1") + ); + assert_eq!(referer("http://app.test").as_deref(), Some("/")); + // The host of another site is dropped; the path stays on this site. + assert_eq!(referer("https://evil.test/form").as_deref(), Some("/form")); + for other in [ + "//evil.test/form", + "/\\evil.test", + "http://app.test//evil.test", + "javascript:alert(1)", + "not a url", + ] { + assert_eq!(referer(other), None, "{other}"); + } + } + + #[test] + fn url_of_collapses_leading_slashes() { + let url = |s: &str| RequestInfo::url_of(&s.parse().unwrap()); + assert_eq!(url("/users?page=2"), "/users?page=2"); + assert_eq!(url("//evil.test/x"), "/evil.test/x"); + assert_eq!(url("/"), "/"); + } + fn hv(s: &str) -> HeaderValue { HeaderValue::from_str(s).unwrap() } @@ -116,10 +192,7 @@ mod tests { let mut h = HeaderMap::new(); h.insert(http::header::REFERER, hv("https://example.com/previous")); let info = RequestInfo::from_parts(Method::POST, "/submit".into(), &h); - assert_eq!( - info.referer.as_deref(), - Some("https://example.com/previous") - ); + assert_eq!(info.referer.as_deref(), Some("/previous")); } #[test] diff --git a/src/response.rs b/src/response.rs index a039936..2cdda95 100644 --- a/src/response.rs +++ b/src/response.rs @@ -19,6 +19,9 @@ pub struct InertiaResponse { pub(crate) skip_ssr: bool, pub(crate) redirect: Option, pub(crate) pending_flash: crate::session::Flash, + pub(crate) status: Option, + /// The props did not serialize; the response is a 500. + pub(crate) props_error: Option, /// Where `Inertia::render` was called (for DevTools). #[cfg_attr(not(feature = "devtools"), allow(dead_code))] pub(crate) render_source: Option<&'static std::panic::Location<'static>>, @@ -38,10 +41,34 @@ impl InertiaResponse { skip_ssr: false, redirect: None, pending_flash: Default::default(), + status: None, + props_error: None, render_source: None, } } + /// Render a component with props. The same as [`crate::Inertia::render`], + /// for code that has no `Inertia` handle, such as the `IntoResponse` impl + /// of an application error type. + #[track_caller] + pub fn render(component: impl Into, props: P) -> Self { + let (value, error) = match serde_json::to_value(&props) { + Ok(v) => (v, None), + Err(e) => (Value::Null, Some(e.to_string())), + }; + let mut response = Self::new(component, value); + response.props_error = error; + response.render_source = Some(std::panic::Location::caller()); + response + } + + /// Set the HTTP status of a page response (for example `404` for an error + /// page). The default is `200`. + pub fn status(mut self, status: http::StatusCode) -> Self { + self.status = Some(status); + self + } + /// Attach a closure-resolved [`Prop`] under a key. A dot path (`auth.perms`) /// puts it inside a nested object. It replaces a plain value at the same path. pub fn prop(mut self, key: impl Into, prop: Prop) -> Self { diff --git a/src/root_view/minimal.rs b/src/root_view/minimal.rs index 6f50a0c..56277ac 100644 --- a/src/root_view/minimal.rs +++ b/src/root_view/minimal.rs @@ -42,7 +42,12 @@ impl RootView for MinimalRootView { let vite = self .vite_entry .as_deref() - .map(|p| format!(r#""#)) + .map(|p| { + format!( + r#""#, + html_escape(p) + ) + }) .unwrap_or_default(); // SSR sidecars (`@inertiajs//server`) return a body that // already contains the ` -"# +"#, + server = html_escape(server), + entry = html_escape(entry), )); out } @@ -292,7 +341,9 @@ fn render_production( .entries .get(entry) .ok_or_else(|| format!("vite manifest: entry {entry:?} not found"))?; - let base = asset_base.trim_end_matches('/'); + let base = html_escape(asset_base.trim_end_matches('/')); + // File names come from the manifest; they go into attributes escaped. + let escaped = |file: &str| html_escape(file); let mut scripts = String::new(); let mut styles = String::new(); @@ -303,12 +354,13 @@ fn render_production( scripts.push_str(&format!( r#" "#, - file = chunk.file + file = escaped(&chunk.file) )); for css in &chunk.css { styles.push_str(&format!( r#" -"# +"#, + css = escaped(css) )); } @@ -335,13 +387,15 @@ fn render_production( for file in &preload_js { scripts.push_str(&format!( r#" -"# +"#, + file = escaped(file) )); } for css in &preload_css { styles.push_str(&format!( r#" -"# +"#, + css = escaped(css) )); } @@ -503,6 +557,26 @@ mod tests { assert!(html.contains(r#"

"#)); } + #[test] + fn version_is_the_manifest_hash_in_production_only() { + let manifest = + ViteManifest::from_str(r#"{"frontend/app.tsx": {"file": "assets/app-1.js"}}"#).unwrap(); + // Setters of the other mode have no effect and do not panic. + let production = ViteRootView::production() + .manifest(manifest.clone()) + .react_refresh(true) + .dev_server("http://localhost:1"); + assert_eq!(production.version(), Some(manifest.hash())); + let dev = ViteRootView::dev().manifest(manifest).asset_base("/x"); + assert_eq!(dev.version(), None); + } + + #[test] + fn load_error_names_the_path() { + let error = ViteManifest::load("no/such/manifest.json").unwrap_err(); + assert!(error.to_string().contains("no/such/manifest.json")); + } + #[test] fn manifest_hash_is_stable_and_change_sensitive() { let a = diff --git a/src/session/cookie.rs b/src/session/cookie.rs index 88322fb..0d85a01 100644 --- a/src/session/cookie.rs +++ b/src/session/cookie.rs @@ -52,20 +52,26 @@ impl CookieSessionStore { self } - fn sign(&self, payload: &[u8]) -> String { + /// `name` is the cookie name. It is part of the signed data, so that the + /// value of one cookie is not valid as another cookie. + fn sign(&self, name: &str, payload: &[u8]) -> String { let mut mac = HmacSha256::new_from_slice(&self.key).expect("hmac key"); + mac.update(name.as_bytes()); + mac.update(b"="); mac.update(payload); URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()) } /// Verify a signature in constant time using HMAC's built-in `verify_slice`. - fn verify(&self, payload: &[u8], sig_b64: &str) -> bool { + fn verify(&self, name: &str, payload: &[u8], sig_b64: &str) -> bool { let Ok(sig_bytes) = URL_SAFE_NO_PAD.decode(sig_b64) else { return false; }; let Ok(mut mac) = HmacSha256::new_from_slice(&self.key) else { return false; }; + mac.update(name.as_bytes()); + mac.update(b"="); mac.update(payload); mac.verify_slice(&sig_bytes).is_ok() } @@ -73,13 +79,13 @@ impl CookieSessionStore { fn encode(&self, flash: &Flash) -> String { let payload = serde_json::to_vec(flash).unwrap(); let b64 = URL_SAFE_NO_PAD.encode(&payload); - let sig = self.sign(b64.as_bytes()); + let sig = self.sign(COOKIE_NAME, b64.as_bytes()); format!("{b64}.{sig}") } fn decode(&self, raw: &str) -> Option { let (b64, sig) = raw.split_once('.')?; - if !self.verify(b64.as_bytes(), sig) { + if !self.verify(COOKIE_NAME, b64.as_bytes(), sig) { return None; } let bytes = URL_SAFE_NO_PAD.decode(b64).ok()?; @@ -150,7 +156,7 @@ impl SessionStore for CookieSessionStore { async fn previous_url(&self, req: &RequestParts) -> Option { let raw = read_cookie(req, PREVIOUS_URL_COOKIE)?; let (b64, sig) = raw.split_once('.')?; - if !self.verify(b64.as_bytes(), sig) { + if !self.verify(PREVIOUS_URL_COOKIE, b64.as_bytes(), sig) { return None; } String::from_utf8(URL_SAFE_NO_PAD.decode(b64).ok()?).ok() @@ -163,7 +169,7 @@ impl SessionStore for CookieSessionStore { url: &str, ) { let b64 = URL_SAFE_NO_PAD.encode(url); - let value = format!("{b64}.{}", self.sign(b64.as_bytes())); + let value = format!("{b64}.{}", self.sign(PREVIOUS_URL_COOKIE, b64.as_bytes())); self.set_cookie(headers, PREVIOUS_URL_COOKIE, value, PREVIOUS_URL_MAX_AGE); } } @@ -225,6 +231,12 @@ mod tests { Some("/users?page=2") ); assert_eq!(store.previous_url(&request("L2V2aWw.bad")).await, None); + // A signed value of one cookie is not valid as the other cookie. + let as_flash = Request::builder().header(header::COOKIE, format!("{COOKIE_NAME}={value}")); + let as_flash = as_flash.body(()).unwrap().into_parts().0; + assert!(store.read_and_clear(&as_flash).await.is_empty()); + let flash_value = store.encode(&Flash::default()); + assert_eq!(store.previous_url(&request(&flash_value)).await, None); } #[tokio::test] diff --git a/src/testing.rs b/src/testing.rs new file mode 100644 index 0000000..3f8de65 --- /dev/null +++ b/src/testing.rs @@ -0,0 +1,165 @@ +//! Helpers for tests of your handlers (`testing` feature). +//! +//! ```ignore +//! use tower::ServiceExt; +//! use veer::testing::{visit, MemorySession, TestPage}; +//! +//! let app = app(InertiaConfig::new().session(MemorySession::default())); +//! let response = app.oneshot(visit("GET", "/users")).await.unwrap(); +//! +//! let page = TestPage::from_response(response).await; +//! assert_eq!(page.component, "Users/Index"); +//! assert_eq!(page.prop("users.0.name"), Some(&json!("Ada"))); +//! ``` + +use crate::session::{Flash, SessionStore}; +use async_trait::async_trait; +use axum::body::Body; +use axum::http::{Request, Response}; +use http::{Extensions, HeaderMap}; +use serde_json::Value; +use std::sync::{Arc, Mutex}; + +/// An Inertia visit: a request with `X-Inertia: true` and an empty body. +/// +/// It sends the asset version `"1"`, the default of [`crate::InertiaConfig`]. +/// If your config has another version, replace the `X-Inertia-Version` +/// header; a `GET` with a different version gets a `409`. Use +/// [`axum::http::Request::builder`] for a request with a body. +pub fn visit(method: &str, uri: &str) -> Request { + Request::builder() + .method(method) + .uri(uri) + .header(&crate::headers::X_INERTIA, "true") + .header(&crate::headers::X_INERTIA_VERSION, "1") + .body(Body::empty()) + .expect("veer::testing::visit: invalid method or URI") +} + +/// A session store that keeps its data in memory. Clones share the data, so +/// one store carries flash data from a request to the request that follows. +#[derive(Clone, Default)] +pub struct MemorySession { + flash: Arc>, + previous_url: Arc>>, +} + +impl MemorySession { + /// The flash data that the next request will read. + pub fn flash(&self) -> Flash { + self.flash.lock().unwrap().clone() + } +} + +#[async_trait] +impl SessionStore for MemorySession { + async fn read_and_clear(&self, _req: &http::request::Parts) -> Flash { + std::mem::take(&mut *self.flash.lock().unwrap()) + } + + async fn write(&self, _headers: &mut HeaderMap, _ext: &Extensions, flash: Flash) { + *self.flash.lock().unwrap() = flash; + } + + async fn previous_url(&self, _req: &http::request::Parts) -> Option { + self.previous_url.lock().unwrap().clone() + } + + async fn store_previous_url(&self, _headers: &mut HeaderMap, _ext: &Extensions, url: &str) { + *self.previous_url.lock().unwrap() = Some(url.to_owned()); + } +} + +/// The page object of a response. +#[derive(Debug, Clone, PartialEq)] +pub struct TestPage { + /// The component name. + pub component: String, + /// The props, with `errors` and the shared props. + pub props: Value, + /// The page URL. + pub url: String, + /// The complete page object. + pub raw: Value, +} + +impl TestPage { + /// Read the page object from an Inertia JSON response or from the + /// `")?; + Some(&html[start..end]) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Inertia, InertiaConfig, InertiaLayer}; + use axum::routing::get; + use serde_json::json; + use tower::ServiceExt; + + fn app() -> axum::Router { + axum::Router::new() + .route( + "/users", + get(|inertia: Inertia| async move { + inertia.render("Users/Index", json!({ "users": [{ "name": "" }] })) + }), + ) + .layer(InertiaLayer::new(InertiaConfig::new())) + } + + #[tokio::test] + async fn reads_the_page_from_json_and_from_html() { + let json_response = app().oneshot(visit("GET", "/users")).await.unwrap(); + let from_json = TestPage::from_response(json_response).await; + + let html_request = Request::get("/users").body(Body::empty()).unwrap(); + let html_response = app().oneshot(html_request).await.unwrap(); + let from_html = TestPage::from_response(html_response).await; + + assert_eq!(from_json, from_html); + assert_eq!(from_json.component, "Users/Index"); + assert_eq!(from_json.url, "/users"); + assert_eq!(from_json.prop("users.0.name"), Some(&json!(""))); + assert_eq!(from_json.prop("users.1"), None); + } +} diff --git a/tests/csrf.rs b/tests/csrf.rs index c15a9ce..abea4c1 100644 --- a/tests/csrf.rs +++ b/tests/csrf.rs @@ -181,3 +181,12 @@ async fn exclude_without_leading_slash_still_matches() { let resp = app.oneshot(req("POST", "/webhooks/stripe")).await.unwrap(); assert_eq!(resp.status(), 200); } + +#[tokio::test] +async fn unknown_method_without_token_is_419() { + let app = Router::new() + .route("/any", axum::routing::any(|| async { "done" })) + .layer(CsrfLayer::new(SECRET.to_vec()).secure(false)); + let resp = app.oneshot(req("PURGE", "/any")).await.unwrap(); + assert_eq!(resp.status().as_u16(), 419); +} diff --git a/tests/dx.rs b/tests/dx.rs new file mode 100644 index 0000000..a2443ab --- /dev/null +++ b/tests/dx.rs @@ -0,0 +1,236 @@ +//! Developer-experience behavior: clear failures, `Validated`, shared props +//! with request data, page status. +#![cfg(all(feature = "testing", feature = "validator", feature = "garde"))] + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use axum::response::IntoResponse; +use axum::routing::{get, post}; +use axum::Router; +use serde_json::json; +use std::collections::HashMap; +use tower::ServiceExt; +use veer::testing::{visit, MemorySession, TestPage}; +use veer::{GardeValidated, Inertia, InertiaConfig, InertiaLayer, InertiaResponse, Validated}; + +async fn body_text(response: axum::response::Response) -> String { + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + String::from_utf8(bytes.to_vec()).unwrap() +} + +/// The cause is in a `500` body in a debug build only. +fn detail(cause: &'static str) -> &'static str { + if cfg!(debug_assertions) { + cause + } else { + "Internal Server Error" + } +} + +fn json_post(uri: &str, body: serde_json::Value) -> Request { + Request::post(uri) + .header("x-inertia", "true") + .header("content-type", "application/json") + .header("referer", "/todos/new") + .body(Body::from(body.to_string())) + .unwrap() +} + +#[tokio::test] +async fn missing_layer_names_the_cause() { + let app = Router::new().route( + "/", + get(|i: Inertia| async move { i.render("Home", json!({})) }), + ); + let response = app.oneshot(visit("GET", "/")).await.unwrap(); + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert!(body_text(response).await.contains(detail("InertiaLayer"))); +} + +#[tokio::test] +async fn props_that_do_not_serialize_are_a_500_and_keep_the_flash_data() { + let session = MemorySession::default(); + let app = Router::new() + .route( + "/", + get(|i: Inertia| async move { + // JSON object keys must be strings. + i.render("Home", HashMap::from([((1, 2), 3)])) + }), + ) + .route( + "/go", + post(|i: Inertia| async move { i.redirect("/").with_flash("success", "Saved") }), + ) + .layer(InertiaLayer::new( + InertiaConfig::new().session(session.clone()), + )); + app.clone().oneshot(visit("POST", "/go")).await.unwrap(); + + let response = app.oneshot(visit("GET", "/")).await.unwrap(); + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + let body = body_text(response).await; + assert!(body.contains(detail("`Home` did not serialize")), "{body}"); + // The failed page did not show the message; the next request gets it. + assert_eq!(session.flash().bags["success"], json!("Saved")); +} + +#[tokio::test] +async fn status_applies_to_a_page_made_without_the_extractor() { + async fn not_found() -> impl IntoResponse { + InertiaResponse::render("Error", json!({ "status": 404 })).status(StatusCode::NOT_FOUND) + } + let app = Router::new() + .fallback(not_found) + .layer(InertiaLayer::new(InertiaConfig::new())); + let response = app.oneshot(visit("GET", "/nothing")).await.unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + let page = TestPage::from_response(response).await; + assert_eq!(page.component, "Error"); + assert_eq!(page.prop("status"), Some(&json!(404))); +} + +#[tokio::test] +async fn shared_props_read_request_extensions() { + #[derive(Clone)] + struct User(&'static str); + + let config = InertiaConfig::new().share(|req| { + let user = req.extension::().map(|u| u.0); + async move { json!({ "auth": { "user": user } }) } + }); + let app = Router::new() + .route( + "/", + get(|i: Inertia| async move { i.render("Home", json!({})) }), + ) + .layer(InertiaLayer::new(config)) + .layer(axum::Extension(User("ada"))); + let page = TestPage::from_response(app.oneshot(visit("GET", "/")).await.unwrap()).await; + assert_eq!(page.prop("auth.user"), Some(&json!("ada"))); +} + +#[derive(serde::Deserialize, validator::Validate)] +struct NewTodo { + #[validate(length(min = 3, message = "too short"))] + title: String, +} + +#[derive(serde::Deserialize, garde::Validate)] +struct GardeTodo { + #[garde(length(min = 3))] + title: String, +} + +fn todo_app(session: MemorySession) -> Router { + Router::new() + .route( + "/todos", + post( + |i: Inertia, Validated(todo): Validated| async move { + i.redirect(format!("/todos/{}", todo.title)) + }, + ), + ) + .route( + "/todos/new", + get(|i: Inertia| async move { i.render("todos/create", json!({})) }), + ) + .route( + "/garde", + post( + |i: Inertia, GardeValidated(todo): GardeValidated| async move { + i.redirect(format!("/todos/{}", todo.title)) + }, + ), + ) + .layer(InertiaLayer::new(InertiaConfig::new().session(session))) +} + +#[tokio::test] +async fn validated_runs_the_handler_for_valid_input() { + let app = todo_app(MemorySession::default()); + let response = app + .oneshot(json_post("/todos", json!({ "title": "milk" }))) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!(response.headers()["location"], "/todos/milk"); +} + +#[tokio::test] +async fn validated_redirects_back_with_the_errors() { + for uri in ["/todos", "/garde"] { + let session = MemorySession::default(); + let response = todo_app(session.clone()) + .oneshot(json_post(uri, json!({ "title": "a" }))) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SEE_OTHER, "{uri}"); + assert_eq!(response.headers()["location"], "/todos/new", "{uri}"); + assert!(session.flash().errors.contains_key("title"), "{uri}"); + + // The page after the redirect has the error. + let response = todo_app(session) + .oneshot(visit("GET", "/todos/new")) + .await + .unwrap(); + let page = TestPage::from_response(response).await; + assert!(page.prop("errors.title").is_some(), "{uri}"); + } +} + +#[tokio::test] +async fn validated_answers_precognition_and_does_not_run_the_handler() { + let precognition = |title: &str| { + let mut request = json_post("/todos", json!({ "title": title })); + request + .headers_mut() + .insert("precognition", "true".parse().unwrap()); + request + }; + let app = todo_app(MemorySession::default()); + + let invalid = app.clone().oneshot(precognition("a")).await.unwrap(); + assert_eq!(invalid.status(), StatusCode::UNPROCESSABLE_ENTITY); + assert!(body_text(invalid).await.contains("too short")); + + // Valid input: `204`, not the handler's redirect. + let valid = app.oneshot(precognition("milk")).await.unwrap(); + assert_eq!(valid.status(), StatusCode::NO_CONTENT); +} + +#[tokio::test] +async fn back_does_not_redirect_to_another_site() { + let app = Router::new() + .route("/act", post(|i: Inertia| async move { i.back() })) + .layer(InertiaLayer::new(InertiaConfig::new())); + let location = |referer: &'static str| { + let app = app.clone(); + async move { + let request = Request::post("/act") + .header("referer", referer) + .body(Body::empty()) + .unwrap(); + let response = app.oneshot(request).await.unwrap(); + response.headers()["location"].to_str().unwrap().to_owned() + } + }; + assert_eq!(location("https://app.test/form?a=1").await, "/form?a=1"); + // The host of the Referer is never used: the target is a path of this site. + assert_eq!(location("https://evil.test/form").await, "/form"); + assert_eq!(location("//evil.test/form").await, "/"); + assert_eq!(location("https://evil.test//other.test").await, "/"); +} + +#[tokio::test] +async fn page_url_never_starts_with_two_slashes() { + let app = Router::new() + .fallback(|i: Inertia| async move { i.render("Error", json!({})) }) + .layer(InertiaLayer::new(InertiaConfig::new())); + let response = app.oneshot(visit("GET", "//evil.test/x")).await.unwrap(); + let page = TestPage::from_response(response).await; + assert_eq!(page.url, "/evil.test/x"); +} diff --git a/tests/embed.rs b/tests/embed.rs index c436d52..faadaf5 100644 --- a/tests/embed.rs +++ b/tests/embed.rs @@ -1,7 +1,7 @@ #![cfg(feature = "embed")] use axum::{body::Body, Router}; -use http::Request; +use http::{Request, StatusCode}; use http_body_util::BodyExt; use std::borrow::Cow; use tower::ServiceExt; @@ -131,3 +131,23 @@ async fn invalid_mime_override_falls_back_instead_of_panicking() { "application/octet-stream" ); } + +#[tokio::test] +async fn path_that_leaves_the_folder_is_404_and_not_resolved() { + // A resolver that would answer any path, as one that reads files does. + let open = EmbeddedAssets::new(|_: &str| Some(Cow::Borrowed(b"secret" as &[u8]))); + let app = Router::new().nest_service("/build", open); + for uri in [ + "/build/../Cargo.toml", + "/build/a/../../b", + "/build/%2e%2e/b", + ] { + let request = Request::get(uri).body(Body::empty()).unwrap(); + let resp = app.clone().oneshot(request).await.unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND, "{uri}"); + } + let request = Request::get("/build/a/b.js").body(Body::empty()).unwrap(); + let resp = app.oneshot(request).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!(resp.headers()["x-content-type-options"], "nosniff"); +} diff --git a/tests/v3_protocol.rs b/tests/v3_protocol.rs index 0bd3736..6a01278 100644 --- a/tests/v3_protocol.rs +++ b/tests/v3_protocol.rs @@ -589,7 +589,11 @@ async fn back_does_not_use_a_stored_url_that_points_at_another_origin() { .oneshot(req_inertia("GET", "//evil.example/x", "v1")) .await .unwrap(); - assert_eq!(*session.previous_url.lock().await, None); + // The leading slashes are collapsed: the stored URL is a path of this site. + assert_eq!( + session.previous_url.lock().await.as_deref(), + Some("/evil.example/x") + ); // An empty 200 goes back to the stored URL when there is no Referer. *session.previous_url.lock().await = Some("/users".into());