use serde_json::Value;
const DRIFTED_INJECT_KEYS: &[(&str, &str)] = &[("inject", "inject_params")];
const DRIFTED_SHAPE_KEYS: &[(&str, &str)] = &[("return_array", "returns_list")];
const DRIFTED_SCOPE_KEYS: &[&str] = &[
"scope",
"scopes",
"requiresScope",
"requiresScopes",
"requires_scopes",
];
const CANONICAL_SCOPE_KEY: &str = "requires_scope";
pub fn reject_drifted_keys(raw: &Value) -> Result<(), String> {
for collection in ["queries", "mutations"] {
for operation in raw.get(collection).and_then(Value::as_array).into_iter().flatten() {
let name = operation.get("name").and_then(Value::as_str).unwrap_or("<unnamed>");
for (found, canonical) in DRIFTED_INJECT_KEYS {
if operation.get(*found).is_some() {
return Err(format!(
"{collection} → '{name}' declares server-side parameter injection under \
`{found}`, which the compiler does not read — it would compile to a query \
with no injected filter and no diagnostic. Rename it to `{canonical}`. \
Values may be either \"jwt:<claim>\" or {{\"source\": \"jwt\", \"claim\": \
\"<claim>\"}}."
));
}
}
for (found, canonical) in DRIFTED_SHAPE_KEYS {
if operation.get(*found).is_some() {
return Err(format!(
"{collection} → '{name}' declares list-ness under `{found}`, which is the \
TOML `[queries.*]` spelling — this authoring surface reads `{canonical}`. \
Rename it to `{canonical}`. The two surfaces name one concept two ways; \
only `{canonical}` binds here."
));
}
}
}
}
for type_def in raw.get("types").and_then(Value::as_array).into_iter().flatten() {
let type_name = type_def.get("name").and_then(Value::as_str).unwrap_or("<unnamed>");
for field in type_def.get("fields").and_then(Value::as_array).into_iter().flatten() {
let field_name = field.get("name").and_then(Value::as_str).unwrap_or("<unnamed>");
for found in DRIFTED_SCOPE_KEYS {
if field.get(*found).is_some() {
return Err(format!(
"field '{type_name}.{field_name}' declares a scope requirement under \
`{found}`, which the compiler does not read — the compiled field would \
carry no scope at all and the runtime would serve it to callers with no \
scopes. Rename it to `{CANONICAL_SCOPE_KEY}` (a single scope string; \
multiple required scopes are not supported by the runtime field filter)."
));
}
}
}
}
reject_input_validation_rules(raw)
}
fn reject_input_validation_rules(raw: &Value) -> Result<(), String> {
let mut input_types: Vec<&Value> =
raw.get("input_types").and_then(Value::as_array).into_iter().flatten().collect();
input_types.extend(
raw.get("types")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter(|t| t.get("is_input").and_then(Value::as_bool) == Some(true)),
);
for input in input_types {
for field in input.get("fields").and_then(Value::as_array).into_iter().flatten() {
refuse_rules(field, &format!("input field '{}.{}'", named(input), named(field)))?;
}
}
for collection in ["queries", "mutations", "subscriptions"] {
for operation in raw.get(collection).and_then(Value::as_array).into_iter().flatten() {
for argument in
operation.get("arguments").and_then(Value::as_array).into_iter().flatten()
{
refuse_rules(
argument,
&format!("argument '{}({})'", named(operation), named(argument)),
)?;
}
}
}
Ok(())
}
fn named(value: &Value) -> &str {
value.get("name").and_then(Value::as_str).unwrap_or("<unnamed>")
}
fn refuse_rules(value: &Value, site: &str) -> Result<(), String> {
let Some(rules) = value.get("validation_rules") else {
return Ok(());
};
let names: Vec<String> = rules
.as_array()
.into_iter()
.flatten()
.map(|rule| match rule.get("type").and_then(Value::as_str) {
Some(kind) => format!("`{kind}`"),
None => rule.to_string(),
})
.collect();
let declared = if names.is_empty() {
"an empty `validation_rules` list".to_string()
} else {
format!("`validation_rules` ({})", names.join(", "))
};
Err(format!(
"{site} declares {declared}, which the server does not enforce on input yet (#1635). \
The compiler refuses them rather than publish a check that never runs. Remove \
`validation_rules` and check the value in the mutation's SQL function instead."
))
}
#[cfg(test)]
mod tests;