From 197edebd5119aa604167a7bbefcead72d9cd1770 Mon Sep 17 00:00:00 2001 From: benwis Date: Tue, 2 Jan 2024 15:06:49 -0800 Subject: [PATCH] Made some progress, started work on pavex integration as well --- examples/pavex_demo/.gitignore | 3 + examples/pavex_demo/Cargo.toml | 92 +++ examples/pavex_demo/README.md | 71 ++ examples/pavex_demo/flake.lock | 119 +++ examples/pavex_demo/flake.nix | 129 ++++ examples/pavex_demo/leptos_app/Cargo.toml | 21 + .../leptos_app/src/error_template.rs | 73 ++ examples/pavex_demo/leptos_app/src/lib.rs | 45 ++ examples/pavex_demo/leptos_front/Cargo.toml | 8 + examples/pavex_demo/leptos_front/src/lib.rs | 13 + examples/pavex_demo/style/main.scss | 4 + .../todo_app_sqlite_pavex/Cargo.toml | 22 + .../todo_app_sqlite_pavex/src/bin/bp.rs | 17 + .../todo_app_sqlite_pavex/src/blueprint.rs | 98 +++ .../src/configuration.rs | 32 + .../todo_app_sqlite_pavex/src/file_handler.rs | 45 ++ .../todo_app_sqlite_pavex/src/leptos.rs | 19 + .../todo_app_sqlite_pavex/src/lib.rs | 7 + .../todo_app_sqlite_pavex/src/routes/greet.rs | 21 + .../todo_app_sqlite_pavex/src/routes/mod.rs | 3 + .../src/routes/status.rs | 7 + .../todo_app_sqlite_pavex/src/telemetry.rs | 84 ++ .../todo_app_sqlite_pavex/src/user_agent.rs | 27 + .../todo_app_sqlite_pavex_server/Cargo.toml | 29 + .../configuration/base.yml | 3 + .../configuration/dev.yml | 6 + .../configuration/prod.yml | 3 + .../configuration/test.yml | 8 + .../src/bin/api.rs | 49 ++ .../src/configuration.rs | 140 ++++ .../todo_app_sqlite_pavex_server/src/lib.rs | 2 + .../src/telemetry.rs | 40 + .../tests/integration/greet.rs | 37 + .../tests/integration/helpers.rs | 52 ++ .../tests/integration/main.rs | 4 + .../tests/integration/ping.rs | 11 + .../Cargo.toml | 21 + .../blueprint.ron | 233 ++++++ .../src/lib.rs | 254 ++++++ flake.lock | 12 +- leptos_macro/src/server.rs | 8 +- leptos_server/src/action.rs | 3 +- leptos_server/src/lib.rs | 720 +++++++++--------- leptos_server/src/multi_action.rs | 2 +- server_fn/Cargo.toml | 4 +- server_fn/src/error.rs | 103 ++- 46 files changed, 2315 insertions(+), 389 deletions(-) create mode 100644 examples/pavex_demo/.gitignore create mode 100644 examples/pavex_demo/Cargo.toml create mode 100644 examples/pavex_demo/README.md create mode 100644 examples/pavex_demo/flake.lock create mode 100644 examples/pavex_demo/flake.nix create mode 100644 examples/pavex_demo/leptos_app/Cargo.toml create mode 100644 examples/pavex_demo/leptos_app/src/error_template.rs create mode 100644 examples/pavex_demo/leptos_app/src/lib.rs create mode 100644 examples/pavex_demo/leptos_front/Cargo.toml create mode 100644 examples/pavex_demo/leptos_front/src/lib.rs create mode 100644 examples/pavex_demo/style/main.scss create mode 100644 examples/pavex_demo/todo_app_sqlite_pavex/Cargo.toml create mode 100644 examples/pavex_demo/todo_app_sqlite_pavex/src/bin/bp.rs create mode 100644 examples/pavex_demo/todo_app_sqlite_pavex/src/blueprint.rs create mode 100644 examples/pavex_demo/todo_app_sqlite_pavex/src/configuration.rs create mode 100644 examples/pavex_demo/todo_app_sqlite_pavex/src/file_handler.rs create mode 100644 examples/pavex_demo/todo_app_sqlite_pavex/src/leptos.rs create mode 100644 examples/pavex_demo/todo_app_sqlite_pavex/src/lib.rs create mode 100644 examples/pavex_demo/todo_app_sqlite_pavex/src/routes/greet.rs create mode 100644 examples/pavex_demo/todo_app_sqlite_pavex/src/routes/mod.rs create mode 100644 examples/pavex_demo/todo_app_sqlite_pavex/src/routes/status.rs create mode 100644 examples/pavex_demo/todo_app_sqlite_pavex/src/telemetry.rs create mode 100644 examples/pavex_demo/todo_app_sqlite_pavex/src/user_agent.rs create mode 100644 examples/pavex_demo/todo_app_sqlite_pavex_server/Cargo.toml create mode 100644 examples/pavex_demo/todo_app_sqlite_pavex_server/configuration/base.yml create mode 100644 examples/pavex_demo/todo_app_sqlite_pavex_server/configuration/dev.yml create mode 100644 examples/pavex_demo/todo_app_sqlite_pavex_server/configuration/prod.yml create mode 100644 examples/pavex_demo/todo_app_sqlite_pavex_server/configuration/test.yml create mode 100644 examples/pavex_demo/todo_app_sqlite_pavex_server/src/bin/api.rs create mode 100644 examples/pavex_demo/todo_app_sqlite_pavex_server/src/configuration.rs create mode 100644 examples/pavex_demo/todo_app_sqlite_pavex_server/src/lib.rs create mode 100644 examples/pavex_demo/todo_app_sqlite_pavex_server/src/telemetry.rs create mode 100644 examples/pavex_demo/todo_app_sqlite_pavex_server/tests/integration/greet.rs create mode 100644 examples/pavex_demo/todo_app_sqlite_pavex_server/tests/integration/helpers.rs create mode 100644 examples/pavex_demo/todo_app_sqlite_pavex_server/tests/integration/main.rs create mode 100644 examples/pavex_demo/todo_app_sqlite_pavex_server/tests/integration/ping.rs create mode 100644 examples/pavex_demo/todo_app_sqlite_pavex_server_sdk/Cargo.toml create mode 100644 examples/pavex_demo/todo_app_sqlite_pavex_server_sdk/blueprint.ron create mode 100644 examples/pavex_demo/todo_app_sqlite_pavex_server_sdk/src/lib.rs diff --git a/examples/pavex_demo/.gitignore b/examples/pavex_demo/.gitignore new file mode 100644 index 000000000..b6be732f9 --- /dev/null +++ b/examples/pavex_demo/.gitignore @@ -0,0 +1,3 @@ +/target +.env +.direnv diff --git a/examples/pavex_demo/Cargo.toml b/examples/pavex_demo/Cargo.toml new file mode 100644 index 000000000..f46abb11a --- /dev/null +++ b/examples/pavex_demo/Cargo.toml @@ -0,0 +1,92 @@ +[workspace] +members = ["todo_app_sqlite_pavex", "todo_app_sqlite_pavex_server_sdk", "todo_app_sqlite_pavex_server", "leptos_app"] +# By setting `todo_app_sqlite_pavex_server` as the default member, `cargo run` will default to running the server binary +# when executed from the root of the workspace. +# Otherwise, you would have to use `cargo run --bin api` to run the server binary. +default-members = ["todo_app_sqlite_pavex_server"] +resolver = "2" + +# need to be applied only to wasm build +[profile.wasm_release] +codegen-units = 1 +lto = true +opt-level = 'z' + +[workspace.dependencies] +leptos = { version = "0.5", features = ["nightly"] } +leptos_meta = { version = "0.5", features = ["nightly"] } +leptos_router = { version = "0.5", features = ["nightly"] } +leptos_pavex = { version = "0.5" } +cfg_if = "1" +thiserror = "1" + +# See https://github.com/akesson/cargo-leptos for documentation of all the parameters. + +# A leptos project defines which workspace members +# that are used together frontend (lib) & server (bin) +[[workspace.metadata.leptos]] +# this name is used for the wasm, js and css file names +name = "start-pavex-workspace" + +# the package in the workspace that contains the server binary (binary crate) +bin-package = "server" + +# the package in the workspace that contains the frontend wasm binary (library crate) +lib-package = "leptos_frontend" + +# The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup. +site-root = "target/site" + +# The site-root relative folder where all compiled output (JS, WASM and CSS) is written +# Defaults to pkg +site-pkg-dir = "pkg" + +# [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to //app.css +style-file = "style/main.scss" + +# Assets source dir. All files found here will be copied and synchronized to site-root. +# The assets-dir cannot have a sub directory with the same name/path as site-pkg-dir. +# +# Optional. Env: LEPTOS_ASSETS_DIR. +assets-dir = "public" + +# The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup. +site-addr = "127.0.0.1:3000" + +# The port to use for automatic reload monitoring +reload-port = 3001 + +# [Optional] Command to use when running end2end tests. It will run in the end2end dir. +# [Windows] for non-WSL use "npx.cmd playwright test" +# This binary name can be checked in Powershell with Get-Command npx +end2end-cmd = "npx playwright test" +end2end-dir = "end2end" + +# The browserlist query used for optimizing the CSS. +browserquery = "defaults" + +# Set by cargo-leptos watch when building with that tool. Controls whether autoreload JS will be included in the head +watch = false + +# The environment Leptos will run in, usually either "DEV" or "PROD" +env = "DEV" + +# The features to use when compiling the bin target +# +# Optional. Can be over-ridden with the command line parameter --bin-features +bin-features = [] + +# If the --no-default-features flag should be used when compiling the bin target +# +# Optional. Defaults to false. +bin-default-features = false + +# The features to use when compiling the lib target +# +# Optional. Can be over-ridden with the command line parameter --lib-features +lib-features = [] + +# If the --no-default-features flag should be used when compiling the lib target +# +# Optional. Defaults to false. +lib-default-features = false diff --git a/examples/pavex_demo/README.md b/examples/pavex_demo/README.md new file mode 100644 index 000000000..72141d8ff --- /dev/null +++ b/examples/pavex_demo/README.md @@ -0,0 +1,71 @@ +# todo_app_sqlite_pavex + +# Getting started + +## Prerequisites + +- Rust (see [here](https://www.rust-lang.org/tools/install) for instructions) +- `cargo-px`: + ```bash + cargo install --locked cargo-px --version="~0.1" + ``` +- [Pavex](https://pavex.dev) + +## Useful commands + +`todo_app_sqlite_pavex` is built using the [Pavex](https://pavex.dev) web framework, which relies on code generation. +You need to use the `cargo px` command instead of `cargo`: it ensures that the +`todo_app_sqlite_pavex_server_sdk` crate is correctly regenerated when the +application blueprint changes. + +`cargo px` is a wrapper around `cargo` that will automatically regenerate the +server SDK when needed. Check out its [documentation](https://github.com/LukeMathWalker/cargo-px) +for more details. + +### Build + +```bash +cargo px build +``` + +### Run + +```bash +cargo px run +``` + +### Test + +```bash +cargo px test +``` + +## Configuration + +All configurable parameters are listed in `todo_app_sqlite_pavex/src/configuration.rs`. + +Configuration values are loaded from two sources: + +- Configuration files +- Environment variables + +Environment variables take precedence over configuration files. + +All configuration files are in the `todo_app_sqlite_pavex_server/configuration` folder. +The application can be run in three different profiles: `dev`, `test` and `prod`. +The settings that you want to share across all profiles should be placed in `todo_app_sqlite_pavex_server/configuration/base.yml`. +Profile-specific configuration files can be then used +to override or supply additional values on top of the default settings (e.g. `todo_app_sqlite_pavex_server/configuration/dev.yml`). + +You can specify the app profile that you want to use by setting the `APP_PROFILE` environment variable; e.g.: + +```bash +APP_PROFILE=prod cargo px run +``` + +for running the application with the `prod` profile. + +By default, the `dev` profile is used since `APP_PROFILE` is set to `dev` in the `.env` file at the root of the project. +The `.env` file should not be committed to version control: it is meant to be used for local development only, +so that each developer can specify their own environment variables for secret values (e.g. database credentials) +that shouldn't be stored in configuration files (given their sensitive nature). diff --git a/examples/pavex_demo/flake.lock b/examples/pavex_demo/flake.lock new file mode 100644 index 000000000..c219f46f5 --- /dev/null +++ b/examples/pavex_demo/flake.lock @@ -0,0 +1,119 @@ +{ + "nodes": { + "cargo-pavex-git": { + "flake": false, + "locked": { + "lastModified": 1703610192, + "narHash": "sha256-+oM6VGRRt/DQdhEFWJFIpKfY29w72V0vRpud8NsOI7c=", + "owner": "LukeMathWalker", + "repo": "pavex", + "rev": "e302f99e3641a55fe5624ba6c8154ce64e732a89", + "type": "github" + }, + "original": { + "owner": "LukeMathWalker", + "repo": "pavex", + "type": "github" + } + }, + "cargo-px-git": { + "flake": false, + "locked": { + "lastModified": 1702137928, + "narHash": "sha256-FbwHEOQnIYKhxp4Ne9XBIUJXu1o+ak6y9MhzRenIW40=", + "owner": "LukeMathWalker", + "repo": "cargo-px", + "rev": "d1bb9075c4993130f31f31c95642567a2255bd8e", + "type": "github" + }, + "original": { + "owner": "LukeMathWalker", + "repo": "cargo-px", + "type": "github" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1701680307, + "narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "4022d587cbbfd70fe950c1e2083a02621806a725", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1703499205, + "narHash": "sha256-lF9rK5mSUfIZJgZxC3ge40tp1gmyyOXZ+lRY3P8bfbg=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e1fa12d4f6c6fe19ccb59cac54b5b3f25e160870", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "cargo-pavex-git": "cargo-pavex-git", + "cargo-px-git": "cargo-px-git", + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay" + } + }, + "rust-overlay": { + "inputs": { + "flake-utils": [ + "flake-utils" + ], + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1703643208, + "narHash": "sha256-UL4KO8JxnD5rOycwHqBAf84lExF1/VnYMDC7b/wpPDU=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "ce117f3e0de8262be8cd324ee6357775228687cf", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/examples/pavex_demo/flake.nix b/examples/pavex_demo/flake.nix new file mode 100644 index 000000000..4452c5f02 --- /dev/null +++ b/examples/pavex_demo/flake.nix @@ -0,0 +1,129 @@ +{ + description = "Build Pavex tools"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + + flake-utils.url = "github:numtide/flake-utils"; + + cargo-px-git = { + url = "github:/LukeMathWalker/cargo-px"; + flake = false; + }; + cargo-pavex-git = { + url = "github:LukeMathWalker/pavex"; + flake = false; + }; + + rust-overlay = { + url = "github:oxalica/rust-overlay"; + inputs = { + nixpkgs.follows = "nixpkgs"; + flake-utils.follows = "flake-utils"; + }; + }; + }; + + outputs = { self, nixpkgs, flake-utils, rust-overlay, ... } @inputs: + flake-utils.lib.eachDefaultSystem + (system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ (import rust-overlay) ]; + }; + inherit (pkgs) lib; + rustTarget = pkgs.rust-bin.selectLatestNightlyWith( toolchain: toolchain.default.override { + extensions = [ "rust-src" "rust-analyzer" "rustc-codegen-cranelift-preview" "rust-docs-json"]; + targets = [ "wasm32-unknown-unknown" ]; + }); + + + cargo-pavex_cli-git = pkgs.rustPlatform.buildRustPackage rec { + pname = "cargo-pavex-cli"; + version = "0.2.22"; + #buildFeatures = ["no_downloads"]; # cargo-leptos will try to download Ruby and other things without this feature + + src = inputs.cargo-pavex-git; + sourceRoot = "source/libs"; + cargoLock = { + lockFile = inputs.cargo-pavex-git + "/libs/Cargo.lock"; + outputHashes = { + "matchit-0.7.3" = "sha256-1bhbWvLlDb6/UJ4j2FqoG7j3DD1dTOLl6RaiY9kasmQ="; + #"pavex-0.1.0" = "sha256-NC7T1pcXJiWPtAWeiMUNzf2MUsYaRYxjLIL9fCqhExo="; + }; + }; + #buildAndTestSubdir = "libs"; + cargoSha256 = ""; + nativeBuildInputs = [pkgs.pkg-config pkgs.openssl pkgs.git]; + + buildInputs = with pkgs; + [openssl pkg-config git] + ++ lib.optionals stdenv.isDarwin [ + Security + ]; + + doCheck = false; # integration tests depend on changing cargo config + + meta = with lib; { + description = "An easy-to-use Rust framework for building robust and performant APIs"; + homepage = "https://github.com/LukeMatthewWalker/pavex"; + changelog = "https://github.com/LukeMatthewWalker/pavex/blob/v${version}/CHANGELOG.md"; + license = with licenses; [mit]; + maintainers = with maintainers; [benwis]; + }; + }; + cargo-px-git = pkgs.rustPlatform.buildRustPackage rec { + pname = "cargo-px"; + version = "0.2.22"; + #buildFeatures = ["no_downloads"]; # cargo-leptos will try to download Ruby and other things without this feature + + src = inputs.cargo-px-git; + + cargoSha256 ="sha256-+pyeqh0IoZ1JMgbhWxhEJw1MPgG7XeocVrqJoSNjgDA="; + + nativeBuildInputs = [pkgs.pkg-config pkgs.openssl pkgs.git]; + + buildInputs = with pkgs; + [openssl pkg-config git] + ++ lib.optionals stdenv.isDarwin [ + Security + ]; + + doCheck = false; # integration tests depend on changing cargo config + + meta = with lib; { + description = "A cargo subcommand that extends cargo's capabilities when it comes to code generation."; + homepage = "https://github.com/LukeMatthewWalker/cargo-px"; + changelog = "https://github.com/LukeMatthewWalker/cargo-px/blob/v${version}/CHANGELOG.md"; + license = with licenses; [mit]; + maintainers = with maintainers; [benwis]; + }; + }; + in + { + + devShells.default = pkgs.mkShell { + + # Extra inputs can be added here + nativeBuildInputs = with pkgs; [ + #rustTarget + rustup + openssl + pkg-config + clang + tailwindcss + mold-wrapped + cargo-px-git + cargo-pavex_cli-git + ]; + #RUST_SRC_PATH = "${rustTarget}/lib/rustlib/src/rust/library"; + MOLD_PATH = "${pkgs.mold-wrapped}/bin/mold"; + + shellHook = '' + sed -i -e '/rustflags = \["-C", "link-arg=-fuse-ld=/ s|ld=.*|ld=${pkgs.mold-wrapped}/bin/mold"]|' .cargo/config.toml + ''; + LIBCLANG_PATH = "${pkgs.llvmPackages.libclang.lib}/lib"; + }; + }); +} diff --git a/examples/pavex_demo/leptos_app/Cargo.toml b/examples/pavex_demo/leptos_app/Cargo.toml new file mode 100644 index 000000000..2a455b47d --- /dev/null +++ b/examples/pavex_demo/leptos_app/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "leptos_app" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +leptos.workspace = true +leptos_meta.workspace = true +leptos_router.workspace = true +leptos_pavex = { workspace = true, optional = true } + +#http.workspace = true +cfg_if.workspace = true +thiserror.workspace = true + +[features] +default = [] +hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"] +ssr = ["leptos/ssr", "leptos_meta/ssr", "leptos_router/ssr", "dep:leptos_pavex"] diff --git a/examples/pavex_demo/leptos_app/src/error_template.rs b/examples/pavex_demo/leptos_app/src/error_template.rs new file mode 100644 index 000000000..f22e4687b --- /dev/null +++ b/examples/pavex_demo/leptos_app/src/error_template.rs @@ -0,0 +1,73 @@ +use cfg_if::cfg_if; +use http::status::StatusCode; +use leptos::*; +#[cfg(feature = "ssr")] +use leptos_axum::ResponseOptions; +use thiserror::Error; + +#[derive(Clone, Debug, Error)] +pub enum AppError { + #[error("Not Found")] + NotFound, +} + +impl AppError { + pub fn status_code(&self) -> StatusCode { + match self { + AppError::NotFound => StatusCode::NOT_FOUND, + } + } +} + +// A basic function to display errors served by the error boundaries. +// Feel free to do more complicated things here than just displaying the error. +#[component] +pub fn ErrorTemplate( + #[prop(optional)] outside_errors: Option, + #[prop(optional)] errors: Option>, +) -> impl IntoView { + let errors = match outside_errors { + Some(e) => create_rw_signal(e), + None => match errors { + Some(e) => e, + None => panic!("No Errors found and we expected errors!"), + }, + }; + // Get Errors from Signal + let errors = errors.get_untracked(); + + // Downcast lets us take a type that implements `std::error::Error` + let errors: Vec = errors + .into_iter() + .filter_map(|(_k, v)| v.downcast_ref::().cloned()) + .collect(); + println!("Errors: {errors:#?}"); + + // Only the response code for the first error is actually sent from the server + // this may be customized by the specific application + cfg_if! { if #[cfg(feature="ssr")] { + let response = use_context::(); + if let Some(response) = response { + response.set_status(errors[0].status_code()); + } + }} + + view! { +

{if errors.len() > 1 { "Errors" } else { "Error" }}

+ {error_code.to_string()} +

"Error: " {error_string}

+ } + } + /> + } +} diff --git a/examples/pavex_demo/leptos_app/src/lib.rs b/examples/pavex_demo/leptos_app/src/lib.rs new file mode 100644 index 000000000..f68dcf688 --- /dev/null +++ b/examples/pavex_demo/leptos_app/src/lib.rs @@ -0,0 +1,45 @@ +use crate::error_template::{AppError, ErrorTemplate}; +use leptos::*; +use leptos_meta::*; +use leptos_router::*; + +pub mod error_template; + +#[component] +pub fn App() -> impl IntoView { + // Provides context that manages stylesheets, titles, meta tags, etc. + provide_meta_context(); + + view! { + + + // sets the document title + + + // content for this welcome page + <Router fallback=|| { + let mut outside_errors = Errors::default(); + outside_errors.insert_with_default_key(AppError::NotFound); + view! { <ErrorTemplate outside_errors/> }.into_view() + }> + <main> + <Routes> + <Route path="" view=HomePage/> + </Routes> + </main> + </Router> + } +} + +/// Renders the home page of your application. +#[component] +fn HomePage() -> impl IntoView { + // Creates a reactive value to update the button + let (count, set_count) = create_signal(0); + let on_click = move |_| set_count.update(|count| *count += 1); + + view! { + <h1>"Welcome to Leptos on Pavex!"</h1> + <button on:click=on_click>"Click Me: " {count}</button> + } +} diff --git a/examples/pavex_demo/leptos_front/Cargo.toml b/examples/pavex_demo/leptos_front/Cargo.toml new file mode 100644 index 000000000..da9f88d9f --- /dev/null +++ b/examples/pavex_demo/leptos_front/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "leptos_front" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] diff --git a/examples/pavex_demo/leptos_front/src/lib.rs b/examples/pavex_demo/leptos_front/src/lib.rs new file mode 100644 index 000000000..8e25fd20e --- /dev/null +++ b/examples/pavex_demo/leptos_front/src/lib.rs @@ -0,0 +1,13 @@ +use leptos::*; +use leptos_app::*; +use wasm_bindgen::prelude::wasm_bindgen; + +#[wasm_bindgen] +pub fn hydrate() { + // initializes logging using the `log` crate + _ = console_log::init_with_level(log::Level::Debug); + console_error_panic_hook::set_once(); + + leptos::mount_to_body(App); +} + diff --git a/examples/pavex_demo/style/main.scss b/examples/pavex_demo/style/main.scss new file mode 100644 index 000000000..2ef54d334 --- /dev/null +++ b/examples/pavex_demo/style/main.scss @@ -0,0 +1,4 @@ +body { + font-family: sans-serif; + text-align: center; +} diff --git a/examples/pavex_demo/todo_app_sqlite_pavex/Cargo.toml b/examples/pavex_demo/todo_app_sqlite_pavex/Cargo.toml new file mode 100644 index 000000000..fc53c5666 --- /dev/null +++ b/examples/pavex_demo/todo_app_sqlite_pavex/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "todo_app_sqlite_pavex" +version = "0.1.0" +edition = "2021" + +[[bin]] +path = "src/bin/bp.rs" +name = "bp" + +[dependencies] +cargo_px_env = "0.1" +pavex = { git = "https://github.com/LukeMathWalker/pavex", branch = "main" } +pavex_cli_client = { git = "https://github.com/LukeMathWalker/pavex", branch = "main" } +tracing = "0.1" + +# Configuration +serde = { version = "1", features = ["derive"] } +serde-aux = "4" + +# Leptos +leptos_pavex.workspace = true + diff --git a/examples/pavex_demo/todo_app_sqlite_pavex/src/bin/bp.rs b/examples/pavex_demo/todo_app_sqlite_pavex/src/bin/bp.rs new file mode 100644 index 000000000..7a75bb9a6 --- /dev/null +++ b/examples/pavex_demo/todo_app_sqlite_pavex/src/bin/bp.rs @@ -0,0 +1,17 @@ +use cargo_px_env::generated_pkg_manifest_path; +use todo_app_sqlite_pavex::blueprint; +use pavex_cli_client::Client; +use std::error::Error; + +/// Generate the `todo_app_sqlite_pavex_server_sdk` crate using Pavex's CLI. +/// +/// Pavex will automatically wire all our routes, constructors and error handlers +/// into the a "server SDK" that can be used by the final API server binary to launch +/// the application. +fn main() -> Result<(), Box<dyn Error>> { + let generated_dir = generated_pkg_manifest_path()?.parent().unwrap().into(); + Client::new() + .generate(blueprint(), generated_dir) + .execute()?; + Ok(()) +} diff --git a/examples/pavex_demo/todo_app_sqlite_pavex/src/blueprint.rs b/examples/pavex_demo/todo_app_sqlite_pavex/src/blueprint.rs new file mode 100644 index 000000000..7ea7275a9 --- /dev/null +++ b/examples/pavex_demo/todo_app_sqlite_pavex/src/blueprint.rs @@ -0,0 +1,98 @@ +use leptos_pavex::{LeptosOptions, RouteListing}; +use pavex::{ + blueprint::{ + constructor::{CloningStrategy, Lifecycle}, + router::{ANY, GET}, + Blueprint, + }, + f, +}; +/// The main blueprint, containing all the routes, constructors and error handlers +/// required by our API. +pub fn blueprint() -> Blueprint { + let mut bp = Blueprint::new(); + register_common_constructors(&mut bp); + + bp.constructor( + f!(crate::user_agent::UserAgent::extract), + Lifecycle::RequestScoped, + ) + .error_handler(f!(crate::user_agent::invalid_user_agent)); + + add_telemetry_middleware(&mut bp); + + bp.route(GET, "/test/ping", f!(crate::routes::status::ping)); + bp.route(GET, "/test/greet/:name", f!(crate::routes::greet::greet)); + // Handle all /api requests as those are Leptos server fns + bp.route(ANY, "/api/*fn_name", f!(leptos_pavex::handle_server_fns)); + bp.route(ANY, "/"); + bp.fallback(f!(file_handler)); + bp +} + +/// Common constructors used by all routes. +fn register_common_constructors(bp: &mut Blueprint) { + // Configuration Options + bp.constructor( + f!(crate::leptos::get_cargo_leptos_conf(), Lifecycle::Singleton), + Lifecycle::Singleton, + ); + // List of Routes + bp.constructor( + f!(crate::leptos::get_app_route_listing(), Lifecycle::Singleton), + Lifecycle::Singleton, + ); + + bp.constructor( + f!(leptos_pavex::PavexRequest::extract), + LifeCycle::RequestScoped, + ); + // Query parameters + bp.constructor( + f!(pavex::request::query::QueryParams::extract), + Lifecycle::RequestScoped, + ) + .error_handler(f!( + pavex::request::query::errors::ExtractQueryParamsError::into_response + )); + + // Route parameters + bp.constructor( + f!(pavex::request::route::RouteParams::extract), + Lifecycle::RequestScoped, + ) + .error_handler(f!( + pavex::request::route::errors::ExtractRouteParamsError::into_response + )); + + // Json body + bp.constructor( + f!(pavex::request::body::JsonBody::extract), + Lifecycle::RequestScoped, + ) + .error_handler(f!( + pavex::request::body::errors::ExtractJsonBodyError::into_response + )); + bp.constructor( + f!(pavex::request::body::BufferedBody::extract), + Lifecycle::RequestScoped, + ) + .error_handler(f!( + pavex::request::body::errors::ExtractBufferedBodyError::into_response + )); + bp.constructor( + f!(<pavex::request::body::BodySizeLimit as std::default::Default>::default), + Lifecycle::RequestScoped, + ); +} + +/// Add the telemetry middleware, as well as the constructors of its dependencies. +fn add_telemetry_middleware(bp: &mut Blueprint) { + bp.constructor( + f!(crate::telemetry::RootSpan::new), + Lifecycle::RequestScoped, + ) + .cloning(CloningStrategy::CloneIfNecessary); + + bp.wrap(f!(crate::telemetry::logger)); +} diff --git a/examples/pavex_demo/todo_app_sqlite_pavex/src/configuration.rs b/examples/pavex_demo/todo_app_sqlite_pavex/src/configuration.rs new file mode 100644 index 000000000..ad8bc7b38 --- /dev/null +++ b/examples/pavex_demo/todo_app_sqlite_pavex/src/configuration.rs @@ -0,0 +1,32 @@ +use pavex::server::IncomingStream; +use serde_aux::field_attributes::deserialize_number_from_string; +use std::net::SocketAddr; + +#[derive(serde::Deserialize)] +/// The top-level configuration, holding all the values required +/// to configure the entire application. +pub struct Config { + pub server: ServerConfig, +} + +#[derive(serde::Deserialize, Clone)] +/// Configuration for the HTTP server used to expose our API +/// to users. +pub struct ServerConfig { + /// The port that the server must listen on. + #[serde(deserialize_with = "deserialize_number_from_string")] + pub port: u16, + /// The network interface that the server must be bound to. + /// + /// E.g. `0.0.0.0` for listening to incoming requests from + /// all sources. + pub ip: std::net::IpAddr, +} + +impl ServerConfig { + /// Bind a TCP listener according to the specified parameters. + pub async fn listener(&self) -> Result<IncomingStream, std::io::Error> { + let addr = SocketAddr::new(self.ip, self.port); + IncomingStream::bind(addr).await + } +} \ No newline at end of file diff --git a/examples/pavex_demo/todo_app_sqlite_pavex/src/file_handler.rs b/examples/pavex_demo/todo_app_sqlite_pavex/src/file_handler.rs new file mode 100644 index 000000000..98b7d8f8a --- /dev/null +++ b/examples/pavex_demo/todo_app_sqlite_pavex/src/file_handler.rs @@ -0,0 +1,45 @@ +use app::error_template::AppError; +use app::error_template::ErrorTemplate; +use app::App; +use axum::response::Response as AxumResponse; +use axum::{ + body::{boxed, Body, BoxBody}, + extract::State, + http::{Request, Response, StatusCode, Uri}, + response::IntoResponse, +}; +use leptos::*; +use tower::ServiceExt; +use tower_http::services::ServeDir; + +pub async fn file_and_error_handler( + uri: Uri, + State(options): State<LeptosOptions>, + req: Request<Body>, +) -> AxumResponse { + let root = options.site_root.clone(); + let res = get_static_file(uri.clone(), &root).await.unwrap(); + + if res.status() == StatusCode::OK { + res.into_response() + } else { + let handler = leptos_axum::render_app_to_stream(options.to_owned(), move || view! { <App/> }); + handler(req).await.into_response() + } +} + +async fn get_static_file(uri: Uri, root: &str) -> Result<Response<BoxBody>, (StatusCode, String)> { + let req = Request::builder() + .uri(uri.clone()) + .body(Body::empty()) + .unwrap(); + // `ServeDir` implements `tower::Service` so we can call it with `tower::ServiceExt::oneshot` + // This path is relative to the cargo root + match ServeDir::new(root).oneshot(req).await { + Ok(res) => Ok(res.map(boxed)), + Err(err) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Something went wrong: {err}"), + )), + } +} diff --git a/examples/pavex_demo/todo_app_sqlite_pavex/src/leptos.rs b/examples/pavex_demo/todo_app_sqlite_pavex/src/leptos.rs new file mode 100644 index 000000000..1c774ae86 --- /dev/null +++ b/examples/pavex_demo/todo_app_sqlite_pavex/src/leptos.rs @@ -0,0 +1,19 @@ +use leptos::{get_configuration, leptos_config::ConfFile}; +use leptos_pavex::generate_route_list; +use leptos_router::RouteListing; +use pavex::{ + http::header::{ToStrError, USER_AGENT}, + request::RequestHead, + response::Response, +}; + +/// Easier to do this to avoid having to register things with Blueprints +/// Provide LeptosOptions via env vars provided by cargo-leptos or the user +pub fn get_cargo_leptos_conf() -> ConfFile { + get_configuration(None) +} + +/// Generate all possible non server fn routes for our app +pub fn get_app_route_listing() -> Vec<RouteListing> { + generate_route_list(TodoApp) +} diff --git a/examples/pavex_demo/todo_app_sqlite_pavex/src/lib.rs b/examples/pavex_demo/todo_app_sqlite_pavex/src/lib.rs new file mode 100644 index 000000000..bea4f0fb9 --- /dev/null +++ b/examples/pavex_demo/todo_app_sqlite_pavex/src/lib.rs @@ -0,0 +1,7 @@ +mod blueprint; +pub mod configuration; +pub mod leptos; +pub mod routes; +pub mod telemetry; +pub mod user_agent; +pub use blueprint::blueprint; diff --git a/examples/pavex_demo/todo_app_sqlite_pavex/src/routes/greet.rs b/examples/pavex_demo/todo_app_sqlite_pavex/src/routes/greet.rs new file mode 100644 index 000000000..1ac11bc56 --- /dev/null +++ b/examples/pavex_demo/todo_app_sqlite_pavex/src/routes/greet.rs @@ -0,0 +1,21 @@ +use crate::user_agent::UserAgent; +use pavex::{request::route::RouteParams, response::Response}; + +#[RouteParams] +pub struct GreetParams { + pub name: String, +} +pub fn greet( + params: RouteParams<GreetParams>, + user_agent: UserAgent, +) -> Response { + if let UserAgent::Unknown = user_agent { + return Response::unauthorized() + .set_typed_body("You must provide a `User-Agent` header") + .box_body(); + } + let GreetParams { name } = params.0; + Response::ok() + .set_typed_body(format!("Hello, {name}!")) + .box_body() +} diff --git a/examples/pavex_demo/todo_app_sqlite_pavex/src/routes/mod.rs b/examples/pavex_demo/todo_app_sqlite_pavex/src/routes/mod.rs new file mode 100644 index 000000000..47f146cc9 --- /dev/null +++ b/examples/pavex_demo/todo_app_sqlite_pavex/src/routes/mod.rs @@ -0,0 +1,3 @@ +pub mod greet; +pub mod status; + diff --git a/examples/pavex_demo/todo_app_sqlite_pavex/src/routes/status.rs b/examples/pavex_demo/todo_app_sqlite_pavex/src/routes/status.rs new file mode 100644 index 000000000..acec3eefe --- /dev/null +++ b/examples/pavex_demo/todo_app_sqlite_pavex/src/routes/status.rs @@ -0,0 +1,7 @@ +use pavex::http::StatusCode; + +/// Respond with a `200 OK` status code to indicate that the server is alive +/// and ready to accept new requests. +pub fn ping() -> StatusCode { + StatusCode::OK +} diff --git a/examples/pavex_demo/todo_app_sqlite_pavex/src/telemetry.rs b/examples/pavex_demo/todo_app_sqlite_pavex/src/telemetry.rs new file mode 100644 index 000000000..6867b9876 --- /dev/null +++ b/examples/pavex_demo/todo_app_sqlite_pavex/src/telemetry.rs @@ -0,0 +1,84 @@ +use pavex::request::route::MatchedRouteTemplate; +use pavex::http::Version; +use pavex::middleware::Next; +use pavex::request::RequestHead; +use pavex::response::Response; +use std::borrow::Cow; +use std::future::IntoFuture; +use tracing::Instrument; + +/// A logging middleware that wraps the request pipeline in the root span. +/// It takes care to record key information about the request and the response. +pub async fn logger<T>(next: Next<T>, root_span: RootSpan) -> Response + where + T: IntoFuture<Output = Response>, +{ + let response = next + .into_future() + .instrument(root_span.clone().into_inner()) + .await; + root_span.record_response_data(&response); + response +} + +/// A root span is the top-level *logical* span for an incoming request. +/// +/// It is not necessarily the top-level *physical* span, as it may be a child of +/// another span (e.g. a span representing the underlying HTTP connection). +/// +/// We use the root span to attach as much information as possible about the +/// incoming request, and to record the final outcome of the request (success or +/// failure). +#[derive(Debug, Clone)] +pub struct RootSpan(tracing::Span); + +impl RootSpan { + /// Create a new root span for the given request. + /// + /// We follow OpenTelemetry's HTTP semantic conventions as closely as + /// possible for field naming. + pub fn new(request_head: &RequestHead, matched_route: MatchedRouteTemplate) -> Self { + let user_agent = request_head + .headers + .get("User-Agent") + .map(|h| h.to_str().unwrap_or_default()) + .unwrap_or_default(); + + let span = tracing::info_span!( + "HTTP request", + http.method = %request_head.method, + http.flavor = %http_flavor(request_head.version), + user_agent.original = %user_agent, + http.response.status_code = tracing::field::Empty, + http.route = %matched_route, + http.target = %request_head.uri.path_and_query().map(|p| p.as_str()).unwrap_or(""), + ); + Self(span) + } + + pub fn record_response_data(&self, response: &Response) { + self.0 + .record("http.response.status_code", &response.status().as_u16()); + } + + /// Get a reference to the underlying [`tracing::Span`]. + pub fn inner(&self) -> &tracing::Span { + &self.0 + } + + /// Deconstruct the root span into its underlying [`tracing::Span`]. + pub fn into_inner(self) -> tracing::Span { + self.0 + } +} + +fn http_flavor(version: Version) -> Cow<'static, str> { + match version { + Version::HTTP_09 => "0.9".into(), + Version::HTTP_10 => "1.0".into(), + Version::HTTP_11 => "1.1".into(), + Version::HTTP_2 => "2.0".into(), + Version::HTTP_3 => "3.0".into(), + other => format!("{other:?}").into(), + } +} \ No newline at end of file diff --git a/examples/pavex_demo/todo_app_sqlite_pavex/src/user_agent.rs b/examples/pavex_demo/todo_app_sqlite_pavex/src/user_agent.rs new file mode 100644 index 000000000..1e08618e1 --- /dev/null +++ b/examples/pavex_demo/todo_app_sqlite_pavex/src/user_agent.rs @@ -0,0 +1,27 @@ +use pavex::{ + http::header::{ToStrError, USER_AGENT}, + request::RequestHead, + response::Response, +}; + +pub enum UserAgent { + /// No User-Agent header was provided + Unknown, + /// The value of the 'User-Agent' header for the incoming request + Known(String), +} +impl UserAgent { + pub fn extract(request_head: &RequestHead) -> Result<Self, ToStrError> { + let Some(user_agent) = request_head.headers.get(USER_AGENT) else { + return Ok(UserAgent::Unknown); + }; + + user_agent.to_str().map(|s| UserAgent::Known(s.into())) + } +} + +pub fn invalid_user_agent(_e: &ToStrError) -> Response { + Response::bad_request() + .set_typed_body("The `User-Agent` header must be a valid UTF-8 string") + .box_body() +} diff --git a/examples/pavex_demo/todo_app_sqlite_pavex_server/Cargo.toml b/examples/pavex_demo/todo_app_sqlite_pavex_server/Cargo.toml new file mode 100644 index 000000000..52d146218 --- /dev/null +++ b/examples/pavex_demo/todo_app_sqlite_pavex_server/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "todo_app_sqlite_pavex_server" +version = "0.1.0" +edition = "2021" + +[[bin]] +path = "src/bin/api.rs" +name = "api" + +[dependencies] +anyhow = "1" +pavex = { git = "https://github.com/LukeMathWalker/pavex", branch = "main" } +tokio = { version = "1", features = ["full"] } +todo_app_sqlite_pavex_server_sdk = { path = "../todo_app_sqlite_pavex_server_sdk" } +todo_app_sqlite_pavex = { path = "../todo_app_sqlite_pavex" } + +# Configuration +dotenvy = "0.15" +figment = { version = "0.10", features = ["env", "yaml"] } +serde = { version = "1", features = ["derive"]} + +# Telemetry +tracing = "0.1" +tracing-bunyan-formatter = "0.3" +tracing-panic = "0.1" +tracing-subscriber = { version = "0.3", default-features = false, features = ["env-filter", "registry", "smallvec", "std", "tracing-log"] } + +[dev-dependencies] +reqwest = "0.11" \ No newline at end of file diff --git a/examples/pavex_demo/todo_app_sqlite_pavex_server/configuration/base.yml b/examples/pavex_demo/todo_app_sqlite_pavex_server/configuration/base.yml new file mode 100644 index 000000000..2e6c1938e --- /dev/null +++ b/examples/pavex_demo/todo_app_sqlite_pavex_server/configuration/base.yml @@ -0,0 +1,3 @@ +server: + ip: "0.0.0.0" + port: 8000 \ No newline at end of file diff --git a/examples/pavex_demo/todo_app_sqlite_pavex_server/configuration/dev.yml b/examples/pavex_demo/todo_app_sqlite_pavex_server/configuration/dev.yml new file mode 100644 index 000000000..fe1171b8c --- /dev/null +++ b/examples/pavex_demo/todo_app_sqlite_pavex_server/configuration/dev.yml @@ -0,0 +1,6 @@ +# This file contains the configuration for the dev environment. +# None of the values here are actually secret, so it's fine +# to commit this file to the repository. +server: + ip: "127.0.0.1" + port: 8000 diff --git a/examples/pavex_demo/todo_app_sqlite_pavex_server/configuration/prod.yml b/examples/pavex_demo/todo_app_sqlite_pavex_server/configuration/prod.yml new file mode 100644 index 000000000..2a9375957 --- /dev/null +++ b/examples/pavex_demo/todo_app_sqlite_pavex_server/configuration/prod.yml @@ -0,0 +1,3 @@ +server: + ip: "0.0.0.0" + port: 8000 diff --git a/examples/pavex_demo/todo_app_sqlite_pavex_server/configuration/test.yml b/examples/pavex_demo/todo_app_sqlite_pavex_server/configuration/test.yml new file mode 100644 index 000000000..4e7a868f5 --- /dev/null +++ b/examples/pavex_demo/todo_app_sqlite_pavex_server/configuration/test.yml @@ -0,0 +1,8 @@ +# This file contains the configuration for the API when spawned +# in black-box tests. +# None of the values here are actually secret, so it's fine +# to commit this file to the repository. +server: + ip: "127.0.0.1" + # The OS will assign a random port to the test server. + port: 0 \ No newline at end of file diff --git a/examples/pavex_demo/todo_app_sqlite_pavex_server/src/bin/api.rs b/examples/pavex_demo/todo_app_sqlite_pavex_server/src/bin/api.rs new file mode 100644 index 000000000..d5fd21d3d --- /dev/null +++ b/examples/pavex_demo/todo_app_sqlite_pavex_server/src/bin/api.rs @@ -0,0 +1,49 @@ +use anyhow::Context; +use todo_app_sqlite_pavex_server::{ + configuration::load_configuration, + telemetry::{get_subscriber, init_telemetry}, +}; +use todo_app_sqlite_pavex_server_sdk::{build_application_state, run}; +use pavex::server::Server; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let subscriber = get_subscriber("todo_app_sqlite_pavex".into(), "info".into(), std::io::stdout); + init_telemetry(subscriber)?; + + // We isolate all the server setup and launch logic in a separate function + // in order to have a single choke point where we make sure to log fatal errors + // that will cause the application to exit. + if let Err(e) = _main().await { + tracing::error!( + error.msg = %e, + error.error_chain = ?e, + "The application is exiting due to an error" + ) + } + + Ok(()) +} + +async fn _main() -> anyhow::Result<()> { + // Load environment variables from a .env file, if it exists. + let _ = dotenvy::dotenv(); + + let config = load_configuration(None)?; + let application_state = build_application_state() + .await; + + let tcp_listener = config + .server + .listener() + .await + .context("Failed to bind the server TCP listener")?; + let address = tcp_listener + .local_addr() + .context("The server TCP listener doesn't have a local socket address")?; + let server_builder = Server::new().listen(tcp_listener); + + tracing::info!("Starting to listen for incoming requests at {}", address); + run(server_builder, application_state).await; + Ok(()) +} diff --git a/examples/pavex_demo/todo_app_sqlite_pavex_server/src/configuration.rs b/examples/pavex_demo/todo_app_sqlite_pavex_server/src/configuration.rs new file mode 100644 index 000000000..093377b59 --- /dev/null +++ b/examples/pavex_demo/todo_app_sqlite_pavex_server/src/configuration.rs @@ -0,0 +1,140 @@ +use std::env::VarError; + +use anyhow::Context; +use todo_app_sqlite_pavex::configuration::Config; +use figment::{ + providers::{Env, Format, Yaml}, + Figment, +}; + +/// Retrieve the application configuration by merging together multiple configuration sources. +/// +/// # Application profiles +/// +/// We use the concept of application profiles to allow for +/// different configuration values depending on the type of environment +/// the application is running in. +/// +/// We don't rely on `figment`'s built-in support for profiles because +/// we want to make sure that values for different profiles are not co-located in +/// the same configuration file. +/// This makes it easier to avoid leaking sensitive information by mistake (e.g. +/// by committing configuration values for the `dev` profile to the repository). +/// +/// You primary mechanism to specify the desired application profile is the `APP_PROFILE` +/// environment variable. +/// You can pass a `default_profile` value that will be used if the environment variable +/// is not set. +/// +/// # Hierarchy +/// +/// The configuration sources are: +/// +/// 1. `base.yml` - Contains the default configuration values, common to all profiles. +/// 2. `<profile>.yml` - Contains the configuration values specific to the desired profile. +/// 3. Environment variables - Contains the configuration values specific to the current environment. +/// +/// The configuration sources are listed in priority order, i.e. +/// the last source in the list will override any previous source. +/// +/// For example, if the same configuration key is defined in both +/// the YAML file and the environment, the value from the environment +/// will be used. +pub fn load_configuration( + default_profile: Option<ApplicationProfile>, +) -> Result<Config, anyhow::Error> { + let application_profile = load_app_profile(default_profile) + .context("Failed to load the desired application profile")?; + + let configuration_dir = { + let manifest_dir = env!( + "CARGO_MANIFEST_DIR", + "`CARGO_MANIFEST_DIR` was not set. Are you using a custom build system?" + ); + std::path::Path::new(manifest_dir).join("configuration") + }; + + let base_filepath = configuration_dir.join("base.yml"); + + let profile_filename = format!("{}.yml", application_profile.as_str()); + let profile_filepath = configuration_dir.join(profile_filename); + + let figment = Figment::new() + .merge(Yaml::file(base_filepath)) + .merge(Yaml::file(profile_filepath)) + .merge(Env::prefixed("APP_")); + + let configuration: Config = figment + .extract() + .context("Failed to load hierarchical configuration")?; + Ok(configuration) +} + +/// Load the application profile from the `APP_PROFILE` environment variable. +fn load_app_profile( + default_profile: Option<ApplicationProfile>, +) -> Result<ApplicationProfile, anyhow::Error> { + static PROFILE_ENV_VAR: &str = "APP_PROFILE"; + + match std::env::var(PROFILE_ENV_VAR) { + Ok(raw_value) => raw_value.parse().with_context(|| { + format!("Failed to parse the `{PROFILE_ENV_VAR}` environment variable") + }), + Err(VarError::NotPresent) if default_profile.is_some() => Ok(default_profile.unwrap()), + Err(e) => Err(anyhow::anyhow!(e).context(format!( + "Failed to read the `{PROFILE_ENV_VAR}` environment variable" + ))), + } +} + +/// The application profile, i.e. the type of environment the application is running in. +/// See [`load_configuration`] for more details. +pub enum ApplicationProfile { + /// Test profile. + /// + /// This is the profile used by the integration test suite. + Test, + /// Local development profile. + /// + /// This is the profile you should use when running the application locally + /// for exploratory testing. + /// + /// The corresponding configuration file is `dev.yml` and it's *never* committed to the repository. + Dev, + /// Production profile. + /// + /// This is the profile you should use when running the application in production—e.g. + /// when deploying it to a staging or production environment, exposed to live traffic. + /// + /// The corresponding configuration file is `prod.yml`. + /// It's committed to the repository, but it's meant to contain exclusively + /// non-sensitive configuration values. + Prod, +} + +impl ApplicationProfile { + /// Return the environment as a string. + pub fn as_str(&self) -> &'static str { + match self { + ApplicationProfile::Test => "test", + ApplicationProfile::Dev => "dev", + ApplicationProfile::Prod => "prod", + } + } +} + +impl std::str::FromStr for ApplicationProfile { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + match s.to_lowercase().as_str() { + "test" => Ok(ApplicationProfile::Test), + "dev" | "development" => Ok(ApplicationProfile::Dev), + "prod" | "production" => Ok(ApplicationProfile::Prod), + s => Err(anyhow::anyhow!( + "`{}` is not a valid application profile.\nValid options are: `test`, `dev`, `prod`.", + s + )), + } + } +} diff --git a/examples/pavex_demo/todo_app_sqlite_pavex_server/src/lib.rs b/examples/pavex_demo/todo_app_sqlite_pavex_server/src/lib.rs new file mode 100644 index 000000000..39bc2ff72 --- /dev/null +++ b/examples/pavex_demo/todo_app_sqlite_pavex_server/src/lib.rs @@ -0,0 +1,2 @@ +pub mod configuration; +pub mod telemetry; \ No newline at end of file diff --git a/examples/pavex_demo/todo_app_sqlite_pavex_server/src/telemetry.rs b/examples/pavex_demo/todo_app_sqlite_pavex_server/src/telemetry.rs new file mode 100644 index 000000000..848bc1884 --- /dev/null +++ b/examples/pavex_demo/todo_app_sqlite_pavex_server/src/telemetry.rs @@ -0,0 +1,40 @@ +use anyhow::Context; +use tracing::subscriber::set_global_default; +use tracing::Subscriber; +use tracing_bunyan_formatter::{BunyanFormattingLayer, JsonStorageLayer}; +use tracing_subscriber::fmt::MakeWriter; +use tracing_subscriber::{layer::SubscriberExt, EnvFilter, Registry}; + +/// Perform all the required setup steps for our telemetry: +/// +/// - Register a subscriber as global default to process span data +/// - Register a panic hook to capture any panic and record its details +/// +/// It should only be called once! +pub fn init_telemetry(subscriber: impl Subscriber + Sync + Send) -> Result<(), anyhow::Error> { + std::panic::set_hook(Box::new(tracing_panic::panic_hook)); + set_global_default(subscriber).context("Failed to set a `tracing` global subscriber") +} + +/// Compose multiple layers into a `tracing`'s subscriber. +/// +/// # Implementation Notes +/// +/// We are using `impl Subscriber` as return type to avoid having to spell out the actual +/// type of the returned subscriber, which is indeed quite complex. +pub fn get_subscriber<Sink>( + application_name: String, + default_env_filter: String, + sink: Sink, +) -> impl Subscriber + Sync + Send + where + Sink: for<'a> MakeWriter<'a> + Send + Sync + 'static, +{ + let env_filter = + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_env_filter)); + let formatting_layer = BunyanFormattingLayer::new(application_name, sink); + Registry::default() + .with(env_filter) + .with(JsonStorageLayer) + .with(formatting_layer) +} \ No newline at end of file diff --git a/examples/pavex_demo/todo_app_sqlite_pavex_server/tests/integration/greet.rs b/examples/pavex_demo/todo_app_sqlite_pavex_server/tests/integration/greet.rs new file mode 100644 index 000000000..9ba0dd35c --- /dev/null +++ b/examples/pavex_demo/todo_app_sqlite_pavex_server/tests/integration/greet.rs @@ -0,0 +1,37 @@ +use crate::helpers::TestApi; +use pavex::http::StatusCode; + +#[tokio::test] +async fn greet_happy_path() { + let api = TestApi::spawn().await; + let name = "Ursula"; + + let response = api + .api_client + .get(&format!("{}/api/greet/{name}", &api.api_address)) + .header("User-Agent", "Test runner") + .send() + .await + .expect("Failed to execute request."); + assert_eq!(response.status().as_u16(), StatusCode::OK.as_u16()); + assert_eq!(response.text().await.unwrap(), "Hello, Ursula!"); +} + +#[tokio::test] +async fn non_utf8_agent_is_rejected() { + let api = TestApi::spawn().await; + let name = "Ursula"; + + let response = api + .api_client + .get(&format!("{}/api/greet/{name}", &api.api_address)) + .header("User-Agent", b"hello\xfa".as_slice()) + .send() + .await + .expect("Failed to execute request."); + assert_eq!(response.status().as_u16(), StatusCode::BAD_REQUEST.as_u16()); + assert_eq!( + response.text().await.unwrap(), + "The `User-Agent` header must be a valid UTF-8 string" + ); +} diff --git a/examples/pavex_demo/todo_app_sqlite_pavex_server/tests/integration/helpers.rs b/examples/pavex_demo/todo_app_sqlite_pavex_server/tests/integration/helpers.rs new file mode 100644 index 000000000..3eaff3713 --- /dev/null +++ b/examples/pavex_demo/todo_app_sqlite_pavex_server/tests/integration/helpers.rs @@ -0,0 +1,52 @@ +use todo_app_sqlite_pavex_server::configuration::{load_configuration, ApplicationProfile}; +use todo_app_sqlite_pavex_server_sdk::{build_application_state, run}; +use todo_app_sqlite_pavex::configuration::Config; +use pavex::server::Server; + +pub struct TestApi { + pub api_address: String, + pub api_client: reqwest::Client, +} + +impl TestApi { + pub async fn spawn() -> Self { + let config = Self::get_config(); + + let application_state = build_application_state().await; + + let tcp_listener = config + .server + .listener() + .await + .expect("Failed to bind the server TCP listener"); + let address = tcp_listener + .local_addr() + .expect("The server TCP listener doesn't have a local socket address"); + let server_builder = Server::new().listen(tcp_listener); + + tokio::spawn(async move { + run(server_builder, application_state).await + }); + + TestApi { + api_address: format!("http://{}:{}", config.server.ip, address.port()), + api_client: reqwest::Client::new(), + } + } + + fn get_config() -> Config { + load_configuration(Some(ApplicationProfile::Test)).expect("Failed to load test configuration") + } +} + +/// Convenient methods for calling the API under test. +impl TestApi { + pub async fn get_ping(&self) -> reqwest::Response + { + self.api_client + .get(&format!("{}/api/ping", &self.api_address)) + .send() + .await + .expect("Failed to execute request.") + } +} diff --git a/examples/pavex_demo/todo_app_sqlite_pavex_server/tests/integration/main.rs b/examples/pavex_demo/todo_app_sqlite_pavex_server/tests/integration/main.rs new file mode 100644 index 000000000..67767e05f --- /dev/null +++ b/examples/pavex_demo/todo_app_sqlite_pavex_server/tests/integration/main.rs @@ -0,0 +1,4 @@ +mod greet; +mod helpers; +mod ping; + diff --git a/examples/pavex_demo/todo_app_sqlite_pavex_server/tests/integration/ping.rs b/examples/pavex_demo/todo_app_sqlite_pavex_server/tests/integration/ping.rs new file mode 100644 index 000000000..c79eb0eb7 --- /dev/null +++ b/examples/pavex_demo/todo_app_sqlite_pavex_server/tests/integration/ping.rs @@ -0,0 +1,11 @@ +use crate::helpers::TestApi; +use pavex::http::StatusCode; + +#[tokio::test] +async fn ping_works() { + let api = TestApi::spawn().await; + + let response = api.get_ping().await; + + assert_eq!(response.status().as_u16(), StatusCode::OK.as_u16()); +} diff --git a/examples/pavex_demo/todo_app_sqlite_pavex_server_sdk/Cargo.toml b/examples/pavex_demo/todo_app_sqlite_pavex_server_sdk/Cargo.toml new file mode 100644 index 000000000..336485067 --- /dev/null +++ b/examples/pavex_demo/todo_app_sqlite_pavex_server_sdk/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "todo_app_sqlite_pavex_server_sdk" +version = "0.1.0" +edition = "2021" + +[package.metadata.px.generate] +generator_type = "cargo_workspace_binary" +generator_name = "bp" + +[lints] +clippy = { all = "allow" } + +[dependencies] +bytes = { version = "1.5.0", package = "bytes" } +http = { version = "1.0.0", package = "http" } +http_body_util = { version = "0.1.0", package = "http-body-util" } +hyper = { version = "1.1.0", package = "hyper" } +matchit = { version = "0.7.3", git = "https://github.com/ibraheemdev/matchit", branch = "master", package = "matchit" } +pavex = { version = "0.1.0", git = "https://github.com/LukeMathWalker/pavex", branch = "main", package = "pavex" } +thiserror = { version = "1.0.52", package = "thiserror" } +todo_app_sqlite_pavex = { version = "0.1.0", path = "../todo_app_sqlite_pavex", package = "todo_app_sqlite_pavex" } diff --git a/examples/pavex_demo/todo_app_sqlite_pavex_server_sdk/blueprint.ron b/examples/pavex_demo/todo_app_sqlite_pavex_server_sdk/blueprint.ron new file mode 100644 index 000000000..39906399e --- /dev/null +++ b/examples/pavex_demo/todo_app_sqlite_pavex_server_sdk/blueprint.ron @@ -0,0 +1,233 @@ +( + creation_location: ( + line: 13, + column: 18, + file: "todo_app_sqlite_pavex/src/blueprint.rs", + ), + constructors: [ + ( + constructor: ( + callable: ( + registered_at: "todo_app_sqlite_pavex", + import_path: "pavex::request::query::QueryParams::extract", + ), + location: ( + line: 32, + column: 8, + file: "todo_app_sqlite_pavex/src/blueprint.rs", + ), + ), + lifecycle: RequestScoped, + cloning_strategy: None, + error_handler: Some(( + callable: ( + registered_at: "todo_app_sqlite_pavex", + import_path: "pavex::request::query::errors::ExtractQueryParamsError::into_response", + ), + location: ( + line: 36, + column: 6, + file: "todo_app_sqlite_pavex/src/blueprint.rs", + ), + )), + ), + ( + constructor: ( + callable: ( + registered_at: "todo_app_sqlite_pavex", + import_path: "pavex::request::route::RouteParams::extract", + ), + location: ( + line: 41, + column: 8, + file: "todo_app_sqlite_pavex/src/blueprint.rs", + ), + ), + lifecycle: RequestScoped, + cloning_strategy: None, + error_handler: Some(( + callable: ( + registered_at: "todo_app_sqlite_pavex", + import_path: "pavex::request::route::errors::ExtractRouteParamsError::into_response", + ), + location: ( + line: 45, + column: 6, + file: "todo_app_sqlite_pavex/src/blueprint.rs", + ), + )), + ), + ( + constructor: ( + callable: ( + registered_at: "todo_app_sqlite_pavex", + import_path: "pavex::request::body::JsonBody::extract", + ), + location: ( + line: 50, + column: 8, + file: "todo_app_sqlite_pavex/src/blueprint.rs", + ), + ), + lifecycle: RequestScoped, + cloning_strategy: None, + error_handler: Some(( + callable: ( + registered_at: "todo_app_sqlite_pavex", + import_path: "pavex::request::body::errors::ExtractJsonBodyError::into_response", + ), + location: ( + line: 54, + column: 6, + file: "todo_app_sqlite_pavex/src/blueprint.rs", + ), + )), + ), + ( + constructor: ( + callable: ( + registered_at: "todo_app_sqlite_pavex", + import_path: "pavex::request::body::BufferedBody::extract", + ), + location: ( + line: 57, + column: 8, + file: "todo_app_sqlite_pavex/src/blueprint.rs", + ), + ), + lifecycle: RequestScoped, + cloning_strategy: None, + error_handler: Some(( + callable: ( + registered_at: "todo_app_sqlite_pavex", + import_path: "pavex::request::body::errors::ExtractBufferedBodyError::into_response", + ), + location: ( + line: 61, + column: 6, + file: "todo_app_sqlite_pavex/src/blueprint.rs", + ), + )), + ), + ( + constructor: ( + callable: ( + registered_at: "todo_app_sqlite_pavex", + import_path: "<pavex::request::body::BodySizeLimit as std::default::Default>::default", + ), + location: ( + line: 64, + column: 8, + file: "todo_app_sqlite_pavex/src/blueprint.rs", + ), + ), + lifecycle: RequestScoped, + cloning_strategy: None, + error_handler: None, + ), + ( + constructor: ( + callable: ( + registered_at: "todo_app_sqlite_pavex", + import_path: "crate::user_agent::UserAgent::extract", + ), + location: ( + line: 16, + column: 8, + file: "todo_app_sqlite_pavex/src/blueprint.rs", + ), + ), + lifecycle: RequestScoped, + cloning_strategy: None, + error_handler: Some(( + callable: ( + registered_at: "todo_app_sqlite_pavex", + import_path: "crate::user_agent::invalid_user_agent", + ), + location: ( + line: 20, + column: 6, + file: "todo_app_sqlite_pavex/src/blueprint.rs", + ), + )), + ), + ( + constructor: ( + callable: ( + registered_at: "todo_app_sqlite_pavex", + import_path: "crate::telemetry::RootSpan::new", + ), + location: ( + line: 72, + column: 8, + file: "todo_app_sqlite_pavex/src/blueprint.rs", + ), + ), + lifecycle: RequestScoped, + cloning_strategy: Some(CloneIfNecessary), + error_handler: None, + ), + ], + middlewares: [ + ( + middleware: ( + callable: ( + registered_at: "todo_app_sqlite_pavex", + import_path: "crate::telemetry::logger", + ), + location: ( + line: 78, + column: 8, + file: "todo_app_sqlite_pavex/src/blueprint.rs", + ), + ), + error_handler: None, + ), + ], + routes: [ + ( + path: "/api/ping", + method_guard: ( + inner: Some(( + bitset: 256, + extensions: [], + )), + ), + request_handler: ( + callable: ( + registered_at: "todo_app_sqlite_pavex", + import_path: "crate::routes::status::ping", + ), + location: ( + line: 24, + column: 8, + file: "todo_app_sqlite_pavex/src/blueprint.rs", + ), + ), + error_handler: None, + ), + ( + path: "/api/greet/:name", + method_guard: ( + inner: Some(( + bitset: 256, + extensions: [], + )), + ), + request_handler: ( + callable: ( + registered_at: "todo_app_sqlite_pavex", + import_path: "crate::routes::greet::greet", + ), + location: ( + line: 25, + column: 8, + file: "todo_app_sqlite_pavex/src/blueprint.rs", + ), + ), + error_handler: None, + ), + ], + fallback_request_handler: None, + nested_blueprints: [], +) \ No newline at end of file diff --git a/examples/pavex_demo/todo_app_sqlite_pavex_server_sdk/src/lib.rs b/examples/pavex_demo/todo_app_sqlite_pavex_server_sdk/src/lib.rs new file mode 100644 index 000000000..a76065bfb --- /dev/null +++ b/examples/pavex_demo/todo_app_sqlite_pavex_server_sdk/src/lib.rs @@ -0,0 +1,254 @@ +//! Do NOT edit this code. +//! It was automatically generated by Pavex. +//! All manual edits will be lost next time the code is generated. +extern crate alloc; +struct ServerState { + router: matchit::Router<u32>, + #[allow(dead_code)] + application_state: ApplicationState, +} +pub struct ApplicationState {} +pub async fn build_application_state() -> crate::ApplicationState { + crate::ApplicationState {} +} +pub fn run( + server_builder: pavex::server::Server, + application_state: ApplicationState, +) -> pavex::server::ServerHandle { + let server_state = std::sync::Arc::new(ServerState { + router: build_router(), + application_state, + }); + server_builder.serve(route_request, server_state) +} +fn build_router() -> matchit::Router<u32> { + let mut router = matchit::Router::new(); + router.insert("/api/greet/:name", 0u32).unwrap(); + router.insert("/api/ping", 1u32).unwrap(); + router +} +async fn route_request( + request: http::Request<hyper::body::Incoming>, + server_state: std::sync::Arc<ServerState>, +) -> pavex::response::Response { + let (request_head, request_body) = request.into_parts(); + #[allow(unused)] + let request_body = pavex::request::body::RawIncomingBody::from(request_body); + let request_head: pavex::request::RequestHead = request_head.into(); + let matched_route = match server_state.router.at(&request_head.uri.path()) { + Ok(m) => m, + Err(_) => { + let allowed_methods: pavex::router::AllowedMethods = pavex::router::MethodAllowList::from_iter( + vec![], + ) + .into(); + let matched_route_template = pavex::request::route::MatchedRouteTemplate::new( + "*", + ); + return route_2::middleware_0( + matched_route_template, + &allowed_methods, + &request_head, + ) + .await; + } + }; + let route_id = matched_route.value; + #[allow(unused)] + let url_params: pavex::request::route::RawRouteParams<'_, '_> = matched_route + .params + .into(); + match route_id { + 0u32 => { + let matched_route_template = pavex::request::route::MatchedRouteTemplate::new( + "/api/greet/:name", + ); + match &request_head.method { + &pavex::http::Method::GET => { + route_1::middleware_0( + matched_route_template, + url_params, + &request_head, + ) + .await + } + _ => { + let allowed_methods: pavex::router::AllowedMethods = pavex::router::MethodAllowList::from_iter([ + pavex::http::Method::GET, + ]) + .into(); + route_2::middleware_0( + matched_route_template, + &allowed_methods, + &request_head, + ) + .await + } + } + } + 1u32 => { + let matched_route_template = pavex::request::route::MatchedRouteTemplate::new( + "/api/ping", + ); + match &request_head.method { + &pavex::http::Method::GET => { + route_0::middleware_0(matched_route_template, &request_head).await + } + _ => { + let allowed_methods: pavex::router::AllowedMethods = pavex::router::MethodAllowList::from_iter([ + pavex::http::Method::GET, + ]) + .into(); + route_2::middleware_0( + matched_route_template, + &allowed_methods, + &request_head, + ) + .await + } + } + } + i => unreachable!("Unknown route id: {}", i), + } +} +pub mod route_0 { + pub async fn middleware_0( + v0: pavex::request::route::MatchedRouteTemplate, + v1: &pavex::request::RequestHead, + ) -> pavex::response::Response { + let v2 = todo_app_sqlite_pavex::telemetry::RootSpan::new(v1, v0); + let v3 = crate::route_0::Next0 { + next: handler, + }; + let v4 = pavex::middleware::Next::new(v3); + todo_app_sqlite_pavex::telemetry::logger(v4, v2).await + } + pub async fn handler() -> pavex::response::Response { + let v0 = todo_app_sqlite_pavex::routes::status::ping(); + <http::StatusCode as pavex::response::IntoResponse>::into_response(v0) + } + pub struct Next0<T> + where + T: std::future::Future<Output = pavex::response::Response>, + { + next: fn() -> T, + } + impl<T> std::future::IntoFuture for Next0<T> + where + T: std::future::Future<Output = pavex::response::Response>, + { + type Output = pavex::response::Response; + type IntoFuture = T; + fn into_future(self) -> Self::IntoFuture { + (self.next)() + } + } +} +pub mod route_1 { + pub async fn middleware_0( + v0: pavex::request::route::MatchedRouteTemplate, + v1: pavex::request::route::RawRouteParams<'_, '_>, + v2: &pavex::request::RequestHead, + ) -> pavex::response::Response { + let v3 = todo_app_sqlite_pavex::telemetry::RootSpan::new(v2, v0); + let v4 = crate::route_1::Next0 { + s_0: v1, + s_1: v2, + next: handler, + }; + let v5 = pavex::middleware::Next::new(v4); + todo_app_sqlite_pavex::telemetry::logger(v5, v3).await + } + pub async fn handler( + v0: pavex::request::route::RawRouteParams<'_, '_>, + v1: &pavex::request::RequestHead, + ) -> pavex::response::Response { + let v2 = todo_app_sqlite_pavex::user_agent::UserAgent::extract(v1); + let v3 = match v2 { + Ok(ok) => ok, + Err(v3) => { + return { + let v4 = todo_app_sqlite_pavex::user_agent::invalid_user_agent(&v3); + <pavex::response::Response as pavex::response::IntoResponse>::into_response( + v4, + ) + }; + } + }; + let v4 = pavex::request::route::RouteParams::extract(v0); + let v5 = match v4 { + Ok(ok) => ok, + Err(v5) => { + return { + let v6 = pavex::request::route::errors::ExtractRouteParamsError::into_response( + &v5, + ); + <pavex::response::Response< + http_body_util::Full<bytes::Bytes>, + > as pavex::response::IntoResponse>::into_response(v6) + }; + } + }; + let v6 = todo_app_sqlite_pavex::routes::greet::greet(v5, v3); + <pavex::response::Response as pavex::response::IntoResponse>::into_response(v6) + } + pub struct Next0<'a, 'b, 'c, T> + where + T: std::future::Future<Output = pavex::response::Response>, + { + s_0: pavex::request::route::RawRouteParams<'a, 'b>, + s_1: &'c pavex::request::RequestHead, + next: fn( + pavex::request::route::RawRouteParams<'a, 'b>, + &'c pavex::request::RequestHead, + ) -> T, + } + impl<'a, 'b, 'c, T> std::future::IntoFuture for Next0<'a, 'b, 'c, T> + where + T: std::future::Future<Output = pavex::response::Response>, + { + type Output = pavex::response::Response; + type IntoFuture = T; + fn into_future(self) -> Self::IntoFuture { + (self.next)(self.s_0, self.s_1) + } + } +} +pub mod route_2 { + pub async fn middleware_0( + v0: pavex::request::route::MatchedRouteTemplate, + v1: &pavex::router::AllowedMethods, + v2: &pavex::request::RequestHead, + ) -> pavex::response::Response { + let v3 = todo_app_sqlite_pavex::telemetry::RootSpan::new(v2, v0); + let v4 = crate::route_2::Next0 { + s_0: v1, + next: handler, + }; + let v5 = pavex::middleware::Next::new(v4); + todo_app_sqlite_pavex::telemetry::logger(v5, v3).await + } + pub async fn handler( + v0: &pavex::router::AllowedMethods, + ) -> pavex::response::Response { + let v1 = pavex::router::default_fallback(v0).await; + <pavex::response::Response as pavex::response::IntoResponse>::into_response(v1) + } + pub struct Next0<'a, T> + where + T: std::future::Future<Output = pavex::response::Response>, + { + s_0: &'a pavex::router::AllowedMethods, + next: fn(&'a pavex::router::AllowedMethods) -> T, + } + impl<'a, T> std::future::IntoFuture for Next0<'a, T> + where + T: std::future::Future<Output = pavex::response::Response>, + { + type Output = pavex::response::Response; + type IntoFuture = T; + fn into_future(self) -> Self::IntoFuture { + (self.next)(self.s_0) + } + } +} diff --git a/flake.lock b/flake.lock index 36554d16b..ffc538721 100644 --- a/flake.lock +++ b/flake.lock @@ -38,11 +38,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1703637592, - "narHash": "sha256-8MXjxU0RfFfzl57Zy3OfXCITS0qWDNLzlBAdwxGZwfY=", + "lastModified": 1703961334, + "narHash": "sha256-M1mV/Cq+pgjk0rt6VxoyyD+O8cOUiai8t9Q6Yyq4noY=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "cfc3698c31b1fb9cdcf10f36c9643460264d0ca8", + "rev": "b0d36bd0a420ecee3bc916c91886caca87c894e9", "type": "github" }, "original": { @@ -81,11 +81,11 @@ "nixpkgs": "nixpkgs_2" }, "locked": { - "lastModified": 1703902408, - "narHash": "sha256-qXdWvu+tlgNjeoz8yQMRKSom6QyRROfgpmeOhwbujqw=", + "lastModified": 1704075545, + "narHash": "sha256-L3zgOuVKhPjKsVLc3yTm2YJ6+BATyZBury7wnhyc8QU=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "319f57cd2c34348c55970a4bf2b35afe82088681", + "rev": "a0df72e106322b67e9c6e591fe870380bd0da0d5", "type": "github" }, "original": { diff --git a/leptos_macro/src/server.rs b/leptos_macro/src/server.rs index cb2cbc197..035d2300a 100644 --- a/leptos_macro/src/server.rs +++ b/leptos_macro/src/server.rs @@ -50,17 +50,21 @@ pub fn server_impl(args: TokenStream, s: TokenStream) -> TokenStream { if args.prefix.is_none() { args.prefix = Some(Literal::string("/api")); } + let args_prefix = match &args.prefix { + Some(s) => s.to_string(), + None => "/api".to_string(), + }; // default to "Url" if no encoding given if args.encoding.is_none() { args.encoding = Some(Literal::string("Url")); } - + // Either this match is wrong, or the impl in the macro crate is wrong match server_fn_macro::server_macro_impl( quote::quote!(#args), mapped_body, syn::parse_quote!(::leptos::leptos_server::ServerFnTraitObj), - None, Some(syn::parse_quote!(::leptos::server_fn)), + &args_prefix, ) { Err(e) => e.to_compile_error().into(), Ok(s) => s.to_token_stream().into(), diff --git a/leptos_server/src/action.rs b/leptos_server/src/action.rs index b068198fd..6052f6401 100644 --- a/leptos_server/src/action.rs +++ b/leptos_server/src/action.rs @@ -1,8 +1,9 @@ -use crate::{ServerFn, ServerFnError}; +//use crate::{ServerFn, ServerFnError}; use leptos_reactive::{ batch, create_rw_signal, is_suppressing_resource_load, signal_prelude::*, spawn_local, store_value, ReadSignal, RwSignal, StoredValue, }; +use server_fn::{ServerFn, ServerFnError}; use std::{cell::Cell, future::Future, pin::Pin, rc::Rc}; /// An action synchronizes an imperative `async` call to the synchronous reactive system. diff --git a/leptos_server/src/lib.rs b/leptos_server/src/lib.rs index 6aa677319..8d925feda 100644 --- a/leptos_server/src/lib.rs +++ b/leptos_server/src/lib.rs @@ -1,120 +1,120 @@ -#![deny(missing_docs)] +//#![deny(missing_docs)] #![forbid(unsafe_code)] -//! # Leptos Server Functions -//! -//! This package is based on a simple idea: sometimes it’s useful to write functions -//! that will only run on the server, and call them from the client. -//! -//! If you’re creating anything beyond a toy app, you’ll need to do this all the time: -//! reading from or writing to a database that only runs on the server, running expensive -//! computations using libraries you don’t want to ship down to the client, accessing -//! APIs that need to be called from the server rather than the client for CORS reasons -//! or because you need a secret API key that’s stored on the server and definitely -//! shouldn’t be shipped down to a user’s browser. -//! -//! Traditionally, this is done by separating your server and client code, and by setting -//! up something like a REST API or GraphQL API to allow your client to fetch and mutate -//! data on the server. This is fine, but it requires you to write and maintain your code -//! in multiple separate places (client-side code for fetching, server-side functions to run), -//! as well as creating a third thing to manage, which is the API contract between the two. -//! -//! This package provides two simple primitives that allow you instead to write co-located, -//! isomorphic server functions. (*Co-located* means you can write them in your app code so -//! that they are “located alongside” the client code that calls them, rather than separating -//! the client and server sides. *Isomorphic* means you can call them from the client as if -//! you were simply calling a function; the function call has the “same shape” on the client -//! as it does on the server.) -//! -//! ### `#[server]` -//! -//! The [`#[server]`](https://docs.rs/leptos/latest/leptos/attr.server.html) macro allows you to annotate a function to -//! indicate that it should only run on the server (i.e., when you have an `ssr` feature in your -//! crate that is enabled). -//! -//! ```rust,ignore -//! use leptos::*; -//! #[server(ReadFromDB)] -//! async fn read_posts(how_many: usize, query: String) -> Result<Vec<Posts>, ServerFnError> { -//! // do some server-only work here to access the database -//! let posts = todo!();; -//! Ok(posts) -//! } -//! -//! // call the function -//! spawn_local(async { -//! let posts = read_posts(3, "my search".to_string()).await; -//! log::debug!("posts = {posts:#?}"); -//! }); -//! ``` -//! -//! If you call this function from the client, it will serialize the function arguments and `POST` -//! them to the server as if they were the inputs in `<form method="POST">`. -//! -//! Here’s what you need to remember: -//! - **Server functions must be `async`.** Even if the work being done inside the function body -//! can run synchronously on the server, from the client’s perspective it involves an asynchronous -//! function call. -//! - **Server functions must return `Result<T, ServerFnError>`.** Even if the work being done -//! inside the function body can’t fail, the processes of serialization/deserialization and the -//! network call are fallible. -//! - **Return types must be [Serializable](leptos_reactive::Serializable).** -//! This should be fairly obvious: we have to serialize arguments to send them to the server, and we -//! need to deserialize the result to return it to the client. -//! - **Arguments must be implement [serde::Serialize].** They are serialized as an `application/x-www-form-urlencoded` -//! form data using [`serde_qs`](https://docs.rs/serde_qs/latest/serde_qs/) or as `application/cbor` -//! using [`cbor`](https://docs.rs/cbor/latest/cbor/). **Note**: You should explicitly include `serde` with the -//! `derive` feature enabled in your `Cargo.toml`. You can do this by running `cargo add serde --features=derive`. -//! - Context comes from the server. [`use_context`](leptos_reactive::use_context) can be used to access specific -//! server-related data, as documented in the server integrations. This allows accessing things like HTTP request -//! headers as needed. However, server functions *not* have access to reactive state that exists in the client. -//! -//! ## Server Function Encodings -//! -//! By default, the server function call is a `POST` request that serializes the arguments as URL-encoded form data in the body -//! of the request. But there are a few other methods supported. Optionally, we can provide another argument to the `#[server]` -//! macro to specify an alternate encoding: -//! -//! ```rust,ignore -//! #[server(AddTodo, "/api", "Url")] -//! #[server(AddTodo, "/api", "GetJson")] -//! #[server(AddTodo, "/api", "Cbor")] -//! #[server(AddTodo, "/api", "GetCbor")] -//! ``` -//! -//! The four options use different combinations of HTTP verbs and encoding methods: -//! -//! | Name | Method | Request | Response | -//! | ----------------- | ------ | ----------- | -------- | -//! | **Url** (default) | POST | URL encoded | JSON | -//! | **GetJson** | GET | URL encoded | JSON | -//! | **Cbor** | POST | CBOR | CBOR | -//! | **GetCbor** | GET | URL encoded | CBOR | -//! -//! In other words, you have two choices: -//! -//! - `GET` or `POST`? This has implications for things like browser or CDN caching; while `POST` requests should not be cached, -//! `GET` requests can be. -//! - Plain text (arguments sent with URL/form encoding, results sent as JSON) or a binary format (CBOR, encoded as a base64 -//! string)? -//! -//! ## Why not `PUT` or `DELETE`? Why URL/form encoding, and not JSON?** -//! -//! These are reasonable questions. Much of the web is built on REST API patterns that encourage the use of semantic HTTP -//! methods like `DELETE` to delete an item from a database, and many devs are accustomed to sending data to APIs in the -//! JSON format. -//! -//! The reason we use `POST` or `GET` with URL-encoded data by default is the `<form>` support. For better or for worse, -//! HTML forms don’t support `PUT` or `DELETE`, and they don’t support sending JSON. This means that if you use anything -//! but a `GET` or `POST` request with URL-encoded data, it can only work once WASM has loaded. -//! -//! The CBOR encoding is suported for historical reasons; an earlier version of server functions used a URL encoding that -//! didn’t support nested objects like structs or vectors as server function arguments, which CBOR did. But note that the -//! CBOR forms encounter the same issue as `PUT`, `DELETE`, or JSON: they do not degrade gracefully if the WASM version of -//! your app is not available. +////! # Leptos Server Functions +////! +////! This package is based on a simple idea: sometimes it’s useful to write functions +////! that will only run on the server, and call them from the client. +////! +////! If you’re creating anything beyond a toy app, you’ll need to do this all the time: +////! reading from or writing to a database that only runs on the server, running expensive +////! computations using libraries you don’t want to ship down to the client, accessing +////! APIs that need to be called from the server rather than the client for CORS reasons +////! or because you need a secret API key that’s stored on the server and definitely +////! shouldn’t be shipped down to a user’s browser. +////! +////! Traditionally, this is done by separating your server and client code, and by setting +////! up something like a REST API or GraphQL API to allow your client to fetch and mutate +////! data on the server. This is fine, but it requires you to write and maintain your code +////! in multiple separate places (client-side code for fetching, server-side functions to run), +////! as well as creating a third thing to manage, which is the API contract between the two. +////! +////! This package provides two simple primitives that allow you instead to write co-located, +////! isomorphic server functions. (*Co-located* means you can write them in your app code so +////! that they are “located alongside” the client code that calls them, rather than separating +////! the client and server sides. *Isomorphic* means you can call them from the client as if +////! you were simply calling a function; the function call has the “same shape” on the client +////! as it does on the server.) +////! +////! ### `#[server]` +////! +////! The [`#[server]`](https://docs.rs/leptos/latest/leptos/attr.server.html) macro allows you to annotate a function to +////! indicate that it should only run on the server (i.e., when you have an `ssr` feature in your +////! crate that is enabled). +////! +////! ```rust,ignore +////! use leptos::*; +////! #[server(ReadFromDB)] +////! async fn read_posts(how_many: usize, query: String) -> Result<Vec<Posts>, ServerFnError> { +////! // do some server-only work here to access the database +////! let posts = todo!();; +////! Ok(posts) +////! } +////! +////! // call the function +////! spawn_local(async { +////! let posts = read_posts(3, "my search".to_string()).await; +////! log::debug!("posts = {posts:#?}"); +////! }); +////! ``` +////! +////! If you call this function from the client, it will serialize the function arguments and `POST` +////! them to the server as if they were the inputs in `<form method="POST">`. +////! +////! Here’s what you need to remember: +////! - **Server functions must be `async`.** Even if the work being done inside the function body +////! can run synchronously on the server, from the client’s perspective it involves an asynchronous +////! function call. +////! - **Server functions must return `Result<T, ServerFnError>`.** Even if the work being done +////! inside the function body can’t fail, the processes of serialization/deserialization and the +////! network call are fallible. +////! - **Return types must be [Serializable](leptos_reactive::Serializable).** +////! This should be fairly obvious: we have to serialize arguments to send them to the server, and we +////! need to deserialize the result to return it to the client. +////! - **Arguments must be implement [serde::Serialize].** They are serialized as an `application/x-www-form-urlencoded` +////! form data using [`serde_qs`](https://docs.rs/serde_qs/latest/serde_qs/) or as `application/cbor` +////! using [`cbor`](https://docs.rs/cbor/latest/cbor/). **Note**: You should explicitly include `serde` with the +////! `derive` feature enabled in your `Cargo.toml`. You can do this by running `cargo add serde --features=derive`. +////! - Context comes from the server. [`use_context`](leptos_reactive::use_context) can be used to access specific +////! server-related data, as documented in the server integrations. This allows accessing things like HTTP request +////! headers as needed. However, server functions *not* have access to reactive state that exists in the client. +////! +////! ## Server Function Encodings +////! +////! By default, the server function call is a `POST` request that serializes the arguments as URL-encoded form data in the body +////! of the request. But there are a few other methods supported. Optionally, we can provide another argument to the `#[server]` +////! macro to specify an alternate encoding: +////! +////! ```rust,ignore +////! #[server(AddTodo, "/api", "Url")] +////! #[server(AddTodo, "/api", "GetJson")] +////! #[server(AddTodo, "/api", "Cbor")] +////! #[server(AddTodo, "/api", "GetCbor")] +////! ``` +////! +////! The four options use different combinations of HTTP verbs and encoding methods: +////! +////! | Name | Method | Request | Response | +////! | ----------------- | ------ | ----------- | -------- | +////! | **Url** (default) | POST | URL encoded | JSON | +////! | **GetJson** | GET | URL encoded | JSON | +////! | **Cbor** | POST | CBOR | CBOR | +////! | **GetCbor** | GET | URL encoded | CBOR | +////! +////! In other words, you have two choices: +////! +////! - `GET` or `POST`? This has implications for things like browser or CDN caching; while `POST` requests should not be cached, +////! `GET` requests can be. +////! - Plain text (arguments sent with URL/form encoding, results sent as JSON) or a binary format (CBOR, encoded as a base64 +////! string)? +////! +////! ## Why not `PUT` or `DELETE`? Why URL/form encoding, and not JSON?** +////! +////! These are reasonable questions. Much of the web is built on REST API patterns that encourage the use of semantic HTTP +////! methods like `DELETE` to delete an item from a database, and many devs are accustomed to sending data to APIs in the +////! JSON format. +////! +////! The reason we use `POST` or `GET` with URL-encoded data by default is the `<form>` support. For better or for worse, +////! HTML forms don’t support `PUT` or `DELETE`, and they don’t support sending JSON. This means that if you use anything +////! but a `GET` or `POST` request with URL-encoded data, it can only work once WASM has loaded. +////! +////! The CBOR encoding is suported for historical reasons; an earlier version of server functions used a URL encoding that +////! didn’t support nested objects like structs or vectors as server function arguments, which CBOR did. But note that the +////! CBOR forms encounter the same issue as `PUT`, `DELETE`, or JSON: they do not degrade gracefully if the WASM version of +////! your app is not available. pub use server_fn::{ - error::ServerFnErrorErr, Encoding, Payload, ServerFnError, + error::ServerFnErrorErr, ServerFnError, }; mod action; @@ -123,250 +123,250 @@ pub use action::*; pub use multi_action::*; extern crate tracing; -#[cfg(any(feature = "ssr", doc))] -use std::{ - collections::HashMap, - sync::{Arc, RwLock}, -}; - -#[cfg(any(feature = "ssr", doc))] -/// A concrete type for a server function. -#[derive(Clone)] -pub struct ServerFnTraitObj(pub server_fn::ServerFnTraitObj<()>); - -#[cfg(any(feature = "ssr", doc))] -impl std::ops::Deref for ServerFnTraitObj { - type Target = server_fn::ServerFnTraitObj<()>; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -#[cfg(any(feature = "ssr", doc))] -impl std::ops::DerefMut for ServerFnTraitObj { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 - } -} - -#[cfg(any(feature = "ssr", doc))] -impl ServerFnTraitObj { - /// Create a new `ServerFnTraitObj` from a `server_fn::ServerFnTraitObj`. - pub const fn from_generic_server_fn( - server_fn: server_fn::ServerFnTraitObj<()>, - ) -> Self { - Self(server_fn) - } -} - -#[cfg(feature = "ssr")] -inventory::collect!(ServerFnTraitObj); - -#[allow(unused)] -type ServerFunction = server_fn::ServerFnTraitObj<()>; - -#[cfg(any(feature = "ssr", doc))] -lazy_static::lazy_static! { - static ref REGISTERED_SERVER_FUNCTIONS: Arc<RwLock<HashMap<&'static str, ServerFnTraitObj>>> = { - let mut map = HashMap::new(); - for server_fn in inventory::iter::<ServerFnTraitObj> { - map.insert(server_fn.0.url(), server_fn.clone()); - } - Arc::new(RwLock::new(map)) - }; -} - -#[cfg(any(feature = "ssr", doc))] -/// The registry of all Leptos server functions. -pub struct LeptosServerFnRegistry; - -#[cfg(any(feature = "ssr", doc))] -impl server_fn::ServerFunctionRegistry<()> for LeptosServerFnRegistry { - type Error = ServerRegistrationFnError; - - /// Server functions are automatically registered on most platforms, (including Linux, macOS, - /// iOS, FreeBSD, Android, and Windows). If you are on another platform, like a WASM server runtime, - /// you should register server functions by calling this `T::register_explicit()`. - fn register_explicit( - prefix: &'static str, - url: &'static str, - server_function: server_fn::SerializedFnTraitObj<()>, - encoding: Encoding, - ) -> Result<(), Self::Error> { - // store it in the hashmap - let mut func_write = REGISTERED_SERVER_FUNCTIONS - .write() - .map_err(|e| ServerRegistrationFnError::Poisoned(e.to_string()))?; - let prev = func_write.insert( - url, - ServerFnTraitObj(server_fn::ServerFnTraitObj::new( - prefix, - url, - encoding, - server_function, - )), - ); - - // if there was already a server function with this key, - // return Err - match prev { - Some(_) => { - Err(ServerRegistrationFnError::AlreadyRegistered(format!( - "There was already a server function registered at {:?}. \ - This can happen if you use the same server function name \ - in two different modules - on `stable` or in `release` mode.", - url - ))) - } - None => Ok(()), - } - } - - /// Returns the server function registered at the given URL, or `None` if no function is registered at that URL. - fn get(url: &str) -> Option<server_fn::ServerFnTraitObj<()>> { - REGISTERED_SERVER_FUNCTIONS - .read() - .ok() - .and_then(|fns| fns.get(url).map(|sf| sf.0.clone())) - } - - /// Returns the server function trait obj registered at the given URL, or `None` if no function is registered at that URL. - fn get_trait_obj(url: &str) -> Option<server_fn::ServerFnTraitObj<()>> { - REGISTERED_SERVER_FUNCTIONS - .read() - .ok() - .and_then(|fns| fns.get(url).map(|sf| sf.0.clone())) - } - /// Return the - fn get_encoding(url: &str) -> Option<Encoding> { - REGISTERED_SERVER_FUNCTIONS - .read() - .ok() - .and_then(|fns| fns.get(url).map(|sf| sf.encoding())) - } - - /// Returns a list of all registered server functions. - fn paths_registered() -> Vec<&'static str> { - REGISTERED_SERVER_FUNCTIONS - .read() - .ok() - .map(|fns| fns.keys().cloned().collect()) - .unwrap_or_default() - } -} - -#[cfg(any(feature = "ssr", doc))] -/// Errors that can occur when registering a server function. -#[derive( - thiserror::Error, Debug, Clone, serde::Serialize, serde::Deserialize, -)] -pub enum ServerRegistrationFnError { - /// The server function is already registered. - #[error("The server function {0} is already registered")] - AlreadyRegistered(String), - /// The server function registry is poisoned. - #[error("The server function registry is poisoned: {0}")] - Poisoned(String), -} - -/// Get a ServerFunction struct containing info about the server fn -#[cfg(any(feature = "ssr", doc))] -pub fn server_fn_by_path(path: &str) -> Option<ServerFnTraitObj> { - REGISTERED_SERVER_FUNCTIONS - .read() - .expect("Server function registry is poisoned") - .get(path) - .cloned() -} - -/// Attempts to find a server function registered at the given path. -/// -/// This can be used by a server to handle the requests, as in the following example (using `actix-web`) -/// -/// ```rust, ignore -/// #[post("{tail:.*}")] -/// async fn handle_server_fns( -/// req: HttpRequest, -/// params: web::Path<String>, -/// body: web::Bytes, -/// ) -> impl Responder { -/// let path = params.into_inner(); -/// let accept_header = req -/// .headers() -/// .get("Accept") -/// .and_then(|value| value.to_str().ok()); -/// if let Some(server_fn) = server_fn_by_path(path.as_str()) { -/// let query = req.query_string().as_bytes(); -/// let data = match &server_fn.encoding { -/// Encoding::Url | Encoding::Cbor => &body, -/// Encoding::GetJSON | Encoding::GetCBOR => query, -/// }; -/// match (server_fn.trait_obj)(data).await { -/// Ok(serialized) => { -/// // if this is Accept: application/json then send a serialized JSON response -/// if let Some("application/json") = accept_header { -/// HttpResponse::Ok().body(serialized) -/// } -/// // otherwise, it's probably a <form> submit or something: redirect back to the referrer -/// else { -/// HttpResponse::SeeOther() -/// .insert_header(("Location", "/")) -/// .content_type("application/json") -/// .body(serialized) -/// } -/// } -/// Err(e) => { -/// eprintln!("server function error: {e:#?}"); -/// HttpResponse::InternalServerError().body(e.to_string()) -/// } -/// } -/// } else { -/// HttpResponse::BadRequest().body(format!("Could not find a server function at that route.")) -/// } -/// } -/// ``` -#[cfg(any(feature = "ssr", doc))] -pub fn server_fn_trait_obj_by_path(path: &str) -> Option<ServerFnTraitObj> { - server_fn::server_fn_trait_obj_by_path::<(), LeptosServerFnRegistry>(path) - .map(ServerFnTraitObj::from_generic_server_fn) -} - -/// Get the Encoding of a server fn if one is registered at that path. Otherwise, return None -#[cfg(any(feature = "ssr", doc))] -pub fn server_fn_encoding_by_path(path: &str) -> Option<Encoding> { - server_fn::server_fn_encoding_by_path::<(), LeptosServerFnRegistry>(path) -} - -/// Returns the set of currently-registered server function paths, for debugging purposes. -#[cfg(any(feature = "ssr", doc))] -pub fn server_fns_by_path() -> Vec<&'static str> { - server_fn::server_fns_by_path::<(), LeptosServerFnRegistry>() -} - -/// Defines a "server function." A server function can be called from the server or the client, -/// but the body of its code will only be run on the server, i.e., if a crate feature `ssr` is enabled. -/// -/// (This follows the same convention as the Leptos framework's distinction between `ssr` for server-side rendering, -/// and `csr` and `hydrate` for client-side rendering and hydration, respectively.) -/// -/// Server functions are created using the `server` macro. -/// -/// The function should be registered by calling `ServerFn::register()`. The set of server functions -/// can be queried on the server for routing purposes by calling [server_fn_by_path]. -/// -/// Technically, the trait is implemented on a type that describes the server function's arguments. -pub trait ServerFn: server_fn::ServerFn<()> { - #[cfg(any(feature = "ssr", doc))] - /// Explicitly registers the server function on platforms that require it, - /// allowing the server to query it by URL. - /// - /// Explicit server function registration is no longer required on most platforms - /// (including Linux, macOS, iOS, FreeBSD, Android, and Windows) - fn register_explicit() -> Result<(), ServerFnError> { - Self::register_in_explicit::<LeptosServerFnRegistry>() - } -} - -impl<T> ServerFn for T where T: server_fn::ServerFn<()> {} +//#[cfg(any(feature = "ssr", doc))] +//use std::{ +// collections::HashMap, +// sync::{Arc, RwLock}, +//}; +// +//#[cfg(any(feature = "ssr", doc))] +///// A concrete type for a server function. +//#[derive(Clone)] +//pub struct ServerFnTraitObj(pub server_fn::ServerFnTraitObj<()>); +// +//#[cfg(any(feature = "ssr", doc))] +//impl std::ops::Deref for ServerFnTraitObj { +// type Target = server_fn::ServerFnTraitObj<()>; +// +// fn deref(&self) -> &Self::Target { +// &self.0 +// } +//} +// +//#[cfg(any(feature = "ssr", doc))] +//impl std::ops::DerefMut for ServerFnTraitObj { +// fn deref_mut(&mut self) -> &mut Self::Target { +// &mut self.0 +// } +//} +// +//#[cfg(any(feature = "ssr", doc))] +//impl ServerFnTraitObj { +// /// Create a new `ServerFnTraitObj` from a `server_fn::ServerFnTraitObj`. +// pub const fn from_generic_server_fn( +// server_fn: server_fn::ServerFnTraitObj<()>, +// ) -> Self { +// Self(server_fn) +// } +//} +// +//#[cfg(feature = "ssr")] +//inventory::collect!(ServerFnTraitObj); +// +//#[allow(unused)] +//type ServerFunction = server_fn::ServerFnTraitObj<()>; +// +//#[cfg(any(feature = "ssr", doc))] +//lazy_static::lazy_static! { +// static ref REGISTERED_SERVER_FUNCTIONS: Arc<RwLock<HashMap<&'static str, ServerFnTraitObj>>> = { +// let mut map = HashMap::new(); +// for server_fn in inventory::iter::<ServerFnTraitObj> { +// map.insert(server_fn.0.url(), server_fn.clone()); +// } +// Arc::new(RwLock::new(map)) +// }; +//} +// +//#[cfg(any(feature = "ssr", doc))] +///// The registry of all Leptos server functions. +//pub struct LeptosServerFnRegistry; +// +//#[cfg(any(feature = "ssr", doc))] +//impl server_fn::ServerFunctionRegistry<()> for LeptosServerFnRegistry { +// type Error = ServerRegistrationFnError; +// +// /// Server functions are automatically registered on most platforms, (including Linux, macOS, +// /// iOS, FreeBSD, Android, and Windows). If you are on another platform, like a WASM server runtime, +// /// you should register server functions by calling this `T::register_explicit()`. +// fn register_explicit( +// prefix: &'static str, +// url: &'static str, +// server_function: server_fn::SerializedFnTraitObj<()>, +// encoding: Encoding, +// ) -> Result<(), Self::Error> { +// // store it in the hashmap +// let mut func_write = REGISTERED_SERVER_FUNCTIONS +// .write() +// .map_err(|e| ServerRegistrationFnError::Poisoned(e.to_string()))?; +// let prev = func_write.insert( +// url, +// ServerFnTraitObj(server_fn::ServerFnTraitObj::new( +// prefix, +// url, +// encoding, +// server_function, +// )), +// ); +// +// // if there was already a server function with this key, +// // return Err +// match prev { +// Some(_) => { +// Err(ServerRegistrationFnError::AlreadyRegistered(format!( +// "There was already a server function registered at {:?}. \ +// This can happen if you use the same server function name \ +// in two different modules +// on `stable` or in `release` mode.", +// url +// ))) +// } +// None => Ok(()), +// } +// } +// +// /// Returns the server function registered at the given URL, or `None` if no function is registered at that URL. +// fn get(url: &str) -> Option<server_fn::ServerFnTraitObj<()>> { +// REGISTERED_SERVER_FUNCTIONS +// .read() +// .ok() +// .and_then(|fns| fns.get(url).map(|sf| sf.0.clone())) +// } +// +// /// Returns the server function trait obj registered at the given URL, or `None` if no function is registered at that URL. +// fn get_trait_obj(url: &str) -> Option<server_fn::ServerFnTraitObj<()>> { +// REGISTERED_SERVER_FUNCTIONS +// .read() +// .ok() +// .and_then(|fns| fns.get(url).map(|sf| sf.0.clone())) +// } +// /// Return the +// fn get_encoding(url: &str) -> Option<Encoding> { +// REGISTERED_SERVER_FUNCTIONS +// .read() +// .ok() +// .and_then(|fns| fns.get(url).map(|sf| sf.encoding())) +// } +// +// /// Returns a list of all registered server functions. +// fn paths_registered() -> Vec<&'static str> { +// REGISTERED_SERVER_FUNCTIONS +// .read() +// .ok() +// .map(|fns| fns.keys().cloned().collect()) +// .unwrap_or_default() +// } +//} +// +//#[cfg(any(feature = "ssr", doc))] +///// Errors that can occur when registering a server function. +//#[derive( +// thiserror::Error, Debug, Clone, serde::Serialize, serde::Deserialize, +//)] +//pub enum ServerRegistrationFnError { +// /// The server function is already registered. +// #[error("The server function {0} is already registered")] +// AlreadyRegistered(String), +// /// The server function registry is poisoned. +// #[error("The server function registry is poisoned: {0}")] +// Poisoned(String), +//} +// +///// Get a ServerFunction struct containing info about the server fn +//#[cfg(any(feature = "ssr", doc))] +//pub fn server_fn_by_path(path: &str) -> Option<ServerFnTraitObj> { +// REGISTERED_SERVER_FUNCTIONS +// .read() +// .expect("Server function registry is poisoned") +// .get(path) +// .cloned() +//} +// +///// Attempts to find a server function registered at the given path. +///// +///// This can be used by a server to handle the requests, as in the following example (using `actix-web`) +///// +///// ```rust, ignore +///// #[post("{tail:.*}")] +///// async fn handle_server_fns( +///// req: HttpRequest, +///// params: web::Path<String>, +///// body: web::Bytes, +///// ) -> impl Responder { +///// let path = params.into_inner(); +///// let accept_header = req +///// .headers() +///// .get("Accept") +///// .and_then(|value| value.to_str().ok()); +///// if let Some(server_fn) = server_fn_by_path(path.as_str()) { +///// let query = req.query_string().as_bytes(); +///// let data = match &server_fn.encoding { +///// Encoding::Url | Encoding::Cbor => &body, +///// Encoding::GetJSON | Encoding::GetCBOR => query, +///// }; +///// match (server_fn.trait_obj)(data).await { +///// Ok(serialized) => { +///// // if this is Accept: application/json then send a serialized JSON response +///// if let Some("application/json") = accept_header { +///// HttpResponse::Ok().body(serialized) +///// } +///// // otherwise, it's probably a <form> submit or something: redirect back to the referrer +///// else { +///// HttpResponse::SeeOther() +///// .insert_header(("Location", "/")) +///// .content_type("application/json") +///// .body(serialized) +///// } +///// } +///// Err(e) => { +///// eprintln!("server function error: {e:#?}"); +///// HttpResponse::InternalServerError().body(e.to_string()) +///// } +///// } +///// } else { +///// HttpResponse::BadRequest().body(format!("Could not find a server function at that route.")) +///// } +///// } +///// ``` +//#[cfg(any(feature = "ssr", doc))] +//pub fn server_fn_trait_obj_by_path(path: &str) -> Option<ServerFnTraitObj> { +// server_fn::server_fn_trait_obj_by_path::<(), LeptosServerFnRegistry>(path) +// .map(ServerFnTraitObj::from_generic_server_fn) +//} +// +///// Get the Encoding of a server fn if one is registered at that path. Otherwise, return None +//#[cfg(any(feature = "ssr", doc))] +//pub fn server_fn_encoding_by_path(path: &str) -> Option<Encoding> { +// server_fn::server_fn_encoding_by_path::<(), LeptosServerFnRegistry>(path) +//} +// +///// Returns the set of currently-registered server function paths, for debugging purposes. +//#[cfg(any(feature = "ssr", doc))] +//pub fn server_fns_by_path() -> Vec<&'static str> { +// server_fn::server_fns_by_path::<(), LeptosServerFnRegistry>() +//} +// +///// Defines a "server function." A server function can be called from the server or the client, +///// but the body of its code will only be run on the server, i.e., if a crate feature `ssr` is enabled. +///// +///// (This follows the same convention as the Leptos framework's distinction between `ssr` for server-side rendering, +///// and `csr` and `hydrate` for client-side rendering and hydration, respectively.) +///// +///// Server functions are created using the `server` macro. +///// +///// The function should be registered by calling `ServerFn::register()`. The set of server functions +///// can be queried on the server for routing purposes by calling [server_fn_by_path]. +///// +///// Technically, the trait is implemented on a type that describes the server function's arguments. +//pub trait ServerFn: server_fn::ServerFn<()> { +// #[cfg(any(feature = "ssr", doc))] +// /// Explicitly registers the server function on platforms that require it, +// /// allowing the server to query it by URL. +// /// +// /// Explicit server function registration is no longer required on most platforms +// /// (including Linux, macOS, iOS, FreeBSD, Android, and Windows) +// fn register_explicit() -> Result<(), ServerFnError> { +// Self::register_in_explicit::<LeptosServerFnRegistry>() +// } +//} +// +//impl<T> ServerFn for T where T: server_fn::ServerFn<()> {} diff --git a/leptos_server/src/multi_action.rs b/leptos_server/src/multi_action.rs index c6c3c23b9..18d480344 100644 --- a/leptos_server/src/multi_action.rs +++ b/leptos_server/src/multi_action.rs @@ -1,4 +1,4 @@ -use crate::{ServerFn, ServerFnError}; +use server_fn::{ServerFn, ServerFnError}; use leptos_reactive::{ create_rw_signal, is_suppressing_resource_load, signal_prelude::*, spawn_local, store_value, untrack, ReadSignal, RwSignal, StoredValue, diff --git a/server_fn/Cargo.toml b/server_fn/Cargo.toml index 9a126aa18..affd31e13 100644 --- a/server_fn/Cargo.toml +++ b/server_fn/Cargo.toml @@ -32,7 +32,7 @@ multer = { version = "3", optional = true } ## output encodings # serde -serde_json = { version = "1", optional = true } +serde_json = "1" futures = "0.3" http = { version = "1", optional = true } ciborium = { version = "0.2", optional = true } @@ -81,7 +81,7 @@ browser = [ "dep:wasm-streams", "dep:wasm-bindgen-futures", ] -json = ["dep:serde_json"] +#json = ["dep:serde_json"] multipart = ["dep:multer"] url = ["dep:serde_qs"] cbor = ["dep:ciborium"] diff --git a/server_fn/src/error.rs b/server_fn/src/error.rs index 00c855669..e517c52c7 100644 --- a/server_fn/src/error.rs +++ b/server_fn/src/error.rs @@ -1,8 +1,55 @@ -use core::fmt::{self, Display}; - +use core::fmt::Display; use serde::{Deserialize, Serialize}; +use std::{error, fmt, ops, sync::Arc}; use thiserror::Error; +/// This is a result type into which any error can be converted, +/// and which can be used directly in your `view`. +/// +/// All errors will be stored as [`struct@Error`]. +pub type Result<T, E = Error> = core::result::Result<T, E>; + +/// A generic wrapper for any error. +#[derive(Debug, Clone)] +#[repr(transparent)] +pub struct Error(Arc<dyn error::Error + Send + Sync>); + +impl Error { + /// Converts the wrapper into the inner reference-counted error. + pub fn into_inner(self) -> Arc<dyn error::Error + Send + Sync> { + Arc::clone(&self.0) + } +} + +impl ops::Deref for Error { + type Target = Arc<dyn error::Error + Send + Sync>; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl<T> From<T> for Error +where + T: std::error::Error + Send + Sync + 'static, +{ + fn from(value: T) -> Self { + Error(Arc::new(value)) + } +} + +impl From<ServerFnError> for Error { + fn from(e: ServerFnError) -> Self { + Error(Arc::new(ServerFnErrorErr::from(e))) + } +} + /// An empty value indicating that there is no custom error type associated /// with this server function. #[derive(Debug, Deserialize, Serialize)] @@ -83,7 +130,12 @@ impl<E: Display + Clone> ViaError<E> for &WrapError<E> { impl<E> ViaError<E> for WrapError<E> { #[track_caller] fn to_server_error(&self) -> ServerFnError<E> { - panic!("At {}, you call `to_server_error()` or use `server_fn_error!` with a value that does not implement `Clone` and either `Error` or `Display`.", std::panic::Location::caller()); + panic!( + "At {}, you call `to_server_error()` or use `server_fn_error!` \ + with a value that does not implement `Clone` and either `Error` \ + or `Display`.", + std::panic::Location::caller() + ); } } @@ -129,19 +181,24 @@ where f, "{}", match self { - ServerFnError::Registration(s) => - format!("error while trying to register the server function: {s}"), - ServerFnError::Request(s) => - format!("error reaching server to call server function: {s}"), - ServerFnError::ServerError(s) => format!("error running server function: {s}"), + ServerFnError::Registration(s) => format!( + "error while trying to register the server function: {s}" + ), + ServerFnError::Request(s) => format!( + "error reaching server to call server function: {s}" + ), + ServerFnError::ServerError(s) => + format!("error running server function: {s}"), ServerFnError::Deserialization(s) => format!("error deserializing server function results: {s}"), ServerFnError::Serialization(s) => format!("error serializing server function arguments: {s}"), - ServerFnError::Args(s) => - format!("error deserializing server function arguments: {s}"), + ServerFnError::Args(s) => format!( + "error deserializing server function arguments: {s}" + ), ServerFnError::MissingArg(s) => format!("missing argument {s}"), - ServerFnError::Response(s) => format!("error generating HTTP response: {s}"), + ServerFnError::Response(s) => + format!("error generating HTTP response: {s}"), ServerFnError::WrappedServerError(e) => format!("{}", e), } ) @@ -202,14 +259,26 @@ pub enum ServerFnErrorErr<E = NoCustomError> { impl<CustErr> From<ServerFnError<CustErr>> for ServerFnErrorErr<CustErr> { fn from(value: ServerFnError<CustErr>) -> Self { match value { - ServerFnError::Registration(value) => ServerFnErrorErr::Registration(value), + ServerFnError::Registration(value) => { + ServerFnErrorErr::Registration(value) + } ServerFnError::Request(value) => ServerFnErrorErr::Request(value), - ServerFnError::ServerError(value) => ServerFnErrorErr::ServerError(value), - ServerFnError::Deserialization(value) => ServerFnErrorErr::Deserialization(value), - ServerFnError::Serialization(value) => ServerFnErrorErr::Serialization(value), + ServerFnError::ServerError(value) => { + ServerFnErrorErr::ServerError(value) + } + ServerFnError::Deserialization(value) => { + ServerFnErrorErr::Deserialization(value) + } + ServerFnError::Serialization(value) => { + ServerFnErrorErr::Serialization(value) + } ServerFnError::Args(value) => ServerFnErrorErr::Args(value), - ServerFnError::MissingArg(value) => ServerFnErrorErr::MissingArg(value), - ServerFnError::WrappedServerError(value) => ServerFnErrorErr::WrappedServerError(value), + ServerFnError::MissingArg(value) => { + ServerFnErrorErr::MissingArg(value) + } + ServerFnError::WrappedServerError(value) => { + ServerFnErrorErr::WrappedServerError(value) + } ServerFnError::Response(value) => ServerFnErrorErr::Response(value), } }