From bf1b4e2ac96c494c90dbf3ea7b86cf3e43758157 Mon Sep 17 00:00:00 2001 From: Christian Legnitto Date: Sat, 6 Jun 2026 15:18:08 -0700 Subject: [PATCH] Return error instead of panicking on unsupported operation type Executing a `mutation` against a schema built with `EmptyMutation` (or a `subscription` with `EmptySubscription`) panicked with "No mutation type found" / "No subscription type found": the schema reports no root type for that operation (`mutation_type()`/`subscription_type()` return `None`, since `_EmptyMutation`/`_EmptySubscription` are intentionally hidden from the schema), but the executor `.expect()`ed it. Validation does not reject the operation (there is no root type to validate its fields against), so a client-supplied mutation/subscription reached the executor and aborted the request via a panic. Return a new `GraphQLError::NotSupported(OperationType)` request error instead, in both the sync and async query executors and the subscription resolver. This is the spec-aligned behavior (GetOperationRootType raises a request error when the schema has no matching root type). Add regression tests covering sync mutation, async mutation, and subscription against a query-only schema. --- juniper/CHANGELOG.md | 3 + juniper/src/executor/mod.rs | 32 +++++--- juniper/src/integrations/serde.rs | 10 ++- juniper/src/lib.rs | 15 +++- juniper/src/tests/mod.rs | 2 + juniper/src/tests/operation_not_supported.rs | 82 ++++++++++++++++++++ 6 files changed, 130 insertions(+), 14 deletions(-) create mode 100644 juniper/src/tests/operation_not_supported.rs diff --git a/juniper/CHANGELOG.md b/juniper/CHANGELOG.md index c2a8f7726..40a954c53 100644 --- a/juniper/CHANGELOG.md +++ b/juniper/CHANGELOG.md @@ -21,6 +21,7 @@ All user visible changes to `juniper` crate will be documented in this file. Thi - Changed `ScalarToken::String` to contain raw quoted and escaped `StringLiteral` (was unquoted but escaped string before). ([#1349]) - Added `LexerError::UnterminatedBlockString` variant. ([#1349]) - Fixed `ValuesStream` to return batch of `ExecutionError`s instead of a single one. ([#1371]) +- Added `GraphQLError::NotSupported` variant. ([#1378]) ### Added @@ -65,6 +66,7 @@ All user visible changes to `juniper` crate will be documented in this file. Thi - Incorrect double escaping in `ScalarToken::String` `Display`ing. ([#1349]) - Memory leak caused by incorrect error handling in `#[graphql_subscription]` macro expansion. ([#1371]) - Incorrect rejection of default values on non-`Null` variables. ([#1376]) +- Executing a `mutation` against a schema without a mutation type (e.g. `EmptyMutation`), or a `subscription` against one without a subscription type (e.g. `EmptySubscription`), now returns a `GraphQLError::NotSupported` error rather than panicking. ([#1378]) [#864]: /../../issues/864 [#1055]: /../../issues/1055 @@ -80,6 +82,7 @@ All user visible changes to `juniper` crate will be documented in this file. Thi [#1371]: /../../pull/1371 [#1376]: /../../pull/1376 [#1377]: /../../pull/1377 +[#1378]: /../../pull/1378 [graphql/graphql-spec#525]: https://github.com/graphql/graphql-spec/pull/525 [graphql/graphql-spec#687]: https://github.com/graphql/graphql-spec/issues/687 [graphql/graphql-spec#805]: https://github.com/graphql/graphql-spec/pull/805 diff --git a/juniper/src/executor/mod.rs b/juniper/src/executor/mod.rs index accddefda..36c5fc1e8 100644 --- a/juniper/src/executor/mod.rs +++ b/juniper/src/executor/mod.rs @@ -859,10 +859,13 @@ where let root_type = match operation.item.operation_type { OperationType::Query => root_node.schema.query_type(), - OperationType::Mutation => root_node - .schema - .mutation_type() - .expect("No mutation type found"), + OperationType::Mutation => match root_node.schema.mutation_type() { + Some(root_type) => root_type, + // The schema has no mutation type (e.g. `EmptyMutation`), so a + // mutation operation can't be executed against it. Return a + // request error instead of panicking. + None => return Err(GraphQLError::NotSupported(OperationType::Mutation)), + }, OperationType::Subscription => unreachable!(), }; @@ -957,10 +960,13 @@ where let root_type = match operation.item.operation_type { OperationType::Query => root_node.schema.query_type(), - OperationType::Mutation => root_node - .schema - .mutation_type() - .expect("No mutation type found"), + OperationType::Mutation => match root_node.schema.mutation_type() { + Some(root_type) => root_type, + // The schema has no mutation type (e.g. `EmptyMutation`), so a + // mutation operation can't be executed against it. Return a + // request error instead of panicking. + None => return Err(GraphQLError::NotSupported(OperationType::Mutation)), + }, OperationType::Subscription => unreachable!(), }; @@ -1103,10 +1109,12 @@ where } let root_type = match operation.item.operation_type { - OperationType::Subscription => root_node - .schema - .subscription_type() - .expect("No subscription type found"), + OperationType::Subscription => match root_node.schema.subscription_type() { + Some(root_type) => root_type, + // The schema has no subscription type (e.g. `EmptySubscription`); + // return a request error instead of panicking. + None => return Err(GraphQLError::NotSupported(OperationType::Subscription)), + }, _ => unreachable!(), }; diff --git a/juniper/src/integrations/serde.rs b/juniper/src/integrations/serde.rs index fbde8721d..990121803 100644 --- a/juniper/src/integrations/serde.rs +++ b/juniper/src/integrations/serde.rs @@ -9,7 +9,7 @@ use serde::{ use crate::{ DefaultScalarValue, GraphQLError, Object, Value, - ast::InputValue, + ast::{InputValue, OperationType}, executor::ExecutionError, parser::{ParseError, SourcePosition, Spanning}, validation::RuleError, @@ -69,6 +69,14 @@ impl Serialize for GraphQLError { message: "Expected subscription, got query", }] .serialize(ser), + Self::NotSupported(op) => [Helper { + message: match op { + OperationType::Query => "Schema is not configured for queries", + OperationType::Mutation => "Schema is not configured for mutations", + OperationType::Subscription => "Schema is not configured for subscriptions", + }, + }] + .serialize(ser), } } } diff --git a/juniper/src/lib.rs b/juniper/src/lib.rs index ff83576d2..56b672bd6 100644 --- a/juniper/src/lib.rs +++ b/juniper/src/lib.rs @@ -137,6 +137,18 @@ pub enum GraphQLError { IsSubscription, #[display("Operation is not a subscription")] NotSubscription, + /// The requested operation type has no corresponding root type defined in + /// the schema — e.g. a `mutation` against [`EmptyMutation`], or a + /// `subscription` against [`EmptySubscription`]. + /// + /// [`EmptyMutation`]: crate::EmptyMutation + /// [`EmptySubscription`]: crate::EmptySubscription + #[display("Schema is not configured for {} operations", match _0 { + OperationType::Query => "queries", + OperationType::Mutation => "mutations", + OperationType::Subscription => "subscriptions", + })] + NotSupported(OperationType), } impl From for GraphQLError { @@ -154,7 +166,8 @@ impl std::error::Error for GraphQLError { | Self::MultipleOperationsProvided | Self::UnknownOperationName | Self::IsSubscription - | Self::NotSubscription => None, + | Self::NotSubscription + | Self::NotSupported(_) => None, } } } diff --git a/juniper/src/tests/mod.rs b/juniper/src/tests/mod.rs index 3e42ec7da..920ee18ab 100644 --- a/juniper/src/tests/mod.rs +++ b/juniper/src/tests/mod.rs @@ -4,6 +4,8 @@ pub mod fixtures; #[cfg(test)] mod introspection_tests; #[cfg(test)] +mod operation_not_supported; +#[cfg(test)] mod query_tests; #[cfg(test)] mod schema_introspection; diff --git a/juniper/src/tests/operation_not_supported.rs b/juniper/src/tests/operation_not_supported.rs new file mode 100644 index 000000000..f165c4ff3 --- /dev/null +++ b/juniper/src/tests/operation_not_supported.rs @@ -0,0 +1,82 @@ +//! Regression tests for [`GraphQLError::NotSupported`]. +//! +//! Executing an operation whose root type the schema doesn't define — e.g. a +//! `mutation` against [`EmptyMutation`], or a `subscription` against +//! [`EmptySubscription`] — must return a request error, not panic. + +use crate::{ + GraphQLError, + ast::OperationType, + graphql, + schema::model::RootNode, + tests::fixtures::starwars::schema::{Database, Query}, + types::scalars::{EmptyMutation, EmptySubscription}, +}; + +fn query_only_schema() -> RootNode, EmptySubscription> { + RootNode::new( + Query, + EmptyMutation::::new(), + EmptySubscription::::new(), + ) +} + +#[test] +fn sync_mutation_on_query_only_schema_errors_instead_of_panicking() { + let schema = query_only_schema(); + let database = Database::new(); + let result = crate::execute_sync( + "mutation { foo }", + None, + &schema, + &graphql::vars! {}, + &database, + ); + assert!( + matches!( + &result, + Err(GraphQLError::NotSupported(OperationType::Mutation)), + ), + "expected NotSupported(Mutation), got {result:?}", + ); +} + +#[tokio::test] +async fn async_mutation_on_query_only_schema_errors_instead_of_panicking() { + let schema = query_only_schema(); + let database = Database::new(); + let result = crate::execute( + "mutation { foo }", + None, + &schema, + &graphql::vars! {}, + &database, + ) + .await; + assert!( + matches!( + &result, + Err(GraphQLError::NotSupported(OperationType::Mutation)), + ), + "expected NotSupported(Mutation), got {result:?}", + ); +} + +#[tokio::test] +async fn subscription_without_subscription_root_errors_instead_of_panicking() { + let schema = query_only_schema(); + let database = Database::new(); + let result = crate::resolve_into_stream( + "subscription { foo }", + None, + &schema, + &graphql::vars! {}, + &database, + ) + .await; + match result { + Err(GraphQLError::NotSupported(OperationType::Subscription)) => {} + Err(other) => panic!("expected NotSupported(Subscription), got {other:?}"), + Ok(_) => panic!("expected an error, got a stream"), + } +}