1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
//! Core query execution — `execute()`, the shared `execute_dispatch()`, and
//! `execute_with_scopes()`.
use std::{sync::Arc, time::Duration};
use super::{Executor, QueryType, pipeline, root_type_name, support};
use crate::{
error::{FraiseQLError, Result},
security::SecurityContext,
};
impl Executor {
/// Execute a GraphQL query string and return a serialized JSON response.
///
/// Applies the configured query timeout if one is set. Handles queries,
/// mutations, introspection, federation, and node lookups.
///
/// If `RuntimeConfig::query_validation` is set, `QueryValidator::validate()`
/// runs first (before parsing or SQL dispatch) to enforce size, depth, and
/// complexity limits. This protects direct `fraiseql-core` embedders that do
/// not route through `fraiseql-server`.
///
/// # Errors
///
/// - `FraiseQLError::Validation` — query violates configured depth/complexity/alias limits
/// (only when `RuntimeConfig::query_validation` is `Some`).
/// - `FraiseQLError::Timeout` — query exceeded `RuntimeConfig::query_timeout_ms`.
/// - Any error returned by `execute_dispatch`.
pub async fn execute(
&self,
query: &str,
variables: Option<&serde_json::Value>,
) -> Result<serde_json::Value> {
// Anonymous entry: no principal. GATE-1, the parse cache, multi-root
// fan-out, and dispatch all live in the shared `execute_dispatch`.
//
// No operation name: the document must define exactly one operation
// (GraphQL § 6.1). Callers that carry an `operationName` — every HTTP
// request does — must use [`execute_operation`](Self::execute_operation)
// instead, or a two-operation document silently runs the first one.
self.execute_with_timeout(query, variables, None, None).await
}
/// Execute a document, selecting the operation named by `operation_name`
/// (GraphQL § 6.1 *`GetOperation`*).
///
/// [`execute`](Self::execute) is this with `None`, which requires the
/// document to define exactly one operation.
///
/// # Errors
///
/// - [`FraiseQLError::Parse`] — the document does not parse, names an operation that does not
/// exist, or defines several operations while `operation_name` is `None`.
/// - Any error returned by [`execute`](Self::execute).
pub async fn execute_operation(
&self,
query: &str,
variables: Option<&serde_json::Value>,
operation_name: Option<&str>,
) -> Result<serde_json::Value> {
self.execute_with_timeout(query, variables, None, normalize_operation_name(operation_name))
.await
}
/// Apply the configured query timeout (if any) around `execute_dispatch`.
///
/// Shared by the anonymous [`execute`](Self::execute) and the authenticated
/// `execute_with_security` entry points so both honor `query_timeout_ms`
/// identically.
///
/// # Errors
///
/// - [`FraiseQLError::Timeout`] — execution exceeded `query_timeout_ms`.
/// - Any error returned by [`execute_dispatch`](Self::execute_dispatch).
pub(super) async fn execute_with_timeout(
&self,
query: &str,
variables: Option<&serde_json::Value>,
security_context: Option<&SecurityContext>,
operation_name: Option<&str>,
) -> Result<serde_json::Value> {
// #1336 backstop: a principal that never passed a transport's enrichment seam
// does not execute. Placed here rather than at the four public entry points
// above it, because every GraphQL-document path funnels through this one.
crate::runtime::executor::support::security::enforce_enrichment_resolved(
&self.ctx.schema,
security_context,
)?;
if self.ctx.config.query_timeout_ms > 0 {
let timeout_duration = Duration::from_millis(self.ctx.config.query_timeout_ms);
tokio::time::timeout(
timeout_duration,
self.execute_dispatch(query, variables, security_context, operation_name),
)
.await
.map_err(|_| {
// Truncate query (char-boundary-safe) for error reporting.
let query_snippet = crate::utils::text::truncate_for_display(query, 100);
FraiseQLError::Timeout {
timeout_ms: self.ctx.config.query_timeout_ms,
query: Some(query_snippet),
}
})?
} else {
self.execute_dispatch(query, variables, security_context, operation_name).await
}
}
/// GATE 1: query-structure validation (depth/complexity/size `DoS` limits).
///
/// The single implementation behind every entry point — `execute_dispatch`
/// and `execute_with_scopes` both call this, so the gate cannot drift
/// between them (#736).
///
/// The validator is resolved once at construction: the embedder-installed
/// `RuntimeConfig::query_validation` when set, otherwise derived from the
/// compiled schema's declared `[validation]` limits (#379). Variables are
/// threaded through so a variable-valued pagination argument is scored at
/// its resolved value, not the fail-closed ceiling (#869).
fn run_gate1(&self, query: &str, variables: Option<&serde_json::Value>) -> Result<()> {
self.ctx.gate1.validate_with_variables(query, variables).map_err(|e| {
FraiseQLError::Validation {
message: e.to_string(),
path: Some("query".to_string()),
}
})?;
// #379: the compiled [security.cost_budget] per_request_max is enforced
// here, at the one chokepoint every document-executing transport
// shares, so no transport can serve an operation the operator declared
// too expensive. An unparseable document is left for the dispatch's own
// parse-error path.
if let Some(cap) = self.ctx.config.max_operation_cost {
if let Ok(doc) = crate::graphql::parse_graphql_document(query) {
let cost = crate::graphql::estimate_query_cost(
&doc,
&self.ctx.schema.operation_cost_weights,
variables,
) as u64;
if cost > cap {
return Err(FraiseQLError::CostExceeded {
message: format!(
"operation cost {cost} exceeds the schema-wide per-request maximum of \
{cap} ([security.cost_budget] per_request_max)"
),
cost,
limit: cap,
retry_after_secs: None,
});
}
}
}
Ok(())
}
/// Answer an introspection document: every root, under its response key
/// (GraphQL § 2.7), with `__type`'s name read from this request (#1445).
///
/// A data root cannot be answered here, and is refused rather than dropped.
fn execute_introspection(
&self,
parsed: &crate::graphql::ParsedQuery,
variables: Option<&serde_json::Value>,
) -> Result<serde_json::Value> {
let vars: std::collections::HashMap<String, serde_json::Value> = match variables {
Some(serde_json::Value::Object(map)) => {
map.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
},
_ => std::collections::HashMap::new(),
};
let mut data = serde_json::Map::new();
for root in &parsed.selections {
let included = crate::graphql::DirectiveEvaluator::evaluate_directives(root, &vars)
.map_err(|e| FraiseQLError::Validation {
message: e.to_string(),
path: Some("directives".to_string()),
})?;
if !included {
continue;
}
let value = match root.name.as_str() {
"__schema" => self.project_introspection(
"__schema",
&self.ctx.introspection.schema_response,
root,
parsed,
),
"__type" => {
let type_name = introspection_type_name(root, &vars)?;
let built = Arc::new(self.ctx.introspection.get_type_response(&type_name));
// The type name is part of the memo key: two `__type` roots
// with the same selection set but different names project
// different values.
self.project_introspection(
&format!("__type\u{1}{type_name}"),
&built,
root,
parsed,
)
},
"__typename" => {
serde_json::Value::String(root_type_name(&parsed.operation_type).to_string())
},
other => {
return Err(FraiseQLError::Validation {
message: format!(
"`{other}` cannot be selected in the same operation as `__schema` or \
`__type`: send it as a separate request"
),
path: Some(root.response_key().to_string()),
});
},
};
data.insert(root.response_key().to_string(), value);
}
Ok(serde_json::json!({ "data": data }))
}
/// Project the value a pre-built introspection response holds for `root`
/// onto that root's selection set (GraphQL § 6.3), memoised by the shape of
/// the selection set.
///
/// `memo_root` keys the cache and must distinguish values that differ for
/// reasons other than the selection set — hence the type name is folded into
/// it for `__type`.
///
/// Falls back to the unprojected value when there is no selection to project
/// against: over-delivering is strictly better than answering with nothing.
fn project_introspection(
&self,
memo_root: &str,
built: &Arc<serde_json::Value>,
root: &crate::graphql::FieldSelection,
parsed: &crate::graphql::ParsedQuery,
) -> serde_json::Value {
use support::introspection_projection as projection;
let inner = built
.get("data")
.and_then(|d| d.get(&root.name))
.cloned()
.unwrap_or(serde_json::Value::Null);
// Fragments and directives are resolved first so a spread projects like
// an inline selection.
let Ok(selections) = crate::graphql::selection_set::resolve(
&root.nested_fields,
&parsed.fragments,
self.max_query_depth(),
) else {
return inner;
};
if selections.is_empty() {
return inner;
}
let key = projection::selection_shape_hash(memo_root, &selections);
if let Some(hit) = self.ctx.introspection_projections.get(&key) {
return hit.as_ref().clone();
}
let projected = Arc::new(projection::project(&inner, &selections));
self.ctx.introspection_projections.insert(key, Arc::clone(&projected));
projected.as_ref().clone()
}
/// Classify a document, memoising the verdict in the parse cache.
///
/// The one place a document is turned into a [`QueryType`]. Both
/// [`execute_dispatch`](Self::execute_dispatch) — which routes on the verdict —
/// and [`execute_read_only`](Self::execute_read_only) — which refuses a write
/// on it — go through here, so the refusal cannot answer a different question
/// than the dispatch it is guarding.
///
/// Only the `Ok` pair is cached, so a document rejected by GraphQL § 5.8.3 is
/// re-checked rather than serving a cached verdict; and because validity of a
/// *definition* is a property of the document alone — never of this request's
/// variable *values* — caching the accepted verdict is sound.
fn classify_cached(
&self,
query: &str,
operation_name: Option<&str>,
) -> Result<support::classify::Classification> {
let cache_key = parse_cache_key(query, operation_name);
if let Some(arc) = self.ctx.parse_cache.get(&cache_key) {
return Ok(arc.as_ref().clone());
}
let pair = self.classify_query_with_parse(query, operation_name)?;
self.ctx.parse_cache.insert(cache_key, Arc::new(pair.clone()));
Ok(pair)
}
/// Execute a document that the engine would **not** execute as a write, as
/// `security_context` (#1328).
///
/// The engine half of the `before:mutation` read bridge. It is
/// [`execute_with_timeout`](Self::execute_with_timeout) with one thing in
/// front: a document the classifier calls a mutation is refused by name,
/// before dispatch, so the bridge cannot become a second write path.
///
/// The refusal is decided on
/// [`classify_cached`](Self::classify_cached) — the same verdict, from the same
/// memoised classification, that `execute_dispatch` would have routed on. A
/// second, independent parse here could disagree with the dispatch it is meant
/// to guard; this cannot.
///
/// No operation name: the guest bridge (`fraiseql_query`) has no field to carry
/// one, so a multi-operation document is already refused as ambiguous by
/// GraphQL § 6.1.
///
/// The refusal is also what terminates the recursion. A hook reaches this
/// through `execute_mutation_impl`, so a bridge that executed a mutation would
/// run that mutation's own `before:mutation` chain, which would issue the same
/// document again: with the check removed, the pin in
/// `runners::mutation::tests::before_mutation_read_bridge` does not fail an
/// assertion, it overflows the stack.
///
/// # Re-entry into a function is refused (#1329)
///
/// A document whose root field is **function-backed** is refused here too, and
/// for a reason of the same kind: the bridge is what a guest reads through, so a
/// guest reading a function-backed field spins a second isolate inside the first,
/// and a field that reads itself recurses until the query timeout, one isolate
/// per level. Root fields are the only place a function can be bound, so
/// checking the roots checks the document.
///
/// This is the guest-side half of the "one invocation per query" property the
/// root-only rule gives the client-side half of. Composition belongs inside the
/// function, where it costs no isolate.
///
/// # Errors
///
/// - [`FraiseQLError::Authorization`] — the document's operation is a mutation.
/// - [`FraiseQLError::Validation`] — a root field is function-backed (#1329).
/// - Any error returned by [`execute_with_timeout`](Self::execute_with_timeout).
pub(super) async fn execute_read_only(
&self,
query: &str,
variables: Option<&serde_json::Value>,
security_context: Option<&SecurityContext>,
) -> Result<serde_json::Value> {
let (query_type, parsed, _) = self.classify_cached(query, None)?;
if matches!(query_type, QueryType::Mutation { .. }) {
return Err(FraiseQLError::Authorization {
message: "the before:mutation read bridge is read-only: this document is a \
mutation, and a before-hook may not write"
.to_string(),
action: Some("before_mutation_read".to_string()),
resource: None,
});
}
if let Some(parsed) = parsed.as_ref() {
self.refuse_function_backed_roots(parsed)?;
}
self.execute_with_timeout(query, variables, security_context, None).await
}
/// Refuse a guest read whose root field is answered by a function (#1329).
///
/// Keyed on the field **name**, never the alias, for the same reason the
/// `before:mutation` chain is: an alias changes only the response key, and
/// keying on it would let `alias: quotePreview` through the check that
/// `quotePreview` fails.
fn refuse_function_backed_roots(&self, parsed: &crate::graphql::ParsedQuery) -> Result<()> {
for root in &parsed.selections {
let Some(query_def) = self.ctx.schema.find_query(&root.name) else {
continue;
};
if let Some(function) = query_def.function.as_deref() {
return Err(FraiseQLError::Validation {
message: format!(
"`{}` is backed by the function `{function}`, and a function may not be \
invoked from inside another one: the read bridge would spin a second \
isolate inside the first, and a field reading itself would recurse \
until the query timed out. Call what that function calls, or move the \
shared work into a module both import.",
root.name
),
path: Some(root.name.clone()),
});
}
}
Ok(())
}
/// Unified query dispatch for both the anonymous and authenticated entry
/// points (H19). `security_context` is `None` for anonymous requests and
/// `Some` for authenticated ones; it threads through GATE-1, the parse
/// cache, the multi-root fan-out, and every per-operation runner so the two
/// paths cannot diverge in which roots they return, whether GATE-1 runs, or
/// whether the parse cache is consulted.
///
/// # Errors
///
/// - [`FraiseQLError::Validation`] — GATE-1 limits exceeded, or a runner rejects.
/// - [`FraiseQLError::Parse`] — GraphQL query string is not valid GraphQL syntax.
/// - [`FraiseQLError::NotFound`] — the query name does not match any compiled query template.
/// - [`FraiseQLError::Database`] — the underlying database returned an error.
/// - [`FraiseQLError::Internal`] — response serialisation failed.
/// - [`FraiseQLError::Authorization`] — field-level access control denied a field.
pub(super) async fn execute_dispatch(
&self,
query: &str,
variables: Option<&serde_json::Value>,
security_context: Option<&SecurityContext>,
operation_name: Option<&str>,
) -> Result<serde_json::Value> {
// 1. Classify query type — also returns the ParsedQuery for Regular
// queries so we do not parse the same string twice. Classification also
// enforces GraphQL § 5.8.3 (every variable used must be defined).
//
// The parse result is memoised in `parse_cache` (keyed by xxHash64 of
// the query string) so repeated identical queries skip re-parsing — on
// both the anonymous and authenticated paths (L-parse-cache). Only the
// `Ok` pair is cached, so a document rejected by § 5.8.3 is re-checked
// rather than serving a cached verdict; and because validity of a
// *definition* is a property of the document alone — never of this
// request's variable *values* — caching the accepted verdict is sound.
//
// This runs BEFORE GATE-1, deliberately. GATE-1 scores cost, and a
// document whose `limit: $undeclared` was silently dropped becomes
// unbounded and trips the complexity ceiling — so with GATE-1 first the
// client receives an error about *cost* that never mentions the
// variable, which is harder to debug than no error at all. Reordering a
// DoS gate exposes no new surface here: `parse_query` goes through
// `complexity::parse_graphql_document`, the same panic-guarded seam
// GATE-1 itself parses with (`validate_with_variables`, and
// `parse_graphql_document` again for `max_operation_cost`), so the
// parser sees nothing it was not already going to see.
let (query_type, maybe_parsed, variable_defaults) =
self.classify_cached(query, operation_name)?;
// GraphQL § 6.4.1 (#1504): a variable the request omits takes its declared
// default, before anything reads the variables — GATE-1, the authorizer and
// every runner — so `$l: Int = 1` limits the read and a defaulted `where`
// filters it. The defaults belong to the document, so they are cached with
// its classification and merged here, per request.
let defaulted =
crate::graphql::value_json::with_variable_defaults(&variable_defaults, variables);
let variables = defaulted.as_ref().or(variables);
// GATE 1: query-structure validation (DoS protection for direct embedders).
// Runs on BOTH the anonymous and authenticated paths (L-gate1-skip).
self.run_gate1(query, variables)?;
// 1b. Operation-level authorization (#422). Runs before single- AND
// multi-root dispatch so a deny short-circuits the parallel pipeline.
// Mutations are gated downstream at `execute_mutation_impl`.
// Fail-closed: a `Deny` or any policy error → 403.
if let Some(authorizer) = self.ctx.config.authorizer.as_ref() {
let ops = support::authz::collect_authz_ops(
&query_type,
maybe_parsed.as_ref(),
&self.ctx.schema,
);
crate::security::authorizer::enforce_authz(
authorizer.as_ref(),
security_context,
&ops,
variables,
)?;
}
// 2. Route to appropriate handler, threading the (optional) principal.
match query_type {
QueryType::Regular => {
// Detect multi-root queries and dispatch them in parallel.
// `maybe_parsed` is always Some for Regular queries (see
// classify_query_with_parse).
let parsed = maybe_parsed.ok_or_else(|| FraiseQLError::Internal {
message: "classifier returned Regular without a parsed query — this is a bug"
.to_string(),
source: None,
})?;
if pipeline::is_multi_root(&parsed) {
// § 6.1.2 for the one shape `QueryMatcher` structurally
// cannot check: `field_selection_to_query` re-serialises
// each root into a synthetic single-root document that
// re-emits `$name` references but carries no variable
// *definitions*, so the matcher's copy of this check sees
// an empty declaration list and passes. Not in
// `classify_query_with_parse` with its siblings: that
// function's verdict is memoised in the parse cache, which
// is keyed on the document and not on the variables, so a
// value-dependent check there would answer the first
// request's question for every later one (#1197).
crate::runtime::validate_variable_values(
parsed.operation_name.as_deref(),
&parsed.variables,
variables,
)?;
// #1362, for the same shape and the same reason: the matcher's
// copy of the enum check also sees an empty declaration list on a
// re-serialised root, so a multi-root document's variables would
// reach SQL with an enum nobody adjudicated.
crate::runtime::validate_enum_variable_values(
&self.ctx.schema,
parsed.operation_name.as_deref(),
&parsed.variables,
variables,
)?;
let pr = self.execute_parallel(&parsed, variables, security_context).await?;
let data = pr.merge_into_data_map();
return Ok(serde_json::json!({ "data": data }));
}
self.query_runner()
.execute_regular_query_maybe_security(
query,
variables,
security_context,
operation_name,
)
.await
},
QueryType::Aggregate(query_name) => {
self.aggregate_runner()
.execute_aggregate_dispatch(&query_name, variables, security_context)
.await
},
QueryType::Window(query_name) => {
self.aggregate_runner()
.execute_window_dispatch(&query_name, variables, security_context)
.await
},
#[cfg(feature = "federation")]
QueryType::Federation(query_name) => {
// The `_entities` path fails closed for RLS-/inject-/role-gated
// types when `security_context` is `None` (C1b).
self.execute_federation_query(&query_name, query, variables, security_context)
.await
},
#[cfg(not(feature = "federation"))]
QueryType::Federation(_) => {
let _ = (query, variables);
Err(FraiseQLError::Validation {
message: "Federation is not enabled in this build".to_string(),
path: None,
})
},
QueryType::Introspection => {
let parsed = maybe_parsed.ok_or_else(|| FraiseQLError::Internal {
message: "classifier returned Introspection without a parsed query — this \
is a bug"
.to_string(),
source: None,
})?;
self.execute_introspection(&parsed, variables)
},
QueryType::Mutation { roots } => {
self.execute_mutation_roots(&roots, variables, security_context).await
},
QueryType::NodeQuery { selections } => {
// The node runner fails closed for any RLS/inject/role-gated type
// when `security_context` is `None` (H2 IDOR fix).
self.query_runner()
.execute_node_query(query, variables, &selections, security_context)
.await
},
QueryType::TypeName {
selection,
operation_type,
} => {
// Root `__typename` meta-field: resolve to the operation's root
// type name with no DB round-trip (spec §"Type Name Introspection").
// Honour `@skip`/`@include` on the field — a skipped meta-field is
// omitted from `data`, exactly like any other skipped field.
let vars: std::collections::HashMap<String, serde_json::Value> = match variables {
Some(serde_json::Value::Object(map)) => {
map.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
},
_ => std::collections::HashMap::new(),
};
let included =
crate::graphql::DirectiveEvaluator::evaluate_directives(&selection, &vars)
.map_err(|e| FraiseQLError::Validation {
message: e.to_string(),
path: Some("directives".to_string()),
})?;
let mut data = serde_json::Map::new();
if included {
data.insert(
selection.response_key().to_string(),
serde_json::Value::String(root_type_name(&operation_type).to_string()),
);
}
Ok(serde_json::json!({ "data": data }))
},
}
}
/// Execute a GraphQL query with user context for field-level access control.
///
/// This method validates that the user has permission to access all requested
/// fields before executing the query. If field filtering is enabled in the
/// `RuntimeConfig` and the user lacks required scopes, this returns an error.
///
/// # Arguments
///
/// * `query` - GraphQL query string
/// * `variables` - Query variables (optional)
/// * `user_scopes` - User's scopes from JWT token (pass empty slice if unauthenticated)
///
/// # Returns
///
/// GraphQL response as JSON string, or error if access denied
///
/// # Errors
///
/// * [`FraiseQLError::Validation`] — query validation fails, or the user's scopes do not
/// include a field required by the `field_filter` policy.
/// * Propagates errors from query classification and execution.
///
/// # Example
///
/// ```no_run
/// // Requires: a live database adapter and authenticated user context.
/// // See: tests/integration/ for runnable examples.
/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
/// let query = r#"query { users { id name salary } }"#;
/// // let user_scopes = user.scopes.clone();
/// // let result = executor.execute_with_scopes(query, None, &user_scopes).await?;
/// # Ok(()) }
/// ```
pub async fn execute_with_scopes(
&self,
query: &str,
variables: Option<&serde_json::Value>,
user_scopes: &[String],
) -> Result<serde_json::Value> {
// GATE 1: Query structure validation (mirrors execute() — DoS protection).
// Same shared gate as execute_dispatch — the two call sites cannot drift (#736).
self.run_gate1(query, variables)?;
// 2. Classify query type
let query_type = self.classify_query(query, None)?;
// 3. Validate field access if filter is configured
if let Some(ref filter) = self.ctx.config.field_filter {
// Only validate for regular queries (not introspection)
if matches!(query_type, QueryType::Regular) {
self.validate_field_access(query, variables, user_scopes, filter)?;
}
}
// 4. Delegate to execute_dispatch — single source of routing truth. Field-access validation
// (step 3) has already run for Regular queries; all other query types (introspection,
// aggregate, federation, …) are routed correctly via execute_dispatch without
// duplication. Scope-based filtering is not RLS, so no SecurityContext is threaded.
self.execute_dispatch(query, variables, None, None).await
}
/// Execute a mutation operation's root fields serially, in document order.
///
/// GraphQL requires this: mutation roots may depend on each other's side
/// effects, so they are never run concurrently and never reordered. Each
/// root's result appears under its response key (its alias when it has one),
/// so two calls to the same mutation stay distinct.
///
/// A root that fails contributes `null` at its key plus an entry in `errors`,
/// and the remaining roots still execute — a client that issued three writes
/// needs to know which of them landed, and a single error envelope naming
/// none of them does not say. The one-root case keeps propagating `Err`
/// unchanged: there is no partial state to report, and every mutation error
/// test in the workspace pins that shape.
///
/// # Errors
///
/// Returns the root's error when the operation has exactly one root, or
/// [`FraiseQLError::Validation`] when the operation has none.
async fn execute_mutation_roots(
&self,
roots: &[super::MutationRoot],
variables: Option<&serde_json::Value>,
security_context: Option<&SecurityContext>,
) -> Result<serde_json::Value> {
let [single] = roots else {
return self.execute_mutation_roots_serially(roots, variables, security_context).await;
};
self.execute_mutation_query(
&single.name,
&single.response_key,
variables,
security_context,
&single.selections,
&single.arguments,
)
.await
}
/// The multi-root arm of [`execute_mutation_roots`].
async fn execute_mutation_roots_serially(
&self,
roots: &[super::MutationRoot],
variables: Option<&serde_json::Value>,
security_context: Option<&SecurityContext>,
) -> Result<serde_json::Value> {
if roots.is_empty() {
return Err(FraiseQLError::Validation {
message: "mutation operation has no root field".to_string(),
path: None,
});
}
let mut data = serde_json::Map::with_capacity(roots.len());
let mut errors: Vec<serde_json::Value> = Vec::new();
for root in roots {
let outcome = self
.execute_mutation_query(
&root.name,
&root.response_key,
variables,
security_context,
&root.selections,
&root.arguments,
)
.await;
match outcome {
Ok(response) => {
// The runner already wrapped its result under the response
// key; lift it back out so every root shares one envelope.
let value = response
.get("data")
.and_then(|d| d.get(root.response_key.as_str()))
.cloned()
.unwrap_or(serde_json::Value::Null);
data.insert(root.response_key.clone(), value);
},
Err(e) => {
data.insert(root.response_key.clone(), serde_json::Value::Null);
let mut entry = self.ctx.config.root_error_renderer.as_ref().map_or_else(
|| serde_json::json!({ "message": e.to_string() }),
|render| render(&e),
);
if let Some(entry) = entry.as_object_mut() {
entry.insert(
"path".to_string(),
serde_json::json!([root.response_key.as_str()]),
);
}
errors.push(entry);
},
}
}
let mut response = serde_json::Map::with_capacity(2);
response.insert("data".to_string(), serde_json::Value::Object(data));
if !errors.is_empty() {
response.insert("errors".to_string(), serde_json::Value::Array(errors));
}
Ok(serde_json::Value::Object(response))
}
}
/// Treat an empty `operationName` as absent.
///
/// Clients that always populate the field send `""` for an unnamed operation.
/// Left as `Some("")` it would match no operation and turn every such request
/// into an `UnknownOperation` error.
pub(super) fn normalize_operation_name(operation_name: Option<&str>) -> Option<&str> {
operation_name.filter(|name| !name.is_empty())
}
/// Parse-cache key for a document **and the operation selected within it**.
///
/// The operation name is part of the key, not decoration. The cached value is a
/// `(QueryType, Option<ParsedQuery>)` pair describing *one selected operation*,
/// so keying on the document alone would serve the first request's operation to
/// every later request naming a different one — the § 6.1 defect, made
/// permanent by the cache and invisible to the request that triggers it.
fn parse_cache_key(query: &str, operation_name: Option<&str>) -> u64 {
let document = xxhash_rust::xxh3::xxh3_64(query.as_bytes());
match operation_name {
None => document,
// Seeded with the document hash so the pair is mixed, not concatenated.
Some(name) => xxhash_rust::xxh3::xxh3_64_with_seed(name.as_bytes(), document),
}
}
/// The type `__type(name:)` names in this request (#1445).
///
/// The argument is `String!`: a literal, or a variable read from the request (with
/// its declared default already applied by the dispatch, § 6.4.1). A missing,
/// null or non-string name is refused: answering `null` would read as "no such
/// type".
fn introspection_type_name(
root: &crate::graphql::FieldSelection,
variables: &std::collections::HashMap<String, serde_json::Value>,
) -> Result<String> {
use crate::graphql::value_json;
let refuse = |why: &str| FraiseQLError::Validation {
message: format!("`__type` takes a `name: String!` argument, and {why}"),
path: Some(root.response_key().to_string()),
};
let arg = root
.arguments
.iter()
.find(|a| a.name == "name")
.ok_or_else(|| refuse("none was given"))?;
let literal = value_json::decode(&arg.value_json)?;
// The dispatch has already given an omitted variable its default (§ 6.4.1).
let value = match value_json::variable_name(&literal) {
Some(var) => variables.get(var).cloned().unwrap_or(serde_json::Value::Null),
None => literal,
};
value
.as_str()
.map(str::to_string)
.ok_or_else(|| refuse("it was null or not a string"))
}