diff --git a/.changeset/tricky-paws-walk.md b/.changeset/tricky-paws-walk.md new file mode 100644 index 00000000..dc1faf72 --- /dev/null +++ b/.changeset/tricky-paws-walk.md @@ -0,0 +1,5 @@ +--- +'@graphql-tools/federation': patch +--- + +Keep the custom directives(using @composeDirective) from the supergraph, in the unified schema served by the gateway should keep it. diff --git a/internal/testing/src/composeLocalSchemasWithApollo.ts b/internal/testing/src/composeLocalSchemasWithApollo.ts new file mode 100644 index 00000000..a0a1977d --- /dev/null +++ b/internal/testing/src/composeLocalSchemasWithApollo.ts @@ -0,0 +1,30 @@ +import { IntrospectAndCompose, LocalGraphQLDataSource } from '@apollo/gateway'; +import { GraphQLSchema } from 'graphql'; + +export interface ComposeLocalSchemaWithApolloSubgraphOpts { + name: string; + schema: GraphQLSchema; + url?: string; +} + +export async function composeLocalSchemasWithApollo( + subgraphs: ComposeLocalSchemaWithApolloSubgraphOpts[], +) { + let { supergraphSdl, cleanup } = await new IntrospectAndCompose({ + subgraphs, + }).initialize({ + update(updatedSupergraphSdl: string) { + supergraphSdl = updatedSupergraphSdl; + }, + healthCheck: async () => {}, + getDataSource({ name }) { + const subgraph = subgraphs.find((subgraph) => subgraph.name === name); + if (!subgraph) { + throw new Error(`Subgraph ${name} not found`); + } + return new LocalGraphQLDataSource(subgraph.schema); + }, + }); + await cleanup(); + return supergraphSdl; +} diff --git a/internal/testing/src/index.ts b/internal/testing/src/index.ts index 7e8b46e0..d840daad 100644 --- a/internal/testing/src/index.ts +++ b/internal/testing/src/index.ts @@ -4,3 +4,4 @@ export * from './getLocalhost'; export * from './opts'; export * from './assertions'; export * from './benchConfig'; +export * from './composeLocalSchemasWithApollo'; diff --git a/packages/federation/src/supergraph.ts b/packages/federation/src/supergraph.ts index 58c0a5a2..d231e37c 100644 --- a/packages/federation/src/supergraph.ts +++ b/packages/federation/src/supergraph.ts @@ -1429,6 +1429,12 @@ export function getStitchingOptionsFromSupergraphSdl( } } } + } else if ( + definition.kind === Kind.DIRECTIVE_DEFINITION && + !definition.name.value.startsWith('join__') && + !definition.name.value.startsWith('core') + ) { + extraDefinitions.push(definition); } } const additionalTypeDefs: DocumentNode = { diff --git a/packages/federation/tests/supergraphs.test.ts b/packages/federation/tests/supergraphs.test.ts index 1d78bf71..721985ea 100644 --- a/packages/federation/tests/supergraphs.test.ts +++ b/packages/federation/tests/supergraphs.test.ts @@ -29,7 +29,13 @@ describe('Supergraphs', () => { fieldFilter: (_, __, fieldConfig) => !getDirective(sortedInputSchema, fieldConfig, 'inaccessible') ?.length, - directiveFilter: () => false, + directiveFilter: (typeName) => + !typeName.startsWith('link__') && + !typeName.startsWith('join__') && + !typeName.startsWith('core__') && + typeName !== 'core' && + typeName !== 'link' && + typeName !== 'inaccessible', enumValueFilter: (_, __, enumValueConfig) => !getDirective(sortedInputSchema, enumValueConfig, 'inaccessible') ?.length, diff --git a/packages/runtime/tests/cache-control.test.ts b/packages/runtime/tests/cache-control.test.ts new file mode 100644 index 00000000..35c6b354 --- /dev/null +++ b/packages/runtime/tests/cache-control.test.ts @@ -0,0 +1,223 @@ +import { setTimeout } from 'timers/promises'; +import { ApolloServer } from '@apollo/server'; +import { startStandaloneServer } from '@apollo/server/standalone'; +import { buildSubgraphSchema } from '@apollo/subgraph'; +import { createGatewayRuntime } from '@graphql-hive/gateway-runtime'; +import InmemoryLRUCache from '@graphql-mesh/cache-inmemory-lru'; +import useHttpCache from '@graphql-mesh/plugin-http-cache'; +import { composeLocalSchemasWithApollo } from '@internal/testing'; +import { parse } from 'graphql'; +import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; + +// ApolloServer is not playing nice with Leak Tests +describe.skipIf(process.env['LEAK_TEST'])( + 'Cache Control directives w/ Apollo Server subgraph', + () => { + vi.useFakeTimers?.(); + const advanceTimersByTimeAsync = vi.advanceTimersByTimeAsync || setTimeout; + const products = [ + { id: '1', name: 'Product 1', price: 100 }, + { id: '2', name: 'Product 2', price: 200 }, + { id: '3', name: 'Product 3', price: 300 }, + ]; + const productsSchema = buildSubgraphSchema({ + typeDefs: parse(/* GraphQL */ ` + enum CacheControlScope { + PUBLIC + PRIVATE + } + + directive @cacheControl( + maxAge: Int + scope: CacheControlScope + inheritMaxAge: Boolean + ) on FIELD_DEFINITION | OBJECT | INTERFACE | UNION + + type Product @key(fields: "id") @cacheControl(maxAge: 3) { + id: ID! + name: String! + price: Int! + } + + extend type Query { + products: [Product!]! + product(id: ID!): Product + } + + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.6" + import: ["@key", "@composeDirective"] + ) + @link( + url: "https://the-guild.dev/mesh/v1.0" + import: ["@cacheControl"] + ) + @composeDirective(name: "@cacheControl") { + query: Query + } + `), + resolvers: { + Query: { + product(_root, { id }) { + return products.find((product) => product.id === id); + }, + products() { + return products; + }, + }, + }, + }); + let supergraph: string; + let apolloServer: ApolloServer; + let requestDidStart: Mock; + beforeEach(async () => { + requestDidStart = vi.fn(); + apolloServer = new ApolloServer({ + schema: productsSchema, + plugins: [ + { + requestDidStart, + }, + ], + }); + const { url } = await startStandaloneServer(apolloServer, { + listen: { port: 0 }, + }); + supergraph = await composeLocalSchemasWithApollo([ + { + schema: productsSchema, + name: 'products', + url, + }, + ]); + }); + it('response caching plugin respect @cacheControl(maxAge:) w/ @composeDirective', async () => { + await using cache = new InmemoryLRUCache(); + await using gw = createGatewayRuntime({ + supergraph, + cache, + responseCaching: { + session: () => null, + includeExtensionMetadata: true, + }, + }); + async function makeRequest() { + const res = await gw.fetch('http://localhost:4000/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query: /* GraphQL */ ` + query { + products { + id + name + price + } + } + `, + }), + }); + return res.json(); + } + await expect(makeRequest()).resolves.toEqual({ + data: { + products, + }, + extensions: { + responseCache: { + didCache: true, + hit: false, + ttl: 3_000, + }, + }, + }); + // 15 seconds later + await advanceTimersByTimeAsync(1_000); + await expect(makeRequest()).resolves.toEqual({ + data: { + products, + }, + extensions: { + responseCache: { + hit: true, + }, + }, + }); + // 15 seconds later but the cache is expired + await advanceTimersByTimeAsync(2_000); + await expect(makeRequest()).resolves.toEqual({ + data: { + products, + }, + extensions: { + responseCache: { + didCache: true, + hit: false, + ttl: 3_000, + }, + }, + }); + // GW received 3 requests but only 2 were forwarded to the subgraph + expect(requestDidStart).toHaveBeenCalledTimes(2); + }); + // TODO: HTTP Cache plugin has issues with Bun + it.skipIf(globalThis.Bun)( + 'http caching plugin should respect cache control headers', + async () => { + await using cache = new InmemoryLRUCache(); + await using gw = createGatewayRuntime({ + supergraph, + cache, + plugins: (ctx) => [ + // @ts-expect-error - we need to fix the types + useHttpCache(ctx), + ], + }); + async function makeRequest() { + const res = await gw.fetch('http://localhost:4000/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query: /* GraphQL */ ` + query { + products { + id + name + price + } + } + `, + }), + }); + return res.json(); + } + await expect(makeRequest()).resolves.toEqual({ + data: { + products, + }, + }); + // 15 seconds later + await advanceTimersByTimeAsync(1_000); + await expect(makeRequest()).resolves.toEqual({ + data: { + products, + }, + }); + // 15 seconds later but the cache is expired + await advanceTimersByTimeAsync(2_000); + await expect(makeRequest()).resolves.toEqual({ + data: { + products, + }, + }); + // GW received 3 requests but only 2 were forwarded to the subgraph + expect(requestDidStart).toHaveBeenCalledTimes(2); + }, + ); + }, +); diff --git a/vitest-jest.js b/vitest-jest.js index f47b02b0..ede18b8d 100644 --- a/vitest-jest.js +++ b/vitest-jest.js @@ -19,8 +19,29 @@ module.exports = new Proxy(require('@jest/globals'), { describeFn.only = function describeOnly(name, ...args) { return jestGlobals.describe.only(name, ...args); }; + describeFn.each = function describeEach(table) { + return jestGlobals.describe.each(table); + }; return describeFn; } + if (prop === 'it') { + const itFn = function it(name, ...args) { + return jestGlobals.it(name, ...args); + }; + itFn.skipIf = function itSkipIf(condition) { + return condition ? itFn.skip : itFn; + }; + itFn.skip = function itSkip(name, ...args) { + return jestGlobals.it.skip(name, ...args); + }; + itFn.only = function itOnly(name, ...args) { + return jestGlobals.it.only(name, ...args); + }; + itFn.each = function itEach(table) { + return jestGlobals.it.each(table); + }; + return itFn; + } return Reflect.get(jestGlobals, prop, receiver); }, }); diff --git a/vitest.config.ts b/vitest.config.ts index e6e8a4be..01e74086 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,14 +6,7 @@ import { defineConfig } from 'vitest/config'; // packages as per the Node resolution spec. // // Vite will process inlined modules. -const inline = [ - /@graphql-mesh\/utils/, - /@graphql-mesh\/runtime/, - /@graphql-mesh\/fusion-composition/, - /@graphql-mesh\/plugin-hive/, - /@graphql-mesh\/transport-rest/, - /@omnigraph\/.*/, -]; +const inline = [/@graphql-mesh\/.*/, /@omnigraph\/.*/]; export default defineConfig({ plugins: [tsconfigPaths()],