use std::future::Future;
pub struct SeedEntry {
pub key: serde_json::Value,
pub data: serde_json::Value,
}
#[derive(Default)]
pub struct QuerySeed {
entries: Vec<SeedEntry>,
}
pub trait IntoSeedEntry {
fn into_seed_entry(self) -> Option<SeedEntry>;
}
impl IntoSeedEntry for SeedEntry {
fn into_seed_entry(self) -> Option<SeedEntry> {
Some(self)
}
}
impl IntoSeedEntry for Option<SeedEntry> {
fn into_seed_entry(self) -> Option<SeedEntry> {
self
}
}
impl QuerySeed {
pub fn new() -> Self {
Self::default()
}
pub async fn seed(mut self, entry: impl Future<Output = impl IntoSeedEntry>) -> Self {
if let Some(entry) = entry.await.into_seed_entry() {
self.entries.push(entry);
}
self
}
pub fn to_json(&self) -> serde_json::Value {
serde_json::Value::Array(
self.entries
.iter()
.map(|e| {
serde_json::json!({
"key": e.key,
"data": e.data,
})
})
.collect(),
)
}
pub fn to_script_tag(&self) -> String {
let safe = self.to_json().to_string().replace('<', "\\u003c");
format!(
r#"<script type="application/json" id="__nx_seeds__">{}</script>"#,
safe
)
}
}
pub fn seed_key(url: &str, params: Option<serde_json::Value>) -> serde_json::Value {
match params {
Some(p) => serde_json::json!([url, p]),
None => serde_json::json!([url]),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn seed_key_shapes_match_generated_client() {
assert_eq!(
seed_key("/api/todos", None).to_string(),
r#"["/api/todos"]"#
);
assert_eq!(
seed_key("/api/todos", Some(serde_json::json!({"status": "open"}))).to_string(),
r#"["/api/todos",{"status":"open"}]"#
);
}
#[tokio::test]
async fn script_tag_escapes_angle_brackets() {
let seed = QuerySeed::new()
.seed(async {
SeedEntry {
key: seed_key("/api/x", None),
data: serde_json::json!({"html": "</script><script>alert(1)</script>"}),
}
})
.await;
let tag = seed.to_script_tag();
assert!(tag.starts_with(r#"<script type="application/json" id="__nx_seeds__">"#));
let inner = &tag[tag.find('>').unwrap() + 1..tag.rfind("</script>").unwrap()];
assert!(!inner.contains('<'), "unescaped < in payload: {}", inner);
let parsed: serde_json::Value = serde_json::from_str(inner).unwrap();
assert_eq!(
parsed[0]["data"]["html"],
serde_json::json!("</script><script>alert(1)</script>")
);
}
#[tokio::test]
async fn seed_accepts_optional_entries() {
let seed = QuerySeed::new()
.seed(async {
Some(SeedEntry {
key: seed_key("/a", None),
data: serde_json::json!(1),
})
})
.await
.seed(async { None::<SeedEntry> })
.await;
let json = seed.to_json();
assert_eq!(json.as_array().unwrap().len(), 1);
assert_eq!(json[0]["key"], serde_json::json!(["/a"]));
}
#[tokio::test]
async fn entries_accumulate_in_order() {
let seed = QuerySeed::new()
.seed(async {
SeedEntry {
key: seed_key("/a", None),
data: serde_json::json!(1),
}
})
.await
.seed(async {
SeedEntry {
key: seed_key("/b", None),
data: serde_json::json!(2),
}
})
.await;
let tag = seed.to_script_tag();
assert!(tag.find("/a").unwrap() < tag.find("/b").unwrap());
}
}