perseus_actix_web/
lib.rs

1#![doc = include_str!("../README.proj.md")]
2/*!
3## Packages
4
5This is the API documentation for the `perseus-actix-web` package, which allows Perseus apps to run on Actix Web. 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/arctic-hen7/framesurge/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 actix_files::{Files, NamedFile};
14use actix_web::{web, HttpRequest, HttpResponse, Responder};
15use perseus::turbine::ApiResponse as PerseusApiResponse;
16use perseus::{
17    http::StatusCode,
18    i18n::TranslationsManager,
19    path::*,
20    server::ServerOptions,
21    stores::MutableStore,
22    turbine::{SubsequentLoadQueryParams, Turbine},
23    Request,
24};
25
26// ----- Request conversion implementation -----
27
28/// Converts an Actix Web request into an `http::request`.
29pub fn convert_req(raw: &actix_web::HttpRequest) -> Result<Request, String> {
30    let mut builder = Request::builder();
31
32    for (name, val) in raw.headers() {
33        builder = builder.header(name, val);
34    }
35
36    builder
37        .uri(raw.uri())
38        .method(raw.method())
39        .version(raw.version())
40        // We always use an empty body because, in a Perseus request, only the URI matters
41        // Any custom data should therefore be sent in headers (if you're doing that, consider a
42        // dedicated API)
43        .body(())
44        .map_err(|err| err.to_string())
45}
46
47// ----- Newtype wrapper for response implementation -----
48
49#[derive(Debug)]
50struct ApiResponse(PerseusApiResponse);
51impl From<PerseusApiResponse> for ApiResponse {
52    fn from(val: PerseusApiResponse) -> Self {
53        Self(val)
54    }
55}
56impl Responder for ApiResponse {
57    type Body = String;
58    fn respond_to(self, _req: &HttpRequest) -> HttpResponse<Self::Body> {
59        let mut res = HttpResponse::build(self.0.status);
60        for header in self.0.headers {
61            // The header name is in an `Option`, but we only ever add them with proper
62            // names in `PerseusApiResponse`
63            res.insert_header((header.0.unwrap(), header.1));
64        }
65        // TODO
66        res.message_body(self.0.body).unwrap()
67    }
68}
69
70// ----- Integration code -----
71
72/// Configures an existing Actix Web app for Perseus. This returns a function
73/// that does the configuring so it can take arguments. This includes a complete
74/// wildcard handler (`*`), and so it should be configured after any other
75/// routes on your server.
76pub async fn configurer<M: MutableStore + 'static, T: TranslationsManager + 'static>(
77    turbine: &'static Turbine<M, T>,
78    opts: ServerOptions,
79) -> impl FnOnce(&mut actix_web::web::ServiceConfig) {
80    move |cfg: &mut web::ServiceConfig| {
81        let snippets_dir = opts.snippets.clone();
82        cfg
83            .app_data(web::Data::new(opts))
84            // --- File handlers ---
85            .route("/.perseus/bundle.js", web::get().to(js_bundle))
86            .route("/.perseus/bundle.wasm", web::get().to(wasm_bundle))
87            .route("/.perseus/bundle.wasm.js", web::get().to(wasm_js_bundle))
88            .service(Files::new("/.perseus/snippets", &snippets_dir))
89            // --- Translation and subsequent load handlers
90            .route(
91                "/.perseus/translations/{locale}",
92                web::get().to(move |http_req: HttpRequest| async move {
93                    let locale = http_req.match_info().query("locale");
94                    ApiResponse(turbine.get_translations(locale).await)
95                }),
96            )
97            .route(
98                // We capture the `.json` ending in the handler
99                "/.perseus/page/{locale}/{filename:.*}",
100                web::get().to(move |http_req: HttpRequest, web::Query(query_params): web::Query<SubsequentLoadQueryParams>| async move {
101                    let raw_path = http_req.match_info().query("filename").to_string();
102                    let locale = http_req.match_info().query("locale");
103                    let SubsequentLoadQueryParams { entity_name, was_incremental_match } = query_params;
104                    let http_req = match convert_req(&http_req) {
105                        Ok(req) => req,
106                        Err(err) => return ApiResponse(PerseusApiResponse::err(StatusCode::BAD_REQUEST, &err))
107                    };
108
109                    ApiResponse(turbine.get_subsequent_load(
110                        PathWithoutLocale(raw_path),
111                        locale.to_string(),
112                        entity_name,
113                        was_incremental_match,
114                        http_req
115                    ).await)
116                }),
117            );
118        // --- Static directory and alias handlers
119        if turbine.static_dir.exists() {
120            cfg.service(Files::new("/.perseus/static", &turbine.static_dir));
121        }
122        for url in turbine.static_aliases.keys() {
123            cfg.route(
124                url,
125                web::get().to(|req| async { static_alias(turbine, req).await }),
126            );
127        }
128        // --- Initial load handler ---
129        cfg.route(
130            "{route:.*}",
131            web::get().to(move |http_req: HttpRequest| async move {
132                let raw_path = http_req.path().to_string();
133                let http_req = match convert_req(&http_req) {
134                    Ok(req) => req,
135                    Err(err) => {
136                        return ApiResponse(PerseusApiResponse::err(StatusCode::BAD_REQUEST, &err))
137                    }
138                };
139                ApiResponse(
140                    turbine
141                        .get_initial_load(PathMaybeWithLocale(raw_path), http_req)
142                        .await,
143                )
144            }),
145        );
146    }
147}
148
149// File handlers (these have to be broken out for Actix)
150async fn js_bundle(opts: web::Data<ServerOptions>) -> std::io::Result<NamedFile> {
151    NamedFile::open(&opts.js_bundle)
152}
153async fn wasm_bundle(opts: web::Data<ServerOptions>) -> std::io::Result<NamedFile> {
154    NamedFile::open(&opts.wasm_bundle)
155}
156async fn wasm_js_bundle(opts: web::Data<ServerOptions>) -> std::io::Result<NamedFile> {
157    NamedFile::open(&opts.wasm_js_bundle)
158}
159async fn static_alias<M: MutableStore, T: TranslationsManager>(
160    turbine: &'static Turbine<M, T>,
161    req: HttpRequest,
162) -> std::io::Result<NamedFile> {
163    let filename = turbine.static_aliases.get(req.path());
164    let filename = match filename {
165        Some(filename) => filename,
166        // If the path doesn't exist, then the alias is not found
167        None => return Err(std::io::Error::from(std::io::ErrorKind::NotFound)),
168    };
169    NamedFile::open(filename)
170}
171
172// ----- Default server -----
173
174/// Creates and starts the default Perseus server using Actix Web. This should
175/// be run in a `main()` function annotated with `#[tokio::main]` (which
176/// requires the `macros` and `rt-multi-thread` features on the `tokio`
177/// dependency).
178#[cfg(feature = "dflt-server")]
179pub async fn dflt_server<M: MutableStore + 'static, T: TranslationsManager + 'static>(
180    turbine: &'static Turbine<M, T>,
181    opts: ServerOptions,
182    (host, port): (String, u16),
183) {
184    use actix_web::{App, HttpServer};
185    use futures::executor::block_on;
186    // TODO Fix issues here
187    HttpServer::new(move ||
188        App::new()
189            .configure(
190                block_on(
191                    configurer(
192                        turbine,
193                        opts.clone(),
194                    )
195                )
196            )
197    )
198        .bind((host, port))
199        .expect("Couldn't bind to given address. Maybe something is already running on the selected port?")
200        .run()
201        .await
202        .expect("Server failed.") // TODO Improve error message here
203}