diff --git a/v2/pkg/astnormalization/astnormalization.go b/v2/pkg/astnormalization/astnormalization.go index b096c2bdfc..4c816a1c84 100644 --- a/v2/pkg/astnormalization/astnormalization.go +++ b/v2/pkg/astnormalization/astnormalization.go @@ -109,6 +109,7 @@ type OperationNormalizer struct { removeOperationDefinitionsVisitor *removeOperationDefinitionsVisitor inlineDeferVisitor *deferExpandIntoInternalVisitor + inlineArgumentsVisitor *inlineArgumentsVisitor options options definitionNormalizer *DefinitionNormalizer @@ -155,6 +156,7 @@ type options struct { ignoreSkipInclude bool enableDefer bool prevalidationRules []func(walker *astvisitor.Walker) + inlineArgumentsValidation *InlineArgumentsValidationOptions } type Option func(options *options) @@ -213,6 +215,17 @@ func WithPrevalidationRules(rules ...func(walker *astvisitor.Walker)) Option { } } +// WithInlineArgumentsValidation enables detection of arguments whose values are +// supplied inline (as literals) instead of as variables. Findings are returned +// from NormalizeNamedOperationWithResult as a NormalizationResult. When opts.Enforce +// is set, normalization aborts on the first inline argument and surfaces the error +// via the report instead of collecting findings. +func WithInlineArgumentsValidation(opts InlineArgumentsValidationOptions) Option { + return func(options *options) { + options.inlineArgumentsValidation = &opts + } +} + func (o *OperationNormalizer) setupOperationWalkers() { o.operationWalkers = make([]walkerStage, 0, 9) @@ -240,6 +253,10 @@ func (o *OperationNormalizer) setupOperationWalkers() { } } + if o.options.inlineArgumentsValidation != nil { + o.inlineArgumentsVisitor = registerInlineArgumentsValidation(&directivesIncludeSkip, *o.options.inlineArgumentsValidation) + } + cleanup := astvisitor.NewWalkerWithID(8, "Cleanup") deduplicateFields(&cleanup) if o.options.enableDefer { @@ -384,12 +401,25 @@ func (o *OperationNormalizer) NormalizeOperation(operation, definition *ast.Docu } } -// NormalizeNamedOperation applies all registered rules to one specific named operation in the AST func (o *OperationNormalizer) NormalizeNamedOperation(operation, definition *ast.Document, operationName []byte, report *operationreport.Report) { + o.NormalizeNamedOperationWithResult(operation, definition, operationName, report, RunOptions{}) +} + +func (o *OperationNormalizer) NormalizeNamedOperationWithResult( + operation, definition *ast.Document, + operationName []byte, + report *operationreport.Report, + runOpts RunOptions, +) *NormalizationResult { + if o.inlineArgumentsVisitor != nil { + o.inlineArgumentsVisitor.disabled = runOpts.SkipInlineArguments + o.inlineArgumentsVisitor.result.InlineArguments = o.inlineArgumentsVisitor.result.InlineArguments[:0] + } + if o.options.normalizeDefinition { o.prepareDefinition(definition, report) if report.HasErrors() { - return + return nil } } @@ -403,7 +433,7 @@ func (o *OperationNormalizer) NormalizeNamedOperation(operation, definition *ast } o.operationWalkers[i].walker.Walk(operation, definition, report) if report.HasErrors() { - return + return nil } // NOTE: debug code - do not remove @@ -412,6 +442,11 @@ func (o *OperationNormalizer) NormalizeNamedOperation(operation, definition *ast // fmt.Println(printed) // fmt.Println("variables:", string(operation.Input.Variables)) } + + if o.inlineArgumentsVisitor != nil { + return &o.inlineArgumentsVisitor.result + } + return nil } type VariablesNormalizer struct { diff --git a/v2/pkg/astnormalization/inline_arguments.go b/v2/pkg/astnormalization/inline_arguments.go new file mode 100644 index 0000000000..6f738dbb09 --- /dev/null +++ b/v2/pkg/astnormalization/inline_arguments.go @@ -0,0 +1,116 @@ +package astnormalization + +import ( + "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" + "github.com/wundergraph/graphql-go-tools/v2/pkg/astvisitor" + "github.com/wundergraph/graphql-go-tools/v2/pkg/lexer/position" + "github.com/wundergraph/graphql-go-tools/v2/pkg/operationreport" +) + +// InlineArgument describes a single argument in an operation whose value was +// supplied inline (a literal) instead of as a variable. +type InlineArgument struct { + ArgumentName string + AncestorName string + AncestorKind ast.NodeKind + Path string + ValueKind ast.ValueKind + Position position.Position +} + +func (a InlineArgument) QualifiedName() string { + switch a.AncestorKind { + case ast.NodeKindField: + return a.Path + "#" + a.ArgumentName + case ast.NodeKindDirective: + return a.Path + "@" + a.AncestorName + "#" + a.ArgumentName + default: + return a.ArgumentName + } +} + +type InlineArgumentsValidationOptions struct { + Enforce bool + ErrorMessage string + ErrorCode string + StatusCode int +} + +// NormalizationResult carries per-run outputs of normalization beyond report errors. +type NormalizationResult struct { + InlineArguments []InlineArgument +} + +// RunOptions are per-call inputs to a normalization run. +type RunOptions struct { + SkipInlineArguments bool +} + +func registerInlineArgumentsValidation(walker *astvisitor.Walker, opts InlineArgumentsValidationOptions) *inlineArgumentsVisitor { + visitor := &inlineArgumentsVisitor{ + Walker: walker, + opts: opts, + } + walker.RegisterEnterDocumentVisitor(visitor) + walker.RegisterEnterArgumentVisitor(visitor) + return visitor +} + +type inlineArgumentsVisitor struct { + *astvisitor.Walker + + operation, definition *ast.Document + opts InlineArgumentsValidationOptions + + // disabled is set per run (see RunOptions.SkipInlineArguments) to exempt this + // operation from detection/enforcement. + disabled bool + // result accumulates the findings for the current run. Reset by the normalizer + // at the start of each run. + result NormalizationResult +} + +func (v *inlineArgumentsVisitor) EnterDocument(operation, definition *ast.Document) { + v.operation = operation + v.definition = definition +} + +func (v *inlineArgumentsVisitor) EnterArgument(ref int) { + if v.disabled { + return + } + valueKind := v.operation.Arguments[ref].Value.Kind + if valueKind == ast.ValueKindVariable { + return + } + + if v.opts.Enforce { + // Reject on the first inline argument and stop the walk. A single generic + // error is enough to signal that the operation is non-compliant; we don't + // name the argument or point at its location. + v.StopWithExternalErr(operationreport.ExternalError{ + Message: v.opts.ErrorMessage, + ExtensionCode: v.opts.ErrorCode, + StatusCode: v.opts.StatusCode, + }) + return + } + + finding := InlineArgument{ + ArgumentName: v.operation.ArgumentNameString(ref), + ValueKind: valueKind, + Position: v.operation.Arguments[ref].Position, + Path: v.Path.DotDelimitedString(), + } + + parent := v.Ancestor() + finding.AncestorKind = parent.Kind + switch parent.Kind { + case ast.NodeKindField: + finding.AncestorName = v.operation.FieldNameString(parent.Ref) + case ast.NodeKindDirective: + finding.AncestorName = v.operation.DirectiveNameString(parent.Ref) + } + + v.result.InlineArguments = append(v.result.InlineArguments, finding) +} diff --git a/v2/pkg/astnormalization/inline_arguments_test.go b/v2/pkg/astnormalization/inline_arguments_test.go new file mode 100644 index 0000000000..81a8e25c67 --- /dev/null +++ b/v2/pkg/astnormalization/inline_arguments_test.go @@ -0,0 +1,284 @@ +package astnormalization + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" + "github.com/wundergraph/graphql-go-tools/v2/pkg/asttransform" + "github.com/wundergraph/graphql-go-tools/v2/pkg/internal/unsafeparser" + "github.com/wundergraph/graphql-go-tools/v2/pkg/operationreport" +) + +const inlineArgumentsTestSchema = ` + schema { query: Query } + type Query { + userById(userId: ID!): User + user: User + field(order: Sort, flag: Boolean, by: [Int], obj: Filter): String + } + type User { + loginName: String + posts(first: Int): String + } + enum Sort { ASC DESC } + input Filter { a: Int } +` + +func runInlineArgumentsRule(t *testing.T, operation string, opts InlineArgumentsValidationOptions) (*NormalizationResult, *operationreport.Report) { + t.Helper() + return runInlineArgumentsRuleWithRunOpts(t, operation, opts, RunOptions{}) +} + +func runInlineArgumentsRuleWithRunOpts(t *testing.T, operation string, opts InlineArgumentsValidationOptions, runOpts RunOptions) (*NormalizationResult, *operationreport.Report) { + t.Helper() + + definitionDocument := unsafeparser.ParseGraphqlDocumentString(inlineArgumentsTestSchema) + require.NoError(t, asttransform.MergeDefinitionWithBaseSchema(&definitionDocument)) + + operationDocument := unsafeparser.ParseGraphqlDocumentString(operation) + report := &operationreport.Report{} + + normalizer := NewWithOpts(WithInlineArgumentsValidation(opts)) + result := normalizer.NormalizeNamedOperationWithResult(&operationDocument, &definitionDocument, nil, report, runOpts) + + return result, report +} + +func TestInlineArgumentsRule_Detection(t *testing.T) { + tests := []struct { + name string + operation string + expected []InlineArgument + }{ + { + name: "inline string field argument", + operation: `query GetUserById { userById(userId: "12345") { loginName } }`, + expected: []InlineArgument{ + {ArgumentName: "userId", AncestorName: "userById", AncestorKind: ast.NodeKindField, ValueKind: ast.ValueKindString}, + }, + }, + { + name: "variable field argument is compliant", + operation: `query GetUserById($userId: ID!) { userById(userId: $userId) { loginName } }`, + expected: nil, + }, + { + name: "inline enum argument", + operation: `query { field(order: ASC) }`, + expected: []InlineArgument{ + {ArgumentName: "order", AncestorName: "field", AncestorKind: ast.NodeKindField, ValueKind: ast.ValueKindEnum}, + }, + }, + { + name: "inline null argument", + operation: `query { field(flag: null) }`, + expected: []InlineArgument{ + {ArgumentName: "flag", AncestorName: "field", AncestorKind: ast.NodeKindField, ValueKind: ast.ValueKindNull}, + }, + }, + { + name: "inline list argument recorded once", + operation: `query { field(by: [1, 2, 3]) }`, + expected: []InlineArgument{ + {ArgumentName: "by", AncestorName: "field", AncestorKind: ast.NodeKindField, ValueKind: ast.ValueKindList}, + }, + }, + { + name: "inline object argument recorded once", + operation: `query { field(obj: { a: 1 }) }`, + expected: []InlineArgument{ + {ArgumentName: "obj", AncestorName: "field", AncestorKind: ast.NodeKindField, ValueKind: ast.ValueKindObject}, + }, + }, + { + name: "mixed variable and literal flags only the literal", + operation: `query q($flag: Boolean) { field(flag: $flag, order: DESC) }`, + expected: []InlineArgument{ + {ArgumentName: "order", AncestorName: "field", AncestorKind: ast.NodeKindField, ValueKind: ast.ValueKindEnum}, + }, + }, + { + name: "inline directive argument (@include)", + operation: `query q($userId: ID!) { userById(userId: $userId) @include(if: true) { loginName } }`, + expected: []InlineArgument{ + {ArgumentName: "if", AncestorName: "include", AncestorKind: ast.NodeKindDirective, ValueKind: ast.ValueKindBoolean}, + }, + }, + { + name: "variable directive argument is compliant", + operation: `query q($userId: ID!, $show: Boolean!) { userById(userId: $userId) @include(if: $show) { loginName } }`, + expected: nil, + }, + { + name: "introspection field argument", + operation: `query { __type(name: "User") { name } }`, + expected: []InlineArgument{ + {ArgumentName: "name", AncestorName: "__type", AncestorKind: ast.NodeKindField, ValueKind: ast.ValueKindString}, + }, + }, + { + // Proves detection runs before @skip/@include prunes the node: both the + // directive's own `if` and the child field's `first` are reported even + // though normalization would delete the `user` selection. + name: "argument under a @skip(if:true)-removed node still flagged", + operation: `query q($userId: ID!) { user @skip(if: true) { posts(first: 10) } userById(userId: $userId) { loginName } }`, + expected: []InlineArgument{ + {ArgumentName: "if", AncestorName: "skip", AncestorKind: ast.NodeKindDirective, ValueKind: ast.ValueKindBoolean}, + {ArgumentName: "first", AncestorName: "posts", AncestorKind: ast.NodeKindField, ValueKind: ast.ValueKindInteger}, + }, + }, + { + name: "no inline arguments", + operation: `query q($userId: ID!) { userById(userId: $userId) { loginName } }`, + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, report := runInlineArgumentsRule(t, tt.operation, InlineArgumentsValidationOptions{Enforce: false}) + require.False(t, report.HasErrors(), "log-only mode must never error: %s", report.Error()) + require.NotNil(t, result) + + if len(tt.expected) == 0 { + assert.Empty(t, result.InlineArguments) + return + } + + require.Len(t, result.InlineArguments, len(tt.expected)) + got := make([]InlineArgument, len(result.InlineArguments)) + for i, f := range result.InlineArguments { + f.Position = tt.expected[i].Position // ignore position in this comparison + f.Path = tt.expected[i].Path // path is asserted via QualifiedName instead + got[i] = f + } + assert.Equal(t, tt.expected, got) + }) + } +} + +func TestInlineArgumentsRule_QualifiedName(t *testing.T) { + tests := []struct { + name string + operation string + expected []string + }{ + { + name: "top-level field argument", + operation: `query { userById(userId: "12345") { loginName } }`, + expected: []string{"query.userById#userId"}, + }, + { + name: "nested field argument carries the full query path", + operation: `query { userById(userId: "12345") { posts(first: 10) } }`, + expected: []string{"query.userById#userId", "query.userById.posts#first"}, + }, + { + name: "field alias is used in the path", + operation: `query { u: userById(userId: "12345") { p: posts(first: 10) } }`, + expected: []string{"query.u#userId", "query.u.p#first"}, + }, + { + name: "directive argument names the enclosing field and directive", + operation: `query { userById(userId: "x") @include(if: true) { loginName } }`, + expected: []string{"query.userById#userId", "query.userById@include#if"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, report := runInlineArgumentsRule(t, tt.operation, InlineArgumentsValidationOptions{Enforce: false}) + require.False(t, report.HasErrors(), "log-only mode must never error: %s", report.Error()) + require.NotNil(t, result) + + got := make([]string, len(result.InlineArguments)) + for i, f := range result.InlineArguments { + got[i] = f.QualifiedName() + } + assert.Equal(t, tt.expected, got) + }) + } +} + +func TestInlineArgumentsRule_Position(t *testing.T) { + // The reported position points at the argument in the operation as parsed. + // `userId` starts at column 30 on line 1 of the operation below. + operation := `query GetUserById { userById(userId: "12345") { loginName } }` + result, report := runInlineArgumentsRule(t, operation, InlineArgumentsValidationOptions{ + Enforce: false, + }) + require.False(t, report.HasErrors()) + require.NotNil(t, result) + require.Len(t, result.InlineArguments, 1) + + pos := result.InlineArguments[0].Position + assert.Equal(t, uint32(1), pos.LineStart) + assert.Equal(t, uint32(30), pos.CharStart) +} + +func TestInlineArgumentsRule_Enforce(t *testing.T) { + t.Run("stops at the first inline argument and reports a typed error", func(t *testing.T) { + result, report := runInlineArgumentsRule(t, + `query { userById(userId: "12345") { loginName } field(order: ASC) }`, + InlineArgumentsValidationOptions{ + Enforce: true, + ErrorMessage: "Inline argument values are not allowed. Use variables instead.", + ErrorCode: "INLINE_ARGUMENT_VALUES_NOT_ALLOWED", + StatusCode: 400, + }, + ) + + require.True(t, report.HasErrors()) + require.Len(t, report.ExternalErrors, 1) + extErr := report.ExternalErrors[0] + assert.Equal(t, "Inline argument values are not allowed. Use variables instead.", extErr.Message) + assert.Equal(t, "INLINE_ARGUMENT_VALUES_NOT_ALLOWED", extErr.ExtensionCode) + assert.Equal(t, 400, extErr.StatusCode) + + // Enforce rejects on the first inline argument and stops the walk, so + // normalization fails and no result is returned. + assert.Nil(t, result) + + // The rejection is a generic error: no per-argument location is attached. + assert.Empty(t, extErr.Locations) + }) + + t.Run("compliant operation passes enforce mode", func(t *testing.T) { + result, report := runInlineArgumentsRule(t, + `query q($userId: ID!) { userById(userId: $userId) { loginName } }`, + InlineArgumentsValidationOptions{Enforce: true, ErrorMessage: "nope", ErrorCode: "CODE", StatusCode: 400}, + ) + require.False(t, report.HasErrors(), "compliant operation must not error: %s", report.Error()) + require.NotNil(t, result) + assert.Empty(t, result.InlineArguments) + }) + + t.Run("SkipInlineArguments records nothing and does not enforce", func(t *testing.T) { + result, report := runInlineArgumentsRuleWithRunOpts(t, + `query { userById(userId: "12345") { loginName } }`, + InlineArgumentsValidationOptions{Enforce: true, ErrorMessage: "x", ErrorCode: "C", StatusCode: 400}, + RunOptions{SkipInlineArguments: true}, + ) + + require.False(t, report.HasErrors()) + require.NotNil(t, result) + assert.Empty(t, result.InlineArguments) + }) +} + +func TestInlineArgumentsRule_OptionOffReturnsNil(t *testing.T) { + definitionDocument := unsafeparser.ParseGraphqlDocumentString(inlineArgumentsTestSchema) + require.NoError(t, asttransform.MergeDefinitionWithBaseSchema(&definitionDocument)) + operationDocument := unsafeparser.ParseGraphqlDocumentString(`query { userById(userId: "12345") { loginName } }`) + report := &operationreport.Report{} + + // No WithInlineArgumentsValidation option: there is no result to produce. + normalizer := NewWithOpts() + result := normalizer.NormalizeNamedOperationWithResult(&operationDocument, &definitionDocument, nil, report, RunOptions{}) + + require.False(t, report.HasErrors()) + assert.Nil(t, result) +} diff --git a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource.go b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource.go index 8c3bb3411d..d8229f3262 100644 --- a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource.go +++ b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource.go @@ -366,11 +366,6 @@ func (p *Planner[T]) ConfigureFetch() resolve.FetchConfiguration { return resolve.FetchConfiguration{} } - if p.rpcTransport == nil { - p.stopWithError(errors.WithStack(errors.New("grpc / connect configuration requires an rpc transport"))) - return resolve.FetchConfiguration{} - } - dataSource, err = grpcdatasource.NewDataSource(p.rpcTransport, grpcdatasource.DataSourceConfig{ Operation: &opDocument, Definition: p.config.schemaConfiguration.upstreamSchemaAst, diff --git a/v2/pkg/engine/datasource/grpc_datasource/grpc_datasource.go b/v2/pkg/engine/datasource/grpc_datasource/grpc_datasource.go index 276e612092..4139904f84 100644 --- a/v2/pkg/engine/datasource/grpc_datasource/grpc_datasource.go +++ b/v2/pkg/engine/datasource/grpc_datasource/grpc_datasource.go @@ -113,7 +113,12 @@ func (d *DataSource) Load(ctx context.Context, headers http.Header, input []byte builder := newJSONBuilder(item.Arena, d.mapping, variables) if d.disabled { - return builder.writeErrorBytes(fmt.Errorf("gRPC datasource needs to be enabled to be used")), nil + return builder.writeErrorBytes(fmt.Errorf("gRPC / connect datasource needs to be enabled to be used")), nil + } + + // If the transport is nil we will return the following error message instead + if d.transport == nil { + return nil, fmt.Errorf("gRPC / connect configuration requires an rpc transport") } // convert headers to grpc metadata and attach to ctx diff --git a/v2/pkg/engine/datasource/grpc_datasource/grpc_datasource_test.go b/v2/pkg/engine/datasource/grpc_datasource/grpc_datasource_test.go index 61c2b3d139..9d2f4e6e4a 100644 --- a/v2/pkg/engine/datasource/grpc_datasource/grpc_datasource_test.go +++ b/v2/pkg/engine/datasource/grpc_datasource/grpc_datasource_test.go @@ -20,6 +20,8 @@ import ( protoref "google.golang.org/protobuf/reflect/protoreflect" "google.golang.org/protobuf/types/dynamicpb" + "github.com/wundergraph/go-arena" + "github.com/wundergraph/graphql-go-tools/v2/pkg/astparser" "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" "github.com/wundergraph/graphql-go-tools/v2/pkg/grpctest" @@ -222,6 +224,14 @@ func Test_DataSource_Load(t *testing.T) { require.NoError(t, err) } +func Test_DataSource_Load_NilTransport(t *testing.T) { + ds := &DataSource{pool: arena.NewArenaPool(), disabled: false} + + out, err := ds.Load(context.Background(), nil, []byte(`{}`)) + require.EqualError(t, err, "gRPC / connect configuration requires an rpc transport") + require.Nil(t, out) +} + // Test_DataSource_Load_WithMockService tests the datasource.Load method with an actual gRPC server // TODO update this test to not use mappings anc expect no response func Test_DataSource_Load_WithMockService(t *testing.T) { diff --git a/v2/pkg/engine/resolve/const.go b/v2/pkg/engine/resolve/const.go index 03c1720dd8..df02daf286 100644 --- a/v2/pkg/engine/resolve/const.go +++ b/v2/pkg/engine/resolve/const.go @@ -30,6 +30,9 @@ var ( literalQueryPlan = []byte("queryPlan") literalValueCompletion = []byte("valueCompletion") literalRateLimit = []byte("rateLimit") + literalInlineArguments = []byte("inlineArguments") + literalCount = []byte("count") + literalArguments = []byte("arguments") literalAuthorization = []byte("authorization") literalIncremental = []byte("incremental") literalHasNext = []byte("hasNext") diff --git a/v2/pkg/engine/resolve/context.go b/v2/pkg/engine/resolve/context.go index 5e61644ca0..55adaa90e8 100644 --- a/v2/pkg/engine/resolve/context.go +++ b/v2/pkg/engine/resolve/context.go @@ -39,6 +39,8 @@ type Context struct { Extensions []byte LoaderHooks LoaderHooks + InlineArguments []string + authorizer Authorizer // preFetchFieldAuthorizer, when non-nil, enables pre-fetch field authorization: fields protected by // an authorization rule are authorized in a single batch call before any subgraph fetch executes @@ -314,6 +316,7 @@ func (c *Context) clone(ctx context.Context) *Context { cpy.Files = append([]*httpclient.FileUpload(nil), c.Files...) cpy.Request.Header = c.Request.Header.Clone() cpy.RenameTypeNames = append([]RenameTypeName(nil), c.RenameTypeNames...) + cpy.InlineArguments = append([]string(nil), c.InlineArguments...) if c.RemapVariables != nil { cpy.RemapVariables = make(map[string]string, len(c.RemapVariables)) @@ -337,6 +340,7 @@ func (c *Context) Free() { c.RemapVariables = nil c.TracingOptions.DisableAll() c.Extensions = nil + c.InlineArguments = nil c.subgraphErrors = nil c.authorizer = nil c.preFetchFieldAuthorizer = nil diff --git a/v2/pkg/engine/resolve/extensions_test.go b/v2/pkg/engine/resolve/extensions_test.go index 66f7e2f21e..1efd9172b1 100644 --- a/v2/pkg/engine/resolve/extensions_test.go +++ b/v2/pkg/engine/resolve/extensions_test.go @@ -141,4 +141,41 @@ func TestExtensions(t *testing.T) { `{"errors":[{"message":"Unauthorized request to Subgraph 'users' at Path 'query', Reason: test.","extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}},{"message":"Failed to fetch from Subgraph 'reviews' at Path 'query.me'.","extensions":{"errors":[{"message":"Failed to render Fetch Input","path":["me"]}]}},{"message":"Failed to fetch from Subgraph 'products' at Path 'query.me.reviews.@.product'.","extensions":{"errors":[{"message":"Failed to render Fetch Input","path":["me","reviews","@","product"]}]}}],"data":{"me":null},"extensions":{"authorization":{"missingScopes":[["read:users"]]},"rateLimit":{"Policy":"policy","Allowed":0,"Used":0},"trace":{"version":"1","info":{"trace_start_time":"","trace_start_unix":0,"parse_stats":{"duration_nanoseconds":0,"duration_pretty":"","duration_since_start_nanoseconds":0,"duration_since_start_pretty":""},"normalize_stats":{"duration_nanoseconds":0,"duration_pretty":"","duration_since_start_nanoseconds":0,"duration_since_start_pretty":""},"validate_stats":{"duration_nanoseconds":0,"duration_pretty":"","duration_since_start_nanoseconds":0,"duration_since_start_pretty":""},"planner_stats":{"duration_nanoseconds":0,"duration_pretty":"","duration_since_start_nanoseconds":0,"duration_since_start_pretty":""}},"fetches":{"kind":"Sequence","children":[{"kind":"Single","fetch":{"kind":"Single","path":"query","source_id":"users","source_name":"users","trace":{"raw_input_data":{},"single_flight_used":false,"single_flight_shared_response":false,"load_skipped":false}}},{"kind":"Single","fetch":{"kind":"Single","path":"query.me","source_id":"reviews","source_name":"reviews","trace":{"raw_input_data":null,"single_flight_used":false,"single_flight_shared_response":false,"load_skipped":false}}},{"kind":"Single","fetch":{"kind":"Single","path":"query.me.reviews.@.product","source_id":"products","source_name":"products","trace":{"raw_input_data":null,"single_flight_used":false,"single_flight_shared_response":false,"load_skipped":false}}}]}}}}`, func(t *testing.T) {} })) + t.Run("inline arguments", testFnWithPostEvaluation(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx *Context, expectedOutput string, postEvaluation func(t *testing.T)) { + + res := generateTestFederationGraphQLResponse(t, ctrl) + + resolveCtx := NewContext(context.Background()) + resolveCtx.InlineArguments = []string{"user.filter", "@include.if"} + return res, resolveCtx, + `{"data":{"me":{"id":"1234","username":"Me","reviews":[{"body":"A highly effective form of birth control.","product":{"upc":"top-1","name":"Trilby"}},{"body":"Fedoras are one of the most fashionable hats around and can look great with a variety of outfits.","product":{"upc":"top-2","name":"Fedora"}}]}},"extensions":{"inlineArguments":{"count":2,"arguments":["user.filter","@include.if"]}}}`, + func(t *testing.T) {} + })) + t.Run("rate limit deny & inline arguments", testFnWithPostEvaluation(func(t *testing.T, ctrl *gomock.Controller) (node *GraphQLResponse, ctx *Context, expectedOutput string, postEvaluation func(t *testing.T)) { + + authorizer := createTestAuthorizer(func(ctx *Context, dataSourceID string, input json.RawMessage, coordinate GraphCoordinate) (result *AuthorizationDeny, err error) { + return nil, nil + }, func(ctx *Context, dataSourceID string, object json.RawMessage, coordinate GraphCoordinate) (result *AuthorizationDeny, err error) { + return nil, nil + }) + + limiter := &testRateLimiter{ + policy: "policy", + allowed: 0, + allowFn: func(ctx *Context, info *FetchInfo, input json.RawMessage) (*RateLimitDeny, error) { + return &RateLimitDeny{Reason: "rate limit exceeded"}, nil + }, + } + + res := generateTestFederationGraphQLResponse(t, ctrl) + + resolveCtx := NewContext(context.Background()) + resolveCtx.authorizer = authorizer + resolveCtx.rateLimiter = limiter + resolveCtx.RateLimitOptions = RateLimitOptions{Enable: true, IncludeStatsInResponseExtension: true} + resolveCtx.InlineArguments = []string{"user.filter", "@include.if"} + return res, resolveCtx, + `{"errors":[{"message":"Rate limit exceeded for Subgraph 'users' at Path 'query', Reason: rate limit exceeded."},{"message":"Failed to fetch from Subgraph 'reviews' at Path 'query.me'.","extensions":{"errors":[{"message":"Failed to render Fetch Input","path":["me"]}]}},{"message":"Failed to fetch from Subgraph 'products' at Path 'query.me.reviews.@.product'.","extensions":{"errors":[{"message":"Failed to render Fetch Input","path":["me","reviews","@","product"]}]}}],"data":{"me":null},"extensions":{"rateLimit":{"Policy":"policy","Allowed":0,"Used":1},"inlineArguments":{"count":2,"arguments":["user.filter","@include.if"]}}}`, + func(t *testing.T) {} + })) } diff --git a/v2/pkg/engine/resolve/resolvable.go b/v2/pkg/engine/resolve/resolvable.go index 40c00aeea6..7f4d5a1a75 100644 --- a/v2/pkg/engine/resolve/resolvable.go +++ b/v2/pkg/engine/resolve/resolvable.go @@ -911,6 +911,14 @@ func (r *Resolvable) printExtensions(ctx context.Context, fetchTree *FetchTreeNo } } + if len(r.ctx.InlineArguments) > 0 { + if writeComma { + r.printBytes(comma) + } + writeComma = true + r.printInlineArgumentsExtension() + } + if r.ctx.TracingOptions.Enable && r.ctx.TracingOptions.IncludeTraceOutputInResponseExtensions { if writeComma { r.printBytes(comma) @@ -1021,6 +1029,36 @@ func getDefaultReservedExtensions() map[string]struct{} { } } +func (r *Resolvable) printInlineArgumentsExtension() { + r.printBytes(quote) + r.printBytes(literalInlineArguments) + r.printBytes(quote) + r.printBytes(colon) + r.printBytes(lBrace) + + r.printBytes(quote) + r.printBytes(literalCount) + r.printBytes(quote) + r.printBytes(colon) + r.printBytes(strconv.AppendInt(nil, int64(len(r.ctx.InlineArguments)), 10)) + r.printBytes(comma) + + r.printBytes(quote) + r.printBytes(literalArguments) + r.printBytes(quote) + r.printBytes(colon) + r.printBytes(lBrack) + for i, name := range r.ctx.InlineArguments { + if i > 0 { + r.printBytes(comma) + } + r.printBytes(strconv.AppendQuote(nil, name)) + } + r.printBytes(rBrack) + + r.printBytes(rBrace) +} + func (r *Resolvable) hasExtensions() bool { // Apply the filter first to avoid missing extensions or applying empty extensions. if r.filterAllowedSubgraphExtensions(getDefaultReservedExtensions()) { @@ -1038,6 +1076,9 @@ func (r *Resolvable) hasExtensions() bool { if r.ctx.ExecutionOptions.IncludeQueryPlanInResponse { return true } + if len(r.ctx.InlineArguments) > 0 { + return true + } if !r.skipValueCompletion && r.valueCompletion != nil { return true }