Skip to content

Commit dbe8207

Browse files
feat: add axum integration (#146)
* chore: updated versions and editions Sycamore v0.8 only runs on Rust 2021. * fix: fixed some rust 2021 syntax issues * fix: fixed easy errors in `perseus` crate Still have to do: - `router/` - `template/` - `shell.rs` - One tricky error in `error_pages.rs` * fix: fixed all errors in `perseus` crate I removed the troublesome function in `error_pages.rs`, so that may bite me soon. * fix: fixed clippy lints * fix: fixed macros and made `basic` example work * fix: partial fixes for `rx_state` Still have problems with `bind:value` (as we will in all the state platform examples for now). * fix: updated state examples (errors persist) The remaining errors will be fixed after `make_rx` can work with non-`Rc` `Signal`s. * fix: updated all examples (lifetime errors persist) * fix: fixed engine Nothing works without hydration disabled though... * refactor: made renderers use top-level router context This *should* make the state platform work with lifetimes. * feat: rewrote render context to use `Signal`s * fix: made macros work with the new render context logic * fix: updated unreactive macros and example * chore: tmp commit before rollback * revert: revert to before axing `RcSignal`s I have been shown a much better way of achieving the same outcome. * revert: return to previous changes It will be easier to manually undo changes to make sure we preserve some good things. This reverts commit 15187b5. * feat: moved back to `RcSignal`s This avoids a huge number of lifetime issues, and actually ends up being more performant, without compromising on ergonomics. * feat: made `struct` given to user's template use `&'a RcSignal<T>` This should make Perseus several orders of magnitude more ergonomic, in line with Sycamore's new no-clones system! * fix: fixed global state functionality in the macros This requires an irritating change to import practices unfortunately, but the convenience and ergonomics are worth it. * fix: fixed lifetimes errors in all examples * fix: fixed all lifetimes issues This also involved some minor changes to the macros to fix some nested state issues. * fix: fixed nested state references This improves ergonomics and makes the auth example compile. * fix: fixed hydration by not inserting hydration keys in `<head>` (#137) * refactor: simplify provide_context_signal_replace Also slightly improves performance in only making a single call to use_context * fix: do not insert hydration keys in the head string * chore: remove perseus/hydrate feature from Cargo.toml * chore: merge imports for consistent code style * fix: update sycamore to v0.8.0-beta.5 and remove workaround Co-authored-by: arctic_hen7 <[email protected]> * chore: updated deps after #137 These were just for the demos that weren't ready at the time of the PR. * chore: re-added `hydrate` feature to `basic` example Hydration still doesn't work in the `auth` example. * chore: removed unused dep * fix: fixed doc tests issue * fix: fixed naming of `PerseusRoot` (was wrongly `perseus_root`) * feat: updated `index_view` example * feat: added axum integration This is all untested as yet, but everything *should* work. * chore: updated to latest sycamore beta This should fix the issues with the `body` element. * fix: fixed an imports issue with latest sycamore beta * fix: fixed types to make i18n work * test: fixed `rx_state` tests for slightly updated structure * fix: ignored a failing doctest * docs: updated security.md for next beta version * docs: added new docs for v0.4.x Also locked the old v0.3.4-5 docs to a specific commit hash, which keeps the examples there safe to use. * feat: integrated axum with other integrations There are still some issues with shared state though that make this completely unusable. * fix: fixed shared state issues No clue why using extensions didn't work, but now we're using closure captures, which are compile-time checked anyway. * fix: made static content work Just some simple errors made this fail. Hopefully, all tests should now pass... Co-authored-by: Luke Chu <[email protected]>
1 parent 51f2b2f commit dbe8207

File tree

14 files changed

+493
-4
lines changed

14 files changed

+493
-4
lines changed

bonnie.toml

+4-1
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,8 @@ test.subcommands.example.args = [ "category", "example", "integration" ]
179179
test.subcommands.example.desc = "tests a single example with the given integration (assumes geckodriver running in background), use `--headless` to run headlessly"
180180
test.subcommands.example-all-integrations.cmd = [
181181
"rust-script scripts/test.rs %category %example actix-web %%",
182-
"rust-script scripts/test.rs %category %example warp %%"
182+
"rust-script scripts/test.rs %category %example warp %%",
183+
"rust-script scripts/test.rs %category %example axum %%"
183184
]
184185
test.subcommands.example-all-integrations.args = [ "category", "example" ]
185186
test.subcommands.example-all-integrations.desc = "tests a single example with all integrations (assumes geckodriver running in background), use `--headless` to run headlessly"
@@ -221,6 +222,8 @@ publish.cmd = [
221222
"cd ../perseus-actix-web",
222223
"cargo publish %%",
223224
"cd ../perseus-warp",
225+
"cargo publish %%",
226+
"cd ../perseus-axum",
224227
"cargo publish %%"
225228
]
226229
publish.desc = "publishes all packages to crates.io (needs branch 'stable', Linux only)"

examples/core/basic/.perseus/server/Cargo.toml

+3-1
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,21 @@ edition = "2021"
1212
perseus = { path = "../../../../../packages/perseus", features = [ "server-side" ] }
1313
perseus-actix-web = { path = "../../../../../packages/perseus-actix-web", optional = true }
1414
perseus-warp = { path = "../../../../../packages/perseus-warp", optional = true }
15+
perseus-axum = { path = "../../../../../packages/perseus-axum", optional = true }
1516
perseus-engine = { path = "../" }
1617
actix-web = { version = "=4.0.0-rc.3", optional = true }
1718
actix-http = { version = "=3.0.0-rc.2", optional = true } # Without this, Actix can introduce breaking changes in a dependency tree
1819
# actix-router = { version = "=0.5.0-rc.3", optional = true }
1920
futures = "0.3"
2021
warp = { package = "warp-fix-171", version = "0.3", optional = true }
21-
# TODO Choose features here
2222
tokio = { version = "1", optional = true, features = [ "macros", "rt-multi-thread" ] } # We don't need this for Actix Web
23+
axum = { version = "0.5", optional = true }
2324

2425
# This binary can use any of the server integrations
2526
[features]
2627
integration-actix-web = [ "perseus-actix-web", "actix-web", "actix-http" ]
2728
integration-warp = [ "perseus-warp", "warp", "tokio" ]
29+
integration-axum = [ "perseus-axum", "axum", "tokio" ]
2830

2931
default = [ "integration-warp" ]
3032

examples/core/basic/.perseus/server/src/main.rs

+20
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,26 @@ async fn main() {
4848
warp::serve(routes).run(addr).await;
4949
}
5050

51+
// Integration: Axum
52+
#[cfg(feature = "integration-axum")]
53+
#[tokio::main]
54+
async fn main() {
55+
use perseus_axum::get_router;
56+
use std::net::SocketAddr;
57+
58+
let is_standalone = get_standalone_and_act();
59+
let props = get_props(is_standalone);
60+
let (host, port) = get_host_and_port();
61+
let addr: SocketAddr = format!("{}:{}", host, port)
62+
.parse()
63+
.expect("Invalid address provided to bind to.");
64+
let app = block_on(get_router(props));
65+
axum::Server::bind(&addr)
66+
.serve(app.into_make_service())
67+
.await
68+
.unwrap();
69+
}
70+
5171
/// Determines whether or not we're operating in standalone mode, and acts accordingly. This MUST be executed in the parent thread, as it switches the current directory.
5272
fn get_standalone_and_act() -> bool {
5373
// So we don't have to define a different `FsConfigManager` just for the server, we shift the execution context to the same level as everything else

packages/perseus-axum/Cargo.toml

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
[package]
2+
name = "perseus-axum"
3+
version = "0.3.5"
4+
edition = "2021"
5+
description = "An integration that makes the Perseus frontend framework easy to use with Axum."
6+
authors = ["arctic_hen7 <[email protected]>"]
7+
license = "MIT"
8+
repository = "https://github.com/arctic-hen7/perseus"
9+
homepage = "https://arctic-hen7.github.io/perseus"
10+
readme = "./README.md"
11+
keywords = ["wasm", "frontend", "webdev", "ssg", "ssr"]
12+
categories = ["wasm", "web-programming::http-server", "development-tools", "asynchronous", "gui"]
13+
14+
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
15+
16+
[dependencies]
17+
perseus = { path = "../perseus", version = "0.4.0-beta.1" }
18+
axum = "0.5"
19+
tower = "0.4"
20+
tower-http = { version = "0.3", features = [ "fs" ] }
21+
urlencoding = "2.1"
22+
serde = "1"
23+
serde_json = "1"
24+
thiserror = "1"
25+
fmterr = "0.1"
26+
futures = "0.3"
27+
sycamore = { version = "=0.8.0-beta.6", features = ["ssr"] }
28+
closure = "0.3"

packages/perseus-axum/README.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Perseus Axum Integration
2+
3+
This is the official [Perseus](https://github.com/arctic-hen7/perseus) integration for making serving your apps on [Axum](https://docs.rs/axum) significantly easier!
4+
5+
If you're new to Perseus, you should check out [the core package](https://github.com/arctic-hen7/perseus) first.

packages/perseus-axum/README.proj.md

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../README.md
+140
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
use axum::{
2+
body::Body,
3+
http::{HeaderMap, StatusCode},
4+
response::Html,
5+
};
6+
use fmterr::fmt_err;
7+
use perseus::{
8+
errors::err_to_status_code,
9+
internal::{
10+
get_path_prefix_server,
11+
i18n::{TranslationsManager, Translator},
12+
router::{match_route_atomic, RouteInfoAtomic, RouteVerdictAtomic},
13+
serve::{
14+
build_error_page, get_page_for_template, get_path_slice, GetPageProps, HtmlShell,
15+
ServerOptions,
16+
},
17+
},
18+
stores::{ImmutableStore, MutableStore},
19+
ErrorPages, Request, SsrNode,
20+
};
21+
use std::{collections::HashMap, rc::Rc, sync::Arc};
22+
23+
/// Builds on the internal Perseus primitives to provide a utility function that returns a `Response` automatically.
24+
fn return_error_page(
25+
url: &str,
26+
status: u16,
27+
// This should already have been transformed into a string (with a source chain etc.)
28+
err: &str,
29+
translator: Option<Rc<Translator>>,
30+
error_pages: &ErrorPages<SsrNode>,
31+
html_shell: &HtmlShell,
32+
) -> (StatusCode, HeaderMap, Html<String>) {
33+
let html = build_error_page(url, status, err, translator, error_pages, html_shell);
34+
(
35+
StatusCode::from_u16(status).unwrap(),
36+
HeaderMap::new(),
37+
Html(html),
38+
)
39+
}
40+
41+
/// The handler for calls to any actual pages (first-time visits), which will render the appropriate HTML and then interpolate it into
42+
/// the app shell.
43+
#[allow(clippy::too_many_arguments)] // As for `page_data_handler`, we don't have a choice
44+
pub async fn initial_load_handler<M: MutableStore, T: TranslationsManager>(
45+
http_req: perseus::http::Request<Body>,
46+
opts: Arc<ServerOptions>,
47+
html_shell: Arc<HtmlShell>,
48+
render_cfg: Arc<HashMap<String, String>>,
49+
immutable_store: Arc<ImmutableStore>,
50+
mutable_store: Arc<M>,
51+
translations_manager: Arc<T>,
52+
global_state: Arc<Option<String>>,
53+
) -> (StatusCode, HeaderMap, Html<String>) {
54+
let path = http_req.uri().path().to_string();
55+
let http_req = Request::from_parts(http_req.into_parts().0, ());
56+
57+
let templates = &opts.templates_map;
58+
let error_pages = &opts.error_pages;
59+
let path_slice = get_path_slice(&path);
60+
// Create a closure to make returning error pages easier (most have the same data)
61+
let html_err = |status: u16, err: &str| {
62+
return return_error_page(&path, status, err, None, error_pages, html_shell.as_ref());
63+
};
64+
65+
// Run the routing algorithms on the path to figure out which template we need
66+
let verdict = match_route_atomic(&path_slice, render_cfg.as_ref(), templates, &opts.locales);
67+
match verdict {
68+
// If this is the outcome, we know that the locale is supported and the like
69+
// Given that all this is valid from the client, any errors are 500s
70+
RouteVerdictAtomic::Found(RouteInfoAtomic {
71+
path, // Used for asset fetching, this is what we'd get in `page_data`
72+
template, // The actual template to use
73+
locale,
74+
was_incremental_match,
75+
}) => {
76+
// Actually render the page as we would if this weren't an initial load
77+
let page_data = get_page_for_template(
78+
GetPageProps::<M, T> {
79+
raw_path: &path,
80+
locale: &locale,
81+
was_incremental_match,
82+
req: http_req,
83+
global_state: &global_state,
84+
immutable_store: &immutable_store,
85+
mutable_store: &mutable_store,
86+
translations_manager: &translations_manager,
87+
},
88+
template,
89+
)
90+
.await;
91+
let page_data = match page_data {
92+
Ok(page_data) => page_data,
93+
// We parse the error to return an appropriate status code
94+
Err(err) => {
95+
return html_err(err_to_status_code(&err), &fmt_err(&err));
96+
}
97+
};
98+
99+
let final_html = html_shell
100+
.as_ref()
101+
.clone()
102+
.page_data(&page_data, &global_state)
103+
.to_string();
104+
105+
// http_res.content_type("text/html");
106+
// Generate and add HTTP headers
107+
let mut header_map = HeaderMap::new();
108+
for (key, val) in template.get_headers(page_data.state) {
109+
header_map.insert(key.unwrap(), val);
110+
}
111+
112+
(StatusCode::OK, header_map, Html(final_html))
113+
}
114+
// For locale detection, we don't know the user's locale, so there's not much we can do except send down the app shell, which will do the rest and fetch from `.perseus/page/...`
115+
RouteVerdictAtomic::LocaleDetection(path) => {
116+
// We use a `302 Found` status code to indicate a redirect
117+
// We 'should' generate a `Location` field for the redirect, but it's not RFC-mandated, so we can use the app shell
118+
(
119+
StatusCode::FOUND,
120+
HeaderMap::new(),
121+
Html(
122+
html_shell
123+
.as_ref()
124+
.clone()
125+
.locale_redirection_fallback(
126+
// We'll redirect the user to the default locale
127+
&format!(
128+
"{}/{}/{}",
129+
get_path_prefix_server(),
130+
opts.locales.default,
131+
path
132+
),
133+
)
134+
.to_string(),
135+
),
136+
)
137+
}
138+
RouteVerdictAtomic::NotFound => html_err(404, "page not found"),
139+
}
140+
}

packages/perseus-axum/src/lib.rs

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
#![doc = include_str!("../README.proj.md")]
2+
/*!
3+
## Packages
4+
5+
This is the API documentation for the `perseus-axum` package, which allows Perseus apps to run on Axum. Note that Perseus mostly uses [the book](https://arctic-hen7.github.io/perseus/en-US) for
6+
documentation, and this should mostly be used as a secondary reference source. You can also find full usage examples [here](https://github.com/arctic-hen7/perseus/tree/main/examples).
7+
*/
8+
9+
#![deny(missing_docs)]
10+
11+
// This integration doesn't need to convert request types, because we can get them straight out of Axum and then just delete the bodies
12+
mod initial_load;
13+
mod page_data;
14+
mod router;
15+
mod translations;
16+
17+
pub use crate::router::get_router;
18+
pub use perseus::internal::serve::ServerOptions;
+112
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
use axum::{
2+
body::Body,
3+
extract::{Path, Query},
4+
http::{HeaderMap, StatusCode},
5+
};
6+
use fmterr::fmt_err;
7+
use perseus::{
8+
errors::err_to_status_code,
9+
internal::{
10+
i18n::TranslationsManager,
11+
serve::{get_page_for_template, GetPageProps, ServerOptions},
12+
},
13+
stores::{ImmutableStore, MutableStore},
14+
Request,
15+
};
16+
use serde::Deserialize;
17+
use std::sync::Arc;
18+
19+
// Note: this is the same as for the Actix Web integration, but other frameworks may handle parsing query parameters differntly, so this shouldn't be integrated into the core library
20+
#[derive(Deserialize)]
21+
pub struct PageDataReq {
22+
pub template_name: String,
23+
pub was_incremental_match: bool,
24+
}
25+
26+
#[allow(clippy::too_many_arguments)] // Because of how Axum extractors work, we don't exactly have a choice
27+
pub async fn page_handler<M: MutableStore, T: TranslationsManager>(
28+
Path(path_parts): Path<Vec<String>>, // From this, we can extract the locale and the path tail (the page path, which *does* have slashes)
29+
Query(PageDataReq {
30+
template_name,
31+
was_incremental_match,
32+
}): Query<PageDataReq>,
33+
// This works without any conversion because Axum allows us to directly get an `http::Request` out!
34+
http_req: perseus::http::Request<Body>,
35+
opts: Arc<ServerOptions>,
36+
immutable_store: Arc<ImmutableStore>,
37+
mutable_store: Arc<M>,
38+
translations_manager: Arc<T>,
39+
global_state: Arc<Option<String>>,
40+
) -> (StatusCode, HeaderMap, String) {
41+
// Separate the locale from the rest of the page name
42+
let locale = &path_parts[0];
43+
let path = path_parts[1..]
44+
.iter()
45+
.map(|x| x.as_str())
46+
.collect::<Vec<&str>>()
47+
.join("/");
48+
// Axum's paths have leading slashes
49+
let path = path.strip_prefix('/').unwrap();
50+
51+
let templates = &opts.templates_map;
52+
// Check if the locale is supported
53+
if opts.locales.is_supported(locale) {
54+
// Warp doesn't let us specify that all paths should end in `.json`, so we'll manually strip that
55+
let path = path.strip_suffix(".json").unwrap();
56+
// Get the template to use
57+
let template = templates.get(&template_name);
58+
let template = match template {
59+
Some(template) => template,
60+
None => {
61+
// We know the template has been pre-routed and should exist, so any failure here is a 500
62+
return (
63+
StatusCode::INTERNAL_SERVER_ERROR,
64+
HeaderMap::new(),
65+
"template not found".to_string(),
66+
);
67+
}
68+
};
69+
// Convert the request into one palatable for Perseus (which doesn't have the body attached)
70+
let http_req = Request::from_parts(http_req.into_parts().0, ());
71+
let page_data = get_page_for_template(
72+
GetPageProps::<M, T> {
73+
raw_path: path,
74+
locale,
75+
was_incremental_match,
76+
req: http_req,
77+
global_state: &global_state,
78+
immutable_store: &immutable_store,
79+
mutable_store: &mutable_store,
80+
translations_manager: &translations_manager,
81+
},
82+
template,
83+
)
84+
.await;
85+
match page_data {
86+
Ok(page_data) => {
87+
// http_res.content_type("text/html");
88+
// Generate and add HTTP headers
89+
let mut header_map = HeaderMap::new();
90+
for (key, val) in template.get_headers(page_data.state.clone()) {
91+
header_map.insert(key.unwrap(), val);
92+
}
93+
94+
let page_data_str = serde_json::to_string(&page_data).unwrap();
95+
96+
(StatusCode::OK, header_map, page_data_str)
97+
}
98+
// We parse the error to return an appropriate status code
99+
Err(err) => (
100+
StatusCode::from_u16(err_to_status_code(&err)).unwrap(),
101+
HeaderMap::new(),
102+
fmt_err(&err),
103+
),
104+
}
105+
} else {
106+
(
107+
StatusCode::NOT_FOUND,
108+
HeaderMap::new(),
109+
"locale not supported".to_string(),
110+
)
111+
}
112+
}

0 commit comments

Comments
 (0)