use std::collections::HashMap;
use std::future::Future;
use std::num::NonZeroUsize;
use std::path::Path;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use serde::Deserialize;
use sha2::{Digest, Sha256};
pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
pub trait PersistedQueryStore: Send + Sync + 'static {
fn get<'a>(&'a self, hash: &'a str) -> BoxFuture<'a, Option<Arc<str>>>;
fn put<'a>(&'a self, hash: &'a str, document: Arc<str>) -> BoxFuture<'a, ()>;
}
pub struct InMemoryPersistedQueryStore {
cache: Mutex<lru::LruCache<String, Arc<str>>>,
}
impl InMemoryPersistedQueryStore {
#[must_use]
pub fn new(capacity: usize) -> Self {
let capacity = NonZeroUsize::new(capacity).unwrap_or(NonZeroUsize::MIN);
Self {
cache: Mutex::new(lru::LruCache::new(capacity)),
}
}
#[must_use]
pub fn len(&self) -> usize {
self.cache.lock().map_or(0, |cache| cache.len())
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
impl std::fmt::Debug for InMemoryPersistedQueryStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("InMemoryPersistedQueryStore")
.field("len", &self.len())
.finish()
}
}
impl PersistedQueryStore for InMemoryPersistedQueryStore {
fn get<'a>(&'a self, hash: &'a str) -> BoxFuture<'a, Option<Arc<str>>> {
let found = self
.cache
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(hash)
.cloned();
Box::pin(std::future::ready(found))
}
fn put<'a>(&'a self, hash: &'a str, document: Arc<str>) -> BoxFuture<'a, ()> {
self.cache
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.put(hash.to_owned(), document);
Box::pin(std::future::ready(()))
}
}
#[must_use]
pub fn sha256_hex(text: &str) -> String {
use std::fmt::Write as _;
let digest = Sha256::digest(text.as_bytes());
let mut out = String::with_capacity(64);
for byte in digest {
let _ = write!(out, "{byte:02x}");
}
out
}
#[must_use]
pub fn is_sha256_hex(id: &str) -> bool {
id.len() == 64 && id.bytes().all(|b| b.is_ascii_hexdigit())
}
#[derive(Debug, thiserror::Error)]
pub enum ManifestError {
#[error("cannot read persisted-query manifest {path}: {source}")]
Io {
path: String,
source: std::io::Error,
},
#[error("invalid persisted-query manifest: {0}")]
Invalid(String),
}
#[derive(Debug, Clone, Default)]
pub struct TrustedDocuments {
by_id: HashMap<String, Arc<str>>,
by_hash: HashMap<String, Arc<str>>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum ManifestFile {
Apollo {
format: String,
version: u32,
operations: Vec<ManifestOperation>,
},
Flat(HashMap<String, String>),
}
#[derive(Deserialize)]
struct ManifestOperation {
id: String,
body: String,
}
impl TrustedDocuments {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_document(mut self, document: impl Into<String>) -> Self {
let document: String = document.into();
let hash = sha256_hex(&document);
self.insert(hash, document);
self
}
pub fn insert(&mut self, id: impl Into<String>, document: impl Into<String>) {
let document: Arc<str> = Arc::from(document.into());
self.by_hash
.insert(sha256_hex(&document), Arc::clone(&document));
self.by_id
.insert(normalize_id(&id.into()).to_owned(), document);
}
pub fn from_json(text: &str) -> Result<Self, ManifestError> {
let parsed: ManifestFile =
serde_json::from_str(text).map_err(|e| ManifestError::Invalid(e.to_string()))?;
let mut documents = Self::new();
match parsed {
ManifestFile::Apollo {
format,
version,
operations,
} => {
if format != "apollo-persisted-query-manifest" || version != 1 {
return Err(ManifestError::Invalid(format!(
"unsupported manifest format {format:?} version {version}"
)));
}
for op in operations {
documents.insert(op.id, op.body);
}
}
ManifestFile::Flat(map) => {
for (id, body) in map {
let id = normalize_id(&id).to_ascii_lowercase();
if is_sha256_hex(&id) && sha256_hex(&body) != id {
return Err(ManifestError::Invalid(format!(
"entry {id} does not hash to its id"
)));
}
documents.insert(id, body);
}
}
}
Ok(documents)
}
pub fn from_file(path: &Path) -> Result<Self, ManifestError> {
let text = std::fs::read_to_string(path).map_err(|source| ManifestError::Io {
path: path.display().to_string(),
source,
})?;
Self::from_json(&text)
}
pub fn extend(&mut self, other: Self) {
self.by_id.extend(other.by_id);
self.by_hash.extend(other.by_hash);
}
#[must_use]
pub fn get(&self, id: &str) -> Option<&Arc<str>> {
let id = normalize_id(id);
self.by_id
.get(id)
.or_else(|| self.by_hash.get(&id.to_ascii_lowercase()))
}
#[must_use]
pub fn contains_document(&self, document: &str) -> bool {
self.by_hash.contains_key(&sha256_hex(document))
}
#[must_use]
pub fn len(&self) -> usize {
self.by_hash.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.by_hash.is_empty()
}
}
fn normalize_id(id: &str) -> &str {
id.strip_prefix("sha256:").unwrap_or(id)
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
const DOC: &str = "{ notes { id } }";
#[test]
fn sha256_matches_known_vector() {
assert_eq!(
sha256_hex("abc"),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
assert!(is_sha256_hex(&sha256_hex(DOC)));
assert!(!is_sha256_hex("xyz"));
}
#[tokio::test]
async fn lru_store_evicts_least_recently_used() {
let store = InMemoryPersistedQueryStore::new(2);
store.put("a", Arc::from("A")).await;
store.put("b", Arc::from("B")).await;
assert_eq!(store.get("a").await.as_deref(), Some("A"));
store.put("c", Arc::from("C")).await;
assert_eq!(store.get("b").await, None, "b was least recently used");
assert_eq!(store.len(), 2);
assert!(!store.is_empty());
assert!(InMemoryPersistedQueryStore::new(0).is_empty());
}
#[test]
fn apollo_manifest_is_addressable_by_id_and_by_hash() {
let manifest = format!(
r#"{{"format":"apollo-persisted-query-manifest","version":1,
"operations":[{{"id":"notes-v1","name":"Notes","type":"query","body":"{DOC}"}}]}}"#
);
let docs = TrustedDocuments::from_json(&manifest).unwrap();
assert_eq!(docs.get("notes-v1").map(|d| &**d), Some(DOC));
assert_eq!(docs.get(&sha256_hex(DOC)).map(|d| &**d), Some(DOC));
assert!(docs.contains_document(DOC));
assert!(!docs.contains_document("{ other }"));
assert_eq!(docs.len(), 1);
}
#[test]
fn flat_manifest_is_verified() {
let hash = sha256_hex(DOC);
let docs = TrustedDocuments::from_json(&format!(r#"{{"sha256:{hash}":"{DOC}"}}"#)).unwrap();
assert!(docs.get(&format!("sha256:{hash}")).is_some());
assert!(docs.get(&hash.to_ascii_uppercase()).is_some());
let wrong = sha256_hex("{ other }");
assert!(TrustedDocuments::from_json(&format!(r#"{{"{wrong}":"{DOC}"}}"#)).is_err());
}
#[test]
fn bad_manifests_are_rejected() {
assert!(TrustedDocuments::from_json("[]").is_err());
assert!(
TrustedDocuments::from_json(
r#"{"format":"apollo-persisted-query-manifest","version":2,"operations":[]}"#
)
.is_err()
);
assert!(TrustedDocuments::from_file(Path::new("/nonexistent/manifest.json")).is_err());
}
#[test]
fn documents_can_be_added_in_code_and_merged() {
let mut docs = TrustedDocuments::new().with_document(DOC);
let mut other = TrustedDocuments::new();
other.insert("custom", "{ x }");
docs.extend(other);
assert_eq!(docs.len(), 2);
assert!(docs.get("custom").is_some());
assert!(!docs.is_empty());
}
}