use serde_json::Value;
use crate::{
error::{FraiseQLError, Result},
graphql::{
types::{GraphQLArgument, VariableDefinition},
value_json,
},
schema::{ArgumentDefinition, CompiledSchema, EnumDefinition, FieldType},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Scalar {
Int,
Float,
String,
Boolean,
Id,
Uuid,
DateTime,
Date,
Time,
Decimal,
}
impl Scalar {
const fn from_field_type(field_type: &FieldType) -> Option<Self> {
match field_type {
FieldType::Int => Some(Self::Int),
FieldType::Float => Some(Self::Float),
FieldType::String => Some(Self::String),
FieldType::Boolean => Some(Self::Boolean),
FieldType::Id => Some(Self::Id),
FieldType::Uuid => Some(Self::Uuid),
FieldType::DateTime => Some(Self::DateTime),
FieldType::Date => Some(Self::Date),
FieldType::Time => Some(Self::Time),
FieldType::Decimal => Some(Self::Decimal),
_ => None,
}
}
fn from_type_name(name: &str) -> Option<Self> {
match name {
"Int" => Some(Self::Int),
"Float" => Some(Self::Float),
"String" => Some(Self::String),
"Boolean" => Some(Self::Boolean),
"ID" => Some(Self::Id),
"UUID" => Some(Self::Uuid),
"DateTime" => Some(Self::DateTime),
"Date" => Some(Self::Date),
"Time" => Some(Self::Time),
"Decimal" => Some(Self::Decimal),
_ => None,
}
}
const fn name(self) -> &'static str {
match self {
Self::Int => "Int",
Self::Float => "Float",
Self::String => "String",
Self::Boolean => "Boolean",
Self::Id => "ID",
Self::Uuid => "UUID",
Self::DateTime => "DateTime",
Self::Date => "Date",
Self::Time => "Time",
Self::Decimal => "Decimal",
}
}
fn rejection(self, value: &Value) -> Option<String> {
if self.accepts(value) {
return None;
}
if self == Self::Int && value.is_i64() {
return Some(format!("an Int outside the 32-bit range ({}..={})", i32::MIN, i32::MAX));
}
if self == Self::Uuid && value.is_string() {
return Some("a String that is not a UUID".to_string());
}
if self == Self::Decimal && value.is_number() {
return Some(
"a number an f64 had already rounded: write a Decimal's digits as a string"
.to_string(),
);
}
Some(match json_shape(value) {
shape @ ("a list" | "an object") => shape.to_string(),
"Int" => "an Int value".to_string(),
shape => format!("a {shape} value"),
})
}
fn accepts(self, value: &Value) -> bool {
if value.is_null() {
return true;
}
match self {
Self::Int => value.as_i64().is_some_and(|v| i32::try_from(v).is_ok()),
Self::Float => value.is_number(),
Self::String | Self::DateTime | Self::Date | Self::Time => value.is_string(),
Self::Uuid => value.as_str().is_some_and(|s| uuid::Uuid::parse_str(s).is_ok()),
Self::Boolean => value.is_boolean(),
Self::Id => value.is_string() || value.as_i64().is_some(),
Self::Decimal => value.is_string() || value.is_i64() || value.is_u64(),
}
}
fn usable_at(self, location: Self) -> bool {
if self == location {
return true;
}
matches!(
(self, location),
(Self::Int, Self::Float | Self::Decimal | Self::Id)
| (
Self::String,
Self::Id
| Self::Uuid
| Self::DateTime
| Self::Date
| Self::Time
| Self::Decimal
)
| (Self::Id, Self::String | Self::Uuid)
| (Self::Uuid, Self::String | Self::Id)
| (Self::DateTime | Self::Date | Self::Time | Self::Decimal, Self::String)
)
}
}
fn json_shape(value: &Value) -> &'static str {
match value {
Value::Null => "null",
Value::Bool(_) => "Boolean",
Value::Number(n) => {
if n.is_f64() {
"Float"
} else {
"Int"
}
},
Value::String(_) => "String",
Value::Array(_) => "a list",
Value::Object(_) => "an object",
}
}
pub fn validate_argument_values(
field_label: &str,
declared: &[ArgumentDefinition],
provided: &[GraphQLArgument],
variable_defs: &[VariableDefinition],
) -> Result<()> {
for arg in provided {
let Some(def) = declared.iter().find(|d| d.name == arg.name) else {
continue;
};
let Some(location) = Scalar::from_field_type(&def.arg_type) else {
continue;
};
if arg.value_type == "enum" {
continue;
}
let value = value_json::decode(&arg.value_json)?;
if let Some(var_name) = value_json::variable_name(&value) {
check_variable_usage(field_label, &arg.name, location, var_name, variable_defs)?;
} else if let Some(reason) = location.rejection(&value) {
return Err(FraiseQLError::Validation {
message: format!(
"Argument `{}` on {field_label} has type `{}`, but the document wrote {reason}",
arg.name,
location.name(),
),
path: Some(arg.name.clone()),
});
}
}
Ok(())
}
fn check_variable_usage(
field_label: &str,
arg_name: &str,
location: Scalar,
var_name: &str,
variable_defs: &[VariableDefinition],
) -> Result<()> {
let Some(def) = variable_defs.iter().find(|v| v.name == var_name) else {
return Ok(());
};
if def.var_type.list {
return Ok(());
}
let Some(declared) = Scalar::from_type_name(&def.var_type.name) else {
return Ok(());
};
if declared.usable_at(location) {
return Ok(());
}
Err(FraiseQLError::Validation {
message: format!(
"Variable `${var_name}` is declared as `{}` but is used for argument `{arg_name}` on \
{field_label}, which has type `{}`",
def.var_type.name,
location.name()
),
path: Some(arg_name.to_string()),
})
}
pub fn validate_variable_values(
operation_name: Option<&str>,
variable_defs: &[VariableDefinition],
values: Option<&Value>,
) -> Result<()> {
let supplied = values.and_then(Value::as_object);
for def in variable_defs {
let value = supplied.and_then(|map| map.get(&def.name));
let Some(value) = value else {
if !def.var_type.nullable && def.default_value.is_none() {
return Err(FraiseQLError::Validation {
message: format!(
"Variable `${}` is declared `{}!`{} but no value was supplied",
def.name,
def.var_type.name,
operation_label(operation_name)
),
path: Some(def.name.clone()),
});
}
continue;
};
if value.is_null() && !def.var_type.nullable {
return Err(FraiseQLError::Validation {
message: format!(
"Variable `${}` is declared `{}!`{} but was supplied as null",
def.name,
def.var_type.name,
operation_label(operation_name)
),
path: Some(def.name.clone()),
});
}
if def.var_type.list {
continue;
}
let Some(declared) = Scalar::from_type_name(&def.var_type.name) else {
continue;
};
if let Some(reason) = declared.rejection(value) {
return Err(FraiseQLError::Validation {
message: format!(
"Variable `${}` is declared `{}`{} but was supplied {reason}",
def.name,
def.var_type.name,
operation_label(operation_name),
),
path: Some(def.name.clone()),
});
}
}
Ok(())
}
const MAX_WALK_DEPTH: usize = 32;
const MAX_MEMBERS_LISTED: usize = 12;
pub fn validate_mutation_argument_values(
schema: &CompiledSchema,
field_label: &str,
declared: &[ArgumentDefinition],
provided: Option<&Value>,
) -> Result<()> {
let Some(map) = provided.and_then(Value::as_object) else {
return Ok(());
};
for arg in declared.iter().filter(|arg| !arg.localized) {
let Some(value) = map.get(&arg.name) else {
continue;
};
walk_field_type(schema, Rules::WRITE, field_label, &arg.name, &arg.arg_type, value, 0)?;
}
Ok(())
}
#[derive(Debug, Clone, Copy)]
struct Rules {
enums: bool,
write: bool,
literal: bool,
}
impl Rules {
const LIST_ITEMS: Self = Self {
enums: false,
write: false,
literal: false,
};
const LITERAL: Self = Self {
enums: true,
write: false,
literal: true,
};
const READ: Self = Self {
enums: true,
write: false,
literal: false,
};
const WRITE: Self = Self {
enums: true,
write: true,
literal: false,
};
fn leaves(self, value: &Value) -> bool {
self.literal && value_json::variable_name(value).is_some()
}
}
pub fn validate_enum_argument_literals(
schema: &CompiledSchema,
field_label: &str,
declared: &[ArgumentDefinition],
provided: &[GraphQLArgument],
) -> Result<()> {
for arg in provided {
let Some(def) = declared.iter().find(|d| d.name == arg.name) else {
continue;
};
let value = value_json::decode(&arg.value_json)?;
if value_json::variable_name(&value).is_some() {
continue;
}
walk_field_type(schema, Rules::LITERAL, field_label, &arg.name, &def.arg_type, &value, 0)?;
}
Ok(())
}
pub fn validate_enum_variable_values(
schema: &CompiledSchema,
operation_name: Option<&str>,
variable_defs: &[VariableDefinition],
values: Option<&Value>,
) -> Result<()> {
let Some(map) = values.and_then(Value::as_object) else {
return Ok(());
};
for def in variable_defs {
let Some(value) = map.get(&def.name) else {
continue;
};
let subject = format!("Variable `${}`{}", def.name, operation_label(operation_name));
walk_type_ref(schema, Rules::READ, &subject, "", &def.var_type.name, value, 0)?;
}
Ok(())
}
pub fn validate_argument_list_items<'a>(
schema: &CompiledSchema,
field_label: impl Fn() -> String,
declared: &[ArgumentDefinition],
argument: impl Fn(&str) -> Option<&'a Value>,
) -> Result<()> {
for arg in declared.iter().filter(|arg| !arg.localized) {
let Some(value) = argument(&arg.name).filter(|value| holds_a_null_item(value, 0)) else {
continue;
};
walk_field_type(
schema,
Rules::LIST_ITEMS,
&field_label(),
&arg.name,
&arg.arg_type,
value,
0,
)?;
}
Ok(())
}
fn holds_a_null_item(value: &Value, depth: usize) -> bool {
if depth > MAX_WALK_DEPTH {
return true;
}
match value {
Value::Array(items) => {
items.iter().any(|item| item.is_null() || holds_a_null_item(item, depth + 1))
},
Value::Object(fields) => fields.values().any(|field| holds_a_null_item(field, depth + 1)),
_ => false,
}
}
fn walk_field_type(
schema: &CompiledSchema,
rules: Rules,
subject: &str,
path: &str,
declared: &FieldType,
value: &Value,
depth: usize,
) -> Result<()> {
if value.is_null() || depth > MAX_WALK_DEPTH || rules.leaves(value) {
return Ok(());
}
match declared {
FieldType::List {
item,
nullable_items,
} => walk_list(subject, path, *nullable_items, value, depth, |v, p, d| {
walk_field_type(schema, rules, subject, p, item, v, d)
}),
FieldType::Enum(name) if rules.enums => {
check_membership(schema, subject, path, name, value)
},
FieldType::Enum(_) => Ok(()),
FieldType::Object(name) | FieldType::Input(name) => {
walk_input_object(schema, rules, subject, path, name, value, depth)
},
other => match Scalar::from_field_type(other) {
Some(scalar) if rules.write => check_scalar(subject, path, scalar, value),
_ => Ok(()),
},
}
}
fn walk_type_ref(
schema: &CompiledSchema,
rules: Rules,
subject: &str,
path: &str,
declared: &str,
value: &Value,
depth: usize,
) -> Result<()> {
if depth > MAX_WALK_DEPTH || rules.leaves(value) {
return Ok(());
}
let declared = declared.trim();
if let Some(inner) = declared.strip_suffix('!') {
return walk_type_ref(schema, rules, subject, path, inner, value, depth);
}
if let Some(inner) = declared.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
let nullable_items = !inner.trim_end().ends_with('!');
return walk_list(subject, path, nullable_items, value, depth, |v, p, d| {
walk_type_ref(schema, rules, subject, p, inner, v, d)
});
}
if value.is_null() {
return Ok(());
}
if schema.find_enum(declared).is_some() {
if !rules.enums {
return Ok(());
}
return check_membership(schema, subject, path, declared, value);
}
if let (true, Some(scalar)) = (rules.write, Scalar::from_type_name(declared)) {
return check_scalar(subject, path, scalar, value);
}
walk_input_object(schema, rules, subject, path, declared, value, depth)
}
fn walk_list(
subject: &str,
path: &str,
nullable_items: bool,
value: &Value,
depth: usize,
mut walk: impl FnMut(&Value, &str, usize) -> Result<()>,
) -> Result<()> {
match value.as_array() {
Some(items) => {
for (index, item) in items.iter().enumerate() {
let at = format!("{path}[{index}]");
if item.is_null() && !nullable_items {
return Err(FraiseQLError::Validation {
message: format!(
"{subject} at `{at}` is null, but the list's items are non-null"
),
path: Some(at),
});
}
walk(item, &at, depth + 1)?;
}
Ok(())
},
None => walk(value, path, depth + 1),
}
}
fn walk_input_object(
schema: &CompiledSchema,
rules: Rules,
subject: &str,
path: &str,
name: &str,
value: &Value,
depth: usize,
) -> Result<()> {
let (Some(input_type), Some(object)) = (schema.find_input_type(name), value.as_object()) else {
return Ok(());
};
let child_path = |key: &str| {
if path.is_empty() {
key.to_string()
} else {
format!("{path}.{key}")
}
};
if rules.write {
let declares = |key: &str| {
input_type
.fields
.iter()
.any(|f| f.name == key || schema.display_name(&f.name) == key)
};
if let Some(key) = object.keys().find(|key| !declares(key)) {
let at = child_path(key);
return Err(FraiseQLError::Validation {
message: format!("{subject} at `{at}`: input type `{name}` declares no such field"),
path: Some(at),
});
}
}
for field in &input_type.fields {
if rules.write && field.localized {
continue;
}
let surface = schema.display_name(&field.name);
let value = object
.get(surface.as_str())
.or_else(|| (surface != field.name).then(|| object.get(&field.name)).flatten());
let Some(value) = value else {
continue;
};
let child = child_path(&surface);
walk_type_ref(schema, rules, subject, &child, &field.field_type, value, depth + 1)?;
}
Ok(())
}
fn check_scalar(subject: &str, path: &str, scalar: Scalar, value: &Value) -> Result<()> {
match scalar.rejection(value) {
None => Ok(()),
Some(reason) => Err(FraiseQLError::Validation {
message: format!(
"{subject} at `{path}` has type `{}`, but the request wrote {reason}",
scalar.name()
),
path: Some(path.to_string()),
}),
}
}
fn check_membership(
schema: &CompiledSchema,
subject: &str,
path: &str,
enum_name: &str,
value: &Value,
) -> Result<()> {
let Some(def) = schema.find_enum(enum_name) else {
return Ok(());
};
if let Some(written) = value.as_str() {
if def.values.iter().any(|member| member.name == written) {
return Ok(());
}
}
let wrote = if value.is_string() {
"a name that is not one of its members".to_string()
} else {
format!("{} , which is not a name at all", json_shape(value))
};
let at = if path.is_empty() {
String::new()
} else {
format!(" at `{path}`")
};
Err(FraiseQLError::Validation {
message: format!(
"{subject}{at} has enum type `{enum_name}`, but the document wrote {wrote}. \
Valid members: {}",
member_list(def)
),
path: (!path.is_empty()).then(|| path.to_string()),
})
}
fn member_list(def: &EnumDefinition) -> String {
let total = def.values.len();
let shown: Vec<&str> =
def.values.iter().take(MAX_MEMBERS_LISTED).map(|v| v.name.as_str()).collect();
if total > shown.len() {
format!("{} (and {} more)", shown.join(", "), total - shown.len())
} else {
shown.join(", ")
}
}
fn operation_label(operation_name: Option<&str>) -> String {
operation_name.map_or_else(String::new, |name| format!(" in operation `{name}`"))
}
#[cfg(test)]
#[path = "argument_value_validation_tests.rs"]
mod argument_value_validation_tests;