Skip to content

Commit

Permalink
feat(federation): @tag support for Object, Interface, and Union types (
Browse files Browse the repository at this point in the history
…#945)

With this change, @tag is now additionally permitted on the listed types above.
Users currently using @tag have been required by our validations to include the
directive definition - this definition will need to be updated accordingly. During
composition, @tag usages in subgraphs will be indiscriminately "rolled up" to the
final type in the supergraph.
  • Loading branch information
trevor-scheer authored Aug 10, 2021
1 parent 68cae3b commit afcd381
Show file tree
Hide file tree
Showing 18 changed files with 376 additions and 176 deletions.
2 changes: 1 addition & 1 deletion docs/source/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ If Apollo Gateway encounters an error, composition fails. This document lists co
## `@tag`
| Code | Description |
|---|---|
| `TAG_DIRECTIVE_DEFINITION_INVALID` | The `@tag` directive definition is included but defined incorrectly. Please include the correct `@tag` directive definition: `directive @tag(name: String!) repeatable on FIELD_DEFINITION`|
| `TAG_DIRECTIVE_DEFINITION_INVALID` | The `@tag` directive definition is included but defined incorrectly. Please include the correct `@tag` directive definition: `directive @tag(name: String!) repeatable on FIELD_DEFINITION | INTERFACE | OBJECT | UNION`|

## Custom directives

Expand Down
10 changes: 7 additions & 3 deletions federation-integration-testsuite-js/src/fixtures/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ export const url = `https://${name}.api.com`;
export const typeDefs = gql`
directive @stream on FIELD
directive @transform(from: String!) on FIELD
directive @tag(name: String!) repeatable on FIELD_DEFINITION
directive @tag(name: String!) repeatable on
| FIELD_DEFINITION
| INTERFACE
| OBJECT
| UNION
enum CacheControlScope {
PUBLIC
Expand Down Expand Up @@ -37,15 +41,15 @@ export const typeDefs = gql`
number: String
}
union AccountType = PasswordAccount | SMSAccount
union AccountType @tag(name: "from-accounts") = PasswordAccount | SMSAccount
type UserMetadata {
name: String
address: String
description: String
}
type User @key(fields: "id") @key(fields: "username name { first last }") {
type User @key(fields: "id") @key(fields: "username name { first last }") @tag(name: "from-accounts") {
id: ID! @tag(name: "accounts")
name: Name @cacheControl(inheritMaxAge: true)
username: String
Expand Down
12 changes: 8 additions & 4 deletions federation-integration-testsuite-js/src/fixtures/reviews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ export const url = `https://${name}.api.com`;
export const typeDefs = gql`
directive @stream on FIELD
directive @transform(from: String!) on FIELD
directive @tag(name: String!) repeatable on FIELD_DEFINITION
directive @tag(name: String!) repeatable on
| INTERFACE
| FIELD_DEFINITION
| OBJECT
| UNION
extend type Query {
topReviews(first: Int = 5): [Review]
Expand All @@ -29,16 +33,16 @@ export const typeDefs = gql`
address: String @external
}
extend type User @key(fields: "id") {
id: ID! @external @tag(name: "on external")
extend type User @key(fields: "id") @tag(name: "from-reviews") {
id: ID! @external @tag(name: "on-external")
username: String @external
reviews: [Review]
numberOfReviews: Int!
metadata: [UserMetadata] @external
goodAddress: Boolean @requires(fields: "metadata { address }")
}
extend interface Product {
extend interface Product @tag(name: "from-reviews") {
reviews: [Review]
}
Expand Down
1 change: 1 addition & 0 deletions federation-js/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
> The changes noted within this `vNEXT` section have not been released yet. New PRs and commits which introduce changes should include an entry in this `vNEXT` section as part of their development. When a release is being prepared, a new header will be (manually) created below and the appropriate changes within that release will be moved into the new section.
- Rename `buildFederatedSchema` to `buildSubgraphSchema`. The previous name will continue to be supported but is deprecated. No functional change, usages of `buildFederatedSchema` should just be replaced with `buildSubgraphSchema`. [PR #915](https://github.com/apollographql/federation/pull/913)
- Support @tag directive on Object, Interface, and Union types. This is a breaking change for current @tag users, as one of the validations was updated. Existing @tag definitions must now accomodate the additional locations `OBJECT | INTERFACE | UNION`. Usages of the @tag directive are rolled up indiscriminately during composition, just as they currently are with fields. For example, a @tag usage on an entity extension will end up in the supergraph alongside any other @tag usages on the same entity in other subgraphs. [PR #945](https://github.com/apollographql/federation/pull/945)

## v0.28.0

Expand Down
193 changes: 193 additions & 0 deletions federation-js/src/composition/DirectiveMetadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import {
ASTNode,
DirectiveNode,
FieldDefinitionNode,
GraphQLInterfaceType,
GraphQLObjectType,
GraphQLSchema,
GraphQLUnionType,
InterfaceTypeDefinitionNode,
InterfaceTypeExtensionNode,
ObjectTypeDefinitionNode,
ObjectTypeExtensionNode,
UnionTypeDefinitionNode,
UnionTypeExtensionNode,
visit,
VisitFn,
} from 'graphql';
import { mapGetOrSet } from '../utilities';
import { FederationField, FederationType, ServiceDefinition } from './types';
import { getFederationMetadata } from './utils';

// directive name => usages
export type DirectiveUsages = Map<string, DirectiveNode[]>;

// field name => directive name => usages
type DirectiveUsagesPerField = Map<string, DirectiveUsages>;

// type name => {
// directives: DirectiveUsages,
// fields: DirectiveUsagesPerField
// }
type DirectiveUsagesPerType = Map<
string,
{ directives: DirectiveUsages; fields: DirectiveUsagesPerField }
>;

// subgraph name => DirectiveUsagesPerType
type DirectiveUsagesPerSubgraph = Map<string, DirectiveUsagesPerType>;

type ObjectInterfaceOrUnionTypeNode =
| ObjectTypeDefinitionNode
| ObjectTypeExtensionNode
| InterfaceTypeDefinitionNode
| InterfaceTypeExtensionNode
| UnionTypeDefinitionNode
| UnionTypeExtensionNode;
export class DirectiveMetadata {
directiveUsagesPerSubgraph: DirectiveUsagesPerSubgraph;

constructor(subgraphs: ServiceDefinition[]) {
this.directiveUsagesPerSubgraph = new Map();
for (const subgraph of subgraphs) {
const visitor = this.getTypeVisitor(subgraph.name);
// visit each object-like type to build the map of directive usages
visit(subgraph.typeDefs, {
ObjectTypeDefinition: visitor,
ObjectTypeExtension: visitor,
InterfaceTypeDefinition: visitor,
InterfaceTypeExtension: visitor,
UnionTypeDefinition: visitor,
UnionTypeExtension: visitor,
});
}
}

// Returns a visitor function which is capable of visiting object, interface, and
// union nodes (and their extensions). The visitor returned from this function
// collects all directive usages in the data structure
// `this.directiveUsagesPerSubgraph`.
getTypeVisitor(
subgraphName: string,
): VisitFn<ASTNode, ObjectInterfaceOrUnionTypeNode> {
function collectDirectiveUsages(
node: ObjectInterfaceOrUnionTypeNode | FieldDefinitionNode,
usagesOnNode: DirectiveUsages,
) {
for (const directive of node.directives ?? []) {
const usages = mapGetOrSet(usagesOnNode, directive.name.value, []);
usages.push(directive);
}
}

// Return a visitor function
return (node) => {
const directiveUsagesPerType: DirectiveUsagesPerType = mapGetOrSet(
this.directiveUsagesPerSubgraph,
subgraphName,
new Map(),
);

const { directives: usagesOnType, fields: usagesByFieldName } =
mapGetOrSet(directiveUsagesPerType, node.name.value, {
directives: new Map<string, DirectiveNode[]>(),
fields: new Map<string, DirectiveUsages>(),
});

// Collect directive usages on the type node
collectDirectiveUsages(node, usagesOnType);

// Collect directive usages on each field node
if ('fields' in node && node.fields) {
for (const field of node.fields) {
const usagesOnField = mapGetOrSet(
usagesByFieldName,
field.name.value,
new Map<string, DirectiveNode[]>(),
);
collectDirectiveUsages(field, usagesOnField);
}
}
};
}

// visit the entire map for any usages of a directive
hasUsages(directiveName: string) {
for (const directiveUsagesPerType of this.directiveUsagesPerSubgraph.values()) {
for (const { directives, fields } of directiveUsagesPerType.values()) {
const usagesOnType = directives.get(directiveName);
if (usagesOnType && usagesOnType.length > 0) return true;

for (const directiveUsages of fields.values()) {
const usagesOnField = directiveUsages.get(directiveName);
if (usagesOnField && usagesOnField.length > 0) return true;
}
}
}
return false;
}

// traverse the map of directive usages and apply metadata to the corresponding
// `extensions` fields on the provided schema.
applyMetadataToSupergraphSchema(schema: GraphQLSchema) {
for (const directiveUsagesPerType of this.directiveUsagesPerSubgraph.values()) {
for (const [
typeName,
{ directives, fields },
] of directiveUsagesPerType.entries()) {
const namedType = schema.getType(typeName) as
| GraphQLObjectType
| GraphQLInterfaceType
| GraphQLUnionType
| undefined;
if (!namedType) continue;

const existingMetadata = getFederationMetadata(namedType);
let directiveUsages = existingMetadata?.directiveUsages;

if (directiveUsages && directiveUsages.size > 0) {
for (const [directiveName, usages] of directiveUsages.entries()) {
usages.push(...(directives.get(directiveName) ?? []));
}
} else {
directiveUsages = directives;
}

const typeFederationMetadata: FederationType = {
...existingMetadata,
directiveUsages,
};
namedType.extensions = {
...namedType.extensions,
federation: typeFederationMetadata,
};

for (const [fieldName, usagesPerDirective] of fields.entries()) {
if (!('getFields' in namedType)) continue;
const field = namedType.getFields()[fieldName];
if (!field) continue;

const originalMetadata = getFederationMetadata(field);
let directiveUsages = originalMetadata?.directiveUsages;
if (directiveUsages && directiveUsages.size > 0) {
for (const [directiveName, usages] of directiveUsages.entries()) {
usages.push(...(usagesPerDirective.get(directiveName) ?? []));
}
} else {
directiveUsages = usagesPerDirective;
}

const fieldFederationMetadata: FederationField = {
...originalMetadata,
directiveUsages,
};

field.extensions = {
...field.extensions,
federation: fieldFederationMetadata,
};
}
}
}
}
}
10 changes: 10 additions & 0 deletions federation-js/src/composition/__tests__/compose.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1076,6 +1076,11 @@ describe('composeServices', () => {
.toMatchInlineSnapshot(`
Object {
"belongsToValueType": false,
"directiveUsages": Map {
"provides" => Array [
@provides(fields: "sku"),
],
},
"provides": sku,
"serviceName": "serviceA",
}
Expand Down Expand Up @@ -1159,6 +1164,11 @@ describe('composeServices', () => {
.toMatchInlineSnapshot(`
Object {
"belongsToValueType": false,
"directiveUsages": Map {
"provides" => Array [
@provides(fields: "sku"),
],
},
"provides": sku,
"serviceName": "serviceA",
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,9 +175,9 @@ describe('unknown types', () => {
const inventory = {
name: 'inventory',
typeDefs: gql`
directive @tag(name: String!) repeatable on FIELD_DEFINITION
directive @tag(name: String!) repeatable on FIELD_DEFINITION | INTERFACE | OBJECT | UNION
extend type Product @key(fields: "id") {
id: ID! @external @tag(name: "hi from inventory")
id: ID! @external @tag(name: "from-inventory")
}
`,
};
Expand Down
Loading

0 comments on commit afcd381

Please sign in to comment.