diff --git a/internal/graphql/lint/federation.go b/internal/graphql/lint/federation.go index cdfcaa1e..45860298 100644 --- a/internal/graphql/lint/federation.go +++ b/internal/graphql/lint/federation.go @@ -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 @@ -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, diff --git a/internal/graphql/lint/federation_test.go b/internal/graphql/lint/federation_test.go index c68b5cae..114caa32 100644 --- a/internal/graphql/lint/federation_test.go +++ b/internal/graphql/lint/federation_test.go @@ -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