Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Adds Federation 2 Support #2115

Merged
merged 9 commits into from
Apr 25, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you want to add a version: 2 key here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can implicitly pull that from the schema, hence why I left it out; might be worthwhile, however.

```

## 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