Skip to main content

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}