perseus_rocket/
lib.rs

1#![doc = include_str!("../README.proj.md")]
2
3/*!
4## Packages
5
6This is the API documentation for the `perseus-rocket` package, which allows Perseus apps to run on Rocket. Note that Perseus mostly uses [the book](https://framesurge.sh/perseus/en-US) for
7documentation, 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).
8*/
9
10#![cfg(engine)]
11#![deny(missing_docs)]
12#![deny(missing_debug_implementations)]
13
14use std::{io::Cursor, path::Path};
15
16use perseus::{
17    i18n::TranslationsManager,
18    path::PathMaybeWithLocale,
19    server::ServerOptions,
20    stores::MutableStore,
21    turbine::{ApiResponse as PerseusApiResponse, Turbine},
22};
23use rocket::{
24    fs::{FileServer, NamedFile},
25    get,
26    http::{Method, Status},
27    response::Responder,
28    route::{Handler, Outcome},
29    routes,
30    tokio::fs::File,
31    Build, Data, Request, Response, Rocket, Route, State,
32};
33
34// ----- Newtype wrapper for response implementation -----
35
36#[derive(Debug)]
37struct ApiResponse(PerseusApiResponse);
38impl From<PerseusApiResponse> for ApiResponse {
39    fn from(val: PerseusApiResponse) -> Self {
40        Self(val)
41    }
42}
43impl<'r> Responder<'r, 'static> for ApiResponse {
44    fn respond_to(self, _request: &'r rocket::Request<'_>) -> rocket::response::Result<'static> {
45        let mut resp_build = Response::build();
46        resp_build
47            .status(rocket::http::Status {
48                code: self.0.status.into(),
49            })
50            .sized_body(self.0.body.len(), Cursor::new(self.0.body));
51
52        for h in self.0.headers.iter() {
53            // Headers that contain non-visible ascii characters are chopped off here in
54            // order to make the conversion
55            if let Ok(value) = h.1.to_str() {
56                resp_build.raw_header(h.0.to_string(), value.to_string());
57            }
58        }
59
60        resp_build.ok()
61    }
62}
63
64// ----- Simple routes -----
65
66#[get("/bundle.js")]
67async fn get_js_bundle(opts: &State<ServerOptions>) -> std::io::Result<NamedFile> {
68    NamedFile::open(&opts.js_bundle).await
69}
70
71#[get("/bundle.wasm")]
72async fn get_wasm_bundle(opts: &State<ServerOptions>) -> std::io::Result<NamedFile> {
73    NamedFile::open(&opts.wasm_bundle).await
74}
75
76#[get("/bundle.wasm.js")]
77async fn get_wasm_js_bundle(opts: &State<ServerOptions>) -> std::io::Result<NamedFile> {
78    NamedFile::open(&opts.wasm_js_bundle).await
79}
80
81// ----- Turbine dependant route handlers -----
82
83async fn perseus_locale<'r, M, T>(req: &'r Request<'_>, turbine: &Turbine<M, T>) -> Outcome<'r>
84where
85    M: MutableStore + 'static,
86    T: TranslationsManager + 'static,
87{
88    match req.routed_segment(1) {
89        Some(locale) => Outcome::from(req, ApiResponse(turbine.get_translations(locale).await)),
90        _ => Outcome::Failure(Status::BadRequest),
91    }
92}
93
94async fn perseus_initial_load_handler<'r, M, T>(
95    req: &'r Request<'_>,
96    turbine: &Turbine<M, T>,
97) -> Outcome<'r>
98where
99    M: MutableStore + 'static,
100    T: TranslationsManager + 'static,
101{
102    // Since this is a fallback handler, we have to do everything from the request
103    // itself
104    let path = req.uri().path().to_string();
105
106    let mut http_req = rocket::http::hyper::Request::builder();
107    http_req = http_req.method("GET");
108    for h in req.headers().iter() {
109        http_req = http_req.header(h.name.to_string(), h.value.to_string());
110    }
111
112    match http_req.body(()) {
113        Ok(r) => Outcome::from(
114            req,
115            ApiResponse(turbine.get_initial_load(PathMaybeWithLocale(path), r).await),
116        ),
117        _ => Outcome::Failure(Status::BadRequest),
118    }
119}
120
121async fn perseus_subsequent_load_handler<'r, M, T>(
122    req: &'r Request<'_>,
123    turbine: &Turbine<M, T>,
124) -> Outcome<'r>
125where
126    M: MutableStore + 'static,
127    T: TranslationsManager + 'static,
128{
129    let locale_opt = req.routed_segment(1);
130    let entity_name_opt = req
131        .query_value::<&str>("entity_name")
132        .and_then(|res| res.ok());
133    let was_incremental_match_opt = req
134        .query_value::<bool>("was_incremental_match")
135        .and_then(|res| res.ok());
136
137    let (locale, entity_name, was_incremental_match) =
138        match (locale_opt, entity_name_opt, was_incremental_match_opt) {
139            (Some(l), Some(e), Some(w)) => (l.to_string(), e.to_string(), w),
140            _ => return Outcome::Failure(Status::BadRequest),
141        };
142
143    let raw_path = req.routed_segments(2..).collect::<Vec<&str>>().join("/");
144
145    let mut http_req = rocket::http::hyper::Request::builder();
146    http_req = http_req.method("GET");
147    for h in req.headers().iter() {
148        http_req = http_req.header(h.name.to_string(), h.value.to_string());
149    }
150
151    match http_req.body(()) {
152        Ok(r) => Outcome::from(
153            req,
154            ApiResponse(
155                turbine
156                    .get_subsequent_load(
157                        perseus::path::PathWithoutLocale(raw_path),
158                        locale,
159                        entity_name,
160                        was_incremental_match,
161                        r,
162                    )
163                    .await,
164            ),
165        ),
166        _ => Outcome::Failure(Status::BadRequest),
167    }
168}
169
170// ----- Rocket handler trait implementation -----
171
172#[derive(Clone)]
173enum PerseusRouteKind<'a> {
174    Locale,
175    StaticAlias(&'a String),
176    IntialLoadHandler,
177    SubsequentLoadHandler,
178}
179
180#[derive(Clone)]
181struct RocketHandlerWithTurbine<'a, M, T>
182where
183    M: MutableStore + 'static,
184    T: TranslationsManager + 'static,
185{
186    turbine: &'a Turbine<M, T>,
187    perseus_route: PerseusRouteKind<'a>,
188}
189
190#[rocket::async_trait]
191impl<M, T> Handler for RocketHandlerWithTurbine<'static, M, T>
192where
193    M: MutableStore + 'static,
194    T: TranslationsManager + 'static,
195{
196    async fn handle<'r>(&self, req: &'r Request<'_>, _data: Data<'r>) -> Outcome<'r> {
197        match self.perseus_route {
198            PerseusRouteKind::Locale => perseus_locale(req, self.turbine).await,
199            PerseusRouteKind::StaticAlias(static_alias) => {
200                perseus_static_alias(req, static_alias).await
201            }
202            PerseusRouteKind::IntialLoadHandler => {
203                perseus_initial_load_handler(req, self.turbine).await
204            }
205            PerseusRouteKind::SubsequentLoadHandler => {
206                perseus_subsequent_load_handler(req, self.turbine).await
207            }
208        }
209    }
210}
211
212async fn perseus_static_alias<'r>(req: &'r Request<'_>, static_alias: &String) -> Outcome<'r> {
213    match File::open(static_alias).await {
214        Ok(file) => Outcome::from(req, file),
215        _ => Outcome::Failure(Status::NotFound),
216    }
217}
218
219// ----- Integration code -----
220
221/// Configures an Rocket Web app for Perseus.
222/// This returns a rocket app at the build stage that can be built upon further
223/// with more routes, fairings etc...
224pub async fn perseus_base_app<M, T>(
225    turbine: &'static Turbine<M, T>,
226    opts: ServerOptions,
227) -> Rocket<Build>
228where
229    M: MutableStore + 'static,
230    T: TranslationsManager + 'static,
231{
232    let get_locale = Route::new(
233        Method::Get,
234        "/translations/<path..>",
235        RocketHandlerWithTurbine {
236            turbine,
237            perseus_route: PerseusRouteKind::Locale,
238        },
239    );
240
241    // Since this route matches everything, its rank has been set to 100,
242    // That means that it will be used after routes that have a rank inferior to 100
243    // forward, see https://rocket.rs/v0.5-rc/guide/requests/#default-ranking
244    let get_initial_load_handler = Route::ranked(
245        100,
246        Method::Get,
247        "/<path..>",
248        RocketHandlerWithTurbine {
249            turbine,
250            perseus_route: PerseusRouteKind::IntialLoadHandler,
251        },
252    );
253
254    let get_subsequent_load_handler = Route::new(
255        Method::Get,
256        "/page/<path..>",
257        RocketHandlerWithTurbine {
258            turbine,
259            perseus_route: PerseusRouteKind::SubsequentLoadHandler,
260        },
261    );
262
263    let mut perseus_routes: Vec<Route> =
264        routes![get_js_bundle, get_wasm_js_bundle, get_wasm_bundle];
265    perseus_routes.append(&mut vec![get_locale, get_subsequent_load_handler]);
266
267    let mut app = rocket::build()
268        .manage(opts.clone())
269        .mount("/.perseus/", perseus_routes)
270        .mount("/", vec![get_initial_load_handler]);
271
272    if Path::new(&opts.snippets).exists() {
273        app = app.mount("/.perseus/snippets", FileServer::from(opts.snippets))
274    }
275
276    if turbine.static_dir.exists() {
277        app = app.mount("/.perseus/static", FileServer::from(&turbine.static_dir))
278    }
279
280    let mut static_aliases: Vec<Route> = vec![];
281
282    for (url, static_path) in turbine.static_aliases.iter() {
283        let route = Route::new(
284            Method::Get,
285            url,
286            RocketHandlerWithTurbine {
287                turbine,
288                perseus_route: PerseusRouteKind::StaticAlias(static_path),
289            },
290        );
291        static_aliases.push(route)
292    }
293
294    app = app.mount("/", static_aliases);
295
296    app
297}
298
299// ----- Default server -----
300
301/// Creates and starts the default Perseus server with Rocket. This should be
302/// run in a `main` function annotated with `#[tokio::main]` (which requires the
303/// `macros` and `rt-multi-thread` features on the `tokio` dependency).
304#[cfg(feature = "dflt-server")]
305pub async fn dflt_server<M: MutableStore + 'static, T: TranslationsManager + 'static>(
306    turbine: &'static Turbine<M, T>,
307    opts: ServerOptions,
308    (host, port): (String, u16),
309) {
310    let addr = host.parse().expect("Invalid address provided to bind to.");
311
312    let mut app = perseus_base_app(turbine, opts).await;
313
314    let config = rocket::Config {
315        port,
316        address: addr,
317        ..Default::default()
318    };
319    app = app.configure(config);
320
321    if let Err(err) = app.launch().await {
322        eprintln!("Error lauching Rocket app: {}.", err);
323    }
324}