Skip to content

Commit 1e6af7e

Browse files
feat(GraphQL): Extend Support For Apollo Federation (#7275)
This PR extends support for the `Apollo Federation`. ## Support for Apollo federation directives Our current implementation allows support for 3 directives, namely @key, @extends and @external. ### @key directive. This directive is used on any type and it takes one field argument inside it which is called @key field. There are some limitations on how to use @key directives. * User can define @key directive only once for a type, Support for multiple key types is not provided yet. * Since the @key field act as a foreign key to resolve entities from the service where it is extended, the field provided as an argument inside @key directive should be of `ID` type or having `@id` directive on it. For example:- ``` type User @key(fields: "id") { id: ID! name: String } ``` ### @extends directive. @extends directive is provided to give support for extended definitions. Suppose the above defined `User` type is defined in some service. Users can extend it to our GraphQL service by using this keyword. ``` type User @key(fields: "id") @extends{ id: ID! @external products: [Product] } ``` ### @external directive. @external directive means that the given field is not stored on this service. It is stored in some other service. This keyword can only be used on extended type definitions. Like it is used above on the `id` field. ## Generated Queries and mutations In this section, we will mention what all the queries and mutations will be available to individual service and to the apollo `gateway`. We will take the given schema as our example:- ``` type Mission @key(fields: "id") { id: ID! crew: [Astronaut] designation: String! startDate: String endDate: String } type Astronaut @key(fields: "id") @extends { id: ID! @external missions: [Mission] } ``` The queries and mutations which are exposed to the gateway are:- ``` type Query { getMission(id: ID!): Mission queryMission(filter: MissionFilter, order: MissionOrder, first: Int, offset: Int): [Mission] aggregateMission(filter: MissionFilter): MissionAggregateResult } type Mutation { addMission(input: [AddMissionInput!]!): AddMissionPayload updateMission(input: UpdateMissionInput!): UpdateMissionPayload deleteMission(filter: MissionFilter!): DeleteMissionPayload addAstronaut(input: [AddAstronautInput!]!): AddAstronautPayload updateAstronaut(input: UpdateAstronautInput!): UpdateAstronautPayload deleteAstronaut(filter: AstronautFilter!): DeleteAstronautPayload } ``` The queries for `Astronaut` are not exposed to the gateway since it will be resolved through the `_entities` resolver. Although these queries will be available on the Dgraph GraphQL endpoint. # Mutation for `extended` types if we want to add an object of Astronaut type which is @Extended in this service. The mutation `addAstronaut` takes `AddAstronautInput` which is generated as:- ``` input AddAstronautInput { id: ID! missions: [MissionRef] } ``` Even though the `id` field is of `ID` type which should be ideally be generated internally by Dgraph, In this case, it should be provided as input. The reason for this is because of the unavailability of `federated mutations`, the user should provide the value of `id` same as the value present in the GraphQL service where the type `Astronaut` is defined. For example, let's take that the type Astronaut is defined in some other service `AstronautService` as:- ``` type Astronaut @key(fields: "id") { id: ID! name: String! } ``` When adding an Object of `Astronaut` type, first it should be added into `AstronautService` and then the `addAstronaut` mutation should be called and value of `id` provided as an argument must be equal to the value in `AstronautService`. # Gateway Supported Directives. Due to the bug in the federation library (see [here](apollographql/federation#346)), some directives are removed from the schema `SDL` which is returned to the gateway in response to the `_service` query. Those directives are `@custom`, `@generate`, and `@auth`. You can still use these directives in your GraphQL schema and they will work as desired but the gateway will unaware of this.
1 parent 023f42e commit 1e6af7e

40 files changed

+4858
-80
lines changed

graphql/admin/admin.go

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -379,7 +379,7 @@ var (
379379
)
380380

381381
func SchemaValidate(sch string) error {
382-
schHandler, err := schema.NewHandler(sch, true)
382+
schHandler, err := schema.NewHandler(sch, true, false)
383383
if err != nil {
384384
return err
385385
}
@@ -639,7 +639,7 @@ func getCurrentGraphQLSchema() (*gqlSchema, error) {
639639
}
640640

641641
func generateGQLSchema(sch *gqlSchema) (schema.Schema, error) {
642-
schHandler, err := schema.NewHandler(sch.Schema, false)
642+
schHandler, err := schema.NewHandler(sch.Schema, false, false)
643643
if err != nil {
644644
return nil, err
645645
}
@@ -837,6 +837,7 @@ func resolverFactoryWithErrorMsg(msg string) resolve.ResolverFactory {
837837
return resolve.NewResolverFactory(qErr, mErr)
838838
}
839839

840+
// Todo(Minhaj): Fetch NewHandler for service query only once
840841
func (as *adminServer) resetSchema(gqlSchema schema.Schema) {
841842
// set status as updating schema
842843
mainHealthStore.updatingSchema()
@@ -850,6 +851,26 @@ func (as *adminServer) resetSchema(gqlSchema schema.Schema) {
850851
} else {
851852
resolverFactory = resolverFactoryWithErrorMsg(errResolverNotFound).
852853
WithConventionResolvers(gqlSchema, as.fns)
854+
// If the schema is a Federated Schema then attach "_service" resolver
855+
if gqlSchema.IsFederated() {
856+
resolverFactory.WithQueryResolver("_service", func(s schema.Query) resolve.QueryResolver {
857+
return resolve.QueryResolverFunc(func(ctx context.Context, query schema.Query) *resolve.Resolved {
858+
as.mux.RLock()
859+
defer as.mux.RUnlock()
860+
sch := as.schema.Schema
861+
handler, err := schema.NewHandler(sch, false, true)
862+
if err != nil {
863+
return resolve.EmptyResult(query, err)
864+
}
865+
data := handler.GQLSchemaWithoutApolloExtras()
866+
return &resolve.Resolved{
867+
Data: map[string]interface{}{"_service": map[string]interface{}{"sdl": data}},
868+
Field: query,
869+
}
870+
})
871+
})
872+
}
873+
853874
if as.withIntrospection {
854875
resolverFactory.WithSchemaIntrospection()
855876
}

graphql/admin/schema.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ func (usr *updateSchemaResolver) Resolve(ctx context.Context, m schema.Mutation)
5757

5858
// We just need to validate the schema. Schema is later set in `resetSchema()` when the schema
5959
// is returned from badger.
60-
schHandler, err := schema.NewHandler(input.Set.Schema, true)
60+
schHandler, err := schema.NewHandler(input.Set.Schema, true, false)
6161
if err != nil {
6262
return resolve.EmptyResult(m, err), false
6363
}

graphql/dgraph/graphquery.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,8 @@ func writeUIDFunc(b *strings.Builder, uids []uint64, args []gql.Arg) {
137137
// writeRoot writes the root function as well as any ordering and paging
138138
// specified in q.
139139
//
140-
// Only uid(0x123, 0x124) and type(...) functions are supported at root.
140+
// Only uid(0x123, 0x124), type(...) and eq(Type.Predicate, ...) functions are supported at root.
141+
// Multiple arguments for `eq` filter will be required in case of resolving `entities` query.
141142
func writeRoot(b *strings.Builder, q *gql.GraphQuery) {
142143
if q.Func == nil {
143144
return
@@ -149,9 +150,10 @@ func writeRoot(b *strings.Builder, q *gql.GraphQuery) {
149150
writeUIDFunc(b, q.Func.UID, q.Func.Args)
150151
case q.Func.Name == "type" && len(q.Func.Args) == 1:
151152
x.Check2(b.WriteString(fmt.Sprintf("(func: type(%s)", q.Func.Args[0].Value)))
152-
case q.Func.Name == "eq" && len(q.Func.Args) == 2:
153-
x.Check2(b.WriteString(fmt.Sprintf("(func: eq(%s, %s)", q.Func.Args[0].Value,
154-
q.Func.Args[1].Value)))
153+
case q.Func.Name == "eq":
154+
x.Check2(b.WriteString("(func: eq("))
155+
writeFilterArguments(b, q.Func.Args)
156+
x.Check2(b.WriteRune(')'))
155157
}
156158
writeOrderAndPage(b, q, true)
157159
x.Check2(b.WriteRune(')'))

graphql/e2e/auth/schema.graphql

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -775,3 +775,31 @@ type Book @auth(
775775
name: String!
776776
desc: String!
777777
}
778+
779+
780+
type Mission @key(fields: "id") @auth(
781+
query:{ rule: """
782+
query($USER: String!) {
783+
queryMission(filter: { supervisorName: {eq: $USER} } ) {
784+
id
785+
}
786+
}""" }
787+
){
788+
id: String! @id
789+
crew: [Astronaut]
790+
supervisorName: String @search(by: [exact])
791+
designation: String!
792+
startDate: String
793+
endDate: String
794+
}
795+
796+
type Astronaut @key(fields: "id") @extends @auth(
797+
query: { rule: "{$ROLE: { eq: \"admin\" } }"},
798+
add: { rule: "{$USER: { eq: \"foo\" } }"},
799+
delete: { rule: "{$USER: { eq: \"foo\" } }"},
800+
update: { rule: "{$USER: { eq: \"foo\" } }"}
801+
){
802+
id: ID! @external
803+
missions: [Mission]
804+
}
805+

graphql/e2e/common/common.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,11 @@ type country struct {
137137
States []*state `json:"states,omitempty"`
138138
}
139139

140+
type mission struct {
141+
ID string `json:"id,omitempty"`
142+
Designation string `json:"designation,omitempty"`
143+
}
144+
140145
type author struct {
141146
ID string `json:"id,omitempty"`
142147
Name string `json:"name,omitempty"`
@@ -630,6 +635,7 @@ func RunAll(t *testing.T) {
630635
t.Run("query only typename", queryOnlyTypename)
631636
t.Run("query nested only typename", querynestedOnlyTypename)
632637
t.Run("test onlytypename for interface types", onlytypenameForInterface)
638+
t.Run("entitites Query on extended type", entitiesQuery)
633639

634640
t.Run("get state by xid", getStateByXid)
635641
t.Run("get state without args", getStateWithoutArgs)
@@ -713,6 +719,8 @@ func RunAll(t *testing.T) {
713719
t.Run("mutation id directive with int", idDirectiveWithIntMutation)
714720
t.Run("mutation id directive with int64", idDirectiveWithInt64Mutation)
715721
t.Run("mutation id directive with float", idDirectiveWithFloatMutation)
722+
t.Run("add mutation on extended type with field of ID type as key field", addMutationOnExtendedTypeWithIDasKeyField)
723+
t.Run("add mutation with deep extended type objects", addMutationWithDeepExtendedTypeObjects)
716724

717725
// error tests
718726
t.Run("graphql completion on", graphQLCompletionOn)

graphql/e2e/common/mutation.go

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4674,3 +4674,149 @@ func idDirectiveWithFloatMutation(t *testing.T) {
46744674

46754675
DeleteGqlType(t, "Section", map[string]interface{}{}, 4, nil)
46764676
}
4677+
4678+
func addMutationWithDeepExtendedTypeObjects(t *testing.T) {
4679+
varMap1 := map[string]interface{}{
4680+
"missionId": "Mission1",
4681+
"astronautId": "Astronaut1",
4682+
"des": "Apollo1",
4683+
}
4684+
addMissionParams := &GraphQLParams{
4685+
Query: `mutation addMission($missionId: String!, $astronautId: ID!, $des: String!) {
4686+
addMission(input: [{id: $missionId, designation: $des, crew: [{id: $astronautId}]}]) {
4687+
mission{
4688+
id
4689+
crew {
4690+
id
4691+
missions(order: {asc: id}){
4692+
id
4693+
}
4694+
}
4695+
}
4696+
}
4697+
}
4698+
`,
4699+
Variables: varMap1,
4700+
}
4701+
gqlResponse := addMissionParams.ExecuteAsPost(t, GraphqlURL)
4702+
RequireNoGQLErrors(t, gqlResponse)
4703+
4704+
expectedJSON := `{
4705+
"addMission": {
4706+
"mission": [
4707+
{
4708+
"id": "Mission1",
4709+
"crew": [
4710+
{
4711+
"id": "Astronaut1",
4712+
"missions": [
4713+
{
4714+
"id": "Mission1"
4715+
}
4716+
]
4717+
}
4718+
]
4719+
}
4720+
]
4721+
}
4722+
}`
4723+
testutil.CompareJSON(t, expectedJSON, string(gqlResponse.Data))
4724+
4725+
varMap2 := map[string]interface{}{
4726+
"missionId": "Mission2",
4727+
"astronautId": "Astronaut1",
4728+
"des": "Apollo2",
4729+
}
4730+
addMissionParams.Variables = varMap2
4731+
4732+
gqlResponse1 := addMissionParams.ExecuteAsPost(t, GraphqlURL)
4733+
RequireNoGQLErrors(t, gqlResponse)
4734+
4735+
expectedJSON = `{
4736+
"addMission": {
4737+
"mission": [
4738+
{
4739+
"id": "Mission2",
4740+
"crew": [
4741+
{
4742+
"id": "Astronaut1",
4743+
"missions": [
4744+
{
4745+
"id": "Mission1"
4746+
},
4747+
{
4748+
"id": "Mission2"
4749+
}
4750+
]
4751+
}
4752+
]
4753+
}
4754+
]
4755+
}
4756+
}`
4757+
testutil.CompareJSON(t, expectedJSON, string(gqlResponse1.Data))
4758+
4759+
astronautDeleteFilter := map[string]interface{}{"id": []string{"Astronaut1"}}
4760+
DeleteGqlType(t, "Astronaut", astronautDeleteFilter, 1, nil)
4761+
4762+
missionDeleteFilter := map[string]interface{}{"id": map[string]interface{}{"in": []string{"Mission1", "Mission2"}}}
4763+
DeleteGqlType(t, "Mission", missionDeleteFilter, 2, nil)
4764+
}
4765+
4766+
func addMutationOnExtendedTypeWithIDasKeyField(t *testing.T) {
4767+
addAstronautParams := &GraphQLParams{
4768+
Query: `mutation addAstronaut($id1: ID!, $missionId1: String!, $id2: ID!, $missionId2: String! ) {
4769+
addAstronaut(input: [{id: $id1, missions: [{id: $missionId1, designation: "Apollo1"}]}, {id: $id2, missions: [{id: $missionId2, designation: "Apollo2"}]}]) {
4770+
astronaut(order: {asc: id}){
4771+
id
4772+
missions {
4773+
id
4774+
designation
4775+
}
4776+
}
4777+
}
4778+
}`,
4779+
Variables: map[string]interface{}{
4780+
"id1": "Astronaut1",
4781+
"missionId1": "Mission1",
4782+
"id2": "Astronaut2",
4783+
"missionId2": "Mission2",
4784+
},
4785+
}
4786+
4787+
gqlResponse := addAstronautParams.ExecuteAsPost(t, GraphqlURL)
4788+
RequireNoGQLErrors(t, gqlResponse)
4789+
4790+
expectedJSON := `{
4791+
"addAstronaut": {
4792+
"astronaut": [
4793+
{
4794+
"id": "Astronaut1",
4795+
"missions": [
4796+
{
4797+
"id": "Mission1",
4798+
"designation": "Apollo1"
4799+
}
4800+
]
4801+
},
4802+
{
4803+
"id": "Astronaut2",
4804+
"missions": [
4805+
{
4806+
"id": "Mission2",
4807+
"designation": "Apollo2"
4808+
}
4809+
]
4810+
}
4811+
]
4812+
}
4813+
}`
4814+
4815+
testutil.CompareJSON(t, expectedJSON, string(gqlResponse.Data))
4816+
4817+
astronautDeleteFilter := map[string]interface{}{"id": []string{"Astronaut1", "Astronaut2"}}
4818+
DeleteGqlType(t, "Astronaut", astronautDeleteFilter, 2, nil)
4819+
4820+
missionDeleteFilter := map[string]interface{}{"id": map[string]interface{}{"in": []string{"Mission1", "Mission2"}}}
4821+
DeleteGqlType(t, "Mission", missionDeleteFilter, 2, nil)
4822+
}

graphql/e2e/common/query.go

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ package common
1919
import (
2020
"encoding/json"
2121
"fmt"
22-
"github.com/spf13/cast"
2322
"io/ioutil"
2423
"math/rand"
2524
"net/http"
@@ -29,6 +28,8 @@ import (
2928
"testing"
3029
"time"
3130

31+
"github.com/spf13/cast"
32+
3233
"github.com/google/go-cmp/cmp/cmpopts"
3334

3435
"github.com/dgraph-io/dgraph/graphql/schema"
@@ -374,6 +375,71 @@ func allPosts(t *testing.T) []*post {
374375
return result.QueryPost
375376
}
376377

378+
func entitiesQuery(t *testing.T) {
379+
addSpaceShipParams := &GraphQLParams{
380+
Query: `mutation addSpaceShip($id1: String!, $missionId1: String! ) {
381+
addSpaceShip(input: [{id: $id1, missions: [{id: $missionId1, designation: "Apollo1"}]} ]) {
382+
spaceShip {
383+
id
384+
missions {
385+
id
386+
designation
387+
}
388+
}
389+
}
390+
}`,
391+
Variables: map[string]interface{}{
392+
"id1": "SpaceShip1",
393+
"missionId1": "Mission1",
394+
},
395+
}
396+
397+
gqlResponse := addSpaceShipParams.ExecuteAsPost(t, GraphqlURL)
398+
RequireNoGQLErrors(t, gqlResponse)
399+
400+
entitiesQueryParams := &GraphQLParams{
401+
Query: `query _entities($typeName: String!, $id1: String!){
402+
_entities(representations: [{__typename: $typeName, id: $id1}]) {
403+
... on SpaceShip {
404+
missions(order: {asc: id}){
405+
id
406+
designation
407+
}
408+
}
409+
}
410+
}`,
411+
Variables: map[string]interface{}{
412+
"typeName": "SpaceShip",
413+
"id1": "SpaceShip1",
414+
},
415+
}
416+
417+
entitiesResp := entitiesQueryParams.ExecuteAsPost(t, GraphqlURL)
418+
RequireNoGQLErrors(t, entitiesResp)
419+
420+
expectedJSON := `{
421+
"_entities": [
422+
{
423+
"missions": [
424+
{
425+
"id": "Mission1",
426+
"designation": "Apollo1"
427+
}
428+
]
429+
}
430+
]
431+
}`
432+
433+
testutil.CompareJSON(t, expectedJSON, string(entitiesResp.Data))
434+
435+
spaceShipDeleteFilter := map[string]interface{}{"id": map[string]interface{}{"in": []string{"SpaceShip1"}}}
436+
DeleteGqlType(t, "SpaceShip", spaceShipDeleteFilter, 1, nil)
437+
438+
missionDeleteFilter := map[string]interface{}{"id": map[string]interface{}{"in": []string{"Mission1"}}}
439+
DeleteGqlType(t, "Mission", missionDeleteFilter, 1, nil)
440+
441+
}
442+
377443
func inFilterOnString(t *testing.T) {
378444
addStateParams := &GraphQLParams{
379445
Query: `mutation addState($name1: String!, $code1: String!, $name2: String!, $code2: String! ) {

0 commit comments

Comments
 (0)