diff --git a/federation-integration-testsuite-js/src/fixtures/index.ts b/federation-integration-testsuite-js/src/fixtures/index.ts index e4a319534..f2bf9f40d 100644 --- a/federation-integration-testsuite-js/src/fixtures/index.ts +++ b/federation-integration-testsuite-js/src/fixtures/index.ts @@ -8,26 +8,9 @@ import * as reviewsWithUpdate from './special-cases/reviewsWithUpdate'; import * as accountsWithoutTag from './special-cases/accountsWithoutTag'; import * as reviewsWithoutTag from './special-cases/reviewsWithoutTag'; -export { - accounts, - books, - documents, - inventory, - product, - reviews, - reviewsWithUpdate, -}; +const fixtures = [accounts, books, documents, inventory, product, reviews]; -export const fixtures = [ - accounts, - books, - documents, - inventory, - product, - reviews, -]; - -export const fixturesWithUpdate = [ +const fixturesWithUpdate = [ accounts, books, documents, @@ -36,7 +19,7 @@ export const fixturesWithUpdate = [ reviewsWithUpdate, ]; -export const fixturesWithoutTag = [ +const fixturesWithoutTag = [ accountsWithoutTag, books, documents, @@ -45,7 +28,7 @@ export const fixturesWithoutTag = [ reviewsWithoutTag, ]; -export const fixtureNames = [ +const fixtureNames = [ accounts.name, product.name, inventory.name, @@ -53,3 +36,18 @@ export const fixtureNames = [ books.name, documents.name, ]; + +export { superGraphWithInaccessible } from './special-cases/supergraphWithInaccessible'; +export { + accounts, + books, + documents, + inventory, + product, + reviews, + reviewsWithUpdate, + fixtures, + fixturesWithUpdate, + fixturesWithoutTag, + fixtureNames, +}; diff --git a/federation-integration-testsuite-js/src/fixtures/special-cases/supergraphWithInaccessible.ts b/federation-integration-testsuite-js/src/fixtures/special-cases/supergraphWithInaccessible.ts new file mode 100644 index 000000000..562bd5867 --- /dev/null +++ b/federation-integration-testsuite-js/src/fixtures/special-cases/supergraphWithInaccessible.ts @@ -0,0 +1,67 @@ +import { composeAndValidate } from '@apollo/federation'; +import { assertCompositionSuccess } from '@apollo/federation/dist/composition/utils'; +import { + DirectiveDefinitionNode, + SchemaDefinitionNode, + DocumentNode, + DirectiveNode, + parse, + visit, +} from 'graphql'; +import { fixtures } from '..'; + +const compositionResult = composeAndValidate(fixtures); +assertCompositionSuccess(compositionResult); +const parsed = parse(compositionResult.supergraphSdl); + +// We need to collect the AST for the inaccessible definition as well +// as the @core and @inaccessible usages. Parsing SDL is a fairly +// clean approach to this and easier to update than handwriting the AST. +const [inaccessibleDefinition, schemaDefinition] = parse(`#graphql + # inaccessibleDefinition + directive @inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION + schema + # inaccessibleCoreUsage + @core(feature: "https://specs.apollo.dev/inaccessible/v0.1") + # inaccessibleUsage + @inaccessible { + query: Query + } + `).definitions as [DirectiveDefinitionNode, SchemaDefinitionNode]; + +const [inaccessibleCoreUsage, inaccessibleUsage] = + schemaDefinition.directives as [DirectiveNode, DirectiveNode]; + +// Append the AST with the inaccessible definition, +// @core inaccessible usage, and @inaccessible usage on the `ssn` field +const superGraphWithInaccessible: DocumentNode = visit(parsed, { + Document(node) { + return { + ...node, + definitions: [...node.definitions, inaccessibleDefinition], + }; + }, + SchemaDefinition(node) { + return { + ...node, + directives: [...(node.directives ?? []), inaccessibleCoreUsage], + }; + }, + ObjectTypeDefinition(node) { + return { + ...node, + fields: + node.fields?.map((field) => { + if (field.name.value === 'ssn') { + return { + ...field, + directives: [...(field.directives ?? []), inaccessibleUsage], + }; + } + return field; + }) ?? [], + }; + }, +}); + +export { superGraphWithInaccessible }; diff --git a/gateway-js/src/__tests__/executeQueryPlan.test.ts b/gateway-js/src/__tests__/executeQueryPlan.test.ts index 369515067..38495e779 100644 --- a/gateway-js/src/__tests__/executeQueryPlan.test.ts +++ b/gateway-js/src/__tests__/executeQueryPlan.test.ts @@ -1,4 +1,10 @@ -import { getIntrospectionQuery, GraphQLSchema } from 'graphql'; +import { + buildClientSchema, + getIntrospectionQuery, + GraphQLObjectType, + GraphQLSchema, + print, +} from 'graphql'; import { addResolversToSchema, GraphQLResolverMap } from 'apollo-graphql'; import gql from 'graphql-tag'; import { GraphQLRequestContext } from 'apollo-server-types'; @@ -6,9 +12,15 @@ import { AuthenticationError } from 'apollo-server-core'; import { buildOperationContext } from '../operationContext'; import { executeQueryPlan } from '../executeQueryPlan'; import { LocalGraphQLDataSource } from '../datasources/LocalGraphQLDataSource'; -import { astSerializer, queryPlanSerializer } from 'apollo-federation-integration-testsuite'; +import { + astSerializer, + queryPlanSerializer, + superGraphWithInaccessible, +} from 'apollo-federation-integration-testsuite'; +import { buildComposedSchema, QueryPlanner } from '@apollo/query-planner'; +import { ApolloGateway } from '..'; +import { ApolloServerBase as ApolloServer } from 'apollo-server-core'; import { getFederatedTestingSchema } from './execution-utils'; -import { QueryPlanner } from '@apollo/query-planner'; expect.addSnapshotSerializer(astSerializer); expect.addSnapshotSerializer(queryPlanSerializer); @@ -34,20 +46,15 @@ describe('executeQueryPlan', () => { let schema: GraphQLSchema; let queryPlanner: QueryPlanner; - beforeEach(() => { expect( () => - ({ - serviceMap, - schema, - queryPlanner, - } = getFederatedTestingSchema()), + ({ serviceMap, schema, queryPlanner } = getFederatedTestingSchema()), ).not.toThrow(); }); function buildRequestContext(): GraphQLRequestContext { - // @ts-ignore + // @ts-ignore return { cache: undefined as any, context: {}, @@ -146,9 +153,8 @@ describe('executeQueryPlan', () => { }); it(`should not send request to downstream services when all entities are undefined`, async () => { - const accountsEntitiesResolverSpy = spyOnEntitiesResolverInService( - 'accounts', - ); + const accountsEntitiesResolverSpy = + spyOnEntitiesResolverInService('accounts'); const operationString = `#graphql query { @@ -224,9 +230,8 @@ describe('executeQueryPlan', () => { }); it(`should send a request to downstream services for the remaining entities when some entities are undefined`, async () => { - const accountsEntitiesResolverSpy = spyOnEntitiesResolverInService( - 'accounts', - ); + const accountsEntitiesResolverSpy = + spyOnEntitiesResolverInService('accounts'); const operationString = `#graphql query { @@ -334,9 +339,8 @@ describe('executeQueryPlan', () => { }); it(`should not send request to downstream service when entities don't match type conditions`, async () => { - const reviewsEntitiesResolverSpy = spyOnEntitiesResolverInService( - 'reviews', - ); + const reviewsEntitiesResolverSpy = + spyOnEntitiesResolverInService('reviews'); const operationString = `#graphql query { @@ -383,9 +387,8 @@ describe('executeQueryPlan', () => { }); it(`should send a request to downstream services for the remaining entities when some entities don't match type conditions`, async () => { - const reviewsEntitiesResolverSpy = spyOnEntitiesResolverInService( - 'reviews', - ); + const reviewsEntitiesResolverSpy = + spyOnEntitiesResolverInService('reviews'); const operationString = `#graphql query { @@ -1178,4 +1181,147 @@ describe('executeQueryPlan', () => { } `); }); + + describe('@inaccessible', () => { + it(`should not include @inaccessible fields in introspection`, async () => { + schema = buildComposedSchema(superGraphWithInaccessible); + queryPlanner = new QueryPlanner(schema); + + const operationContext = buildOperationContext({ + schema, + operationDocument: gql` + ${getIntrospectionQuery()} + `, + }); + const queryPlan = queryPlanner.buildQueryPlan(operationContext); + + const response = await executeQueryPlan( + queryPlan, + serviceMap, + buildRequestContext(), + operationContext, + ); + + expect(response.data).toHaveProperty('__schema'); + expect(response.errors).toBeUndefined(); + + const introspectedSchema = buildClientSchema(response.data as any); + + const userType = introspectedSchema.getType('User') as GraphQLObjectType; + + expect(userType.getFields()['username']).toBeDefined(); + expect(userType.getFields()['ssn']).toBeUndefined(); + }); + + it(`should not return @inaccessible fields`, async () => { + const operationString = `#graphql + query { + topReviews { + body + author { + username + ssn + } + } + } + `; + + const operationDocument = gql(operationString); + + schema = buildComposedSchema(superGraphWithInaccessible); + + const operationContext = buildOperationContext({ + schema, + operationDocument, + }); + + queryPlanner = new QueryPlanner(schema); + const queryPlan = queryPlanner.buildQueryPlan(operationContext); + + const response = await executeQueryPlan( + queryPlan, + serviceMap, + buildRequestContext(), + operationContext, + ); + + expect(response.data).toMatchInlineSnapshot(` + Object { + "topReviews": Array [ + Object { + "author": Object { + "username": "@ada", + }, + "body": "Love it!", + }, + Object { + "author": Object { + "username": "@ada", + }, + "body": "Too expensive.", + }, + Object { + "author": Object { + "username": "@complete", + }, + "body": "Could be better.", + }, + Object { + "author": Object { + "username": "@complete", + }, + "body": "Prefer something else.", + }, + Object { + "author": Object { + "username": "@complete", + }, + "body": "Wish I had read this before.", + }, + ], + } + `); + }); + + it(`should return a validation error when an @inaccessible field is requested`, async () => { + // Because validation is part of the Apollo Server request pipeline, + // we have to construct an instance of ApolloServer and execute the + // the operation against it. + // This test uses the same `gateway.load()` pattern as existing tests that + // execute operations against Apollo Server (like queryPlanCache.test.ts). + // But note that this is only one possible initialization path for the + // gateway, and with the current duplication of logic we'd actually need + // to test other scenarios (like loading from supergraph SDL) separately. + const gateway = new ApolloGateway({ + supergraphSdl: print(superGraphWithInaccessible), + }); + + const { schema, executor } = await gateway.load(); + + const server = new ApolloServer({ schema, executor }); + + const query = `#graphql + query { + topReviews { + body + author { + username + ssn + } + } + } + `; + + const response = await server.executeOperation({ + query, + }); + + expect(response.data).toBeUndefined(); + expect(response.errors).toMatchInlineSnapshot(` + Array [ + [ValidationError: Cannot query field "ssn" on type "User".], + ] + `); + }); + }); }); diff --git a/gateway-js/src/__tests__/execution-utils.ts b/gateway-js/src/__tests__/execution-utils.ts index 566d76f11..b66bd6815 100644 --- a/gateway-js/src/__tests__/execution-utils.ts +++ b/gateway-js/src/__tests__/execution-utils.ts @@ -93,12 +93,7 @@ export function getFederatedTestingSchema(services: ServiceDefinitionModule[] = ]), ); - const compositionResult = composeAndValidate( - Object.entries(serviceMap).map(([serviceName, dataSource]) => ({ - name: serviceName, - typeDefs: dataSource.sdl(), - })), - ); + const compositionResult = composeAndValidate(services); if (compositionHasErrors(compositionResult)) { throw new GraphQLSchemaValidationError(compositionResult.errors); diff --git a/gateway-js/src/__tests__/gateway/composedSdl.test.ts b/gateway-js/src/__tests__/gateway/composedSdl.test.ts index 8fda0a542..1f8ac1956 100644 --- a/gateway-js/src/__tests__/gateway/composedSdl.test.ts +++ b/gateway-js/src/__tests__/gateway/composedSdl.test.ts @@ -19,17 +19,16 @@ describe('Using supergraphSdl configuration', () => { const server = await getSupergraphSdlGatewayServer(); fetch.mockJSONResponseOnce({ - data: { me: { id: 1, username: '@jbaxleyiii' } }, + data: { me: { username: '@jbaxleyiii' } }, }); const result = await server.executeOperation({ - query: '{ me { id username } }', + query: '{ me { username } }', }); expect(result.data).toMatchInlineSnapshot(` Object { "me": Object { - "id": "1", "username": "@jbaxleyiii", }, } @@ -38,7 +37,7 @@ describe('Using supergraphSdl configuration', () => { const [url, request] = fetch.mock.calls[0]; expect(url).toEqual('https://accounts.api.com'); expect(request?.body).toEqual( - JSON.stringify({ query: '{me{id username}}', variables: {} }), + JSON.stringify({ query: '{me{username}}', variables: {} }), ); await server.stop(); }); diff --git a/gateway-js/src/__tests__/gateway/executor.test.ts b/gateway-js/src/__tests__/gateway/executor.test.ts index baf84535a..440f6f3a9 100644 --- a/gateway-js/src/__tests__/gateway/executor.test.ts +++ b/gateway-js/src/__tests__/gateway/executor.test.ts @@ -56,8 +56,8 @@ describe('ApolloGateway executor', () => { ); }); - it('should not crash if no variables are not provided', async () => { - const me = { id: '1', birthDate: '1988-10-21'}; + it('should not crash if variables are not provided', async () => { + const me = { birthDate: '1988-10-21'}; fetch.mockJSONResponseOnce({ data: { me } }); const gateway = new ApolloGateway({ localServiceList: fixtures, @@ -68,7 +68,6 @@ describe('ApolloGateway executor', () => { const source = `#graphql query Me($locale: String) { me { - id birthDate(locale: $locale) } } diff --git a/gateway-js/src/executeQueryPlan.ts b/gateway-js/src/executeQueryPlan.ts index 8090cf4cc..ca6eea807 100644 --- a/gateway-js/src/executeQueryPlan.ts +++ b/gateway-js/src/executeQueryPlan.ts @@ -24,7 +24,8 @@ import { ResponsePath, QueryPlanSelectionNode, QueryPlanFieldNode, - getResponseName + getResponseName, + toAPISchema } from '@apollo/query-planner'; import { deepMerge } from './utilities/deepMerge'; import { isNotNullOrUndefined } from './utilities/array'; @@ -90,7 +91,7 @@ export async function executeQueryPlan( // It is also used to allow execution of introspection queries though. try { const executionResult = await execute({ - schema: operationContext.schema, + schema: toAPISchema(operationContext.schema), document: { kind: Kind.DOCUMENT, definitions: [ diff --git a/gateway-js/src/index.ts b/gateway-js/src/index.ts index f16d53f05..ee21f2492 100644 --- a/gateway-js/src/index.ts +++ b/gateway-js/src/index.ts @@ -39,7 +39,7 @@ import { getVariableValues } from 'graphql/execution/values'; import fetcher from 'make-fetch-happen'; import { HttpRequestCache } from './cache'; import { fetch } from 'apollo-server-env'; -import { QueryPlanner, QueryPlan, prettyFormatQueryPlan } from '@apollo/query-planner'; +import { QueryPlanner, QueryPlan, prettyFormatQueryPlan, toAPISchema } from '@apollo/query-planner'; import { ServiceEndpointDefinition, Experimental_DidFailCompositionCallback, @@ -435,7 +435,7 @@ export class ApolloGateway implements GraphQLService { throw e; } - this.schema = schema; + this.schema = toAPISchema(schema); // TODO(trevor): #580 redundant parse this.parsedSupergraphSdl = parse(supergraphSdl); this.queryPlanner = new QueryPlanner(schema); @@ -515,7 +515,7 @@ export class ApolloGateway implements GraphQLService { "A valid schema couldn't be composed. Falling back to previous schema.", ); } else { - this.schema = schema; + this.schema = toAPISchema(schema); this.queryPlanner = new QueryPlanner(schema); // Notify the schema listeners of the updated schema @@ -588,7 +588,7 @@ export class ApolloGateway implements GraphQLService { "A valid schema couldn't be composed. Falling back to previous schema.", ); } else { - this.schema = schema; + this.schema = toAPISchema(schema); this.queryPlanner = new QueryPlanner(schema); // Notify the schema listeners of the updated schema diff --git a/package-lock.json b/package-lock.json index 07c80bd4c..e0808b393 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22853,6 +22853,7 @@ "version": "0.2.3", "license": "MIT", "dependencies": { + "apollo-graphql": "^0.9.3", "chalk": "^4.1.0", "deep-equal": "^2.0.5", "pretty-format": "^26.0.0" @@ -22947,6 +22948,7 @@ "@apollo/query-planner": { "version": "file:query-planner-js", "requires": { + "apollo-graphql": "^0.9.3", "chalk": "^4.1.0", "deep-equal": "^2.0.5", "pretty-format": "^26.0.0" diff --git a/package.json b/package.json index 450dc975b..e549d3ba5 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@graphql-codegen/cli": "1.21.7", "@graphql-codegen/typescript": "1.23.0", "@graphql-codegen/typescript-operations": "1.18.4", + "@opentelemetry/node": "0.23.0", "@rollup/plugin-commonjs": "19.0.1", "@rollup/plugin-node-resolve": "13.0.2", "@types/bunyan": "1.8.7", @@ -68,7 +69,6 @@ "mocked-env": "1.3.5", "nock": "13.1.1", "node-fetch": "2.6.1", - "@opentelemetry/node": "0.23.0", "prettier": "2.3.2", "rollup": "2.51.1", "rollup-plugin-node-polyfills": "0.2.1", diff --git a/query-planner-js/package.json b/query-planner-js/package.json index 009dd1add..b7de9dd88 100644 --- a/query-planner-js/package.json +++ b/query-planner-js/package.json @@ -25,6 +25,7 @@ "access": "public" }, "dependencies": { + "apollo-graphql": "^0.9.3", "chalk": "^4.1.0", "deep-equal": "^2.0.5", "pretty-format": "^26.0.0" diff --git a/query-planner-js/src/composedSchema/__tests__/removeInaccessibleElements.test.ts b/query-planner-js/src/composedSchema/__tests__/removeInaccessibleElements.test.ts new file mode 100644 index 000000000..42936dd44 --- /dev/null +++ b/query-planner-js/src/composedSchema/__tests__/removeInaccessibleElements.test.ts @@ -0,0 +1,257 @@ +import { buildSchema, assertValidSchema, GraphQLObjectType } from 'graphql'; +import { removeInaccessibleElements } from '../removeInaccessibleElements'; + +describe('removeInaccessibleElements', () => { + it(`removes @inaccessible fields`, () => { + let schema = buildSchema(` + directive @core(feature: String!) repeatable on SCHEMA + + directive @inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION + + schema + @core(feature: "https://specs.apollo.dev/core/v0.1") + @core(feature: "https://specs.apollo.dev/inaccessible/v0.1") + { + query: Query + } + + type Query { + someField: String + privateField: String @inaccessible + } + `); + + schema = removeInaccessibleElements(schema); + + const queryType = schema.getQueryType()!; + + expect(queryType.getFields()['someField']).toBeDefined(); + expect(queryType.getFields()['privateField']).toBeUndefined(); + }); + + it(`removes @inaccessible object types`, () => { + let schema = buildSchema(` + directive @core(feature: String!) repeatable on SCHEMA + + directive @inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION + + schema + @core(feature: "https://specs.apollo.dev/core/v0.1") + @core(feature: "https://specs.apollo.dev/inaccessible/v0.1") + { + query: Query + } + + type Query { + fooField: Foo @inaccessible + } + + type Foo @inaccessible { + someField: String + } + + union Bar = Foo + `); + + schema = removeInaccessibleElements(schema); + + expect(schema.getType('Foo')).toBeUndefined(); + }); + + it(`removes @inaccessible interface types`, () => { + let schema = buildSchema(` + directive @core(feature: String!) repeatable on SCHEMA + + directive @inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION + + schema + @core(feature: "https://specs.apollo.dev/core/v0.1") + @core(feature: "https://specs.apollo.dev/inaccessible/v0.1") + { + query: Query + } + + type Query { + fooField: Foo @inaccessible + } + + interface Foo @inaccessible { + someField: String + } + + type Bar implements Foo { + someField: String + } + `); + + schema = removeInaccessibleElements(schema); + + expect(schema.getType('Foo')).toBeUndefined(); + const barType = schema.getType('Bar') as GraphQLObjectType | undefined; + expect(barType).toBeDefined(); + expect(barType?.getFields()['someField']).toBeDefined(); + expect(barType?.getInterfaces()).toHaveLength(0); + }); + + it(`removes @inaccessible union types`, () => { + let schema = buildSchema(` + directive @core(feature: String!) repeatable on SCHEMA + + directive @inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION + + schema + @core(feature: "https://specs.apollo.dev/core/v0.1") + @core(feature: "https://specs.apollo.dev/inaccessible/v0.1") + { + query: Query + } + + type Query { + fooField: Foo @inaccessible + } + + union Foo @inaccessible = Bar | Baz + + type Bar { + someField: String + } + + type Baz { + anotherField: String + } + `); + + schema = removeInaccessibleElements(schema); + + expect(schema.getType('Foo')).toBeUndefined(); + expect(schema.getType('Bar')).toBeDefined(); + expect(schema.getType('Baz')).toBeDefined(); + }); + + it(`throws when a field returning an @inaccessible type isn't marked @inaccessible itself`, () => { + let schema = buildSchema(` + directive @core(feature: String!) repeatable on SCHEMA + + directive @inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION + + schema + @core(feature: "https://specs.apollo.dev/core/v0.1") + @core(feature: "https://specs.apollo.dev/inaccessible/v0.1") + { + query: Query + } + + type Query { + fooField: Foo + } + + type Foo @inaccessible { + someField: String + } + + union Bar = Foo + `); + + expect(() => { + removeInaccessibleElements(schema); + }).toThrow( + `Field Query.fooField returns an @inaccessible type without being marked @inaccessible itself`, + ); + }); + + it(`removes @inaccessible query root type`, () => { + let schema = buildSchema(` + directive @core(feature: String!) repeatable on SCHEMA + + directive @inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION + + schema + @core(feature: "https://specs.apollo.dev/core/v0.1") + @core(feature: "https://specs.apollo.dev/inaccessible/v0.1") + { + query: Query + } + + type Query @inaccessible { + fooField: Foo + } + + type Foo { + someField: String + } + `); + + schema = removeInaccessibleElements(schema); + + expect(schema.getQueryType()).toBeUndefined(); + expect(schema.getType('Query')).toBeUndefined(); + + expect(() => assertValidSchema(schema)).toThrow(); + }); + + it(`removes @inaccessible mutation root type`, () => { + let schema = buildSchema(` + directive @core(feature: String!) repeatable on SCHEMA + + directive @inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION + + schema + @core(feature: "https://specs.apollo.dev/core/v0.1") + @core(feature: "https://specs.apollo.dev/inaccessible/v0.1") + { + query: Query + mutation: Mutation + } + + type Query { + fooField: Foo + } + + type Mutation @inaccessible { + fooField: Foo + } + + type Foo { + someField: String + } + `); + + schema = removeInaccessibleElements(schema); + + expect(schema.getMutationType()).toBeUndefined(); + expect(schema.getType('Mutation')).toBeUndefined(); + }); + + it(`removes @inaccessible subscription root type`, () => { + let schema = buildSchema(` + directive @core(feature: String!) repeatable on SCHEMA + + directive @inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION + + schema + @core(feature: "https://specs.apollo.dev/core/v0.1") + @core(feature: "https://specs.apollo.dev/inaccessible/v0.1") + { + query: Query + subscription: Subscription + } + + type Query { + fooField: Foo + } + + type Subscription @inaccessible { + fooField: Foo + } + + type Foo { + someField: String + } + `); + + schema = removeInaccessibleElements(schema); + + expect(schema.getSubscriptionType()).toBeUndefined(); + expect(schema.getType('Subscription')).toBeUndefined(); + }); +}); diff --git a/query-planner-js/src/composedSchema/__tests__/buildComposedSchema.test.ts b/query-planner-js/src/composedSchema/__tests__/toAPISchema.test.ts similarity index 89% rename from query-planner-js/src/composedSchema/__tests__/buildComposedSchema.test.ts rename to query-planner-js/src/composedSchema/__tests__/toAPISchema.test.ts index f7a12c926..f1fa9a476 100644 --- a/query-planner-js/src/composedSchema/__tests__/buildComposedSchema.test.ts +++ b/query-planner-js/src/composedSchema/__tests__/toAPISchema.test.ts @@ -4,9 +4,9 @@ import { parse, } from 'graphql'; import path from 'path'; -import { buildComposedSchema } from '..'; +import { buildComposedSchema, toAPISchema } from '..'; -describe('buildComposedSchema', () => { +describe('toAPISchema', () => { let schema: GraphQLSchema; beforeAll(() => { @@ -17,7 +17,7 @@ describe('buildComposedSchema', () => { ); const supergraphSdl = fs.readFileSync(schemaPath, 'utf8'); - schema = buildComposedSchema(parse(supergraphSdl)); + schema = toAPISchema(buildComposedSchema(parse(supergraphSdl))); }); it(`doesn't include core directives`, () => { diff --git a/query-planner-js/src/composedSchema/buildComposedSchema.ts b/query-planner-js/src/composedSchema/buildComposedSchema.ts index 1c72d68c3..32ea7c2c1 100644 --- a/query-planner-js/src/composedSchema/buildComposedSchema.ts +++ b/query-planner-js/src/composedSchema/buildComposedSchema.ts @@ -1,11 +1,8 @@ import { buildASTSchema, DocumentNode, - GraphQLDirective, GraphQLError, - GraphQLNamedType, GraphQLSchema, - isDirective, isEnumType, isIntrospectionType, isObjectType, @@ -17,6 +14,7 @@ import { parseFieldSet, } from '../utilities/graphql'; import { MultiMap } from '../utilities/MultiMap'; +import { ParsedFeatureURL, parseFeatureURL } from './core'; import { FederationFieldMetadata, FederationTypeMetadata, @@ -26,6 +24,16 @@ import { Graph, } from './metadata'; +// TODO: We should replace this hard coded list of supported features by a way +// of differentiating between features that require runtime support and those +// that can be silently ignored, without enumerating features explicitly. +export const supportedFeatures: ParsedFeatureURL[] = [ + 'https://specs.apollo.dev/core/v0.1', + 'https://specs.apollo.dev/join/v0.1', + 'https://specs.apollo.dev/inaccessible/v0.1', + 'https://specs.apollo.dev/tag/v0.1' +].map(parseFeatureURL); + export function buildComposedSchema(document: DocumentNode): GraphQLSchema { const schema = buildASTSchema(document); @@ -47,13 +55,15 @@ export function buildComposedSchema(document: DocumentNode): GraphQLSchema { ); for (const coreDirectiveArgs of coreDirectivesArgs) { - const feature: string = coreDirectiveArgs['feature']; + const feature = parseFeatureURL(coreDirectiveArgs['feature']); + // TODO: Replace strict feature matching with version satisfaction from the + // Core Schema spec. if ( - !( - feature === 'https://specs.apollo.dev/core/v0.1' || - feature === 'https://specs.apollo.dev/join/v0.1' || - feature === 'https://specs.apollo.dev/tag/v0.1' + !supportedFeatures.some( + (supportedFeature) => + supportedFeature.identity === feature.identity && + supportedFeature.version === feature.version, ) ) { throw new GraphQLError( @@ -227,36 +237,9 @@ directive without an @${ownerDirective.name} directive`, } } - // We filter out schema elements that should not be exported to get to the - // API schema. - - const schemaConfig = schema.toConfig(); - - return new GraphQLSchema({ - ...schemaConfig, - types: schemaConfig.types.filter(isExported), - directives: schemaConfig.directives.filter(isExported), - }); - - // TODO: Implement the IsExported algorithm from the Core Schema spec. - function isExported(element: NamedSchemaElement) { - return !(isAssociatedWithFeature(element, coreName) || isAssociatedWithFeature(element, joinName)) - } - - function isAssociatedWithFeature( - element: NamedSchemaElement, - featureName: string, - ) { - return ( - // Only directives can use the unprefixed feature name - isDirective(element) && element.name === featureName || - element.name.startsWith(`${featureName}__`) - ); - } + return schema; } -type NamedSchemaElement = GraphQLDirective | GraphQLNamedType; - // This should never happen, hence 'programming error', but this assertion // guarantees the existence of `graph`. function assertGraphFound(graph: Graph | undefined, graphName: string, directiveName: string): asserts graph { diff --git a/query-planner-js/src/composedSchema/core.ts b/query-planner-js/src/composedSchema/core.ts new file mode 100644 index 000000000..2ce3d9e74 --- /dev/null +++ b/query-planner-js/src/composedSchema/core.ts @@ -0,0 +1,32 @@ +import { URL } from "url"; + +export interface ParsedFeatureURL { + identity: string; + name: string; + version: string; +} + +export function parseFeatureURL(feature: string): ParsedFeatureURL { + const url = new URL(feature); + + const path = url.pathname.split('/'); + + // Remove trailing slashes + while (path[path.length - 1] === '') { + path.pop(); + } + + const version = path.pop()!; + + const name = path[path.length - 1]; + + // This was copied from core-schema-js, not sure if this normalization is + // part of the spec? + url.hash = ''; + url.search = ''; + url.password = ''; + url.username = ''; + url.pathname = path.join('/'); + + return { identity: url.toString(), name, version }; +} diff --git a/query-planner-js/src/composedSchema/index.ts b/query-planner-js/src/composedSchema/index.ts index 5794db0c3..c1fc9d8ef 100644 --- a/query-planner-js/src/composedSchema/index.ts +++ b/query-planner-js/src/composedSchema/index.ts @@ -1,2 +1,3 @@ export { buildComposedSchema } from './buildComposedSchema'; +export { toAPISchema } from './toAPISchema'; export * from './metadata'; diff --git a/query-planner-js/src/composedSchema/removeInaccessibleElements.ts b/query-planner-js/src/composedSchema/removeInaccessibleElements.ts new file mode 100644 index 000000000..062fde81d --- /dev/null +++ b/query-planner-js/src/composedSchema/removeInaccessibleElements.ts @@ -0,0 +1,147 @@ +import { + ASTNode, + DirectiveNode, + GraphQLDirective, + GraphQLFieldConfigMap, + GraphQLInterfaceType, + GraphQLObjectType, + GraphQLSchema, + isInterfaceType, + isObjectType, + getNamedType, + GraphQLNamedType, + isUnionType, + GraphQLUnionType, + GraphQLError, + GraphQLCompositeType, +} from 'graphql'; +import { transformSchema } from 'apollo-graphql'; + +export function removeInaccessibleElements( + schema: GraphQLSchema, +): GraphQLSchema { + const inaccessibleDirective = schema.getDirective('inaccessible'); + if (!inaccessibleDirective) return schema; + + // We need to compute the types to remove beforehand, because we also need + // to remove any fields that return a removed type. Otherwise, GraphQLSchema + // being a graph just means the removed type would be added back. + const typesToRemove = new Set( + Object.values(schema.getTypeMap()).filter((type) => { + // If the type hasn't been built from an AST, it won't have directives. + // This shouldn't happen, because we only call this function from + // buildComposedSchema and that builds the schema from the supergraph SDL. + if (!type.astNode) return false; + + // If the type itself has `@inaccessible`, remove it. + return hasDirective(inaccessibleDirective, type.astNode); + }), + ); + + removeRootTypesIfNeeded(); + + return transformSchema(schema, (type) => { + // Remove the type. + if (typesToRemove.has(type)) return null; + + if (isObjectType(type)) { + const typeConfig = type.toConfig(); + + return new GraphQLObjectType({ + ...typeConfig, + fields: removeInaccessibleFields(type, typeConfig.fields), + interfaces: removeInaccessibleTypes(typeConfig.interfaces) + }); + } else if (isInterfaceType(type)) { + const typeConfig = type.toConfig(); + + return new GraphQLInterfaceType({ + ...typeConfig, + fields: removeInaccessibleFields(type, typeConfig.fields), + interfaces: removeInaccessibleTypes(typeConfig.interfaces) + }); + } else if (isUnionType(type)) { + const typeConfig = type.toConfig(); + + return new GraphQLUnionType({ + ...typeConfig, + types: removeInaccessibleTypes(typeConfig.types) + }); + } else { + // Keep the type as is. + return undefined; + } + }); + + function removeRootTypesIfNeeded() { + let schemaConfig = schema.toConfig(); + let hasRemovedRootType = false; + + const queryType = schema.getQueryType(); + + if (queryType && typesToRemove.has(queryType)) { + schemaConfig.query = undefined; + hasRemovedRootType = true; + } + + const mutationType = schema.getMutationType(); + + if (mutationType && typesToRemove.has(mutationType)) { + schemaConfig.mutation = undefined; + hasRemovedRootType = true; + } + + const subscriptionType = schema.getSubscriptionType(); + + if (subscriptionType && typesToRemove.has(subscriptionType)) { + schemaConfig.subscription = undefined; + hasRemovedRootType = true; + } + + if (hasRemovedRootType) { + schema = new GraphQLSchema(schemaConfig); + } + } + + function removeInaccessibleFields( + type: GraphQLCompositeType, + fieldMapConfig: GraphQLFieldConfigMap, + ) { + const newFieldMapConfig: GraphQLFieldConfigMap = + Object.create(null); + + for (const [fieldName, fieldConfig] of Object.entries(fieldMapConfig)) { + if ( + fieldConfig.astNode && + hasDirective(inaccessibleDirective!, fieldConfig.astNode) + ) { + continue; + } else if (typesToRemove.has(getNamedType(fieldConfig.type))) { + throw new GraphQLError( + `Field ${type.name}.${fieldName} returns ` + + `an @inaccessible type without being marked @inaccessible itself.`, + fieldConfig.astNode, + ); + } + + newFieldMapConfig[fieldName] = fieldConfig; + } + + return newFieldMapConfig; + } + + function removeInaccessibleTypes(types: T[]) { + return types.filter(type => !typesToRemove.has(type)) + } +} + +function hasDirective( + directiveDef: GraphQLDirective, + node: { directives?: readonly DirectiveNode[] } & ASTNode, +): boolean { + if (!node.directives) return false; + + return node.directives.some( + (directiveNode) => directiveNode.name.value === directiveDef.name, + ); +} diff --git a/query-planner-js/src/composedSchema/toAPISchema.ts b/query-planner-js/src/composedSchema/toAPISchema.ts new file mode 100644 index 000000000..995c76c45 --- /dev/null +++ b/query-planner-js/src/composedSchema/toAPISchema.ts @@ -0,0 +1,72 @@ +import { + assertValidSchema, + GraphQLDirective, + GraphQLNamedType, + GraphQLSchema, + isDirective, +} from 'graphql'; +import { supportedFeatures } from './buildComposedSchema'; +import { removeInaccessibleElements } from './removeInaccessibleElements'; + +declare module 'graphql' { + interface GraphQLSchema { + __apiSchema: GraphQLSchema | undefined; + } +} + +export function toAPISchema(schema: GraphQLSchema): GraphQLSchema { + // Similar to `validateSchema()` caching its result in `__validationErrors`, + // we cache the API schema to avoid recomputing it unnecessarily. + + if (schema.__apiSchema) { + return schema.__apiSchema; + } + + assertValidSchema(schema); + + schema = removeInaccessibleElements(schema); + + // TODO: We should get a list of feature names from the schema itself, rather + // than relying on a static list of supported features. + const featureNames = supportedFeatures.map(feature => feature.name); + + // We filter out schema elements that should not be exported to get to the + // API schema. + + const schemaConfig = schema.toConfig(); + + const apiSchema = new GraphQLSchema({ + ...schemaConfig, + types: schemaConfig.types.filter(isExported), + directives: schemaConfig.directives.filter(isExported), + }); + + assertValidSchema(apiSchema); + + schema.__apiSchema = apiSchema; + + return apiSchema; + + function isExported(element: NamedSchemaElement) { + for (const featureName of featureNames) { + // For now, we skip any element that is associated with a feature. + if (isAssociatedWithFeature(element, featureName)) { + return false; + } + } + return true; + } +} + +type NamedSchemaElement = GraphQLDirective | GraphQLNamedType; + +function isAssociatedWithFeature( + element: NamedSchemaElement, + featureName: string, +) { + return ( + // Only directives can use the unprefixed feature name. + (isDirective(element) && element.name === featureName) || + element.name.startsWith(`${featureName}__`) + ); +}