Skip to content

Commit

Permalink
Merge pull request #116 from gmac/customizable-graph-id
Browse files Browse the repository at this point in the history
Allow customization of the graph "id" field
  • Loading branch information
pkqk authored Nov 28, 2021
2 parents 6d3f91c + 0f4b4d3 commit c69e57f
Show file tree
Hide file tree
Showing 11 changed files with 81 additions and 41 deletions.
5 changes: 5 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ type PluginConfig struct {

// Config contains the gateway configuration
type Config struct {
IdFieldName string `json:"id-field-name"`
GatewayListenAddress string `json:"gateway-address"`
MetricsListenAddress string `json:"metrics-address"`
PrivateListenAddress string `json:"private-address"`
Expand Down Expand Up @@ -93,6 +94,10 @@ func (c *Config) Load() error {
}
c.Plugins = plugins

if strings.TrimSpace(c.IdFieldName) != "" {
IdFieldName = c.IdFieldName
}

logLevel := os.Getenv("BRAMBLE_LOG_LEVEL")
if level, err := log.ParseLevel(logLevel); err == nil {
c.LogLevel = level
Expand Down
19 changes: 5 additions & 14 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,21 +55,12 @@ go test ./...

## Comparison with other projects

- [Apollo Server](https://www.apollographql.com/)
Bramble provides a common-sense approach to GraphQL federation implemented in Golang. It assumes that subgraph fields are mutually exclusive, and that all boundary types join on a universal key. Compared with other projects:

While Apollo Server is a popular tool we felt is was not the right tool for us as:
- [Apollo Federation](https://www.apollographql.com/) and [Golang port](https://github.com/jensneuse/graphql-go-tools): while quite popular, we felt the Apollo spec was more complex than necessary with its nuanced GraphQL SDL and specialized `_entities` query, and thus not the right fit for us.

- the federation specification is more complex than necessary
- it is written in NodeJS where we favour Go
- [GraphQL Tools Stitching](https://www.graphql-tools.com/docs/schema-stitching/stitch-combining-schemas): while Stitching is similar in design to Bramble with self-contained subgraphs joined by basic queries, it offers more features than necessary at the cost of some performance overhead. It is also written in JavaScript where as we favour Golang.

- [Nautilus](https://github.com/nautilus/gateway)
- [Nautilus](https://github.com/nautilus/gateway): provided a lot of inspiration for Bramble, and has been improved upon with bug fixes and additional features (fine-grained permissions, namespaces, better plugins, configuration hot-reloading). Bramble is a recommended successor.

Nautilus provided a lot of inspiration for Bramble.

Although the approach to federation was initially similar, Bramble now uses
a different approach and supports for a few more things:
fine-grained permissions, namespaces, easy plugin configuration,
configuration hot-reloading...

Bramble is also a central piece of software for [Movio](https://movio.co)
products and thus is actively maintained and developed.
Bramble is a central piece of software for [Movio](https://movio.co) products and thus is actively maintained and developed.
3 changes: 3 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Sample configuration:
"poll-interval": "5s",
"max-requests-per-query": 50,
"max-client-response-size": 1048576,
"id-field-name": "id",
"plugins": [
{
"name": "admin-ui"
Expand Down Expand Up @@ -76,6 +77,8 @@ Sample configuration:
- Default: 1MB
- Supports hot-reload: No

- `id-field-name`: Optional customisation of the field name used to cross-reference boundary types. Defaults to `id`.

- `plugins`: Optional list of plugins to enable. See [plugins](plugins.md) for plugins-specific config.

- Supports hot-reload: Partial. `Configure` method of previously enabled plugins will get called with new configuration.
Expand Down
10 changes: 5 additions & 5 deletions docs/federation.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,8 +181,8 @@ Object definitions that have the `@boundary` directive and that have the same na
1. its description contains both `A` and `B`'s descriptions, separated with a blank line
1. it has the `@boundary` directive and only that directive
1. it implements all of `A` and `B`'s interfaces
1. it has an `id: ID!` field
1. it has all of `A` and `B`'s fields, none of which may overlap (except for `id: ID!`)
1. it has an `id` field of type `ID!`, the name of which [may be customised](/configuration)
1. it has all of `A` and `B`'s fields, none of which may overlap (except for the `id` field)
1. its copied fields from `A` and `B` are not modified (type, arguments, description, etc.)

### Namespace Objects
Expand All @@ -204,8 +204,8 @@ The resulting object definition `M` from the merge of the object definitions `A`

Bramble's field resolution semantics is quite easy to define, thanks to its simple design. From the section above you can see that the following is true:

> **With the exception of namespaces and the `id` field in objects with the `@boundary` directive, every field in the merged schema is defined in exactly one federated service.**
> **With the exception of namespaces and the `id` field of boundary objects, every field in the merged schema is defined in exactly one federated service.**

As a consequence of the statement above, with the exception of the `id` field in objects with the `@boundary` directive, every field in the merged schema has exactly one resolver. Therefore, with the exception of the `id` fields in objects with the `@boundary` directive, the semantics of resolving fields in the merged schema is identical to that of a normal GraphQL schema. The resolvers are distributed among different services, but that is an implementation concern, that does not affect the resolution semantics. Of course, this semantics definition doesn't explain _how_ Bramble executes operations and is able to invoke remote resolvers; this is covered in the _"Algorithm Definitions"_ section.
Because all fields in the graph are mutually exclusive (with the exception of boundary `id` fields which are mutually consistent), every field in the merged schema has exactly one resolver. Therefore, the semantics of resolving fields among merged schemas follows normal GraphQL patterns. Field resolvers are simply distributed among services, and the gateway handles routing field requests to their appropraite resolver locations.

Finally, we need to define the resolution semantics of `id` fields in objects with the `@boundary` directive. First note that any service that defines the `@boundary` directive, must have a resolver for the `id` field. Also, in any query document, all such `id` fields will have a _parent field_ (i.e. it cannot be a root field). As observed before, that parent field's resolver is located in exactly one service, and that service must necessarily define the `@boundary` directive. The resolution semantics of the `id` fields in objects with the `@boundary` directive is the resolution semantics of the resolver for that `id` field in that service.
All boundary object types across services must resolve an `id` field (or an [alternate key field name](/configuration) used across the graph). The resolved values of these key fields must be consistent across services, and will be used to cross-reference portions of a merged object.
4 changes: 2 additions & 2 deletions merge.go
Original file line number Diff line number Diff line change
Expand Up @@ -409,13 +409,13 @@ func isNodeField(f *ast.FieldDefinition) bool {
return false
}
arg := f.Arguments[0]
return arg.Name == idFieldName &&
return arg.Name == IdFieldName &&
isIDType(arg.Type) &&
isNullableTypeNamed(f.Type, nodeInterfaceName)
}

func isIDField(f *ast.FieldDefinition) bool {
return f.Name == idFieldName && len(f.Arguments) == 0 && isIDType(f.Type)
return f.Name == IdFieldName && len(f.Arguments) == 0 && isIDType(f.Type)
}

func isServiceField(f *ast.FieldDefinition) bool {
Expand Down
40 changes: 40 additions & 0 deletions merge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -883,3 +883,43 @@ func TestMergeRemovesCustomDirectives(t *testing.T) {
}
fixture.CheckSuccess(t)
}

func TestMergeWithAlternateId(t *testing.T) {
IdFieldName = "gid"
fixture := MergeTestFixture{
Input1: `
directive @boundary on OBJECT | FIELD_DEFINITION
type Dog @boundary {
gid: ID!
name: String
}
type Query {
dog(gid: ID!): Dog @boundary
doggie: Dog
}
`,
Input2: `
directive @boundary on OBJECT | FIELD_DEFINITION
type Dog @boundary {
gid: ID!
color: String
}
type Query {
dogs(gids: [ID!]!): [Dog]! @boundary
}
`,
Expected: `
directive @boundary on OBJECT | FIELD_DEFINITION
type Dog @boundary {
gid: ID!
color: String
name: String
}
type Query {
doggie: Dog
}
`,
}
fixture.CheckSuccess(t)
IdFieldName = "id" // reset!
}
12 changes: 6 additions & 6 deletions plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ func createSteps(ctx *PlanningContext, insertionPoint []string, parentType strin

var reservedAliases = map[string]string{
"_bramble__typename": "__typename",
"_bramble_id": "id",
"_bramble_id": IdFieldName,
}

func extractSelectionSet(ctx *PlanningContext, insertionPoint []string, parentType string, input ast.SelectionSet, location string) (ast.SelectionSet, []*QueryPlanStep, error) {
Expand All @@ -133,7 +133,7 @@ func extractSelectionSet(ctx *PlanningContext, insertionPoint []string, parentTy
return nil, nil, gqlerror.Errorf("%s.%s: alias \"%s\" is reserved for system use", strings.Join(insertionPoint, "."), reservedAlias, reservedAlias)
}
}
if parentType != queryObjectName && parentType != mutationObjectName && ctx.IsBoundary[parentType] && selection.Name == "id" {
if parentType != queryObjectName && parentType != mutationObjectName && ctx.IsBoundary[parentType] && selection.Name == IdFieldName {
selectionSetResult = append(selectionSetResult, selection)
continue
}
Expand Down Expand Up @@ -242,10 +242,10 @@ func extractSelectionSet(ctx *PlanningContext, insertionPoint []string, parentTy
}
implementationType := ctx.Schema.Types[implementationName]

if idDef := implementationType.Fields.ForName("id"); idDef != nil {
if idDef := implementationType.Fields.ForName(IdFieldName); idDef != nil {
possibleId := &ast.InlineFragment{
TypeCondition: implementationName,
SelectionSet: []ast.Selection{&ast.Field{Alias: "_bramble_id", Name: "id", Definition: idDef}},
SelectionSet: []ast.Selection{&ast.Field{Alias: "_bramble_id", Name: IdFieldName, Definition: idDef}},
ObjectDefinition: implementationType,
}
selectionSetResult = append(selectionSetResult, possibleId)
Expand All @@ -260,8 +260,8 @@ func extractSelectionSet(ctx *PlanningContext, insertionPoint []string, parentTy
})
} else if parentType != queryObjectName && parentType != mutationObjectName && ctx.IsBoundary[parentType] {
// Otherwise, add an id selection to all boundary types
if idDef := parentDef.Fields.ForName("id"); idDef != nil {
selectionSetResult = append(selectionSetResult, &ast.Field{Alias: "_bramble_id", Name: "id", Definition: idDef})
if idDef := parentDef.Fields.ForName(IdFieldName); idDef != nil {
selectionSetResult = append(selectionSetResult, &ast.Field{Alias: "_bramble_id", Name: IdFieldName, Definition: idDef})
}
}
return selectionSetResult, childrenStepsResult, nil
Expand Down
2 changes: 1 addition & 1 deletion plugins/meta.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ func (f brambleFields) Len() int {
}

func (f brambleFields) Less(i, j int) bool {
if f[i].Name == "id" {
if f[i].Name == bramble.IdFieldName {
return true
}
return f[i].Name < f[j].Name
Expand Down
3 changes: 2 additions & 1 deletion schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ import (
"github.com/vektah/gqlparser/v2/ast"
)

var IdFieldName = "id"

const (
idFieldName = "id"
nodeRootFieldName = "node"
nodeInterfaceName = "Node"
serviceObjectName = "Service"
Expand Down
20 changes: 10 additions & 10 deletions validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ func validateNodeQuery(schema *ast.Schema) error {
return fmt.Errorf("the 'node' field of Query must take a single argument")
}
arg := f.Arguments[0]
if arg.Name != idFieldName {
if arg.Name != IdFieldName {
return fmt.Errorf("the 'node' field of Query must take a single argument called 'id'")
}
if !isIDType(arg.Type) {
Expand All @@ -176,7 +176,7 @@ func validateNodeInterface(schema *ast.Schema) error {
return fmt.Errorf("the Node interface should have exactly one field")
}
field := t.Fields[0]
if field.Name != idFieldName {
if field.Name != IdFieldName {
return fmt.Errorf("the Node interface should have a field called 'id'")
}
if !isIDType(field.Type) {
Expand Down Expand Up @@ -382,13 +382,13 @@ func validateBoundaryObjectsFormat(schema *ast.Schema) error {
continue
}

idField := t.Fields.ForName(idFieldName)
idField := t.Fields.ForName(IdFieldName)
if idField == nil {
return fmt.Errorf(`missing "id: ID!" field in boundary type %q`, t.Name)
return fmt.Errorf(`missing "%s: ID!" field in boundary type %q`, IdFieldName, t.Name)
}

if idField.Type.String() != "ID!" {
return fmt.Errorf(`id field should have type "ID!" in boundary type %q`, t.Name)
return fmt.Errorf(`%q field should have type "ID!" in boundary type %q`, IdFieldName, t.Name)
}
}

Expand All @@ -409,13 +409,13 @@ func validateBoundaryQueries(schema *ast.Schema) error {

func validateBoundaryQuery(f *ast.FieldDefinition) error {
if len(f.Arguments) != 1 {
return fmt.Errorf(`boundary query must have a single "id: ID!" argument`)
return fmt.Errorf(`boundary query must have exactly one argument`)
}

if f.Arguments[0].Type.Elem != nil {
// array type check
if idsField := f.Arguments.ForName("ids"); idsField == nil || idsField.Type.String() != "[ID!]!" {
return fmt.Errorf(`boundary query must have a single "id: ID!" or list "ids: [ID!]!" argument`)
if f.Arguments[0].Type.String() != "[ID!]!" {
return fmt.Errorf(`boundary list query must accept an argument of type "[ID!]!"`)
}

if !f.Type.NonNull || f.Type.Elem == nil {
Expand All @@ -426,8 +426,8 @@ func validateBoundaryQuery(f *ast.FieldDefinition) error {
}

// regular type check
if idField := f.Arguments.ForName(idFieldName); idField == nil || idField.Type.String() != "ID!" {
return fmt.Errorf(`boundary query must have a single "id: ID!" argument`)
if f.Arguments[0].Type.String() != "ID!" {
return fmt.Errorf(`boundary query must accept an argument of type "ID!"`)
}

if f.Type.NonNull {
Expand Down
4 changes: 2 additions & 2 deletions validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -636,7 +636,7 @@ func TestSchemaValidateBoundaryFields(t *testing.T) {
type Query {
foo(ids: [ID!]): [Foo!] @boundary
}
`).assertInvalid(`invalid boundary query "foo": boundary query must have a single "id: ID!" or list "ids: [ID!]!" argument`, validateBoundaryQueries)
`).assertInvalid(`invalid boundary query "foo": boundary list query must accept an argument of type "[ID!]!"`, validateBoundaryQueries)
})

t.Run("non-nullable boundary query result", func(t *testing.T) {
Expand Down Expand Up @@ -672,7 +672,7 @@ func TestSchemaValidateBoundaryFields(t *testing.T) {
`).assertInvalid(`declared duplicate query for boundary type "Foo"`, validateBoundaryFields)
})

t.Run("requires at least one arguments", func(t *testing.T) {
t.Run("requires at least one argument", func(t *testing.T) {
withSchema(t, `
directive @boundary on OBJECT | FIELD_DEFINITION
Expand Down

0 comments on commit c69e57f

Please sign in to comment.