diff --git a/.changeset/spicy-olives-exercise.md b/.changeset/spicy-olives-exercise.md new file mode 100644 index 0000000000000..f7e14d56d6f77 --- /dev/null +++ b/.changeset/spicy-olives-exercise.md @@ -0,0 +1,5 @@ +--- +"@graphql-mesh/supergraph": patch +--- + +Fix interpolation of headers and endpoint configuration for each subgraph diff --git a/packages/legacy/handlers/supergraph/src/index.ts b/packages/legacy/handlers/supergraph/src/index.ts index 7ecac757cc5b9..a584c80e8e3d2 100644 --- a/packages/legacy/handlers/supergraph/src/index.ts +++ b/packages/legacy/handlers/supergraph/src/index.ts @@ -1,8 +1,9 @@ -import { DocumentNode, parse } from 'graphql'; +import { DocumentNode, EnumTypeDefinitionNode, parse } from 'graphql'; import { process } from '@graphql-mesh/cross-helpers'; import { PredefinedProxyOptions, StoreProxy } from '@graphql-mesh/store'; import { getInterpolatedHeadersFactory, + getInterpolatedStringFactory, ResolverData, stringInterpolator, } from '@graphql-mesh/string-interpolation'; @@ -89,22 +90,46 @@ export default class SupergraphHandler implements MeshHandler { this.config.operationHeaders != null ? getInterpolatedHeadersFactory(this.config.operationHeaders) : undefined; + const joingraphEnum = supergraphSdl.definitions.find( + def => def.kind === 'EnumTypeDefinition' && def.name.value === 'join__Graph', + ) as EnumTypeDefinitionNode; + const subgraphNameIdMap = new Map(); + if (joingraphEnum) { + joingraphEnum.values?.forEach(value => { + value.directives?.forEach(directive => { + if (directive.name.value === 'join__graph') { + const nameArg = directive.arguments?.find(arg => arg.name.value === 'name'); + if (nameArg?.value?.kind === 'StringValue') { + subgraphNameIdMap.set(value.name.value, nameArg.value.value); + } + } + }); + }); + } const schema = getStitchedSchemaFromSupergraphSdl({ supergraphSdl, - onExecutor: ({ subgraphName, endpoint }) => { + onExecutor: ({ subgraphName, endpoint: nonInterpolatedEndpoint }) => { + const subgraphRealName = subgraphNameIdMap.get(subgraphName); const subgraphConfiguration: YamlConfig.SubgraphConfiguration = subgraphConfigs.find( - subgraphConfig => subgraphConfig.name === subgraphName, + subgraphConfig => subgraphConfig.name === subgraphRealName, ) || { name: subgraphName, }; + nonInterpolatedEndpoint = subgraphConfiguration.endpoint || nonInterpolatedEndpoint; + const endpointFactory = getInterpolatedStringFactory(nonInterpolatedEndpoint); return buildHTTPExecutor({ - endpoint, ...(subgraphConfiguration as any), - fetch: fetchFn, + endpoint: nonInterpolatedEndpoint, + fetch(url: string, init: any, context: any, info: any) { + const endpoint = endpointFactory({ + env: process.env, + context, + info, + }); + url = url.replace(nonInterpolatedEndpoint, endpoint); + return fetchFn(url, init, context, info); + }, headers(executorRequest) { - const subgraphConfiguration = subgraphConfigs.find( - subgraphConfig => subgraphConfig.name === subgraphName, - ); const headers = {}; const resolverData: ResolverData = { root: executorRequest.rootValue, @@ -122,6 +147,7 @@ export default class SupergraphHandler implements MeshHandler { if (operationHeadersFactory) { Object.assign(headers, operationHeadersFactory(resolverData)); } + return headers; }, } as HTTPExecutorOptions); }, diff --git a/packages/legacy/handlers/supergraph/tests/fixtures/service-author/resolvers.ts b/packages/legacy/handlers/supergraph/tests/fixtures/service-author/resolvers.ts new file mode 100644 index 0000000000000..f52051dd38351 --- /dev/null +++ b/packages/legacy/handlers/supergraph/tests/fixtures/service-author/resolvers.ts @@ -0,0 +1,26 @@ +export const resolvers = { + Author: { + __resolveReference(author: { id: string }) { + return authors.find(a => a.id === author.id); + }, + }, + Query: { + author(_: any, { id }: { id: string }) { + return authors.find(a => a.id === id); + }, + authors() { + return authors; + }, + }, +}; + +const authors = [ + { + id: '1', + name: 'Jane Doe', + }, + { + id: '2', + name: 'John Doe', + }, +]; diff --git a/packages/legacy/handlers/supergraph/tests/fixtures/service-author/server.ts b/packages/legacy/handlers/supergraph/tests/fixtures/service-author/server.ts new file mode 100644 index 0000000000000..21f7e50a2a57a --- /dev/null +++ b/packages/legacy/handlers/supergraph/tests/fixtures/service-author/server.ts @@ -0,0 +1,36 @@ +/* eslint-disable import/no-nodejs-modules */ +/* eslint-disable import/no-extraneous-dependencies */ +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { GraphQLError, parse } from 'graphql'; +import { createYoga } from 'graphql-yoga'; +import { buildSubgraphSchema } from '@apollo/subgraph'; +import { resolvers } from './resolvers'; + +export const AUTH_HEADER = 'Bearer AUTHORS_SECRET'; + +export const server = createYoga({ + schema: buildSubgraphSchema({ + typeDefs: parse(readFileSync(join(__dirname, 'typeDefs.graphql'), 'utf-8')), + resolvers, + }), + plugins: [ + { + onRequest({ request }) { + if (request.url.includes('skip-auth')) { + return; + } + const authHeader = request.headers.get('authorization'); + if (authHeader !== AUTH_HEADER) { + throw new GraphQLError('Unauthorized', { + extensions: { + http: { + status: 401, + }, + }, + }); + } + }, + }, + ], +}); diff --git a/packages/legacy/handlers/supergraph/tests/fixtures/service-author/typeDefs.graphql b/packages/legacy/handlers/supergraph/tests/fixtures/service-author/typeDefs.graphql new file mode 100644 index 0000000000000..42d76966b65ef --- /dev/null +++ b/packages/legacy/handlers/supergraph/tests/fixtures/service-author/typeDefs.graphql @@ -0,0 +1,10 @@ +type Author @key(fields: "id") { + id: ID! + name: String! + birthDate: String +} + +type Query { + authors: [Author] + author(id: ID!): Author +} diff --git a/packages/legacy/handlers/supergraph/tests/fixtures/service-book/resolvers.ts b/packages/legacy/handlers/supergraph/tests/fixtures/service-book/resolvers.ts new file mode 100644 index 0000000000000..1fcf074e90bfa --- /dev/null +++ b/packages/legacy/handlers/supergraph/tests/fixtures/service-book/resolvers.ts @@ -0,0 +1,44 @@ +export const resolvers = { + Book: { + __resolveReference(book: { id: string }) { + return books.find(b => b.id === book.id); + }, + author(book: { authorId: string }) { + return { __typename: 'Author', id: book.authorId }; + }, + }, + Author: { + __resolveReference(author: { id: string }) { + return books.filter(b => b.authorId === author.id); + }, + books(author: { id: string }) { + return books.filter(b => b.authorId === author.id); + }, + }, + Query: { + book(_: any, { id }: { id: string }) { + return books.find(b => b.id === id); + }, + books() { + return books; + }, + }, +}; + +const books = [ + { + id: '1', + title: 'Awesome Book', + authorId: '1', + }, + { + id: '2', + title: 'Book of Secrets', + authorId: '2', + }, + { + id: '3', + title: 'Book of Mystery', + authorId: '2', + }, +]; diff --git a/packages/legacy/handlers/supergraph/tests/fixtures/service-book/server.ts b/packages/legacy/handlers/supergraph/tests/fixtures/service-book/server.ts new file mode 100644 index 0000000000000..8c70f44ed6fd4 --- /dev/null +++ b/packages/legacy/handlers/supergraph/tests/fixtures/service-book/server.ts @@ -0,0 +1,36 @@ +/* eslint-disable import/no-nodejs-modules */ +/* eslint-disable import/no-extraneous-dependencies */ +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { GraphQLError, parse } from 'graphql'; +import { createYoga } from 'graphql-yoga'; +import { buildSubgraphSchema } from '@apollo/subgraph'; +import { resolvers } from './resolvers'; + +export const AUTH_HEADER = 'Bearer BOOKS_SECRET'; + +export const server = createYoga({ + schema: buildSubgraphSchema({ + typeDefs: parse(readFileSync(join(__dirname, 'typeDefs.graphql'), 'utf-8')), + resolvers, + }), + plugins: [ + { + onRequest({ request }) { + if (request.url.includes('skip-auth')) { + return; + } + const authHeader = request.headers.get('authorization'); + if (authHeader !== AUTH_HEADER) { + throw new GraphQLError('Unauthorized', { + extensions: { + http: { + status: 401, + }, + }, + }); + } + }, + }, + ], +}); diff --git a/packages/legacy/handlers/supergraph/tests/fixtures/service-book/typeDefs.graphql b/packages/legacy/handlers/supergraph/tests/fixtures/service-book/typeDefs.graphql new file mode 100644 index 0000000000000..66b6e1c44a332 --- /dev/null +++ b/packages/legacy/handlers/supergraph/tests/fixtures/service-book/typeDefs.graphql @@ -0,0 +1,16 @@ +type Book @key(fields: "id") { + id: ID! + title: String! + genre: String + author: Author! +} + +type Author @extends @key(fields: "id") { + id: ID! @external + books: [Book] +} + +type Query { + books: [Book] + book(id: ID!): Book +} diff --git a/packages/legacy/handlers/supergraph/tests/fixtures/supergraph.graphql b/packages/legacy/handlers/supergraph/tests/fixtures/supergraph.graphql new file mode 100644 index 0000000000000..c4bedb67ffd49 --- /dev/null +++ b/packages/legacy/handlers/supergraph/tests/fixtures/supergraph.graphql @@ -0,0 +1,62 @@ +schema + @core(feature: "https://specs.apollo.dev/core/v0.2") + @core(feature: "https://specs.apollo.dev/join/v0.1", for: EXECUTION) { + query: Query +} + +directive @core(as: String, feature: String!, for: core__Purpose) repeatable on SCHEMA + +directive @join__field( + graph: join__Graph + provides: join__FieldSet + requires: join__FieldSet +) on FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__owner(graph: join__Graph!) on INTERFACE | OBJECT + +directive @join__type(graph: join__Graph!, key: join__FieldSet) repeatable on INTERFACE | OBJECT + +type Author + @join__owner(graph: AUTHORS) + @join__type(graph: AUTHORS, key: "id") + @join__type(graph: BOOKS, key: "id") { + birthDate: String @join__field(graph: AUTHORS) + books: [Book] @join__field(graph: BOOKS) + id: ID! @join__field(graph: AUTHORS) + name: String! @join__field(graph: AUTHORS) +} + +type Book @join__owner(graph: BOOKS) @join__type(graph: BOOKS, key: "id") { + author: Author! @join__field(graph: BOOKS) + genre: String @join__field(graph: BOOKS) + id: ID! @join__field(graph: BOOKS) + title: String! @join__field(graph: BOOKS) +} + +type Query { + author(id: ID!): Author @join__field(graph: AUTHORS) + authors: [Author] @join__field(graph: AUTHORS) + book(id: ID!): Book @join__field(graph: BOOKS) + books: [Book] @join__field(graph: BOOKS) +} + +enum core__Purpose { + """ + `EXECUTION` features provide metadata necessary to for operation execution. + """ + EXECUTION + + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY +} + +scalar join__FieldSet + +enum join__Graph { + AUTHORS @join__graph(name: "authors", url: "http://authors/graphql") + BOOKS @join__graph(name: "books", url: "http://books/graphql") +} diff --git a/packages/legacy/handlers/supergraph/tests/fixtures/supergraph.yaml b/packages/legacy/handlers/supergraph/tests/fixtures/supergraph.yaml new file mode 100644 index 0000000000000..60b1d14b85b99 --- /dev/null +++ b/packages/legacy/handlers/supergraph/tests/fixtures/supergraph.yaml @@ -0,0 +1,9 @@ +subgraphs: + authors: + routing_url: http://authors/graphql + schema: + file: ./service-author/typeDefs.graphql + books: + routing_url: http://books/graphql + schema: + file: ./service-book/typeDefs.graphql diff --git a/packages/legacy/handlers/supergraph/tests/supergraph.spec.ts b/packages/legacy/handlers/supergraph/tests/supergraph.spec.ts new file mode 100644 index 0000000000000..dc321ade631ff --- /dev/null +++ b/packages/legacy/handlers/supergraph/tests/supergraph.spec.ts @@ -0,0 +1,212 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import LocalforageCache from '@graphql-mesh/cache-localforage'; +import BareMerger from '@graphql-mesh/merger-bare'; +import { getMesh } from '@graphql-mesh/runtime'; +import { InMemoryStoreStorageAdapter, MeshStore } from '@graphql-mesh/store'; +import SupergraphHandler from '@graphql-mesh/supergraph'; +import { MeshFetch } from '@graphql-mesh/types'; +import { DefaultLogger, defaultImportFn as importFn, PubSub } from '@graphql-mesh/utils'; +import { + AUTH_HEADER as AUTHORS_AUTH_HEADER, + server as authorsServer, +} from './fixtures/service-author/server'; +import { + AUTH_HEADER as BOOKS_AUTH_HEADER, + server as booksServer, +} from './fixtures/service-book/server'; + +describe('Supergraph', () => { + const baseDir = __dirname; + const cache = new LocalforageCache(); + const store = new MeshStore('test', new InMemoryStoreStorageAdapter(), { + validate: false, + readonly: false, + }); + const logger = new DefaultLogger('test'); + const pubsub = new PubSub(); + const merger = new BareMerger({ cache, pubsub, store, logger }); + const fetchFn: MeshFetch = async (url, options) => { + if (url.includes('authors')) { + return authorsServer.fetch(url, options); + } + if (url.includes('books')) { + return booksServer.fetch(url, options); + } + throw new Error(`Unknown URL: ${url}`); + }; + const baseHandlerConfig = { + name: 'BooksAndAuthors', + baseDir, + cache, + store, + pubsub, + logger, + importFn, + }; + const baseGetMeshConfig = { + cache, + fetchFn, + merger, + }; + it('supports individual headers for each subgraph with interpolation', async () => { + const handler = new SupergraphHandler({ + ...baseHandlerConfig, + config: { + source: './fixtures/supergraph.graphql', + subgraphs: [ + { + name: 'authors', + operationHeaders: { + Authorization: AUTHORS_AUTH_HEADER, + }, + }, + { + name: 'books', + operationHeaders: { + Authorization: '{context.books_auth_header}', + }, + }, + ], + }, + }); + const meshRuntime = await getMesh({ + sources: [ + { + name: 'supergraph', + handler, + }, + ], + ...baseGetMeshConfig, + }); + const result = await meshRuntime.execute( + /* GraphQL */ ` + query { + book(id: 1) { + title + author { + name + } + } + } + `, + {}, + { + books_auth_header: BOOKS_AUTH_HEADER, + }, + ); + expect(result.errors).toBeFalsy(); + expect(result.data).toMatchObject({ + book: { + title: 'Awesome Book', + author: { + name: 'Jane Doe', + }, + }, + }); + }); + it('supports custom endpoint for each subgraph', async () => { + const handler = new SupergraphHandler({ + ...baseHandlerConfig, + config: { + source: './fixtures/supergraph.graphql', + subgraphs: [ + { + name: 'authors', + operationHeaders: { + Authorization: AUTHORS_AUTH_HEADER, + }, + }, + { + name: 'books', + endpoint: 'http://books/graphql?skip-auth', + }, + ], + }, + }); + const meshRuntime = await getMesh({ + sources: [ + { + name: 'supergraph', + handler, + }, + ], + ...baseGetMeshConfig, + }); + const result = await meshRuntime.execute( + /* GraphQL */ ` + query { + book(id: 1) { + title + author { + name + } + } + } + `, + {}, + ); + expect(result.errors).toBeFalsy(); + expect(result.data).toMatchObject({ + book: { + title: 'Awesome Book', + author: { + name: 'Jane Doe', + }, + }, + }); + }); + it('supports interpolation in custom endpoint for each subgraph', async () => { + const handler = new SupergraphHandler({ + ...baseHandlerConfig, + config: { + source: './fixtures/supergraph.graphql', + subgraphs: [ + { + name: 'authors', + operationHeaders: { + Authorization: AUTHORS_AUTH_HEADER, + }, + }, + { + name: 'books', + endpoint: 'http://{context.books_endpoint}', + }, + ], + }, + }); + const meshRuntime = await getMesh({ + sources: [ + { + name: 'supergraph', + handler, + }, + ], + ...baseGetMeshConfig, + }); + const result = await meshRuntime.execute( + /* GraphQL */ ` + query { + book(id: 1) { + title + author { + name + } + } + } + `, + {}, + { + books_endpoint: 'books/graphql?skip-auth', + }, + ); + expect(result.errors).toBeFalsy(); + expect(result.data).toMatchObject({ + book: { + title: 'Awesome Book', + author: { + name: 'Jane Doe', + }, + }, + }); + }); +}); diff --git a/website/src/pages/docs/handlers/supergraph.mdx b/website/src/pages/docs/handlers/supergraph.mdx index 56355eaaabb81..beedb1e178cfe 100644 --- a/website/src/pages/docs/handlers/supergraph.mdx +++ b/website/src/pages/docs/handlers/supergraph.mdx @@ -34,22 +34,14 @@ sources: supergraph: source: http://some-source.com/supergraph.graphql subgraphs: - accounts: + - name: accounts endpoint: http://localhost:9871/graphql - operationHeaders: - Authorization: "Bearer {context.headers['x-accounts-token']}" - reviews: - endpoint: http://localhost:9872/graphql - operationHeaders: + operationHeaders: # You can use context variables here Authorization: "Bearer {context.headers['x-accounts-token']}" - products: - endpoint: http://localhost:9873/graphql + - name: reviews + endpoint: '{env.REVIEWS_ENDPOINT:https://default-reviews.com/graphql}' operationHeaders: - Authorization: "Bearer {context.headers['x-accounts-token']}" - inventory: - endpoint: http://localhost:9874/graphql - operationHeaders: - Authorization: "Bearer {context.headers['x-accounts-token']}" + Authorization: "Bearer {context.headers['x-reviews-token']}" ``` ### Config API Reference