Repository: graph-gophers/graphql-go (go)

Issue:
Populating entry points without explicity schema{}
From https://facebook.github.io/graphql/June2018/#sec-Root-Operation-Types : 

> While any type can be the root operation type for a GraphQL operation, the type system definition language can omit the schema definition when the query, mutation, and subscription root types are named Query, Mutation, and Subscription respectively.
> 
> Likewise, when representing a GraphQL schema using the type system definition language, a schema definition should be omitted if it only uses the default root operation type names.
> 
> This example describes a valid complete GraphQL schema, despite not explicitly including a schema definition. The Query type is presumed to be the query root operation type of the schema.

If I understand this correctly, having a type named `type Query` with no `schema {}` should be the same as doing `type Query ... schema { query: Query }` but currently this does not work.

Pull request description:
Allow `schema` to be omitted when using default root op names
Fixes #304 

Patch:
diff --git a/README.md b/README.md
index 123057d..be58951 100644
--- a/README.md
+++ b/README.md
@@ -45,9 +45,6 @@ func (_ *query) Hello() string { return "Hello, world!" }
 
 func main() {
         s := `
-                schema {
-                        query: Query
-                }
                 type Query {
                         hello: String!
                 }
diff --git a/graphql.go b/graphql.go
index b99b504..0a66179 100644
--- a/graphql.go
+++ b/graphql.go
@@ -37,6 +37,9 @@ func ParseSchema(schemaString string, resolver interface{}, opts ...SchemaOpt) (
 	if err := s.schema.Parse(schemaString, s.useStringDescriptions); err != nil {
 		return nil, err
 	}
+	if err := s.validateSchema(); err != nil {
+		return nil, err
+	}
 
 	r, err := resolvable.ApplyResolver(s.schema, resolver)
 	if err != nil {
@@ -183,6 +186,11 @@ func (s *Schema) exec(ctx context.Context, queryString string, operationName str
 	if op.Type == query.Subscription {
 		return &Response{Errors: []*errors.QueryError{&errors.QueryError{ Message: "graphql-ws protocol header is missing" }}}
 	}
+	if op.Type == query.Mutation {
+		if _, ok := s.schema.EntryPoints["mutation"]; !ok {
+			return &Response{Errors: []*errors.QueryError{{ Message: "no mutations are offered by the schema" }}}
+		}
+	}
 
 	// Fill in variables with the defaults from the operation
 	if variables == nil {
@@ -223,6 +231,39 @@ func (s *Schema) exec(ctx context.Context, queryString string, operationName str
 	}
 }
 
+func (s *Schema) validateSchema() error {
+	// https://graphql.github.io/graphql-spec/June2018/#sec-Root-Operation-Types
+	// > The query root operation type must be provided and must be an Object type.
+	if err := validateRootOp(s.schema, "query", true); err != nil {
+		return err
+	}
+	// > The mutation root operation type is optional; if it is not provided, the service does not support mutations.
+	// > If it is provided, it must be an Object type.
+	if err := validateRootOp(s.schema, "mutation", false); err != nil {
+		return err
+	}
+	// > Similarly, the subscription root operation type is also optional; if it is not provided, the service does not
+	// > support subscriptions. If it is provided, it must be an Object type.
+	if err := validateRootOp(s.schema, "subscription", false); err != nil {
+		return err
+	}
+	return nil
+}
+
+func validateRootOp(s *schema.Schema, name string, mandatory bool) error {
+	t, ok := s.EntryPoints[name]
+	if !ok {
+		if mandatory {
+			return fmt.Errorf("root operation %q must be defined", name)
+		}
+		return nil
+	}
+	if t.Kind() != "OBJECT" {
+		return fmt.Errorf("root operation %q must be an OBJECT", name)
+	}
+	return nil
+}
+
 func getOperation(document *query.Document, operationName string) (*query.Operation, error) {
 	if len(document.Operations) == 0 {
 		return nil, fmt.Errorf("no operations in query document")
diff --git a/internal/exec/resolvable/resolvable.go b/internal/exec/resolvable/resolvable.go
index 1b248e6..bb503e3 100644
--- a/internal/exec/resolvable/resolvable.go
+++ b/internal/exec/resolvable/resolvable.go
@@ -362,7 +362,8 @@ func (b *execBuilder) makeFieldExec(typeName string, f *schema.Field, m reflect.
 	var out reflect.Type
 	if methodIndex != -1 {
 		out = m.Type.Out(0)
-		if typeName == "Subscription" && out.Kind() == reflect.Chan {
+		sub, ok := b.schema.EntryPoints["subscription"]
+		if ok && typeName == sub.TypeName() && out.Kind() == reflect.Chan {
 			out = m.Type.Out(0).Elem()
 		}
 	} else {
diff --git a/internal/schema/schema.go b/internal/schema/schema.go
index dd9a16c..cc7ebe4 100644
--- a/internal/schema/schema.go
+++ b/internal/schema/schema.go
@@ -286,6 +286,21 @@ func (s *Schema) Parse(schemaString string, useStringDescriptions bool) error {
 		}
 	}
 
+	// https://graphql.github.io/graphql-spec/June2018/#sec-Root-Operation-Types
+	// > While any type can be the root operation type for a GraphQL operation, the type system definition language can
+	// > omit the schema definition when the query, mutation, and subscription root types are named Query, Mutation,
+	// > and Subscription respectively.
+	if len(s.entryPointNames) == 0 {
+		if _, ok := s.Types["Query"]; ok {
+			s.entryPointNames["query"] = "Query"
+		}
+		if _, ok := s.Types["Mutation"]; ok {
+			s.entryPointNames["mutation"] = "Mutation"
+		}
+		if _, ok := s.Types["Subscription"]; ok {
+			s.entryPointNames["subscription"] = "Subscription"
+		}
+	}
 	s.EntryPoints = make(map[string]NamedType)
 	for key, name := range s.entryPointNames {
 		t, ok := s.Types[name]
diff --git a/subscriptions.go b/subscriptions.go
index 2ee71e5..4199c06 100644
--- a/subscriptions.go
+++ b/subscriptions.go
@@ -24,6 +24,9 @@ func (s *Schema) Subscribe(ctx context.Context, queryString string, operationNam
 	if s.res.Resolver == (reflect.Value{}) {
 		return nil, errors.New("schema created without resolver, can not subscribe")
 	}
+	if _, ok := s.schema.EntryPoints["subscription"]; !ok {
+		return nil, errors.New("no subscriptions are offered by the schema")
+	}
 	return s.subscribe(ctx, queryString, operationName, variables, s.res), nil
 }
 


Test patch:
diff --git a/graphql_test.go b/graphql_test.go
index ba4a3eb..0358b1f 100644
--- a/graphql_test.go
+++ b/graphql_test.go
@@ -313,6 +313,236 @@ func TestHelloSnakeArguments(t *testing.T) {
 	})
 }
 
+func TestRootOperations_invalidSchema(t *testing.T) {
+	type args struct {
+		Schema string
+	}
+	type want struct {
+		Error string
+	}
+	testTable := map[string]struct {
+		Args args
+		Want want
+	}{
+		"Empty schema": {
+			Want: want{Error: `root operation "query" must be defined`},
+		},
+		"Query declared by schema, but type not present": {
+			Args: args{
+				Schema: `
+					schema {
+						query: Query
+					}
+				`,
+			},
+			Want: want{Error: `graphql: type "Query" not found`},
+		},
+		"Query as incorrect type": {
+			Args: args{
+				Schema: `
+					schema {
+						query: String
+					}
+				`,
+			},
+			Want: want{Error: `root operation "query" must be an OBJECT`},
+		},
+		"Query with custom name, schema omitted": {
+			Args: args{
+				Schema: `
+					type QueryType {
+						hello: String!
+					}
+				`,
+			},
+			Want: want{Error: `root operation "query" must be defined`},
+		},
+		"Mutation as incorrect type": {
+			Args: args{
+				Schema: `
+					schema {
+						query: Query
+						mutation: String
+					}
+					type Query {
+						thing: String
+					}
+				`,
+			},
+			Want: want{Error: `root operation "mutation" must be an OBJECT`},
+		},
+		"Mutation declared by schema, but type not present": {
+			Args: args{
+				Schema: `
+					schema {
+						query: Query
+						mutation: Mutation
+					}
+					type Query {
+						hello: String!
+					}
+				`,
+			},
+			Want: want{Error: `graphql: type "Mutation" not found`},
+		},
+	}
+
+	for name, tt := range testTable {
+		tt := tt
+		t.Run(name, func(t *testing.T) {
+			t.Parallel()
+
+			_, err := graphql.ParseSchema(tt.Args.Schema, nil)
+			if err == nil || err.Error() != tt.Want.Error {
+				t.Logf("got:  %v", err)
+				t.Logf("want: %s", tt.Want.Error)
+				t.Fail()
+			}
+		})
+	}
+}
+
+func TestRootOperations_validSchema(t *testing.T) {
+	type resolver struct {
+		helloSaidResolver
+		helloWorldResolver1
+		theNumberResolver
+	}
+	gqltesting.RunTests(t, []*gqltesting.Test{
+		{
+			// Query only, default name with `schema` omitted
+			Schema: graphql.MustParseSchema(`
+				type Query {
+					hello: String!
+				}
+			`, &resolver{}),
+			Query:          `{ hello }`,
+			ExpectedResult: `{"hello": "Hello world!"}`,
+		},
+		{
+			// Query only, default name with `schema` present
+			Schema: graphql.MustParseSchema(`
+				schema {
+					query: Query
+				}
+				type Query {
+					hello: String!
+				}
+			`, &resolver{}),
+			Query:          `{ hello }`,
+			ExpectedResult: `{"hello": "Hello world!"}`,
+		},
+		{
+			// Query only, custom name
+			Schema: graphql.MustParseSchema(`
+				schema {
+					query: QueryType
+				}
+				type QueryType {
+					hello: String!
+				}
+			`, &resolver{}),
+			Query:          `{ hello }`,
+			ExpectedResult: `{"hello": "Hello world!"}`,
+		},
+		{
+			// Query+Mutation, default names with `schema` omitted
+			Schema: graphql.MustParseSchema(`
+				type Query {
+					hello: String!
+				}
+				type Mutation {
+					changeTheNumber(newNumber: Int!): ChangedNumber!
+				}
+				type ChangedNumber {
+					theNumber: Int!
+				}
+			`, &resolver{}),
+			Query: `
+				mutation {
+					changeTheNumber(newNumber: 1) {
+						theNumber
+					}
+				}
+			`,
+			ExpectedResult: `{"changeTheNumber": {"theNumber": 1}}`,
+		},
+		{
+			// Query+Mutation, custom names
+			Schema: graphql.MustParseSchema(`
+				schema {
+					query: QueryType
+					mutation: MutationType
+				}
+				type QueryType {
+					hello: String!
+				}
+				type MutationType {
+					changeTheNumber(newNumber: Int!): ChangedNumber!
+				}
+				type ChangedNumber {
+					theNumber: Int!
+				}
+			`, &resolver{}),
+			Query: `
+				mutation {
+					changeTheNumber(newNumber: 1) {
+						theNumber
+					}
+				}
+			`,
+			ExpectedResult: `{"changeTheNumber": {"theNumber": 1}}`,
+		},
+		{
+			// Mutation with custom name, schema omitted
+			Schema: graphql.MustParseSchema(`
+				type Query {
+					hello: String!
+				}
+				type MutationType {
+					changeTheNumber(newNumber: Int!): ChangedNumber!
+				}
+				type ChangedNumber {
+					theNumber: Int!
+				}
+			`, &resolver{}),
+			Query: `
+				mutation {
+					changeTheNumber(newNumber: 1) {
+						theNumber
+					}
+				}
+			`,
+			ExpectedErrors: []*gqlerrors.QueryError{{Message: "no mutations are offered by the schema"}},
+		},
+		{
+			// Explicit schema without mutation field
+			Schema: graphql.MustParseSchema(`
+				schema {
+					query: Query
+				}
+				type Query {
+					hello: String!
+				}
+				type Mutation {
+					changeTheNumber(newNumber: Int!): ChangedNumber!
+				}
+				type ChangedNumber {
+					theNumber: Int!
+				}
+			`, &resolver{}),
+			Query: `
+				mutation {
+					changeTheNumber(newNumber: 1) {
+						theNumber
+					}
+				}
+			`,
+			ExpectedErrors: []*gqlerrors.QueryError{{Message: "no mutations are offered by the schema"}},
+		},
+	})
+}
+
 func TestBasic(t *testing.T) {
 	gqltesting.RunTests(t, []*gqltesting.Test{
 		{
@@ -3204,16 +3434,22 @@ func (r *subscriptionsInExecResolver) AppUpdated() <-chan string {
 }
 
 func TestSubscriptions_In_Exec(t *testing.T) {
+	r := &struct {
+		*helloResolver
+		*subscriptionsInExecResolver
+	}{
+		helloResolver:               &helloResolver{},
+		subscriptionsInExecResolver: &subscriptionsInExecResolver{},
+	}
 	gqltesting.RunTest(t, &gqltesting.Test{
 		Schema: graphql.MustParseSchema(`
-			schema {
-				subscription: Subscription
+			type Query {
+				hello: String!
 			}
-
 			type Subscription {
 				appUpdated : String!
 			}
-	`, &subscriptionsInExecResolver{}),
+		`, r),
 		Query: `
 			subscription {
 				appUpdated
diff --git a/internal/schema/schema_test.go b/internal/schema/schema_test.go
index 9dff8ec..1eaae71 100644
--- a/internal/schema/schema_test.go
+++ b/internal/schema/schema_test.go
@@ -165,6 +165,46 @@ func TestParse(t *testing.T) {
 				return nil
 			},
 		},
+		{
+			name: "Default Root schema",
+			sdl: `
+			type Query {
+				hello: String!
+			}
+			type Mutation {
+				concat(a: String!, b: String!): String!
+			}
+			`,
+			validateSchema: func(s *schema.Schema) error {
+				typq, ok := s.Types["Query"].(*schema.Object)
+				if !ok {
+					return fmt.Errorf("type %q not found", "Query")
+				}
+				helloField := typq.Fields.Get("hello")
+				if helloField == nil {
+					return fmt.Errorf("field %q not found", "hello")
+				}
+				if helloField.Type.String() != "String!" {
+					return fmt.Errorf("field %q has an invalid type: %q", "hello", helloField.Type.String())
+				}
+
+				typm, ok := s.Types["Mutation"].(*schema.Object)
+				if !ok {
+					return fmt.Errorf("type %q not found", "Mutation")
+				}
+				concatField := typm.Fields.Get("concat")
+				if concatField == nil {
+					return fmt.Errorf("field %q not found", "concat")
+				}
+				if concatField.Type.String() != "String!" {
+					return fmt.Errorf("field %q has an invalid type: %q", "concat", concatField.Type.String())
+				}
+				if len(concatField.Args) != 2 || concatField.Args[0] == nil || concatField.Args[1] == nil || concatField.Args[0].Type.String() != "String!" || concatField.Args[1].Type.String() != "String!" {
+					return fmt.Errorf("field %q has an invalid args: %+v", "concat", concatField.Args)
+				}
+				return nil
+			},
+		},
 		{
 			name: "Extend type",
 			sdl: `
diff --git a/subscription_test.go b/subscription_test.go
index 7a7de6e..5b8c56b 100644
--- a/subscription_test.go
+++ b/subscription_test.go
@@ -276,6 +276,146 @@ func TestSchemaSubscribe(t *testing.T) {
 	})
 }
 
+func TestRootOperations_invalidSubscriptionSchema(t *testing.T) {
+	type args struct {
+		Schema string
+	}
+	type want struct {
+		Error string
+	}
+	testTable := map[string]struct {
+		Args args
+		Want want
+	}{
+		"Subscription as incorrect type": {
+			Args: args{
+				Schema: `
+					schema {
+						query: Query
+						subscription: String
+					}
+					type Query {
+						thing: String
+					}
+				`,
+			},
+			Want: want{Error: `root operation "subscription" must be an OBJECT`},
+		},
+		"Subscription declared by schema, but type not present": {
+			Args: args{
+				Schema: `
+					schema {
+						query: Query
+						subscription: Subscription
+					}
+					type Query {
+						hello: String!
+					}
+				`,
+			},
+			Want: want{Error: `graphql: type "Subscription" not found`},
+		},
+	}
+
+	for name, tt := range testTable {
+		tt := tt
+		t.Run(name, func(t *testing.T) {
+			t.Parallel()
+
+			_, err := graphql.ParseSchema(tt.Args.Schema, nil)
+			if err == nil || err.Error() != tt.Want.Error {
+				t.Logf("got:  %v", err)
+				t.Logf("want: %s", tt.Want.Error)
+				t.Fail()
+			}
+		})
+	}
+}
+
+func TestRootOperations_validSubscriptionSchema(t *testing.T) {
+	gqltesting.RunSubscribes(t, []*gqltesting.TestSubscription{
+		{
+			Name: "Default name, schema omitted",
+			Schema: graphql.MustParseSchema(`
+				type Query {
+					hello: String!
+				}
+				type Subscription {
+					helloSaid: HelloSaidEvent!
+				}
+				type HelloSaidEvent {
+					msg: String!
+				}
+			`, &rootResolver{helloSaidResolver: &helloSaidResolver{upstream: closedUpstream(&helloSaidEventResolver{msg: "Hello world!"})}}),
+			Query: `subscription { helloSaid { msg } }`,
+			ExpectedResults: []gqltesting.TestResponse{
+				{
+					Data: json.RawMessage(`{"helloSaid": {"msg": "Hello world!"}}`),
+				},
+			},
+		},
+		{
+			Name: "Custom name, schema omitted",
+			Schema: graphql.MustParseSchema(`
+				type Query {
+					hello: String!
+				}
+				type SubscriptionType {
+					helloSaid: HelloSaidEvent!
+				}
+				type HelloSaidEvent {
+					msg: String!
+				}
+			`, &rootResolver{}),
+			Query:       `subscription { helloSaid { msg } }`,
+			ExpectedErr: errors.New("no subscriptions are offered by the schema"),
+		},
+		{
+			Name: "Custom name, schema required",
+			Schema: graphql.MustParseSchema(`
+					schema {
+						query: Query
+						subscription: SubscriptionType
+					}
+					type Query {
+						hello: String!
+					}
+					type SubscriptionType {
+						helloSaid: HelloSaidEvent!
+					}
+					type HelloSaidEvent {
+						msg: String!
+					}
+			`, &rootResolver{helloSaidResolver: &helloSaidResolver{upstream: closedUpstream(&helloSaidEventResolver{msg: "Hello world!"})}}),
+			Query: `subscription { helloSaid { msg } }`,
+			ExpectedResults: []gqltesting.TestResponse{
+				{
+					Data: json.RawMessage(`{"helloSaid": {"msg": "Hello world!"}}`),
+				},
+			},
+		},
+		{
+			Name: "Explicit schema without subscription field",
+			Schema: graphql.MustParseSchema(`
+					schema {
+						query: Query
+					}
+					type Query {
+						hello: String!
+					}
+					type Subscription {
+						helloSaid: HelloSaidEvent!
+					}
+					type HelloSaidEvent {
+						msg: String!
+					}
+			`, &rootResolver{helloSaidResolver: &helloSaidResolver{upstream: closedUpstream(&helloSaidEventResolver{msg: "Hello world!"})}}),
+			Query:       `subscription { helloSaid { msg } }`,
+			ExpectedErr: errors.New("no subscriptions are offered by the schema"),
+		},
+	})
+}
+
 const schema = `
 	schema {
 		subscription: Subscription,