use std::{
collections::HashMap,
sync::atomic::{AtomicU64, Ordering},
};
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::Value as JsonValue;
use uuid::Uuid;
use super::cascade::MutationErrorClass;
use crate::error::{FraiseQLError, Result};
const HTTP_STATUS_MIN: i16 = 100;
const HTTP_STATUS_MAX: i16 = 599;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum MutationErrorShapeCheck {
#[default]
Off,
Warn,
}
static ERROR_SHAPE_VIOLATIONS: AtomicU64 = AtomicU64::new(0);
#[must_use]
pub fn mutation_error_shape_violations() -> u64 {
ERROR_SHAPE_VIOLATIONS.load(Ordering::Relaxed)
}
pub(crate) fn check_error_shape(
check: MutationErrorShapeCheck,
outcome: &MutationOutcome,
mutation: &str,
function: &str,
) {
if check == MutationErrorShapeCheck::Off {
return;
}
let MutationOutcome::Error { metadata, .. } = outcome else {
return;
};
let problems = error_shape_problems(metadata);
if problems.is_empty() {
return;
}
ERROR_SHAPE_VIOLATIONS.fetch_add(1, Ordering::Relaxed);
tracing::warn!(
mutation = %mutation,
function = %function,
problems = %problems.join("; "),
"mutation failure without a translatable errors[] entry"
);
}
#[must_use]
pub fn error_shape_problems(error_detail: &JsonValue) -> Vec<String> {
let Some(entries) = error_detail.get("errors").and_then(JsonValue::as_array) else {
return vec!["no `errors` array in error_detail".to_string()];
};
if entries.is_empty() {
return vec!["an empty `errors` array in error_detail".to_string()];
}
entries
.iter()
.enumerate()
.filter_map(|(i, entry)| match entry.get("identifier").and_then(JsonValue::as_str) {
None => Some(format!("errors[{i}] has no string `identifier`")),
Some(id) if !is_translation_key(id) => Some(format!(
"errors[{i}].identifier '{id}' is not a translation key (^[a-z][a-z0-9_]*$)"
)),
Some(_) => None,
})
.collect()
}
fn is_translation_key(identifier: &str) -> bool {
let mut chars = identifier.chars();
chars.next().is_some_and(|c| c.is_ascii_lowercase())
&& chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum MutationOutcome {
Success {
entity: JsonValue,
entity_type: Option<String>,
entity_id: Option<String>,
cascade: Option<JsonValue>,
updated_fields: Vec<String>,
},
Error {
error_class: MutationErrorClass,
message: String,
http_status: Option<i16>,
entity_type: Option<String>,
metadata: JsonValue,
},
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct MutationResponse {
pub succeeded: bool,
pub state_changed: bool,
#[serde(default)]
pub error_class: Option<MutationErrorClass>,
#[serde(default)]
pub status_detail: Option<String>,
#[serde(default)]
pub http_status: Option<i16>,
#[serde(default)]
pub message: Option<String>,
#[serde(default)]
pub entity_id: Option<Uuid>,
#[serde(default)]
pub entity_type: Option<String>,
#[serde(default)]
pub entity: JsonValue,
#[serde(default, deserialize_with = "null_as_empty_string_vec")]
pub updated_fields: Vec<String>,
#[serde(default)]
pub cascade: JsonValue,
#[serde(default)]
pub error_detail: JsonValue,
#[serde(default)]
pub metadata: JsonValue,
}
fn null_as_empty_string_vec<'de, D>(deserializer: D) -> std::result::Result<Vec<String>, D::Error>
where
D: Deserializer<'de>,
{
Ok(Option::<Vec<String>>::deserialize(deserializer)?.unwrap_or_default())
}
pub fn parse_mutation_row<S: ::std::hash::BuildHasher>(
row: &HashMap<String, JsonValue, S>,
) -> Result<MutationOutcome> {
let obj: serde_json::Map<String, JsonValue> =
row.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
let parsed: MutationResponse =
serde_json::from_value(JsonValue::Object(obj)).map_err(|e| FraiseQLError::Validation {
message: format!("mutation_response row failed to deserialize: {e}"),
path: None,
})?;
to_outcome(parsed)
}
fn to_outcome(row: MutationResponse) -> Result<MutationOutcome> {
if let Some(status) = row.http_status {
if !(HTTP_STATUS_MIN..=HTTP_STATUS_MAX).contains(&status) {
return Err(FraiseQLError::Validation {
message: format!(
"mutation_response 'http_status' out of range: {status} \
(expected {HTTP_STATUS_MIN}..={HTTP_STATUS_MAX})"
),
path: None,
});
}
}
if row.succeeded {
if row.error_class.is_some() {
return Err(FraiseQLError::Validation {
message: "mutation_response: succeeded=true but error_class is set".to_string(),
path: None,
});
}
Ok(MutationOutcome::Success {
entity: row.entity,
entity_type: row.entity_type,
entity_id: row.entity_id.map(|u| u.to_string()),
cascade: filter_null(row.cascade),
updated_fields: row.updated_fields,
})
} else {
if row.state_changed {
return Err(FraiseQLError::Validation {
message: "mutation_response: succeeded=false with state_changed=true is illegal \
(partial-failure rows are builder-rejected)"
.to_string(),
path: None,
});
}
let Some(class) = row.error_class else {
return Err(FraiseQLError::Validation {
message: "mutation_response: succeeded=false requires error_class".to_string(),
path: None,
});
};
Ok(MutationOutcome::Error {
error_class: class,
message: row.message.unwrap_or_default(),
http_status: row.http_status,
entity_type: row.entity_type,
metadata: row.error_detail,
})
}
}
fn filter_null(v: JsonValue) -> Option<JsonValue> {
if v.is_null() { None } else { Some(v) }
}