Skip to content

Commit

Permalink
graphql: Support queries from custom HTTP endpoints. (#5004)
Browse files Browse the repository at this point in the history
This PR supports types with @not_dgraph directive and allows us to specify the @Custom directive for queries.

For types with @not_dgraph directives, queries, mutations and other types are not generated. Queries with @Custom directive, are sent to the remote endpoint and the result returned is validated against the specified schema.
  • Loading branch information
pawanrawal authored Mar 24, 2020
1 parent da208cd commit 34790b1
Show file tree
Hide file tree
Showing 45 changed files with 1,433 additions and 53 deletions.
11 changes: 7 additions & 4 deletions graphql/admin/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
193 changes: 193 additions & 0 deletions graphql/resolve/custom_query_test.yaml
Original file line number Diff line number Diff line change
@@ -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": []
}
]
}
89 changes: 89 additions & 0 deletions graphql/resolve/query_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
})
}
}
61 changes: 61 additions & 0 deletions graphql/resolve/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
}
Loading

0 comments on commit 34790b1

Please sign in to comment.