use std::collections::{HashMap, HashSet};
use crate::graphql::{
document::{Document, Operation},
types::{FieldSelection, FragmentDefinition, GraphQLArgument},
};
pub type SharedDocument = std::sync::Arc<Document>;
pub type DocumentParse = Result<SharedDocument, ComplexityValidationError>;
pub fn parse_graphql_document(query: &str) -> DocumentParse {
let in_scope = REQUEST_DOCUMENT.try_with(|slot| {
let scope = slot.borrow();
let kept = scope.kept.as_ref().and_then(|kept| kept.parse_of(query));
(kept, scope.documents.clone())
});
let documents = match in_scope {
Ok((Some(parse), _)) => return parse,
Ok((None, documents)) => documents,
Err(_) => None,
};
let kept = if let Some(entry) = documents.as_ref().and_then(|d| d.get(query)) {
Kept::Cached(entry)
} else {
let parse = parse_uncached(query).map(SharedDocument::new);
match (&documents, parse) {
(Some(documents), Ok(document)) => Kept::Cached(documents.insert(query, document)),
(_, parse) => Kept::Parsed(query.into(), parse),
}
};
let parse = kept.parse();
let _ = REQUEST_DOCUMENT.try_with(|slot| {
let mut scope = slot.borrow_mut();
if scope.kept.is_none() {
scope.kept = Some(kept);
}
});
parse
}
#[derive(Default)]
struct RequestScope {
kept: Option<Kept>,
documents: Option<DocumentCache>,
}
enum Kept {
Cached(std::sync::Arc<(Box<str>, SharedDocument)>),
Parsed(Box<str>, DocumentParse),
}
impl Kept {
fn parse(&self) -> DocumentParse {
match self {
Self::Cached(entry) => Ok(entry.1.clone()),
Self::Parsed(_, parse) => parse.clone(),
}
}
fn parse_of(&self, query: &str) -> Option<DocumentParse> {
let source = match self {
Self::Cached(entry) => &entry.0,
Self::Parsed(source, _) => source,
};
(**source == *query).then(|| self.parse())
}
}
tokio::task_local! {
static REQUEST_DOCUMENT: std::cell::RefCell<RequestScope>;
}
pub async fn document_scope<F: std::future::Future>(future: F) -> F::Output {
scope_with(None, future).await
}
async fn scope_with<F: std::future::Future>(
documents: Option<DocumentCache>,
future: F,
) -> F::Output {
let joined = REQUEST_DOCUMENT.try_with(|slot| {
let mut scope = slot.borrow_mut();
if scope.documents.is_none() {
scope.documents.clone_from(&documents);
}
});
if joined.is_ok() {
future.await
} else {
let scope = RequestScope {
kept: None,
documents,
};
REQUEST_DOCUMENT.scope(std::cell::RefCell::new(scope), future).await
}
}
#[derive(Clone)]
pub struct DocumentCache(
pub(super) moka::sync::Cache<u64, std::sync::Arc<(Box<str>, SharedDocument)>>,
);
impl DocumentCache {
#[must_use]
pub fn new(capacity: u64) -> Self {
Self(moka::sync::Cache::new(capacity))
}
pub(super) fn key(source: &str) -> u64 {
xxhash_rust::xxh3::xxh3_64(source.as_bytes())
}
fn get(&self, source: &str) -> Option<std::sync::Arc<(Box<str>, SharedDocument)>> {
let entry = self.0.get(&Self::key(source))?;
(*entry.0 == *source).then_some(entry)
}
fn insert(
&self,
source: &str,
document: SharedDocument,
) -> std::sync::Arc<(Box<str>, SharedDocument)> {
let entry = std::sync::Arc::new((source.into(), document));
self.0.insert(Self::key(source), entry.clone());
entry
}
pub async fn scope<F: std::future::Future>(&self, future: F) -> F::Output {
scope_with(Some(self.clone()), future).await
}
}
impl std::fmt::Debug for DocumentCache {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DocumentCache").field("entries", &self.0.entry_count()).finish()
}
}
fn parse_uncached(query: &str) -> Result<Document, ComplexityValidationError> {
#[cfg(any(test, feature = "test-utils"))]
PARSES_ON_THIS_THREAD.with(|n| n.set(n.get() + 1));
if query.trim().is_empty() {
return Err(ComplexityValidationError::MalformedQuery("Empty query".to_string()));
}
match std::panic::catch_unwind(|| crate::graphql::document::parse(query)) {
Ok(result) => result.map_err(|e| ComplexityValidationError::MalformedQuery(e.to_string())),
Err(_) => Err(ComplexityValidationError::MalformedQuery(
"the GraphQL parser failed on this document".to_string(),
)),
}
}
#[cfg(any(test, feature = "test-utils"))]
thread_local! {
static PARSES_ON_THIS_THREAD: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
#[cfg(any(test, feature = "test-utils"))]
#[must_use]
pub fn document_parses_on_this_thread() -> usize {
PARSES_ON_THIS_THREAD.with(std::cell::Cell::get)
}
pub const DEFAULT_MAX_ALIASES: usize = 30;
pub const MAX_VARIABLES_COUNT: usize = 1_000;
#[derive(Debug, Clone)]
pub struct ComplexityConfig {
pub max_depth: usize,
pub max_complexity: usize,
pub max_aliases: usize,
}
impl Default for ComplexityConfig {
fn default() -> Self {
Self {
max_depth: 10,
max_complexity: 100,
max_aliases: DEFAULT_MAX_ALIASES,
}
}
}
#[derive(Debug, Clone)]
pub(super) struct Scored {
limits: (usize, usize),
metrics: QueryMetrics,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QueryMetrics {
pub depth: usize,
pub complexity: usize,
pub alias_count: usize,
}
#[derive(Debug, thiserror::Error, Clone)]
#[non_exhaustive]
pub enum ComplexityValidationError {
#[error("Query exceeds maximum depth of {max_depth}: depth = {actual_depth}")]
QueryTooDeep {
max_depth: usize,
actual_depth: usize,
},
#[error("Query exceeds maximum complexity of {max_complexity}: score = {actual_complexity}")]
QueryTooComplex {
max_complexity: usize,
actual_complexity: usize,
},
#[error("Query exceeds maximum alias count of {max_aliases}: count = {actual_aliases}")]
TooManyAliases {
max_aliases: usize,
actual_aliases: usize,
},
#[error("Invalid variables: {0}")]
InvalidVariables(String),
#[error("Malformed GraphQL query: {0}")]
MalformedQuery(String),
}
#[derive(Debug, Clone)]
pub struct RequestValidator {
max_depth: usize,
max_complexity: usize,
max_aliases_per_query: usize,
validate_depth: bool,
validate_complexity: bool,
}
impl RequestValidator {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub const fn from_config(config: &ComplexityConfig) -> Self {
Self {
max_depth: config.max_depth,
max_complexity: config.max_complexity,
max_aliases_per_query: config.max_aliases,
validate_depth: true,
validate_complexity: true,
}
}
#[must_use]
pub const fn with_max_depth(mut self, max_depth: usize) -> Self {
self.max_depth = max_depth;
self
}
#[must_use]
pub const fn with_max_complexity(mut self, max_complexity: usize) -> Self {
self.max_complexity = max_complexity;
self
}
#[must_use]
pub const fn with_depth_validation(mut self, enabled: bool) -> Self {
self.validate_depth = enabled;
self
}
#[must_use]
pub const fn with_complexity_validation(mut self, enabled: bool) -> Self {
self.validate_complexity = enabled;
self
}
#[must_use]
pub const fn with_max_aliases(mut self, max_aliases: usize) -> Self {
self.max_aliases_per_query = max_aliases;
self
}
pub fn analyze(&self, query: &str) -> Result<QueryMetrics, ComplexityValidationError> {
self.analyze_with_variables(query, None)
}
pub fn analyze_with_variables(
&self,
query: &str,
variables: Option<&serde_json::Value>,
) -> Result<QueryMetrics, ComplexityValidationError> {
if query.trim().is_empty() {
return Err(ComplexityValidationError::MalformedQuery("Empty query".to_string()));
}
let document = parse_graphql_document(query)?;
Ok(self.document_metrics(&document, variables))
}
fn document_metrics<'a>(
&self,
document: &'a Document,
variables: Option<&'a serde_json::Value>,
) -> QueryMetrics {
let limits = (self.max_depth, self.max_complexity);
if let Some(scored) = document.scored.get().filter(|scored| scored.limits == limits) {
return scored.metrics.clone();
}
let fragments = collect_fragments(document);
let mut analyzer = DocumentAnalyzer::new(
document,
&fragments,
self.max_depth,
self.max_complexity,
variables,
);
let metrics = analyzer.analyze_document(document);
if !analyzer.read_a_variable {
let _ = document.scored.set(Scored {
limits,
metrics: metrics.clone(),
});
}
metrics
}
#[must_use]
pub const fn is_no_op(&self) -> bool {
!self.validate_depth && !self.validate_complexity && self.max_aliases_per_query == 0
}
pub fn validate_query(&self, query: &str) -> Result<(), ComplexityValidationError> {
if query.trim().is_empty() {
return Err(ComplexityValidationError::MalformedQuery("Empty query".to_string()));
}
if self.is_no_op() {
return Ok(());
}
let document = parse_graphql_document(query)?;
self.validate_query_doc(&document, None)
}
pub fn validate_query_doc<'a>(
&self,
document: &'a Document,
variables: Option<&'a serde_json::Value>,
) -> Result<(), ComplexityValidationError> {
if self.is_no_op() {
return Ok(());
}
let metrics = self.document_metrics(document, variables);
if self.validate_depth && metrics.depth > self.max_depth {
return Err(ComplexityValidationError::QueryTooDeep {
max_depth: self.max_depth,
actual_depth: metrics.depth,
});
}
if self.validate_complexity && metrics.complexity > self.max_complexity {
return Err(ComplexityValidationError::QueryTooComplex {
max_complexity: self.max_complexity,
actual_complexity: metrics.complexity,
});
}
if metrics.alias_count > self.max_aliases_per_query {
return Err(ComplexityValidationError::TooManyAliases {
max_aliases: self.max_aliases_per_query,
actual_aliases: metrics.alias_count,
});
}
Ok(())
}
pub fn validate_variables(
&self,
variables: Option<&serde_json::Value>,
) -> Result<(), ComplexityValidationError> {
if let Some(vars) = variables {
if !vars.is_object() {
return Err(ComplexityValidationError::InvalidVariables(
"Variables must be an object".to_string(),
));
}
let obj = vars.as_object().expect("invariant: vars.is_object() checked above");
if obj.len() > MAX_VARIABLES_COUNT {
return Err(ComplexityValidationError::InvalidVariables(format!(
"Too many variables: {} exceeds maximum of {}",
obj.len(),
MAX_VARIABLES_COUNT
)));
}
}
Ok(())
}
}
const FRAGMENT_SPREAD_DEPTH_LIMIT: usize = 32;
#[derive(Debug, Clone, Copy)]
struct SelectionMetrics {
depth: usize,
complexity: usize,
aliases: usize,
}
struct DocumentAnalyzer<'a> {
by_name: HashMap<&'a str, &'a FragmentDefinition>,
memo: HashMap<&'a str, SelectionMetrics>,
visiting: HashSet<&'a str>,
max_depth: usize,
max_complexity: usize,
variables: Option<&'a serde_json::Map<String, serde_json::Value>>,
defaults: HashMap<&'a str, i64>,
read_a_variable: bool,
}
impl<'a> DocumentAnalyzer<'a> {
fn new(
document: &'a Document,
fragments: &[&'a FragmentDefinition],
max_depth: usize,
max_complexity: usize,
variables: Option<&'a serde_json::Value>,
) -> Self {
let mut defaults: HashMap<&'a str, i64> = HashMap::new();
for op in &document.operations {
for var in &op.variables {
let default = var.default_value.as_deref().and_then(|json| {
crate::graphql::value_json::decode(json).ok().and_then(|v| v.as_i64())
});
if let Some(n) = default {
let slot = defaults.entry(var.name.as_str()).or_insert(n);
*slot = (*slot).max(n);
}
}
}
let mut by_name = HashMap::with_capacity(fragments.len());
for frag in fragments {
by_name.entry(frag.name.as_str()).or_insert(*frag);
}
Self {
by_name,
memo: HashMap::new(),
visiting: HashSet::new(),
max_depth,
max_complexity,
variables: variables.and_then(serde_json::Value::as_object),
defaults,
read_a_variable: false,
}
}
const fn over_limit(&self) -> SelectionMetrics {
SelectionMetrics {
depth: self.max_depth.saturating_add(1),
complexity: self.max_complexity.saturating_add(1),
aliases: 0,
}
}
fn analyze_document(&mut self, document: &'a Document) -> QueryMetrics {
let mut depth = 0usize;
let mut complexity = 0usize;
let mut aliases = 0usize;
for op in &document.operations {
let m = self.selection_metrics(&op.selections, FRAGMENT_SPREAD_DEPTH_LIMIT);
depth = depth.max(m.depth);
complexity = complexity.saturating_add(m.complexity);
aliases = aliases.saturating_add(m.aliases);
}
for f in &document.fragments {
let m = self.resolve_fragment(f.name.as_str(), FRAGMENT_SPREAD_DEPTH_LIMIT);
depth = depth.max(m.depth);
}
QueryMetrics {
depth,
complexity,
alias_count: aliases,
}
}
fn resolve_fragment(&mut self, name: &'a str, budget: usize) -> SelectionMetrics {
if let Some(m) = self.memo.get(name) {
return *m;
}
if self.visiting.contains(name) || budget == 0 {
return self.over_limit();
}
let Some(frag) = self.by_name.get(name).copied() else {
let m = SelectionMetrics {
depth: self.max_depth,
complexity: 10,
aliases: 0,
};
self.memo.insert(name, m);
return m;
};
self.visiting.insert(name);
let m = self.selection_metrics(&frag.selections, budget - 1);
self.visiting.remove(name);
self.memo.insert(name, m);
m
}
fn selection_metrics(
&mut self,
selection_set: &'a [FieldSelection],
budget: usize,
) -> SelectionMetrics {
let mut max_child_depth = 0usize;
let mut complexity = 0usize;
let mut aliases = 0usize;
for sel in selection_set {
let child = match Kind::of(sel) {
Kind::Field => {
let self_alias = usize::from(sel.alias.is_some());
if sel.nested_fields.is_empty() {
SelectionMetrics {
depth: 0,
complexity: 1,
aliases: self_alias,
}
} else {
let nested = self.selection_metrics(&sel.nested_fields, budget);
let multiplier = self.limit_multiplier(&sel.arguments);
SelectionMetrics {
depth: nested.depth,
complexity: 1usize
.saturating_add(nested.complexity.saturating_mul(multiplier)),
aliases: self_alias.saturating_add(nested.aliases),
}
}
},
Kind::InlineFragment => self.selection_metrics(&sel.nested_fields, budget),
Kind::Spread(name) => self.resolve_fragment(name, budget),
};
max_child_depth = max_child_depth.max(child.depth);
complexity = complexity.saturating_add(child.complexity);
aliases = aliases.saturating_add(child.aliases);
}
let depth = if selection_set.is_empty() {
0
} else {
max_child_depth.saturating_add(1)
};
SelectionMetrics {
depth,
complexity,
aliases,
}
}
fn limit_multiplier(&mut self, arguments: &'a [GraphQLArgument]) -> usize {
let (multiplier, from_a_variable) =
extract_limit_multiplier(arguments, self.variables, &self.defaults);
self.read_a_variable |= from_a_variable;
multiplier
}
fn field_complexity(&mut self, field: &'a FieldSelection, budget: usize) -> usize {
if field.nested_fields.is_empty() {
1
} else {
let nested = self.selection_metrics(&field.nested_fields, budget);
let multiplier = self.limit_multiplier(&field.arguments);
1usize.saturating_add(nested.complexity.saturating_mul(multiplier))
}
}
fn document_cost<S: std::hash::BuildHasher>(
&mut self,
document: &'a Document,
root_weights: &HashMap<String, usize, S>,
) -> usize {
document.operations.iter().fold(0usize, |cost, op: &'a Operation| {
cost.saturating_add(self.root_cost(&op.selections, root_weights))
})
}
fn root_cost<S: std::hash::BuildHasher>(
&mut self,
selection_set: &'a [FieldSelection],
root_weights: &HashMap<String, usize, S>,
) -> usize {
let mut cost = 0usize;
for sel in selection_set {
let c = match Kind::of(sel) {
Kind::Field => root_weights
.get(sel.name.as_str())
.copied()
.unwrap_or_else(|| self.field_complexity(sel, FRAGMENT_SPREAD_DEPTH_LIMIT)),
Kind::InlineFragment => self.root_cost(&sel.nested_fields, root_weights),
Kind::Spread(name) => {
self.resolve_fragment(name, FRAGMENT_SPREAD_DEPTH_LIMIT).complexity
},
};
cost = cost.saturating_add(c);
}
cost
}
}
enum Kind<'a> {
Field,
InlineFragment,
Spread(&'a str),
}
impl<'a> Kind<'a> {
fn of(selection: &'a FieldSelection) -> Self {
match selection.name.strip_prefix("...") {
None => Self::Field,
Some(rest) if rest.starts_with("on ") => Self::InlineFragment,
Some(fragment) => Self::Spread(fragment),
}
}
}
#[must_use]
pub fn estimate_query_cost<'a, S: std::hash::BuildHasher>(
document: &'a Document,
root_cost_weights: &HashMap<String, usize, S>,
variables: Option<&'a serde_json::Value>,
) -> usize {
let fragments = collect_fragments(document);
let mut analyzer =
DocumentAnalyzer::new(document, &fragments, usize::MAX, usize::MAX, variables);
analyzer.document_cost(document, root_cost_weights)
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct DirectReadProjection {
pub leaf_fields: usize,
pub limit: Option<u32>,
pub nested: Vec<DirectReadProjection>,
}
impl DirectReadProjection {
#[must_use]
pub const fn flat(leaf_fields: usize, limit: Option<u32>) -> Self {
Self {
leaf_fields,
limit,
nested: Vec::new(),
}
}
fn complexity(&self) -> usize {
let inner = self
.nested
.iter()
.fold(self.leaf_fields, |acc, level| acc.saturating_add(level.complexity()));
if inner == 0 {
return 1;
}
let multiplier =
self.limit.map_or(1, |l| (l as usize).clamp(1, PAGINATION_MULTIPLIER_CEILING));
1usize.saturating_add(inner.saturating_mul(multiplier))
}
}
#[must_use]
pub fn estimate_direct_read_cost<S: std::hash::BuildHasher>(
query_name: &str,
root_cost_weights: &HashMap<String, usize, S>,
projection: &DirectReadProjection,
) -> usize {
if let Some(weight) = root_cost_weights.get(query_name) {
return *weight;
}
projection.complexity()
}
impl Default for RequestValidator {
fn default() -> Self {
Self {
max_depth: 10,
max_complexity: 100,
max_aliases_per_query: DEFAULT_MAX_ALIASES,
validate_depth: true,
validate_complexity: true,
}
}
}
fn collect_fragments(document: &Document) -> Vec<&FragmentDefinition> {
document.fragments.iter().collect()
}
const PAGINATION_MULTIPLIER_CEILING: usize = 100;
fn extract_limit_multiplier(
arguments: &[GraphQLArgument],
variables: Option<&serde_json::Map<String, serde_json::Value>>,
defaults: &HashMap<&str, i64>,
) -> (usize, bool) {
for argument in arguments {
if !matches!(argument.name.as_str(), "first" | "limit" | "take" | "last") {
continue;
}
let Ok(value) = crate::graphql::value_json::decode(&argument.value_json) else {
continue;
};
if let Some(var) = crate::graphql::value_json::variable_name(&value) {
let supplied = variables.and_then(|vars| vars.get(var));
let multiplier = supplied
.map_or_else(|| defaults.get(var).copied(), serde_json::Value::as_i64)
.map_or(PAGINATION_MULTIPLIER_CEILING, |n| {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let limit = n as usize;
limit.clamp(1, PAGINATION_MULTIPLIER_CEILING)
});
return (multiplier, true);
}
if argument.value_type == "int" {
if let Some(n) = value.as_i64() {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let limit = n as usize;
return (limit.clamp(1, PAGINATION_MULTIPLIER_CEILING), false);
}
}
}
(1, false)
}
#[cfg(test)]
#[path = "complexity_tests.rs"]
mod complexity_tests;