diff --git a/config.go b/config.go index 27b2c0cf..6f6510ec 100644 --- a/config.go +++ b/config.go @@ -331,9 +331,16 @@ func (c *Config) Init() error { return fmt.Errorf("error building service list: %w", err) } + serviceClientOptions := []ClientOpt{ + WithMaxResponseSize(c.MaxServiceResponseSize), + } + if c.QueryHTTPClient != nil { + serviceClientOptions = append(serviceClientOptions, WithHTTPClient(c.QueryHTTPClient)) + } + var services []*Service for _, s := range c.Services { - services = append(services, NewService(s)) + services = append(services, NewService(s, serviceClientOptions...)) } queryClientOptions := []ClientOpt{WithMaxResponseSize(c.MaxServiceResponseSize), WithUserAgent(GenerateUserAgent("query"))} diff --git a/executable_schema.go b/executable_schema.go index d425fd46..e1283245 100644 --- a/executable_schema.go +++ b/executable_schema.go @@ -56,7 +56,7 @@ func (s *ExecutableSchema) UpdateServiceList(services []string) error { if svc, ok := s.Services[svcURL]; ok { newServices[svcURL] = svc } else { - newServices[svcURL] = NewService(svcURL) + newServices[svcURL] = NewService(svcURL, WithHTTPClient(s.GraphqlClient.HTTPClient)) } } s.Services = newServices diff --git a/introspection.go b/introspection.go index 1b9d65c5..90ff4e42 100644 --- a/introspection.go +++ b/introspection.go @@ -21,10 +21,11 @@ type Service struct { } // NewService returns a new Service. -func NewService(serviceURL string) *Service { +func NewService(serviceURL string, opts ...ClientOpt) *Service { + opts = append(opts, WithUserAgent(GenerateUserAgent("update"))) s := &Service{ ServiceURL: serviceURL, - client: NewClientWithoutKeepAlive(WithUserAgent(GenerateUserAgent("update"))), + client: NewClientWithoutKeepAlive(opts...), } return s } diff --git a/merge.go b/merge.go index 1922453c..985bb7fc 100644 --- a/merge.go +++ b/merge.go @@ -405,13 +405,21 @@ func hasIDField(t *ast.Definition) bool { } func isNodeField(f *ast.FieldDefinition) bool { - if f.Name != nodeRootFieldName || len(f.Arguments) != 1 { + if (f.Name != nodeRootFieldName && f.Name != nodesRootFieldName) || len(f.Arguments) != 1 { return false } arg := f.Arguments[0] - return arg.Name == IdFieldName && + + isNode := (arg.Name == IdFieldName && isIDType(arg.Type) && - isNullableTypeNamed(f.Type, nodeInterfaceName) + isNullableTypeNamed(f.Type, nodeInterfaceName)) + + isNodes := (arg.Name == IdsFieldName && + isIDsType(arg.Type) && + f.Type != nil && + isNullableTypeNamed(f.Type.Elem, nodeInterfaceName)) + + return isNode || isNodes } func isIDField(f *ast.FieldDefinition) bool { diff --git a/schema.go b/schema.go index f399fe81..c23b9743 100644 --- a/schema.go +++ b/schema.go @@ -6,10 +6,14 @@ import ( "github.com/vektah/gqlparser/v2/ast" ) -var IdFieldName = "id" +var ( + IdFieldName = "id" + IdsFieldName = "ids" +) const ( nodeRootFieldName = "node" + nodesRootFieldName = "nodes" nodeInterfaceName = "Node" serviceObjectName = "Service" serviceRootFieldName = "service" @@ -31,6 +35,10 @@ func isIDType(t *ast.Type) bool { return isNonNullableTypeNamed(t, "ID") } +func isIDsType(t *ast.Type) bool { + return t.Elem != nil && isNonNullableTypeNamed(t, "ID") && isNonNullableTypeNamed(t.Elem, "ID") +} + func isNonNullableTypeNamed(t *ast.Type, typename string) bool { return t.Name() == typename && t.NonNull } diff --git a/validate.go b/validate.go index a385437f..0f198573 100644 --- a/validate.go +++ b/validate.go @@ -54,17 +54,21 @@ func validateBoundaryObjects(schema *ast.Schema) error { return err } } - } else { - if err := validateNodeInterface(schema); err != nil { + } + + if hasNodeQuery(schema) { + if err := validateNodeQuery(schema); err != nil { return err } - if err := validateImplementsNode(schema); err != nil { + } + if hasNodesQuery(schema) { + if err := validateNodesQuery(schema); err != nil { return err } } - if hasNodeQuery(schema) { - if err := validateNodeQuery(schema); err != nil { + if hasNodesQuery(schema) { + if err := validateNodesQuery(schema); err != nil { return err } } @@ -108,10 +112,16 @@ func validateServiceObject(schema *ast.Schema) error { switch field.Name { case "name", "version", "schema": if !isNonNullableTypeNamed(field.Type, "String") { - return fmt.Errorf("the Service object should have a field called '%s' of type 'String!'", field.Name) + return fmt.Errorf( + "the Service object should have a field called '%s' of type 'String!'", + field.Name, + ) } default: - return fmt.Errorf("the Service object should not have a field called %s", field.Name) + return fmt.Errorf( + "the Service object should not have a field called %s", + field.Name, + ) } } return nil @@ -138,6 +148,32 @@ func validateServiceQuery(schema *ast.Schema) error { return fmt.Errorf("the Query type is missing the 'service' field") } +func validateNodesQuery(schema *ast.Schema) error { + if schema.Query == nil { + return fmt.Errorf("the schema is missing a Query type") + } + for _, f := range schema.Query.Fields { + if f.Name != nodesRootFieldName { + continue + } + if len(f.Arguments) != 1 { + return fmt.Errorf("the 'nodes' field of Query must take a single argument") + } + arg := f.Arguments[0] + if arg.Name != IdsFieldName { + return fmt.Errorf("the 'nodes' field of Query must take a single argument called 'ids'") + } + if !isIDsType(arg.Type) { + return fmt.Errorf("the 'nodes' field of Query must take a single argument of type 'ID!'") + } + if f.Type == nil || f.Type.Elem == nil || !isNullableTypeNamed(f.Type.Elem, nodeInterfaceName) { + return fmt.Errorf("the 'nodes' field of Query must be of type '[Node]!'") + } + return nil + } + return fmt.Errorf("the Query type is missing the 'nodes' field") +} + func validateNodeQuery(schema *ast.Schema) error { if schema.Query == nil { return fmt.Errorf("the schema is missing a Query type") @@ -198,7 +234,10 @@ func validateImplementsNode(schema *ast.Schema) error { if implementsNode(schema, t) { continue } - return fmt.Errorf("object '%s' has the boundary directive but doesn't implement Node", t.Name) + return fmt.Errorf( + "object '%s' has the boundary directive but doesn't implement Node", + t.Name, + ) } return nil } @@ -212,6 +251,10 @@ func implementsNode(schema *ast.Schema, def *ast.Definition) bool { return false } +func hasNodesQuery(schema *ast.Schema) bool { + return schema.Query.Fields.ForName(nodesRootFieldName) != nil +} + func hasNodeQuery(schema *ast.Schema) bool { return schema.Query.Fields.ForName(nodeRootFieldName) != nil } @@ -247,7 +290,11 @@ func validateNamespaceDirective(schema *ast.Schema) error { return fmt.Errorf("@namespace directive not found") } -func validateNamespacesFields(schema *ast.Schema, currentType *ast.Definition, rootType string) error { +func validateNamespacesFields( + schema *ast.Schema, + currentType *ast.Definition, + rootType string, +) error { if currentType == nil { return nil } @@ -256,7 +303,11 @@ func validateNamespacesFields(schema *ast.Schema, currentType *ast.Definition, r ft := schema.Types[f.Type.Name()] if isNamespaceObject(ft) { if !f.Type.NonNull { - return fmt.Errorf("namespace return type should be non nullable on %s.%s", currentType.Name, f.Name) + return fmt.Errorf( + "namespace return type should be non nullable on %s.%s", + currentType.Name, + f.Name, + ) } err := validateNamespacesFields(schema, ft, rootType) @@ -272,14 +323,20 @@ func validateNamespacesFields(schema *ast.Schema, currentType *ast.Definition, r // validateNamespaceTypesAscendence validates that namespace types are only used in other namespaces type or Query/Mutation/Subscription func validateNamespaceTypesAscendence(schema *ast.Schema) error { for _, t := range schema.Types { - if isNamespaceObject(t) || t.Name == queryObjectName || t.Name == mutationObjectName || t.Name == subscriptionObjectName { + if isNamespaceObject(t) || t.Name == queryObjectName || t.Name == mutationObjectName || + t.Name == subscriptionObjectName { continue } for _, f := range t.Fields { ft := schema.Types[f.Type.Name()] if isNamespaceObject(ft) { - return fmt.Errorf("type %q (namespace type) is used for field %q in non-namespace object %q", ft.Name, f.Name, t.Name) + return fmt.Errorf( + "type %q (namespace type) is used for field %q in non-namespace object %q", + ft.Name, + f.Name, + t.Name, + ) } } } @@ -370,7 +427,10 @@ func validateBoundaryFields(schema *ast.Schema) error { } if len(missingBoundaryQueries) > 0 { - return fmt.Errorf("missing boundary fields for the following types: %v", missingBoundaryQueries) + return fmt.Errorf( + "missing boundary fields for the following types: %v", + missingBoundaryQueries, + ) } return nil @@ -388,7 +448,11 @@ func validateBoundaryObjectsFormat(schema *ast.Schema) error { } if idField.Type.String() != "ID!" { - return fmt.Errorf(`%q field should have type "ID!" in boundary type %q`, IdFieldName, t.Name) + return fmt.Errorf( + `%q field should have type "ID!" in boundary type %q`, + IdFieldName, + t.Name, + ) } } diff --git a/validate_test.go b/validate_test.go index 3e74518c..634aadab 100644 --- a/validate_test.go +++ b/validate_test.go @@ -48,6 +48,7 @@ func TestSchemaIsValid(t *testing.T) { } type Query { node(id: ID!): Node + nodes(ids: [ID!]!): [Node]! service: Service! } `).assertValid(ValidateSchema) @@ -276,6 +277,86 @@ func TestNodeQuery(t *testing.T) { }) } +func TestNodesQuery(t *testing.T) { + t.Run("query type missing", func(t *testing.T) { + withSchema(t, "").assertInvalid("the schema is missing a Query type", validateNodesQuery) + }) + t.Run("nodes query missing", func(t *testing.T) { + withSchema(t, ` + type Query { + other: String + } + `).assertInvalid("the Query type is missing the 'nodes' field", validateNodesQuery) + }) + t.Run("query with no arguments", func(t *testing.T) { + withSchema(t, ` + type Query { + nodes: ID! + } + `).assertInvalid("the 'nodes' field of Query must take a single argument", validateNodesQuery) + }) + t.Run("query with wrong argument name", func(t *testing.T) { + withSchema(t, ` + type Query { + nodes(incorrect: ID!): ID! + } + `).assertInvalid("the 'nodes' field of Query must take a single argument called 'ids'", validateNodesQuery) + }) + t.Run("query with extra argument", func(t *testing.T) { + withSchema(t, ` + type Query { + nodes(ids: [ID!]!, incorrect: String): ID! + } + `).assertInvalid("the 'nodes' field of Query must take a single argument", validateNodesQuery) + }) + + t.Run("query with wrong nullable array type", func(t *testing.T) { + withSchema(t, ` + type Query { + nodes(ids: [ID!]): ID! + } + `).assertInvalid("the 'nodes' field of Query must take a single argument of type 'ID!'", validateNodesQuery) + }) + t.Run("query with wrong nullable array type", func(t *testing.T) { + withSchema(t, ` + type Query { + nodes(ids: [ID]!): ID! + } + `).assertInvalid("the 'nodes' field of Query must take a single argument of type 'ID!'", validateNodesQuery) + }) + t.Run("query with wrong type", func(t *testing.T) { + withSchema(t, ` + type Query { + nodes(ids: [ID!]!): ID! + } + `).assertInvalid("the 'nodes' field of Query must be of type '[Node]!'", validateNodesQuery) + }) + t.Run("query is correct", func(t *testing.T) { + withSchema(t, ` + interface Node { + id: ID! + } + type Query { + nodes(ids: [ID!]!): [Node]! + } + `).assertValid(validateNodesQuery) + }) + t.Run("Query is checked if @boundary is used", func(t *testing.T) { + withSchema(t, ` + directive @boundary on OBJECT + type Query { + nodes(ids: [ID!]!): ID! + node(id: ID!): ID! + } + interface Node { + id: ID! + } + type Gizmo implements Node @boundary { + id: ID! + }`).assertInvalid("the 'nodes' field of Query must be of type '[Node]!'", validateNodesQuery) + }) +} + func TestUnions(t *testing.T) { t.Run("Unions are supported", func(t *testing.T) { withSchema(t, ` @@ -425,7 +506,6 @@ func TestServiceQuery(t *testing.T) { } `).assertInvalid("the Query type is missing the 'service' field", ValidateSchema) }) - } func TestRootObjectNaming(t *testing.T) {