Skip to content

Commit

Permalink
feat(gateway): Strip federation primitives during normalization (apol…
Browse files Browse the repository at this point in the history
…lographql/apollo-server#4209)

This commit makes references to "federation primitives". This is just a way
of saying all of the additions to a schema that federation requires as
listed in the spec.

This commit removes all federation primitives during the
normalization step of composition. This simplifies the lives of
all non-ApolloServer federation implementors.

buildFederatedSchema goes to some lengths to provide a limited
subset of SDL in the { _service { sdl } } resolver. In its current form,
composition expects this format. This subset is fairly
easy to achieve in JavaScript land, but isn't necessarily simple
in other, non-JS graphql reference implementations. This has been
an outstanding pain point for an endless number of users and
can be quite simply normalized away during this step.

This enables implementors to return a service's complete SDL from
the { _service { sdl } } resolver without any errors. For
unmanaged users, the gateway will now normalize the federation
primitives away. For managed users, our backend will allow
a service:push to contain federation primitives which will be
normalized away in the same fashion.

Fixes apollographql/apollo-server#3334
Apollo-Orig-Commit-AS: apollographql/apollo-server@b739e21
  • Loading branch information
trevor-scheer authored Jun 8, 2020
1 parent 4b36905 commit 2040775
Show file tree
Hide file tree
Showing 7 changed files with 255 additions and 40 deletions.
146 changes: 146 additions & 0 deletions federation-js/src/composition/__tests__/normalize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
defaultRootOperationTypes,
replaceExtendedDefinitionsWithExtensions,
normalizeTypeDefs,
stripFederationPrimitives,
} from '../normalize';
import { astSerializer } from '../../snapshotSerializers';

Expand Down Expand Up @@ -142,15 +143,160 @@ describe('SDL normalization and its respective parts', () => {
});
});

describe('stripFederationPrimitives', () => {
it(`removes all federation directive definitions`, () => {
const typeDefs = gql`
directive @key(fields: _FieldSet!) on OBJECT | INTERFACE
directive @external on FIELD_DEFINITION
directive @requires(fields: _FieldSet!) on FIELD_DEFINITION
directive @provides(fields: _FieldSet!) on FIELD_DEFINITION
directive @extends on OBJECT | INTERFACE
type Query {
thing: String
}
`;

expect(stripFederationPrimitives(typeDefs)).toMatchInlineSnapshot(`
type Query {
thing: String
}
`);
});

it(`doesn't remove custom directive definitions`, () => {
const typeDefs = gql`
directive @custom on OBJECT
type Query {
thing: String
}
`;

expect(stripFederationPrimitives(typeDefs)).toMatchInlineSnapshot(`
directive @custom on OBJECT
type Query {
thing: String
}
`);
});

it(`removes all federation type definitions (scalars, unions, object types)`, () => {
const typeDefs = gql`
scalar _Any
scalar _FieldSet
union _Entity
type _Service {
sdl: String
}
type Query {
thing: String
}
`;

expect(stripFederationPrimitives(typeDefs)).toMatchInlineSnapshot(`
type Query {
thing: String
}
`);
});

it(`doesn't remove custom scalar, union, or object type definitions`, () => {
const typeDefs = gql`
scalar CustomScalar
type CustomType {
field: String!
}
union CustomUnion
type Query {
thing: String
}
`;

expect(stripFederationPrimitives(typeDefs)).toMatchInlineSnapshot(`
scalar CustomScalar
type CustomType {
field: String!
}
union CustomUnion
type Query {
thing: String
}
`);
});

it(`removes all federation field definitions (_service, _entities)`, () => {
const typeDefs = gql`
type Query {
_service: _Service!
_entities(representations: [_Any!]!): [_Entity]!
thing: String
}
`;

expect(stripFederationPrimitives(typeDefs)).toMatchInlineSnapshot(`
type Query {
thing: String
}
`);
});

it(`removes the Query type altogether if it has no fields left after normalization`, () => {
const typeDefs = gql`
type Query {
_service: _Service!
_entities(representations: [_Any!]!): [_Entity]!
}
type Custom {
field: String
}
`;

expect(stripFederationPrimitives(typeDefs)).toMatchInlineSnapshot(`
type Custom {
field: String
}
`);
});
});

describe('normalizeTypeDefs', () => {
it('integration', () => {
const typeDefsToNormalize = gql`
directive @key(fields: _FieldSet!) on OBJECT | INTERFACE
directive @external on FIELD_DEFINITION
directive @requires(fields: _FieldSet!) on FIELD_DEFINITION
directive @provides(fields: _FieldSet!) on FIELD_DEFINITION
directive @extends on OBJECT | INTERFACE
scalar _Any
scalar _FieldSet
union _Entity
type _Service {
sdl: String
}
schema {
query: RootQuery
mutation: RootMutation
}
type RootQuery {
_service: _Service!
_entities(representations: [_Any!]!): [_Entity]!
product: Product
}
Expand Down
14 changes: 4 additions & 10 deletions federation-js/src/composition/compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
isFederationDirective,
executableDirectiveLocations,
stripTypeSystemDirectivesFromTypeDefs,
defaultRootOperationNameLookup,
} from './utils';
import {
ServiceDefinition,
Expand All @@ -41,13 +42,13 @@ import { compositionRules } from './rules';

const EmptyQueryDefinition = {
kind: Kind.OBJECT_TYPE_DEFINITION,
name: { kind: Kind.NAME, value: 'Query' },
name: { kind: Kind.NAME, value: defaultRootOperationNameLookup.query },
fields: [],
serviceName: null,
};
const EmptyMutationDefinition = {
kind: Kind.OBJECT_TYPE_DEFINITION,
name: { kind: Kind.NAME, value: 'Mutation' },
name: { kind: Kind.NAME, value: defaultRootOperationNameLookup.mutation },
fields: [],
serviceName: null,
};
Expand Down Expand Up @@ -531,16 +532,9 @@ export function composeServices(services: ServiceDefinition[]) {

// TODO: We should fix this to take non-default operation root types in
// implementing services into account.

const operationTypeMap = {
query: 'Query',
mutation: 'Mutation',
subscription: 'Subscription',
};

schema = new GraphQLSchema({
...schema.toConfig(),
...mapValues(operationTypeMap, typeName =>
...mapValues(defaultRootOperationNameLookup, typeName =>
typeName
? (schema.getType(typeName) as GraphQLObjectType<any, any>)
: undefined,
Expand Down
3 changes: 2 additions & 1 deletion federation-js/src/composition/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ export * from './compose';
export * from './composeAndValidate';
export * from './types';
export { compositionRules } from './rules';
export { defaultRootOperationNameLookup, normalizeTypeDefs } from './normalize';
export { normalizeTypeDefs } from './normalize';
export { defaultRootOperationNameLookup } from './utils';
93 changes: 80 additions & 13 deletions federation-js/src/composition/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,30 @@ import {
DocumentNode,
visit,
ObjectTypeDefinitionNode,
ObjectTypeExtensionNode,
Kind,
OperationTypeNode,
InterfaceTypeDefinitionNode,
VisitFn,
} from 'graphql';
import { findDirectivesOnTypeOrField, defKindToExtKind } from './utils';
import {
findDirectivesOnTypeOrField,
defKindToExtKind,
reservedRootFields,
defaultRootOperationNameLookup
} from './utils';
import federationDirectives from '../directives';

export function normalizeTypeDefs(typeDefs: DocumentNode) {
return defaultRootOperationTypes(
replaceExtendedDefinitionsWithExtensions(typeDefs),
// The order of this is important - `stripFederationPrimitives` must come after
// `defaultRootOperationTypes` because it depends on the `Query` type being named
// its default: `Query`.
return stripFederationPrimitives(
defaultRootOperationTypes(
replaceExtendedDefinitionsWithExtensions(typeDefs),
),
);
}

// Map of OperationTypeNode to its respective default root operation type name
export const defaultRootOperationNameLookup: {
[node in OperationTypeNode]: DefaultRootOperationTypeName;
} = {
query: 'Query',
mutation: 'Mutation',
subscription: 'Subscription',
};

export function defaultRootOperationTypes(
typeDefs: DocumentNode,
): DocumentNode {
Expand Down Expand Up @@ -251,3 +254,67 @@ export function replaceExtendedDefinitionsWithExtensions(

return typeDefsWithExtendedTypesReplaced;
}

// For non-ApolloServer libraries that support federation, this allows a
// library to report the entire schema's SDL rather than an awkward, stripped out
// subset of the schema. Generally there's no need to include the federation
// primitives, but in many cases it's more difficult to exclude them.
//
// This removes the following from a GraphQL Document:
// directives: @external, @key, @requires, @provides, @extends
// scalars: _Any, _FieldSet
// union: _Entity
// object type: _Service
// Query fields: _service, _entities
export function stripFederationPrimitives(document: DocumentNode) {
const typeDefinitionVisitor: VisitFn<
any,
ObjectTypeDefinitionNode | ObjectTypeExtensionNode
> = (node) => {
// Remove the `_entities` and `_service` fields from the `Query` type
if (node.name.value === defaultRootOperationNameLookup.query) {
const filteredFieldDefinitions = node.fields?.filter(
(fieldDefinition) =>
!reservedRootFields.includes(fieldDefinition.name.value),
);

// If the 'Query' type is now empty just remove it
if (!filteredFieldDefinitions || filteredFieldDefinitions.length === 0) {
return null;
}

return {
...node,
fields: filteredFieldDefinitions,
};
}

// Remove the _Service type from the document
const isFederationType = node.name.value === '_Service';
return isFederationType ? null : node;
};

return visit(document, {
// Remove all federation directive definitions from the document
DirectiveDefinition(node) {
const isFederationDirective = federationDirectives.some(
(directive) => directive.name === node.name.value,
);
return isFederationDirective ? null : node;
},
// Remove all federation scalar definitions from the document
ScalarTypeDefinition(node) {
const isFederationScalar = ['_Any', '_FieldSet'].includes(
node.name.value,
);
return isFederationScalar ? null : node;
},
// Remove all federation union definitions from the document
UnionTypeDefinition(node) {
const isFederationUnion = node.name.value === "_Entity";
return isFederationUnion ? null : node;
},
ObjectTypeDefinition: typeDefinitionVisitor,
ObjectTypeExtension: typeDefinitionVisitor,
});
}
14 changes: 13 additions & 1 deletion federation-js/src/composition/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,10 @@ import {
ASTNode,
DirectiveDefinitionNode,
GraphQLDirective,
OperationTypeNode,
} from 'graphql';
import Maybe from 'graphql/tsutils/Maybe';
import { ExternalFieldDefinition } from './types';
import { ExternalFieldDefinition, DefaultRootOperationTypeName } from './types';
import federationDirectives from '../directives';

export function isStringValueNode(node: any): node is StringValueNode {
Expand Down Expand Up @@ -549,3 +550,14 @@ export const executableDirectiveLocations = [
export function isFederationDirective(directive: GraphQLDirective): boolean {
return federationDirectives.some(({ name }) => name === directive.name);
}

export const reservedRootFields = ['_service', '_entities'];

// Map of OperationTypeNode to its respective default root operation type name
export const defaultRootOperationNameLookup: {
[node in OperationTypeNode]: DefaultRootOperationTypeName;
} = {
query: 'Query',
mutation: 'Mutation',
subscription: 'Subscription',
};
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { GraphQLError, visit } from 'graphql';
import { ServiceDefinition } from '../../types';
import { logServiceAndType, errorWithCode } from '../../utils';

const reservedRootFields = ['_service', '_entities'];
import {
logServiceAndType,
errorWithCode,
reservedRootFields
} from '../../utils';

/**
* - Schemas should not define the _service or _entitites fields on the query root
Expand Down
Loading

0 comments on commit 2040775

Please sign in to comment.