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

Transforms for normalizing stitched subschema deprecations #1925

Merged
merged 30 commits into from
Aug 24, 2020
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
28885c6
[deploy_website] add transforms for normalizing subschema deprecations.
gmac Aug 19, 2020
23f2468
fix import/export
gmac Aug 19, 2020
a3b6ee5
fix linting errors.
gmac Aug 19, 2020
3268533
[deploy_website] my crash course in typescript.
gmac Aug 19, 2020
0eb33a7
eat more dogfood.
gmac Aug 19, 2020
a543d19
im terrible at typescript.
gmac Aug 19, 2020
713154b
refactor into respective libs.
gmac Aug 19, 2020
ea3778e
yeah, whoops.
gmac Aug 19, 2020
bceead6
is this right?
gmac Aug 19, 2020
e7c6358
also check the ast composition.
gmac Aug 19, 2020
48ed54c
fix rogue typing.
gmac Aug 19, 2020
3d405e0
shrink diff.
gmac Aug 19, 2020
415773c
rename RemoveFieldsWithDirective.
gmac Aug 19, 2020
67e931d
another better name.
gmac Aug 19, 2020
894478e
Fix documentation mistake
gmac Aug 20, 2020
88f9cab
fix incomplete test.
gmac Aug 20, 2020
1f7e249
compare typed directives.
gmac Aug 21, 2020
d3dd95e
add tests for empty criteria.
gmac Aug 21, 2020
d47ef15
refactor
yaacovCR Aug 21, 2020
dbd92f5
small fix
yaacovCR Aug 21, 2020
c129730
Merge pull request #1 from ardatan/hmmmm
gmac Aug 22, 2020
f3306e9
Merge branch 'master' of https://github.com/ardatan/graphql-tools int…
gmac Aug 22, 2020
52480d7
add FilterFieldDirectives tests.
gmac Aug 22, 2020
4c366a2
more tests for valueMatchesCriteria.
gmac Aug 22, 2020
2ff6fa9
cleanup stitching package tests.
gmac Aug 22, 2020
03564a4
break apart deprecations from directive filters.
gmac Aug 23, 2020
4d2b3d9
cleanup naming and fix typescript error.
gmac Aug 23, 2020
57b7d67
[deploy_website] remove nested ifs.
gmac Aug 23, 2020
ce730da
eat more dogfood.
gmac Aug 23, 2020
eeafdad
renaming.
gmac Aug 24, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/stitch/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
export { stitchSchemas } from './stitchSchemas';
export { forwardArgsToSelectionSet } from './selectionSetArgs';
export { default as RemoveFieldDirectives } from './transforms/RemoveFieldDirectives';
gmac marked this conversation as resolved.
Show resolved Hide resolved
export { default as RemoveFieldsWithDirective } from './transforms/RemoveFieldsWithDirective';
15 changes: 15 additions & 0 deletions packages/stitch/src/transforms/RemoveFieldDirectives.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { GraphQLSchema, DirectiveNode } from 'graphql';
import { Transform, matchDirective } from '@graphql-tools/utils';
import { FilterFieldDirectives } from '@graphql-tools/wrap';

export default class RemoveFieldDirectives implements Transform {
private readonly transformer: FilterFieldDirectives;

constructor(directiveName: string, args: Record<string, any> = {}) {
this.transformer = new FilterFieldDirectives((dir: DirectiveNode) => !matchDirective(dir, directiveName, args));
}

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

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

constructor(directiveName: string, args: Record<string, any> = {}) {
this.transformer = new FilterObjectFields(
(_typeName: string, _fieldName: string, fieldConfig: GraphQLFieldConfig<any, any>) => {
return !!fieldConfig.astNode.directives.find(dir => matchDirective(dir, directiveName, args));
}
);
}

public transformSchema(originalSchema: GraphQLSchema): GraphQLSchema {
return this.transformer.transformSchema(originalSchema);
}
}
60 changes: 60 additions & 0 deletions packages/stitch/tests/transformDirectives.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { makeExecutableSchema } from '@graphql-tools/schema';
import { stitchSchemas } from '../src/stitchSchemas';
import { RemoveFieldDirectives, RemoveFieldsWithDirective } from '../src/index';

describe('transform deprecations', () => {
test('removes directives with arguments, includes deprecations', async () => {
const listingsSchema = makeExecutableSchema({
typeDefs: `
type Listing {
id: ID!
description: String!
price: Float!
sellerId: ID! @deprecated(reason: "stitching use only")
buyerId: ID @deprecated(reason: "stitching use only")
}
`
});

const usersSchema = makeExecutableSchema({
typeDefs: `
type User {
id: ID!
email: String! @deprecated(reason: "other deprecation")
}
type Listing {
seller: User! @deprecated(reason: "gateway access only")
buyer: User @deprecated(reason: "gateway access only")
}
`
});



const gatewaySchema = stitchSchemas({
subschemas: [
{
schema: listingsSchema,
transforms: [new RemoveFieldsWithDirective('deprecated', { reason: 'gateway access only' })]
gmac marked this conversation as resolved.
Show resolved Hide resolved
},
{
schema: usersSchema,
transforms: [new RemoveFieldDirectives('deprecated', { reason: 'gateway access only' })]
},
],
});

expect(listingsSchema.getType('Listing').getFields().sellerId.deprecationReason).toBe('stitching use only');
expect(gatewaySchema.getType('Listing').getFields().sellerId).toBe(undefined);

expect(usersSchema.getType('Listing').getFields().seller.deprecationReason).toBe('gateway access only');
expect(usersSchema.getType('Listing').getFields().seller.astNode.directives.length).toEqual(1);
expect(gatewaySchema.getType('Listing').getFields().seller.deprecationReason).toBe(undefined);
expect(gatewaySchema.getType('Listing').getFields().seller.astNode.directives.length).toEqual(0);

expect(usersSchema.getType('User').getFields().email.deprecationReason).toBe('other deprecation');
expect(usersSchema.getType('User').getFields().email.astNode.directives.length).toEqual(1);
expect(gatewaySchema.getType('User').getFields().email.deprecationReason).toBe('other deprecation');
expect(gatewaySchema.getType('User').getFields().email.astNode.directives.length).toEqual(1);
});
});
1 change: 1 addition & 0 deletions packages/utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,4 @@ export * from './errors';
export * from './toConfig';
export * from './observableToAsyncIterable';
export * from './visitResult';
export * from './matchDirective';
21 changes: 21 additions & 0 deletions packages/utils/src/matchDirective.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { DirectiveNode, ArgumentNode } from 'graphql';

export function matchDirective(
directive: DirectiveNode,
requiredName: string,
requiredArgs: Record<string, any> = {}
): boolean {
if (directive.name.value === requiredName) {
const requiredArgNames = Object.keys(requiredArgs);

if (requiredArgNames.length) {
const argsMap: Record<string, any> = Object.create(null);
directive.arguments.forEach((arg: ArgumentNode) => {
argsMap[arg.name.value] = 'value' in arg.value ? arg.value.value : undefined;
});
return requiredArgNames.every(name => requiredArgs[name] === argsMap[name]);
}
return true;
}
return false;
}
34 changes: 34 additions & 0 deletions packages/utils/tests/matchDirective.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { matchDirective } from '../src/matchDirective';

describe('matchDirective', () => {
test('matches a directive node based on flexible criteria', () => {
const dir = {
kind: 'Directive',
name: { kind: 'Name', value: 'deprecated' },
arguments: [{
kind: 'Argument',
name: { kind: 'Name', value: 'reason' },
value: {
kind: 'StringValue',
value: 'reason',
block: false,
}
}, {
kind: 'Argument',
name: { kind: 'Name', value: 'also' },
value: {
kind: 'StringValue',
value: 'also',
block: false,
}
}]
};

expect(matchDirective(dir, 'notthis')).toBe(false);
expect(matchDirective(dir, 'deprecated')).toBe(true);
expect(matchDirective(dir, 'deprecated', { reason: 'reason' })).toBe(true);
expect(matchDirective(dir, 'deprecated', { reason: 'reason', also: 'also' })).toBe(true);
expect(matchDirective(dir, 'deprecated', { reason: 'reason', and: 'and' })).toBe(false);
expect(matchDirective(dir, 'deprecated', { this: 'this' })).toBe(false);
});
});
34 changes: 34 additions & 0 deletions packages/wrap/src/transforms/FilterFieldDirectives.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { GraphQLSchema, GraphQLFieldConfig, DirectiveNode } from 'graphql';
import { Transform } from '@graphql-tools/utils';
import TransformObjectFields from './TransformObjectFields';

export default class FilterFieldDirectives implements Transform {
private readonly transformer: TransformObjectFields;

constructor(filter: (dir: DirectiveNode) => boolean) {
this.transformer = new TransformObjectFields(
(_typeName: string, _fieldName: string, fieldConfig: GraphQLFieldConfig<any, any>) => {
const keepDirectives = fieldConfig.astNode.directives.filter(dir => filter(dir));

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

if (fieldConfig.deprecationReason && !keepDirectives.some(dir => dir.name.value === 'deprecated')) {
gmac marked this conversation as resolved.
Show resolved Hide resolved
delete fieldConfig.deprecationReason;
}
return fieldConfig;
}
}
);
}

public transformSchema(originalSchema: GraphQLSchema): GraphQLSchema {
return this.transformer.transformSchema(originalSchema);
}
}
1 change: 1 addition & 0 deletions packages/wrap/src/transforms/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export { default as FilterInterfaceFields } from './FilterInterfaceFields';
export { default as TransformInputObjectFields } from './TransformInputObjectFields';
export { default as RenameInputObjectFields } from './RenameInputObjectFields';
export { default as FilterInputObjectFields } from './FilterInputObjectFields';
export { default as FilterFieldDirectives } from './FilterFieldDirectives';
export { default as MapLeafValues } from './MapLeafValues';
export { default as TransformEnumValues } from './TransformEnumValues';
export { default as TransformQuery } from './TransformQuery';
Expand Down
53 changes: 49 additions & 4 deletions website/docs/stitch-type-merging.md
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,7 @@ Some important features to notice in the above schema:
- Users service `Listing` now _only_ provides `buyer` and `seller` associations without any need for a shared `id`.
- Users service defines a `ListingRepresentation` input for external keys, and a `_listingsByReps` query that recieves them.

To bring this all together, the gateway orchestrates collecting plain keys from the listing service, and then injecting them as representations of external records into the users service... from which they return as a complete type:
To bring this all together, the gateway orchestrates collecting plain keys from the listing service, and then injects them as representations of external records into the users service...

```js
const gatewaySchema = stitchSchemas({
Expand Down Expand Up @@ -373,9 +373,9 @@ const gatewaySchema = stitchSchemas({
});
```

In summary, the gateway had selected `buyerId` and `sellerId` fields from the listings services, sent those keys as input over to the users service, and then recieved back a complete type resolved with multiple fields of any type and selection. Neat!
To recap, the gateway has selected `buyerId` and `sellerId` fields from the listings services, sent those keys as input over to the users service, and then recieved back a complete type resolved with multiple fields of any type and selection. Neat!

However, you may notice that both `sellerId` and `buyerId` keys are _always_ requested from the listing service, even though they are only needed when resolving their respective associations. If we were sensitive to costs associated with keys, then we could judiciously select only the keys needed for the query with a field-level selectionSet mapping:
However, you may notice that both `buyerId` and `sellerId` keys are _always_ requested from the listing service, even though they are only needed when resolving their respective associations. If we were sensitive to costs associated with keys, then we could judiciously select only the keys needed for the query with a field-level selectionSet mapping:

```js
{
Expand All @@ -394,7 +394,52 @@ However, you may notice that both `sellerId` and `buyerId` keys are _always_ req
}
```

One minor disadvantage of this pattern is that the listings service includes ugly `sellerId` and `buyerId` fields. There's no harm in marking these IDs as `@deprecated`, or they may be removed completely from the gateway schema using a [transform](/docs/stitch-combining-schemas#adding-transforms).
One disadvantage of this pattern is that we end up with clutter&mdash;`buyerId`/`sellerId` are extra fields, and `buyer`/`seller` fields have gateway dependencies. To tidy things up, we can aggressively deprecate these fields in subschemas and then remove/normalize their behavior in the gateway using available transforms:

```js
import { RemoveFieldsWithDirective, RemoveFieldDirectives } from '@graphql-tools/stitch';

const listingsSchema = makeExecutableSchema({
typeDefs: `
type Listing {
id: ID!
description: String!
price: Float!
sellerId: ID! @deprecated(reason: "stitching use only")
buyerId: ID @deprecated(reason: "stitching use only")
}
`
});

const usersSchema = makeExecutableSchema({
typeDefs: `
type User {
id: ID!
email: String!
}

type Listing {
seller: User! @deprecated(reason: "gateway access only")
buyer: User @deprecated(reason: "gateway access only")
}
`
});

const gatewaySchema = stitchSchemas({
subschemas: [
{
schema: listingsSchema,
transforms: [new RemoveFieldsWithDirective('deprecated', { reason: 'stitching use only' })],
merge: { ... }
},
{
schema: usersSchema,
transforms: [new RemoveFieldDirectives('deprecated', { reason: 'gateway access only' })],
merge: { ... }
},
],
});
```

### Federation services

Expand Down