Skip to main content

perseus_axum/
lib.rs

1#![doc = include_str!("../README.proj.md")]
2/*!
3## Packages
4
5This 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://framesurge.sh/perseus/en-US) for
6documentation, and this should mostly be used as a secondary reference source. You can also find full usage examples [here](https://github.com/framesurge/perseus/tree/main/examples).
7*/
8
9#![cfg(engine)] // This crate needs to be run with the Perseus CLI
10#![deny(missing_docs)]
11#![deny(missing_debug_implementations)]
12
13use axum::{
14    body::Body,
15    extract::{Path, Query},
16    http::{Request, StatusCode},
17    response::{IntoResponse, Response},
18    routing::{get, get_service},
19    Router,
20};
21use perseus::turbine::ApiResponse as PerseusApiResponse;
22use perseus::{
23    i18n::TranslationsManager,
24    path::*,
25    server::ServerOptions,
26    stores::MutableStore,
27    turbine::{SubsequentLoadQueryParams, Turbine},
28};
29use tower_http::services::{ServeDir, ServeFile};
30
31// ----- Request conversion implementation -----
32
33// Not needed, since Axum uses `http::Request` under the hood, and we can just
34// change the body type to `()`.
35
36// ----- Newtype wrapper for response implementation -----
37
38#[derive(Debug)]
39struct ApiResponse(PerseusApiResponse);
40impl From<PerseusApiResponse> for ApiResponse {
41    fn from(val: PerseusApiResponse) -> Self {
42        Self(val)
43    }
44}
45impl IntoResponse for ApiResponse {
46    fn into_response(self) -> Response {
47        // Very convenient!
48        (self.0.status, self.0.headers, self.0.body).into_response()
49    }
50}
51
52// ----- Integration code -----
53
54/// Gets the `Router` needed to configure an existing Axum app for Perseus, and
55/// should be provided after any other routes, as they include a wildcard route.
56pub async fn get_router<M: MutableStore + 'static, T: TranslationsManager + 'static>(
57    turbine: &'static Turbine<M, T>,
58    opts: ServerOptions,
59) -> Router {
60    let router = Router::new()
61        // --- File handlers ---
62        .route(
63            "/.perseus/bundle.js",
64            get_service(ServeFile::new(opts.js_bundle.clone())).handle_error(handle_fs_error),
65        )
66        .route(
67            "/.perseus/bundle.wasm",
68            get_service(ServeFile::new(opts.wasm_bundle.clone())).handle_error(handle_fs_error),
69        )
70        .route(
71            "/.perseus/bundle.wasm.js",
72            get_service(ServeFile::new(opts.wasm_js_bundle.clone())).handle_error(handle_fs_error),
73        )
74        .nest_service(
75            "/.perseus/snippets",
76            get_service(ServeDir::new(opts.snippets)).handle_error(handle_fs_error),
77        );
78    // --- Translation and subsequent load handlers ---
79    let mut router = router
80        .route(
81            "/.perseus/translations/:locale",
82            get(move |Path(locale): Path<String>| async move {
83                ApiResponse(turbine.get_translations(&locale).await)
84            }),
85        )
86        .route(
87            "/.perseus/page/:locale/*tail",
88            get(
89                move |Path(path_parts): Path<Vec<String>>,
90                      Query(SubsequentLoadQueryParams {
91                          entity_name,
92                          was_incremental_match,
93                      }): Query<SubsequentLoadQueryParams>,
94                      http_req: Request<Body>| async move {
95                    // Separate the locale from the rest of the page name
96                    let locale = &path_parts[0];
97                    let raw_path = path_parts[1..]
98                        .iter()
99                        .map(|x| x.as_str())
100                        .collect::<Vec<&str>>()
101                        .join("/");
102                    // Get rid of the body from the request (Perseus only needs the metadata)
103                    let req = Request::from_parts(http_req.into_parts().0, ());
104
105                    ApiResponse(
106                        turbine
107                            .get_subsequent_load(
108                                PathWithoutLocale(raw_path),
109                                locale.to_string(),
110                                entity_name,
111                                was_incremental_match,
112                                req,
113                            )
114                            .await,
115                    )
116                },
117            ),
118        );
119    // --- Static directory and alias handlers ---
120    if turbine.static_dir.exists() {
121        router = router.nest_service(
122            "/.perseus/static",
123            get_service(ServeDir::new(&turbine.static_dir)).handle_error(handle_fs_error),
124        )
125    }
126    for (url, static_path) in turbine.static_aliases.iter() {
127        router = router.route(
128            url, // This comes with a leading forward slash!
129            get_service(ServeFile::new(static_path)).handle_error(handle_fs_error),
130        );
131    }
132    // --- Initial load handler ---
133    router.fallback_service(get(move |http_req: Request<Body>| async move {
134        // Since this is a fallback handler, we have to do everything from the request
135        // itself
136        let path = http_req.uri().path().to_string();
137        let http_req = Request::from_parts(http_req.into_parts().0, ());
138
139        ApiResponse(
140            turbine
141                .get_initial_load(PathMaybeWithLocale(path), http_req)
142                .await,
143        )
144    }))
145}
146
147// TODO Review if there's anything more to do here
148async fn handle_fs_error(_err: std::io::Error) -> impl IntoResponse {
149    dbg!("Error!");
150    (StatusCode::INTERNAL_SERVER_ERROR, "Couldn't serve file.")
151}
152
153// ----- Default server -----
154
155/// Creates and starts the default Perseus server with Axum. This should be run
156/// in a `main` function annotated with `#[tokio::main]` (which requires the
157/// `macros` and `rt-multi-thread` features on the `tokio` dependency).
158#[cfg(feature = "dflt-server")]
159pub async fn dflt_server<M: MutableStore + 'static, T: TranslationsManager + 'static>(
160    turbine: &'static Turbine<M, T>,
161    opts: ServerOptions,
162    (host, port): (String, u16),
163) {
164    use std::net::SocketAddr;
165
166    let addr: SocketAddr = format!("{}:{}", host, port)
167        .parse()
168        .expect("Invalid address provided to bind to.");
169    let app = get_router(turbine, opts).await;
170    axum::Server::bind(&addr)
171        .serve(app.into_make_service())
172        .await
173        .unwrap();
174}