diff --git a/.changeset/khaki-terms-call.md b/.changeset/khaki-terms-call.md new file mode 100644 index 0000000000..40fae613e1 --- /dev/null +++ b/.changeset/khaki-terms-call.md @@ -0,0 +1,9 @@ +--- +'@graphql-inspector/commands': minor +'@graphql-inspector/loaders': minor +'@graphql-inspector/core': minor +'@graphql-inspector/cli': minor +--- + +Apollo Federation v2 support. Introduced built-in Federation v2 directives and a new CLI flag +--federationV2 to enable processing schemas that use them. diff --git a/packages/commands/commands/src/index.ts b/packages/commands/commands/src/index.ts index be6dce25c0..f141c1906c 100644 --- a/packages/commands/commands/src/index.ts +++ b/packages/commands/commands/src/index.ts @@ -38,6 +38,7 @@ export interface GlobalArgs { leftHeader?: string[]; rightHeader?: string[]; federation?: boolean; + federationV2?: boolean; aws?: boolean; method?: string; } diff --git a/packages/commands/coverage/src/index.ts b/packages/commands/coverage/src/index.ts index fef400cbfa..a7f1d1fcfc 100644 --- a/packages/commands/coverage/src/index.ts +++ b/packages/commands/coverage/src/index.ts @@ -106,6 +106,7 @@ export default createCommand< const silent = args.silent; const { headers, token } = parseGlobalArgs(args); const apolloFederation = args.federation || false; + const apolloFederationV2 = args.federationV2 || false; const aws = args.aws || false; const method = args.method?.toUpperCase() || 'POST'; @@ -117,6 +118,7 @@ export default createCommand< method, }, apolloFederation, + apolloFederationV2, aws, ); const documents = await loaders.loadDocuments(args.documents); diff --git a/packages/commands/diff/src/index.ts b/packages/commands/diff/src/index.ts index 61e36af53d..6c63f2b2c5 100644 --- a/packages/commands/diff/src/index.ts +++ b/packages/commands/diff/src/index.ts @@ -130,6 +130,7 @@ export default createCommand< const oldSchemaPointer = args.oldSchema; const newSchemaPointer = args.newSchema; const apolloFederation = args.federation || false; + const apolloFederationV2 = args.federationV2 || false; const aws = args.aws || false; const method = args.method?.toUpperCase() || 'POST'; const { headers, leftHeaders, rightHeaders, token } = parseGlobalArgs(args); @@ -151,6 +152,7 @@ export default createCommand< method, }, apolloFederation, + apolloFederationV2, aws, ); const newSchema = await loaders.loadSchema( @@ -161,6 +163,7 @@ export default createCommand< method, }, apolloFederation, + apolloFederationV2, aws, ); diff --git a/packages/commands/introspect/src/index.ts b/packages/commands/introspect/src/index.ts index 5443c385e0..e9bc44ab68 100644 --- a/packages/commands/introspect/src/index.ts +++ b/packages/commands/introspect/src/index.ts @@ -89,6 +89,7 @@ export default createCommand< const output = args.write!; const comments = args.comments || false; const apolloFederation = args.federation || false; + const apolloFederationV2 = args.federationV2 || false; const aws = args.aws || false; const method = args.method?.toUpperCase() || 'POST'; @@ -100,6 +101,7 @@ export default createCommand< method, }, apolloFederation, + apolloFederationV2, aws, ); diff --git a/packages/commands/serve/src/index.ts b/packages/commands/serve/src/index.ts index 93d27b8b60..de6b07b9a0 100644 --- a/packages/commands/serve/src/index.ts +++ b/packages/commands/serve/src/index.ts @@ -43,6 +43,7 @@ export default createCommand< async handler(args) { const { headers, token } = parseGlobalArgs(args); const apolloFederation = args.federation || false; + const apolloFederationV2 = args.federationV2 || false; const aws = args.aws || false; const method = args.method?.toUpperCase() || 'POST'; @@ -54,6 +55,7 @@ export default createCommand< method, }, apolloFederation, + apolloFederationV2, aws, ); diff --git a/packages/commands/similar/src/index.ts b/packages/commands/similar/src/index.ts index 2b7e4d3f7f..1ef9d1b6c7 100644 --- a/packages/commands/similar/src/index.ts +++ b/packages/commands/similar/src/index.ts @@ -117,6 +117,7 @@ export default createCommand< const type = args.name; const threshold = args.threshold; const apolloFederation = args.federation || false; + const apolloFederationV2 = args.federationV2 || false; const aws = args.aws || false; const method = args.method?.toUpperCase() || 'POST'; @@ -128,6 +129,7 @@ export default createCommand< method, }, apolloFederation, + apolloFederationV2, aws, ); diff --git a/packages/commands/validate/src/index.ts b/packages/commands/validate/src/index.ts index 63e61d134a..cd6b6e5e56 100644 --- a/packages/commands/validate/src/index.ts +++ b/packages/commands/validate/src/index.ts @@ -278,6 +278,8 @@ export default createCommand< const apollo = args.apollo || false; const aws = args.aws || false; const apolloFederation = args.federation || false; + const apolloFederationV2 = args.federationV2 || false; + const method = args.method?.toUpperCase() || 'POST'; const maxDepth = args.maxDepth == null ? undefined : args.maxDepth; const maxAliasCount = args.maxAliasCount == null ? undefined : args.maxAliasCount; @@ -311,6 +313,7 @@ export default createCommand< method, }, apolloFederation, + apolloFederationV2, aws, ); const documents = await loaders.loadDocuments(args.documents, { diff --git a/packages/core/__tests__/validate/apollo-federation-v2.test.ts b/packages/core/__tests__/validate/apollo-federation-v2.test.ts new file mode 100644 index 0000000000..150f63c759 --- /dev/null +++ b/packages/core/__tests__/validate/apollo-federation-v2.test.ts @@ -0,0 +1,449 @@ +import { parse, print, Source } from 'graphql'; +import { LoadersRegistry } from '@graphql-inspector/loaders'; +import { validate } from '../../src/index.js'; + +describe('apollo federation v2', () => { + test('should accept basic Federation V2 directives', async () => { + const doc = parse(/* GraphQL */ ` + query getUser { + user(id: "1") { + id + name + email + orders { + id + total + } + } + } + `); + + const schema = await new LoadersRegistry().loadSchema( + /* GraphQL */ ` + type User @key(fields: "id") { + id: ID! + name: String! + email: String! + orders: [Order!]! @external + } + + type Order @key(fields: "id") { + id: ID! + total: Float! + user: User! @external + } + + type Query { + user(id: ID!): User + } + `, + {}, + false, + true, + false, + ); + + const results = validate(schema, [new Source(print(doc))]); + + expect(results).toHaveLength(0); + }); + + test('should accept @shareable directive on fields', async () => { + const doc = parse(/* GraphQL */ ` + query getProduct { + product(id: "1") { + id + name + description + } + } + `); + + const schema = await new LoadersRegistry().loadSchema( + /* GraphQL */ ` + extend schema + @link( + url: "https://specs.apollographql.com/federation/v2.3" + import: ["@key", "@shareable"] + ) + + type Product @key(fields: "id") { + id: ID! + name: String! @shareable + description: String @shareable + } + + type Query { + product(id: ID!): Product + } + `, + {}, + false, + true, + false, + ); + + const results = validate(schema, [new Source(print(doc))]); + + expect(results).toHaveLength(0); + }); + + test('should accept @requires and @provides directives', async () => { + const doc = parse(/* GraphQL */ ` + query getUserWithShippingEstimate { + user(id: "1") { + id + shippingEstimate + cart { + estimatedDelivery + } + } + } + `); + + const schema = await new LoadersRegistry().loadSchema( + /* GraphQL */ ` + extend schema + @link( + url: "https://specs.apollographql.com/federation/v2.3" + import: ["@key", "@external", "@requires", "@provides"] + ) + + type User @key(fields: "id") { + id: ID! + zipCode: String @external + shippingEstimate: String @requires(fields: "zipCode") + cart: Cart @provides(fields: "estimatedDelivery") + } + + type Cart { + estimatedDelivery: String @external + } + + type Query { + user(id: ID!): User + } + `, + {}, + false, + true, + false, + ); + + const results = validate(schema, [new Source(print(doc))]); + + expect(results).toHaveLength(0); + }); + + test('should accept @override directive', async () => { + const doc = parse(/* GraphQL */ ` + query getAccount { + account(id: "1") { + id + balance + currency + } + } + `); + + const schema = await new LoadersRegistry().loadSchema( + /* GraphQL */ ` + extend schema + @link( + url: "https://specs.apollographql.com/federation/v2.3" + import: ["@key", "@override"] + ) + + type Account @key(fields: "id") { + id: ID! + balance: Float! @override(from: "accounts") + currency: String! + } + + type Query { + account(id: ID!): Account + } + `, + {}, + false, + true, + false, + ); + + const results = validate(schema, [new Source(print(doc))]); + + expect(results).toHaveLength(0); + }); + + test('should accept @inaccessible directive', async () => { + const doc = parse(/* GraphQL */ ` + query getUser { + user(id: "1") { + id + name + } + } + `); + + const schema = await new LoadersRegistry().loadSchema( + /* GraphQL */ ` + extend schema + @link( + url: "https://specs.apollographql.com/federation/v2.3" + import: ["@key", "@inaccessible"] + ) + + type User @key(fields: "id") { + id: ID! + name: String! + internalId: String @inaccessible + } + + type Query { + user(id: ID!): User + } + `, + {}, + false, + true, + false, + ); + + const results = validate(schema, [new Source(print(doc))]); + + expect(results).toHaveLength(0); + }); + + test('should accept @tag directive', async () => { + const doc = parse(/* GraphQL */ ` + query getProduct { + product(id: "1") { + id + name + price + } + } + `); + + const schema = await new LoadersRegistry().loadSchema( + /* GraphQL */ ` + extend schema + @link(url: "https://specs.apollographql.com/federation/v2.3", import: ["@key", "@tag"]) + + type Product @key(fields: "id") { + id: ID! + name: String! + price: Float! @tag(name: "pricing") + } + + type Query { + product(id: ID!): Product + } + `, + {}, + false, + true, + false, + ); + + const results = validate(schema, [new Source(print(doc))]); + + expect(results).toHaveLength(0); + }); + + test('should accept @interfaceObject directive', async () => { + const doc = parse(/* GraphQL */ ` + query getEntity { + entity(id: "1") { + id + name + } + } + `); + + const schema = await new LoadersRegistry().loadSchema( + /* GraphQL */ ` + extend schema + @link( + url: "https://specs.apollographql.com/federation/v2.3" + import: ["@key", "@interfaceObject"] + ) + + type Entity @key(fields: "id") @interfaceObject { + id: ID! + name: String! + } + + type Query { + entity(id: ID!): Entity + } + `, + {}, + false, + true, + false, + ); + + const results = validate(schema, [new Source(print(doc))]); + + expect(results).toHaveLength(0); + }); + + test('should accept @composeDirective directive', async () => { + const doc = parse(/* GraphQL */ ` + query getUser { + user(id: "1") { + id + name + profile + } + } + `); + + const schema = await new LoadersRegistry().loadSchema( + /* GraphQL */ ` + extend schema + @link( + url: "https://specs.apollographql.com/federation/v2.3" + import: ["@key", "@composeDirective"] + ) + @composeDirective(name: "@custom") + + directive @custom(value: String) on FIELD_DEFINITION + + type User @key(fields: "id") { + id: ID! + name: String! + profile: String @custom(value: "user-profile") + } + + type Query { + user(id: ID!): User + } + `, + {}, + false, + true, + false, + ); + + const results = validate(schema, [new Source(print(doc))]); + + expect(results).toHaveLength(0); + }); + + test('should accept complex Federation V2 schema with multiple entities', async () => { + const doc = parse(/* GraphQL */ ` + query getRecommendations { + user(id: "1") { + id + name + recommendations { + id + name + price + reviews { + id + rating + comment + } + } + } + } + `); + + const schema = await new LoadersRegistry().loadSchema( + /* GraphQL */ ` + extend schema + @link( + url: "https://specs.apollographql.com/federation/v2.3" + import: ["@key", "@external", "@requires", "@provides", "@shareable", "@tag"] + ) + + type User @key(fields: "id") { + id: ID! + name: String! @shareable + age: Int @external + recommendations: [Product!]! + @requires(fields: "age") + @provides(fields: "reviews { rating }") + } + + type Product @key(fields: "id") { + id: ID! + name: String! @shareable + price: Float! @tag(name: "pricing") + reviews: [Review!]! @external + } + + type Review @key(fields: "id") { + id: ID! + rating: Int @external + comment: String + } + + type Query { + user(id: ID!): User + } + `, + {}, + false, + true, + false, + ); + + const results = validate(schema, [new Source(print(doc))]); + + expect(results).toHaveLength(0); + }); + + test('should handle Federation V2 with custom scalar types', async () => { + const doc = parse(/* GraphQL */ ` + query getEvent { + event(id: "1") { + id + timestamp + location { + coordinates + } + } + } + `); + + const schema = await new LoadersRegistry().loadSchema( + /* GraphQL */ ` + extend schema + @link( + url: "https://specs.apollographql.com/federation/v2.3" + import: ["@key", "@shareable"] + ) + + scalar DateTime + scalar GeoCoordinates + + type Event @key(fields: "id") { + id: ID! + timestamp: DateTime! @shareable + location: Location + } + + type Location @key(fields: "id") { + id: ID! + coordinates: GeoCoordinates! + } + + type Query { + event(id: ID!): Event + } + `, + {}, + false, + true, + false, + ); + + const results = validate(schema, [new Source(print(doc))]); + + expect(results).toHaveLength(0); + }); +}); diff --git a/packages/core/__tests__/validate/aws.test.ts b/packages/core/__tests__/validate/aws.test.ts index 8ece72b220..7bd4d88fae 100644 --- a/packages/core/__tests__/validate/aws.test.ts +++ b/packages/core/__tests__/validate/aws.test.ts @@ -42,6 +42,7 @@ describe('aws', () => { `, {}, false, + false, true, ); diff --git a/packages/loaders/loaders/src/index.ts b/packages/loaders/loaders/src/index.ts index 60208c6cba..e0b8b1ade4 100644 --- a/packages/loaders/loaders/src/index.ts +++ b/packages/loaders/loaders/src/index.ts @@ -1,4 +1,4 @@ -import { buildSchema, GraphQLSchema } from 'graphql'; +import { buildSchema, GraphQLSchema, isInterfaceType, isObjectType, printSchema } from 'graphql'; import { InspectorConfig } from '@graphql-inspector/config'; import { loadDocuments, @@ -26,16 +26,82 @@ export class LoadersRegistry { } } - loadSchema( + async loadSchema( pointer: string, options: Omit = {}, enableApolloFederation: boolean, + enableApolloFederationV2: boolean, enableAWS: boolean, ): Promise { - return enrichError( + const schema = await enrichError( loadSchema(pointer, { loaders: this.loaders, ...options, + ...(enableApolloFederationV2 + ? { + schemas: [ + buildSchema(/* GraphQL */ ` + scalar _Any + union _Entity + scalar FieldSet + scalar link__Import + scalar federation__ContextFieldValue + scalar federation__Scope + scalar federation__Policy + + type Query + + enum link__Purpose { + SECURITY + EXECUTION + } + + type _Service { + sdl: String! + } + + extend type Query { + _entities(representations: [_Any!]!): [_Entity]! + _service: _Service! + } + + directive @external on FIELD_DEFINITION | OBJECT + directive @requires(fields: FieldSet!) on FIELD_DEFINITION + directive @provides(fields: FieldSet!) on FIELD_DEFINITION + directive @key( + fields: FieldSet! + resolvable: Boolean = true + ) repeatable on OBJECT | INTERFACE + directive @link( + url: String! + as: String + for: link__Purpose + import: [link__Import] + ) repeatable on SCHEMA + directive @shareable repeatable on OBJECT | FIELD_DEFINITION + directive @inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + directive @tag( + name: String! + ) repeatable on FIELD_DEFINITION | INTERFACE | OBJECT | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + directive @override(from: String!) on FIELD_DEFINITION + directive @composeDirective(name: String!) repeatable on SCHEMA + directive @interfaceObject on OBJECT + directive @authenticated on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + directive @requiresScopes( + scopes: [[federation__Scope!]!]! + ) on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + directive @policy( + policies: [[federation__Policy!]!]! + ) on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + directive @context(name: String!) repeatable on INTERFACE | OBJECT | UNION + directive @fromContext( + field: federation__ContextFieldValue + ) on ARGUMENT_DEFINITION + directive @extends on OBJECT | INTERFACE + `), + ], + } + : {}), ...(enableApolloFederation ? { schemas: [ @@ -86,6 +152,12 @@ export class LoadersRegistry { : {}), }), ); + + if (enableApolloFederationV2) { + return this.buildEntityUnion(schema); + } + + return schema; } loadDocuments( @@ -99,6 +171,33 @@ export class LoadersRegistry { }), ); } + + private buildEntityUnion(schema: GraphQLSchema): GraphQLSchema { + const entityTypes: string[] = []; + const typeMap = schema.getTypeMap(); + + // Find all types with @key directive + for (const type of Object.values(typeMap)) { + if ( + (isObjectType(type) || isInterfaceType(type)) && + type.astNode?.directives?.some(dir => dir.name.value === 'key') + ) { + entityTypes.push(type.name); + } + } + + if (entityTypes.length === 0) { + return schema; // No entities found + } + + // Create new schema SDL with populated _Entity union + const entityUnion = `union _Entity = ${entityTypes.join(' | ')}`; + + // Rebuild schema with proper _Entity union + const schemaSDL = printSchema(schema).replace('union _Entity', entityUnion); + + return buildSchema(schemaSDL); + } } export type Loaders = Pick; diff --git a/website/src/pages/docs/commands/coverage.mdx b/website/src/pages/docs/commands/coverage.mdx index 89195661ef..32a5796db6 100644 --- a/website/src/pages/docs/commands/coverage.mdx +++ b/website/src/pages/docs/commands/coverage.mdx @@ -33,7 +33,8 @@ graphql-inspector coverage DOCUMENTS SCHEMA - `-t, --token ` - an access token - `-h, --header ` - set http header (`--header 'Auth: Basic 123'`) - `--method` - method on url schema pointers (default: `POST`) -- `--federation` - Support Apollo Federation directives (default: `false`) +- `--federation` - Support Apollo Federation V1 directives (default: `false`) +- `--federationV2` - Support Apollo Federation V2 directives (default: `false`) - `--aws` - Support AWS Appsync directives and scalar types (default: `false`) ## Output diff --git a/website/src/pages/docs/commands/diff.mdx b/website/src/pages/docs/commands/diff.mdx index 785a945388..e271fd945f 100644 --- a/website/src/pages/docs/commands/diff.mdx +++ b/website/src/pages/docs/commands/diff.mdx @@ -50,7 +50,8 @@ graphql-inspector diff OLD_SCHEMA NEW_SCHEMA - `-t, --token ` - an access token - `-h, --header ` - set http header (`--header 'Auth: Basic 123') - `--method` - method on url schema pointers (default: `POST`) -- `--federation` - Support Apollo Federation directives (default: `false`) +- `--federation` - Support Apollo Federation V1 directives (default: `false`) +- `--federationV2` - Support Apollo Federation V2 directives (default: `false`) - `--aws` - Support AWS Appsync directives and scalar types (default: `false`) ### Output diff --git a/website/src/pages/docs/commands/introspect.mdx b/website/src/pages/docs/commands/introspect.mdx index f826ba52ef..a51997d66c 100644 --- a/website/src/pages/docs/commands/introspect.mdx +++ b/website/src/pages/docs/commands/introspect.mdx @@ -29,7 +29,8 @@ It supports `.graphql`, `.gql` and `.json` extensions. - `-t, --token ` - an access token - `-h, --header ` - set HTTP header (`--header 'Auth: Basic 123'`) - `--method` - method on URL schema pointers (default: `POST`) -- `--federation` - Support Apollo Federation directives (default: `false`) +- `--federation` - Support Apollo Federation V1 directives (default: `false`) +- `--federationV2` - Support Apollo Federation V2 directives (default: `false`) - `--aws` - Support AWS Appsync directives and scalar types (default: `false`) ## Output diff --git a/website/src/pages/docs/commands/serve.mdx b/website/src/pages/docs/commands/serve.mdx index 9827c2eba9..e343db3f6a 100644 --- a/website/src/pages/docs/commands/serve.mdx +++ b/website/src/pages/docs/commands/serve.mdx @@ -26,7 +26,8 @@ graphql-inspector serve SCHEMA - `-t, --token ` - an access token - `-h, --header ` - set HTTP header (`--header 'Auth: Basic 123'`) - `--method` - method on URL schema pointers (default: `POST`) -- `--federation` - Support Apollo Federation directives (default: `false`) +- `--federation` - Support Apollo Federation V1 directives (default: `false`) +- `--federationV2` - Support Apollo Federation V2 directives (default: `false`) - `--aws` - Support AWS Appsync directives and scalar types (default: `false`) ## Output diff --git a/website/src/pages/docs/commands/similar.mdx b/website/src/pages/docs/commands/similar.mdx index 908808edfd..7dcc2ab6a5 100644 --- a/website/src/pages/docs/commands/similar.mdx +++ b/website/src/pages/docs/commands/similar.mdx @@ -29,7 +29,8 @@ graphql-inspector similar SCHEMA - `-t, --token ` - an access token - `-h, --header ` - set HTTP header (`--header 'Auth: Basic 123'`) - `--method` - method on URL schema pointers (default: `POST`) -- `--federation` - Support Apollo Federation directives (default: `false`) +- `--federation` - Support Apollo Federation V1 directives (default: `false`) +- `--federationV2` - Support Apollo Federation V2 directives (default: `false`) - `--aws` - Support AWS Appsync directives and scalar types (default: `false`) ## Output diff --git a/website/src/pages/docs/commands/validate.mdx b/website/src/pages/docs/commands/validate.mdx index 19176b98ab..29b50b2663 100644 --- a/website/src/pages/docs/commands/validate.mdx +++ b/website/src/pages/docs/commands/validate.mdx @@ -32,7 +32,8 @@ graphql-inspector validate DOCUMENTS SCHEMA - `--keepClientFields ` - Keeps the fields with `@client`, but removes `@client` directive from them - works only with combination of `--apollo` (default: `false`) - `--method` - method on url schema pointers (default: `POST`) -- `--federation` - Support Apollo Federation directives (default: `false`) +- `--federation` - Support Apollo Federation V1 directives (default: `false`) +- `--federationV2` - Support Apollo Federation V2 directives (default: `false`) - `--aws` - Support AWS Appsync directives and scalar types (default: `false`) - `--maxDepth ` - Fail when operation depth exceeds maximum depth (default: `false`) - `--maxAliasCount ` - Fail when alias count (including the referenced fragments) exceeds maximum