Skip to content

Commit

Permalink
[deploy_website] feat(transforms): normalizing stitched subschemas (#…
Browse files Browse the repository at this point in the history
…1925)

Adds package support and a documented approach for handling Federation-style stitching patterns that result in field oddities outside of the gateway context.
  • Loading branch information
gmac committed Aug 24, 2020
1 parent 54ed871 commit 5ba1fc2
Show file tree
Hide file tree
Showing 15 changed files with 501 additions and 4 deletions.
2 changes: 2 additions & 0 deletions packages/utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,5 @@ export * from './errors';
export * from './toConfig';
export * from './observableToAsyncIterable';
export * from './visitResult';
export * from './getArgumentValues';
export * from './valueMatchesCriteria';
17 changes: 17 additions & 0 deletions packages/utils/src/valueMatchesCriteria.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export function valueMatchesCriteria(value: any, criteria: any): boolean {
if (value == null) {
return value === criteria;
} else if (Array.isArray(value)) {
return Array.isArray(criteria) && value.every((val, index) => valueMatchesCriteria(val, criteria[index]));
} else if (typeof value === 'object') {
return (
typeof criteria === 'object' &&
criteria &&
Object.keys(criteria).every(propertyName => valueMatchesCriteria(value[propertyName], criteria[propertyName]))
);
} else if (criteria instanceof RegExp) {
return criteria.test(value);
}

return value === criteria;
}
59 changes: 59 additions & 0 deletions packages/utils/tests/valueMatchesCriteria.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { valueMatchesCriteria } from '../src/index';

describe('valueMatchesCriteria', () => {
test('matches empty values', () => {
expect(valueMatchesCriteria(undefined, undefined)).toBe(true);
expect(valueMatchesCriteria(undefined, null)).toBe(false);
expect(valueMatchesCriteria(null, null)).toBe(true);
});

test('matches primitives', () => {
expect(valueMatchesCriteria(1, 1)).toBe(true);
expect(valueMatchesCriteria(1, 2)).toBe(false);
expect(valueMatchesCriteria('a', 'a')).toBe(true);
expect(valueMatchesCriteria('a', 'b')).toBe(false);
expect(valueMatchesCriteria(false, false)).toBe(true);
expect(valueMatchesCriteria(false, true)).toBe(false);
});

test('matches empty object values', () => {
expect(valueMatchesCriteria({}, {})).toBe(true);
});

test('matches value object with varying specificity', () => {
const dirValue = { reason: 'reason', also: 'also' };

expect(valueMatchesCriteria(dirValue, {})).toBe(true);
expect(valueMatchesCriteria(dirValue, { reason: 'reason' })).toBe(true);
expect(valueMatchesCriteria(dirValue, { reason: 'reason', also: 'also' })).toBe(true);
expect(valueMatchesCriteria(dirValue, { reason: 'reason', and: 'and' })).toBe(false);
expect(valueMatchesCriteria(dirValue, { this: 'this' })).toBe(false);
expect(valueMatchesCriteria(dirValue, { reason: 'this' })).toBe(false);
});

test('matches value objects recursively', () => {
const dirValue = { reason: 'reason', also: { a: 1, b: 2 } };

expect(valueMatchesCriteria(dirValue, { reason: 'reason' })).toBe(true);
expect(valueMatchesCriteria(dirValue, { also: {} })).toBe(true);
expect(valueMatchesCriteria(dirValue, { also: { a: 1 } })).toBe(true);
expect(valueMatchesCriteria(dirValue, { also: { a: 1, b: 2 } })).toBe(true);
expect(valueMatchesCriteria(dirValue, { also: { a: 1, b: 0 } })).toBe(false);
expect(valueMatchesCriteria(dirValue, { also: { c: 1 } })).toBe(false);
});

test('matches value arrays', () => {
const dirValue = [23, { hello: true, world: false }];

expect(valueMatchesCriteria(dirValue, [23, { hello: true }])).toBe(true);
expect(valueMatchesCriteria(dirValue, [23, { world: false }])).toBe(true);
expect(valueMatchesCriteria(dirValue, [{ hello: true }, 23])).toBe(false);
});

test('matches value with regex', () => {
const dirValue = { reason: 'requires: id' };

expect(valueMatchesCriteria(dirValue, { reason: /^requires:/ })).toBe(true);
expect(valueMatchesCriteria(dirValue, { reason: /^required:/ })).toBe(false);
});
});
36 changes: 36 additions & 0 deletions packages/wrap/src/transforms/FilterObjectFieldDirectives.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { GraphQLSchema, GraphQLFieldConfig } from 'graphql';
import { Transform, getArgumentValues } from '@graphql-tools/utils';
import TransformObjectFields from './TransformObjectFields';

export default class FilterObjectFieldDirectives implements Transform {
private readonly filter: (dirName: string, dirValue: any) => boolean;

constructor(filter: (dirName: string, dirValue: any) => boolean) {
this.filter = filter;
}

public transformSchema(originalSchema: GraphQLSchema): GraphQLSchema {
const transformer = new TransformObjectFields(
(_typeName: string, _fieldName: string, fieldConfig: GraphQLFieldConfig<any, any>) => {
const keepDirectives = fieldConfig.astNode.directives.filter(dir => {
const directiveDef = originalSchema.getDirective(dir.name.value);
const directiveValue = directiveDef ? getArgumentValues(directiveDef, dir) : undefined;
return this.filter(dir.name.value, directiveValue);
});

if (keepDirectives.length !== fieldConfig.astNode.directives.length) {
fieldConfig = {
...fieldConfig,
astNode: {
...fieldConfig.astNode,
directives: keepDirectives,
},
};
return fieldConfig;
}
}
);

return transformer.transformSchema(originalSchema);
}
}
28 changes: 28 additions & 0 deletions packages/wrap/src/transforms/RemoveObjectFieldDeprecations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { GraphQLSchema, GraphQLFieldConfig } from 'graphql';
import { Transform, valueMatchesCriteria } from '@graphql-tools/utils';
import { FilterObjectFieldDirectives, TransformObjectFields } from '@graphql-tools/wrap';

export default class RemoveObjectFieldDeprecations implements Transform {
private readonly removeDirectives: FilterObjectFieldDirectives;
private readonly removeDeprecations: TransformObjectFields;

constructor(reason: string | RegExp) {
const args = { reason };
this.removeDirectives = new FilterObjectFieldDirectives((dirName: string, dirValue: any) => {
return !(dirName === 'deprecated' && valueMatchesCriteria(dirValue, args));
});
this.removeDeprecations = new TransformObjectFields(
(_typeName: string, _fieldName: string, fieldConfig: GraphQLFieldConfig<any, any>) => {
if (fieldConfig.deprecationReason && valueMatchesCriteria(fieldConfig.deprecationReason, reason)) {
fieldConfig = { ...fieldConfig };
delete fieldConfig.deprecationReason;
}
return fieldConfig;
}
);
}

public transformSchema(originalSchema: GraphQLSchema): GraphQLSchema {
return this.removeDeprecations.transformSchema(this.removeDirectives.transformSchema(originalSchema));
}
}
17 changes: 17 additions & 0 deletions packages/wrap/src/transforms/RemoveObjectFieldDirectives.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { GraphQLSchema } from 'graphql';
import { Transform, valueMatchesCriteria } from '@graphql-tools/utils';
import { FilterObjectFieldDirectives } from '@graphql-tools/wrap';

export default class RemoveObjectFieldDirectives implements Transform {
private readonly transformer: FilterObjectFieldDirectives;

constructor(directiveName: string | RegExp, args: Record<string, any> = {}) {
this.transformer = new FilterObjectFieldDirectives((dirName: string, dirValue: any) => {
return !(valueMatchesCriteria(dirName, directiveName) && valueMatchesCriteria(dirValue, args));
});
}

public transformSchema(originalSchema: GraphQLSchema): GraphQLSchema {
return this.transformer.transformSchema(originalSchema);
}
}
22 changes: 22 additions & 0 deletions packages/wrap/src/transforms/RemoveObjectFieldsWithDeprecation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { GraphQLSchema, GraphQLFieldConfig } from 'graphql';
import { Transform, valueMatchesCriteria } from '@graphql-tools/utils';
import { FilterObjectFields } from '@graphql-tools/wrap';

export default class RemoveObjectFieldsWithDeprecation implements Transform {
private readonly transformer: FilterObjectFields;

constructor(reason: string | RegExp) {
this.transformer = new FilterObjectFields(
(_typeName: string, _fieldName: string, fieldConfig: GraphQLFieldConfig<any, any>) => {
if (fieldConfig.deprecationReason) {
return !valueMatchesCriteria(fieldConfig.deprecationReason, reason);
}
return true;
}
);
}

public transformSchema(originalSchema: GraphQLSchema): GraphQLSchema {
return this.transformer.transformSchema(originalSchema);
}
}
30 changes: 30 additions & 0 deletions packages/wrap/src/transforms/RemoveObjectFieldsWithDirective.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { GraphQLSchema, GraphQLFieldConfig } from 'graphql';
import { Transform, getDirectives, valueMatchesCriteria } from '@graphql-tools/utils';
import { FilterObjectFields } from '@graphql-tools/wrap';

export default class RemoveObjectFieldsWithDirective implements Transform {
private readonly directiveName: string | RegExp;
private readonly args: Record<string, any>;

constructor(directiveName: string | RegExp, args: Record<string, any> = {}) {
this.directiveName = directiveName;
this.args = args;
}

public transformSchema(originalSchema: GraphQLSchema): GraphQLSchema {
const transformer = new FilterObjectFields(
(_typeName: string, _fieldName: string, fieldConfig: GraphQLFieldConfig<any, any>) => {
const valueMap = getDirectives(originalSchema, fieldConfig);
return !Object.keys(valueMap).some(
directiveName =>
valueMatchesCriteria(directiveName, this.directiveName) &&
((Array.isArray(valueMap[directiveName]) &&
valueMap[directiveName].some((value: any) => valueMatchesCriteria(value, this.args))) ||
valueMatchesCriteria(valueMap[directiveName], this.args))
);
}
);

return transformer.transformSchema(originalSchema);
}
}
5 changes: 5 additions & 0 deletions packages/wrap/src/transforms/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ export { default as FilterInputObjectFields } from './FilterInputObjectFields';
export { default as MapLeafValues } from './MapLeafValues';
export { default as TransformEnumValues } from './TransformEnumValues';
export { default as TransformQuery } from './TransformQuery';
export { default as FilterObjectFieldDirectives } from './FilterObjectFieldDirectives';
export { default as RemoveObjectFieldDirectives } from './RemoveObjectFieldDirectives';
export { default as RemoveObjectFieldsWithDirective } from './RemoveObjectFieldsWithDirective';
export { default as RemoveObjectFieldDeprecations } from './RemoveObjectFieldDeprecations';
export { default as RemoveObjectFieldsWithDeprecation } from './RemoveObjectFieldsWithDeprecation';

export { default as ExtendSchema } from './ExtendSchema';
export { default as PruneSchema } from './PruneSchema';
Expand Down
29 changes: 29 additions & 0 deletions packages/wrap/tests/transformFilterObjectFieldDirectives.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { wrapSchema, FilterObjectFieldDirectives } from '@graphql-tools/wrap';
import { makeExecutableSchema } from '@graphql-tools/schema';

describe('FilterObjectFieldDirectives', () => {
test('removes unmatched field directives', async () => {
const schema = makeExecutableSchema({
typeDefs: `
directive @remove on FIELD_DEFINITION
directive @keep(arg: Int) on FIELD_DEFINITION
type Query {
alpha:String @remove
bravo:String @keep
charlie:String @keep(arg:1)
delta:String @keep(arg:2)
}
`
});

const transformedSchema = wrapSchema(schema, [
new FilterObjectFieldDirectives((dirName: string, dirValue: any) => dirName === 'keep' && dirValue.arg !== 1)
]);

const fields = transformedSchema.getType('Query').getFields();
expect(fields.alpha.astNode.directives.length).toEqual(0);
expect(fields.bravo.astNode.directives.length).toEqual(1);
expect(fields.charlie.astNode.directives.length).toEqual(0);
expect(fields.delta.astNode.directives.length).toEqual(1);
});
});
38 changes: 38 additions & 0 deletions packages/wrap/tests/transformRemoveObjectFieldDeprecations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { wrapSchema, RemoveObjectFieldDeprecations } from '@graphql-tools/wrap';
import { makeExecutableSchema } from '@graphql-tools/schema';

describe('RemoveObjectFieldDeprecations', () => {
const originalSchema = makeExecutableSchema({
typeDefs: `
type Test {
id: ID!
first: String! @deprecated(reason: "do not remove")
second: String! @deprecated(reason: "remove this")
}
`
});

test('removes deprecations by reason', async () => {
const transformedSchema = wrapSchema(originalSchema, [
new RemoveObjectFieldDeprecations('remove this')
]);

const fields = transformedSchema.getType('Test').getFields();
expect(fields.first.deprecationReason).toEqual('do not remove');
expect(fields.second.deprecationReason).toBeUndefined();
expect(fields.first.astNode.directives.length).toEqual(1);
expect(fields.second.astNode.directives.length).toEqual(0);
});

test('removes deprecations by reason regex', async () => {
const transformedSchema = wrapSchema(originalSchema, [
new RemoveObjectFieldDeprecations(/remove/)
]);

const fields = transformedSchema.getType('Test').getFields();
expect(fields.first.deprecationReason).toBeUndefined();
expect(fields.second.deprecationReason).toBeUndefined();
expect(fields.first.astNode.directives.length).toEqual(0);
expect(fields.second.astNode.directives.length).toEqual(0);
});
});
66 changes: 66 additions & 0 deletions packages/wrap/tests/transformRemoveObjectFieldDirectives.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { wrapSchema, RemoveObjectFieldDirectives } from '@graphql-tools/wrap';
import { makeExecutableSchema } from '@graphql-tools/schema';

describe('RemoveObjectFieldDirectives', () => {
const originalSchema = makeExecutableSchema({
typeDefs: `
directive @alpha(arg: String) on FIELD_DEFINITION
directive @bravo(arg: String) on FIELD_DEFINITION
type Test {
id: ID! @bravo(arg: "remove this")
first: String! @alpha(arg: "do not remove")
second: String! @alpha(arg: "remove this")
third: String @alpha(arg: "remove this")
}
`
});

test('removes directives by name', async () => {
const transformedSchema = wrapSchema(originalSchema, [
new RemoveObjectFieldDirectives('alpha')
]);

const fields = transformedSchema.getType('Test').getFields();
expect(fields.id.astNode.directives.length).toEqual(1);
expect(fields.first.astNode.directives.length).toEqual(0);
expect(fields.second.astNode.directives.length).toEqual(0);
expect(fields.third.astNode.directives.length).toEqual(0);
});

test('removes directives by name regex', async () => {
const transformedSchema = wrapSchema(originalSchema, [
new RemoveObjectFieldDirectives(/^alp/)
]);

const fields = transformedSchema.getType('Test').getFields();
expect(fields.id.astNode.directives.length).toEqual(1);
expect(fields.first.astNode.directives.length).toEqual(0);
expect(fields.second.astNode.directives.length).toEqual(0);
expect(fields.third.astNode.directives.length).toEqual(0);
});

test('removes directives by argument', async () => {
const transformedSchema = wrapSchema(originalSchema, [
new RemoveObjectFieldDirectives(/.+/, { arg: 'remove this' })
]);

const fields = transformedSchema.getType('Test').getFields();
expect(fields.id.astNode.directives.length).toEqual(0);
expect(fields.first.astNode.directives.length).toEqual(1);
expect(fields.second.astNode.directives.length).toEqual(0);
expect(fields.third.astNode.directives.length).toEqual(0);
});

test('removes directives by argument regex', async () => {
const transformedSchema = wrapSchema(originalSchema, [
new RemoveObjectFieldDirectives(/.+/, { arg: /remove/ })
]);

const fields = transformedSchema.getType('Test').getFields();
expect(fields.id.astNode.directives.length).toEqual(0);
expect(fields.first.astNode.directives.length).toEqual(0);
expect(fields.second.astNode.directives.length).toEqual(0);
expect(fields.third.astNode.directives.length).toEqual(0);
});
});

0 comments on commit 5ba1fc2

Please sign in to comment.