use anyhow::{Result, bail};
use graphql_parser::query::Field;
use serde_json::Value;
use std::sync::Arc;
use uuid::Uuid;
use super::field_resolver;
use super::link_mutations;
use super::utils;
use crate::core::events::{EntityEvent, FrameworkEvent};
use crate::server::host::ServerHost;
pub async fn resolve_mutation_field(
host: &Arc<ServerHost>,
field: &Field<'_, String>,
) -> Result<Value> {
let field_name = field.name.as_str();
if field_name == "createLink" {
return link_mutations::create_link_mutation(host, field).await;
}
if field_name.starts_with("create") && field_name.contains("For") {
return link_mutations::create_and_link_mutation(host, field).await;
}
if field_name.starts_with("link") && field_name.contains("To") {
return link_mutations::link_entities_mutation(host, field).await;
}
if field_name.starts_with("unlink") && field_name.contains("From") {
return link_mutations::unlink_entities_mutation(host, field).await;
}
if field_name.starts_with("create") {
return create_entity_mutation(host, field).await;
}
if field_name.starts_with("update") {
return update_entity_mutation(host, field).await;
}
if field_name.starts_with("delete") {
return delete_entity_mutation(host, field).await;
}
if field_name == "deleteLink" {
return link_mutations::delete_link_mutation(host, field).await;
}
if let Some(store) = host.notification_store() {
if field_name == "markNotificationAsRead" {
let id = utils::get_string_arg(field, "id")
.ok_or_else(|| anyhow::anyhow!("Missing required argument 'id'"))?;
let uuid = Uuid::parse_str(&id)?;
let marked = store.mark_as_read(&[uuid], None).await;
return Ok(Value::Bool(marked > 0));
}
if field_name == "markAllNotificationsAsRead" {
let user_id = utils::get_string_arg(field, "userId")
.ok_or_else(|| anyhow::anyhow!("Missing required argument 'userId'"))?;
let marked = store.mark_all_as_read(&user_id).await;
return Ok(serde_json::json!(marked));
}
if field_name == "deleteNotification" {
let id = utils::get_string_arg(field, "id")
.ok_or_else(|| anyhow::anyhow!("Missing required argument 'id'"))?;
let uuid = Uuid::parse_str(&id)?;
let deleted = store.delete(&uuid).await;
return Ok(Value::Bool(deleted));
}
}
bail!("Unknown mutation field: {}", field_name);
}
async fn create_entity_mutation(
host: &Arc<ServerHost>,
field: &Field<'_, String>,
) -> Result<Value> {
let field_name = field.name.as_str();
let entity_type = utils::mutation_name_to_entity_type(field_name, "create");
let data = utils::get_json_arg(field, "data")
.ok_or_else(|| anyhow::anyhow!("Missing required argument 'data'"))?;
if let Some(creator) = host.entity_creators.get(&entity_type) {
let created = creator.create_from_json(data).await?;
if let Some(event_bus) = host.event_bus() {
let entity_id = utils::extract_uuid_from_value(&created).unwrap_or_default();
event_bus.publish(FrameworkEvent::Entity(EntityEvent::Created {
entity_type: entity_type.clone(),
entity_id,
data: created.clone(),
}));
}
let resolved = field_resolver::resolve_entity_fields(
host,
created,
&field.selection_set.items,
&entity_type,
)
.await?;
Ok(resolved)
} else {
bail!("Unknown entity type: {}", entity_type);
}
}
async fn update_entity_mutation(
host: &Arc<ServerHost>,
field: &Field<'_, String>,
) -> Result<Value> {
let field_name = field.name.as_str();
let entity_type = utils::mutation_name_to_entity_type(field_name, "update");
let id = utils::get_string_arg(field, "id")
.ok_or_else(|| anyhow::anyhow!("Missing required argument 'id'"))?;
let uuid = Uuid::parse_str(&id)?;
let data = utils::get_json_arg(field, "data")
.ok_or_else(|| anyhow::anyhow!("Missing required argument 'data'"))?;
if let Some(creator) = host.entity_creators.get(&entity_type) {
let updated = creator.update_from_json(&uuid, data).await?;
if let Some(event_bus) = host.event_bus() {
event_bus.publish(FrameworkEvent::Entity(EntityEvent::Updated {
entity_type: entity_type.clone(),
entity_id: uuid,
data: updated.clone(),
}));
}
let resolved = field_resolver::resolve_entity_fields(
host,
updated,
&field.selection_set.items,
&entity_type,
)
.await?;
Ok(resolved)
} else {
bail!("Unknown entity type: {}", entity_type);
}
}
async fn delete_entity_mutation(
host: &Arc<ServerHost>,
field: &Field<'_, String>,
) -> Result<Value> {
let field_name = field.name.as_str();
let entity_type = utils::mutation_name_to_entity_type(field_name, "delete");
let id = utils::get_string_arg(field, "id")
.ok_or_else(|| anyhow::anyhow!("Missing required argument 'id'"))?;
let uuid = Uuid::parse_str(&id)?;
if let Some(creator) = host.entity_creators.get(&entity_type) {
creator.delete(&uuid).await?;
if let Some(event_bus) = host.event_bus() {
event_bus.publish(FrameworkEvent::Entity(EntityEvent::Deleted {
entity_type: entity_type.clone(),
entity_id: uuid,
}));
}
Ok(Value::Bool(true))
} else {
bail!("Unknown entity type: {}", entity_type);
}
}
#[cfg(test)]
#[cfg(feature = "graphql")]
mod tests {
use super::super::core::GraphQLExecutor;
use crate::config::{EntityAuthConfig, EntityConfig, LinksConfig};
use crate::core::link::LinkDefinition;
use crate::core::{EntityCreator, EntityFetcher};
use crate::server::entity_registry::{EntityDescriptor, EntityRegistry};
use crate::server::host::ServerHost;
use crate::storage::in_memory::InMemoryLinkService;
use async_trait::async_trait;
use axum::Router;
use serde_json::{Value, json};
use std::collections::HashMap;
use std::sync::Arc;
use uuid::Uuid;
struct MockFetcher;
#[async_trait]
impl EntityFetcher for MockFetcher {
async fn fetch_as_json(&self, _entity_id: &Uuid) -> anyhow::Result<Value> {
Ok(json!({}))
}
}
struct MockCreator;
#[async_trait]
impl EntityCreator for MockCreator {
async fn create_from_json(&self, mut data: Value) -> anyhow::Result<Value> {
let id = Uuid::new_v4();
if let Some(obj) = data.as_object_mut() {
obj.insert("id".to_string(), json!(id.to_string()));
}
Ok(data)
}
async fn update_from_json(
&self,
entity_id: &Uuid,
mut data: Value,
) -> anyhow::Result<Value> {
if let Some(obj) = data.as_object_mut() {
obj.insert("id".to_string(), json!(entity_id.to_string()));
}
Ok(data)
}
async fn delete(&self, _entity_id: &Uuid) -> anyhow::Result<()> {
Ok(())
}
}
struct StubDescriptor {
entity_type: String,
plural: String,
}
impl StubDescriptor {
fn new(singular: &str, plural: &str) -> Self {
Self {
entity_type: singular.to_string(),
plural: plural.to_string(),
}
}
}
impl EntityDescriptor for StubDescriptor {
fn entity_type(&self) -> &str {
&self.entity_type
}
fn plural(&self) -> &str {
&self.plural
}
fn build_routes(&self) -> Router {
Router::new()
}
}
fn build_test_host() -> Arc<ServerHost> {
let link_service = Arc::new(InMemoryLinkService::new());
let config = LinksConfig {
entities: vec![
EntityConfig {
singular: "order".to_string(),
plural: "orders".to_string(),
auth: EntityAuthConfig::default(),
},
EntityConfig {
singular: "invoice".to_string(),
plural: "invoices".to_string(),
auth: EntityAuthConfig::default(),
},
],
links: vec![LinkDefinition {
link_type: "has_invoice".to_string(),
source_type: "order".to_string(),
target_type: "invoice".to_string(),
forward_route_name: "invoices".to_string(),
reverse_route_name: "order".to_string(),
description: None,
required_fields: None,
auth: None,
}],
validation_rules: None,
events: None,
sinks: None,
};
let mut registry = EntityRegistry::new();
registry.register(Box::new(StubDescriptor::new("order", "orders")));
registry.register(Box::new(StubDescriptor::new("invoice", "invoices")));
let mut fetchers: HashMap<String, Arc<dyn EntityFetcher>> = HashMap::new();
fetchers.insert("order".to_string(), Arc::new(MockFetcher));
fetchers.insert("invoice".to_string(), Arc::new(MockFetcher));
let mut creators: HashMap<String, Arc<dyn EntityCreator>> = HashMap::new();
creators.insert("order".to_string(), Arc::new(MockCreator));
creators.insert("invoice".to_string(), Arc::new(MockCreator));
Arc::new(
ServerHost::from_builder_components(link_service, config, registry, fetchers, creators)
.expect("should build test host"),
)
}
#[tokio::test]
async fn test_create_entity_mutation() {
let host = build_test_host();
let executor = GraphQLExecutor::new(host).await;
let result = executor
.execute(
r#"mutation { createOrder(data: {name: "test"}) { id name } }"#,
None,
)
.await
.expect("createOrder should succeed");
let created = result
.get("data")
.and_then(|d| d.get("createOrder"))
.expect("should have createOrder");
assert!(created.get("id").is_some(), "created entity should have id");
}
#[tokio::test]
async fn test_create_entity_missing_data_returns_err() {
let host = build_test_host();
let executor = GraphQLExecutor::new(host).await;
let result = executor
.execute(r#"mutation { createOrder { id } }"#, None)
.await;
assert!(result.is_err(), "missing data should error");
let err_msg = result.expect_err("error").to_string();
assert!(
err_msg.contains("data"),
"error should mention 'data': {}",
err_msg
);
}
#[tokio::test]
async fn test_update_entity_mutation() {
let order_id = Uuid::new_v4();
let host = build_test_host();
let executor = GraphQLExecutor::new(host).await;
let query = format!(
r#"mutation {{ updateOrder(id: "{}", data: {{name: "updated"}}) {{ id name }} }}"#,
order_id
);
let result = executor
.execute(&query, None)
.await
.expect("updateOrder should succeed");
let updated = result
.get("data")
.and_then(|d| d.get("updateOrder"))
.expect("should have updateOrder");
assert_eq!(
updated.get("id").and_then(|v| v.as_str()),
Some(order_id.to_string()).as_deref()
);
}
#[tokio::test]
async fn test_update_entity_missing_id_returns_err() {
let host = build_test_host();
let executor = GraphQLExecutor::new(host).await;
let result = executor
.execute(
r#"mutation { updateOrder(data: {name: "updated"}) { id } }"#,
None,
)
.await;
assert!(result.is_err(), "missing id should error");
}
#[tokio::test]
async fn test_delete_entity_mutation() {
let order_id = Uuid::new_v4();
let host = build_test_host();
let executor = GraphQLExecutor::new(host).await;
let query = format!(r#"mutation {{ deleteOrder(id: "{}") }}"#, order_id);
let result = executor
.execute(&query, None)
.await
.expect("deleteOrder should succeed");
let deleted = result
.get("data")
.and_then(|d| d.get("deleteOrder"))
.expect("should have deleteOrder");
assert_eq!(*deleted, Value::Bool(true));
}
#[tokio::test]
async fn test_delete_entity_missing_id_returns_err() {
let host = build_test_host();
let executor = GraphQLExecutor::new(host).await;
let result = executor.execute(r#"mutation { deleteOrder }"#, None).await;
assert!(result.is_err(), "missing id should error");
}
#[tokio::test]
async fn test_unknown_mutation_returns_err() {
let host = build_test_host();
let executor = GraphQLExecutor::new(host).await;
let result = executor
.execute(r#"mutation { doSomethingWeird { id } }"#, None)
.await;
assert!(result.is_err(), "unknown mutation should error");
let err_msg = result.expect_err("error").to_string();
assert!(
err_msg.contains("Unknown mutation field"),
"should mention unknown mutation: {}",
err_msg
);
}
#[tokio::test]
async fn test_create_unknown_entity_type_returns_err() {
let host = build_test_host();
let executor = GraphQLExecutor::new(host).await;
let result = executor
.execute(
r#"mutation { createWidget(data: {name: "w"}) { id } }"#,
None,
)
.await;
assert!(result.is_err(), "unknown entity type should error");
let err_msg = result.expect_err("error").to_string();
assert!(
err_msg.contains("Unknown entity type"),
"should mention unknown entity: {}",
err_msg
);
}
#[tokio::test]
async fn test_create_link_mutation_dispatches() {
let host = build_test_host();
let executor = GraphQLExecutor::new(host).await;
let source_id = Uuid::new_v4();
let target_id = Uuid::new_v4();
let query = format!(
r#"mutation {{ createLink(sourceId: "{}", targetId: "{}", linkType: "has_invoice") {{ id }} }}"#,
source_id, target_id
);
let result = executor
.execute(&query, None)
.await
.expect("createLink should succeed");
let link_result = result
.get("data")
.and_then(|d| d.get("createLink"))
.expect("should have createLink");
assert!(link_result.get("id").is_some(), "link should have id");
}
#[tokio::test]
async fn test_unlink_entities_mutation_dispatches() {
let host = build_test_host();
let executor = GraphQLExecutor::new(host).await;
let source_id = Uuid::new_v4();
let target_id = Uuid::new_v4();
let query = format!(
r#"mutation {{ unlinkInvoiceFromOrder(sourceId: "{}", targetId: "{}") }}"#,
source_id, target_id
);
let result = executor
.execute(&query, None)
.await
.expect("unlink should succeed");
let unlink_result = result
.get("data")
.and_then(|d| d.get("unlinkInvoiceFromOrder"))
.expect("should have unlink result");
assert_eq!(
*unlink_result,
Value::Bool(false),
"should return false when no link found"
);
}
fn build_test_host_with_event_bus() -> Arc<ServerHost> {
use crate::core::events::EventBus;
let link_service = Arc::new(InMemoryLinkService::new());
let config = LinksConfig {
entities: vec![
EntityConfig {
singular: "order".to_string(),
plural: "orders".to_string(),
auth: EntityAuthConfig::default(),
},
EntityConfig {
singular: "invoice".to_string(),
plural: "invoices".to_string(),
auth: EntityAuthConfig::default(),
},
],
links: vec![LinkDefinition {
link_type: "has_invoice".to_string(),
source_type: "order".to_string(),
target_type: "invoice".to_string(),
forward_route_name: "invoices".to_string(),
reverse_route_name: "order".to_string(),
description: None,
required_fields: None,
auth: None,
}],
validation_rules: None,
events: None,
sinks: None,
};
let mut registry = EntityRegistry::new();
registry.register(Box::new(StubDescriptor::new("order", "orders")));
registry.register(Box::new(StubDescriptor::new("invoice", "invoices")));
let mut fetchers: HashMap<String, Arc<dyn EntityFetcher>> = HashMap::new();
fetchers.insert("order".to_string(), Arc::new(MockFetcher));
fetchers.insert("invoice".to_string(), Arc::new(MockFetcher));
let mut creators: HashMap<String, Arc<dyn EntityCreator>> = HashMap::new();
creators.insert("order".to_string(), Arc::new(MockCreator));
creators.insert("invoice".to_string(), Arc::new(MockCreator));
Arc::new(
ServerHost::from_builder_components(link_service, config, registry, fetchers, creators)
.expect("should build test host")
.with_event_bus(EventBus::new(256)),
)
}
#[tokio::test]
async fn test_create_entity_publishes_event() {
let host = build_test_host_with_event_bus();
let executor = GraphQLExecutor::new(host).await;
let result = executor
.execute(
r#"mutation { createOrder(data: {name: "test"}) { id name } }"#,
None,
)
.await
.expect("createOrder should succeed with EventBus");
let created = result
.get("data")
.and_then(|d| d.get("createOrder"))
.expect("should have createOrder");
assert!(created.get("id").is_some());
}
#[tokio::test]
async fn test_update_entity_publishes_event() {
let host = build_test_host_with_event_bus();
let executor = GraphQLExecutor::new(host).await;
let order_id = Uuid::new_v4();
let query = format!(
r#"mutation {{ updateOrder(id: "{}", data: {{name: "updated"}}) {{ id }} }}"#,
order_id
);
let result = executor
.execute(&query, None)
.await
.expect("updateOrder should succeed with EventBus");
let updated = result
.get("data")
.and_then(|d| d.get("updateOrder"))
.expect("should have updateOrder");
assert!(updated.get("id").is_some());
}
#[tokio::test]
async fn test_delete_entity_publishes_event() {
let host = build_test_host_with_event_bus();
let executor = GraphQLExecutor::new(host).await;
let order_id = Uuid::new_v4();
let query = format!(r#"mutation {{ deleteOrder(id: "{}") }}"#, order_id);
let result = executor
.execute(&query, None)
.await
.expect("deleteOrder should succeed with EventBus");
let deleted = result
.get("data")
.and_then(|d| d.get("deleteOrder"))
.expect("should have deleteOrder");
assert_eq!(*deleted, Value::Bool(true));
}
fn build_test_host_with_notifications() -> (
Arc<ServerHost>,
Arc<crate::events::sinks::in_app::NotificationStore>,
) {
use crate::core::events::EventBus;
use crate::events::sinks::in_app::NotificationStore;
let link_service = Arc::new(InMemoryLinkService::new());
let config = LinksConfig {
entities: vec![
EntityConfig {
singular: "order".to_string(),
plural: "orders".to_string(),
auth: EntityAuthConfig::default(),
},
EntityConfig {
singular: "invoice".to_string(),
plural: "invoices".to_string(),
auth: EntityAuthConfig::default(),
},
],
links: vec![LinkDefinition {
link_type: "has_invoice".to_string(),
source_type: "order".to_string(),
target_type: "invoice".to_string(),
forward_route_name: "invoices".to_string(),
reverse_route_name: "order".to_string(),
description: None,
required_fields: None,
auth: None,
}],
validation_rules: None,
events: None,
sinks: None,
};
let mut registry = EntityRegistry::new();
registry.register(Box::new(StubDescriptor::new("order", "orders")));
registry.register(Box::new(StubDescriptor::new("invoice", "invoices")));
let mut fetchers: HashMap<String, Arc<dyn EntityFetcher>> = HashMap::new();
fetchers.insert("order".to_string(), Arc::new(MockFetcher));
fetchers.insert("invoice".to_string(), Arc::new(MockFetcher));
let mut creators: HashMap<String, Arc<dyn EntityCreator>> = HashMap::new();
creators.insert("order".to_string(), Arc::new(MockCreator));
creators.insert("invoice".to_string(), Arc::new(MockCreator));
let notification_store = Arc::new(NotificationStore::new());
let host = Arc::new(
ServerHost::from_builder_components(link_service, config, registry, fetchers, creators)
.expect("should build test host")
.with_event_bus(EventBus::new(256))
.with_notification_store(notification_store.clone()),
);
(host, notification_store)
}
#[tokio::test]
async fn test_mark_notification_as_read_mutation() {
use crate::events::sinks::in_app::StoredNotification;
let (host, store) = build_test_host_with_notifications();
let notif_id = Uuid::new_v4();
store
.insert(StoredNotification {
id: notif_id,
recipient_id: "user-1".to_string(),
notification_type: "test".to_string(),
title: "Test Title".to_string(),
body: "Test body".to_string(),
data: json!({}),
read: false,
created_at: chrono::Utc::now(),
})
.await;
let executor = GraphQLExecutor::new(host).await;
let query = format!(
r#"mutation {{ markNotificationAsRead(id: "{}") }}"#,
notif_id
);
let result = executor
.execute(&query, None)
.await
.expect("markNotificationAsRead should succeed");
let marked = result
.get("data")
.and_then(|d| d.get("markNotificationAsRead"))
.expect("should have result");
assert_eq!(*marked, Value::Bool(true));
}
#[tokio::test]
async fn test_mark_all_notifications_as_read_mutation() {
use crate::events::sinks::in_app::StoredNotification;
let (host, store) = build_test_host_with_notifications();
for i in 0..3 {
store
.insert(StoredNotification {
id: Uuid::new_v4(),
recipient_id: "user-42".to_string(),
notification_type: "info".to_string(),
title: format!("Notification {}", i),
body: "body".to_string(),
data: json!({}),
read: false,
created_at: chrono::Utc::now(),
})
.await;
}
let executor = GraphQLExecutor::new(host).await;
let result = executor
.execute(
r#"mutation { markAllNotificationsAsRead(userId: "user-42") }"#,
None,
)
.await
.expect("markAllNotificationsAsRead should succeed");
let count = result
.get("data")
.and_then(|d| d.get("markAllNotificationsAsRead"))
.expect("should have result");
assert_eq!(*count, json!(3));
}
}