Repository: jensneuse/graphql-go-tools
Pull request #210 (2020-05-11 07:46:16)

field restrictions sync


Patch:
diff --git a/pkg/graphql/extractor.go b/pkg/graphql/extractor.go
new file mode 100644
index 00000000..ff2ad20d
--- /dev/null
+++ b/pkg/graphql/extractor.go
@@ -0,0 +1,66 @@
+package graphql
+
+import (
+	"github.com/jensneuse/graphql-go-tools/pkg/ast"
+	"github.com/jensneuse/graphql-go-tools/pkg/astvisitor"
+	"github.com/jensneuse/graphql-go-tools/pkg/operationreport"
+)
+
+type Extractor struct {
+	walker  *astvisitor.Walker
+	visitor *requestVisitor
+}
+
+func NewExtractor() *Extractor {
+	walker := astvisitor.NewWalker(48)
+	visitor := requestVisitor{
+		Walker: &walker,
+	}
+
+	walker.RegisterEnterFieldVisitor(&visitor)
+
+	return &Extractor{
+		walker:  &walker,
+		visitor: &visitor,
+	}
+}
+
+func (e *Extractor) ExtractFieldsFromRequest(request *Request, schema *Schema, report *operationreport.Report, data RequestTypes) {
+	if !request.IsNormalized() {
+		result, err := request.Normalize(schema)
+		if err != nil {
+			report.AddInternalError(err)
+		}
+
+		if !result.Successful {
+			report.AddInternalError(result.Errors)
+		}
+	}
+
+	e.visitor.data = data
+	e.visitor.operation = &request.document
+	e.visitor.definition = &schema.document
+	e.walker.Walk(&request.document, &schema.document, report)
+}
+
+type requestVisitor struct {
+	*astvisitor.Walker
+	operation, definition *ast.Document
+	data                  RequestTypes
+}
+
+func (p *requestVisitor) EnterField(ref int) {
+	fieldName := p.operation.FieldNameString(ref)
+	parentTypeName := p.definition.NodeNameString(p.EnclosingTypeDefinition)
+
+	t, ok := p.data[parentTypeName]
+	if !ok {
+		t = make(RequestFields)
+	}
+
+	if _, ok := t[fieldName]; !ok {
+		t[fieldName] = struct{}{}
+	}
+
+	p.data[parentTypeName] = t
+}
diff --git a/pkg/graphql/request.go b/pkg/graphql/request.go
index 0a163379..91a266fd 100644
--- a/pkg/graphql/request.go
+++ b/pkg/graphql/request.go
@@ -16,6 +16,7 @@ import (
 var (
 	ErrEmptyRequest = errors.New("the provided request is empty")
 	ErrNilSchema    = errors.New("the provided schema is nil")
+	ErrEmptySchema  = errors.New("the provided schema is empty")
 )
 
 type Request struct {
@@ -102,6 +103,20 @@ func (r *Request) IsNormalized() bool {
 	return r.isNormalized
 }
 
+func (r *Request) ValidateRestrictedFields(schema *Schema, restrictedFields []Type) (RequestFieldsValidationResult, error) {
+	if schema == nil {
+		return RequestFieldsValidationResult{Valid: false}, ErrNilSchema
+	}
+
+	report := r.parseQueryOnce()
+	if report.HasErrors() {
+		return fieldsValidationResult(report, false, "", "")
+	}
+
+	var fieldsValidator RequestFieldsValidator = fieldsValidator{}
+	return fieldsValidator.Validate(r, schema, restrictedFields)
+}
+
 func (r *Request) parseQueryOnce() (report operationreport.Report) {
 	if r.isParsed {
 		return report
diff --git a/pkg/graphql/request_fields_validator.go b/pkg/graphql/request_fields_validator.go
new file mode 100644
index 00000000..2e87e16f
--- /dev/null
+++ b/pkg/graphql/request_fields_validator.go
@@ -0,0 +1,72 @@
+package graphql
+
+import (
+	"fmt"
+
+	"github.com/jensneuse/graphql-go-tools/pkg/operationreport"
+)
+
+type RequestFieldsValidator interface {
+	Validate(request *Request, schema *Schema, restrictions []Type) (RequestFieldsValidationResult, error)
+}
+
+type fieldsValidator struct {
+}
+
+func (d fieldsValidator) Validate(request *Request, schema *Schema, restrictions []Type) (RequestFieldsValidationResult, error) {
+	report := operationreport.Report{}
+	if len(restrictions) == 0 {
+		return fieldsValidationResult(report, true, "", "")
+	}
+
+	requestedTypes := make(RequestTypes)
+	NewExtractor().ExtractFieldsFromRequest(request, schema, &report, requestedTypes)
+
+	for _, restrictedType := range restrictions {
+		requestedFields, hasRestrictedType := requestedTypes[restrictedType.Name]
+		if !hasRestrictedType {
+			continue
+		}
+		for _, field := range restrictedType.Fields {
+			if _, hasRestrictedField := requestedFields[field]; hasRestrictedField {
+				return fieldsValidationResult(report, false, restrictedType.Name, field)
+			}
+		}
+	}
+
+	return fieldsValidationResult(report, true, "", "")
+}
+
+type RequestFieldsValidationResult struct {
+	Valid  bool
+	Errors Errors
+}
+
+func fieldsValidationResult(report operationreport.Report, valid bool, typeName, fieldName string) (RequestFieldsValidationResult, error) {
+	result := RequestFieldsValidationResult{
+		Valid:  valid,
+		Errors: nil,
+	}
+
+	var errors OperationValidationErrors
+	if !result.Valid {
+		errors = append(errors, OperationValidationError{
+			Message: fmt.Sprintf("field: %s is restricted on type: %s", fieldName, typeName),
+		})
+	}
+	result.Errors = errors
+
+	if !report.HasErrors() {
+		return result, nil
+	}
+
+	errors = append(errors, operationValidationErrorsFromOperationReport(report)...)
+	result.Errors = errors
+
+	var err error
+	if len(report.InternalErrors) > 0 {
+		err = report.InternalErrors[0]
+	}
+
+	return result, err
+}
diff --git a/pkg/graphql/types.go b/pkg/graphql/types.go
new file mode 100644
index 00000000..f9844bf9
--- /dev/null
+++ b/pkg/graphql/types.go
@@ -0,0 +1,11 @@
+package graphql
+
+type (
+	Type struct {
+		Name   string   `json:"name"`
+		Fields []string `json:"fields"`
+	}
+
+	RequestFields map[string]struct{}
+	RequestTypes  map[string]RequestFields
+)


Test patch:
diff --git a/pkg/graphql/extractor_test.go b/pkg/graphql/extractor_test.go
new file mode 100644
index 00000000..caab821c
--- /dev/null
+++ b/pkg/graphql/extractor_test.go
@@ -0,0 +1,91 @@
+package graphql
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/jensneuse/graphql-go-tools/pkg/operationreport"
+)
+
+func TestExtractor_ExtractFieldsFromRequest(t *testing.T) {
+	schema, err := NewSchemaFromString(testDefinition)
+	require.NoError(t, err)
+
+	request := Request{
+		OperationName: "PostsUserQuery",
+		Variables:     nil,
+		Query:         testOperation,
+	}
+
+	fields := make(RequestTypes)
+	report := operationreport.Report{}
+	NewExtractor().ExtractFieldsFromRequest(&request, schema, &report, fields)
+
+	expectedFields := RequestTypes{
+		"Foo":   {"fooField": {}},
+		"Post":  {"description": {}, "id": {}, "user": {}},
+		"Query": {"foo": {}, "posts": {}},
+		"User":  {"id": {}, "name": {}},
+	}
+
+	assert.False(t, report.HasErrors())
+	assert.Equal(t, expectedFields, fields)
+}
+
+const testOperation = `query PostsUserQuery {
+	posts {
+		id
+		description
+		user {
+			id
+			name
+		}
+	}
+}
+fragment FirstFragment on Post {
+	id
+}
+query ArgsQuery {
+	foo(bar: "barValue", baz: true){
+		fooField
+	}
+}
+query VariableQuery($bar: String, $baz: Boolean) {
+	foo(bar: $bar, baz: $baz){
+		fooField
+	}
+}
+query VariableQuery {
+	posts {
+		id @include(if: true)
+	}
+}
+`
+
+const testDefinition = `
+directive @include(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT
+schema {
+	query: Query
+}
+type Query {
+	posts: [Post]
+	foo(bar: String!, baz: Boolean!): Foo
+}
+type User {
+	id: ID
+	name: String
+	posts: [Post]
+}
+type Post {
+	id: ID
+	description: String
+	user: User
+}
+type Foo {
+	fooField: String
+}
+scalar ID
+scalar String
+`
diff --git a/pkg/graphql/request_test.go b/pkg/graphql/request_test.go
index ed02f45a..88768b79 100644
--- a/pkg/graphql/request_test.go
+++ b/pkg/graphql/request_test.go
@@ -83,12 +83,7 @@ func TestRequest_ValidateForSchema(t *testing.T) {
 
 	t.Run("should return valid result for introspection query after normalization", func(t *testing.T) {
 		schema := starwarsSchema(t)
-
-		rawRequest := starwars.LoadQuery(t, starwars.FileIntrospectionQuery, nil)
-
-		var request Request
-		err := UnmarshalRequest(bytes.NewBuffer(rawRequest), &request)
-		require.NoError(t, err)
+		request := requestForQuery(t, starwars.FileIntrospectionQuery)
 
 		normalizationResult, err := request.Normalize(schema)
 		require.NoError(t, err)
@@ -103,12 +98,7 @@ func TestRequest_ValidateForSchema(t *testing.T) {
 
 	t.Run("should return valid result when validation is successful", func(t *testing.T) {
 		schema := starwarsSchema(t)
-
-		rawRequest := starwars.LoadQuery(t, starwars.FileSimpleHeroQuery, nil)
-
-		var request Request
-		err := UnmarshalRequest(bytes.NewBuffer(rawRequest), &request)
-		require.NoError(t, err)
+		request := requestForQuery(t, starwars.FileSimpleHeroQuery)
 
 		result, err := request.ValidateForSchema(schema)
 		assert.NoError(t, err)
@@ -134,13 +124,7 @@ func TestRequest_Normalize(t *testing.T) {
 
 	t.Run("should successfully normalize the request", func(t *testing.T) {
 		schema := starwarsSchema(t)
-
-		rawRequest := starwars.LoadQuery(t, starwars.FileFragmentsQuery, nil)
-
-		var request Request
-		err := UnmarshalRequest(bytes.NewBuffer(rawRequest), &request)
-		require.NoError(t, err)
-
+		request := requestForQuery(t, starwars.FileFragmentsQuery)
 		documentBeforeNormalization := request.document
 
 		result, err := request.Normalize(schema)
@@ -169,12 +153,7 @@ func TestRequest_Print(t *testing.T) {
 
 func TestRequest_CalculateComplexity(t *testing.T) {
 	t.Run("should return error when schema is nil", func(t *testing.T) {
-		request := Request{
-			OperationName: "Hello",
-			Variables:     nil,
-			Query:         `query Hello { hello }`,
-		}
-
+		request := Request{}
 		result, err := request.CalculateComplexity(DefaultComplexityCalculator, nil)
 		assert.Error(t, err)
 		assert.Equal(t, ErrNilSchema, err)
@@ -186,12 +165,7 @@ func TestRequest_CalculateComplexity(t *testing.T) {
 	t.Run("should successfully calculate the complexity of request", func(t *testing.T) {
 		schema := starwarsSchema(t)
 
-		rawRequest := starwars.LoadQuery(t, starwars.FileSimpleHeroQuery, nil)
-
-		var request Request
-		err := UnmarshalRequest(bytes.NewBuffer(rawRequest), &request)
-		require.NoError(t, err)
-
+		request := requestForQuery(t, starwars.FileSimpleHeroQuery)
 		result, err := request.CalculateComplexity(DefaultComplexityCalculator, schema)
 		assert.NoError(t, err)
 		assert.Equal(t, 1, result.NodeCount, "unexpected node count")
@@ -200,6 +174,94 @@ func TestRequest_CalculateComplexity(t *testing.T) {
 	})
 }
 
+func TestRequest_ValidateRestrictedFields(t *testing.T) {
+	t.Run("should return error when schema is nil", func(t *testing.T) {
+		request := Request{}
+		result, err := request.ValidateRestrictedFields(nil, nil)
+		assert.Error(t, err)
+		assert.Equal(t, ErrNilSchema, err)
+		assert.False(t, result.Valid)
+	})
+
+	t.Run("should allow request when no restrictions set", func(t *testing.T) {
+		schema := starwarsSchema(t)
+		request := requestForQuery(t, starwars.FileSimpleHeroQuery)
+
+		result, err := request.ValidateRestrictedFields(schema, nil)
+		assert.NoError(t, err)
+		assert.True(t, result.Valid)
+	})
+
+	t.Run("when restrictions set", func(t *testing.T) {
+		schema := starwarsSchema(t)
+		restrictedFields := []Type{
+			{Name: "Query", Fields: []string{"droid"}},
+			{Name: "Mutation", Fields: []string{"createReview"}},
+			{Name: "Character", Fields: []string{"friends"}},
+			{Name: "Starship", Fields: []string{"length"}},
+		}
+
+		t.Run("should allow request", func(t *testing.T) {
+			t.Run("when only allowed fields requested", func(t *testing.T) {
+				request := requestForQuery(t, starwars.FileSimpleHeroQuery)
+				result, err := request.ValidateRestrictedFields(schema, restrictedFields)
+				assert.NoError(t, err)
+				assert.True(t, result.Valid)
+				assert.Empty(t, result.Errors)
+
+				request = requestForQuery(t, starwars.FileHeroWithAliasesQuery)
+				result, err = request.ValidateRestrictedFields(schema, restrictedFields)
+				assert.NoError(t, err)
+				assert.True(t, result.Valid)
+				assert.Empty(t, result.Errors)
+			})
+		})
+
+		t.Run("should disallow request", func(t *testing.T) {
+			t.Run("when query is restricted", func(t *testing.T) {
+				request := requestForQuery(t, starwars.FileDroidWithArgAndVarQuery)
+				result, err := request.ValidateRestrictedFields(schema, restrictedFields)
+				assert.NoError(t, err)
+				assert.False(t, result.Valid)
+				assert.Error(t, result.Errors)
+
+				var buf bytes.Buffer
+				_, _ = result.Errors.WriteResponse(&buf)
+				assert.Equal(t, `{"errors":[{"message":"field: droid is restricted on type: Query"}]}`, buf.String())
+			})
+
+			t.Run("when mutation is restricted", func(t *testing.T) {
+				request := requestForQuery(t, starwars.FileCreateReviewMutation)
+				result, err := request.ValidateRestrictedFields(schema, restrictedFields)
+				assert.NoError(t, err)
+				assert.False(t, result.Valid)
+				assert.Error(t, result.Errors)
+			})
+
+			t.Run("when type field is restricted", func(t *testing.T) {
+				request := requestForQuery(t, starwars.FileUnionQuery)
+				result, err := request.ValidateRestrictedFields(schema, restrictedFields)
+				assert.NoError(t, err)
+				assert.False(t, result.Valid)
+				assert.Error(t, result.Errors)
+			})
+
+			t.Run("when mutation response type has restricted field", func(t *testing.T) {
+				restrictedFields := []Type{
+					{Name: "Review", Fields: []string{"id"}},
+				}
+
+				request := requestForQuery(t, starwars.FileCreateReviewMutation)
+				result, err := request.ValidateRestrictedFields(schema, restrictedFields)
+				assert.NoError(t, err)
+				assert.False(t, result.Valid)
+				assert.Error(t, result.Errors)
+			})
+		})
+	})
+
+}
+
 func starwarsSchema(t *testing.T) *Schema {
 	starwars.SetRelativePathToStarWarsPackage("../starwars")
 	schemaBytes := starwars.Schema(t)
@@ -209,3 +271,13 @@ func starwarsSchema(t *testing.T) *Schema {
 
 	return schema
 }
+
+func requestForQuery(t *testing.T, fileName string) Request {
+	rawRequest := starwars.LoadQuery(t, fileName, nil)
+
+	var request Request
+	err := UnmarshalRequest(bytes.NewBuffer(rawRequest), &request)
+	require.NoError(t, err)
+
+	return request
+}