Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Validate against using keys that haven't been declared #3227

Closed
wants to merge 8 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/source/federation/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Apollo Federation implements a strict composition model. When building a gateway
- `KEY_FIELDS_SELECT_INVALID_TYPE`: The fields argument can not have root fields that result in a list, interface, or union type.
- `KEY_FIELDS_MISSING_ON_BASE`: the fields argument can not select fields that were overwritten by another service.
- `KEY_FIELDS_MISSING_EXTERNAL`: On extended types, keys must reference a field marked as `@external`.
- `KEY_NOT_DECLARED`: Keys of extended types must be declared on the base type.

### `@external` directives

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import gql from 'graphql-tag';
import { composeServices } from '../../../compose';
import { keySelectionSetsDeclared } from '..';
import { graphqlErrorSerializer } from '../../../../snapshotSerializers';

expect.addSnapshotSerializer(graphqlErrorSerializer);

describe('keySelectionSetsDeclared', () => {
it('returns no warnings with proper @key usage', () => {
const serviceA = {
typeDefs: gql`
type Product @key(fields: "sku") {
sku: String!
}
`,
name: 'serviceA',
};

const serviceB = {
typeDefs: gql`
extend type Product @key(fields: "sku") {
sku: String! @external
price: Int!
}
`,
name: 'serviceB',
};

const { schema, errors } = composeServices([serviceA, serviceB]);
expect(errors).toHaveLength(0);

const warnings = keySelectionSetsDeclared(schema);
expect(warnings).toHaveLength(0);
});

it("warns if @key is used that isn't declared on the base service", () => {
const serviceA = {
typeDefs: gql`
type Product @key(fields: "sku") {
sku: String!
upc: String!
}
`,
name: 'serviceA',
};

const serviceB = {
typeDefs: gql`
extend type Product @key(fields: "upc") {
sku: String! @external
upc: String! @external
price: Int!
}
`,
name: 'serviceB',
};

const { schema, errors } = composeServices([serviceA, serviceB]);
expect(errors).toHaveLength(0);

const warnings = keySelectionSetsDeclared(schema);
expect(warnings).toMatchInlineSnapshot(`
Array [
Object {
"code": "KEY_NOT_DECLARED",
"message": "[serviceB] Product -> uses the key \`upc\`, which is not declared on the base service. All keys must be declared on the base service.",
},
]
`);
});

it('does not warn if the only difference is ordering of fields', () => {
const serviceA = {
typeDefs: gql`
type Product
@key(
fields: "upc sku thing { a b product { upc sku thing { a b } } }"
) {
sku: String!
upc: String!
thing: Thing!
}

type Thing {
product: Product!
a: String
b: String
}
`,
name: 'serviceA',
};

const serviceB = {
typeDefs: gql`
extend type Product
@key(
fields: "upc thing { a product { thing { b a } sku upc } b } sku"
) {
sku: String! @external
upc: String! @external
price: Int!
}

type Thing {
product: Product!
a: String
b: String
}
`,
name: 'serviceB',
};

const { schema, errors } = composeServices([serviceA, serviceB]);
expect(errors).toHaveLength(0);

const warnings = keySelectionSetsDeclared(schema);
expect(warnings).toHaveLength(0);
});

it('warns if compound keys do not have the same fields', () => {
const serviceA = {
typeDefs: gql`
type Product @key(fields: "upc sku thing { a b }") {
sku: String!
upc: String!
thing: Thing!
}

type Thing {
product: Product!
a: String
b: String
}
`,
name: 'serviceA',
};

const serviceB = {
typeDefs: gql`
extend type Product @key(fields: "upc sku thing { a }") {
sku: String! @external
upc: String! @external
price: Int!
}

type Thing {
product: Product!
a: String
b: String
}
`,
name: 'serviceB',
};

const { schema, errors } = composeServices([serviceA, serviceB]);
expect(errors).toHaveLength(0);

const warnings = keySelectionSetsDeclared(schema);
expect(warnings).toMatchInlineSnapshot(`
Array [
Object {
"code": "KEY_NOT_DECLARED",
"message": "[serviceB] Product -> uses the key \`upc sku thing { a }\`, which is not declared on the base service. All keys must be declared on the base service.",
},
]
`);
});

it('does not warn if one of the multiple primary keys matches', () => {
const serviceA = {
typeDefs: gql`
type Product
@key(fields: "upc sku thing { a b }")
@key(fields: "upc sku") {
sku: String!
upc: String!
thing: Thing!
}

type Thing {
product: Product!
a: String
b: String
}
`,
name: 'serviceA',
};

const serviceB = {
typeDefs: gql`
extend type Product @key(fields: "upc sku") {
sku: String! @external
upc: String! @external
price: Int!
}

type Thing {
product: Product!
a: String
b: String
}
`,
name: 'serviceB',
};

const { schema, errors } = composeServices([serviceA, serviceB]);
expect(errors).toHaveLength(0);

const warnings = keySelectionSetsDeclared(schema);
expect(warnings).toHaveLength(0);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export { requiresFieldsMissingExternal } from './requiresFieldsMissingExternal';
export { requiresFieldsMissingOnBase } from './requiresFieldsMissingOnBase';
export { keyFieldsMissingOnBase } from './keyFieldsMissingOnBase';
export { keyFieldsSelectInvalidType } from './keyFieldsSelectInvalidType';
export { keySelectionSetsDeclared } from './keySelectionSetsDeclared';
export { providesFieldsMissingExternal } from './providesFieldsMissingExternal';
export {
providesFieldsSelectInvalidType,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { GraphQLSchema, isObjectType, FieldNode, GraphQLError } from 'graphql';

import { logServiceAndType, errorWithCode } from '../../utils';

/**
* - Each key's selection set must be present on the base service
*/
export const keySelectionSetsDeclared = (schema: GraphQLSchema) => {
const errors: GraphQLError[] = [];

const types = schema.getTypeMap();
for (const [typeName, namedType] of Object.entries(types)) {
if (!isObjectType(namedType)) continue;

if (namedType.federation && namedType.federation.keys) {
const baseService = namedType.federation.serviceName;
if (!baseService) {
// This is an invalid case which is handled by the UniqueTypeNamesWithFields validator, throwing error VALUE_TYPE_NO_ENTITY
continue;
}

const baseKeys = namedType.federation.keys[baseService] as FieldNode[][];

for (const [serviceName, selectionSets] of Object.entries(
namedType.federation.keys,
)) {
for (const selectionSet of selectionSets as FieldNode[][]) {
if (!baseKeys.some(key => selectionSetsEqual(key, selectionSet))) {
errors.push(
errorWithCode(
'KEY_NOT_DECLARED',
logServiceAndType(serviceName, typeName) +
`uses the key \`${selectionSetToString(
selectionSet,
)}\`, which is not declared on the base service. All keys must be declared on the base service.`,
),
);
}
}
}
}
}

return errors;
};

// graphql.print(...) gives non-ideal formatting.
const selectionSetToString = (a: FieldNode[]): string =>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use graphql.print() for this or is the outcome different from what I'm imagining?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It gave weird formatting in my experience, also it doesn't operate that smoothly with the FieldNode array, and we don't have a SelectionSet to pass it, so idk. Left a comment.

a
.map(
node =>
node.name.value +
(node.selectionSet
? ` { ${selectionSetToString(node.selectionSet
.selections as FieldNode[])} }`
: ''),
)
.join(' ');

const selectionSetsEqual = (a: FieldNode[], b: FieldNode[]) => {
if (a.length !== b.length) return false;
a = a.slice();
b = b.slice();

a.sort((_a, _b) => _a.name.value.localeCompare(_b.name.value));
b.sort((_a, _b) => _a.name.value.localeCompare(_b.name.value));

for (let i = 0; i < a.length; i++) {
const nodeA = a[i];
const nodeB = b[i];

if (nodeA.name.value !== nodeB.name.value) return false;

const nodeASelections = nodeA.selectionSet;
const nodeBSelections = nodeB.selectionSet;

if (nodeASelections && nodeBSelections) {
if (
!selectionSetsEqual(
nodeASelections.selections as FieldNode[],
nodeBSelections.selections as FieldNode[],
)
) {
return false;
}
} else if (nodeASelections || nodeBSelections) {
return false;
}
}
return true;
};