Skip to content

Commit

Permalink
feat(templates): ✨ added ability to set http headers for templates (#25)
Browse files Browse the repository at this point in the history
* test(i18n): ✅ added unit tests for locale detection

* feat(templates): ✨ added ability to set custom http headers

* feat(templates): ✨ added default headers

* style: 🎨 ran `cargo fmt`
  • Loading branch information
arctic-hen7 authored Sep 25, 2021
1 parent 295a7b5 commit 058d625
Show file tree
Hide file tree
Showing 8 changed files with 99 additions and 11 deletions.
17 changes: 16 additions & 1 deletion examples/basic/src/templates/index.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand All @@ -21,6 +24,7 @@ pub fn get_template<G: GenericNode>() -> Template<G> {
.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<String> {
Expand All @@ -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
})
}
1 change: 0 additions & 1 deletion examples/i18n/tests/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 8 additions & 3 deletions packages/perseus-actix-web/src/initial_load.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,14 @@ pub async fn initial_load<C: ConfigManager, T: TranslationsManager>(

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(_) => {
Expand Down
11 changes: 10 additions & 1 deletion packages/perseus-actix-web/src/page_data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,16 @@ pub async fn page_data<C: ConfigManager, T: TranslationsManager>(
)
.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())
Expand Down
9 changes: 9 additions & 0 deletions packages/perseus/src/default_headers.rs
Original file line number Diff line number Diff line change
@@ -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<G>`
// TODO
pub(crate) fn default_headers() -> HeaderMap {
let mut map = HeaderMap::new();
map.insert(header::CACHE_CONTROL, "max-age=300".parse().unwrap());
map
}
1 change: 1 addition & 0 deletions packages/perseus/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
33 changes: 30 additions & 3 deletions packages/perseus/src/locale_detector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand All @@ -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;
Expand All @@ -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),
Expand All @@ -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<S: Into<String> + 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 {
Expand All @@ -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()))
}
}
27 changes: 25 additions & 2 deletions packages/perseus/src/template.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -118,6 +120,8 @@ pub type TemplateFn<G> = Rc<dyn Fn(Option<String>) -> SycamoreTemplate<G>>;
/// 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<SsrNode>;
/// The type of functions that modify HTTP response headers.
pub type SetHeadersFn = Rc<dyn Fn(Option<String>) -> HeaderMap>;
/// The type of functions that get build paths.
pub type GetBuildPathsFn = Rc<dyn GetBuildPathsFnType>;
/// The type of functions that get build state.
Expand Down Expand Up @@ -146,6 +150,10 @@ pub struct Template<G: GenericNode> {
/// the same way as `template`, but will always be rendered to a string, whcih will then be interpolated directly into the `<head>`,
/// so reactivity here will not work!
head: TemplateFn<SsrNode>,
/// 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<GetBuildPathsFn>,
Expand Down Expand Up @@ -182,6 +190,9 @@ impl<G: GenericNode> Template<G> {
template: Rc::new(|_: Option<String>| sycamore::template! {}),
// Unlike `template`, this may not be set at all (especially in very simple apps)
head: Rc::new(|_: Option<String>| sycamore::template! {}),
// We create sensible header defaults here
// TODO header defaults
set_headers: Rc::new(|_: Option<String>| default_headers()),
get_build_paths: None,
incremental_generation: false,
get_build_state: None,
Expand Down Expand Up @@ -326,6 +337,12 @@ impl<G: GenericNode> Template<G> {
))
}
}
/// 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<String>) -> 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
Expand Down Expand Up @@ -371,7 +388,8 @@ impl<G: GenericNode> Template<G> {
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()
Expand All @@ -387,10 +405,15 @@ impl<G: GenericNode> Template<G> {
self
}
/// Sets the document head rendering function to use.
pub fn head(mut self, val: TemplateFn<SsrNode>) -> Template<G> {
pub fn head(mut self, val: HeadFn) -> Template<G> {
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<G> {
self.set_headers = val;
self
}
/// Enables the *build paths* strategy with the given function.
pub fn build_paths_fn(mut self, val: GetBuildPathsFn) -> Template<G> {
self.get_build_paths = Some(val);
Expand Down

0 comments on commit 058d625

Please sign in to comment.