use std::collections::{HashMap, HashSet};
use graphql_parser::query::{
Definition, Document, Field, FragmentDefinition, OperationDefinition, Selection, SelectionSet,
};
pub fn parse_graphql_document(
query: &str,
) -> Result<Document<'_, String>, ComplexityValidationError> {
if query.trim().is_empty() {
return Err(ComplexityValidationError::MalformedQuery("Empty query".to_string()));
}
graphql_parser::parse_query::<String>(query)
.map_err(|e| ComplexityValidationError::MalformedQuery(format!("{e}")))
}
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, 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> {
if query.trim().is_empty() {
return Err(ComplexityValidationError::MalformedQuery("Empty query".to_string()));
}
let document = graphql_parser::parse_query::<String>(query)
.map_err(|e| ComplexityValidationError::MalformedQuery(format!("{e}")))?;
Ok(self.document_metrics(&document))
}
fn document_metrics<'a>(&self, document: &'a Document<'a, String>) -> QueryMetrics {
let fragments = collect_fragments(document);
let mut analyzer = DocumentAnalyzer::new(&fragments, self.max_depth, self.max_complexity);
analyzer.analyze_document(document)
}
#[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 = graphql_parser::parse_query::<String>(query)
.map_err(|e| ComplexityValidationError::MalformedQuery(format!("{e}")))?;
self.validate_query_doc(&document)
}
pub fn validate_query_doc<'a>(
&self,
document: &'a Document<'a, String>,
) -> Result<(), ComplexityValidationError> {
if self.is_no_op() {
return Ok(());
}
let metrics = self.document_metrics(document);
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<'a, String>>,
memo: HashMap<&'a str, SelectionMetrics>,
visiting: HashSet<&'a str>,
max_depth: usize,
max_complexity: usize,
}
impl<'a> DocumentAnalyzer<'a> {
fn new(
fragments: &[&'a FragmentDefinition<'a, String>],
max_depth: usize,
max_complexity: usize,
) -> Self {
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,
}
}
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<'a, String>) -> QueryMetrics {
let mut depth = 0usize;
let mut complexity = 0usize;
let mut aliases = 0usize;
for def in &document.definitions {
match def {
Definition::Operation(op) => {
let m = self.selection_metrics(
operation_selection_set(op),
FRAGMENT_SPREAD_DEPTH_LIMIT,
);
depth = depth.max(m.depth);
complexity = complexity.saturating_add(m.complexity);
aliases = aliases.saturating_add(m.aliases);
},
Definition::Fragment(f) => {
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.selection_set, budget - 1);
self.visiting.remove(name);
self.memo.insert(name, m);
m
}
fn selection_metrics(
&mut self,
selection_set: &'a SelectionSet<'a, String>,
budget: usize,
) -> SelectionMetrics {
let mut max_child_depth = 0usize;
let mut complexity = 0usize;
let mut aliases = 0usize;
for sel in &selection_set.items {
let child = match sel {
Selection::Field(field) => {
let self_alias = usize::from(field.alias.is_some());
if field.selection_set.items.is_empty() {
SelectionMetrics {
depth: 0,
complexity: 1,
aliases: self_alias,
}
} else {
let nested = self.selection_metrics(&field.selection_set, budget);
let multiplier = extract_limit_multiplier(&field.arguments);
SelectionMetrics {
depth: nested.depth,
complexity: 1usize
.saturating_add(nested.complexity.saturating_mul(multiplier)),
aliases: self_alias.saturating_add(nested.aliases),
}
}
},
Selection::InlineFragment(inline) => {
self.selection_metrics(&inline.selection_set, budget)
},
Selection::FragmentSpread(spread) => {
self.resolve_fragment(spread.fragment_name.as_str(), 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.items.is_empty() {
0
} else {
max_child_depth.saturating_add(1)
};
SelectionMetrics {
depth,
complexity,
aliases,
}
}
fn field_complexity(&mut self, field: &'a Field<'a, String>, budget: usize) -> usize {
if field.selection_set.items.is_empty() {
1
} else {
let nested = self.selection_metrics(&field.selection_set, budget);
let multiplier = extract_limit_multiplier(&field.arguments);
1usize.saturating_add(nested.complexity.saturating_mul(multiplier))
}
}
fn document_cost<S: std::hash::BuildHasher>(
&mut self,
document: &'a Document<'a, String>,
root_weights: &HashMap<String, usize, S>,
) -> usize {
let mut cost = 0usize;
for def in &document.definitions {
if let Definition::Operation(op) = def {
cost =
cost.saturating_add(self.root_cost(operation_selection_set(op), root_weights));
}
}
cost
}
fn root_cost<S: std::hash::BuildHasher>(
&mut self,
selection_set: &'a SelectionSet<'a, String>,
root_weights: &HashMap<String, usize, S>,
) -> usize {
let mut cost = 0usize;
for sel in &selection_set.items {
let c = match sel {
Selection::Field(field) => root_weights
.get(field.name.as_str())
.copied()
.unwrap_or_else(|| self.field_complexity(field, FRAGMENT_SPREAD_DEPTH_LIMIT)),
Selection::InlineFragment(inline) => {
self.root_cost(&inline.selection_set, root_weights)
},
Selection::FragmentSpread(spread) => {
self.resolve_fragment(
spread.fragment_name.as_str(),
FRAGMENT_SPREAD_DEPTH_LIMIT,
)
.complexity
},
};
cost = cost.saturating_add(c);
}
cost
}
}
#[must_use]
pub fn estimate_query_cost<'a, S: std::hash::BuildHasher>(
document: &'a Document<'a, String>,
root_cost_weights: &HashMap<String, usize, S>,
) -> usize {
let fragments = collect_fragments(document);
let mut analyzer = DocumentAnalyzer::new(&fragments, usize::MAX, usize::MAX);
analyzer.document_cost(document, root_cost_weights)
}
const fn operation_selection_set<'a>(
op: &'a OperationDefinition<'a, String>,
) -> &'a SelectionSet<'a, String> {
match op {
OperationDefinition::Query(q) => &q.selection_set,
OperationDefinition::Mutation(m) => &m.selection_set,
OperationDefinition::Subscription(s) => &s.selection_set,
OperationDefinition::SelectionSet(ss) => ss,
}
}
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<'a>(
document: &'a Document<'a, String>,
) -> Vec<&'a FragmentDefinition<'a, String>> {
document
.definitions
.iter()
.filter_map(|def| {
if let Definition::Fragment(f) = def {
Some(f)
} else {
None
}
})
.collect()
}
fn extract_limit_multiplier(arguments: &[(String, graphql_parser::query::Value<String>)]) -> usize {
for (name, value) in arguments {
if matches!(name.as_str(), "first" | "limit" | "take" | "last") {
if let graphql_parser::query::Value::Int(n) = value {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let limit = n.as_i64().unwrap_or(10) as usize;
return limit.clamp(1, 100);
}
}
}
1
}