Skip to content

Commit

Permalink
Feature: Adds Federation 2 Support (#2115)
Browse files Browse the repository at this point in the history
* fed2 rough support

* autodetection of fed2

* adding basic tests for changes

* fixing docs

* Update plugin/federation/federation.go

* removing custom scalar since it was causing issues

* fixing lint test

* should fix for real this time

* fixing test failures

Co-authored-by: lleadbetter <lleadbetter@MacBook-Pro.localdomain>
Co-authored-by: Lucas Leadbetter <lucas@apollographql.com>
Co-authored-by: Steve Coffman <StevenACoffman@users.noreply.github.com>
  • Loading branch information
4 people authored Apr 25, 2022
1 parent 77260e8 commit 2a2a3dc
Show file tree
Hide file tree
Showing 18 changed files with 452 additions and 54 deletions.
17 changes: 9 additions & 8 deletions _examples/federation/accounts/graph/generated/generated.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 9 additions & 8 deletions _examples/federation/products/graph/generated/generated.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 9 additions & 8 deletions _examples/federation/reviews/graph/generated/generated.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 15 additions & 1 deletion api/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package api

import (
"fmt"
"regexp"
"syscall"

"github.com/99designs/gqlgen/codegen"
Expand All @@ -24,7 +25,20 @@ func Generate(cfg *config.Config, option ...Option) error {
}
plugins = append(plugins, resolvergen.New())
if cfg.Federation.IsDefined() {
plugins = append([]plugin.Plugin{federation.New()}, plugins...)
if cfg.Federation.Version == 0 { // default to using the user's choice of version, but if unset, try to sort out which federation version to use
urlRegex := regexp.MustCompile(`(?s)@link.*\(.*url:.*?"(.*?)"[^)]+\)`) // regex to grab the url of a link directive, should it exist

// check the sources, and if one is marked as federation v2, we mark the entirety to be generated using that format
for _, v := range cfg.Sources {
cfg.Federation.Version = 1
urlString := urlRegex.FindStringSubmatch(v.Input)
if urlString != nil && urlString[1] == "https://specs.apollo.dev/federation/v2.0" {
cfg.Federation.Version = 2
break
}
}
}
plugins = append([]plugin.Plugin{federation.New(cfg.Federation.Version)}, plugins...)
}

for _, o := range option {
Expand Down
7 changes: 7 additions & 0 deletions api/generate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ func TestGenerate(t *testing.T) {
},
wantErr: false,
},
{
name: "federation2",
args: args{
workDir: path.Join(wd, "testdata", "federation2"),
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand Down
8 changes: 4 additions & 4 deletions api/option_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,29 +26,29 @@ func (t *testPlugin) MutateConfig(_ *config.Config) error {
func TestReplacePlugin(t *testing.T) {
t.Run("replace plugin if exists", func(t *testing.T) {
pg := []plugin.Plugin{
federation.New(),
federation.New(1),
modelgen.New(),
resolvergen.New(),
}

expectedPlugin := &testPlugin{}
ReplacePlugin(expectedPlugin)(config.DefaultConfig(), &pg)

require.EqualValues(t, federation.New(), pg[0])
require.EqualValues(t, federation.New(1), pg[0])
require.EqualValues(t, expectedPlugin, pg[1])
require.EqualValues(t, resolvergen.New(), pg[2])
})

t.Run("add plugin if doesn't exist", func(t *testing.T) {
pg := []plugin.Plugin{
federation.New(),
federation.New(1),
resolvergen.New(),
}

expectedPlugin := &testPlugin{}
ReplacePlugin(expectedPlugin)(config.DefaultConfig(), &pg)

require.EqualValues(t, federation.New(), pg[0])
require.EqualValues(t, federation.New(1), pg[0])
require.EqualValues(t, resolvergen.New(), pg[1])
require.EqualValues(t, expectedPlugin, pg[2])
})
Expand Down
56 changes: 56 additions & 0 deletions api/testdata/federation2/gqlgen.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Where are all the schema files located? globs are supported eg src/**/*.graphqls
schema:
- graph/*.graphqls

# Where should the generated server code go?
exec:
filename: graph/generated/generated.go
package: generated

# Uncomment to enable federation
federation:
filename: graph/generated/federation.go
package: generated

# Where should any generated models go?
model:
filename: graph/model/models_gen.go
package: model

# Where should the resolver implementations go?
resolver:
layout: follow-schema
dir: graph
package: graph

# Optional: turn on use `gqlgen:"fieldName"` tags in your models
# struct_tag: json

# Optional: turn on to use []Thing instead of []*Thing
# omit_slice_element_pointers: false

# Optional: set to speed up generation time by not performing a final validation pass.
# skip_validation: true

# gqlgen will search for any type names in the schema in these go packages
# if they match it will use them, otherwise it will generate them.
autobind:
- "github.com/99designs/gqlgen/api/testdata/default/graph/model"

# This section declares type mapping between the GraphQL and go type systems
#
# The first line in each type will be used as defaults for resolver arguments and
# modelgen, the others will be allowed when binding to fields. Configure them to
# your liking
models:
ID:
model:
- github.com/99designs/gqlgen/graphql.ID
- github.com/99designs/gqlgen/graphql.Int
- github.com/99designs/gqlgen/graphql.Int64
- github.com/99designs/gqlgen/graphql.Int32
Int:
model:
- github.com/99designs/gqlgen/graphql.Int
- github.com/99designs/gqlgen/graphql.Int64
- github.com/99designs/gqlgen/graphql.Int32
1 change: 1 addition & 0 deletions api/testdata/federation2/graph/model/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
package model
31 changes: 31 additions & 0 deletions api/testdata/federation2/graph/schema.graphqls
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# GraphQL schema example
#
# https://gqlgen.com/getting-started/
extend schema
@link(url: "https://specs.apollo.dev/federation/v2.0",
import: ["@key", "@shareable", "@provides", "@external", "@tag", "@extends", "@override", "@inaccessible"])

type Todo {
id: ID!
text: String!
done: Boolean!
user: User!
}

type User {
id: ID!
name: String!
}

type Query {
todos: [Todo!]!
}

input NewTodo {
text: String!
userId: String!
}

type Mutation {
createTodo(input: NewTodo!): Todo!
}
1 change: 1 addition & 0 deletions codegen/config/package.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
type PackageConfig struct {
Filename string `yaml:"filename,omitempty"`
Package string `yaml:"package,omitempty"`
Version int `yaml:"version,omitempty"`
}

func (c *PackageConfig) ImportPath() string {
Expand Down
10 changes: 10 additions & 0 deletions docs/content/recipes/federation.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@ federation:
package: generated
```
### Federation 2
If you are using Apollo's Federation 2 standard, your schema should automatically be upgraded so long as you include the required `@link` directive within your schema. If you want to force Federation 2 composition, the `federation` configuration supports a `version` flag to override that. For example:

```yml
federation:
filename: graph/generated/federation.go
package: generated
```

## Create the federated servers

For each server to be federated we will create a new gqlgen project.
Expand Down
73 changes: 57 additions & 16 deletions plugin/federation/federation.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,17 @@ import (

type federation struct {
Entities []*Entity
Version int
}

// New returns a federation plugin that injects
// federated directives and types into the schema
func New() plugin.Plugin {
return &federation{}
func New(version int) plugin.Plugin {
if version == 0 {
version = 1
}

return &federation{Version: version}
}

// Name returns the plugin name
Expand Down Expand Up @@ -51,6 +56,7 @@ func (f *federation) MutateConfig(cfg *config.Config) error {
Model: config.StringList{"github.com/99designs/gqlgen/graphql.Map"},
},
}

for typeName, entry := range builtins {
if cfg.Models.Exists(typeName) {
return fmt.Errorf("%v already exists which must be reserved when Federation is enabled", typeName)
Expand All @@ -63,22 +69,46 @@ func (f *federation) MutateConfig(cfg *config.Config) error {
cfg.Directives["key"] = config.DirectiveConfig{SkipRuntime: true}
cfg.Directives["extends"] = config.DirectiveConfig{SkipRuntime: true}

// Federation 2 specific directives
if f.Version == 2 {
cfg.Directives["shareable"] = config.DirectiveConfig{SkipRuntime: true}
cfg.Directives["link"] = config.DirectiveConfig{SkipRuntime: true}
cfg.Directives["tag"] = config.DirectiveConfig{SkipRuntime: true}
cfg.Directives["override"] = config.DirectiveConfig{SkipRuntime: true}
cfg.Directives["inaccessible"] = config.DirectiveConfig{SkipRuntime: true}
}

return nil
}

func (f *federation) InjectSourceEarly() *ast.Source {
input := `
scalar _Any
scalar _FieldSet
directive @external on FIELD_DEFINITION
directive @requires(fields: _FieldSet!) on FIELD_DEFINITION
directive @provides(fields: _FieldSet!) on FIELD_DEFINITION
directive @extends on OBJECT | INTERFACE
`
// add version-specific changes on key directive, as well as adding the new directives for federation 2
if f.Version == 1 {
input += `
directive @key(fields: _FieldSet!) repeatable on OBJECT | INTERFACE
`
} else if f.Version == 2 {
input += `
directive @key(fields: _FieldSet!, resolvable: Boolean) repeatable on OBJECT | INTERFACE
directive @link(import: [String!], url: String!) repeatable on SCHEMA
directive @shareable on OBJECT | FIELD_DEFINITION
directive @tag repeatable on OBJECT | FIELD_DEFINITION | INTERFACE | UNION
directive @override(from: String!) on FIELD_DEFINITION
directive @inaccessible on SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION
`
}
return &ast.Source{
Name: "federation/directives.graphql",
Input: `
scalar _Any
scalar _FieldSet
directive @external on FIELD_DEFINITION
directive @requires(fields: _FieldSet!) on FIELD_DEFINITION
directive @provides(fields: _FieldSet!) on FIELD_DEFINITION
directive @key(fields: _FieldSet!) repeatable on OBJECT | INTERFACE
directive @extends on OBJECT | INTERFACE
`,
Name: "federation/directives.graphql",
Input: input,
BuiltIn: true,
}
}
Expand Down Expand Up @@ -290,10 +320,21 @@ func (f *federation) setEntities(schema *ast.Schema) {
// }
if !e.allFieldsAreExternal() {
for _, dir := range keys {
if len(dir.Arguments) != 1 || dir.Arguments[0].Name != "fields" {
panic("Exactly one `fields` argument needed for @key declaration.")
if len(dir.Arguments) > 2 {
panic("More than two arguments provided for @key declaration.")
}
arg := dir.Arguments[0]
var arg *ast.Argument

// since keys are able to now have multiple arguments, we need to check both possible for a possible @key(fields="" fields="")
for _, a := range dir.Arguments {
if a.Name == "fields" {
if arg != nil {
panic("More than one `fields` provided for @key declaration.")
}
arg = a
}
}

keyFieldSet := fieldset.New(arg.Value.Raw, nil)

keyFields := make([]*KeyField, len(keyFieldSet))
Expand Down
Loading

0 comments on commit 2a2a3dc

Please sign in to comment.