use std::collections::BTreeMap;
use std::sync::Arc;
use axum::http::StatusCode;
use serde::Serialize;
use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Component(Arc<str>);
impl Component {
pub fn new(name: impl Into<Arc<str>>) -> Self {
Component(name.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl From<&str> for Component {
fn from(s: &str) -> Self {
Component(Arc::from(s))
}
}
impl From<String> for Component {
fn from(s: String) -> Self {
Component(Arc::from(s))
}
}
impl std::fmt::Display for Component {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(untagged)]
pub enum PageIdentifier {
Number(i64),
Text(String),
}
impl From<i64> for PageIdentifier {
fn from(value: i64) -> Self {
PageIdentifier::Number(value)
}
}
impl From<u32> for PageIdentifier {
fn from(value: u32) -> Self {
PageIdentifier::Number(i64::from(value))
}
}
impl From<&str> for PageIdentifier {
fn from(value: &str) -> Self {
PageIdentifier::Text(value.to_string())
}
}
impl From<String> for PageIdentifier {
fn from(value: String) -> Self {
PageIdentifier::Text(value)
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ScrollProp {
page_name: String,
previous_page: Option<PageIdentifier>,
next_page: Option<PageIdentifier>,
current_page: Option<PageIdentifier>,
reset: bool,
}
impl ScrollProp {
pub fn new(page_name: impl Into<String>) -> Self {
ScrollProp {
page_name: page_name.into(),
previous_page: None,
next_page: None,
current_page: None,
reset: false,
}
}
pub fn current(mut self, page: impl Into<PageIdentifier>) -> Self {
self.current_page = Some(page.into());
self
}
pub fn previous(mut self, page: impl Into<PageIdentifier>) -> Self {
self.previous_page = Some(page.into());
self
}
pub fn next(mut self, page: impl Into<PageIdentifier>) -> Self {
self.next_page = Some(page.into());
self
}
pub fn reset(mut self) -> Self {
self.reset = true;
self
}
pub fn page_name(&self) -> &str {
&self.page_name
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct OncePropEntry {
pub prop: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<u64>,
}
#[derive(Debug, Serialize)]
pub(crate) struct Page {
pub component: String,
pub props: Value,
pub url: String,
pub version: Option<String>,
#[serde(flatten)]
pub metadata: PageMetadata,
}
#[derive(Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PageMetadata {
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub encrypt_history: bool,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub clear_history: bool,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub preserve_fragment: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub flash: Option<Value>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub shared_props: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub merge_props: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub prepend_props: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub deep_merge_props: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub match_props_on: Vec<String>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub scroll_props: BTreeMap<String, ScrollProp>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub once_props: BTreeMap<String, OncePropEntry>,
#[serde(skip_serializing_if = "Option::is_none")]
pub deferred_props: Option<BTreeMap<String, Vec<String>>>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub rescued_props: Vec<String>,
}
impl PageMetadata {
pub(crate) fn apply_options(&mut self, options: PageOptions) {
self.encrypt_history = options.encrypt_history;
self.clear_history = options.clear_history;
self.preserve_fragment = options.preserve_fragment;
self.flash = options.flash;
for key in options.match_props_on {
self.record_match_on(&key);
}
}
pub(crate) fn record_merge(&mut self, strategy: super::props::MergeStrategy, path: &str) {
let entry = path.to_string();
let entries = match strategy {
super::props::MergeStrategy::Merge => &mut self.merge_props,
super::props::MergeStrategy::Prepend => &mut self.prepend_props,
super::props::MergeStrategy::DeepMerge => &mut self.deep_merge_props,
};
if !entries.contains(&entry) {
entries.push(entry);
}
}
pub(crate) fn record_match_on(&mut self, key: &str) {
let entry = key.to_string();
if !self.match_props_on.contains(&entry) {
self.match_props_on.push(entry);
}
}
pub(crate) fn record_scroll(&mut self, prop: &str, scroll: ScrollProp) {
self.scroll_props.insert(prop.to_string(), scroll);
}
pub(crate) fn record_once(&mut self, key: &str, prop: &str, expires_at: Option<u64>) {
self.once_props.insert(
key.to_string(),
OncePropEntry {
prop: prop.to_string(),
expires_at,
},
);
}
pub(crate) fn record_deferred(&mut self, group: &str, path: &str) {
self.deferred_props
.get_or_insert_with(BTreeMap::new)
.entry(group.to_string())
.or_default()
.push(path.to_string());
}
pub(crate) fn record_rescued(&mut self, path: &str) {
self.rescued_props.push(path.to_string());
}
pub(crate) fn record_shared(&mut self, key: &str) {
if !self.shared_props.iter().any(|existing| existing == key) {
self.shared_props.push(key.to_string());
}
}
}
#[derive(Debug, Default, Clone)]
pub struct PageOptions {
pub(crate) encrypt_history: bool,
pub(crate) clear_history: bool,
pub(crate) preserve_fragment: bool,
pub(crate) flash: Option<Value>,
pub(crate) match_props_on: Vec<String>,
pub(crate) status: Option<StatusCode>,
}
impl PageOptions {
pub fn new() -> Self {
Self::default()
}
pub fn encrypt_history(mut self) -> Self {
self.encrypt_history = true;
self
}
pub fn clear_history(mut self) -> Self {
self.clear_history = true;
self
}
pub fn preserve_fragment(mut self) -> Self {
self.preserve_fragment = true;
self
}
pub fn flash(mut self, flash: Value) -> Self {
self.flash = Some(flash);
self
}
pub fn match_props_on<I, S>(mut self, keys: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.match_props_on.extend(keys.into_iter().map(Into::into));
self
}
pub fn status(mut self, status: StatusCode) -> Self {
self.status = Some(status);
self
}
pub(crate) fn resolved_status(&self) -> StatusCode {
self.status.unwrap_or(StatusCode::OK)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn json(metadata: PageMetadata) -> Value {
serde_json::to_value(Page {
component: "users/index".to_string(),
props: Value::Object(serde_json::Map::new()),
url: "/users".to_string(),
version: None,
metadata,
})
.expect("page serializes")
}
#[test]
fn an_absent_asset_version_is_null_and_not_a_missing_key() {
let page = json(PageMetadata::default());
assert!(page.as_object().expect("object").contains_key("version"));
assert_eq!(page["version"], Value::Null);
}
#[test]
fn a_page_with_no_metadata_carries_no_metadata_keys() {
let page = json(PageMetadata::default());
let keys: Vec<&String> = page.as_object().expect("object").keys().collect();
assert_eq!(keys, ["component", "props", "url", "version"]);
}
#[test]
fn an_exhausted_scroll_direction_serializes_as_null_not_zero() {
let mut metadata = PageMetadata::default();
metadata.record_scroll("posts", ScrollProp::new("page").current(1_i64));
let scroll = &json(metadata)["scrollProps"]["posts"];
assert_eq!(scroll["currentPage"], Value::from(1));
assert_eq!(scroll["nextPage"], Value::Null);
assert_eq!(scroll["previousPage"], Value::Null);
assert_eq!(scroll["pageName"], Value::from("page"));
assert_eq!(scroll["reset"], Value::Bool(false));
}
#[test]
fn a_cursor_scroll_identifier_stays_a_string() {
let mut metadata = PageMetadata::default();
metadata.record_scroll("posts", ScrollProp::new("cursor").next("eyJpZCI6MX0"));
assert_eq!(
json(metadata)["scrollProps"]["posts"]["nextPage"],
Value::from("eyJpZCI6MX0")
);
}
#[test]
fn a_once_entry_without_a_ttl_omits_expires_at() {
let mut metadata = PageMetadata::default();
metadata.record_once("dashboard", "stats", None);
let entry = &json(metadata)["onceProps"]["dashboard"];
assert_eq!(entry["prop"], Value::from("stats"));
assert!(!entry.as_object().expect("object").contains_key("expiresAt"));
}
#[test]
fn a_once_entry_with_a_ttl_reports_a_millisecond_epoch() {
let mut metadata = PageMetadata::default();
metadata.record_once("dashboard", "stats", Some(1_700_000_000_000));
assert_eq!(
json(metadata)["onceProps"]["dashboard"]["expiresAt"],
Value::from(1_700_000_000_000_u64)
);
}
#[test]
fn match_props_on_does_not_repeat_a_key() {
let mut metadata = PageMetadata::default();
metadata.record_match_on("posts.data.id");
metadata.record_match_on("posts.data.id");
assert_eq!(metadata.match_props_on, ["posts.data.id"]);
}
#[test]
fn page_level_match_keys_reach_the_metadata() {
let mut metadata = PageMetadata::default();
metadata.apply_options(PageOptions::new().match_props_on(["posts.data.id", "tags.slug"]));
assert_eq!(metadata.match_props_on, ["posts.data.id", "tags.slug"]);
}
#[test]
fn the_default_page_status_is_ok() {
assert_eq!(PageOptions::new().resolved_status(), StatusCode::OK);
assert_eq!(
PageOptions::new()
.status(StatusCode::NOT_FOUND)
.resolved_status(),
StatusCode::NOT_FOUND
);
}
}