From e06eac22adc08a1f744e94fa37661f4d954d000e Mon Sep 17 00:00:00 2001 From: spetrunin Date: Wed, 22 Jul 2026 13:57:36 +0300 Subject: [PATCH] feat(router): add allow_string_literals_for_enums engine option --- docs-website/docs.json | 1 + docs-website/router/configuration.mdx | 2 + .../router/string-literals-for-enums.mdx | 71 +++++++++++++++++++ .../inline_literal_validation_test.go | 70 ++++++++++++++++++ router/core/graph_server.go | 9 +-- router/core/operation_processor.go | 6 ++ router/pkg/config/config.go | 2 + router/pkg/config/config.schema.json | 5 ++ .../pkg/config/testdata/config_defaults.json | 1 + router/pkg/config/testdata/config_full.json | 1 + 10 files changed, 164 insertions(+), 4 deletions(-) create mode 100644 docs-website/router/string-literals-for-enums.mdx diff --git a/docs-website/docs.json b/docs-website/docs.json index 9bf12830dd..f31810c91b 100644 --- a/docs-website/docs.json +++ b/docs-website/docs.json @@ -291,6 +291,7 @@ ] }, "router/relaxed-field-selection-merging-nullability", + "router/string-literals-for-enums", "router/file-upload", "router/access-logs", "router/profiling", diff --git a/docs-website/router/configuration.mdx b/docs-website/router/configuration.mdx index c0c3bd88ef..ebfc748316 100644 --- a/docs-website/router/configuration.mdx +++ b/docs-website/router/configuration.mdx @@ -2051,6 +2051,7 @@ Configure the GraphQL Execution Engine of the Router. | ENGINE_SUBSCRIPTION_FETCH_TIMEOUT | subscription_fetch_timeout | | The maximum time a subscription fetch can take before it is considered timed out. | 30s | | ENGINE_ENABLE_DEFER | enable_defer | | Enables support for the `@defer` directive, allowing clients to defer parts of a query so that the initial response is returned faster and deferred fields are streamed incrementally. | false | | ENGINE_RELAX_SUBGRAPH_OPERATION_FIELD_SELECTION_MERGING_NULLABILITY | relax_subgraph_operation_field_selection_merging_nullability | | Relaxes nullability validation for [field selection merging](/router/relaxed-field-selection-merging-nullability) across union member types. | false | +| ENGINE_ALLOW_STRING_LITERALS_FOR_ENUMS | allow_string_literals_for_enums | | Accepts a [string literal where an enum value is expected](/router/string-literals-for-enums) if the string content matches one of the enum's values. This is a deviation from the GraphQL specification. | false | **Subject to change.** `enable_net_poll`, `websocket_server_poll_timeout`, and `websocket_server_conn_buffer_size` may be removed or changed in a future release without a standard deprecation cycle. Avoid depending on them. @@ -2098,6 +2099,7 @@ engine: enable_subgraph_fetch_operation_name: true subscription_fetch_timeout: 30s relax_subgraph_operation_field_selection_merging_nullability: false + allow_string_literals_for_enums: false ``` ### Debug Configuration diff --git a/docs-website/router/string-literals-for-enums.mdx b/docs-website/router/string-literals-for-enums.mdx new file mode 100644 index 0000000000..8c11c12597 --- /dev/null +++ b/docs-website/router/string-literals-for-enums.mdx @@ -0,0 +1,71 @@ +--- +title: "String Literals for Enums" +icon: "quotes" +description: "Accept string literals for enum values as an opt-in deviation from the GraphQL specification." +--- + +The GraphQL specification distinguishes between enum literals and string literals in an operation document. An enum argument accepts the enum literal `VALUE1`, but not the string literal `"VALUE1"`. By default, the router rejects a string literal used for an enum type with a validation error: + +``` +Enum "SomeEnum" cannot represent non-enum value: "VALUE1". +``` + +The `allow_string_literals_for_enums` option accepts such string literals when the string content matches one of the enum's values. Strings that do not match a value are still rejected: + +``` +Value "NOPE" does not exist in "SomeEnum" enum. +``` + + + This is a deviation from the GraphQL specification. Servers built on graphql-js reject string literals for enums. Prefer fixing clients to send enum literals. Use this option to keep existing clients working while they migrate. + + +## Examples + +Given the schema: + +```graphql +enum SomeEnum { + VALUE1 + VALUE2 +} + +type Query { + field(arg: SomeEnum): String +} +``` + +The following queries behave as listed: + +```graphql +{ field(arg: VALUE1) } # Valid. Enum literal. +{ field(arg: "VALUE1") } # Invalid by default. Valid with this option enabled. +{ field(arg: "NOPE") } # Invalid. "NOPE" is not a value of SomeEnum. +``` + +Variables are not affected by this option. JSON has no enum type, so a string is the standard representation for an enum variable value and is always accepted when it matches an enum value: + +```graphql +query ($arg: SomeEnum) { field(arg: $arg) } +# variables: {"arg": "VALUE1"} is valid with or without this option +``` + +## Configuration + + + + ```bash + ENGINE_ALLOW_STRING_LITERALS_FOR_ENUMS=true + ``` + + + ```yaml config.yaml + version: "1" + + engine: + allow_string_literals_for_enums: true + ``` + + + +This option is disabled by default. See the [Router Configuration reference](/router/configuration#router-engine-configuration) for all engine flags. diff --git a/router-tests/operations/inline_literal_validation_test.go b/router-tests/operations/inline_literal_validation_test.go index ac10abf0c2..18aa631fb4 100644 --- a/router-tests/operations/inline_literal_validation_test.go +++ b/router-tests/operations/inline_literal_validation_test.go @@ -204,6 +204,76 @@ func TestInlineLiteralValidationWithPersistedOperations(t *testing.T) { }) } +// TestAllowStringLiteralsForEnums verifies the opt-in spec deviation +// (engine execution config allow_string_literals_for_enums) that accepts a string +// literal where an enum value is expected, as long as the string content matches +// one of the enum's values. Without the flag, the router enforces the strict +// GraphQL spec behavior and rejects such literals at validation. +func TestAllowStringLiteralsForEnums(t *testing.T) { + t.Parallel() + + t.Run("disabled by default", func(t *testing.T) { + testenv.Run(t, &testenv.Config{}, func(t *testing.T, xEnv *testenv.Environment) { + t.Run("rejects string literal for enum argument", func(t *testing.T) { + res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{ + Query: `query { rootFieldWithListOfEnumArg(arg: ["A"]) }`, + }) + requireValidationError(t, res.Body, `Enum "EnumType" cannot represent non-enum value: "A".`) + }) + + t.Run("rejects string literal for enum field of input object", func(t *testing.T) { + res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{ + Query: `query { rootFieldWithInput(arg: {enum: "A"}) }`, + }) + requireValidationError(t, res.Body, `Enum "EnumType" cannot represent non-enum value: "A".`) + }) + }) + }) + + t.Run("enabled", func(t *testing.T) { + testenv.Run(t, &testenv.Config{ + ModifyEngineExecutionConfiguration: func(cfg *config.EngineExecutionConfiguration) { + cfg.AllowStringLiteralsForEnums = true + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + t.Run("accepts string literal matching an enum value for enum argument", func(t *testing.T) { + res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{ + Query: `query { rootFieldWithListOfEnumArg(arg: ["A"]) }`, + }) + assert.Equal(t, `{"data":{"rootFieldWithListOfEnumArg":["A"]}}`, res.Body) + }) + + t.Run("accepts string literal matching an enum value for enum field of input object", func(t *testing.T) { + res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{ + Query: `query { rootFieldWithInput(arg: {enum: "A"}) }`, + }) + assert.Equal(t, `{"data":{"rootFieldWithInput":"A"}}`, res.Body) + }) + + t.Run("rejects string literal not matching an enum value", func(t *testing.T) { + res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{ + Query: `query { rootFieldWithListOfEnumArg(arg: ["NOPE"]) }`, + }) + requireValidationError(t, res.Body, `Value "NOPE" does not exist in "EnumType" enum.`) + }) + + t.Run("still accepts inline enum literal for enum argument", func(t *testing.T) { + res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{ + Query: `query { rootFieldWithListOfEnumArg(arg: [A]) }`, + }) + assert.Equal(t, `{"data":{"rootFieldWithListOfEnumArg":["A"]}}`, res.Body) + }) + + t.Run("still rejects string literal for a non-enum argument mismatch", func(t *testing.T) { + res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{ + Query: `query { floatField(arg: "1.5") }`, + }) + requireValidationError(t, res.Body, `Float cannot represent non numeric value: "1.5"`) + }) + }) + }) +} + // requireValidationError asserts the GraphQL response body carries exactly one // error with the given message and no data payload. // diff --git a/router/core/graph_server.go b/router/core/graph_server.go index 8c5182ce44..d5231277a3 100644 --- a/router/core/graph_server.go +++ b/router/core/graph_server.go @@ -1599,10 +1599,11 @@ func (s *graphServer) buildGraphMux( ApolloRouterCompatibilityFlags: s.apolloRouterCompatibilityFlags, DisableExposingVariablesContentOnValidationError: s.engineExecutionConfiguration.DisableExposingVariablesContentOnValidationError, RelaxSubgraphOperationFieldSelectionMergingNullability: s.engineExecutionConfiguration.RelaxSubgraphOperationFieldSelectionMergingNullability, - EnableDefer: s.engineExecutionConfiguration.EnableDefer, - ComplexityLimits: s.securityConfiguration.ComplexityLimits, - CostControl: s.securityConfiguration.CostControl, - ValidateInlineArguments: s.engineExecutionConfiguration.ValidateInlineArguments, + AllowStringLiteralsForEnums: s.engineExecutionConfiguration.AllowStringLiteralsForEnums, + EnableDefer: s.engineExecutionConfiguration.EnableDefer, + ComplexityLimits: s.securityConfiguration.ComplexityLimits, + CostControl: s.securityConfiguration.CostControl, + ValidateInlineArguments: s.engineExecutionConfiguration.ValidateInlineArguments, }) if opts.ReloadPersistentState.inMemoryPlanCacheFallback.IsEnabled() { diff --git a/router/core/operation_processor.go b/router/core/operation_processor.go index 656abdd66d..6bbad944bc 100644 --- a/router/core/operation_processor.go +++ b/router/core/operation_processor.go @@ -128,6 +128,7 @@ type OperationProcessorOptions struct { ApolloRouterCompatibilityFlags config.ApolloRouterCompatibilityFlags DisableExposingVariablesContentOnValidationError bool RelaxSubgraphOperationFieldSelectionMergingNullability bool + AllowStringLiteralsForEnums bool ComplexityLimits *config.ComplexityLimits CostControl *config.CostControl ParserTokenizerLimits astparser.TokenizerLimits @@ -1563,6 +1564,7 @@ type parseKitOptions struct { apolloRouterCompatibilityFlags config.ApolloRouterCompatibilityFlags disableExposingVariablesContentOnValidationError bool relaxSubgraphOperationFieldSelectionMergingNullability bool + allowStringLiteralsForEnums bool enableDefer bool validateInlineArguments config.ValidateInlineArguments } @@ -1638,6 +1640,9 @@ func createOperationValidator(options *parseKitOptions) *astvalidation.Operation if options.relaxSubgraphOperationFieldSelectionMergingNullability { opts = append(opts, astvalidation.WithRelaxFieldSelectionMergingNullability()) } + if options.allowStringLiteralsForEnums { + opts = append(opts, astvalidation.WithAllowStringLiteralsForEnums()) + } return astvalidation.DefaultOperationValidator(opts...) } @@ -1662,6 +1667,7 @@ func NewOperationProcessor(opts OperationProcessorOptions) *OperationProcessor { apolloRouterCompatibilityFlags: opts.ApolloRouterCompatibilityFlags, disableExposingVariablesContentOnValidationError: opts.DisableExposingVariablesContentOnValidationError, relaxSubgraphOperationFieldSelectionMergingNullability: opts.RelaxSubgraphOperationFieldSelectionMergingNullability, + allowStringLiteralsForEnums: opts.AllowStringLiteralsForEnums, validateInlineArguments: opts.ValidateInlineArguments, }, } diff --git a/router/pkg/config/config.go b/router/pkg/config/config.go index d06034474b..1d12fff4c8 100644 --- a/router/pkg/config/config.go +++ b/router/pkg/config/config.go @@ -517,6 +517,8 @@ type EngineExecutionConfiguration struct { RelaxSubgraphOperationFieldSelectionMergingNullability bool `envDefault:"false" env:"ENGINE_RELAX_SUBGRAPH_OPERATION_FIELD_SELECTION_MERGING_NULLABILITY" yaml:"relax_subgraph_operation_field_selection_merging_nullability"` + AllowStringLiteralsForEnums bool `envDefault:"false" env:"ENGINE_ALLOW_STRING_LITERALS_FOR_ENUMS" yaml:"allow_string_literals_for_enums"` + ValidateInlineArguments ValidateInlineArguments `yaml:"validate_inline_arguments" envPrefix:"ENGINE_VALIDATE_INLINE_ARGUMENTS_"` } diff --git a/router/pkg/config/config.schema.json b/router/pkg/config/config.schema.json index bad726c88e..7a9ca9ab96 100644 --- a/router/pkg/config/config.schema.json +++ b/router/pkg/config/config.schema.json @@ -4093,6 +4093,11 @@ "type": "boolean", "default": false, "description": "Relaxes nullability validation for field selection merging when enclosing types are non-overlapping concrete object types. When enabled, fields with differing nullability (e.g. String! vs String) in inline fragments on different union member types will not cause validation errors." + }, + "allow_string_literals_for_enums": { + "type": "boolean", + "default": false, + "description": "Enables a deliberate deviation from the GraphQL specification that accepts a string literal where an enum value is expected, as long as the string content matches one of the enum's values (e.g. f(arg: \"VALUE1\") for enum SomeEnum { VALUE1 }). When disabled, only enum literals are valid inline values for enum types." } } }, diff --git a/router/pkg/config/testdata/config_defaults.json b/router/pkg/config/testdata/config_defaults.json index e46a613899..4c19bfa1e5 100644 --- a/router/pkg/config/testdata/config_defaults.json +++ b/router/pkg/config/testdata/config_defaults.json @@ -517,6 +517,7 @@ "WebSocketClientAckTimeout": 30000000000, "ValidateRequiredExternalFields": false, "RelaxSubgraphOperationFieldSelectionMergingNullability": false, + "AllowStringLiteralsForEnums": false, "ValidateInlineArguments": { "Mode": "off", "EnforceHTTPStatusCode": 400, diff --git a/router/pkg/config/testdata/config_full.json b/router/pkg/config/testdata/config_full.json index 2661afcea2..f10f01d2ec 100644 --- a/router/pkg/config/testdata/config_full.json +++ b/router/pkg/config/testdata/config_full.json @@ -985,6 +985,7 @@ "WebSocketClientAckTimeout": 30000000000, "ValidateRequiredExternalFields": false, "RelaxSubgraphOperationFieldSelectionMergingNullability": false, + "AllowStringLiteralsForEnums": false, "ValidateInlineArguments": { "Mode": "off", "EnforceHTTPStatusCode": 400,