Skip to content
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
27 changes: 14 additions & 13 deletions internal/graphql/lint/federation.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,10 @@
// Directive signatures below come from the Apollo Federation subgraph
// specification (github.com/apollographql/federation,
// docs/source/schema-design/federated-schemas/reference/subgraph-spec.mdx,
// fetched 2026-08-21). Only the 9 directives named in
// fetched 2026-10-22). Only the directives named in
// federationDirectives are supported; an import naming any other
// directive, for example @interfaceObject or @authenticated added in
// later spec versions, is reported by name instead of assuming an
// unverified signature.
// directive, for example @authenticated added in later spec versions,
// is reported by name instead of assuming an unverified signature.

package lint

Expand Down Expand Up @@ -91,15 +90,17 @@ type federationDirective struct {
//
//nolint:gochecknoglobals,lll // Why: a fixed lookup table, never mutated; Go has no map consts, and directive location lists can't be wrapped.
var federationDirectives = map[string]federationDirective{
"key": {sdl: "directive @%[1]s(fields: FieldSet!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE", needsFieldSet: true},
"shareable": {sdl: "directive @%[1]s repeatable on OBJECT | FIELD_DEFINITION"},
"override": {sdl: "directive @%[1]s(from: String!) on FIELD_DEFINITION"},
"inaccessible": {sdl: "directive @%[1]s on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION"},
"external": {sdl: "directive @%[1]s on FIELD_DEFINITION | OBJECT"},
"requires": {sdl: "directive @%[1]s(fields: FieldSet!) on FIELD_DEFINITION", needsFieldSet: true},
"provides": {sdl: "directive @%[1]s(fields: FieldSet!) on FIELD_DEFINITION", needsFieldSet: true},
"tag": {sdl: "directive @%[1]s(name: String!) repeatable on FIELD_DEFINITION | INTERFACE | OBJECT | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION"},
"extends": {sdl: "directive @%[1]s on OBJECT | INTERFACE"},
"key": {sdl: "directive @%[1]s(fields: FieldSet!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE", needsFieldSet: true},
"shareable": {sdl: "directive @%[1]s repeatable on OBJECT | FIELD_DEFINITION"},
"override": {sdl: "directive @%[1]s(from: String!) on FIELD_DEFINITION"},
"inaccessible": {sdl: "directive @%[1]s on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION"},
"external": {sdl: "directive @%[1]s on FIELD_DEFINITION | OBJECT"},
"requires": {sdl: "directive @%[1]s(fields: FieldSet!) on FIELD_DEFINITION", needsFieldSet: true},
"provides": {sdl: "directive @%[1]s(fields: FieldSet!) on FIELD_DEFINITION", needsFieldSet: true},
"tag": {sdl: "directive @%[1]s(name: String!) repeatable on FIELD_DEFINITION | INTERFACE | OBJECT | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION"},
"extends": {sdl: "directive @%[1]s on OBJECT | INTERFACE"},
"interfaceObject": {sdl: "directive @%[1]s on OBJECT"},
"composeDirective": {sdl: "directive @%[1]s(name: String!) repeatable on SCHEMA"},
}

// federationImport is a single entry from a `@link` import list,
Expand Down
88 changes: 88 additions & 0 deletions internal/graphql/lint/federation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,94 @@ func TestFilesFederationImportedDirectivesPass(t *testing.T) {
assert.Equal(t, len(violations), 0)
}

// TestFilesFederationInterfaceObjectAndComposeDirectivePass confirms
// that @interfaceObject and @composeDirective are defined when
// imported. @composeDirective is applied to the schema itself.
func TestFilesFederationInterfaceObjectAndComposeDirectivePass(t *testing.T) {
dir := t.TempDir()
path := writeFile(t, dir, "schema.graphql", `
extend schema
@link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key", "@interfaceObject", "@composeDirective"])
@composeDirective(name: "@custom")

directive @custom on FIELD_DEFINITION

type Widget @key(fields: "id") @interfaceObject {
id: ID!
name: String @custom
}
`)

violations, err := Files([]string{path}, &config.Lint{Federation: "v2.3"})
assert.NilError(t, err)
assert.Equal(t, len(violations), 0)
}

// TestFilesFederationInterfaceObjectAndComposeDirectiveLocations
// confirms @interfaceObject is limited to object types and
// @composeDirective to the schema.
func TestFilesFederationInterfaceObjectAndComposeDirectiveLocations(t *testing.T) {
cases := []struct {
name string
sdl string
wantErr string
}{
{
"interfaceObject on a field",
`type Widget { id: ID! @interfaceObject }`,
"Directive interfaceObject is not applicable on FIELD_DEFINITION.",
},
{
"composeDirective on a type",
`type Widget @composeDirective(name: "@custom") { id: ID! }`,
"Directive composeDirective is not applicable on OBJECT.",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
dir := t.TempDir()
path := writeFile(t, dir, "schema.graphql", `
extend schema @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@interfaceObject", "@composeDirective"])

`+c.sdl)

violations, err := Files([]string{path}, &config.Lint{Federation: "v2.3"})
assert.NilError(t, err)
assert.Equal(t, len(violations), 1)
assert.ErrorContains(t, violations[0].err, c.wantErr)
})
}
}

// TestFilesFederationInterfaceObjectAndComposeDirectiveHonorAs confirms
// both directives can be imported under an alias, and that
// @composeDirective is repeatable on the schema.
func TestFilesFederationInterfaceObjectAndComposeDirectiveHonorAs(t *testing.T) {
dir := t.TempDir()
path := writeFile(t, dir, "schema.graphql", `
extend schema
@link(
url: "https://specs.apollo.dev/federation/v2.3"
import: [{name: "@composeDirective", as: "@compose"}, {name: "@interfaceObject", as: "@iface"}]
)
@compose(name: "@a")
@compose(name: "@b")

directive @a on FIELD_DEFINITION
directive @b on FIELD_DEFINITION

type Widget @iface {
id: ID!
name: String @a
label: String @b
}
`)

violations, err := Files([]string{path}, &config.Lint{Federation: "v2.3"})
assert.NilError(t, err)
assert.Equal(t, len(violations), 0)
}

// TestFilesFederationDirectiveNotImportedFails confirms the real
// point of import-list awareness: a directive that exists in the
// Federation spec but was never imported by this schema's own @link
Expand Down
Loading