Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add HTTP header setting #25

Merged
merged 4 commits into from
Sep 25, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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