Skip to content

Commit

Permalink
feat: add axum integration (#146)
Browse files Browse the repository at this point in the history
* 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 <arctic_hen7@pm.me>

* 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 <37006668+lukechu10@users.noreply.github.com>
  • Loading branch information
arctic-hen7 and lukechu10 committed Jun 2, 2022
1 parent 51f2b2f commit dbe8207
Show file tree
Hide file tree
Showing 14 changed files with 493 additions and 4 deletions.
5 changes: 4 additions & 1 deletion bonnie.toml
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,8 @@ test.subcommands.example.args = [ "category", "example", "integration" ]
test.subcommands.example.desc = "tests a single example with the given integration (assumes geckodriver running in background), use `--headless` to run headlessly"
test.subcommands.example-all-integrations.cmd = [
"rust-script scripts/test.rs %category %example actix-web %%",
"rust-script scripts/test.rs %category %example warp %%"
"rust-script scripts/test.rs %category %example warp %%",
"rust-script scripts/test.rs %category %example axum %%"
]
test.subcommands.example-all-integrations.args = [ "category", "example" ]
test.subcommands.example-all-integrations.desc = "tests a single example with all integrations (assumes geckodriver running in background), use `--headless` to run headlessly"
Expand Down Expand Up @@ -221,6 +222,8 @@ publish.cmd = [
"cd ../perseus-actix-web",
"cargo publish %%",
"cd ../perseus-warp",
"cargo publish %%",
"cd ../perseus-axum",
"cargo publish %%"
]
publish.desc = "publishes all packages to crates.io (needs branch 'stable', Linux only)"
Expand Down
4 changes: 3 additions & 1 deletion examples/core/basic/.perseus/server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,21 @@ edition = "2021"
perseus = { path = "../../../../../packages/perseus", features = [ "server-side" ] }
perseus-actix-web = { path = "../../../../../packages/perseus-actix-web", optional = true }
perseus-warp = { path = "../../../../../packages/perseus-warp", optional = true }
perseus-axum = { path = "../../../../../packages/perseus-axum", optional = true }
perseus-engine = { path = "../" }
actix-web = { version = "=4.0.0-rc.3", optional = true }
actix-http = { version = "=3.0.0-rc.2", optional = true } # Without this, Actix can introduce breaking changes in a dependency tree
# actix-router = { version = "=0.5.0-rc.3", optional = true }
futures = "0.3"
warp = { package = "warp-fix-171", version = "0.3", optional = true }
# TODO Choose features here
tokio = { version = "1", optional = true, features = [ "macros", "rt-multi-thread" ] } # We don't need this for Actix Web
axum = { version = "0.5", optional = true }

# This binary can use any of the server integrations
[features]
integration-actix-web = [ "perseus-actix-web", "actix-web", "actix-http" ]
integration-warp = [ "perseus-warp", "warp", "tokio" ]
integration-axum = [ "perseus-axum", "axum", "tokio" ]

default = [ "integration-warp" ]

Expand Down
20 changes: 20 additions & 0 deletions examples/core/basic/.perseus/server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,26 @@ async fn main() {
warp::serve(routes).run(addr).await;
}

// Integration: Axum
#[cfg(feature = "integration-axum")]
#[tokio::main]
async fn main() {
use perseus_axum::get_router;
use std::net::SocketAddr;

let is_standalone = get_standalone_and_act();
let props = get_props(is_standalone);
let (host, port) = get_host_and_port();
let addr: SocketAddr = format!("{}:{}", host, port)
.parse()
.expect("Invalid address provided to bind to.");
let app = block_on(get_router(props));
axum::Server::bind(&addr)
.serve(app.into_make_service())
.await
.unwrap();
}

/// 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.
fn get_standalone_and_act() -> bool {
// 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
Expand Down
28 changes: 28 additions & 0 deletions packages/perseus-axum/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
[package]
name = "perseus-axum"
version = "0.3.5"
edition = "2021"
description = "An integration that makes the Perseus frontend framework easy to use with Axum."
authors = ["arctic_hen7 <arctic_hen7@pm.me>"]
license = "MIT"
repository = "https://github.com/arctic-hen7/perseus"
homepage = "https://arctic-hen7.github.io/perseus"
readme = "./README.md"
keywords = ["wasm", "frontend", "webdev", "ssg", "ssr"]
categories = ["wasm", "web-programming::http-server", "development-tools", "asynchronous", "gui"]

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
perseus = { path = "../perseus", version = "0.4.0-beta.1" }
axum = "0.5"
tower = "0.4"
tower-http = { version = "0.3", features = [ "fs" ] }
urlencoding = "2.1"
serde = "1"
serde_json = "1"
thiserror = "1"
fmterr = "0.1"
futures = "0.3"
sycamore = { version = "=0.8.0-beta.6", features = ["ssr"] }
closure = "0.3"
5 changes: 5 additions & 0 deletions packages/perseus-axum/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Perseus Axum Integration

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!

If you're new to Perseus, you should check out [the core package](https://github.com/arctic-hen7/perseus) first.
1 change: 1 addition & 0 deletions packages/perseus-axum/README.proj.md
140 changes: 140 additions & 0 deletions packages/perseus-axum/src/initial_load.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
use axum::{
body::Body,
http::{HeaderMap, StatusCode},
response::Html,
};
use fmterr::fmt_err;
use perseus::{
errors::err_to_status_code,
internal::{
get_path_prefix_server,
i18n::{TranslationsManager, Translator},
router::{match_route_atomic, RouteInfoAtomic, RouteVerdictAtomic},
serve::{
build_error_page, get_page_for_template, get_path_slice, GetPageProps, HtmlShell,
ServerOptions,
},
},
stores::{ImmutableStore, MutableStore},
ErrorPages, Request, SsrNode,
};
use std::{collections::HashMap, rc::Rc, sync::Arc};

/// Builds on the internal Perseus primitives to provide a utility function that returns a `Response` automatically.
fn return_error_page(
url: &str,
status: u16,
// This should already have been transformed into a string (with a source chain etc.)
err: &str,
translator: Option<Rc<Translator>>,
error_pages: &ErrorPages<SsrNode>,
html_shell: &HtmlShell,
) -> (StatusCode, HeaderMap, Html<String>) {
let html = build_error_page(url, status, err, translator, error_pages, html_shell);
(
StatusCode::from_u16(status).unwrap(),
HeaderMap::new(),
Html(html),
)
}

/// The handler for calls to any actual pages (first-time visits), which will render the appropriate HTML and then interpolate it into
/// the app shell.
#[allow(clippy::too_many_arguments)] // As for `page_data_handler`, we don't have a choice
pub async fn initial_load_handler<M: MutableStore, T: TranslationsManager>(
http_req: perseus::http::Request<Body>,
opts: Arc<ServerOptions>,
html_shell: Arc<HtmlShell>,
render_cfg: Arc<HashMap<String, String>>,
immutable_store: Arc<ImmutableStore>,
mutable_store: Arc<M>,
translations_manager: Arc<T>,
global_state: Arc<Option<String>>,
) -> (StatusCode, HeaderMap, Html<String>) {
let path = http_req.uri().path().to_string();
let http_req = Request::from_parts(http_req.into_parts().0, ());

let templates = &opts.templates_map;
let error_pages = &opts.error_pages;
let path_slice = get_path_slice(&path);
// Create a closure to make returning error pages easier (most have the same data)
let html_err = |status: u16, err: &str| {
return return_error_page(&path, status, err, None, error_pages, html_shell.as_ref());
};

// Run the routing algorithms on the path to figure out which template we need
let verdict = match_route_atomic(&path_slice, render_cfg.as_ref(), templates, &opts.locales);
match verdict {
// If this is the outcome, we know that the locale is supported and the like
// Given that all this is valid from the client, any errors are 500s
RouteVerdictAtomic::Found(RouteInfoAtomic {
path, // Used for asset fetching, this is what we'd get in `page_data`
template, // The actual template to use
locale,
was_incremental_match,
}) => {
// Actually render the page as we would if this weren't an initial load
let page_data = get_page_for_template(
GetPageProps::<M, T> {
raw_path: &path,
locale: &locale,
was_incremental_match,
req: http_req,
global_state: &global_state,
immutable_store: &immutable_store,
mutable_store: &mutable_store,
translations_manager: &translations_manager,
},
template,
)
.await;
let page_data = match page_data {
Ok(page_data) => page_data,
// We parse the error to return an appropriate status code
Err(err) => {
return html_err(err_to_status_code(&err), &fmt_err(&err));
}
};

let final_html = html_shell
.as_ref()
.clone()
.page_data(&page_data, &global_state)
.to_string();

// http_res.content_type("text/html");
// Generate and add HTTP headers
let mut header_map = HeaderMap::new();
for (key, val) in template.get_headers(page_data.state) {
header_map.insert(key.unwrap(), val);
}

(StatusCode::OK, header_map, Html(final_html))
}
// 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/...`
RouteVerdictAtomic::LocaleDetection(path) => {
// We use a `302 Found` status code to indicate a redirect
// We 'should' generate a `Location` field for the redirect, but it's not RFC-mandated, so we can use the app shell
(
StatusCode::FOUND,
HeaderMap::new(),
Html(
html_shell
.as_ref()
.clone()
.locale_redirection_fallback(
// We'll redirect the user to the default locale
&format!(
"{}/{}/{}",
get_path_prefix_server(),
opts.locales.default,
path
),
)
.to_string(),
),
)
}
RouteVerdictAtomic::NotFound => html_err(404, "page not found"),
}
}
18 changes: 18 additions & 0 deletions packages/perseus-axum/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#![doc = include_str!("../README.proj.md")]
/*!
## Packages
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
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).
*/

#![deny(missing_docs)]

// This integration doesn't need to convert request types, because we can get them straight out of Axum and then just delete the bodies
mod initial_load;
mod page_data;
mod router;
mod translations;

pub use crate::router::get_router;
pub use perseus::internal::serve::ServerOptions;
112 changes: 112 additions & 0 deletions packages/perseus-axum/src/page_data.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
use axum::{
body::Body,
extract::{Path, Query},
http::{HeaderMap, StatusCode},
};
use fmterr::fmt_err;
use perseus::{
errors::err_to_status_code,
internal::{
i18n::TranslationsManager,
serve::{get_page_for_template, GetPageProps, ServerOptions},
},
stores::{ImmutableStore, MutableStore},
Request,
};
use serde::Deserialize;
use std::sync::Arc;

// 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
#[derive(Deserialize)]
pub struct PageDataReq {
pub template_name: String,
pub was_incremental_match: bool,
}

#[allow(clippy::too_many_arguments)] // Because of how Axum extractors work, we don't exactly have a choice
pub async fn page_handler<M: MutableStore, T: TranslationsManager>(
Path(path_parts): Path<Vec<String>>, // From this, we can extract the locale and the path tail (the page path, which *does* have slashes)
Query(PageDataReq {
template_name,
was_incremental_match,
}): Query<PageDataReq>,
// This works without any conversion because Axum allows us to directly get an `http::Request` out!
http_req: perseus::http::Request<Body>,
opts: Arc<ServerOptions>,
immutable_store: Arc<ImmutableStore>,
mutable_store: Arc<M>,
translations_manager: Arc<T>,
global_state: Arc<Option<String>>,
) -> (StatusCode, HeaderMap, String) {
// Separate the locale from the rest of the page name
let locale = &path_parts[0];
let path = path_parts[1..]
.iter()
.map(|x| x.as_str())
.collect::<Vec<&str>>()
.join("/");
// Axum's paths have leading slashes
let path = path.strip_prefix('/').unwrap();

let templates = &opts.templates_map;
// Check if the locale is supported
if opts.locales.is_supported(locale) {
// Warp doesn't let us specify that all paths should end in `.json`, so we'll manually strip that
let path = path.strip_suffix(".json").unwrap();
// Get the template to use
let template = templates.get(&template_name);
let template = match template {
Some(template) => template,
None => {
// We know the template has been pre-routed and should exist, so any failure here is a 500
return (
StatusCode::INTERNAL_SERVER_ERROR,
HeaderMap::new(),
"template not found".to_string(),
);
}
};
// Convert the request into one palatable for Perseus (which doesn't have the body attached)
let http_req = Request::from_parts(http_req.into_parts().0, ());
let page_data = get_page_for_template(
GetPageProps::<M, T> {
raw_path: path,
locale,
was_incremental_match,
req: http_req,
global_state: &global_state,
immutable_store: &immutable_store,
mutable_store: &mutable_store,
translations_manager: &translations_manager,
},
template,
)
.await;
match page_data {
Ok(page_data) => {
// http_res.content_type("text/html");
// Generate and add HTTP headers
let mut header_map = HeaderMap::new();
for (key, val) in template.get_headers(page_data.state.clone()) {
header_map.insert(key.unwrap(), val);
}

let page_data_str = serde_json::to_string(&page_data).unwrap();

(StatusCode::OK, header_map, page_data_str)
}
// We parse the error to return an appropriate status code
Err(err) => (
StatusCode::from_u16(err_to_status_code(&err)).unwrap(),
HeaderMap::new(),
fmt_err(&err),
),
}
} else {
(
StatusCode::NOT_FOUND,
HeaderMap::new(),
"locale not supported".to_string(),
)
}
}
Loading

0 comments on commit dbe8207

Please sign in to comment.