diff --git a/graphql/admin/schema.go b/graphql/admin/schema.go index 5722a3f4aba..cfc0d5c11d1 100644 --- a/graphql/admin/schema.go +++ b/graphql/admin/schema.go @@ -133,10 +133,13 @@ func (asr *updateSchemaResolver) Mutate( asr.newSchema.ID = asr.admin.schema.ID } - _, err = (&edgraph.Server{}).Alter(ctx, &dgoapi.Operation{Schema: asr.newDgraphSchema}) - if err != nil { - return nil, nil, schema.GQLWrapf(err, - "succeeded in saving GraphQL schema but failed to alter Dgraph schema ") + if asr.newDgraphSchema != "" { + // The schema could be empty if it only has custom types/queries/mutations. + _, err = (&edgraph.Server{}).Alter(ctx, &dgoapi.Operation{Schema: asr.newDgraphSchema}) + if err != nil { + return nil, nil, schema.GQLWrapf(err, + "succeeded in saving GraphQL schema but failed to alter Dgraph schema ") + } } asr.admin.resetSchema(asr.newGQLSchema) diff --git a/graphql/resolve/custom_query_test.yaml b/graphql/resolve/custom_query_test.yaml new file mode 100644 index 00000000000..228146c295e --- /dev/null +++ b/graphql/resolve/custom_query_test.yaml @@ -0,0 +1,193 @@ +- + name: "custom GET query returning users" + gqlquery: | + query { + myFavoriteMovies(id: "0x1", name: "Michael") { + id + name + director { + id + name + } + } + } + httpresponse: | + { + "myFavoriteMovies": [ + { + "id": "0x1", + "name": "Star Wars", + "director": [ + { + "id": "0x2", + "name": "George Lucas" + } + ] + }, + { + "id": "0x3", + "name": "Star Trek" + } + ] + } + url: http://myapi.com/favMovies/0x1?name=Michael&num= + method: GET + resolvedresponse: | + { + "myFavoriteMovies": [ + { + "id": "0x1", + "name": "Star Wars", + "director": [ + { + "id": "0x2", + "name": "George Lucas" + } + ] + }, + { + "id": "0x3", + "name": "Star Trek", + "director": [] + } + ] + } + +- + name: "custom GET query returning users one of which becomes null" + gqlquery: | + query { + myFavoriteMovies(id: "0x1", name: "Michael") { + id + name + director { + id + name + } + } + } + httpresponse: | + { + "myFavoriteMovies": [ + { + "id": "0x1", + "director": [ + { + "id": "0x2", + "name": "George Lucas" + } + ] + }, + { + "id": "0x3", + "name": "Star Trek" + } + ] + } + url: http://myapi.com/favMovies/0x1?name=Michael&num= + method: GET + resolvedresponse: | + { + "myFavoriteMovies": [ + null, + { + "id": "0x3", + "name": "Star Trek", + "director": [] + } + ] + } + +- + name: "custom GET query gets URL filled from GraphQL variables" + gqlquery: | + query users($id: ID!) { + myFavoriteMovies(id: $id, name: "Michael Compton", num: 10) { + id + name + director { + id + name + } + } + } + variables: { "id": "0x9" } + httpresponse: | + { + "myFavoriteMovies": [ + { + "id": "0x1", + "director": [ + { + "id": "0x2", + "name": "George Lucas" + } + ] + }, + { + "id": "0x3", + "name": "Star Trek" + } + ] + } + url: http://myapi.com/favMovies/0x9?name=Michael+Compton&num=10 + method: GET + resolvedresponse: | + { + "myFavoriteMovies": [ + null, + { + "id": "0x3", + "name": "Star Trek", + "director": [] + } + ] + } + +- + name: "custom POST query gets body filled from variables" + gqlquery: | + query movies($id: ID!) { + myFavoriteMoviesPart2(id: $id, name: "Michael", num: 10) { + id + name + director { + id + name + } + } + } + variables: { "id": "0x9" } + httpresponse: | + { + "myFavoriteMoviesPart2": [ + { + "id": "0x1", + "director": [ + { + "id": "0x2", + "name": "George Lucas" + } + ] + }, + { + "id": "0x3", + "name": "Star Trek" + } + ] + } + url: http://myapi.com/favMovies/0x9?name=Michael&num=10 + method: POST + body: '{ "id": "0x9", "name": "Michael", "director": { "number": 10 }}' + headers: { "X-App-Token": ["val"], "Auth0-Token": ["tok"] } + resolvedresponse: | + { + "myFavoriteMoviesPart2": [ + null, + { + "id": "0x3", + "name": "Star Trek", + "director": [] + } + ] + } diff --git a/graphql/resolve/query_test.go b/graphql/resolve/query_test.go index 02ced809b2a..5322d438e69 100644 --- a/graphql/resolve/query_test.go +++ b/graphql/resolve/query_test.go @@ -17,13 +17,16 @@ package resolve import ( + "bytes" "context" "io/ioutil" + "net/http" "testing" "github.com/dgraph-io/dgraph/graphql/dgraph" "github.com/dgraph-io/dgraph/graphql/schema" "github.com/dgraph-io/dgraph/graphql/test" + "github.com/dgraph-io/dgraph/testutil" "github.com/stretchr/testify/require" _ "github.com/vektah/gqlparser/v2/validator/rules" // make gql validator init() all rules "gopkg.in/yaml.v2" @@ -67,3 +70,89 @@ func TestQueryRewriting(t *testing.T) { }) } } + +type HTTPRewritingCase struct { + Name string + GQLQuery string + Variables map[string]interface{} + HTTPResponse string + ResolvedResponse string + Method string + URL string + Body string + Headers map[string][]string +} + +// RoundTripFunc . +type RoundTripFunc func(req *http.Request) *http.Response + +// RoundTrip . +func (f RoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req), nil +} + +// NewTestClient returns *http.Client with Transport replaced to avoid making real calls +func NewTestClient(fn RoundTripFunc) *http.Client { + return &http.Client{ + Transport: RoundTripFunc(fn), + } +} + +func newClient(t *testing.T, hrc HTTPRewritingCase) *http.Client { + return NewTestClient(func(req *http.Request) *http.Response { + require.Equal(t, hrc.Method, req.Method) + require.Equal(t, hrc.URL, req.URL.String()) + if hrc.Body != "" { + body, err := ioutil.ReadAll(req.Body) + require.NoError(t, err) + require.JSONEq(t, hrc.Body, string(body)) + } + expectedHeaders := http.Header{} + for h, v := range hrc.Headers { + expectedHeaders.Set(h, v[0]) + } + require.Equal(t, expectedHeaders, req.Header) + + return &http.Response{ + StatusCode: 200, + // Send response to be tested + Body: ioutil.NopCloser(bytes.NewBufferString(hrc.HTTPResponse)), + // Must be set to non-nil value or it panics + Header: make(http.Header), + } + }) +} + +func TestCustomHTTPQuery(t *testing.T) { + b, err := ioutil.ReadFile("custom_query_test.yaml") + require.NoError(t, err, "Unable to read test file") + + var tests []HTTPRewritingCase + err = yaml.Unmarshal(b, &tests) + require.NoError(t, err, "Unable to unmarshal tests to yaml.") + + gqlSchema := test.LoadSchemaFromFile(t, "schema.graphql") + + for _, tcase := range tests { + t.Run(tcase.Name, func(t *testing.T) { + op, err := gqlSchema.Operation( + &schema.Request{ + Query: tcase.GQLQuery, + Variables: tcase.Variables, + Header: map[string][]string{ + "bogus": []string{"header"}, + "X-App-Token": []string{"val"}, + "Auth0-Token": []string{"tok"}, + }, + }) + require.NoError(t, err) + gqlQuery := test.GetQuery(t, op) + + client := newClient(t, tcase) + resolver := NewHTTPResolver(client, nil, nil, StdQueryCompletion()) + resolved := resolver.Resolve(context.Background(), gqlQuery) + res := `{` + string(resolved.Data) + `}` + testutil.CompareJSON(t, tcase.ResolvedResponse, res) + }) + } +} diff --git a/graphql/resolve/resolver.go b/graphql/resolve/resolver.go index 140df3dd382..1a0d3860141 100644 --- a/graphql/resolve/resolver.go +++ b/graphql/resolve/resolver.go @@ -20,8 +20,11 @@ import ( "bytes" "context" "encoding/json" + "io/ioutil" + "net/http" "strings" "sync" + "time" "github.com/dgraph-io/dgraph/edgraph" "github.com/dgraph-io/dgraph/graphql/dgraph" @@ -215,6 +218,15 @@ func (rf *resolverFactory) WithConventionResolvers( }) } + for _, q := range s.Queries(schema.HTTPQuery) { + rf.WithQueryResolver(q, func(q schema.Query) QueryResolver { + return NewHTTPResolver(&http.Client{ + // TODO - This can be part of a config later. + Timeout: time.Minute, + }, nil, nil, StdQueryCompletion()) + }) + } + for _, m := range s.Mutations(schema.AddMutation) { rf.WithMutationResolver(m, func(m schema.Mutation) MutationResolver { return NewMutationResolver( @@ -1077,3 +1089,52 @@ func aliasObject( return result, errs } + +// a httpResolver can resolve a single GraphQL query field from an HTTP endpoint +type httpResolver struct { + *http.Client + httpRewriter QueryRewriter + httpExecutor QueryExecutor + resultCompleter ResultCompleter +} + +// NewHTTPResolver creates a resolver that can resolve GraphQL query/mutation from an HTTP endpoint +func NewHTTPResolver(hc *http.Client, + qr QueryRewriter, + qe QueryExecutor, + rc ResultCompleter) QueryResolver { + return &httpResolver{hc, qr, qe, rc} +} + +func (hr *httpResolver) Resolve(ctx context.Context, query schema.Query) *Resolved { + span := otrace.FromContext(ctx) + stop := x.SpanTimer(span, "resolveHTTPQuery") + defer stop() + + res, err := hr.rewriteAndExecute(ctx, query) + + completed, err := hr.resultCompleter.Complete(ctx, query, res, err) + return &Resolved{Data: completed, Err: err} +} + +func (hr *httpResolver) rewriteAndExecute( + ctx context.Context, query schema.Query) ([]byte, error) { + hrc, err := query.HTTPResolver() + if err != nil { + return nil, err + } + req, err := http.NewRequest(hrc.Method, hrc.URL, bytes.NewBufferString(hrc.Body)) + if err != nil { + return nil, err + } + req.Header = hrc.ForwardHeaders + + resp, err := hr.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + b, err := ioutil.ReadAll(resp.Body) + return b, err +} diff --git a/graphql/resolve/schema.graphql b/graphql/resolve/schema.graphql index 09d633be037..f1764d22d53 100644 --- a/graphql/resolve/schema.graphql +++ b/graphql/resolve/schema.graphql @@ -111,3 +111,17 @@ type ComputerOwner { type User @secret(field: "pwd") { name: String! @id } + +type Query { + myFavoriteMovies(id: ID!, name: String!, num: Int): [Movie] @custom(http: { + url: "http://myapi.com/favMovies/$id?name=$name&num=$num", + method: "GET" + }) + + myFavoriteMoviesPart2(id: ID!, name: String!, num: Int): [Movie] @custom(http: { + url: "http://myapi.com/favMovies/$id?name=$name&num=$num", + method: "POST", + body: "{ id: $id, name: $name, director: { number: $num }}", + forwardHeaders: ["X-App-Token", "Auth0-token"] + }) +} diff --git a/graphql/schema/schemagen_test.yml b/graphql/schema/dgraph_schemagen_test.yml similarity index 95% rename from graphql/schema/schemagen_test.yml rename to graphql/schema/dgraph_schemagen_test.yml index a317d5ac769..df69d964b0a 100644 --- a/graphql/schema/schemagen_test.yml +++ b/graphql/schema/dgraph_schemagen_test.yml @@ -434,3 +434,31 @@ schemas: } A.p: string . A.q: string . + + - + name: "custom types shouldn't be part of Dgraph schema" + input: | + interface C { + c: String + } + + type A implements C @not_dgraph { + id: ID! + p: String + q: String + } + + type B implements C { + id: ID! + p: String + } + output: | + type C { + C.c + } + C.c: string . + type B { + C.c + B.p + } + B.p: string . diff --git a/graphql/schema/gqlschema.go b/graphql/schema/gqlschema.go index 1c43d65c54b..1d45d2cb0fa 100644 --- a/graphql/schema/gqlschema.go +++ b/graphql/schema/gqlschema.go @@ -34,11 +34,13 @@ const ( searchDirective = "search" searchArgs = "by" - dgraphDirective = "dgraph" - dgraphTypeArg = "type" - dgraphPredArg = "pred" - idDirective = "id" - secretDirective = "secret" + dgraphDirective = "dgraph" + dgraphTypeArg = "type" + dgraphPredArg = "pred" + idDirective = "id" + secretDirective = "secret" + customDirective = "custom" + notDgraphDirective = "not_dgraph" // types with this directive are not stored in Dgraph. deprecatedDirective = "deprecated" NumUid = "numUids" @@ -67,11 +69,18 @@ enum DgraphIndex { hour } +input CustomHTTP { + url: String! + method: String! +} + directive @hasInverse(field: String!) on FIELD_DEFINITION directive @search(by: [DgraphIndex!]) on FIELD_DEFINITION directive @dgraph(type: String, pred: String) on OBJECT | INTERFACE | FIELD_DEFINITION directive @id on FIELD_DEFINITION directive @secret(field: String!, pred: String) on OBJECT | INTERFACE +directive @custom(http: CustomHTTP) on FIELD_DEFINITION +directive @not_dgraph on OBJECT input IntFilter { eq: Int @@ -232,11 +241,13 @@ var scalarToDgraph = map[string]string{ } var directiveValidators = map[string]directiveValidator{ - inverseDirective: hasInverseValidation, - searchDirective: searchValidation, - dgraphDirective: dgraphDirectiveValidation, - idDirective: idValidation, - secretDirective: passwordValidation, + inverseDirective: hasInverseValidation, + searchDirective: searchValidation, + dgraphDirective: dgraphDirectiveValidation, + idDirective: idValidation, + secretDirective: passwordValidation, + customDirective: customDirectiveValidation, + notDgraphDirective: notDgraphDirectiveValidation, deprecatedDirective: func( sch *ast.Schema, typ *ast.Definition, @@ -380,11 +391,17 @@ func applyFieldValidations(typ *ast.Definition, field *ast.FieldDefinition) gqle // completeSchema generates all the required types and fields for // query/mutation/update for all the types mentioned the the schema. func completeSchema(sch *ast.Schema, definitions []string) { - - sch.Query = &ast.Definition{ - Kind: ast.Object, - Name: "Query", - Fields: make([]*ast.FieldDefinition, 0), + query := sch.Types["Query"] + if query != nil { + query.Kind = ast.Object + sch.Query = query + delete(sch.Types, "Query") + } else { + sch.Query = &ast.Definition{ + Kind: ast.Object, + Name: "Query", + Fields: make([]*ast.FieldDefinition, 0), + } } sch.Mutation = &ast.Definition{ @@ -394,8 +411,10 @@ func completeSchema(sch *ast.Schema, definitions []string) { } for _, key := range definitions { + if key == "Query" { + continue + } defn := sch.Types[key] - if defn.Kind != ast.Interface && defn.Kind != ast.Object { continue } @@ -1359,6 +1378,10 @@ func Stringify(schema *ast.Schema, originalTypes []string) string { // as the original schema. for _, typName := range originalTypes { typ := schema.Types[typName] + if typName == "Query" { + // This would be printed later in schema.Query + continue + } switch typ.Kind { case ast.Interface: x.Check2(original.WriteString(generateInterfaceString(typ) + "\n")) @@ -1418,21 +1441,33 @@ func Stringify(schema *ast.Schema, originalTypes []string) string { "#######################\n# Extended Definitions\n#######################\n")) x.Check2(sch.WriteString(schemaExtras)) x.Check2(sch.WriteString("\n")) - x.Check2(sch.WriteString( - "#######################\n# Generated Types\n#######################\n\n")) - x.Check2(sch.WriteString(object.String())) - x.Check2(sch.WriteString( - "#######################\n# Generated Enums\n#######################\n\n")) - x.Check2(sch.WriteString(enum.String())) - x.Check2(sch.WriteString( - "#######################\n# Generated Inputs\n#######################\n\n")) - x.Check2(sch.WriteString(input.String())) - x.Check2(sch.WriteString( - "#######################\n# Generated Query\n#######################\n\n")) - x.Check2(sch.WriteString(generateObjectString(schema.Query) + "\n")) - x.Check2(sch.WriteString( - "#######################\n# Generated Mutations\n#######################\n\n")) - x.Check2(sch.WriteString(generateObjectString(schema.Mutation))) + if len(object.String()) > 0 { + x.Check2(sch.WriteString( + "#######################\n# Generated Types\n#######################\n\n")) + x.Check2(sch.WriteString(object.String())) + } + if len(enum.String()) > 0 { + x.Check2(sch.WriteString( + "#######################\n# Generated Enums\n#######################\n\n")) + x.Check2(sch.WriteString(enum.String())) + } + if len(input.String()) > 0 { + x.Check2(sch.WriteString( + "#######################\n# Generated Inputs\n#######################\n\n")) + x.Check2(sch.WriteString(input.String())) + } + + if len(schema.Query.Fields) > 0 { + x.Check2(sch.WriteString( + "#######################\n# Generated Query\n#######################\n\n")) + x.Check2(sch.WriteString(generateObjectString(schema.Query) + "\n")) + } + + if len(schema.Mutation.Fields) > 0 { + x.Check2(sch.WriteString( + "#######################\n# Generated Mutations\n#######################\n\n")) + x.Check2(sch.WriteString(generateObjectString(schema.Mutation))) + } return sch.String() } diff --git a/graphql/schema/gqlschema_test.yml b/graphql/schema/gqlschema_test.yml index bbbe62dcbc1..57af215c965 100644 --- a/graphql/schema/gqlschema_test.yml +++ b/graphql/schema/gqlschema_test.yml @@ -31,8 +31,8 @@ invalid_schemas: getAuthro(id: ID): Author! } errlist: [ - {"message":"You don't need to define the GraphQL Query or Mutation types. Those are built automatically for you.", "locations":[{"line":1, "column":6}]}, - {"message":"You don't need to define the GraphQL Query or Mutation types. Those are built automatically for you.", "locations":[{"line":4, "column":6}]}, + {"message":"GraphQL Query and Mutation types are only allowed to have fields with @custom directive. Other fields are built automatically for you.", "locations":[{"line":1, "column":6}]}, + {"message":"GraphQL Query and Mutation types are only allowed to have fields with @custom directive. Other fields are built automatically for you.", "locations":[{"line":4, "column":6}]}, ] - @@ -707,6 +707,50 @@ invalid_schemas: "locations":[{"line":1, "column":6}]}, ] + - + name: "@custom directive with wrong argument name" + input: | + type Author { + id: ID! + } + + type Query { + getAuthor(id: ID): Author! @custom(https: {url: "blah.com", method: "GET"}) + } + errlist: [ + {"message": "Type Query; Field getAuthor: http argument for @custom directive should not be empty.", + "locations":[{"line":6, "column":31}]}, + ] + + - + name: "@custom directive with wrong url" + input: | + type Author { + id: ID! + } + + type Query { + getAuthor(id: ID): Author! @custom(http: {url: "123", method: "GET"}) + } + errlist: [ + {"message": "Type Query; Field getAuthor; url field inside @custom directive is invalid.", + "locations":[{"line":6, "column":31}]}, + ] + + - + name: "@custom directive with wrong value for method" + input: | + type Author { + id: ID! + } + + type Query { + getAuthor(id: ID): Author! @custom(http: {url: "http://google.com/", method: "GETS"}) + } + errlist: [ + {"message": "Type Query; Field getAuthor; method field inside @custom directive can only be GET/POST.", + "locations":[{"line":6, "column":31}]}, + ] valid_schemas: @@ -810,3 +854,17 @@ valid_schemas: type Y { f1: [X] @dgraph(pred: "f1") } + + - + name: "Query, Mutation in initial schema with @custom directive" + input: | + type Author { + id: ID! + } + + type Query { + getAuthro(id: ID): Author! @custom(http: {url: "http://blah.com", method: "GET"}) + } + type Mutation { + getAuthro(id: ID): Author! @custom(http: {url: "http://blah.com", method: "POST"}) + } \ No newline at end of file diff --git a/graphql/schema/request.go b/graphql/schema/request.go index f0aaee645d3..bb0bd965550 100644 --- a/graphql/schema/request.go +++ b/graphql/schema/request.go @@ -17,6 +17,8 @@ package schema import ( + "net/http" + "github.com/pkg/errors" "github.com/vektah/gqlparser/v2/ast" @@ -30,6 +32,8 @@ type Request struct { Query string `json:"query"` OperationName string `json:"operationName"` Variables map[string]interface{} `json:"variables"` + + Header http.Header } // Operation finds the operation in req, if it is a valid request for GraphQL @@ -70,6 +74,7 @@ func (s *schema) Operation(req *Request) (Operation, error) { operation := &operation{op: op, vars: vars, query: req.Query, + header: req.Header, doc: doc, inSchema: s, } diff --git a/graphql/schema/rules.go b/graphql/schema/rules.go index 8007023fdb9..8ec47466586 100644 --- a/graphql/schema/rules.go +++ b/graphql/schema/rules.go @@ -18,6 +18,7 @@ package schema import ( "fmt" + "net/url" "sort" "strings" @@ -57,8 +58,19 @@ func nameCheck(defn *ast.Definition) *gqlerror.Error { var errMesg string if defn.Name == "Query" || defn.Name == "Mutation" { - errMesg = "You don't need to define the GraphQL Query or Mutation types." + - " Those are built automatically for you." + for _, fld := range defn.Fields { + // If we find any query or mutation field defined without a @custom directive, that + // is an error for us. + custom := fld.Directives.ForName("custom") + if custom == nil { + errMesg = "GraphQL Query and Mutation types are only allowed to have fields " + + "with @custom directive. Other fields are built automatically for you." + break + } + } + if errMesg == "" { + return nil + } } else { errMesg = fmt.Sprintf( "%s is a reserved word, so you can't declare a type with this name. "+ @@ -212,6 +224,9 @@ func isValidFieldForList(typ *ast.Definition, field *ast.FieldDefinition) *gqler } func fieldArgumentCheck(typ *ast.Definition, field *ast.FieldDefinition) *gqlerror.Error { + if typ.Name == "Query" || typ.Name == "Mutation" { + return nil + } if field.Arguments != nil { return gqlerror.ErrorPosf( field.Position, @@ -223,7 +238,7 @@ func fieldArgumentCheck(typ *ast.Definition, field *ast.FieldDefinition) *gqlerr } func fieldNameCheck(typ *ast.Definition, field *ast.FieldDefinition) *gqlerror.Error { - //field name cannot be a reserved word + // field name cannot be a reserved word if isReservedKeyWord(field.Name) { return gqlerror.ErrorPosf( field.Position, "Type %s; Field %s: %s is a reserved keyword and "+ @@ -603,6 +618,64 @@ func passwordValidation(sch *ast.Schema, return passwordDirectiveValidation(typ) } +func notDgraphDirectiveValidation(sch *ast.Schema, + typ *ast.Definition, + field *ast.FieldDefinition, + dir *ast.Directive) *gqlerror.Error { + return nil +} + +func customDirectiveValidation(sch *ast.Schema, + typ *ast.Definition, + field *ast.FieldDefinition, + dir *ast.Directive) *gqlerror.Error { + + httpArg := dir.Arguments.ForName("http") + if httpArg == nil || httpArg.Value.String() == "" { + return gqlerror.ErrorPosf( + dir.Position, + "Type %s; Field %s: http argument for @custom directive should not be empty.", + typ.Name, field.Name, + ) + } + if httpArg.Value.Kind != ast.ObjectValue { + return gqlerror.ErrorPosf( + dir.Position, + "Type %s; Field %s: http argument for @custom directive should of type Object.", + typ.Name, field.Name, + ) + } + u := httpArg.Value.Children.ForName("url") + if u == nil { + return gqlerror.ErrorPosf( + dir.Position, + "Type %s; Field %s; url field inside @custom directive is mandatory.", typ.Name, + field.Name) + } + if _, err := url.ParseRequestURI(u.Raw); err != nil { + return gqlerror.ErrorPosf( + dir.Position, + "Type %s; Field %s; url field inside @custom directive is invalid.", typ.Name, + field.Name) + } + method := httpArg.Value.Children.ForName("method") + if method == nil { + return gqlerror.ErrorPosf( + dir.Position, + "Type %s; Field %s; method field inside @custom directive is mandatory.", typ.Name, + field.Name) + } + if method.Raw != "GET" && method.Raw != "POST" { + return gqlerror.ErrorPosf( + dir.Position, + "Type %s; Field %s; method field inside @custom directive can only be GET/POST.", + typ.Name, field.Name) + + } + + return nil +} + func idValidation(sch *ast.Schema, typ *ast.Definition, field *ast.FieldDefinition, diff --git a/graphql/schema/schemagen.go b/graphql/schema/schemagen.go index 722be274d75..c2308f8b9d2 100644 --- a/graphql/schema/schemagen.go +++ b/graphql/schema/schemagen.go @@ -112,12 +112,20 @@ func NewHandler(input string) (Handler, error) { return nil, gqlErrList } + typesToComplete := make([]string, 0, len(doc.Definitions)) defns := make([]string, 0, len(doc.Definitions)) for _, defn := range doc.Definitions { if defn.BuiltIn { continue } defns = append(defns, defn.Name) + if defn.Kind == ast.Object || defn.Kind == ast.Interface { + notInDgraph := defn.Directives.ForName(notDgraphDirective) + if notInDgraph != nil { + continue + } + } + typesToComplete = append(typesToComplete, defn.Name) } expandSchema(doc) @@ -132,8 +140,12 @@ func NewHandler(input string) (Handler, error) { return nil, gqlErrList } - dgSchema := genDgSchema(sch, defns) - completeSchema(sch, defns) + dgSchema := genDgSchema(sch, typesToComplete) + completeSchema(sch, typesToComplete) + + if len(sch.Query.Fields) == 0 && len(sch.Mutation.Fields) == 0 { + return nil, gqlerror.Errorf("No query or mutation found in the generated schema") + } return &handler{ input: input, diff --git a/graphql/schema/schemagen_test.go b/graphql/schema/schemagen_test.go index cb2c831aef7..030df3467cd 100644 --- a/graphql/schema/schemagen_test.go +++ b/graphql/schema/schemagen_test.go @@ -38,7 +38,7 @@ type TestCase struct { } func TestDGSchemaGen(t *testing.T) { - fileName := "schemagen_test.yml" + fileName := "dgraph_schemagen_test.yml" byts, err := ioutil.ReadFile(fileName) require.NoError(t, err, "Unable to read file %s", fileName) diff --git a/graphql/schema/testdata/schemagen/input/custom-nested-types.graphql b/graphql/schema/testdata/schemagen/input/custom-nested-types.graphql new file mode 100644 index 00000000000..add0c7dc067 --- /dev/null +++ b/graphql/schema/testdata/schemagen/input/custom-nested-types.graphql @@ -0,0 +1,21 @@ +type Car @not_dgraph { + id: ID! + name: String! +} + +interface Person @not_dgraph { + age: Int! +} + +type User implements Person @not_dgraph { + id: ID! + name: String! + cars: [Car] +} + +type Query { + getMyFavoriteUsers(id: ID!): [User] @custom(http: { + url: "http://my-api.com", + method: "GET" + }) +} diff --git a/graphql/schema/testdata/schemagen/input/custom-query.graphql b/graphql/schema/testdata/schemagen/input/custom-query.graphql new file mode 100644 index 00000000000..64618d4e2a7 --- /dev/null +++ b/graphql/schema/testdata/schemagen/input/custom-query.graphql @@ -0,0 +1,11 @@ +type User { + id: ID! + name: String! +} + +type Query { + getMyFavoriteUsers(id: ID!): [User] @custom(http: { + url: "http://my-api.com", + method: "GET" + }) +} \ No newline at end of file diff --git a/graphql/schema/testdata/schemagen/input/custom-types.graphql b/graphql/schema/testdata/schemagen/input/custom-types.graphql new file mode 100644 index 00000000000..c66162107de --- /dev/null +++ b/graphql/schema/testdata/schemagen/input/custom-types.graphql @@ -0,0 +1,11 @@ +type User @not_dgraph { + id: ID! + name: String! +} + +type Query { + getMyFavoriteUsers(id: ID!): [User] @custom(http: { + url: "http://my-api.com", + method: "GET" + }) +} \ No newline at end of file diff --git a/graphql/schema/testdata/schemagen/output/comments-and-descriptions.graphql b/graphql/schema/testdata/schemagen/output/comments-and-descriptions.graphql index 4b5dc5d3bac..39a7aa4cffa 100644 --- a/graphql/schema/testdata/schemagen/output/comments-and-descriptions.graphql +++ b/graphql/schema/testdata/schemagen/output/comments-and-descriptions.graphql @@ -45,11 +45,18 @@ enum DgraphIndex { hour } +input CustomHTTP { + url: String! + method: String! +} + directive @hasInverse(field: String!) on FIELD_DEFINITION directive @search(by: [DgraphIndex!]) on FIELD_DEFINITION directive @dgraph(type: String, pred: String) on OBJECT | INTERFACE | FIELD_DEFINITION directive @id on FIELD_DEFINITION directive @secret(field: String!, pred: String) on OBJECT | INTERFACE +directive @custom(http: CustomHTTP) on FIELD_DEFINITION +directive @not_dgraph on OBJECT input IntFilter { eq: Int diff --git a/graphql/schema/testdata/schemagen/output/custom-nested-types.graphql b/graphql/schema/testdata/schemagen/output/custom-nested-types.graphql new file mode 100644 index 00000000000..dc1d23c7cc5 --- /dev/null +++ b/graphql/schema/testdata/schemagen/output/custom-nested-types.graphql @@ -0,0 +1,113 @@ +####################### +# Input Schema +####################### + +type Car @not_dgraph { + id: ID! + name: String! +} + +interface Person @not_dgraph { + age: Int! +} + +type User implements Person @not_dgraph { + age: Int! + id: ID! + name: String! + cars: [Car] +} + +####################### +# Extended Definitions +####################### + +scalar DateTime + +enum DgraphIndex { + int + float + bool + hash + exact + term + fulltext + trigram + regexp + year + month + day + hour +} + +input CustomHTTP { + url: String! + method: String! +} + +directive @hasInverse(field: String!) on FIELD_DEFINITION +directive @search(by: [DgraphIndex!]) on FIELD_DEFINITION +directive @dgraph(type: String, pred: String) on OBJECT | INTERFACE | FIELD_DEFINITION +directive @id on FIELD_DEFINITION +directive @secret(field: String!, pred: String) on OBJECT | INTERFACE +directive @custom(http: CustomHTTP) on FIELD_DEFINITION +directive @not_dgraph on OBJECT + +input IntFilter { + eq: Int + le: Int + lt: Int + ge: Int + gt: Int +} + +input FloatFilter { + eq: Float + le: Float + lt: Float + ge: Float + gt: Float +} + +input DateTimeFilter { + eq: DateTime + le: DateTime + lt: DateTime + ge: DateTime + gt: DateTime +} + +input StringTermFilter { + allofterms: String + anyofterms: String +} + +input StringRegExpFilter { + regexp: String +} + +input StringFullTextFilter { + alloftext: String + anyoftext: String +} + +input StringExactFilter { + eq: String + le: String + lt: String + ge: String + gt: String +} + +input StringHashFilter { + eq: String +} + +####################### +# Generated Query +####################### + +type Query { + getMyFavoriteUsers(id: ID!): [User] @custom(http: {url:"http://my-api.com",method:"GET"}) +} + diff --git a/graphql/schema/testdata/schemagen/output/custom-query.graphql b/graphql/schema/testdata/schemagen/output/custom-query.graphql new file mode 100644 index 00000000000..8222e0d46be --- /dev/null +++ b/graphql/schema/testdata/schemagen/output/custom-query.graphql @@ -0,0 +1,174 @@ +####################### +# Input Schema +####################### + +type User { + id: ID! + name: String! +} + +####################### +# Extended Definitions +####################### + +scalar DateTime + +enum DgraphIndex { + int + float + bool + hash + exact + term + fulltext + trigram + regexp + year + month + day + hour +} + +input CustomHTTP { + url: String! + method: String! +} + +directive @hasInverse(field: String!) on FIELD_DEFINITION +directive @search(by: [DgraphIndex!]) on FIELD_DEFINITION +directive @dgraph(type: String, pred: String) on OBJECT | INTERFACE | FIELD_DEFINITION +directive @id on FIELD_DEFINITION +directive @secret(field: String!, pred: String) on OBJECT | INTERFACE +directive @custom(http: CustomHTTP) on FIELD_DEFINITION +directive @not_dgraph on OBJECT + +input IntFilter { + eq: Int + le: Int + lt: Int + ge: Int + gt: Int +} + +input FloatFilter { + eq: Float + le: Float + lt: Float + ge: Float + gt: Float +} + +input DateTimeFilter { + eq: DateTime + le: DateTime + lt: DateTime + ge: DateTime + gt: DateTime +} + +input StringTermFilter { + allofterms: String + anyofterms: String +} + +input StringRegExpFilter { + regexp: String +} + +input StringFullTextFilter { + alloftext: String + anyoftext: String +} + +input StringExactFilter { + eq: String + le: String + lt: String + ge: String + gt: String +} + +input StringHashFilter { + eq: String +} + +####################### +# Generated Types +####################### + +type AddUserPayload { + user(filter: UserFilter, order: UserOrder, first: Int, offset: Int): [User] + numUids: Int +} + +type DeleteUserPayload { + msg: String + numUids: Int +} + +type UpdateUserPayload { + user(filter: UserFilter, order: UserOrder, first: Int, offset: Int): [User] + numUids: Int +} + +####################### +# Generated Enums +####################### + +enum UserOrderable { + name +} + +####################### +# Generated Inputs +####################### + +input AddUserInput { + name: String! +} + +input UpdateUserInput { + filter: UserFilter! + set: UserPatch + remove: UserPatch +} + +input UserFilter { + id: [ID!] + not: UserFilter +} + +input UserOrder { + asc: UserOrderable + desc: UserOrderable + then: UserOrder +} + +input UserPatch { + name: String +} + +input UserRef { + id: ID + name: String +} + +####################### +# Generated Query +####################### + +type Query { + getMyFavoriteUsers(id: ID!): [User] @custom(http: {url:"http://my-api.com",method:"GET"}) + getUser(id: ID!): User + queryUser(filter: UserFilter, order: UserOrder, first: Int, offset: Int): [User] +} + +####################### +# Generated Mutations +####################### + +type Mutation { + addUser(input: [AddUserInput!]!): AddUserPayload + updateUser(input: UpdateUserInput!): UpdateUserPayload + deleteUser(filter: UserFilter!): DeleteUserPayload +} diff --git a/graphql/schema/testdata/schemagen/output/custom-types.graphql b/graphql/schema/testdata/schemagen/output/custom-types.graphql new file mode 100644 index 00000000000..9268fa4dcd9 --- /dev/null +++ b/graphql/schema/testdata/schemagen/output/custom-types.graphql @@ -0,0 +1,102 @@ +####################### +# Input Schema +####################### + +type User @not_dgraph { + id: ID! + name: String! +} + +####################### +# Extended Definitions +####################### + +scalar DateTime + +enum DgraphIndex { + int + float + bool + hash + exact + term + fulltext + trigram + regexp + year + month + day + hour +} + +input CustomHTTP { + url: String! + method: String! +} + +directive @hasInverse(field: String!) on FIELD_DEFINITION +directive @search(by: [DgraphIndex!]) on FIELD_DEFINITION +directive @dgraph(type: String, pred: String) on OBJECT | INTERFACE | FIELD_DEFINITION +directive @id on FIELD_DEFINITION +directive @secret(field: String!, pred: String) on OBJECT | INTERFACE +directive @custom(http: CustomHTTP) on FIELD_DEFINITION +directive @not_dgraph on OBJECT + +input IntFilter { + eq: Int + le: Int + lt: Int + ge: Int + gt: Int +} + +input FloatFilter { + eq: Float + le: Float + lt: Float + ge: Float + gt: Float +} + +input DateTimeFilter { + eq: DateTime + le: DateTime + lt: DateTime + ge: DateTime + gt: DateTime +} + +input StringTermFilter { + allofterms: String + anyofterms: String +} + +input StringRegExpFilter { + regexp: String +} + +input StringFullTextFilter { + alloftext: String + anyoftext: String +} + +input StringExactFilter { + eq: String + le: String + lt: String + ge: String + gt: String +} + +input StringHashFilter { + eq: String +} + +####################### +# Generated Query +####################### + +type Query { + getMyFavoriteUsers(id: ID!): [User] @custom(http: {url:"http://my-api.com",method:"GET"}) +} + diff --git a/graphql/schema/testdata/schemagen/output/deprecated.graphql b/graphql/schema/testdata/schemagen/output/deprecated.graphql index 539b2209b82..b9f9a2a9c68 100644 --- a/graphql/schema/testdata/schemagen/output/deprecated.graphql +++ b/graphql/schema/testdata/schemagen/output/deprecated.graphql @@ -29,11 +29,18 @@ enum DgraphIndex { hour } +input CustomHTTP { + url: String! + method: String! +} + directive @hasInverse(field: String!) on FIELD_DEFINITION directive @search(by: [DgraphIndex!]) on FIELD_DEFINITION directive @dgraph(type: String, pred: String) on OBJECT | INTERFACE | FIELD_DEFINITION directive @id on FIELD_DEFINITION directive @secret(field: String!, pred: String) on OBJECT | INTERFACE +directive @custom(http: CustomHTTP) on FIELD_DEFINITION +directive @not_dgraph on OBJECT input IntFilter { eq: Int diff --git a/graphql/schema/testdata/schemagen/output/dgraph-reverse-directive-on-concrete-type-with-interfaces.graphql b/graphql/schema/testdata/schemagen/output/dgraph-reverse-directive-on-concrete-type-with-interfaces.graphql index fbfb8de1976..f556595248d 100644 --- a/graphql/schema/testdata/schemagen/output/dgraph-reverse-directive-on-concrete-type-with-interfaces.graphql +++ b/graphql/schema/testdata/schemagen/output/dgraph-reverse-directive-on-concrete-type-with-interfaces.graphql @@ -43,11 +43,18 @@ enum DgraphIndex { hour } +input CustomHTTP { + url: String! + method: String! +} + directive @hasInverse(field: String!) on FIELD_DEFINITION directive @search(by: [DgraphIndex!]) on FIELD_DEFINITION directive @dgraph(type: String, pred: String) on OBJECT | INTERFACE | FIELD_DEFINITION directive @id on FIELD_DEFINITION directive @secret(field: String!, pred: String) on OBJECT | INTERFACE +directive @custom(http: CustomHTTP) on FIELD_DEFINITION +directive @not_dgraph on OBJECT input IntFilter { eq: Int diff --git a/graphql/schema/testdata/schemagen/output/dgraph-reverse-directive-with-interfaces.graphql b/graphql/schema/testdata/schemagen/output/dgraph-reverse-directive-with-interfaces.graphql index 2f2353236fc..945397cf875 100644 --- a/graphql/schema/testdata/schemagen/output/dgraph-reverse-directive-with-interfaces.graphql +++ b/graphql/schema/testdata/schemagen/output/dgraph-reverse-directive-with-interfaces.graphql @@ -43,11 +43,18 @@ enum DgraphIndex { hour } +input CustomHTTP { + url: String! + method: String! +} + directive @hasInverse(field: String!) on FIELD_DEFINITION directive @search(by: [DgraphIndex!]) on FIELD_DEFINITION directive @dgraph(type: String, pred: String) on OBJECT | INTERFACE | FIELD_DEFINITION directive @id on FIELD_DEFINITION directive @secret(field: String!, pred: String) on OBJECT | INTERFACE +directive @custom(http: CustomHTTP) on FIELD_DEFINITION +directive @not_dgraph on OBJECT input IntFilter { eq: Int diff --git a/graphql/schema/testdata/schemagen/output/field-with-id-directive.graphql b/graphql/schema/testdata/schemagen/output/field-with-id-directive.graphql index 31e574bb710..59f5d44c6ec 100644 --- a/graphql/schema/testdata/schemagen/output/field-with-id-directive.graphql +++ b/graphql/schema/testdata/schemagen/output/field-with-id-directive.graphql @@ -42,11 +42,18 @@ enum DgraphIndex { hour } +input CustomHTTP { + url: String! + method: String! +} + directive @hasInverse(field: String!) on FIELD_DEFINITION directive @search(by: [DgraphIndex!]) on FIELD_DEFINITION directive @dgraph(type: String, pred: String) on OBJECT | INTERFACE | FIELD_DEFINITION directive @id on FIELD_DEFINITION directive @secret(field: String!, pred: String) on OBJECT | INTERFACE +directive @custom(http: CustomHTTP) on FIELD_DEFINITION +directive @not_dgraph on OBJECT input IntFilter { eq: Int diff --git a/graphql/schema/testdata/schemagen/output/field-with-reverse-predicate-in-dgraph-directive.graphql b/graphql/schema/testdata/schemagen/output/field-with-reverse-predicate-in-dgraph-directive.graphql index 506d980a6e8..943e5daa924 100644 --- a/graphql/schema/testdata/schemagen/output/field-with-reverse-predicate-in-dgraph-directive.graphql +++ b/graphql/schema/testdata/schemagen/output/field-with-reverse-predicate-in-dgraph-directive.graphql @@ -36,11 +36,18 @@ enum DgraphIndex { hour } +input CustomHTTP { + url: String! + method: String! +} + directive @hasInverse(field: String!) on FIELD_DEFINITION directive @search(by: [DgraphIndex!]) on FIELD_DEFINITION directive @dgraph(type: String, pred: String) on OBJECT | INTERFACE | FIELD_DEFINITION directive @id on FIELD_DEFINITION directive @secret(field: String!, pred: String) on OBJECT | INTERFACE +directive @custom(http: CustomHTTP) on FIELD_DEFINITION +directive @not_dgraph on OBJECT input IntFilter { eq: Int diff --git a/graphql/schema/testdata/schemagen/output/hasInverse-with-interface-having-directive.graphql b/graphql/schema/testdata/schemagen/output/hasInverse-with-interface-having-directive.graphql index e35f2a0bf3f..44bdf87c3cc 100644 --- a/graphql/schema/testdata/schemagen/output/hasInverse-with-interface-having-directive.graphql +++ b/graphql/schema/testdata/schemagen/output/hasInverse-with-interface-having-directive.graphql @@ -53,11 +53,18 @@ enum DgraphIndex { hour } +input CustomHTTP { + url: String! + method: String! +} + directive @hasInverse(field: String!) on FIELD_DEFINITION directive @search(by: [DgraphIndex!]) on FIELD_DEFINITION directive @dgraph(type: String, pred: String) on OBJECT | INTERFACE | FIELD_DEFINITION directive @id on FIELD_DEFINITION directive @secret(field: String!, pred: String) on OBJECT | INTERFACE +directive @custom(http: CustomHTTP) on FIELD_DEFINITION +directive @not_dgraph on OBJECT input IntFilter { eq: Int diff --git a/graphql/schema/testdata/schemagen/output/hasInverse-with-interface.graphql b/graphql/schema/testdata/schemagen/output/hasInverse-with-interface.graphql index aaa9f8a2836..6b15d9cda79 100644 --- a/graphql/schema/testdata/schemagen/output/hasInverse-with-interface.graphql +++ b/graphql/schema/testdata/schemagen/output/hasInverse-with-interface.graphql @@ -54,11 +54,18 @@ enum DgraphIndex { hour } +input CustomHTTP { + url: String! + method: String! +} + directive @hasInverse(field: String!) on FIELD_DEFINITION directive @search(by: [DgraphIndex!]) on FIELD_DEFINITION directive @dgraph(type: String, pred: String) on OBJECT | INTERFACE | FIELD_DEFINITION directive @id on FIELD_DEFINITION directive @secret(field: String!, pred: String) on OBJECT | INTERFACE +directive @custom(http: CustomHTTP) on FIELD_DEFINITION +directive @not_dgraph on OBJECT input IntFilter { eq: Int diff --git a/graphql/schema/testdata/schemagen/output/hasInverse-with-type-having-directive.graphql b/graphql/schema/testdata/schemagen/output/hasInverse-with-type-having-directive.graphql index e35f2a0bf3f..44bdf87c3cc 100644 --- a/graphql/schema/testdata/schemagen/output/hasInverse-with-type-having-directive.graphql +++ b/graphql/schema/testdata/schemagen/output/hasInverse-with-type-having-directive.graphql @@ -53,11 +53,18 @@ enum DgraphIndex { hour } +input CustomHTTP { + url: String! + method: String! +} + directive @hasInverse(field: String!) on FIELD_DEFINITION directive @search(by: [DgraphIndex!]) on FIELD_DEFINITION directive @dgraph(type: String, pred: String) on OBJECT | INTERFACE | FIELD_DEFINITION directive @id on FIELD_DEFINITION directive @secret(field: String!, pred: String) on OBJECT | INTERFACE +directive @custom(http: CustomHTTP) on FIELD_DEFINITION +directive @not_dgraph on OBJECT input IntFilter { eq: Int diff --git a/graphql/schema/testdata/schemagen/output/hasInverse.graphql b/graphql/schema/testdata/schemagen/output/hasInverse.graphql index 25a57aae440..91a970d8fc4 100644 --- a/graphql/schema/testdata/schemagen/output/hasInverse.graphql +++ b/graphql/schema/testdata/schemagen/output/hasInverse.graphql @@ -34,11 +34,18 @@ enum DgraphIndex { hour } +input CustomHTTP { + url: String! + method: String! +} + directive @hasInverse(field: String!) on FIELD_DEFINITION directive @search(by: [DgraphIndex!]) on FIELD_DEFINITION directive @dgraph(type: String, pred: String) on OBJECT | INTERFACE | FIELD_DEFINITION directive @id on FIELD_DEFINITION directive @secret(field: String!, pred: String) on OBJECT | INTERFACE +directive @custom(http: CustomHTTP) on FIELD_DEFINITION +directive @not_dgraph on OBJECT input IntFilter { eq: Int @@ -124,10 +131,6 @@ type UpdatePostPayload { numUids: Int } -####################### -# Generated Enums -####################### - ####################### # Generated Inputs ####################### diff --git a/graphql/schema/testdata/schemagen/output/ignore-unsupported-directive.graphql b/graphql/schema/testdata/schemagen/output/ignore-unsupported-directive.graphql index 6d9cb4b742b..dcb2c036efe 100644 --- a/graphql/schema/testdata/schemagen/output/ignore-unsupported-directive.graphql +++ b/graphql/schema/testdata/schemagen/output/ignore-unsupported-directive.graphql @@ -36,11 +36,18 @@ enum DgraphIndex { hour } +input CustomHTTP { + url: String! + method: String! +} + directive @hasInverse(field: String!) on FIELD_DEFINITION directive @search(by: [DgraphIndex!]) on FIELD_DEFINITION directive @dgraph(type: String, pred: String) on OBJECT | INTERFACE | FIELD_DEFINITION directive @id on FIELD_DEFINITION directive @secret(field: String!, pred: String) on OBJECT | INTERFACE +directive @custom(http: CustomHTTP) on FIELD_DEFINITION +directive @not_dgraph on OBJECT input IntFilter { eq: Int diff --git a/graphql/schema/testdata/schemagen/output/interface-with-id-directive.graphql b/graphql/schema/testdata/schemagen/output/interface-with-id-directive.graphql index 606d8ad16c1..2e63c6a8de2 100644 --- a/graphql/schema/testdata/schemagen/output/interface-with-id-directive.graphql +++ b/graphql/schema/testdata/schemagen/output/interface-with-id-directive.graphql @@ -38,11 +38,18 @@ enum DgraphIndex { hour } +input CustomHTTP { + url: String! + method: String! +} + directive @hasInverse(field: String!) on FIELD_DEFINITION directive @search(by: [DgraphIndex!]) on FIELD_DEFINITION directive @dgraph(type: String, pred: String) on OBJECT | INTERFACE | FIELD_DEFINITION directive @id on FIELD_DEFINITION directive @secret(field: String!, pred: String) on OBJECT | INTERFACE +directive @custom(http: CustomHTTP) on FIELD_DEFINITION +directive @not_dgraph on OBJECT input IntFilter { eq: Int diff --git a/graphql/schema/testdata/schemagen/output/interface-with-no-ids.graphql b/graphql/schema/testdata/schemagen/output/interface-with-no-ids.graphql index 8b1e099507e..876e7954179 100644 --- a/graphql/schema/testdata/schemagen/output/interface-with-no-ids.graphql +++ b/graphql/schema/testdata/schemagen/output/interface-with-no-ids.graphql @@ -38,11 +38,18 @@ enum DgraphIndex { hour } +input CustomHTTP { + url: String! + method: String! +} + directive @hasInverse(field: String!) on FIELD_DEFINITION directive @search(by: [DgraphIndex!]) on FIELD_DEFINITION directive @dgraph(type: String, pred: String) on OBJECT | INTERFACE | FIELD_DEFINITION directive @id on FIELD_DEFINITION directive @secret(field: String!, pred: String) on OBJECT | INTERFACE +directive @custom(http: CustomHTTP) on FIELD_DEFINITION +directive @not_dgraph on OBJECT input IntFilter { eq: Int diff --git a/graphql/schema/testdata/schemagen/output/interfaces-with-types-and-password.graphql b/graphql/schema/testdata/schemagen/output/interfaces-with-types-and-password.graphql index 3129384fa1a..449d953a463 100644 --- a/graphql/schema/testdata/schemagen/output/interfaces-with-types-and-password.graphql +++ b/graphql/schema/testdata/schemagen/output/interfaces-with-types-and-password.graphql @@ -60,11 +60,18 @@ enum DgraphIndex { hour } +input CustomHTTP { + url: String! + method: String! +} + directive @hasInverse(field: String!) on FIELD_DEFINITION directive @search(by: [DgraphIndex!]) on FIELD_DEFINITION directive @dgraph(type: String, pred: String) on OBJECT | INTERFACE | FIELD_DEFINITION directive @id on FIELD_DEFINITION directive @secret(field: String!, pred: String) on OBJECT | INTERFACE +directive @custom(http: CustomHTTP) on FIELD_DEFINITION +directive @not_dgraph on OBJECT input IntFilter { eq: Int diff --git a/graphql/schema/testdata/schemagen/output/interfaces-with-types.graphql b/graphql/schema/testdata/schemagen/output/interfaces-with-types.graphql index 0c28c2951d4..1264f447514 100644 --- a/graphql/schema/testdata/schemagen/output/interfaces-with-types.graphql +++ b/graphql/schema/testdata/schemagen/output/interfaces-with-types.graphql @@ -60,11 +60,18 @@ enum DgraphIndex { hour } +input CustomHTTP { + url: String! + method: String! +} + directive @hasInverse(field: String!) on FIELD_DEFINITION directive @search(by: [DgraphIndex!]) on FIELD_DEFINITION directive @dgraph(type: String, pred: String) on OBJECT | INTERFACE | FIELD_DEFINITION directive @id on FIELD_DEFINITION directive @secret(field: String!, pred: String) on OBJECT | INTERFACE +directive @custom(http: CustomHTTP) on FIELD_DEFINITION +directive @not_dgraph on OBJECT input IntFilter { eq: Int diff --git a/graphql/schema/testdata/schemagen/output/no-id-field-with-searchables.graphql b/graphql/schema/testdata/schemagen/output/no-id-field-with-searchables.graphql index 8981d12dcfe..4157d7d12d5 100644 --- a/graphql/schema/testdata/schemagen/output/no-id-field-with-searchables.graphql +++ b/graphql/schema/testdata/schemagen/output/no-id-field-with-searchables.graphql @@ -28,11 +28,18 @@ enum DgraphIndex { hour } +input CustomHTTP { + url: String! + method: String! +} + directive @hasInverse(field: String!) on FIELD_DEFINITION directive @search(by: [DgraphIndex!]) on FIELD_DEFINITION directive @dgraph(type: String, pred: String) on OBJECT | INTERFACE | FIELD_DEFINITION directive @id on FIELD_DEFINITION directive @secret(field: String!, pred: String) on OBJECT | INTERFACE +directive @custom(http: CustomHTTP) on FIELD_DEFINITION +directive @not_dgraph on OBJECT input IntFilter { eq: Int diff --git a/graphql/schema/testdata/schemagen/output/no-id-field.graphql b/graphql/schema/testdata/schemagen/output/no-id-field.graphql index cd6b3a64f05..0edad31fc88 100644 --- a/graphql/schema/testdata/schemagen/output/no-id-field.graphql +++ b/graphql/schema/testdata/schemagen/output/no-id-field.graphql @@ -40,11 +40,18 @@ enum DgraphIndex { hour } +input CustomHTTP { + url: String! + method: String! +} + directive @hasInverse(field: String!) on FIELD_DEFINITION directive @search(by: [DgraphIndex!]) on FIELD_DEFINITION directive @dgraph(type: String, pred: String) on OBJECT | INTERFACE | FIELD_DEFINITION directive @id on FIELD_DEFINITION directive @secret(field: String!, pred: String) on OBJECT | INTERFACE +directive @custom(http: CustomHTTP) on FIELD_DEFINITION +directive @not_dgraph on OBJECT input IntFilter { eq: Int diff --git a/graphql/schema/testdata/schemagen/output/password-type.graphql b/graphql/schema/testdata/schemagen/output/password-type.graphql index 50ac7fd8383..54034b5f240 100644 --- a/graphql/schema/testdata/schemagen/output/password-type.graphql +++ b/graphql/schema/testdata/schemagen/output/password-type.graphql @@ -29,11 +29,18 @@ enum DgraphIndex { hour } +input CustomHTTP { + url: String! + method: String! +} + directive @hasInverse(field: String!) on FIELD_DEFINITION directive @search(by: [DgraphIndex!]) on FIELD_DEFINITION directive @dgraph(type: String, pred: String) on OBJECT | INTERFACE | FIELD_DEFINITION directive @id on FIELD_DEFINITION directive @secret(field: String!, pred: String) on OBJECT | INTERFACE +directive @custom(http: CustomHTTP) on FIELD_DEFINITION +directive @not_dgraph on OBJECT input IntFilter { eq: Int diff --git a/graphql/schema/testdata/schemagen/output/searchables-references.graphql b/graphql/schema/testdata/schemagen/output/searchables-references.graphql index 935442a1ef9..28d9304e7c8 100644 --- a/graphql/schema/testdata/schemagen/output/searchables-references.graphql +++ b/graphql/schema/testdata/schemagen/output/searchables-references.graphql @@ -38,11 +38,18 @@ enum DgraphIndex { hour } +input CustomHTTP { + url: String! + method: String! +} + directive @hasInverse(field: String!) on FIELD_DEFINITION directive @search(by: [DgraphIndex!]) on FIELD_DEFINITION directive @dgraph(type: String, pred: String) on OBJECT | INTERFACE | FIELD_DEFINITION directive @id on FIELD_DEFINITION directive @secret(field: String!, pred: String) on OBJECT | INTERFACE +directive @custom(http: CustomHTTP) on FIELD_DEFINITION +directive @not_dgraph on OBJECT input IntFilter { eq: Int diff --git a/graphql/schema/testdata/schemagen/output/searchables.graphql b/graphql/schema/testdata/schemagen/output/searchables.graphql index e34474d5ff1..ee6847dfabd 100644 --- a/graphql/schema/testdata/schemagen/output/searchables.graphql +++ b/graphql/schema/testdata/schemagen/output/searchables.graphql @@ -55,11 +55,18 @@ enum DgraphIndex { hour } +input CustomHTTP { + url: String! + method: String! +} + directive @hasInverse(field: String!) on FIELD_DEFINITION directive @search(by: [DgraphIndex!]) on FIELD_DEFINITION directive @dgraph(type: String, pred: String) on OBJECT | INTERFACE | FIELD_DEFINITION directive @id on FIELD_DEFINITION directive @secret(field: String!, pred: String) on OBJECT | INTERFACE +directive @custom(http: CustomHTTP) on FIELD_DEFINITION +directive @not_dgraph on OBJECT input IntFilter { eq: Int diff --git a/graphql/schema/testdata/schemagen/output/single-type-with-enum.graphql b/graphql/schema/testdata/schemagen/output/single-type-with-enum.graphql index 9604f5f96b7..388156ff6c3 100644 --- a/graphql/schema/testdata/schemagen/output/single-type-with-enum.graphql +++ b/graphql/schema/testdata/schemagen/output/single-type-with-enum.graphql @@ -37,11 +37,18 @@ enum DgraphIndex { hour } +input CustomHTTP { + url: String! + method: String! +} + directive @hasInverse(field: String!) on FIELD_DEFINITION directive @search(by: [DgraphIndex!]) on FIELD_DEFINITION directive @dgraph(type: String, pred: String) on OBJECT | INTERFACE | FIELD_DEFINITION directive @id on FIELD_DEFINITION directive @secret(field: String!, pred: String) on OBJECT | INTERFACE +directive @custom(http: CustomHTTP) on FIELD_DEFINITION +directive @not_dgraph on OBJECT input IntFilter { eq: Int diff --git a/graphql/schema/testdata/schemagen/output/single-type.graphql b/graphql/schema/testdata/schemagen/output/single-type.graphql index 8fbc4536f5f..435eedb4844 100644 --- a/graphql/schema/testdata/schemagen/output/single-type.graphql +++ b/graphql/schema/testdata/schemagen/output/single-type.graphql @@ -31,11 +31,18 @@ enum DgraphIndex { hour } +input CustomHTTP { + url: String! + method: String! +} + directive @hasInverse(field: String!) on FIELD_DEFINITION directive @search(by: [DgraphIndex!]) on FIELD_DEFINITION directive @dgraph(type: String, pred: String) on OBJECT | INTERFACE | FIELD_DEFINITION directive @id on FIELD_DEFINITION directive @secret(field: String!, pred: String) on OBJECT | INTERFACE +directive @custom(http: CustomHTTP) on FIELD_DEFINITION +directive @not_dgraph on OBJECT input IntFilter { eq: Int diff --git a/graphql/schema/testdata/schemagen/output/type-implements-multiple-interfaces.graphql b/graphql/schema/testdata/schemagen/output/type-implements-multiple-interfaces.graphql index 377a81aca5e..2ec7372a0bc 100644 --- a/graphql/schema/testdata/schemagen/output/type-implements-multiple-interfaces.graphql +++ b/graphql/schema/testdata/schemagen/output/type-implements-multiple-interfaces.graphql @@ -44,11 +44,18 @@ enum DgraphIndex { hour } +input CustomHTTP { + url: String! + method: String! +} + directive @hasInverse(field: String!) on FIELD_DEFINITION directive @search(by: [DgraphIndex!]) on FIELD_DEFINITION directive @dgraph(type: String, pred: String) on OBJECT | INTERFACE | FIELD_DEFINITION directive @id on FIELD_DEFINITION directive @secret(field: String!, pred: String) on OBJECT | INTERFACE +directive @custom(http: CustomHTTP) on FIELD_DEFINITION +directive @not_dgraph on OBJECT input IntFilter { eq: Int diff --git a/graphql/schema/testdata/schemagen/output/type-reference.graphql b/graphql/schema/testdata/schemagen/output/type-reference.graphql index 274cc84ef8f..46533103863 100644 --- a/graphql/schema/testdata/schemagen/output/type-reference.graphql +++ b/graphql/schema/testdata/schemagen/output/type-reference.graphql @@ -36,11 +36,18 @@ enum DgraphIndex { hour } +input CustomHTTP { + url: String! + method: String! +} + directive @hasInverse(field: String!) on FIELD_DEFINITION directive @search(by: [DgraphIndex!]) on FIELD_DEFINITION directive @dgraph(type: String, pred: String) on OBJECT | INTERFACE | FIELD_DEFINITION directive @id on FIELD_DEFINITION directive @secret(field: String!, pred: String) on OBJECT | INTERFACE +directive @custom(http: CustomHTTP) on FIELD_DEFINITION +directive @not_dgraph on OBJECT input IntFilter { eq: Int diff --git a/graphql/schema/wrappers.go b/graphql/schema/wrappers.go index 4cefc2e4bda..05a769760ee 100644 --- a/graphql/schema/wrappers.go +++ b/graphql/schema/wrappers.go @@ -17,9 +17,14 @@ package schema import ( + "bytes" + "encoding/json" "fmt" + "net/http" + "net/url" "strconv" "strings" + "text/scanner" "github.com/dgraph-io/dgraph/x" "github.com/pkg/errors" @@ -40,12 +45,20 @@ type QueryType string // MutationType is currently supported mutations type MutationType string +type HTTPResolverConfig struct { + URL string + Method string + Body string + ForwardHeaders http.Header +} + // Query/Mutation types and arg names const ( GetQuery QueryType = "get" FilterQuery QueryType = "query" SchemaQuery QueryType = "schema" PasswordQuery QueryType = "checkPassword" + HTTPQuery QueryType = "http" NotSupportedQuery QueryType = "notsupported" AddMutation MutationType = "add" UpdateMutation MutationType = "update" @@ -112,6 +125,7 @@ type Query interface { Field QueryType() QueryType Rename(newName string) + HTTPResolver() (HTTPResolverConfig, error) } // A Type is a GraphQL type like: Float, T, T! and [T!]!. If it's not a list, then @@ -166,8 +180,9 @@ type schema struct { } type operation struct { - op *ast.OperationDefinition - vars map[string]interface{} + op *ast.OperationDefinition + vars map[string]interface{} + header http.Header // The fields below are used by schema introspection queries. query string @@ -196,7 +211,7 @@ type query field func (s *schema) Queries(t QueryType) []string { var result []string for _, q := range s.schema.Query.Fields { - if queryType(q.Name) == t { + if queryType(q.Name, q.Directives.ForName("custom")) == t { result = append(result, q.Name) } } @@ -205,6 +220,9 @@ func (s *schema) Queries(t QueryType) []string { func (s *schema) Mutations(t MutationType) []string { var result []string + if s.schema.Mutation == nil { + return nil + } for _, m := range s.schema.Mutation.Fields { if mutationType(m.Name) == t { result = append(result, m.Name) @@ -740,11 +758,13 @@ func (q *query) GetObjectName() string { } func (q *query) QueryType() QueryType { - return queryType(q.Name()) + return queryType(q.Name(), q.field.Directives.ForName("custom")) } -func queryType(name string) QueryType { +func queryType(name string, custom *ast.Directive) QueryType { switch { + case custom != nil: + return HTTPQuery case strings.HasPrefix(name, "get"): return GetQuery case name == "__schema" || name == "__type": @@ -778,6 +798,54 @@ func (q *query) IncludeInterfaceField(dgraphTypes []interface{}) bool { return (*field)(q).IncludeInterfaceField(dgraphTypes) } +func (q *query) HTTPResolver() (HTTPResolverConfig, error) { + // We have to fetch the original definition of the query from the name of the query to be + // able to get the value stored in custom directive. + // TODO - This should be cached later. + query := q.op.inSchema.schema.Query.Fields.ForName(q.Name()) + custom := query.Directives.ForName("custom") + httpArg := custom.Arguments.ForName("http") + rc := HTTPResolverConfig{ + URL: httpArg.Value.Children.ForName("url").Raw, + Method: httpArg.Value.Children.ForName("method").Raw, + } + + argMap := q.field.ArgumentMap(q.op.vars) + vars := make(map[string]interface{}) + // Let's collect the value of query args in vars map and use that for constructing the body + // from the template below. + for _, arg := range query.Arguments { + val := argMap[arg.Name] + vars[arg.Name] = val + if val == nil { + // Instead of replacing value to nil for optional arguments, we replace it with an + // empty string. + val = "" + } + rc.URL = strings.ReplaceAll(rc.URL, "$"+arg.Name, url.QueryEscape(fmt.Sprintf("%v", val))) + } + + bodyArg := httpArg.Value.Children.ForName("body") + if bodyArg != nil { + bodyTemplate := bodyArg.Raw + body, err := substitueVarsInBody(bodyTemplate, vars) + if err != nil { + return rc, err + } + rc.Body = string(body) + } + forwardHeaders := httpArg.Value.Children.ForName("forwardHeaders") + if forwardHeaders != nil { + headers := http.Header{} + for _, h := range forwardHeaders.Children { + headers.Add(h.Value.Raw, q.op.header.Get(h.Value.Raw)) + } + rc.ForwardHeaders = headers + } + + return rc, nil +} + func (m *mutation) Name() string { return (*field)(m).Name() } @@ -1192,3 +1260,78 @@ func (t *astType) EnsureNonNulls(obj map[string]interface{}, exclusion string) e } return nil } + +func isName(s string) bool { + for _, r := range s { + switch { + case r >= 'a' && r <= 'z': + continue + case r >= 'A' && r <= 'Z': + continue + default: + return false + } + } + return true +} + +// Given a template for a body with variables defined, this function parses the body, substitutes +// the variables and returns the final JSON. +// for e.g. +// { author: $id, post: { id: $postID }} with variables {"id": "0x3", postID: "0x9"} should return +// { "author" : "0x3", "post": { "id": "0x9" }} +// If the final result is not a valid JSON, then an error is returned. +func substitueVarsInBody(body string, variables map[string]interface{}) ([]byte, error) { + var s scanner.Scanner + s.Init(strings.NewReader(body)) + + result := new(bytes.Buffer) + parsingVariable := false + depth := 0 + for tok := s.Scan(); tok != scanner.EOF; tok = s.Scan() { + text := s.TokenText() + switch { + case text == "{": + result.WriteString(text) + depth++ + case text == "}": + depth-- + result.WriteString(text) + case text == ":" || text == "," || text == "[" || text == "]": + result.WriteString(text) + case text == "$": + parsingVariable = true + case isName(text): + // Name could either be a key or be part of a variable after dollar. + if parsingVariable { + variable := "$" + text + // Look it up in the map and replace. + val, ok := variables[text] + if !ok { + return nil, errors.Errorf("couldn't find variable: %s in variables map", + variable) + } + switch v := val.(type) { + case string: + fmt.Fprintf(result, `"%s"`, v) + default: + fmt.Fprintf(result, "%v", val) + } + parsingVariable = false + continue + } + result.WriteString(fmt.Sprintf(`"%s"`, text)) + default: + return nil, errors.Errorf("invalid character: %s while parsing body template", text) + } + } + if depth != 0 { + return nil, errors.New("found unmatched curly braces while parsing body template") + } + + m := make(map[string]interface{}) + if err := json.Unmarshal(result.Bytes(), &m); err != nil { + return nil, errors.Errorf("couldn't unmarshal HTTP body: %s as JSON", result.Bytes()) + } + return result.Bytes(), nil +} diff --git a/graphql/schema/wrappers_test.go b/graphql/schema/wrappers_test.go index c63dab93edf..6ae789da38f 100644 --- a/graphql/schema/wrappers_test.go +++ b/graphql/schema/wrappers_test.go @@ -272,7 +272,7 @@ func TestDgraphMapping_WithDirectives(t *testing.T) { func TestCheckNonNulls(t *testing.T) { gqlSchema, err := FromString(` - type T { + type T { req: String! notReq: String alsoReq: String! @@ -323,3 +323,72 @@ func TestCheckNonNulls(t *testing.T) { }) } } + +func TestParseCustomBody(t *testing.T) { + tcases := []struct { + name string + variables map[string]interface{} + template string + expected string + expectedErr error + }{ + { + "substitutes variables correctly", + map[string]interface{}{"id": "0x3", "postID": "0x9"}, + `{ author: $id, post: { id: $postID }}`, + `{ "author": "0x3", "post": { "id": "0x9" }}`, + nil, + }, + { + "substitutes variables with multiple properties correctly", + map[string]interface{}{"id": "0x3", "admin": false, "postID": "0x9", + "text": "Random comment", "age": 28}, + `{ author: $id, admin: $admin, post: { id: $postID, comments: [{ text: $text }] }, + age: $age}`, + `{ "author": "0x3", "admin": false, "post": { "id": "0x9", + "comments": [{ "text": "Random comment"}]}, "age": 28}`, + nil, + }, + { + "variable not found error", + map[string]interface{}{"postID": "0x9"}, + `{ author: $id, post: { id: $postID }}`, + `{ "author": "0x3", "post": { "id": "0x9" }}`, + errors.New("couldn't find variable: $id in variables map"), + }, + { + "json unmarshal error", + map[string]interface{}{"id": "0x3", "postID": "0x9"}, + `{ author: $id, post: { id $postID }}`, + `{ "author": "0x3", "post": { "id": "0x9" }}`, + errors.New("couldn't unmarshal HTTP body: {\"author\":\"0x3\",\"post\":{\"id\"\"0x9\"}}" + + " as JSON"), + }, + { + "unmatched brackets error", + map[string]interface{}{"id": "0x3", "postID": "0x9"}, + `{{ author: $id, post: { id: $postID }}`, + `{ "author": "0x3", "post": { "id": "0x9" }}`, + errors.New("found unmatched curly braces while parsing body template"), + }, + { + "invalid character error", + map[string]interface{}{"id": "0x3", "postID": "0x9"}, + `(author: $id, post: { id: $postID }}`, + `{ "author": "0x3", "post": { "id": "0x9" }}`, + errors.New("invalid character: ( while parsing body template"), + }, + } + + for _, test := range tcases { + t.Run(test.name, func(t *testing.T) { + b, err := substitueVarsInBody(test.template, test.variables) + if test.expectedErr == nil { + require.NoError(t, err) + require.JSONEq(t, test.expected, string(b)) + } else { + require.EqualError(t, err, test.expectedErr.Error()) + } + }) + } +} diff --git a/graphql/web/http.go b/graphql/web/http.go index c7631409ed3..4f0f567e690 100644 --- a/graphql/web/http.go +++ b/graphql/web/http.go @@ -130,6 +130,7 @@ func (gh *graphqlHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if err != nil { res = schema.ErrorResponse(err) } else { + gqlReq.Header = r.Header res = gh.resolver.Resolve(ctx, gqlReq) }