Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add SpaRouter #904

Merged
merged 2 commits into from
Apr 3, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions axum-extra/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@ and this project adheres to [Semantic Versioning].

# Unreleased

- **added:** Re-export `SameSite` and `Expiration` from the `cookie` crate.
- **added:** Re-export `SameSite` and `Expiration` from the `cookie` crate ([#898])
- **fixed:** Fix `SignedCookieJar` when using custom key types ([#899])
- **added:** `PrivateCookieJar` for managing private cookies
- **added:** Add `PrivateCookieJar` for managing private cookies ([#900])
- **added:** Add `SpaRouter` for routing setups commonly used for single page applications

[#898]: https://github.com/tokio-rs/axum/pull/898
[#899]: https://github.com/tokio-rs/axum/pull/899
[#900]: https://github.com/tokio-rs/axum/pull/900

# 0.2.0 (31. March, 2022)

Expand Down
2 changes: 2 additions & 0 deletions axum-extra/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ default = []
erased-json = ["serde_json", "serde"]
typed-routing = ["axum-macros", "serde", "percent-encoding"]
cookie = ["cookie-lib"]
spa = ["tower-http/fs"]

[dependencies]
axum = { path = "../axum", version = "0.5" }
Expand All @@ -37,6 +38,7 @@ cookie-lib = { package = "cookie", version = "0.16", features = ["percent-encode
[dev-dependencies]
axum = { path = "../axum", version = "0.5", features = ["headers"] }
hyper = "0.14"
reqwest = { version = "0.11", default-features = false, features = ["json", "stream", "multipart"] }
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1.14", features = ["full"] }
tower = { version = "0.4", features = ["util"] }
Expand Down
16 changes: 16 additions & 0 deletions axum-extra/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,19 @@ pub mod __private {
const PATH: &AsciiSet = &FRAGMENT.add(b'#').add(b'?').add(b'{').add(b'}');
pub const PATH_SEGMENT: &AsciiSet = &PATH.add(b'/').add(b'%');
}

#[cfg(test)]
pub(crate) mod test_helpers {
#![allow(unused_imports)]

use axum::{body::HttpBody, BoxError};

mod test_client {
#![allow(dead_code)]
include!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../axum/src/test_helpers/test_client.rs"
));
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Poor mans code sharing 🙃

}
pub(crate) use self::test_client::*;
}
6 changes: 6 additions & 0 deletions axum-extra/src/routing/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ use axum::{handler::Handler, Router};

mod resource;

#[cfg(feature = "spa")]
mod spa;

#[cfg(feature = "typed-routing")]
mod typed;

Expand All @@ -15,6 +18,9 @@ pub use axum_macros::TypedPath;
#[cfg(feature = "typed-routing")]
pub use self::typed::{FirstElementIs, TypedPath};

#[cfg(feature = "spa")]
pub use self::spa::SpaRouter;

/// Extension trait that adds additional methods to [`Router`].
pub trait RouterExt<B>: sealed::Sealed {
/// Add a typed `GET` route to the router.
Expand Down
270 changes: 270 additions & 0 deletions axum-extra/src/routing/spa.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
use axum::{
body::{Body, HttpBody},
error_handling::HandleError,
response::Response,
routing::{get_service, Route},
Router,
};
use http::{Request, StatusCode};
use std::{
any::type_name,
convert::Infallible,
fmt,
future::{ready, Ready},
io,
marker::PhantomData,
path::{Path, PathBuf},
sync::Arc,
};
use tower_http::services::{ServeDir, ServeFile};
use tower_service::Service;

/// Router for single page applications.
///
/// `SpaRouter` gives a routing setup commonly used for single page applications.
///
/// # Example
///
/// ```
/// use axum_extra::routing::SpaRouter;
/// use axum::{Router, routing::get};
///
/// let spa = SpaRouter::new("/assets", "dist");
///
/// let app = Router::new()
/// // `SpaRouter` implements `Into<Router>` so it works with `merge`
/// .merge(spa)
/// // we can still add other routes
/// .route("/api/foo", get(api_foo));
/// # let _: Router<axum::body::Body> = app;
///
/// async fn api_foo() {}
/// ```
///
/// With this setup we get this behavior:
///
/// - `GET /` will serve `index.html`
/// - `GET /assets/app.js` will serve `dist/app.js` assuming that file exists
/// - `GET /assets/doesnt_exist` will respond with `404 Not Found` assuming no
/// such file exists
/// - `GET /some/other/path` will serve `index.html` since there isn't another
/// route for it
/// - `GET /api/foo` will serve the `api_foo` handler function
pub struct SpaRouter<B = Body, T = (), F = fn(io::Error) -> Ready<StatusCode>> {
paths: Arc<Paths>,
handle_error: F,
_marker: PhantomData<fn() -> (B, T)>,
}

#[derive(Debug)]
struct Paths {
assets_path: String,
assets_dir: PathBuf,
index_file: PathBuf,
}

impl<B> SpaRouter<B, (), fn(io::Error) -> Ready<StatusCode>> {
/// Create a new `SpaRouter`.
///
/// Assets will be served at `GET /{serve_assets_at}` from the directory at `assets_dir`.
///
/// The index file defaults to `assets_dir.join("index.html")`.
pub fn new<P>(serve_assets_at: &str, assets_dir: P) -> Self
where
P: AsRef<Path>,
{
let path = assets_dir.as_ref();
Self {
paths: Arc::new(Paths {
assets_path: serve_assets_at.to_owned(),
assets_dir: path.to_owned(),
index_file: path.join("index.html"),
}),
handle_error: |_| ready(StatusCode::INTERNAL_SERVER_ERROR),
_marker: PhantomData,
}
}
}

impl<B, T, F> SpaRouter<B, T, F> {
/// Set the path to the index file.
///
/// `path` must be relative to `assets_dir` passed to [`SpaRouter::new`].
///
/// # Example
///
/// ```
/// use axum_extra::routing::SpaRouter;
/// use axum::Router;
///
/// let spa = SpaRouter::new("/assets", "dist")
/// .index_file("another_file.html");
///
/// let app = Router::new().merge(spa);
/// # let _: Router<axum::body::Body> = app;
/// ```
pub fn index_file<P>(mut self, path: P) -> Self
where
P: AsRef<Path>,
{
self.paths = Arc::new(Paths {
assets_path: self.paths.assets_path.clone(),
assets_dir: self.paths.assets_dir.clone(),
index_file: self.paths.assets_dir.join(path),
});
self
}

/// Change the function used to handle unknown IO errors.
///
/// `SpaRouter` automatically maps missing files and permissions denied to
/// `404 Not Found`. The callback given here will be used for another IO
/// errors.
///
/// See [`axum::error_handling::HandleErrorLayer`] for more details.
///
/// # Example
///
/// ```
/// use std::io;
/// use axum_extra::routing::SpaRouter;
/// use axum::{Router, http::{Method, Uri}};
///
/// let spa = SpaRouter::new("/assets", "dist").handle_error(handle_error);
///
/// async fn handle_error(method: Method, uri: Uri, err: io::Error) -> String {
/// format!("{} {} failed with {}", method, uri, err)
/// }
///
/// let app = Router::new().merge(spa);
/// # let _: Router<axum::body::Body> = app;
/// ```
pub fn handle_error<T2, F2>(self, f: F2) -> SpaRouter<B, T2, F2> {
SpaRouter {
paths: self.paths,
handle_error: f,
_marker: PhantomData,
}
}
}

impl<B, F, T> From<SpaRouter<B, T, F>> for Router<B>
where
F: Clone + Send + 'static,
HandleError<Route<B, io::Error>, F, T>:
Service<Request<B>, Response = Response, Error = Infallible>,
<HandleError<Route<B, io::Error>, F, T> as Service<Request<B>>>::Future: Send,
B: HttpBody + Send + 'static,
T: 'static,
{
fn from(spa: SpaRouter<B, T, F>) -> Self {
let assets_service = get_service(ServeDir::new(&spa.paths.assets_dir))
.handle_error(spa.handle_error.clone());

Router::new()
.nest(&spa.paths.assets_path, assets_service)
.fallback(
get_service(ServeFile::new(&spa.paths.index_file)).handle_error(spa.handle_error),
)
}
}

impl<B, T, F> fmt::Debug for SpaRouter<B, T, F> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let Self {
paths,
handle_error: _,
_marker,
} = self;

f.debug_struct("SpaRouter")
.field("paths", &paths)
.field("handle_error", &format_args!("{}", type_name::<F>()))
.field("request_body_type", &format_args!("{}", type_name::<B>()))
.field(
"extractor_input_type",
&format_args!("{}", type_name::<T>()),
)
.finish()
}
}

impl<B, T, F> Clone for SpaRouter<B, T, F>
where
F: Clone,
{
fn clone(&self) -> Self {
Self {
paths: self.paths.clone(),
handle_error: self.handle_error.clone(),
_marker: self._marker,
}
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers::*;
use axum::{
http::{Method, Uri},
routing::get,
};

#[tokio::test]
async fn basic() {
let app = Router::new()
.route("/foo", get(|| async { "GET /foo" }))
.merge(SpaRouter::new("/assets", "test_files"));
let client = TestClient::new(app);

let res = client.get("/").send().await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(res.text().await, "<h1>Hello, World!</h1>\n");

let res = client.get("/some/random/path").send().await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(res.text().await, "<h1>Hello, World!</h1>\n");

let res = client.get("/assets/script.js").send().await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(res.text().await, "console.log('hi')\n");

let res = client.get("/foo").send().await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(res.text().await, "GET /foo");

let res = client.get("/assets/doesnt_exist").send().await;
assert_eq!(res.status(), StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn setting_index_file() {
let app =
Router::new().merge(SpaRouter::new("/assets", "test_files").index_file("index_2.html"));
let client = TestClient::new(app);

let res = client.get("/").send().await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(res.text().await, "<strong>Hello, World!</strong>\n");

let res = client.get("/some/random/path").send().await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(res.text().await, "<strong>Hello, World!</strong>\n");
}

// this should just compile
#[allow(dead_code)]
fn setting_error_handler() {
async fn handle_error(method: Method, uri: Uri, err: io::Error) -> (StatusCode, String) {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("{} {} failed. Error: {}", method, uri, err),
)
}

let spa = SpaRouter::new("/assets", "test_files").handle_error(handle_error);

Router::<Body>::new().merge(spa);
}
}
1 change: 1 addition & 0 deletions axum-extra/test_files/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<h1>Hello, World!</h1>
1 change: 1 addition & 0 deletions axum-extra/test_files/index_2.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<strong>Hello, World!</strong>
1 change: 1 addition & 0 deletions axum-extra/test_files/script.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
console.log('hi')
12 changes: 12 additions & 0 deletions axum/src/test_helpers/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
#![allow(clippy::blacklisted_name)]

use crate::{body::HttpBody, BoxError};

mod test_client;
pub(crate) use self::test_client::*;

pub(crate) fn assert_send<T: Send>() {}
pub(crate) fn assert_sync<T: Sync>() {}
pub(crate) fn assert_unpin<T: Unpin>() {}

pub(crate) struct NotSendSync(*const ());
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
#![allow(clippy::blacklisted_name)]

use crate::body::HttpBody;
use crate::BoxError;
use super::{BoxError, HttpBody};
use bytes::Bytes;
use http::{
header::{HeaderName, HeaderValue},
Expand All @@ -15,12 +12,6 @@ use std::{
use tower::make::Shared;
use tower_service::Service;

pub(crate) fn assert_send<T: Send>() {}
pub(crate) fn assert_sync<T: Sync>() {}
pub(crate) fn assert_unpin<T: Unpin>() {}

pub(crate) struct NotSendSync(*const ());

pub(crate) struct TestClient {
client: reqwest::Client,
addr: SocketAddr,
Expand Down