From db57d4deefdfca9a61f0ea0322984947c52d7aab Mon Sep 17 00:00:00 2001 From: David Chanin Date: Mon, 7 Oct 2019 13:58:49 +0100 Subject: [PATCH 1/4] adding which can be used to further restrict matching --- src/index.ts | 10 +- src/matchConditions/fragmentMatchCondition.ts | 38 ++++ src/matchConditions/index.ts | 10 + src/matchConditions/matchCondition.ts | 6 + src/matchConditions/mutationMatchCondition.ts | 17 ++ .../operationMatchCondition.ts | 41 ++++ src/matchConditions/queryMatchCondition.ts | 14 ++ src/rewriters/Rewriter.ts | 15 +- src/rewriters/index.ts | 6 + .../functional/fragmentMatchCondition.test.ts | 203 ++++++++++++++++++ test/functional/matchCondition.test.ts | 75 +++++++ .../functional/mutationMatchCondition.test.ts | 151 +++++++++++++ test/functional/queryMatchCondition.test.ts | 151 +++++++++++++ 13 files changed, 727 insertions(+), 10 deletions(-) create mode 100644 src/matchConditions/fragmentMatchCondition.ts create mode 100644 src/matchConditions/index.ts create mode 100644 src/matchConditions/matchCondition.ts create mode 100644 src/matchConditions/mutationMatchCondition.ts create mode 100644 src/matchConditions/operationMatchCondition.ts create mode 100644 src/matchConditions/queryMatchCondition.ts create mode 100644 src/rewriters/index.ts create mode 100644 test/functional/fragmentMatchCondition.test.ts create mode 100644 test/functional/matchCondition.test.ts create mode 100644 test/functional/mutationMatchCondition.test.ts create mode 100644 test/functional/queryMatchCondition.test.ts diff --git a/src/index.ts b/src/index.ts index 24b2627..7bb537b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,3 @@ export { default as RewriteHandler } from './RewriteHandler'; -export { default as Rewriter } from './rewriters/Rewriter'; -export { default as FieldArgNameRewriter } from './rewriters/FieldArgNameRewriter'; -export { default as FieldArgsToInputTypeRewriter } from './rewriters/FieldArgsToInputTypeRewriter'; -export { default as FieldArgTypeRewriter } from './rewriters/FieldArgTypeRewriter'; -export { default as NestFieldOutputsRewriter } from './rewriters/NestFieldOutputsRewriter'; -export { - default as ScalarFieldToObjectFieldRewriter -} from './rewriters/ScalarFieldToObjectFieldRewriter'; +export * from './rewriters'; +export * from './matchConditions'; diff --git a/src/matchConditions/fragmentMatchCondition.ts b/src/matchConditions/fragmentMatchCondition.ts new file mode 100644 index 0000000..19d482c --- /dev/null +++ b/src/matchConditions/fragmentMatchCondition.ts @@ -0,0 +1,38 @@ +import { FragmentDefinitionNode } from 'graphql'; +import { extractPath } from '../ast'; +import matchCondition from './matchCondition'; +export interface FragmentMatchConditionOpts { + fragmentNames?: string[]; + fragmentTypes?: string[]; + pathRegexes?: RegExp[]; +} + +export default ({ + fragmentNames, + fragmentTypes, + pathRegexes +}: FragmentMatchConditionOpts = {}): matchCondition => { + return ({ node }, parents) => { + const fragmentDef = parents.find(({ kind }) => kind === 'FragmentDefinition') as + | FragmentDefinitionNode + | undefined; + if (!fragmentDef) return false; + + if (fragmentNames && !fragmentNames.includes(fragmentDef.name.value)) { + return false; + } + + if (fragmentTypes && !fragmentTypes.includes(fragmentDef.typeCondition.name.value)) { + return false; + } + + if (pathRegexes) { + const pathStr = extractPath([...parents, node]).join('.'); + if (!pathRegexes.find(pathRegex => pathRegex.test(pathStr))) { + return false; + } + } + + return true; + }; +}; diff --git a/src/matchConditions/index.ts b/src/matchConditions/index.ts new file mode 100644 index 0000000..563c953 --- /dev/null +++ b/src/matchConditions/index.ts @@ -0,0 +1,10 @@ +export { default as matchCondition } from './matchCondition'; +export { + default as fragmentMatchCondition, + FragmentMatchConditionOpts +} from './fragmentMatchCondition'; +export { default as queryMatchCondition, QueryMatchConditionOpts } from './queryMatchCondition'; +export { + default as mutationMatchCondition, + MutationMatchConditionOpts +} from './mutationMatchCondition'; diff --git a/src/matchConditions/matchCondition.ts b/src/matchConditions/matchCondition.ts new file mode 100644 index 0000000..a6b0117 --- /dev/null +++ b/src/matchConditions/matchCondition.ts @@ -0,0 +1,6 @@ +import { ASTNode } from 'graphql'; +import { NodeAndVarDefs } from '../ast'; + +type matchCondition = (nodeAndVarDefs: NodeAndVarDefs, parents: ReadonlyArray) => boolean; + +export default matchCondition; diff --git a/src/matchConditions/mutationMatchCondition.ts b/src/matchConditions/mutationMatchCondition.ts new file mode 100644 index 0000000..0d945af --- /dev/null +++ b/src/matchConditions/mutationMatchCondition.ts @@ -0,0 +1,17 @@ +import matchCondition from './matchCondition'; +import operationMatchCondition from './operationMatchCondition'; +export interface MutationMatchConditionOpts { + mutationNames?: string[]; + pathRegexes?: RegExp[]; +} + +export default ({ + mutationNames, + pathRegexes +}: MutationMatchConditionOpts = {}): matchCondition => { + return operationMatchCondition({ + pathRegexes, + operationNames: mutationNames, + operationTypes: ['mutation'] + }); +}; diff --git a/src/matchConditions/operationMatchCondition.ts b/src/matchConditions/operationMatchCondition.ts new file mode 100644 index 0000000..8691f59 --- /dev/null +++ b/src/matchConditions/operationMatchCondition.ts @@ -0,0 +1,41 @@ +import { OperationDefinitionNode } from 'graphql'; +import { extractPath } from '../ast'; +import matchCondition from './matchCondition'; +export interface OperationMatchConditionOpts { + operationNames?: string[]; + operationTypes?: string[]; + pathRegexes?: RegExp[]; +} + +export default ({ + operationNames, + operationTypes, + pathRegexes +}: OperationMatchConditionOpts = {}): matchCondition => { + return ({ node }, parents) => { + const operationDef = parents.find(({ kind }) => kind === 'OperationDefinition') as + | OperationDefinitionNode + | undefined; + + if (!operationDef) return false; + + if (operationNames) { + if (!operationDef.name || !operationNames.includes(operationDef.name.value)) { + return false; + } + } + + if (operationTypes && !operationTypes.includes(operationDef.operation)) { + return false; + } + + if (pathRegexes) { + const pathStr = extractPath([...parents, node]).join('.'); + if (!pathRegexes.find(pathRegex => pathRegex.test(pathStr))) { + return false; + } + } + + return true; + }; +}; diff --git a/src/matchConditions/queryMatchCondition.ts b/src/matchConditions/queryMatchCondition.ts new file mode 100644 index 0000000..8f23a71 --- /dev/null +++ b/src/matchConditions/queryMatchCondition.ts @@ -0,0 +1,14 @@ +import matchCondition from './matchCondition'; +import operationMatchCondition from './operationMatchCondition'; +export interface QueryMatchConditionOpts { + queryNames?: string[]; + pathRegexes?: RegExp[]; +} + +export default ({ queryNames, pathRegexes }: QueryMatchConditionOpts = {}): matchCondition => { + return operationMatchCondition({ + pathRegexes, + operationNames: queryNames, + operationTypes: ['query'] + }); +}; diff --git a/src/rewriters/Rewriter.ts b/src/rewriters/Rewriter.ts index c18b4d7..794c67c 100644 --- a/src/rewriters/Rewriter.ts +++ b/src/rewriters/Rewriter.ts @@ -1,5 +1,6 @@ import { ASTNode } from 'graphql'; import { NodeAndVarDefs } from '../ast'; +import matchCondition from '../matchConditions/matchCondition'; export type Variables = { [key: string]: any } | undefined; export type RootType = 'query' | 'mutation' | 'fragment'; @@ -7,6 +8,7 @@ export type RootType = 'query' | 'mutation' | 'fragment'; export interface RewriterOpts { fieldName: string; rootTypes?: RootType[]; + matchConditions?: matchCondition[]; } /** @@ -16,13 +18,16 @@ export interface RewriterOpts { abstract class Rewriter { protected fieldName: string; protected rootTypes: RootType[] = ['query', 'mutation', 'fragment']; + protected matchConditions?: matchCondition[]; - constructor({ fieldName, rootTypes }: RewriterOpts) { + constructor({ fieldName, rootTypes, matchConditions }: RewriterOpts) { this.fieldName = fieldName; + this.matchConditions = matchConditions; if (rootTypes) this.rootTypes = rootTypes; } - public matches({ node }: NodeAndVarDefs, parents: ReadonlyArray): boolean { + public matches(nodeAndVarDefs: NodeAndVarDefs, parents: ReadonlyArray): boolean { + const { node } = nodeAndVarDefs; if (node.kind !== 'Field' || node.name.value !== this.fieldName) return false; const root = parents[0]; if ( @@ -34,6 +39,12 @@ abstract class Rewriter { if (root.kind === 'FragmentDefinition' && this.rootTypes.indexOf('fragment') === -1) { return false; } + if ( + this.matchConditions && + !this.matchConditions.find(condition => condition(nodeAndVarDefs, parents)) + ) { + return false; + } return true; } diff --git a/src/rewriters/index.ts b/src/rewriters/index.ts new file mode 100644 index 0000000..9908781 --- /dev/null +++ b/src/rewriters/index.ts @@ -0,0 +1,6 @@ +export { default as Rewriter } from './Rewriter'; +export { default as FieldArgNameRewriter } from './FieldArgNameRewriter'; +export { default as FieldArgsToInputTypeRewriter } from './FieldArgsToInputTypeRewriter'; +export { default as FieldArgTypeRewriter } from './FieldArgTypeRewriter'; +export { default as NestFieldOutputsRewriter } from './NestFieldOutputsRewriter'; +export { default as ScalarFieldToObjectFieldRewriter } from './ScalarFieldToObjectFieldRewriter'; diff --git a/test/functional/fragmentMatchCondition.test.ts b/test/functional/fragmentMatchCondition.test.ts new file mode 100644 index 0000000..9f75921 --- /dev/null +++ b/test/functional/fragmentMatchCondition.test.ts @@ -0,0 +1,203 @@ +import { fragmentMatchCondition } from '../../src/matchConditions'; +import RewriteHandler from '../../src/RewriteHandler'; +import ScalarFieldToObjectFieldRewriter from '../../src/rewriters/ScalarFieldToObjectFieldRewriter'; +import { gqlFmt } from '../testUtils'; + +describe('fragment match condition', () => { + it('restricts matches to only fragments that meet the criteria specified', () => { + const createHandler = () => + new RewriteHandler([ + new ScalarFieldToObjectFieldRewriter({ + fieldName: 'title', + objectFieldName: 'text', + matchConditions: [ + fragmentMatchCondition({ + fragmentNames: ['matchingName1', 'matchingName2'], + fragmentTypes: ['Thingy'] + }) + ] + }) + ]); + + const noFragmentQuery = gqlFmt` + query getTheThing { + theThing { + thingField { + title + } + } + } + `; + expect(createHandler().rewriteRequest(noFragmentQuery)).toEqual({ query: noFragmentQuery }); + + const wrongFragmentNameQuery = gqlFmt` + query getTheThing { + theThing { + ...wrongNameFragment + } + } + + fragment wrongNameFragment on Thingy { + title + } + `; + expect(createHandler().rewriteRequest(wrongFragmentNameQuery)).toEqual({ + query: wrongFragmentNameQuery + }); + + const wrongFragmentTypeQuery = gqlFmt` + query getTheThing { + theThing { + ...matchingName2 + } + } + + fragment matchingName2 on WrongType { + title + } + `; + expect(createHandler().rewriteRequest(wrongFragmentTypeQuery)).toEqual({ + query: wrongFragmentTypeQuery + }); + + const matchingFragmentQuery = gqlFmt` + query getTheThing { + theThing { + ...matchingName2 + } + } + + fragment matchingName2 on Thingy { + title + } + `; + const expectedRewrittenMatchingFragmentQuery = gqlFmt` + query getTheThing { + theThing { + ...matchingName2 + } + } + + fragment matchingName2 on Thingy { + title { + text + } + } + `; + expect(createHandler().rewriteRequest(matchingFragmentQuery)).toEqual({ + query: expectedRewrittenMatchingFragmentQuery + }); + }); + + it('restricts matches to only fragments if no other criteria is specified', () => { + const createHandler = () => + new RewriteHandler([ + new ScalarFieldToObjectFieldRewriter({ + fieldName: 'title', + objectFieldName: 'text', + matchConditions: [fragmentMatchCondition()] + }) + ]); + + const noFragmentQuery = gqlFmt` + query getTheThing { + theThing { + thingField { + title + } + } + } + `; + expect(createHandler().rewriteRequest(noFragmentQuery)).toEqual({ query: noFragmentQuery }); + + const matchingFragmentQuery = gqlFmt` + query getTheThing { + theThing { + ...fragName + } + } + + fragment fragName on Thingy { + title + } + `; + const expectedRewrittenMatchingFragmentQuery = gqlFmt` + query getTheThing { + theThing { + ...fragName + } + } + + fragment fragName on Thingy { + title { + text + } + } + `; + expect(createHandler().rewriteRequest(matchingFragmentQuery)).toEqual({ + query: expectedRewrittenMatchingFragmentQuery + }); + }); + + it('can use path regexes to further restrict matches', () => { + const createHandler = () => + new RewriteHandler([ + new ScalarFieldToObjectFieldRewriter({ + fieldName: 'title', + objectFieldName: 'text', + matchConditions: [ + fragmentMatchCondition({ + pathRegexes: [/^thingField.title$/] + }) + ] + }) + ]); + + const nonMatchingPathFragment = gqlFmt` + query getTheThing { + theThing { + ...fragName + } + } + + fragment fragName on Thingy { + title + } + `; + expect(createHandler().rewriteRequest(nonMatchingPathFragment)).toEqual({ + query: nonMatchingPathFragment + }); + + const matchingFragmentQuery = gqlFmt` + query getTheThing { + theThing { + ...fragName + } + } + + fragment fragName on Thingy { + thingField { + title + } + } + `; + const expectedRewrittenMatchingFragmentQuery = gqlFmt` + query getTheThing { + theThing { + ...fragName + } + } + + fragment fragName on Thingy { + thingField { + title { + text + } + } + } + `; + expect(createHandler().rewriteRequest(matchingFragmentQuery)).toEqual({ + query: expectedRewrittenMatchingFragmentQuery + }); + }); +}); diff --git a/test/functional/matchCondition.test.ts b/test/functional/matchCondition.test.ts new file mode 100644 index 0000000..a2c8a54 --- /dev/null +++ b/test/functional/matchCondition.test.ts @@ -0,0 +1,75 @@ +import { queryMatchCondition, mutationMatchCondition } from '../../src/matchConditions'; +import RewriteHandler from '../../src/RewriteHandler'; +import ScalarFieldToObjectFieldRewriter from '../../src/rewriters/ScalarFieldToObjectFieldRewriter'; +import { gqlFmt } from '../testUtils'; + +describe('matchCondition', () => { + it('matches if any of the provided conditions matches', () => { + const createHandler = () => + new RewriteHandler([ + new ScalarFieldToObjectFieldRewriter({ + fieldName: 'title', + objectFieldName: 'text', + matchConditions: [ + queryMatchCondition({ + queryNames: ['queryName1', 'queryName2'] + }), + mutationMatchCondition({ + mutationNames: ['mutationName1', 'mutationName2'] + }) + ] + }) + ]); + + const wrongNameQuery = gqlFmt` + query getTheThing { + theThing { + title + } + } + `; + expect(createHandler().rewriteRequest(wrongNameQuery)).toEqual({ + query: wrongNameQuery + }); + + const matchingQuery = gqlFmt` + query queryName1 { + theThing { + title + } + } + `; + const expectedRewrittenMatchingQuery = gqlFmt` + query queryName1 { + theThing { + title { + text + } + } + } + `; + expect(createHandler().rewriteRequest(matchingQuery)).toEqual({ + query: expectedRewrittenMatchingQuery + }); + + const matchingMutation = gqlFmt` + mutation mutationName1 { + theThing { + title + } + } + `; + const expectedRewrittenMatchingMutation = gqlFmt` + mutation mutationName1 { + theThing { + title { + text + } + } + } + `; + expect(createHandler().rewriteRequest(matchingMutation)).toEqual({ + query: expectedRewrittenMatchingMutation + }); + }); +}); diff --git a/test/functional/mutationMatchCondition.test.ts b/test/functional/mutationMatchCondition.test.ts new file mode 100644 index 0000000..163edcc --- /dev/null +++ b/test/functional/mutationMatchCondition.test.ts @@ -0,0 +1,151 @@ +import { mutationMatchCondition } from '../../src/matchConditions'; +import RewriteHandler from '../../src/RewriteHandler'; +import ScalarFieldToObjectFieldRewriter from '../../src/rewriters/ScalarFieldToObjectFieldRewriter'; +import { gqlFmt } from '../testUtils'; + +describe('query match condition', () => { + it('restricts matches to only queries that meet the criteria specified', () => { + const createHandler = () => + new RewriteHandler([ + new ScalarFieldToObjectFieldRewriter({ + fieldName: 'title', + objectFieldName: 'text', + matchConditions: [ + mutationMatchCondition({ + mutationNames: ['matchingName1', 'matchingName2'] + }) + ] + }) + ]); + + const noMutation = gqlFmt` + query getTheThing { + theThing { + thingField { + title + } + } + } + `; + expect(createHandler().rewriteRequest(noMutation)).toEqual({ query: noMutation }); + + const wrongNameMutation = gqlFmt` + mutation getTheThing { + theThing { + title + } + } + `; + expect(createHandler().rewriteRequest(wrongNameMutation)).toEqual({ + query: wrongNameMutation + }); + + const matchingMutation = gqlFmt` + mutation matchingName2 { + theThing { + title + } + } + `; + const expectedRewrittenMatchingMutation = gqlFmt` + mutation matchingName2 { + theThing { + title { + text + } + } + } + `; + expect(createHandler().rewriteRequest(matchingMutation)).toEqual({ + query: expectedRewrittenMatchingMutation + }); + }); + + it('restricts matches to only mutations if no other criteria is specified', () => { + const createHandler = () => + new RewriteHandler([ + new ScalarFieldToObjectFieldRewriter({ + fieldName: 'title', + objectFieldName: 'text', + matchConditions: [mutationMatchCondition()] + }) + ]); + + const noMutation = gqlFmt` + query getTheThing { + theThing { + thingField { + title + } + } + } + `; + expect(createHandler().rewriteRequest(noMutation)).toEqual({ query: noMutation }); + + const matchingMutation = gqlFmt` + mutation getTheThing { + theThing { + title + } + } + `; + const expectedRewrittenMatchingMutation = gqlFmt` + mutation getTheThing { + theThing { + title { + text + } + } + } + `; + expect(createHandler().rewriteRequest(matchingMutation)).toEqual({ + query: expectedRewrittenMatchingMutation + }); + }); + + it('can use path regexes to further restrict matches', () => { + const createHandler = () => + new RewriteHandler([ + new ScalarFieldToObjectFieldRewriter({ + fieldName: 'title', + objectFieldName: 'text', + matchConditions: [ + mutationMatchCondition({ + pathRegexes: [/^thingField.title$/] + }) + ] + }) + ]); + + const nonMatchingPathFragment = gqlFmt` + mutation getTheThing { + wrongField { + title + } + } + `; + expect(createHandler().rewriteRequest(nonMatchingPathFragment)).toEqual({ + query: nonMatchingPathFragment + }); + + const matchingMutation = gqlFmt` + mutation getTheThing { + thingField { + title + } + } + `; + const expectedRewrittenMatchingMutation = gqlFmt` + mutation getTheThing { + thingField { + title { + text + } + } + } + `; + expect(createHandler().rewriteRequest(matchingMutation)).toEqual({ + query: expectedRewrittenMatchingMutation + }); + }); +}); diff --git a/test/functional/queryMatchCondition.test.ts b/test/functional/queryMatchCondition.test.ts new file mode 100644 index 0000000..44399b0 --- /dev/null +++ b/test/functional/queryMatchCondition.test.ts @@ -0,0 +1,151 @@ +import { queryMatchCondition } from '../../src/matchConditions'; +import RewriteHandler from '../../src/RewriteHandler'; +import ScalarFieldToObjectFieldRewriter from '../../src/rewriters/ScalarFieldToObjectFieldRewriter'; +import { gqlFmt } from '../testUtils'; + +describe('query match condition', () => { + it('restricts matches to only queries that meet the criteria specified', () => { + const createHandler = () => + new RewriteHandler([ + new ScalarFieldToObjectFieldRewriter({ + fieldName: 'title', + objectFieldName: 'text', + matchConditions: [ + queryMatchCondition({ + queryNames: ['matchingName1', 'matchingName2'] + }) + ] + }) + ]); + + const noQuery = gqlFmt` + mutation getTheThing { + theThing { + thingField { + title + } + } + } + `; + expect(createHandler().rewriteRequest(noQuery)).toEqual({ query: noQuery }); + + const wrongNameQuery = gqlFmt` + query getTheThing { + theThing { + title + } + } + `; + expect(createHandler().rewriteRequest(wrongNameQuery)).toEqual({ + query: wrongNameQuery + }); + + const matchingQuery = gqlFmt` + query matchingName2 { + theThing { + title + } + } + `; + const expectedRewrittenMatchingQuery = gqlFmt` + query matchingName2 { + theThing { + title { + text + } + } + } + `; + expect(createHandler().rewriteRequest(matchingQuery)).toEqual({ + query: expectedRewrittenMatchingQuery + }); + }); + + it('restricts matches to only querys if no other criteria is specified', () => { + const createHandler = () => + new RewriteHandler([ + new ScalarFieldToObjectFieldRewriter({ + fieldName: 'title', + objectFieldName: 'text', + matchConditions: [queryMatchCondition()] + }) + ]); + + const noQuery = gqlFmt` + mutation getTheThing { + theThing { + thingField { + title + } + } + } + `; + expect(createHandler().rewriteRequest(noQuery)).toEqual({ query: noQuery }); + + const matchingQuery = gqlFmt` + query getTheThing { + theThing { + title + } + } + `; + const expectedRewrittenMatchingQuery = gqlFmt` + query getTheThing { + theThing { + title { + text + } + } + } + `; + expect(createHandler().rewriteRequest(matchingQuery)).toEqual({ + query: expectedRewrittenMatchingQuery + }); + }); + + it('can use path regexes to further restrict matches', () => { + const createHandler = () => + new RewriteHandler([ + new ScalarFieldToObjectFieldRewriter({ + fieldName: 'title', + objectFieldName: 'text', + matchConditions: [ + queryMatchCondition({ + pathRegexes: [/^thingField.title$/] + }) + ] + }) + ]); + + const nonMatchingPathFragment = gqlFmt` + query getTheThing { + wrongField { + title + } + } + `; + expect(createHandler().rewriteRequest(nonMatchingPathFragment)).toEqual({ + query: nonMatchingPathFragment + }); + + const matchingQuery = gqlFmt` + query getTheThing { + thingField { + title + } + } + `; + const expectedRewrittenMatchingQuery = gqlFmt` + query getTheThing { + thingField { + title { + text + } + } + } + `; + expect(createHandler().rewriteRequest(matchingQuery)).toEqual({ + query: expectedRewrittenMatchingQuery + }); + }); +}); From 3d7a1950ad046b290a6a3bd4d55343dd9d1afba3 Mon Sep 17 00:00:00 2001 From: David Chanin Date: Mon, 7 Oct 2019 14:33:41 +0100 Subject: [PATCH 2/4] adding docs on how to use match conditions --- README.md | 140 +++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 117 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 3563727..e01ab0d 100644 --- a/README.md +++ b/README.md @@ -13,14 +13,15 @@ Full API docs are available at https://ef-eng.github.io/graphql-query-rewriter GraphQL is great at enforcing a strict schema for APIs, but its lack of versioning makes it extremely difficult to make changes to GraphQL schemas without breaking existing clients. For example, take the following query: -``` +```graphql query getUserById($id: String!) { userById(id: $id) { ... } } ``` -Oh no! We should have used `ID!` as the type for `userById(id)` instead of `String!`, but it's already in production! Now if we change our schema to use `ID!` instead of `String!` then our old clients will start getting the error `Variable "$id" of type "String!" used in position expecting type "ID!"`. Currently your only options are to continue using the incorrect `String!` type forever (*eeew*), or make a new query with a new name, like `userByIdNew(id: ID!)` (*gross*)! + +Oh no! We should have used `ID!` as the type for `userById(id)` instead of `String!`, but it's already in production! Now if we change our schema to use `ID!` instead of `String!` then our old clients will start getting the error `Variable "$id" of type "String!" used in position expecting type "ID!"`. Currently your only options are to continue using the incorrect `String!` type forever (_eeew_), or make a new query with a new name, like `userByIdNew(id: ID!)` (_gross_)! Wouldn't it be great if you could change the schema to use `ID!`, but just silently replace `String!` in old queries with `ID!` in your middleware so the old queries will continue to work just like they had been? @@ -56,7 +57,8 @@ app.use('/graphql', graphqlHTTP( ... )); ``` Now, when old clients send the following query: -``` + +```graphql query getUserById($id: String!) { userById(id: $id) { ... @@ -65,7 +67,8 @@ query getUserById($id: String!) { ``` It will be rewritten before it gets processed to: -``` + +```graphql query getUserById($id: ID!) { userById(id: $id) { ... @@ -75,7 +78,6 @@ query getUserById($id: ID!) { Now your schema is clean and up to date, and deprecated clients keep working! GraphQL Schema Rewriter can rewrite much more complex queries than just changing a single input type as well. - ## Installation Installation requires the base package `graphql-query-rewriter` and a middleware adapter for the web framework you use. Currently works with `express-graphql` and `apollo-server`. @@ -88,7 +90,7 @@ npm install graphql-query-rewriter express-graphql-query-rewriter #### For Apollo-server -Apollo server works with `express-graphql-query-rewriter` via [Apollo server middleware](https://www.apollographql.com/docs/apollo-server/migration-two-dot/#adding-additional-middleware-to-apollo-server-2). +Apollo server works with `express-graphql-query-rewriter` via [Apollo server middleware](https://www.apollographql.com/docs/apollo-server/migration-two-dot/#adding-additional-middleware-to-apollo-server-2). ``` npm install graphql-query-rewriter express-graphql express-graphql-query-rewriter @@ -154,10 +156,10 @@ const rewriter = new FieldArgTypeRewriter({ argName: 'arg1', oldType: 'Int', newType: 'Int!' -}) +}); ``` -Sometimes, you'll need to do some preprocessing on the variables submitted to the rewritten argument to make them into the type needed by the new schema. You can do this by passing in a `coerceVariable` function which returns a new value of the variable. For example, the following changes the value of `arg1` from `Int!` to `String!`, and also changes the value of `arg1` to a string as well: +Sometimes, you'll need to do some preprocessing on the variables submitted to the rewritten argument to make them into the type needed by the new schema. You can do this by passing in a `coerceVariable` function which returns a new value of the variable. For example, the following changes the value of `arg1` from `Int!` to `String!`, and also changes the value of `arg1` to a string as well: ```js import { FieldArgTypeRewriter } from 'graphql-query-rewriter'; @@ -184,13 +186,14 @@ const rewriter = new FieldArgNameRewriter({ fieldName: 'createUser', oldArgName: 'userID', newArgName: 'userId' -}) +}); ``` ### FieldArgsToInputTypeRewriter `FieldArgsToInputTypeRewriter` can be used to move mutation parameters into a single input object, by default named `input`. It's a best-practice to use a single input type for mutations in GraphQL, and it's required by the [Relay GraphQL Spec](https://facebook.github.io/relay/docs/en/graphql-server-specification.html#mutations). For example, to migrate the mutation `createUser(username: String!, password: String!)` to a mutation with a proper input type like: -``` + +```graphql mutation createUser(input: CreateUserInput!) { ... } type CreateUserInput { @@ -209,12 +212,12 @@ const rewriter = new FieldArgsToInputTypeRewriter({ fieldName: 'createUser', argNames: ['username', 'password'], inputArgName: 'input' // inputArgName can be left out to use 'input' by default -}) +}); ``` For example, This would rewrite the following mutation: -``` +```graphql mutation createUser($username: String!, $password: String!) { createUser(username: $username, password: $password) { ... @@ -224,7 +227,7 @@ mutation createUser($username: String!, $password: String!) { and turn it into: -``` +```graphql mutation createUser($username: String!, $password: String!) { createUser(input: { username: $username, password: $password }) { ... @@ -236,7 +239,7 @@ mutation createUser($username: String!, $password: String!) { `ScalarFieldToObjectFieldRewriter` can be used to rewrite a scalar field into an object selecing a single scalar field. For example, imagine there's a `User` type with a `full_name` field that's of type `String!`. But to internationalize, that `full_name` field needs to support different names in different languges, something like `full_name: { default: 'Jackie Chan', 'cn': '成龙', ... }`. We could use the `ScalarFieldToObjectFieldRewriter` to rewriter `full_name` to instead select the `default` name. Specifically, given we have the schema below: -``` +```graphql type User { id: ID! full_name: String! @@ -246,7 +249,7 @@ type User { and we want to change it to -``` +```graphql type User { id: ID! full_name: { @@ -267,13 +270,13 @@ import { ScalarFieldToObjectFieldRewriter } from 'graphql-query-rewriter'; // add this to the rewriters array in graphqlRewriterMiddleware(...) const rewriter = new ScalarFieldToObjectFieldRewriter({ fieldName: 'full_name', - objectFieldName: 'default', -}) + objectFieldName: 'default' +}); ``` For example, This would rewrite the following query: -``` +```graphql query getUser(id: ID!) { user { id @@ -284,7 +287,7 @@ query getUser(id: ID!) { and turn it into: -``` +```graphql query getUser(id: ID!) { user { id @@ -299,7 +302,7 @@ query getUser(id: ID!) { `NestFieldOutputsRewriter` can be used to move mutation outputs into a nested payload object. It's a best-practice for each mutation in GraphQL to have its own output type, and it's required by the [Relay GraphQL Spec](https://facebook.github.io/relay/docs/en/graphql-server-specification.html#mutations). For example, to migrate the mutation `createUser(input: CreateUserInput!): User!` to a mutation with a proper output payload type like: -``` +```graphql mutation createUser(input: CreateUserInput!) CreateUserPayload type User { @@ -322,12 +325,12 @@ const rewriter = new NestFieldOutputsRewriter({ fieldName: 'createUser', newOutputName: 'user', outputsToNest: ['id', 'username'] -}) +}); ``` For example, This would rewrite the following mutation: -``` +```graphql mutation createUser(input: CreateUserInput!) { createUser(input: $input) { id @@ -338,7 +341,7 @@ mutation createUser(input: CreateUserInput!) { and turn it into: -``` +```graphql mutation createUser(input: CreateUserInput!) { createUser(input: $input) { user { @@ -349,6 +352,97 @@ mutation createUser(input: CreateUserInput!) { } ``` +## Restricting Matches Further + +Sometimes you need more control over which fields get rewritten to avoid accidentally rewriting fields which happen to have the same name in an unrelated query. This can be accomplished by providing a list of `matchConditions` to the `RewriteHandler`. There are 3 built-in match condition helpers you can use to make this easier, specifically `fragmentMatchCondition`, `queryMatchCondition`, and `mutationMatchCondition`. If any of the conditions passed in to `matchConditions` match, then the rewriter will proceed as normal. + +For example, to restrict matches to only to the `title` field of fragments named `thingFragment`, on type `Thing`, we could use the following `matchConditions`: + +```js +import { fragmentMatchCondition, ScalarFieldToObjectFieldRewriter } from 'graphql-query-rewriter'; + +const rewriter = new ScalarFieldToObjectFieldRewriter({ + fieldName: 'title', + objectFieldName: 'text', + matchConditions: [ + fragmentMatchCondition({ + fragmentNames: ['thingFragment'], + fragmentTypes: ['Thing'] + }) + ] +}); +``` + +Then, this will rewrite the following query as follows: + +```graphql +query { + articles { + title # <- This will not get rewritten, it doesn't match the matchConditions + things { + ...thingFragment + } + } +} + +fragment thingFragment on Thing { + id + title # <- This will be rewritten, because it matches the matchConditions +} +``` + +You can also pass a `pathRegexes` array of regexes to `fragmentMatchCondition` if you'd like to restrict the specific to the object field within the fragment that you'd like to rewrite. For example: + +```js +const rewriter = new ScalarFieldToObjectFieldRewriter({ + fieldName: 'title', + objectFieldName: 'text', + matchConditions: [ + fragmentMatchCondition({ + pathRegexes: [/^innerThing.title$/] + }) + ] +}); +``` + +Then, this will rewrite the query below as follows: + +```graphql +query { + things { + ...parentThingFragment + } +} + +fragment parentThingFragment on Thing { + id + title # <- not rewritten, it's not at the correct path + innerThing { + title # <- This will be rewritten, it's at path innerThing.title + } +} +``` + +There are also `queryMatchCondition` and `mutationMatchCondition`. These work similarly to `fragmentMatchCondition`, except they match only fields directly inside of a query or a mutation, respectively. +All of these matches take `pathRegexes` to search for matching paths, but `queryMatchCondition` can also take `queryNames`, to match only named queries, and likewise `mutationMatchCondition` can take `mutationNames` to match named mutations. + +If there are multiple `matchConditions` provided, then if any of the conditions match then the rewriter will continue as normal. For example: + +```js +const rewriter = new ScalarFieldToObjectFieldRewriter({ + fieldName: 'title', + objectFieldName: 'text', + matchConditions: [ + fragmentMatchCondition({ + fragmentNames: ['thingFragment'] + }), + queryMatchCondition({ + queryNames: ['getThing', 'getOtherThing'] + }) + ] +}); +``` + ## Current Limitations Currently GraphQL Query Rewriter can only work with a single operation per query, and cannot properly handle aliased fields. These limitations should hopefully be fixed soon. Contributions are welcome! From 9c297031f3bfb8d2923fd313fd9396ab7b864475 Mon Sep 17 00:00:00 2001 From: David Chanin Date: Mon, 7 Oct 2019 14:38:28 +0100 Subject: [PATCH 3/4] tweaking wording --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e01ab0d..fc4dd48 100644 --- a/README.md +++ b/README.md @@ -391,7 +391,7 @@ fragment thingFragment on Thing { } ``` -You can also pass a `pathRegexes` array of regexes to `fragmentMatchCondition` if you'd like to restrict the specific to the object field within the fragment that you'd like to rewrite. For example: +You can also pass a `pathRegexes` array of regexes to `fragmentMatchCondition` if you'd like to restrict the path to the object field within the fragment that you'd like to rewrite. For example: ```js const rewriter = new ScalarFieldToObjectFieldRewriter({ @@ -399,6 +399,7 @@ const rewriter = new ScalarFieldToObjectFieldRewriter({ objectFieldName: 'text', matchConditions: [ fragmentMatchCondition({ + // rewrite only at exatly path innerThing.title pathRegexes: [/^innerThing.title$/] }) ] From 067ee54f1fb92b23c087ec25881b19444498c54d Mon Sep 17 00:00:00 2001 From: David Chanin Date: Mon, 7 Oct 2019 14:40:57 +0100 Subject: [PATCH 4/4] tweaking wording --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index fc4dd48..94f69ac 100644 --- a/README.md +++ b/README.md @@ -444,6 +444,8 @@ const rewriter = new ScalarFieldToObjectFieldRewriter({ }); ``` +The above rewriter will only match on fragments named `thingFragment`, or queries named `getThing` or `getOtherThing`. + ## Current Limitations Currently GraphQL Query Rewriter can only work with a single operation per query, and cannot properly handle aliased fields. These limitations should hopefully be fixed soon. Contributions are welcome!