diff --git a/examples/basic/src/templates/index.rs b/examples/basic/src/templates/index.rs index aa14fc706a..d26cc1753b 100644 --- a/examples/basic/src/templates/index.rs +++ b/examples/basic/src/templates/index.rs @@ -1,4 +1,7 @@ -use perseus::{GenericNode, StringResultWithCause, Template}; +use perseus::{ + http::header::{HeaderMap, HeaderName}, + GenericNode, StringResultWithCause, Template, +}; use serde::{Deserialize, Serialize}; use std::rc::Rc; use sycamore::prelude::{component, template, Template as SycamoreTemplate}; @@ -21,6 +24,7 @@ pub fn get_template() -> Template { .build_state_fn(Rc::new(get_build_props)) .template(template_fn()) .head(head_fn()) + .set_headers_fn(set_headers_fn()) } pub async fn get_build_props(_path: String) -> StringResultWithCause { @@ -47,3 +51,14 @@ pub fn head_fn() -> perseus::template::HeadFn { } }) } + +pub fn set_headers_fn() -> perseus::template::SetHeadersFn { + Rc::new(|_| { + let mut map = HeaderMap::new(); + map.insert( + HeaderName::from_lowercase(b"x-test").unwrap(), + "custom value".parse().unwrap(), + ); + map + }) +} diff --git a/examples/i18n/tests/main.rs b/examples/i18n/tests/main.rs index 0311f74cd2..a09d499a49 100644 --- a/examples/i18n/tests/main.rs +++ b/examples/i18n/tests/main.rs @@ -8,7 +8,6 @@ async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { let url = c.current_url().await?; // We only test for one locale here because changing the browser's preferred languages is very hard, we do unit testing on the locale detection system instead assert!(url.as_ref().starts_with("http://localhost:8080/en-US")); - // TODO test locale detection (unit testing) // This tests translations, variable interpolation, and multiple aspects of Sycamore all at once let text = c.find(Locator::Css("p")).await?.text().await?; assert_eq!(text, "Hello, \u{2068}User\u{2069}!"); // Ask Fluent about these characters diff --git a/packages/perseus-actix-web/src/initial_load.rs b/packages/perseus-actix-web/src/initial_load.rs index b816f3fabc..70e88e71f2 100644 --- a/packages/perseus-actix-web/src/initial_load.rs +++ b/packages/perseus-actix-web/src/initial_load.rs @@ -125,9 +125,14 @@ pub async fn initial_load( let final_html = interpolate_page_data(&html_shell, &page_data, &opts.root_id); - HttpResponse::Ok() - .content_type("text/html") - .body(final_html) + let mut http_res = HttpResponse::Ok(); + http_res.content_type("text/html"); + // Generate and add HTTP headers + for (key, val) in template.get_headers(page_data.state.clone()) { + http_res.set_header(key.unwrap(), val); + } + + http_res.body(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/...` RouteVerdict::LocaleDetection(_) => { diff --git a/packages/perseus-actix-web/src/page_data.rs b/packages/perseus-actix-web/src/page_data.rs index 9438a5a075..f05eb52642 100644 --- a/packages/perseus-actix-web/src/page_data.rs +++ b/packages/perseus-actix-web/src/page_data.rs @@ -54,7 +54,16 @@ pub async fn page_data( ) .await; match page_data { - Ok(page_data) => HttpResponse::Ok().body(serde_json::to_string(&page_data).unwrap()), + Ok(page_data) => { + let mut http_res = HttpResponse::Ok(); + http_res.content_type("text/html"); + // Generate and add HTTP headers + for (key, val) in template.get_headers(page_data.state.clone()) { + http_res.set_header(key.unwrap(), val); + } + + http_res.body(serde_json::to_string(&page_data).unwrap()) + } // We parse the error to return an appropriate status code Err(err) => { HttpResponse::build(StatusCode::from_u16(err_to_status_code(&err)).unwrap()) diff --git a/packages/perseus/src/default_headers.rs b/packages/perseus/src/default_headers.rs new file mode 100644 index 0000000000..54465b1b67 --- /dev/null +++ b/packages/perseus/src/default_headers.rs @@ -0,0 +1,9 @@ +use http::header::{self, HeaderMap}; + +/// Creates the default headers used in Perseus. This is the default value for `set_headers` on every `Template` +// TODO +pub(crate) fn default_headers() -> HeaderMap { + let mut map = HeaderMap::new(); + map.insert(header::CACHE_CONTROL, "max-age=300".parse().unwrap()); + map +} diff --git a/packages/perseus/src/lib.rs b/packages/perseus/src/lib.rs index 6d381ad3cd..b1edf4d6b8 100644 --- a/packages/perseus/src/lib.rs +++ b/packages/perseus/src/lib.rs @@ -41,6 +41,7 @@ mod client_translations_manager; /// Utilities for creating custom config managers, as well as the default `FsConfigManager`. pub mod config_manager; mod decode_time_str; +mod default_headers; /// Utilities regarding the formation of error pages for HTTP status codes, like a `404 Not Found` page. pub mod error_pages; pub mod errors; diff --git a/packages/perseus/src/locale_detector.rs b/packages/perseus/src/locale_detector.rs index c2b7d9b92f..15841f9d12 100644 --- a/packages/perseus/src/locale_detector.rs +++ b/packages/perseus/src/locale_detector.rs @@ -16,7 +16,7 @@ pub fn detect_locale(url: String, locales: Locales) { // We'll fall back to `language`, which only gives us one locale to compare with // If that isn't supported, we'll automatically fall back to the default locale if let Some(lang) = navigator.language() { - locale = match compare_locale(&lang, locales.get_all()) { + locale = match compare_locale(&lang, &locales.get_all()) { LocaleMatch::Exact(matched) | LocaleMatch::Language(matched) => matched, LocaleMatch::None => locales.default, } @@ -28,7 +28,7 @@ pub fn detect_locale(url: String, locales: Locales) { let cmp_str = cmp.as_string().unwrap(); // As per RFC 4647, the first match (exact or language-only) is the one we'll use if let LocaleMatch::Exact(matched) | LocaleMatch::Language(matched) = - compare_locale(&cmp_str, locales.get_all()) + compare_locale(&cmp_str, &locales.get_all()) { locale = matched; break; @@ -46,6 +46,7 @@ pub fn detect_locale(url: String, locales: Locales) { } /// The possible outcomes of trying to match a locale. +#[derive(Debug, PartialEq, Eq)] enum LocaleMatch { /// The language and region match to a supported locale. Exact(String), @@ -64,12 +65,13 @@ enum LocaleMatch { /// /// This does NOT comply fully with [RFC 4647](https://www.rfc-editor.org/rfc/rfc4647.txt) yet, as only `xx-XX` form locales are /// currently supported. This functionality will eventually be broken out into a separate module for ease of use. -fn compare_locale(cmp: &str, locales: Vec<&String>) -> LocaleMatch { +fn compare_locale + std::fmt::Display>(cmp: &str, locales: &[S]) -> LocaleMatch { let mut outcome = LocaleMatch::None; // Split into language and region (e.g. `en-US`) if possible let cmp_parts: Vec<&str> = cmp.split('-').collect(); for locale in locales { + let locale = locale.to_string(); // Split into language and region (e.g. `en-US`) if possible let parts: Vec<&str> = locale.split('-').collect(); if locale == cmp { @@ -88,3 +90,28 @@ fn compare_locale(cmp: &str, locales: Vec<&String>) -> LocaleMatch { outcome } + +mod tests { + #[allow(unused_imports)] // For some reason this throws a warning otherwise... + use super::*; + #[test] + fn matches_exact() { + let verdict = compare_locale("en-US", &["en-US"]); + assert_eq!(verdict, LocaleMatch::Exact("en-US".to_string())) + } + #[test] + fn matches_lang() { + let verdict = compare_locale("en-US", &["en-GB"]); + assert_eq!(verdict, LocaleMatch::Language("en-GB".to_string())) + } + #[test] + fn fails_on_no_match() { + let verdict = compare_locale("en-US", &["zh-CN"]); + assert_eq!(verdict, LocaleMatch::None) + } + #[test] + fn uses_later_exact_match() { + let verdict = compare_locale("en-US", &["en-GB", "en-US"]); + assert_eq!(verdict, LocaleMatch::Exact("en-US".to_string())) + } +} diff --git a/packages/perseus/src/template.rs b/packages/perseus/src/template.rs index 2774144199..6fc28a4db1 100644 --- a/packages/perseus/src/template.rs +++ b/packages/perseus/src/template.rs @@ -1,10 +1,12 @@ // This file contains logic to define how templates are rendered +use crate::default_headers::default_headers; use crate::errors::*; use crate::Request; use crate::SsrNode; use crate::Translator; use futures::Future; +use http::header::HeaderMap; use std::collections::HashMap; use std::pin::Pin; use std::rc::Rc; @@ -118,6 +120,8 @@ pub type TemplateFn = Rc) -> SycamoreTemplate>; /// rendered in function (it may be rendered on the client, but it will always be used to create an HTML string, rather than a reactive /// template). pub type HeadFn = TemplateFn; +/// The type of functions that modify HTTP response headers. +pub type SetHeadersFn = Rc) -> HeaderMap>; /// The type of functions that get build paths. pub type GetBuildPathsFn = Rc; /// The type of functions that get build state. @@ -146,6 +150,10 @@ pub struct Template { /// the same way as `template`, but will always be rendered to a string, whcih will then be interpolated directly into the ``, /// so reactivity here will not work! head: TemplateFn, + /// A function to be run when the server returns an HTTP response. This should return headers for said response, given the template's + /// state. The most common use-case of this is to add cache control that respects revalidation. This will only be run on successful + /// responses, and does have the power to override existing headers. By default, this will create sensible cache control headers. + set_headers: SetHeadersFn, /// A function that gets the paths to render for at built-time. This is equivalent to `get_static_paths` in NextJS. If /// `incremental_generation` is `true`, more paths can be rendered at request time on top of these. get_build_paths: Option, @@ -182,6 +190,9 @@ impl Template { template: Rc::new(|_: Option| sycamore::template! {}), // Unlike `template`, this may not be set at all (especially in very simple apps) head: Rc::new(|_: Option| sycamore::template! {}), + // We create sensible header defaults here + // TODO header defaults + set_headers: Rc::new(|_: Option| default_headers()), get_build_paths: None, incremental_generation: false, get_build_state: None, @@ -326,6 +337,12 @@ impl Template { )) } } + /// Gets the template's headers for the given state. These will be inserted into any successful HTTP responses for this template, + /// and they have the power to override. + #[allow(clippy::mutable_key_type)] + pub fn get_headers(&self, state: Option) -> HeaderMap { + (self.set_headers)(state) + } // Value getters /// Gets the path of the template. This is the root path under which any generated pages will be served. In the simplest case, there will @@ -371,7 +388,8 @@ impl Template { pub fn can_amalgamate_states(&self) -> bool { self.amalgamate_states.is_some() } - /// Checks if this template defines no rendering logic whatsoever. Such templates will be rendered using SSG. + /// Checks if this template defines no rendering logic whatsoever. Such templates will be rendered using SSG. Basic templates can + /// still modify headers. pub fn is_basic(&self) -> bool { !self.uses_build_paths() && !self.uses_build_state() @@ -387,10 +405,15 @@ impl Template { self } /// Sets the document head rendering function to use. - pub fn head(mut self, val: TemplateFn) -> Template { + pub fn head(mut self, val: HeadFn) -> Template { self.head = val; self } + /// Sets the function to set headers. This will override Perseus' inbuilt header defaults. + pub fn set_headers_fn(mut self, val: SetHeadersFn) -> Template { + self.set_headers = val; + self + } /// Enables the *build paths* strategy with the given function. pub fn build_paths_fn(mut self, val: GetBuildPathsFn) -> Template { self.get_build_paths = Some(val);