-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: graphql recursion limit extension (#32)
* feat: graphql recursion limit extension * typo * fix: recursion counting, other minor PR fixes
- Loading branch information
Showing
7 changed files
with
321 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,46 @@ | ||
This package includes an extension that can be used as `"github.com/99designs/gqlgen/graphql".HandlerExtension`, | ||
e.g. in `"github.com/99designs/gqlgen/graphql/handler".Server.Use`. | ||
|
||
The extension `RecursionLimitByTypeAndField` limits the number of times the same field of a type can be accessed | ||
in a request (query/mutation). | ||
|
||
Usage: | ||
```go | ||
gqlServer := handler.New() | ||
gqlServer.Use(RecursionLimitByTypeAndField(1)) | ||
``` | ||
|
||
This allow only one of each "type.field" field access in a query. For following examples, | ||
consider that both root `user` and `User.friends` returns a type `User` (although friends may return a list). | ||
|
||
Allows: | ||
```graphql | ||
query { | ||
user { | ||
id | ||
friends { | ||
id | ||
} | ||
} | ||
} | ||
``` | ||
|
||
Forbids: | ||
```graphql | ||
query { | ||
user { | ||
friends { | ||
friends { | ||
id | ||
} | ||
} | ||
} | ||
} | ||
``` | ||
|
||
`User.friends` is accessed twice here. Once in `user.friends`, and second time on `friends.friends`. | ||
|
||
|
||
The intention of this extension is to replace `extension.FixedComplexityLimit`, as that is very difficult to configure | ||
properly. With `RecursionLimitByTypeAndField`, the client can query the whole graph in one query, but at least | ||
the query does have an upper bound of its size. If needed, both extensions can be used at the same time. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,77 @@ | ||
package extension | ||
|
||
import ( | ||
"context" | ||
|
||
"github.com/99designs/gqlgen/graphql" | ||
"github.com/vektah/gqlparser/v2/ast" | ||
"github.com/vektah/gqlparser/v2/gqlerror" | ||
) | ||
|
||
type RecursionLimit struct { | ||
maxRecursion int | ||
} | ||
|
||
func RecursionLimitByTypeAndField(limit int) *RecursionLimit { | ||
return &RecursionLimit{ | ||
maxRecursion: limit, | ||
} | ||
} | ||
|
||
var _ interface { | ||
graphql.OperationContextMutator | ||
graphql.HandlerExtension | ||
} = &RecursionLimit{} | ||
|
||
func (r *RecursionLimit) ExtensionName() string { | ||
return "RecursionLimit" | ||
} | ||
|
||
func (r *RecursionLimit) Validate(_ graphql.ExecutableSchema) error { | ||
return nil | ||
} | ||
|
||
func (r *RecursionLimit) MutateOperationContext(_ context.Context, opCtx *graphql.OperationContext) *gqlerror.Error { | ||
return checkRecursionLimitByTypeAndField(recursionContext{ | ||
maxRecursion: r.maxRecursion, | ||
opCtx: opCtx, | ||
typeAndFieldCount: map[nestingByTypeAndField]int{}, | ||
}, string(opCtx.Operation.Operation), opCtx.Operation.SelectionSet) | ||
} | ||
|
||
type nestingByTypeAndField struct { | ||
parentTypeName string | ||
childFieldName string | ||
} | ||
|
||
type recursionContext struct { | ||
maxRecursion int | ||
opCtx *graphql.OperationContext | ||
typeAndFieldCount map[nestingByTypeAndField]int | ||
} | ||
|
||
func checkRecursionLimitByTypeAndField(rCtx recursionContext, typeName string, selectionSet ast.SelectionSet) *gqlerror.Error { | ||
if selectionSet == nil { | ||
return nil | ||
} | ||
|
||
collected := graphql.CollectFields(rCtx.opCtx, selectionSet, nil) | ||
for _, collectedField := range collected { | ||
nesting := nestingByTypeAndField{ | ||
parentTypeName: typeName, | ||
childFieldName: collectedField.Name, | ||
} | ||
newCount := rCtx.typeAndFieldCount[nesting] + 1 | ||
if newCount > rCtx.maxRecursion { | ||
return gqlerror.Errorf("too many nesting on %s.%s", nesting.parentTypeName, nesting.childFieldName) | ||
} | ||
rCtx.typeAndFieldCount[nesting] = newCount | ||
err := checkRecursionLimitByTypeAndField(rCtx, collectedField.Definition.Type.Name(), collectedField.SelectionSet) | ||
if err != nil { | ||
return err | ||
} | ||
rCtx.typeAndFieldCount[nesting] -= 1 | ||
} | ||
|
||
return nil | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,91 @@ | ||
package extension | ||
|
||
import ( | ||
"context" | ||
_ "embed" | ||
"testing" | ||
|
||
"github.com/99designs/gqlgen/graphql" | ||
"github.com/99designs/gqlgen/graphql/executor" | ||
"github.com/stretchr/testify/assert" | ||
"github.com/vektah/gqlparser/v2" | ||
"github.com/vektah/gqlparser/v2/ast" | ||
"github.com/vektah/gqlparser/v2/gqlerror" | ||
) | ||
|
||
var ( | ||
//go:embed test/schema.graphqls | ||
schema string | ||
//go:embed test/queries.graphql | ||
queries string | ||
) | ||
|
||
func TestRecursionLimitByTypeAndField(t *testing.T) { | ||
tests := []struct { | ||
operationName string | ||
expectedErr gqlerror.List | ||
}{ | ||
{ | ||
operationName: "Allowed", | ||
expectedErr: nil, | ||
}, | ||
{ | ||
operationName: "RecursionExceeded", | ||
expectedErr: gqlerror.List{{ | ||
Message: "too many nesting on User.friends", | ||
}}, | ||
}, | ||
{ | ||
operationName: "InterleavedTypesAllowed", | ||
expectedErr: nil, | ||
}, | ||
{ | ||
operationName: "InterleavedTypesRecursionExceeded", | ||
expectedErr: gqlerror.List{{ | ||
Message: "too many nesting on User.items", | ||
}}, | ||
}, | ||
|
||
{ | ||
operationName: "DifferentSubtreeAllowed", | ||
expectedErr: nil, | ||
}, | ||
} | ||
|
||
for _, tt := range tests { | ||
t.Run(tt.operationName, func(t *testing.T) { | ||
exec := executor.New(executableSchema{}) | ||
exec.Use(RecursionLimitByTypeAndField(1)) | ||
ctx := context.Background() | ||
ctx = graphql.StartOperationTrace(ctx) | ||
_, err := exec.CreateOperationContext(ctx, &graphql.RawParams{ | ||
Query: queries, | ||
OperationName: tt.operationName, | ||
}) | ||
assert.Equal(t, tt.expectedErr, err) | ||
}) | ||
} | ||
} | ||
|
||
var sources = []*ast.Source{ | ||
{Name: "schema.graphqls", Input: schema, BuiltIn: false}, | ||
} | ||
var parsedSchema = gqlparser.MustLoadSchema(sources...) | ||
|
||
var _ graphql.ExecutableSchema = executableSchema{} | ||
|
||
type executableSchema struct{} | ||
|
||
func (e executableSchema) Schema() *ast.Schema { | ||
return parsedSchema | ||
} | ||
|
||
func (e executableSchema) Complexity(_, _ string, _ int, _ map[string]interface{}) (int, bool) { | ||
return 0, false | ||
} | ||
|
||
func (e executableSchema) Exec(_ context.Context) graphql.ResponseHandler { | ||
return func(ctx context.Context) *graphql.Response { | ||
return &graphql.Response{} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,71 @@ | ||
query Allowed { | ||
user { | ||
id | ||
friends { | ||
id | ||
} | ||
} | ||
} | ||
|
||
query RecursionExceeded { | ||
user { | ||
id | ||
friends { | ||
id | ||
friends { | ||
id | ||
} | ||
} | ||
} | ||
} | ||
|
||
query InterleavedTypesAllowed { | ||
user { | ||
id | ||
items { | ||
id | ||
owners { | ||
id | ||
} | ||
} | ||
} | ||
} | ||
|
||
query InterleavedTypesRecursionExceeded { | ||
user { | ||
id | ||
items { | ||
id | ||
owners { | ||
id | ||
items { | ||
id | ||
} | ||
} | ||
} | ||
} | ||
} | ||
|
||
query DifferentSubtreeAllowed { | ||
user { | ||
id | ||
friends { | ||
id | ||
items { | ||
id | ||
owners { | ||
id | ||
} | ||
} | ||
} | ||
items { | ||
id | ||
owners { | ||
id | ||
friends { | ||
id | ||
} | ||
} | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
type User { | ||
id: String! | ||
friends: [User!]! | ||
items: [Item!]! | ||
} | ||
|
||
type Item { | ||
id: String! | ||
owners: [User!]! | ||
} | ||
|
||
type Query { | ||
user: User! | ||
} |