Skip to main content

fraiseql_core/graphql/
value_json.rs

1//! The `value_json` seam: how an inline GraphQL argument is stored and read back.
2//!
3//! A parsed argument is kept as a JSON string on [`GraphQLArgument::value_json`]
4//! and re-read by the matcher, the directive evaluator, the query classifier and
5//! the multi-root pipeline. Everything about that round trip lives here, because
6//! #719 was two independent failures of it:
7//!
8//! * **The writer escaped only `"`.** A Windows path, a newline or a control character in a string
9//!   literal produced *invalid JSON*, which the reader dropped via `.ok()?`. A dropped `where:`
10//!   argument does not narrow a result set — it **widens** it.
11//! * **Variables were signalled in-band as `"$name"`.** A literal `"$100"` was indistinguishable
12//!   from a reference to a variable named `100`, and resolved to `null`.
13//!
14//! Both are fixed structurally rather than by patching the escaper: serialization
15//! goes through `serde_json`, and a variable is a tagged **object**
16//! `{"$var": "name"}`. GraphQL names match `[_A-Za-z][_0-9A-Za-z]*`, so no
17//! client-supplied object key can ever be `$var` — the marker is unforgeable
18//! rather than merely unlikely.
19
20use std::collections::HashMap;
21
22use fraiseql_error::{FraiseQLError, Result};
23use graphql_parser::query;
24use serde_json::{Map, Value};
25
26/// Object key marking a GraphQL variable reference.
27///
28/// Not a valid GraphQL name, so a client cannot produce it as a literal key.
29pub const VARIABLE_TAG: &str = "$var";
30
31/// Maximum nesting depth accepted when serializing or resolving an argument.
32///
33/// Bounds stack use against an adversarial payload; the parser and the resolver
34/// share the limit so a value that serialized cannot fail to resolve.
35pub const MAX_DEPTH: usize = 64;
36
37/// Build the JSON representation of a parsed GraphQL argument value.
38///
39/// # Errors
40///
41/// Returns `FraiseQLError::Validation` if the value nests deeper than
42/// [`MAX_DEPTH`]. Serialization itself cannot fail: every GraphQL value has a
43/// JSON counterpart.
44pub fn encode(value: &query::Value<String>) -> Result<String> {
45    let json = to_json(value, 0)?;
46    serde_json::to_string(&json).map_err(|e| FraiseQLError::Internal {
47        message: format!("failed to serialize GraphQL argument: {e}"),
48        source:  None,
49    })
50}
51
52fn to_json(value: &query::Value<String>, depth: usize) -> Result<Value> {
53    if depth > MAX_DEPTH {
54        return Err(FraiseQLError::validation(format!(
55            "GraphQL argument nests deeper than the {MAX_DEPTH}-level limit"
56        )));
57    }
58    Ok(match value {
59        query::Value::String(s) => Value::String(s.clone()),
60        query::Value::Int(i) => i.as_i64().map_or(Value::Null, Value::from),
61        query::Value::Float(f) => {
62            serde_json::Number::from_f64(*f).map_or(Value::Null, Value::Number)
63        },
64        query::Value::Boolean(b) => Value::Bool(*b),
65        query::Value::Null => Value::Null,
66        // An enum value is a bare GraphQL name; JSON has no enum, so it travels
67        // as a string and `value_type` records that it was an enum.
68        query::Value::Enum(e) => Value::String(e.clone()),
69        query::Value::List(items) => Value::Array(
70            items.iter().map(|item| to_json(item, depth + 1)).collect::<Result<Vec<_>>>()?,
71        ),
72        query::Value::Object(obj) => {
73            let mut map = Map::with_capacity(obj.len());
74            for (k, v) in obj {
75                map.insert(k.clone(), to_json(v, depth + 1)?);
76            }
77            Value::Object(map)
78        },
79        query::Value::Variable(v) => variable_ref(v),
80    })
81}
82
83/// The JSON representation of a reference to variable `name`.
84#[must_use]
85pub fn variable_ref(name: &str) -> Value {
86    let mut map = Map::with_capacity(1);
87    map.insert(VARIABLE_TAG.to_string(), Value::String(name.to_string()));
88    Value::Object(map)
89}
90
91/// The variable this value references, if it is a reference rather than a literal.
92#[must_use]
93pub fn variable_name(value: &Value) -> Option<&str> {
94    let map = value.as_object()?;
95    if map.len() != 1 {
96        return None;
97    }
98    map.get(VARIABLE_TAG)?.as_str()
99}
100
101/// Read a stored `value_json` back into JSON.
102///
103/// # Errors
104///
105/// Returns `FraiseQLError::Internal` if the stored string is not valid JSON.
106/// This can only happen if something wrote `value_json` without going through
107/// [`encode`] — and it must be loud: the previous `.ok()?` dropped the argument,
108/// and a dropped filter widens the result set instead of narrowing it.
109pub fn decode(value_json: &str) -> Result<Value> {
110    serde_json::from_str(value_json).map_err(|e| FraiseQLError::Internal {
111        message: format!(
112            "stored GraphQL argument is not valid JSON ({e}); refusing to execute the query \
113             rather than dropping the argument, which would widen the result set"
114        ),
115        source:  None,
116    })
117}
118
119/// Substitute every variable reference inside `value` with its concrete value.
120///
121/// An undefined variable resolves to JSON `null`, matching GraphQL's treatment
122/// of an omitted nullable argument.
123#[must_use]
124#[allow(clippy::implicit_hasher)]
125// Reason: the variables map is built by the runtime with the default hasher; a
126// generic parameter here would leak into every caller for no benefit.
127pub fn resolve_variables(value: Value, variables: &HashMap<String, Value>) -> Value {
128    resolve_at(value, variables, 0)
129}
130
131/// Read a stored `value_json` back into JSON **with its variable references
132/// substituted** — the form a consumer that wants values almost always means.
133///
134/// [`decode`] alone yields a value that may still contain `{"$var": "name"}`
135/// markers. Every consumer that then compares, filters or authorizes on the
136/// result must pair it with [`resolve_variables`]; the field authorizer did not,
137/// so a policy matching on an argument compared against the marker and silently
138/// took its default branch for every client that passes arguments as variables —
139/// which is every generated client (#903).
140///
141/// Use [`decode`] directly only when the marker itself is the subject, as
142/// `resolve_inline_arg` does: it distinguishes a whole-argument variable the
143/// request omitted (drop the argument, fall back to the query default) from one
144/// bound to null.
145///
146/// # Errors
147///
148/// Returns `FraiseQLError::Internal` if the stored string is not valid JSON.
149#[allow(clippy::implicit_hasher)] // Reason: as resolve_variables — one hasher, no generic leak.
150pub fn decode_resolved(value_json: &str, variables: &HashMap<String, Value>) -> Result<Value> {
151    Ok(resolve_variables(decode(value_json)?, variables))
152}
153
154fn resolve_at(value: Value, variables: &HashMap<String, Value>, depth: usize) -> Value {
155    if depth >= MAX_DEPTH {
156        return value;
157    }
158    match value {
159        Value::Array(items) => {
160            Value::Array(items.into_iter().map(|v| resolve_at(v, variables, depth + 1)).collect())
161        },
162        Value::Object(map) => {
163            if let Some(name) = map.get(VARIABLE_TAG).and_then(Value::as_str) {
164                if map.len() == 1 {
165                    return variables.get(name).cloned().unwrap_or(Value::Null);
166                }
167            }
168            Value::Object(
169                map.into_iter().map(|(k, v)| (k, resolve_at(v, variables, depth + 1))).collect(),
170            )
171        },
172        other => other,
173    }
174}
175
176/// Render a stored argument value back as GraphQL source syntax.
177///
178/// Used by the multi-root pipeline, which re-serializes each root field into a
179/// standalone query. Strings are escaped by `serde_json` — GraphQL and JSON
180/// agree on string-literal syntax — and variable references become `$name`
181/// rather than a quoted string.
182#[must_use]
183pub fn to_graphql(value: &Value) -> String {
184    if let Some(name) = variable_name(value) {
185        return format!("${name}");
186    }
187    match value {
188        Value::Object(map) => {
189            // GraphQL object keys are bare names, not quoted strings.
190            let pairs: Vec<String> =
191                map.iter().map(|(k, v)| format!("{k}: {}", to_graphql(v))).collect();
192            format!("{{{}}}", pairs.join(", "))
193        },
194        Value::Array(items) => {
195            let rendered: Vec<String> = items.iter().map(to_graphql).collect();
196            format!("[{}]", rendered.join(", "))
197        },
198        // `serde_json` emits a correctly escaped string literal; GraphQL's
199        // string syntax is the same. Hand-rolling this is what #719 reports.
200        Value::String(_) | Value::Number(_) | Value::Bool(_) | Value::Null => value.to_string(),
201    }
202}
203
204/// The declared defaults of an operation's variables, decoded: `(name, value)` for
205/// each variable definition that declares one (GraphQL § 6.4.1).
206///
207/// # Errors
208///
209/// Returns `FraiseQLError::Internal` if a stored default is not valid JSON (see
210/// [`decode`]).
211pub fn variable_defaults(
212    definitions: &[crate::graphql::VariableDefinition],
213) -> Result<Vec<(String, Value)>> {
214    definitions
215        .iter()
216        .filter_map(|def| def.default_value.as_deref().map(|json| (def.name.as_str(), json)))
217        .map(|(name, json)| Ok((name.to_string(), decode(json)?)))
218        .collect()
219}
220
221/// The request's variables, with every omitted variable that declares a default
222/// set to it (GraphQL § 6.4.1 `CoerceVariableValues`).
223///
224/// A variable the request supplies, `null` included, keeps the request's value.
225///
226/// `None` when nothing was added, so the caller keeps the request's own value; a
227/// `variables` that is not an object is left for validation to refuse.
228#[must_use]
229pub fn with_variable_defaults(
230    defaults: &[(String, Value)],
231    variables: Option<&Value>,
232) -> Option<Value> {
233    let supplied = match variables {
234        None | Some(Value::Null) => Map::new(),
235        Some(Value::Object(map)) => map.clone(),
236        Some(_) => return None,
237    };
238    let missing: Vec<&(String, Value)> =
239        defaults.iter().filter(|(name, _)| !supplied.contains_key(name)).collect();
240    if missing.is_empty() {
241        return None;
242    }
243    let mut merged = supplied;
244    for (name, value) in missing {
245        merged.insert(name.clone(), value.clone());
246    }
247    Some(Value::Object(merged))
248}
249
250#[cfg(test)]
251#[path = "value_json_tests.rs"]
252mod value_json_tests;