tako_rs_core/graphql/apq.rs
1//! Apollo Persisted Queries (APQ) support.
2//!
3//! APQ flow:
4//!
5//! 1. Client sends `{extensions: {persistedQuery: {sha256Hash, version: 1}}}`
6//! without a `query` field.
7//! 2. Server looks up the hash in a [`PersistedQueryStore`](crate::graphql::apq::PersistedQueryStore).
8//! - **Hit:** populate the request `query` from the cache and execute.
9//! - **Miss:** respond with `PERSISTED_QUERY_NOT_FOUND`. Client retries
10//! with the full `query`.
11//! 3. Server caches the `(hash, query)` pair on first full submission.
12//!
13//! This module exposes the [`PersistedQueryStore`](crate::graphql::apq::PersistedQueryStore) trait, an in-memory
14//! implementation, and the [`process`](crate::graphql::apq::process) helper that walks an `async_graphql`
15//! request through the lookup-or-store flow. It is a thin wrapper — the
16//! actual `GraphQL` execution still goes through the `async-graphql` schema.
17
18use std::sync::Arc;
19
20use async_trait::async_trait;
21use scc::HashMap as SccHashMap;
22use sha2::Digest;
23use sha2::Sha256;
24
25/// Store backing the persisted-query cache.
26#[async_trait]
27pub trait PersistedQueryStore: Send + Sync + 'static {
28 /// Retrieve a cached query by its SHA-256 hex hash.
29 async fn get(&self, hash: &str) -> Option<String>;
30 /// Cache a `(hash, query)` pair.
31 async fn put(&self, hash: String, query: String);
32}
33
34/// Default in-memory store backed by `scc::HashMap` with a hard-capped entry
35/// count. The previous implementation was unbounded, which let any client
36/// flood the cache with `(unique-hash, full-query)` pairs and OOM the
37/// process. Use [`Self::with_max_entries`] to size the cap; the default is
38/// 1024 entries.
39///
40/// When the cap is reached, the store performs a bulk flush rather than LRU
41/// eviction (the underlying `scc::HashMap` does not expose ordering hooks).
42/// Callers that need finer-grained eviction should wrap their own store
43/// implementing [`PersistedQueryStore`].
44///
45/// **Soft cap, not hard:** the `len() >= cap → clear() → insert()` sequence
46/// in [`PersistedQueryStore::put`] is not atomic. Concurrent puts each
47/// observe `len < cap`, all proceed past the check, and each insert lands —
48/// the effective ceiling is `cap + concurrent_put_count`. The race is
49/// memory-safe (scc takes care of the per-entry locking) and only ever
50/// over-shoots by the number of in-flight `put`s, which is bounded by your
51/// request concurrency. If you need a hard cap, layer in your own
52/// `PersistedQueryStore` impl with `Mutex<HashMap>` semantics.
53#[derive(Clone)]
54pub struct MemoryPersistedQueryStore {
55 inner: Arc<SccHashMap<String, String>>,
56 max_entries: usize,
57}
58
59impl Default for MemoryPersistedQueryStore {
60 fn default() -> Self {
61 Self::new()
62 }
63}
64
65impl MemoryPersistedQueryStore {
66 /// Create a store with the default 1024-entry cap.
67 pub fn new() -> Self {
68 Self::with_max_entries(1024)
69 }
70
71 /// Create a store that admits at most `max_entries` cached `(hash, query)`
72 /// pairs before the next insert triggers a full flush.
73 pub fn with_max_entries(max_entries: usize) -> Self {
74 Self {
75 inner: Arc::new(SccHashMap::new()),
76 max_entries: max_entries.max(1),
77 }
78 }
79}
80
81#[async_trait]
82impl PersistedQueryStore for MemoryPersistedQueryStore {
83 async fn get(&self, hash: &str) -> Option<String> {
84 self.inner.get_async(hash).await.map(|e| e.get().clone())
85 }
86
87 async fn put(&self, hash: String, query: String) {
88 if self.inner.len() >= self.max_entries {
89 self.inner.clear_async().await;
90 }
91 let _ = self.inner.insert_async(hash, query).await;
92 }
93}
94
95/// Errors emitted by the APQ pipeline.
96#[derive(Debug, Clone)]
97pub enum ApqError {
98 /// Client referenced a hash the store does not know — instruct the client
99 /// to retry with the full query.
100 PersistedQueryNotFound,
101 /// Client supplied both a query and a hash but they don't match.
102 HashMismatch,
103 /// `extensions.persistedQuery.version` was not `1`.
104 UnsupportedVersion,
105}
106
107impl ApqError {
108 /// `PERSISTED_QUERY_NOT_FOUND` is the canonical Apollo extensions code.
109 pub fn extensions_code(&self) -> &'static str {
110 match self {
111 ApqError::PersistedQueryNotFound => "PERSISTED_QUERY_NOT_FOUND",
112 ApqError::HashMismatch => "PERSISTED_QUERY_HASH_MISMATCH",
113 ApqError::UnsupportedVersion => "PERSISTED_QUERY_UNSUPPORTED_VERSION",
114 }
115 }
116}
117
118/// Compute the lowercase hex SHA-256 of a query string.
119pub fn sha256_hash(query: &str) -> String {
120 use std::fmt::Write as _;
121 let digest = Sha256::digest(query.as_bytes());
122 let mut hex = String::with_capacity(64);
123 // `write!` formats directly into the existing buffer; the old
124 // `format!("{b:02x}")` allocated a fresh 2-byte String per nibble
125 // pair (32 throwaway allocations per hash) on the APQ hot path.
126 for b in digest {
127 let _ = write!(&mut hex, "{b:02x}");
128 }
129 hex
130}
131
132/// Process an `async_graphql::Request` against the persisted-query store.
133///
134/// - When the request carries `extensions.persistedQuery.sha256Hash`:
135/// - if `query` is empty: look up the hash in the store; on miss return
136/// `PersistedQueryNotFound`.
137/// - if `query` is present: verify the hash matches; on success cache it.
138/// - When no persisted-query extension is present: pass-through.
139#[cfg(feature = "async-graphql")]
140pub async fn process(
141 mut req: async_graphql::Request,
142 store: &dyn PersistedQueryStore,
143) -> Result<async_graphql::Request, ApqError> {
144 use async_graphql::Value;
145
146 let Some(Value::Object(pq)) = req.extensions.get("persistedQuery").cloned() else {
147 return Ok(req);
148 };
149
150 let version = pq
151 .get("version")
152 .and_then(|v| match v {
153 Value::Number(n) => n.as_u64(),
154 _ => None,
155 })
156 .unwrap_or(1);
157 if version != 1 {
158 return Err(ApqError::UnsupportedVersion);
159 }
160
161 let hash: Option<String> = pq.get("sha256Hash").and_then(|v| match v {
162 Value::String(s) => Some(s.clone()),
163 _ => None,
164 });
165
166 let Some(hash) = hash else {
167 return Ok(req);
168 };
169
170 if req.query.is_empty() {
171 if let Some(query) = store.get(&hash).await {
172 req.query = query;
173 Ok(req)
174 } else {
175 Err(ApqError::PersistedQueryNotFound)
176 }
177 } else {
178 let computed = sha256_hash(&req.query);
179 if computed == hash {
180 let q = req.query.clone();
181 store.put(hash, q).await;
182 Ok(req)
183 } else {
184 Err(ApqError::HashMismatch)
185 }
186 }
187}