From dfb15cf139ef9366962e10d79d16916ce2c028a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20P=C3=A9rez=20Manr=C3=ADquez?= Date: Tue, 24 May 2022 11:00:28 -0400 Subject: [PATCH 01/21] enhances for argtype coercion --- src/RewriteHandler.ts | 2 +- src/rewriters/FieldArgTypeRewriter.ts | 112 +++++++++++++++++++++----- src/rewriters/Rewriter.ts | 7 +- 3 files changed, 97 insertions(+), 24 deletions(-) diff --git a/src/RewriteHandler.ts b/src/RewriteHandler.ts index 505448d..92b3dd2 100644 --- a/src/RewriteHandler.ts +++ b/src/RewriteHandler.ts @@ -39,7 +39,7 @@ export default class RewriteHandler { const isMatch = rewriter.matches(nodeAndVars, parents); if (isMatch) { rewrittenVariables = rewriter.rewriteVariables(rewrittenNodeAndVars, rewrittenVariables); - rewrittenNodeAndVars = rewriter.rewriteQuery(rewrittenNodeAndVars); + rewrittenNodeAndVars = rewriter.rewriteQuery(rewrittenNodeAndVars, rewrittenVariables); const simplePath = extractPath([...parents, rewrittenNodeAndVars.node]); let paths: ReadonlyArray> = [simplePath]; const fragmentDef = parents.find(({ kind }) => kind === 'FragmentDefinition') as diff --git a/src/rewriters/FieldArgTypeRewriter.ts b/src/rewriters/FieldArgTypeRewriter.ts index 294c0ec..11427fb 100644 --- a/src/rewriters/FieldArgTypeRewriter.ts +++ b/src/rewriters/FieldArgTypeRewriter.ts @@ -1,4 +1,12 @@ -import { ArgumentNode, ASTNode, FieldNode, parseType, TypeNode, VariableNode } from 'graphql'; +import { + ArgumentNode, + ValueNode, + ASTNode, + FieldNode, + parseType, + TypeNode, + VariableNode +} from 'graphql'; import { NodeAndVarDefs, nodesMatch } from '../ast'; import { identifyFunc } from '../utils'; import Rewriter, { RewriterOpts, Variables } from './Rewriter'; @@ -7,7 +15,17 @@ interface FieldArgTypeRewriterOpts extends RewriterOpts { argName: string; oldType: string; newType: string; - coerceVariable?: (variable: any) => any; + coerceVariable?: (variable: any, context: { variables: Variables; args: ArgumentNode[] }) => any; + /** + * EXPERIMENTAL: + * This allows to coerce value of argument when their value is not stored in a variable + * but comes in the query node itself. + * NOTE: At the moment, the user has to return the ast value node herself. + */ + coerceArgumentValue?: ( + variable: any, + context: { variables: Variables; args: ArgumentNode[] } + ) => any; } /** @@ -18,7 +36,13 @@ class FieldArgTypeRewriter extends Rewriter { protected argName: string; protected oldTypeNode: TypeNode; protected newTypeNode: TypeNode; - protected coerceVariable: (variable: any) => any; + // Passes context with rest of arguments and variables. + // Quite useful for variable coercion that depends on other arguments/variables + // (e.g., [offset, limit] to [pageSize, pageNumber] coercion) + protected coerceVariable; + // (Experimental): Used to coerce arguments whose value + // does not come in a variable. + protected coerceArgumentValue; constructor(options: FieldArgTypeRewriterOpts) { super(options); @@ -26,6 +50,7 @@ class FieldArgTypeRewriter extends Rewriter { this.oldTypeNode = parseType(options.oldType); this.newTypeNode = parseType(options.newType); this.coerceVariable = options.coerceVariable || identifyFunc; + this.coerceArgumentValue = options.coerceArgumentValue || identifyFunc; } public matches(nodeAndVars: NodeAndVarDefs, parents: ASTNode[]) { @@ -34,34 +59,81 @@ class FieldArgTypeRewriter extends Rewriter { const { variableDefinitions } = nodeAndVars; // is this a field with the correct fieldName and arguments? if (node.kind !== 'Field') return false; - if (node.name.value !== this.fieldName || !node.arguments) return false; + + // If the fieldName doesnt match, but there are matchConditions. + // matchConditions should have higher priority than fieldName to determine a match. + if ((node.name.value !== this.fieldName && !this.matchConditions) || !node.arguments) + return false; // is there an argument with the correct name and type in a variable? const matchingArgument = node.arguments.find(arg => arg.name.value === this.argName); - if (!matchingArgument || matchingArgument.value.kind !== 'Variable') return false; - const varRef = matchingArgument.value.name.value; - // does the referenced variable have the correct type? - for (const varDefinition of variableDefinitions) { - if (varDefinition.variable.name.value === varRef) { - return nodesMatch(this.oldTypeNode, varDefinition.type); + if (!matchingArgument) return false; + + // argument value is stored in a variable + if (matchingArgument.value.kind === 'Variable') { + const varRef = matchingArgument.value.name.value; + // does the referenced variable have the correct type? + for (const varDefinition of variableDefinitions) { + if (varDefinition.variable.name.value === varRef) { + return nodesMatch(this.oldTypeNode, varDefinition.type); + } } } + // argument value comes in query doc. + else { + const argRef = matchingArgument.value; + return nodesMatch(this.oldTypeNode, parseType(argRef.kind)); + } + return false; } - public rewriteQuery({ node, variableDefinitions }: NodeAndVarDefs) { - const varRefName = this.extractMatchingVarRefName(node as FieldNode); - const newVarDefs = variableDefinitions.map(varDef => { - if (varDef.variable.name.value !== varRefName) return varDef; - return { ...varDef, type: this.newTypeNode }; - }); - return { node, variableDefinitions: newVarDefs }; + public rewriteQuery( + { node: astNode, variableDefinitions }: NodeAndVarDefs, + variables: Variables + ) { + const node = astNode as FieldNode; + const varRefName = this.extractMatchingVarRefName(node); + // If argument value is stored in a variable + if (varRefName) { + const newVarDefs = variableDefinitions.map(varDef => { + if (varDef.variable.name.value !== varRefName) return varDef; + return { ...varDef, type: this.newTypeNode }; + }); + return { node, variableDefinitions: newVarDefs }; + } + // If argument value is not stored in a variable but in the query node. + else { + const matchingArgument = (node.arguments || []).find(arg => arg.name.value === this.argName); + if (node.arguments && matchingArgument) { + const args = [...node.arguments]; + const newValue = this.coerceArgumentValue(matchingArgument.value, { variables, args }); + /** + TODO: If somewhow we can get the schema here, we could make the coerceArgumentValue + even easier, as we would be able to construct the ast node for the argument value. + as of now, the user has to take care of correctly constructing the argument value ast node herself. + + const schema = makeExecutableSchema({typeDefs}) + const myCustomType = schema.getType("MY_CUSTOM_TYPE_NAME") + const newArgValue = astFromValue(newValue, myCustomType) + Object.assign(matchingArgument, { value: newArgValue }) + + */ + Object.assign(matchingArgument, { value: newValue }); + } + return { node, variableDefinitions }; + } } - public rewriteVariables({ node }: NodeAndVarDefs, variables: Variables) { + public rewriteVariables({ node: astNode }: NodeAndVarDefs, variables: Variables) { + const node = astNode as FieldNode; if (!variables) return variables; - const varRefName = this.extractMatchingVarRefName(node as FieldNode); - return { ...variables, [varRefName]: this.coerceVariable(variables[varRefName]) }; + const varRefName = this.extractMatchingVarRefName(node); + const args = [...(node.arguments ? node.arguments : [])]; + return { + ...variables, + [varRefName]: this.coerceVariable(variables[varRefName], { variables, args }) + }; } private extractMatchingVarRefName(node: FieldNode) { diff --git a/src/rewriters/Rewriter.ts b/src/rewriters/Rewriter.ts index f33d659..ccca7db 100644 --- a/src/rewriters/Rewriter.ts +++ b/src/rewriters/Rewriter.ts @@ -28,7 +28,8 @@ abstract class Rewriter { public matches(nodeAndVarDefs: NodeAndVarDefs, parents: ReadonlyArray): boolean { const { node } = nodeAndVarDefs; - if (node.kind !== 'Field' || node.name.value !== this.fieldName) return false; + if (node.kind !== 'Field' || (node.name.value !== this.fieldName && !this.matchConditions)) + return false; const root = parents[0]; if ( root.kind === 'OperationDefinition' && @@ -48,11 +49,11 @@ abstract class Rewriter { return true; } - public rewriteQuery(nodeAndVarDefs: NodeAndVarDefs): NodeAndVarDefs { + public rewriteQuery(nodeAndVarDefs: NodeAndVarDefs, _?: Variables): NodeAndVarDefs { return nodeAndVarDefs; } - public rewriteVariables(nodeAndVarDefs: NodeAndVarDefs, variables: Variables): Variables { + public rewriteVariables(_: NodeAndVarDefs, variables: Variables): Variables { return variables; } From b73c68a48cfe46aafd13f4f220c0a73bd1f12840 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20P=C3=A9rez=20Manr=C3=ADquez?= Date: Tue, 24 May 2022 15:48:07 -0400 Subject: [PATCH 02/21] Adds unit tests for new functionalities --- src/rewriters/FieldArgTypeRewriter.ts | 48 +++++-- src/rewriters/Rewriter.ts | 18 ++- test/functional/rewriteFieldArgType.test.ts | 140 ++++++++++++++++++++ 3 files changed, 189 insertions(+), 17 deletions(-) diff --git a/src/rewriters/FieldArgTypeRewriter.ts b/src/rewriters/FieldArgTypeRewriter.ts index 11427fb..aac6854 100644 --- a/src/rewriters/FieldArgTypeRewriter.ts +++ b/src/rewriters/FieldArgTypeRewriter.ts @@ -5,8 +5,11 @@ import { FieldNode, parseType, TypeNode, - VariableNode + VariableNode, + Kind, + isValueNode } from 'graphql'; +import Maybe from 'graphql/tsutils/Maybe'; import { NodeAndVarDefs, nodesMatch } from '../ast'; import { identifyFunc } from '../utils'; import Rewriter, { RewriterOpts, Variables } from './Rewriter'; @@ -25,7 +28,7 @@ interface FieldArgTypeRewriterOpts extends RewriterOpts { coerceArgumentValue?: ( variable: any, context: { variables: Variables; args: ArgumentNode[] } - ) => any; + ) => Maybe; } /** @@ -39,10 +42,16 @@ class FieldArgTypeRewriter extends Rewriter { // Passes context with rest of arguments and variables. // Quite useful for variable coercion that depends on other arguments/variables // (e.g., [offset, limit] to [pageSize, pageNumber] coercion) - protected coerceVariable; + protected coerceVariable: ( + variable: any, + context: { variables: Variables; args: ArgumentNode[] } + ) => any; // (Experimental): Used to coerce arguments whose value // does not come in a variable. - protected coerceArgumentValue; + protected coerceArgumentValue: ( + variable: any, + context: { variables: Variables; args: ArgumentNode[] } + ) => Maybe; constructor(options: FieldArgTypeRewriterOpts) { super(options); @@ -60,10 +69,9 @@ class FieldArgTypeRewriter extends Rewriter { // is this a field with the correct fieldName and arguments? if (node.kind !== 'Field') return false; - // If the fieldName doesnt match, but there are matchConditions. - // matchConditions should have higher priority than fieldName to determine a match. - if ((node.name.value !== this.fieldName && !this.matchConditions) || !node.arguments) - return false; + // does this field contain arguments? + if (!node.arguments) return false; + // is there an argument with the correct name and type in a variable? const matchingArgument = node.arguments.find(arg => arg.name.value === this.argName); @@ -81,8 +89,15 @@ class FieldArgTypeRewriter extends Rewriter { } // argument value comes in query doc. else { - const argRef = matchingArgument.value; - return nodesMatch(this.oldTypeNode, parseType(argRef.kind)); + const argValueNode = matchingArgument.value; + return isValueNode(argValueNode); + // Would be ideal to do a nodesMatch in here, however argument value nodes + // have different format for their values than when passed as variables. + // For instance, are parsed with Kinds as "graphql.Kind" (e.g., INT="IntValue") and not "graphql.TokenKinds" (e.g., INT="Int") + // So they might not match correctly. Also they dont contain additional parsed syntax + // as the non-optional symbol "!". So just return true if the argument.value is a ValueNode. + // + // return nodesMatch(this.oldTypeNode, parseType(argRef.kind)); } return false; @@ -119,7 +134,7 @@ class FieldArgTypeRewriter extends Rewriter { Object.assign(matchingArgument, { value: newArgValue }) */ - Object.assign(matchingArgument, { value: newValue }); + if (newValue) Object.assign(matchingArgument, { value: newValue }); } return { node, variableDefinitions }; } @@ -132,13 +147,18 @@ class FieldArgTypeRewriter extends Rewriter { const args = [...(node.arguments ? node.arguments : [])]; return { ...variables, - [varRefName]: this.coerceVariable(variables[varRefName], { variables, args }) + ...(varRefName + ? { [varRefName]: this.coerceVariable(variables[varRefName], { variables, args }) } + : {}) }; } private extractMatchingVarRefName(node: FieldNode) { - const matchingArgument = (node.arguments || []).find(arg => arg.name.value === this.argName); - return ((matchingArgument as ArgumentNode).value as VariableNode).name.value; + const matchingArgument = ( + (node.arguments || []).find(arg => arg.name.value === this.argName) + ); + const variableNode = matchingArgument.value; + return variableNode.kind === Kind.VARIABLE && variableNode.name.value; } } diff --git a/src/rewriters/Rewriter.ts b/src/rewriters/Rewriter.ts index ccca7db..4ed5d29 100644 --- a/src/rewriters/Rewriter.ts +++ b/src/rewriters/Rewriter.ts @@ -6,7 +6,7 @@ export type Variables = { [key: string]: any } | undefined; export type RootType = 'query' | 'mutation' | 'fragment'; export interface RewriterOpts { - fieldName: string; + fieldName?: string; rootTypes?: RootType[]; matchConditions?: matchCondition[]; } @@ -16,19 +16,31 @@ export interface RewriterOpts { * Extend this class and overwrite its methods to create a new rewriter */ abstract class Rewriter { - protected fieldName: string; protected rootTypes: RootType[] = ['query', 'mutation', 'fragment']; + protected fieldName?: string; protected matchConditions?: matchCondition[]; constructor({ fieldName, rootTypes, matchConditions }: RewriterOpts) { this.fieldName = fieldName; this.matchConditions = matchConditions; + if (!this.fieldName && !this.matchConditions) { + throw new Error( + 'Neither a fieldName or matchConditions were provided. Please choose to pass either one in order to be able to detect which fields to rewrite.' + ); + } if (rootTypes) this.rootTypes = rootTypes; } public matches(nodeAndVarDefs: NodeAndVarDefs, parents: ReadonlyArray): boolean { const { node } = nodeAndVarDefs; - if (node.kind !== 'Field' || (node.name.value !== this.fieldName && !this.matchConditions)) + + // If no fieldName is provided, check for defined matchConditions. + // This avoids having to define one rewriter for many fields individually. + // Alternatively, regex matching for fieldName could be implemented. + if ( + node.kind !== 'Field' || + (this.fieldName ? node.name.value !== this.fieldName : !this.matchConditions) + ) return false; const root = parents[0]; if ( diff --git a/test/functional/rewriteFieldArgType.test.ts b/test/functional/rewriteFieldArgType.test.ts index 9f8d173..af47e1c 100644 --- a/test/functional/rewriteFieldArgType.test.ts +++ b/test/functional/rewriteFieldArgType.test.ts @@ -1,3 +1,4 @@ +import { FieldNode, astFromValue, GraphQLInt, Kind } from 'graphql'; import RewriteHandler from '../../src/RewriteHandler'; import FieldArgTypeRewriter from '../../src/rewriters/FieldArgTypeRewriter'; import { gqlFmt } from '../testUtils'; @@ -98,6 +99,145 @@ describe('Rewrite field arg type', () => { }); }); + it('variable coercion comes with additional variables and arguments as context.', () => { + const query = gqlFmt` + query doTheThings($arg1: String!, $arg2: String) { + things(identifier: $arg1, arg2: $arg2, arg3: "blah") { + cat + } + } + `; + const expectedRewritenQuery = gqlFmt` + query doTheThings($arg1: Int!, $arg2: String) { + things(identifier: $arg1, arg2: $arg2, arg3: "blah") { + cat + } + } + `; + + const handler = new RewriteHandler([ + new FieldArgTypeRewriter({ + fieldName: 'things', + argName: 'identifier', + oldType: 'String!', + newType: 'Int!', + coerceVariable: (_, { variables = {}, args }) => { + expect(args.length).toBe(3); + expect(args[0].kind).toBe('Argument'); + expect(args[0].value.kind).toBe(Kind.VARIABLE); + expect(args[1].kind).toBe('Argument'); + expect(args[1].value.kind).toBe(Kind.VARIABLE); + expect(args[2].kind).toBe('Argument'); + expect(args[2].value.kind).toBe(Kind.STRING); + const { arg2 = 0 } = variables; + return parseInt(arg2, 10); + } + }) + ]); + expect(handler.rewriteRequest(query, { arg1: 'someString', arg2: '123' })).toEqual({ + query: expectedRewritenQuery, + variables: { + arg1: 123, + arg2: '123' + } + }); + }); + + it('can be passed a coerceArgumentValue function to change argument values.', () => { + const query = gqlFmt` + query doTheThings { + things(identifier: "123", arg2: "blah") { + cat + } + } + `; + const expectedRewritenQuery = gqlFmt` + query doTheThings { + things(identifier: 123, arg2: "blah") { + cat + } + } + `; + + const handler = new RewriteHandler([ + new FieldArgTypeRewriter({ + fieldName: 'things', + argName: 'identifier', + oldType: 'String!', + newType: 'Int!', + coerceArgumentValue: argValue => { + const value = argValue.value; + const newArgValue = astFromValue(parseInt(value, 10), GraphQLInt); + return newArgValue; + } + }) + ]); + + expect(handler.rewriteRequest(query)).toEqual({ + query: expectedRewritenQuery + }); + }); + + it('should fail if neither a fieldName or matchConditions are provided', () => { + try { + new FieldArgTypeRewriter({ + argName: 'identifier', + oldType: 'String!', + newType: 'Int!' + }); + } catch (error) { + console.log(error.message); + expect( + error.message.includes('Neither a fieldName or matchConditions were provided') + ).toEqual(true); + } + }); + + it('allows matching using matchConditions when fieldName is not provided.', () => { + const query = gqlFmt` + query doTheThings($arg1: String!, $arg2: String) { + things(identifier: $arg1, arg2: $arg2) { + cat + } + } + `; + const expectedRewritenQuery = gqlFmt` + query doTheThings($arg1: Int!, $arg2: String) { + things(identifier: $arg1, arg2: $arg2) { + cat + } + } + `; + + // Tests a dummy regex to match the "things" field. + const fieldNameRegExp = '.hings'; + + const handler = new RewriteHandler([ + new FieldArgTypeRewriter({ + argName: 'identifier', + oldType: 'String!', + newType: 'Int!', + matchConditions: [ + nodeAndVars => { + const node = nodeAndVars.node as FieldNode; + const { + name: { value: fieldName } + } = node; + return fieldName.search(new RegExp(fieldNameRegExp)) !== -1; + } + ], + coerceVariable: val => parseInt(val, 10) + }) + ]); + expect(handler.rewriteRequest(query, { arg1: '123', arg2: 'blah' })).toEqual({ + query: expectedRewritenQuery, + variables: { + arg1: 123, + arg2: 'blah' + } + }); + }); + it('works on deeply nested fields', () => { const query = gqlFmt` query doTheThings($arg1: String!, $arg2: String) { From 6717867bc07ba39626fcc60ae86511aa6b59e404 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20P=C3=A9rez=20Manr=C3=ADquez?= Date: Thu, 26 May 2022 10:17:54 -0400 Subject: [PATCH 03/21] fixes linting issues --- src/rewriters/FieldArgTypeRewriter.ts | 51 +++++++++++++-------------- src/rewriters/Rewriter.ts | 7 ++-- 2 files changed, 28 insertions(+), 30 deletions(-) diff --git a/src/rewriters/FieldArgTypeRewriter.ts b/src/rewriters/FieldArgTypeRewriter.ts index aac6854..dbac77a 100644 --- a/src/rewriters/FieldArgTypeRewriter.ts +++ b/src/rewriters/FieldArgTypeRewriter.ts @@ -1,13 +1,13 @@ import { ArgumentNode, - ValueNode, ASTNode, FieldNode, + isValueNode, + Kind, parseType, TypeNode, - VariableNode, - Kind, - isValueNode + ValueNode, + VariableNode } from 'graphql'; import Maybe from 'graphql/tsutils/Maybe'; import { NodeAndVarDefs, nodesMatch } from '../ast'; @@ -118,26 +118,23 @@ class FieldArgTypeRewriter extends Rewriter { return { node, variableDefinitions: newVarDefs }; } // If argument value is not stored in a variable but in the query node. - else { - const matchingArgument = (node.arguments || []).find(arg => arg.name.value === this.argName); - if (node.arguments && matchingArgument) { - const args = [...node.arguments]; - const newValue = this.coerceArgumentValue(matchingArgument.value, { variables, args }); - /** - TODO: If somewhow we can get the schema here, we could make the coerceArgumentValue - even easier, as we would be able to construct the ast node for the argument value. - as of now, the user has to take care of correctly constructing the argument value ast node herself. - - const schema = makeExecutableSchema({typeDefs}) - const myCustomType = schema.getType("MY_CUSTOM_TYPE_NAME") - const newArgValue = astFromValue(newValue, myCustomType) - Object.assign(matchingArgument, { value: newArgValue }) - - */ - if (newValue) Object.assign(matchingArgument, { value: newValue }); - } - return { node, variableDefinitions }; + const matchingArgument = (node.arguments || []).find(arg => arg.name.value === this.argName); + if (node.arguments && matchingArgument) { + const args = [...node.arguments]; + const newValue = this.coerceArgumentValue(matchingArgument.value, { variables, args }); + /** + * TODO: If somewhow we can get the schema here, we could make the coerceArgumentValue + * even easier, as we would be able to construct the ast node for the argument value. + * as of now, the user has to take care of correctly constructing the argument value ast node herself. + * + * const schema = makeExecutableSchema({typeDefs}) + * const myCustomType = schema.getType("MY_CUSTOM_TYPE_NAME") + * const newArgValue = astFromValue(newValue, myCustomType) + * Object.assign(matchingArgument, { value: newArgValue }) + */ + if (newValue) Object.assign(matchingArgument, { value: newValue }); } + return { node, variableDefinitions }; } public rewriteVariables({ node: astNode }: NodeAndVarDefs, variables: Variables) { @@ -154,10 +151,10 @@ class FieldArgTypeRewriter extends Rewriter { } private extractMatchingVarRefName(node: FieldNode) { - const matchingArgument = ( - (node.arguments || []).find(arg => arg.name.value === this.argName) - ); - const variableNode = matchingArgument.value; + const matchingArgument = (node.arguments || []).find( + arg => arg.name.value === this.argName + ) as ArgumentNode; + const variableNode = matchingArgument.value as VariableNode; return variableNode.kind === Kind.VARIABLE && variableNode.name.value; } } diff --git a/src/rewriters/Rewriter.ts b/src/rewriters/Rewriter.ts index 4ed5d29..f138596 100644 --- a/src/rewriters/Rewriter.ts +++ b/src/rewriters/Rewriter.ts @@ -40,8 +40,9 @@ abstract class Rewriter { if ( node.kind !== 'Field' || (this.fieldName ? node.name.value !== this.fieldName : !this.matchConditions) - ) + ) { return false; + } const root = parents[0]; if ( root.kind === 'OperationDefinition' && @@ -61,11 +62,11 @@ abstract class Rewriter { return true; } - public rewriteQuery(nodeAndVarDefs: NodeAndVarDefs, _?: Variables): NodeAndVarDefs { + public rewriteQuery(nodeAndVarDefs: NodeAndVarDefs, variables: Variables): NodeAndVarDefs { return nodeAndVarDefs; } - public rewriteVariables(_: NodeAndVarDefs, variables: Variables): Variables { + public rewriteVariables(nodeAndVarDefs: NodeAndVarDefs, variables: Variables): Variables { return variables; } From d92469185c985ec2d875efafbfc785d2c97fb227 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20P=C3=A9rez=20Manr=C3=ADquez?= Date: Thu, 26 May 2022 12:14:05 -0400 Subject: [PATCH 04/21] Creates a generic FieldRewriter --- src/rewriters/FieldRewriter.ts | 84 ++++ test/functional/rewriteField.test.ts | 451 ++++++++++++++++++++ test/functional/rewriteFieldArgType.test.ts | 1 - 3 files changed, 535 insertions(+), 1 deletion(-) create mode 100644 src/rewriters/FieldRewriter.ts create mode 100644 test/functional/rewriteField.test.ts diff --git a/src/rewriters/FieldRewriter.ts b/src/rewriters/FieldRewriter.ts new file mode 100644 index 0000000..983939a --- /dev/null +++ b/src/rewriters/FieldRewriter.ts @@ -0,0 +1,84 @@ +import { ASTNode, FieldNode, SelectionSetNode } from 'graphql'; +import { NodeAndVarDefs } from '../ast'; +import Rewriter, { RewriterOpts } from './Rewriter'; + +interface FieldRewriterOpts extends RewriterOpts { + newFieldName?: string; + objectFieldName?: string; +} + +/** + * More generic version of ScalarFieldToObjectField rewriter + */ +class FieldRewriter extends Rewriter { + protected newFieldName?: string; + protected objectFieldName?: string; + + constructor(options: FieldRewriterOpts) { + super(options); + this.newFieldName = options.newFieldName; + this.objectFieldName = options.objectFieldName; + } + + public matches(nodeAndVars: NodeAndVarDefs, parents: ASTNode[]): boolean { + if (!super.matches(nodeAndVars, parents)) return false; + const node = nodeAndVars.node as FieldNode; + // if there's the intention of converting the field to a subselection + // make sure there's no subselections on this field + if (node.selectionSet && !!this.objectFieldName) return false; + return true; + } + + public rewriteQuery(nodeAndVarDefs: NodeAndVarDefs) { + const node = nodeAndVarDefs.node as FieldNode; + const { variableDefinitions } = nodeAndVarDefs; + // if there's the intention of converting the field to a subselection + // and there's a subselection already, just return + if (node.selectionSet && !!this.objectFieldName) return nodeAndVarDefs; + + // if fieldName is meant to be renamed. + if (this.newFieldName) { + Object.assign(node.name, { value: this.newFieldName }); + } + + // if there's the intention of converting the field to a subselection + // of objectFieldNames + if (this.objectFieldName) { + const selectionSet: SelectionSetNode = { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: this.objectFieldName } + } + ] + }; + Object.assign(node, { selectionSet }); + } + + return { + variableDefinitions, + node + } as NodeAndVarDefs; + } + + public rewriteResponse(response: any, key: string, index?: number) { + // Extract the element we are working on + const element = super.extractReponseElement(response, key, index); + if (element === null) return response; + + let originalKey = key; + if (key === this.newFieldName) { + delete response[key]; + if (this.fieldName) originalKey = this.fieldName; + } + // Undo the nesting in the response so it matches the original query + let newElement = element; + if (this.objectFieldName) { + newElement = element[this.objectFieldName]; + } + return super.rewriteResponseElement(response, newElement, originalKey, index); + } +} + +export default FieldRewriter; diff --git a/test/functional/rewriteField.test.ts b/test/functional/rewriteField.test.ts new file mode 100644 index 0000000..cf34860 --- /dev/null +++ b/test/functional/rewriteField.test.ts @@ -0,0 +1,451 @@ +import RewriteHandler from '../../src/RewriteHandler'; +import FieldRewriter from '../../src/rewriters/FieldRewriter'; +import { gqlFmt } from '../testUtils'; + +describe('Rewrite scalar field to be a nested object with a single scalar field', () => { + it('rewrites a scalar field to be an object field with 1 scalar subfield', () => { + const handler = new RewriteHandler([ + new FieldRewriter({ + fieldName: 'title', + objectFieldName: 'text' + }) + ]); + + const query = gqlFmt` + query getTheThing { + theThing { + thingField { + id + title + color + } + } + } + `; + const expectedRewritenQuery = gqlFmt` + query getTheThing { + theThing { + thingField { + id + title { + text + } + color + } + } + } + `; + expect(handler.rewriteRequest(query)).toEqual({ + query: expectedRewritenQuery + }); + expect( + handler.rewriteResponse({ + theThing: { + thingField: { + id: 1, + title: { + text: 'THING' + }, + color: 'blue' + } + } + }) + ).toEqual({ + theThing: { + thingField: { + id: 1, + title: 'THING', + color: 'blue' + } + } + }); + }); + + it('rewrites a scalar field to be a renamed object field with 1 scalar subfield', () => { + const handler = new RewriteHandler([ + new FieldRewriter({ + fieldName: 'subField', + newFieldName: 'renamedSubField', + objectFieldName: 'value' + }) + ]); + + const query = gqlFmt` + query getTheThing { + theThing { + thingField { + id + subField + color + } + } + } + `; + const expectedRewritenQuery = gqlFmt` + query getTheThing { + theThing { + thingField { + id + renamedSubField { + value + } + color + } + } + } + `; + expect(handler.rewriteRequest(query)).toEqual({ + query: expectedRewritenQuery + }); + expect( + handler.rewriteResponse({ + theThing: { + thingField: { + id: 1, + renamedSubField: { + value: 'THING' + }, + color: 'blue' + } + } + }) + ).toEqual({ + theThing: { + thingField: { + id: 1, + subField: 'THING', + color: 'blue' + } + } + }); + }); + + it('renames a field', () => { + const handler = new RewriteHandler([ + new FieldRewriter({ + fieldName: 'subField', + newFieldName: 'renamedSubField' + }) + ]); + + const query = gqlFmt` + query getTheThing { + theThing { + thingField { + id + subField { + value + } + color + } + } + } + `; + const expectedRewritenQuery = gqlFmt` + query getTheThing { + theThing { + thingField { + id + renamedSubField { + value + } + color + } + } + } + `; + expect(handler.rewriteRequest(query)).toEqual({ + query: expectedRewritenQuery + }); + expect( + handler.rewriteResponse({ + theThing: { + thingField: { + id: 1, + renamedSubField: { + value: 'THING' + }, + color: 'blue' + } + } + }) + ).toEqual({ + theThing: { + thingField: { + id: 1, + subField: { value: 'THING' }, + color: 'blue' + } + } + }); + }); + + it('rewrites a scalar field to be a renamed object field with 1 scalar subfield', () => { + const handler = new RewriteHandler([ + new FieldRewriter({ + fieldName: 'subField', + newFieldName: 'renamedSubField', + objectFieldName: 'value' + }) + ]); + + const query = gqlFmt` + query getTheThing { + theThing { + thingField { + id + subField + color + } + } + } + `; + const expectedRewritenQuery = gqlFmt` + query getTheThing { + theThing { + thingField { + id + renamedSubField { + value + } + color + } + } + } + `; + expect(handler.rewriteRequest(query)).toEqual({ + query: expectedRewritenQuery + }); + expect( + handler.rewriteResponse({ + theThing: { + thingField: { + id: 1, + renamedSubField: { + value: 'THING' + }, + color: 'blue' + } + } + }) + ).toEqual({ + theThing: { + thingField: { + id: 1, + subField: 'THING', + color: 'blue' + } + } + }); + }); + + it('works with fragments', () => { + const handler = new RewriteHandler([ + new FieldRewriter({ + fieldName: 'title', + objectFieldName: 'text' + }) + ]); + + const query = gqlFmt` + query getTheThing { + theThing { + ...thingFragment + } + } + + fragment thingFragment on Thing { + id + title + } + `; + const expectedRewritenQuery = gqlFmt` + query getTheThing { + theThing { + ...thingFragment + } + } + + fragment thingFragment on Thing { + id + title { + text + } + } + `; + expect(handler.rewriteRequest(query)).toEqual({ + query: expectedRewritenQuery + }); + expect( + handler.rewriteResponse({ + theThing: { + id: 1, + title: { + text: 'THING' + } + } + }) + ).toEqual({ + theThing: { + id: 1, + title: 'THING' + } + }); + }); + + it('works within repeated and nested fragments', () => { + const handler = new RewriteHandler([ + new FieldRewriter({ + fieldName: 'title', + objectFieldName: 'text' + }) + ]); + + const query = gqlFmt` + query getTheThing { + theThing { + ...thingFragment + } + otherThing { + ...otherThingFragment + } + } + + fragment thingFragment on Thing { + id + title + } + + fragment otherThingFragment on Thing { + id + edges { + node { + ...thingFragment + } + } + } + `; + const expectedRewritenQuery = gqlFmt` + query getTheThing { + theThing { + ...thingFragment + } + otherThing { + ...otherThingFragment + } + } + + fragment thingFragment on Thing { + id + title { + text + } + } + + fragment otherThingFragment on Thing { + id + edges { + node { + ...thingFragment + } + } + } + `; + expect(handler.rewriteRequest(query)).toEqual({ + query: expectedRewritenQuery + }); + expect( + handler.rewriteResponse({ + theThing: { + id: 1, + title: { + text: 'THING' + } + }, + otherThing: { + id: 3, + edges: [ + { + node: { + title: { + text: 'NODE_TEXT1' + } + } + }, + { + node: { + title: { + text: 'NODE_TEXT2' + } + } + } + ] + } + }) + ).toEqual({ + theThing: { + id: 1, + title: 'THING' + }, + otherThing: { + id: 3, + edges: [ + { + node: { + title: 'NODE_TEXT1' + } + }, + { + node: { + title: 'NODE_TEXT2' + } + } + ] + } + }); + }); + + it('rewrites a scalar field array to be an array of object fields with 1 scalar subfield', () => { + const handler = new RewriteHandler([ + new FieldRewriter({ + fieldName: 'titles', + objectFieldName: 'text' + }) + ]); + + const query = gqlFmt` + query getThing { + thing { + titles + } + } + `; + const expectedRewritenQuery = gqlFmt` + query getThing { + thing { + titles { + text + } + } + } + `; + expect(handler.rewriteRequest(query)).toEqual({ + query: expectedRewritenQuery + }); + expect( + handler.rewriteResponse({ + thing: { + titles: [ + { + text: 'THING' + } + ] + } + }) + ).toEqual({ + thing: { + titles: ['THING'] + } + }); + }); +}); diff --git a/test/functional/rewriteFieldArgType.test.ts b/test/functional/rewriteFieldArgType.test.ts index af47e1c..142e3ea 100644 --- a/test/functional/rewriteFieldArgType.test.ts +++ b/test/functional/rewriteFieldArgType.test.ts @@ -186,7 +186,6 @@ describe('Rewrite field arg type', () => { newType: 'Int!' }); } catch (error) { - console.log(error.message); expect( error.message.includes('Neither a fieldName or matchConditions were provided') ).toEqual(true); From f67ca802fdaf5f116453055d928ff983940ef81c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20P=C3=A9rez=20Manr=C3=ADquez?= Date: Thu, 26 May 2022 12:52:05 -0400 Subject: [PATCH 05/21] forgot to export new rewriter --- src/rewriters/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/rewriters/index.ts b/src/rewriters/index.ts index 35376e3..52a4560 100644 --- a/src/rewriters/index.ts +++ b/src/rewriters/index.ts @@ -5,3 +5,4 @@ export { default as FieldArgTypeRewriter } from './FieldArgTypeRewriter'; export { default as NestFieldOutputsRewriter } from './NestFieldOutputsRewriter'; export { default as ScalarFieldToObjectFieldRewriter } from './ScalarFieldToObjectFieldRewriter'; export { default as JsonToTypedObjectRewriter } from './JsonToTypedObjectRewriter'; +export { default as FieldRewriter } from './FieldRewriter'; From ac91ec44a0a92a6efc75a3860776eea0cd2cde9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20P=C3=A9rez=20Manr=C3=ADquez?= Date: Thu, 26 May 2022 15:25:36 -0400 Subject: [PATCH 06/21] Adds ability to add arguments to the field in FieldRewriter --- src/ast.ts | 30 +++++++++++++- src/rewriters/FieldRewriter.ts | 39 +++++++++++++++--- test/functional/rewriteField.test.ts | 61 ++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 6 deletions(-) diff --git a/src/ast.ts b/src/ast.ts index 525cde9..a3a8f36 100644 --- a/src/ast.ts +++ b/src/ast.ts @@ -1,4 +1,11 @@ -import { ASTNode, DocumentNode, FragmentDefinitionNode, VariableDefinitionNode } from 'graphql'; +import { + ArgumentNode, + ASTNode, + DocumentNode, + FragmentDefinitionNode, + Kind, + VariableDefinitionNode +} from 'graphql'; import { pushToArrayAtKey } from './utils'; const ignoreKeys = new Set(['loc']); @@ -252,6 +259,27 @@ export const extractPath = (parents: ReadonlyArray): ReadonlyArray { + return { + kind: Kind.ARGUMENT, + name: { + kind: Kind.NAME, + value: argName + }, + value: { + kind: Kind.VARIABLE, + name: { + kind: Kind.NAME, + value: argName + } + } + }; +}; + /** @hidden */ interface ResultObj { [key: string]: any; diff --git a/src/rewriters/FieldRewriter.ts b/src/rewriters/FieldRewriter.ts index 983939a..143b6f5 100644 --- a/src/rewriters/FieldRewriter.ts +++ b/src/rewriters/FieldRewriter.ts @@ -1,9 +1,10 @@ -import { ASTNode, FieldNode, SelectionSetNode } from 'graphql'; -import { NodeAndVarDefs } from '../ast'; -import Rewriter, { RewriterOpts } from './Rewriter'; +import { ArgumentNode, ASTNode, FieldNode, SelectionSetNode } from 'graphql'; +import { astArgVarNode, NodeAndVarDefs } from '../ast'; +import Rewriter, { RewriterOpts, Variables } from './Rewriter'; interface FieldRewriterOpts extends RewriterOpts { newFieldName?: string; + arguments?: string[]; objectFieldName?: string; } @@ -12,11 +13,13 @@ interface FieldRewriterOpts extends RewriterOpts { */ class FieldRewriter extends Rewriter { protected newFieldName?: string; + protected arguments?: string[]; protected objectFieldName?: string; constructor(options: FieldRewriterOpts) { super(options); this.newFieldName = options.newFieldName; + this.arguments = options.arguments; this.objectFieldName = options.objectFieldName; } @@ -29,7 +32,7 @@ class FieldRewriter extends Rewriter { return true; } - public rewriteQuery(nodeAndVarDefs: NodeAndVarDefs) { + public rewriteQuery(nodeAndVarDefs: NodeAndVarDefs, variables: Variables) { const node = nodeAndVarDefs.node as FieldNode; const { variableDefinitions } = nodeAndVarDefs; // if there's the intention of converting the field to a subselection @@ -42,7 +45,7 @@ class FieldRewriter extends Rewriter { } // if there's the intention of converting the field to a subselection - // of objectFieldNames + // of objectFieldNames assign SelectionSetNode to the field accordingly. if (this.objectFieldName) { const selectionSet: SelectionSetNode = { kind: 'SelectionSet', @@ -56,6 +59,23 @@ class FieldRewriter extends Rewriter { Object.assign(node, { selectionSet }); } + // If, 1) the field is a SelectionSet, + // 2) this.arguments is not empty nor undefined, and + // 3) query comes with variables, then assign ArgumentNodes to the field accordingly. + if (node.selectionSet && !!this.arguments && variables) { + // field may already come with some arguments + const newArguments: ArgumentNode[] = [...(node.arguments || [])]; + this.arguments.forEach(argName => { + if ( + this.isArgumentInVariables(argName, variables) && + !this.isArgumentInArguments(argName, newArguments) + ) { + newArguments.push(astArgVarNode(argName)); + } + }); + if (!!newArguments) Object.assign(node, { arguments: newArguments }); + } + return { variableDefinitions, node @@ -79,6 +99,15 @@ class FieldRewriter extends Rewriter { } return super.rewriteResponseElement(response, newElement, originalKey, index); } + + private isArgumentInArguments(argName: string, argumentNodes: ArgumentNode[]) { + return argumentNodes.map(argNode => argNode.name.value).includes(argName); + } + + private isArgumentInVariables(argName: string, variables: Variables): boolean { + if (variables && Object.keys(variables).includes(argName)) return true; + return false; + } } export default FieldRewriter; diff --git a/test/functional/rewriteField.test.ts b/test/functional/rewriteField.test.ts index cf34860..eab489c 100644 --- a/test/functional/rewriteField.test.ts +++ b/test/functional/rewriteField.test.ts @@ -120,6 +120,67 @@ describe('Rewrite scalar field to be a nested object with a single scalar field' }); }); + it('rewrites a scalar field to be a renamed object field with variable arguments and with 1 scalar subfield', () => { + const handler = new RewriteHandler([ + new FieldRewriter({ + fieldName: 'subField', + newFieldName: 'renamedSubField', + arguments: ['arg1'], + objectFieldName: 'value' + }) + ]); + + const query = gqlFmt` + query getTheThing($arg1: String) { + theThing { + thingField { + id + subField + color + } + } + } + `; + const expectedRewritenQuery = gqlFmt` + query getTheThing($arg1: String) { + theThing { + thingField { + id + renamedSubField(arg1: $arg1) { + value + } + color + } + } + } + `; + expect(handler.rewriteRequest(query, { arg1: 'thingArg' })).toEqual({ + query: expectedRewritenQuery, + variables: { arg1: 'thingArg' } + }); + expect( + handler.rewriteResponse({ + theThing: { + thingField: { + id: 1, + renamedSubField: { + value: 'THING' + }, + color: 'blue' + } + } + }) + ).toEqual({ + theThing: { + thingField: { + id: 1, + subField: 'THING', + color: 'blue' + } + } + }); + }); + it('renames a field', () => { const handler = new RewriteHandler([ new FieldRewriter({ From b05e39ccae4d3ffd27245f5e270f2d6191538b67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20P=C3=A9rez=20Manr=C3=ADquez?= Date: Thu, 26 May 2022 17:50:13 -0400 Subject: [PATCH 07/21] fixes issue when renamed object fields returned an array of values --- src/rewriters/FieldRewriter.ts | 10 ++- test/functional/rewriteField.test.ts | 129 +++++++++++++++++++++++++++ 2 files changed, 137 insertions(+), 2 deletions(-) diff --git a/src/rewriters/FieldRewriter.ts b/src/rewriters/FieldRewriter.ts index 143b6f5..fa44bd4 100644 --- a/src/rewriters/FieldRewriter.ts +++ b/src/rewriters/FieldRewriter.ts @@ -88,9 +88,15 @@ class FieldRewriter extends Rewriter { if (element === null) return response; let originalKey = key; + // if the key is found to be the renamed field + // then change the name of such field in the response + // and pass the new key (field name) down. if (key === this.newFieldName) { - delete response[key]; - if (this.fieldName) originalKey = this.fieldName; + if (this.fieldName) { + originalKey = this.fieldName; + Object.assign(response, { [originalKey]: response[key] }); + delete response[key]; + } } // Undo the nesting in the response so it matches the original query let newElement = element; diff --git a/test/functional/rewriteField.test.ts b/test/functional/rewriteField.test.ts index eab489c..2454478 100644 --- a/test/functional/rewriteField.test.ts +++ b/test/functional/rewriteField.test.ts @@ -120,6 +120,135 @@ describe('Rewrite scalar field to be a nested object with a single scalar field' }); }); + it('renames object field with an object as response value', () => { + const handler = new RewriteHandler([ + new FieldRewriter({ + fieldName: 'subField', + newFieldName: 'renamedSubField' + }) + ]); + + const query = gqlFmt` + query getTheThing { + theThing { + thingField { + id + subField { + value + } + color + } + } + } + `; + const expectedRewritenQuery = gqlFmt` + query getTheThing { + theThing { + thingField { + id + renamedSubField { + value + } + color + } + } + } + `; + expect(handler.rewriteRequest(query)).toEqual({ + query: expectedRewritenQuery + }); + expect( + handler.rewriteResponse({ + theThing: { + thingField: { + id: 1, + renamedSubField: { + value: 'THING_1' + }, + color: 'blue' + } + } + }) + ).toEqual({ + theThing: { + thingField: { + id: 1, + subField: { + value: 'THING_1' + }, + color: 'blue' + } + } + }); + }); + + it('renames object field with an array as response values', () => { + const handler = new RewriteHandler([ + new FieldRewriter({ + fieldName: 'subField', + newFieldName: 'renamedSubField' + }) + ]); + + const query = gqlFmt` + query getTheThing { + theThing { + thingField { + id + subField { + value + } + color + } + } + } + `; + const expectedRewritenQuery = gqlFmt` + query getTheThing { + theThing { + thingField { + id + renamedSubField { + value + } + color + } + } + } + `; + + const values = [ + { + value: 'THING_1' + }, + { + value: 'THING_2' + } + ]; + expect(handler.rewriteRequest(query)).toEqual({ + query: expectedRewritenQuery + }); + expect( + handler.rewriteResponse({ + theThing: { + thingField: { + id: 1, + renamedSubField: values, + color: 'blue' + } + } + }) + ).toEqual({ + theThing: { + thingField: { + id: 1, + subField: values, + color: 'blue' + } + } + }); + }); + it('rewrites a scalar field to be a renamed object field with variable arguments and with 1 scalar subfield', () => { const handler = new RewriteHandler([ new FieldRewriter({ From 9da7701a88034bd355a36aa7c083cf7a3e8f23a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20P=C3=A9rez=20Manr=C3=ADquez?= Date: Fri, 27 May 2022 13:04:34 -0400 Subject: [PATCH 08/21] Adds support for alises. Implements new CustomRewriter --- src/RewriteHandler.ts | 23 ++++--- src/ast.ts | 27 ++++++-- src/rewriters/CustomRewriter.ts | 44 +++++++++++++ src/rewriters/index.ts | 1 + test/functional/rewriteCustom.test.ts | 89 +++++++++++++++++++++++++++ test/functional/rewriteField.test.ts | 45 +++++++++++++- 6 files changed, 216 insertions(+), 13 deletions(-) create mode 100644 src/rewriters/CustomRewriter.ts create mode 100644 test/functional/rewriteCustom.test.ts diff --git a/src/RewriteHandler.ts b/src/RewriteHandler.ts index 92b3dd2..750a93b 100644 --- a/src/RewriteHandler.ts +++ b/src/RewriteHandler.ts @@ -4,7 +4,9 @@ import Rewriter, { Variables } from './rewriters/Rewriter'; interface RewriterMatch { rewriter: Rewriter; - paths: ReadonlyArray>; + fieldPaths: ReadonlyArray>; + // TODO: allPaths hasnt been tested for fragments + allPaths: ReadonlyArray>; } /** @@ -17,9 +19,11 @@ export default class RewriteHandler { private rewriters: Rewriter[]; private hasProcessedRequest: boolean = false; private hasProcessedResponse: boolean = false; + private matchAnyPath: boolean = false; - constructor(rewriters: Rewriter[]) { + constructor(rewriters: Rewriter[], matchAnyPath: boolean = false) { this.rewriters = rewriters; + this.matchAnyPath = matchAnyPath; } /** @@ -40,17 +44,21 @@ export default class RewriteHandler { if (isMatch) { rewrittenVariables = rewriter.rewriteVariables(rewrittenNodeAndVars, rewrittenVariables); rewrittenNodeAndVars = rewriter.rewriteQuery(rewrittenNodeAndVars, rewrittenVariables); - const simplePath = extractPath([...parents, rewrittenNodeAndVars.node]); - let paths: ReadonlyArray> = [simplePath]; + const fieldPath = extractPath([...parents, rewrittenNodeAndVars.node]); + const anyPath = extractPath([...parents, rewrittenNodeAndVars.node], true); + let fieldPaths: ReadonlyArray> = [fieldPath]; + let allPaths: ReadonlyArray> = [anyPath]; const fragmentDef = parents.find(({ kind }) => kind === 'FragmentDefinition') as | FragmentDefinitionNode | undefined; if (fragmentDef) { - paths = fragmentTracer.prependFragmentPaths(fragmentDef.name.value, simplePath); + fieldPaths = fragmentTracer.prependFragmentPaths(fragmentDef.name.value, fieldPath); + allPaths = fragmentTracer.prependFragmentPaths(fragmentDef.name.value, anyPath); } this.matches.push({ rewriter, - paths + fieldPaths, + allPaths }); } return isMatch; @@ -70,7 +78,8 @@ export default class RewriteHandler { if (this.hasProcessedResponse) throw new Error('This handler has already returned a response'); this.hasProcessedResponse = true; let rewrittenResponse = response; - this.matches.reverse().forEach(({ rewriter, paths }) => { + this.matches.reverse().forEach(({ rewriter, fieldPaths, allPaths }) => { + const paths = this.matchAnyPath ? allPaths : fieldPaths; paths.forEach(path => { rewrittenResponse = rewriteResultsAtPath( rewrittenResponse, diff --git a/src/ast.ts b/src/ast.ts index a3a8f36..c035686 100644 --- a/src/ast.ts +++ b/src/ast.ts @@ -246,14 +246,31 @@ export const replaceVariableDefinitions = ( }; /** - * return the path that will be returned in the response from from the chain of parents + * Return the path that will be returned in the response from the chain of parents. + * By default this will only build up paths for field nodes, but the anyKind flag allows + * to build paths for any named node. + * + * It also supports aliases. */ /** @hidden */ -export const extractPath = (parents: ReadonlyArray): ReadonlyArray => { +export const extractPath = ( + parents: ReadonlyArray, + anyKind?: boolean +): ReadonlyArray => { const path: string[] = []; - parents.forEach(parent => { - if (parent.kind === 'Field') { - path.push(parent.name.value); + parents.forEach((parent: any) => { + if (anyKind) { + if (parent.alias) { + path.push(parent.alias.value); + } else if (parent.name) { + path.push(parent.name.value); + } + } else if (parent.kind === 'Field') { + if (parent.alias) { + path.push(parent.alias.value); + } else { + path.push(parent.name.value); + } } }); return path; diff --git a/src/rewriters/CustomRewriter.ts b/src/rewriters/CustomRewriter.ts new file mode 100644 index 0000000..006e7e9 --- /dev/null +++ b/src/rewriters/CustomRewriter.ts @@ -0,0 +1,44 @@ +import { ASTNode } from 'graphql'; +import { NodeAndVarDefs } from '../ast'; +import Rewriter, { RewriterOpts, Variables } from './Rewriter'; + +interface CustomRewriterOpts extends RewriterOpts { + matchesFn?: (nodeAndVarDefs: NodeAndVarDefs, parents: ReadonlyArray) => boolean; + rewriteQueryFn?: (nodeAndVarDefs: NodeAndVarDefs, variables: Variables) => NodeAndVarDefs; + rewriteResponseFn?: (response: any, key: string, index?: number) => NodeAndVarDefs; +} + +/** + * A Custom rewriter with its Rewriter functions received as arguments. + * This Rewriter allows users to write their own rewriter functions. + */ +class CustomRewriter extends Rewriter { + protected matchesFn: (nodeAndVarDefs: NodeAndVarDefs, parents: ReadonlyArray) => boolean; + protected rewriteQueryFn: ( + nodeAndVarDefs: NodeAndVarDefs, + variables: Variables + ) => NodeAndVarDefs; + protected rewriteResponseFn: (response: any, key: string, index?: number) => NodeAndVarDefs; + + constructor(options: CustomRewriterOpts) { + const { matchesFn, rewriteQueryFn, rewriteResponseFn, ...rewriterOpts } = options; + super({ ...rewriterOpts, matchConditions: [() => true] }); + this.matchesFn = matchesFn || super.matches; + this.rewriteQueryFn = rewriteQueryFn || super.rewriteQuery; + this.rewriteResponseFn = rewriteResponseFn || super.rewriteResponse; + } + + public matches(nodeAndVarDefs: NodeAndVarDefs, parents: ReadonlyArray): boolean { + return this.matchesFn(nodeAndVarDefs, parents); + } + + public rewriteQuery(nodeAndVarDefs: NodeAndVarDefs, variables: Variables) { + return this.rewriteQueryFn(nodeAndVarDefs, variables); + } + + public rewriteResponse(response: any, key: string, index?: number) { + return this.rewriteResponseFn(response, key, index); + } +} + +export default CustomRewriter; diff --git a/src/rewriters/index.ts b/src/rewriters/index.ts index 52a4560..573581f 100644 --- a/src/rewriters/index.ts +++ b/src/rewriters/index.ts @@ -6,3 +6,4 @@ export { default as NestFieldOutputsRewriter } from './NestFieldOutputsRewriter' export { default as ScalarFieldToObjectFieldRewriter } from './ScalarFieldToObjectFieldRewriter'; export { default as JsonToTypedObjectRewriter } from './JsonToTypedObjectRewriter'; export { default as FieldRewriter } from './FieldRewriter'; +export { default as CustomRewriter } from './CustomRewriter'; diff --git a/test/functional/rewriteCustom.test.ts b/test/functional/rewriteCustom.test.ts new file mode 100644 index 0000000..0098dd8 --- /dev/null +++ b/test/functional/rewriteCustom.test.ts @@ -0,0 +1,89 @@ +import { ASTNode, Kind } from 'graphql'; +import { NodeAndVarDefs } from '../../src/ast'; +import RewriteHandler from '../../src/RewriteHandler'; +import CustomRewriter from '../../src/rewriters/CustomRewriter'; +import { gqlFmt } from '../testUtils'; + +const matchesFn = ({ node }: NodeAndVarDefs, parents: ReadonlyArray) => { + const parent = parents.slice(-1)[0]; + if (node.kind === Kind.SELECTION_SET && parent.kind === Kind.OPERATION_DEFINITION) { + return true; + } + return false; +}; + +const rewriteQueryFn = (nodeAndVarDefs: any) => { + const newNode = nodeAndVarDefs.node; + // Get 'queryObjectFields' so we can add the new queryField later. + const queryObjectFields = newNode.selections; + + // Find the target field we want to hoist and remove it from its current position. + const selectionSet = queryObjectFields[0].selectionSet; + const theThingObjectFields = selectionSet.selections; + const targetFieldNode = theThingObjectFields.pop(); + + // Hoist the target field node. + queryObjectFields.push(targetFieldNode); + return { ...nodeAndVarDefs, node: newNode }; +}; + +const rewriteResponseFn = (response: any, key: string, index?: number) => { + // If the key is the query name, then get into the response + // and retrieve the targetField, then delete it and place it in the + // desired position. + if (key === 'getTheThing') { + const targetField = response.targetField; + delete response.targetField; + Object.assign(response.theThing, { targetField }); + } + return response; +}; + +describe('Custom Rewriter, tests for specific rewriters.', () => { + it('Hoists a target Field Node', () => { + const handler = new RewriteHandler( + [ + new CustomRewriter({ + matchesFn, + rewriteQueryFn, + rewriteResponseFn + }) + ], + true + ); + + const query = gqlFmt` + query getTheThing { + theThing { + thingField + targetField + } + } + `; + const expectedRewritenQuery = gqlFmt` + query getTheThing { + theThing { + thingField + } + targetField + } + `; + expect(handler.rewriteRequest(query)).toEqual({ + query: expectedRewritenQuery + }); + + expect( + handler.rewriteResponse({ + theThing: { + thingField: 'thingFieldValue' + }, + targetField: 'targetValue' + }) + ).toEqual({ + theThing: { + thingField: 'thingFieldValue', + targetField: 'targetValue' + } + }); + }); +}); diff --git a/test/functional/rewriteField.test.ts b/test/functional/rewriteField.test.ts index 2454478..b2b28c6 100644 --- a/test/functional/rewriteField.test.ts +++ b/test/functional/rewriteField.test.ts @@ -2,7 +2,7 @@ import RewriteHandler from '../../src/RewriteHandler'; import FieldRewriter from '../../src/rewriters/FieldRewriter'; import { gqlFmt } from '../testUtils'; -describe('Rewrite scalar field to be a nested object with a single scalar field', () => { +describe('Generic Field rewriter', () => { it('rewrites a scalar field to be an object field with 1 scalar subfield', () => { const handler = new RewriteHandler([ new FieldRewriter({ @@ -120,6 +120,49 @@ describe('Rewrite scalar field to be a nested object with a single scalar field' }); }); + it('works with aliased fields', () => { + const handler = new RewriteHandler([ + new FieldRewriter({ + fieldName: 'subField', + newFieldName: 'renamedSubField', + objectFieldName: 'value' + }) + ]); + + const query = gqlFmt` + query getTheThing { + agg: anotheThing { + subField + } + } + `; + const expectedRewritenQuery = gqlFmt` + query getTheThing { + agg: anotheThing { + renamedSubField { + value + } + } + } + `; + expect(handler.rewriteRequest(query)).toEqual({ + query: expectedRewritenQuery + }); + expect( + handler.rewriteResponse({ + agg: { + renamedSubField: { + value: 'THING' + } + } + }) + ).toEqual({ + agg: { + subField: 'THING' + } + }); + }); + it('renames object field with an object as response value', () => { const handler = new RewriteHandler([ new FieldRewriter({ From 5a2b3d31caa2f5c26798e0c0e21b133f033acf40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20P=C3=A9rez=20Manr=C3=ADquez?= Date: Fri, 27 May 2022 13:17:11 -0400 Subject: [PATCH 09/21] matchAnyPath moved from rewrite handler to rewrite claass --- src/RewriteHandler.ts | 6 ++---- src/rewriters/Rewriter.ts | 5 ++++- test/functional/rewriteCustom.test.ts | 18 ++++++++---------- 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/src/RewriteHandler.ts b/src/RewriteHandler.ts index 750a93b..97252ba 100644 --- a/src/RewriteHandler.ts +++ b/src/RewriteHandler.ts @@ -19,11 +19,9 @@ export default class RewriteHandler { private rewriters: Rewriter[]; private hasProcessedRequest: boolean = false; private hasProcessedResponse: boolean = false; - private matchAnyPath: boolean = false; - constructor(rewriters: Rewriter[], matchAnyPath: boolean = false) { + constructor(rewriters: Rewriter[]) { this.rewriters = rewriters; - this.matchAnyPath = matchAnyPath; } /** @@ -79,7 +77,7 @@ export default class RewriteHandler { this.hasProcessedResponse = true; let rewrittenResponse = response; this.matches.reverse().forEach(({ rewriter, fieldPaths, allPaths }) => { - const paths = this.matchAnyPath ? allPaths : fieldPaths; + const paths = rewriter.matchAnyPath ? allPaths : fieldPaths; paths.forEach(path => { rewrittenResponse = rewriteResultsAtPath( rewrittenResponse, diff --git a/src/rewriters/Rewriter.ts b/src/rewriters/Rewriter.ts index f138596..3509c3e 100644 --- a/src/rewriters/Rewriter.ts +++ b/src/rewriters/Rewriter.ts @@ -9,6 +9,7 @@ export interface RewriterOpts { fieldName?: string; rootTypes?: RootType[]; matchConditions?: matchCondition[]; + matchAnyPath?: boolean; } /** @@ -16,13 +17,15 @@ export interface RewriterOpts { * Extend this class and overwrite its methods to create a new rewriter */ abstract class Rewriter { + public matchAnyPath: boolean = false; protected rootTypes: RootType[] = ['query', 'mutation', 'fragment']; protected fieldName?: string; protected matchConditions?: matchCondition[]; - constructor({ fieldName, rootTypes, matchConditions }: RewriterOpts) { + constructor({ fieldName, rootTypes, matchConditions, matchAnyPath = false }: RewriterOpts) { this.fieldName = fieldName; this.matchConditions = matchConditions; + this.matchAnyPath = matchAnyPath; if (!this.fieldName && !this.matchConditions) { throw new Error( 'Neither a fieldName or matchConditions were provided. Please choose to pass either one in order to be able to detect which fields to rewrite.' diff --git a/test/functional/rewriteCustom.test.ts b/test/functional/rewriteCustom.test.ts index 0098dd8..756907d 100644 --- a/test/functional/rewriteCustom.test.ts +++ b/test/functional/rewriteCustom.test.ts @@ -41,16 +41,14 @@ const rewriteResponseFn = (response: any, key: string, index?: number) => { describe('Custom Rewriter, tests for specific rewriters.', () => { it('Hoists a target Field Node', () => { - const handler = new RewriteHandler( - [ - new CustomRewriter({ - matchesFn, - rewriteQueryFn, - rewriteResponseFn - }) - ], - true - ); + const handler = new RewriteHandler([ + new CustomRewriter({ + matchesFn, + rewriteQueryFn, + rewriteResponseFn, + matchAnyPath: true + }) + ]); const query = gqlFmt` query getTheThing { From 5d1e8fc2d3f80cefe331d0db94b4ddfcb1b6ad49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20P=C3=A9rez=20Manr=C3=ADquez?= Date: Fri, 27 May 2022 18:02:47 -0400 Subject: [PATCH 10/21] adds support to rename fields with alises --- src/rewriters/FieldRewriter.ts | 28 +++++++++++++----- test/functional/rewriteField.test.ts | 43 ++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/src/rewriters/FieldRewriter.ts b/src/rewriters/FieldRewriter.ts index fa44bd4..57bbae4 100644 --- a/src/rewriters/FieldRewriter.ts +++ b/src/rewriters/FieldRewriter.ts @@ -1,4 +1,4 @@ -import { ArgumentNode, ASTNode, FieldNode, SelectionSetNode } from 'graphql'; +import { ArgumentNode, ASTNode, FieldNode, Kind, SelectionSetNode } from 'graphql'; import { astArgVarNode, NodeAndVarDefs } from '../ast'; import Rewriter, { RewriterOpts, Variables } from './Rewriter'; @@ -41,7 +41,13 @@ class FieldRewriter extends Rewriter { // if fieldName is meant to be renamed. if (this.newFieldName) { - Object.assign(node.name, { value: this.newFieldName }); + let newName = this.newFieldName; + if (this.newFieldName.includes(':')) { + const [alias, name] = this.newFieldName.split(':'); + newName = name.trim(); + Object.assign(node, { alias: { value: alias.trim(), kind: Kind.NAME } }); + } + Object.assign(node.name, { value: newName }); } // if there's the intention of converting the field to a subselection @@ -91,11 +97,19 @@ class FieldRewriter extends Rewriter { // if the key is found to be the renamed field // then change the name of such field in the response // and pass the new key (field name) down. - if (key === this.newFieldName) { - if (this.fieldName) { - originalKey = this.fieldName; - Object.assign(response, { [originalKey]: response[key] }); - delete response[key]; + if (this.newFieldName) { + let newFieldName = this.newFieldName; + // the newFieldName may be alised. + if (this.newFieldName.includes(':')) { + const [alias] = this.newFieldName.split(':'); + newFieldName = alias.trim(); + } + if (key === newFieldName) { + if (this.fieldName) { + originalKey = this.fieldName; + Object.assign(response, { [originalKey]: response[key] }); + delete response[key]; + } } } // Undo the nesting in the response so it matches the original query diff --git a/test/functional/rewriteField.test.ts b/test/functional/rewriteField.test.ts index b2b28c6..2856c50 100644 --- a/test/functional/rewriteField.test.ts +++ b/test/functional/rewriteField.test.ts @@ -163,6 +163,49 @@ describe('Generic Field rewriter', () => { }); }); + it('works using alias for new field names', () => { + const handler = new RewriteHandler([ + new FieldRewriter({ + fieldName: 'subField', + newFieldName: 'aliasedField: renamedSubField', + objectFieldName: 'value' + }) + ]); + + const query = gqlFmt` + query getTheThing { + agg: anotheThing { + subField + } + } + `; + const expectedRewritenQuery = gqlFmt` + query getTheThing { + agg: anotheThing { + aliasedField: renamedSubField { + value + } + } + } + `; + expect(handler.rewriteRequest(query)).toEqual({ + query: expectedRewritenQuery + }); + expect( + handler.rewriteResponse({ + agg: { + aliasedField: { + value: 'THING' + } + } + }) + ).toEqual({ + agg: { + subField: 'THING' + } + }); + }); + it('renames object field with an object as response value', () => { const handler = new RewriteHandler([ new FieldRewriter({ From 843d05746b004f6af26455efc98714a093462c40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20P=C3=A9rez=20Manr=C3=ADquez?= Date: Fri, 27 May 2022 18:44:33 -0400 Subject: [PATCH 11/21] Adds matching node to the rewriteResponse fn --- src/RewriteHandler.ts | 13 +++++++++---- src/rewriters/CustomRewriter.ts | 23 +++++++++++++++++++---- src/rewriters/Rewriter.ts | 18 ++++++++++++++++-- test/functional/rewriteCustom.test.ts | 26 +++++++++++++++++++------- 4 files changed, 63 insertions(+), 17 deletions(-) diff --git a/src/RewriteHandler.ts b/src/RewriteHandler.ts index 97252ba..f3327b1 100644 --- a/src/RewriteHandler.ts +++ b/src/RewriteHandler.ts @@ -1,4 +1,4 @@ -import { FragmentDefinitionNode, parse, print } from 'graphql'; +import { ASTNode, FragmentDefinitionNode, parse, print } from 'graphql'; import { extractPath, FragmentTracer, rewriteDoc, rewriteResultsAtPath } from './ast'; import Rewriter, { Variables } from './rewriters/Rewriter'; @@ -7,6 +7,7 @@ interface RewriterMatch { fieldPaths: ReadonlyArray>; // TODO: allPaths hasnt been tested for fragments allPaths: ReadonlyArray>; + nodeMatchAndParents?: ASTNode[]; } /** @@ -56,7 +57,10 @@ export default class RewriteHandler { this.matches.push({ rewriter, fieldPaths, - allPaths + allPaths, + ...(rewriter.saveNode + ? { nodeMatchAndParents: [...parents, rewrittenNodeAndVars.node] } + : {}) }); } return isMatch; @@ -76,13 +80,14 @@ export default class RewriteHandler { if (this.hasProcessedResponse) throw new Error('This handler has already returned a response'); this.hasProcessedResponse = true; let rewrittenResponse = response; - this.matches.reverse().forEach(({ rewriter, fieldPaths, allPaths }) => { + this.matches.reverse().forEach(({ rewriter, fieldPaths, allPaths, nodeMatchAndParents }) => { const paths = rewriter.matchAnyPath ? allPaths : fieldPaths; paths.forEach(path => { rewrittenResponse = rewriteResultsAtPath( rewrittenResponse, path, - (parentResponse, key, index) => rewriter.rewriteResponse(parentResponse, key, index) + (parentResponse, key, index) => + rewriter.rewriteResponse(parentResponse, key, index, nodeMatchAndParents) ); }); }); diff --git a/src/rewriters/CustomRewriter.ts b/src/rewriters/CustomRewriter.ts index 006e7e9..ee6100b 100644 --- a/src/rewriters/CustomRewriter.ts +++ b/src/rewriters/CustomRewriter.ts @@ -5,7 +5,12 @@ import Rewriter, { RewriterOpts, Variables } from './Rewriter'; interface CustomRewriterOpts extends RewriterOpts { matchesFn?: (nodeAndVarDefs: NodeAndVarDefs, parents: ReadonlyArray) => boolean; rewriteQueryFn?: (nodeAndVarDefs: NodeAndVarDefs, variables: Variables) => NodeAndVarDefs; - rewriteResponseFn?: (response: any, key: string, index?: number) => NodeAndVarDefs; + rewriteResponseFn?: ( + response: any, + key: string, + index?: number, + nodeMatchAndParents?: ASTNode[] + ) => NodeAndVarDefs; } /** @@ -18,7 +23,12 @@ class CustomRewriter extends Rewriter { nodeAndVarDefs: NodeAndVarDefs, variables: Variables ) => NodeAndVarDefs; - protected rewriteResponseFn: (response: any, key: string, index?: number) => NodeAndVarDefs; + protected rewriteResponseFn: ( + response: any, + key: string, + index?: number, + nodeMatchAndParents?: ASTNode[] + ) => NodeAndVarDefs; constructor(options: CustomRewriterOpts) { const { matchesFn, rewriteQueryFn, rewriteResponseFn, ...rewriterOpts } = options; @@ -36,8 +46,13 @@ class CustomRewriter extends Rewriter { return this.rewriteQueryFn(nodeAndVarDefs, variables); } - public rewriteResponse(response: any, key: string, index?: number) { - return this.rewriteResponseFn(response, key, index); + public rewriteResponse( + response: any, + key: string, + index?: number, + nodeMatchAndParents?: ASTNode[] + ) { + return this.rewriteResponseFn(response, key, index, nodeMatchAndParents); } } diff --git a/src/rewriters/Rewriter.ts b/src/rewriters/Rewriter.ts index 3509c3e..4287440 100644 --- a/src/rewriters/Rewriter.ts +++ b/src/rewriters/Rewriter.ts @@ -10,6 +10,7 @@ export interface RewriterOpts { rootTypes?: RootType[]; matchConditions?: matchCondition[]; matchAnyPath?: boolean; + saveNode?: boolean; } /** @@ -18,14 +19,22 @@ export interface RewriterOpts { */ abstract class Rewriter { public matchAnyPath: boolean = false; + public saveNode: boolean = false; protected rootTypes: RootType[] = ['query', 'mutation', 'fragment']; protected fieldName?: string; protected matchConditions?: matchCondition[]; - constructor({ fieldName, rootTypes, matchConditions, matchAnyPath = false }: RewriterOpts) { + constructor({ + fieldName, + rootTypes, + matchConditions, + matchAnyPath = false, + saveNode = false + }: RewriterOpts) { this.fieldName = fieldName; this.matchConditions = matchConditions; this.matchAnyPath = matchAnyPath; + this.saveNode = saveNode; if (!this.fieldName && !this.matchConditions) { throw new Error( 'Neither a fieldName or matchConditions were provided. Please choose to pass either one in order to be able to detect which fields to rewrite.' @@ -77,7 +86,12 @@ abstract class Rewriter { * Receives the parent object of the matched field with the key of the matched field. * For arrays, the index of the element is also present. */ - public rewriteResponse(response: any, key: string, index?: number): any { + public rewriteResponse( + response: any, + key: string, + index?: number, + nodeMatchAndParents?: ASTNode[] + ): any { return response; } diff --git a/test/functional/rewriteCustom.test.ts b/test/functional/rewriteCustom.test.ts index 756907d..afa413f 100644 --- a/test/functional/rewriteCustom.test.ts +++ b/test/functional/rewriteCustom.test.ts @@ -1,4 +1,4 @@ -import { ASTNode, Kind } from 'graphql'; +import { ASTNode, Kind, NameNode, OperationDefinitionNode } from 'graphql'; import { NodeAndVarDefs } from '../../src/ast'; import RewriteHandler from '../../src/RewriteHandler'; import CustomRewriter from '../../src/rewriters/CustomRewriter'; @@ -27,14 +27,25 @@ const rewriteQueryFn = (nodeAndVarDefs: any) => { return { ...nodeAndVarDefs, node: newNode }; }; -const rewriteResponseFn = (response: any, key: string, index?: number) => { +const rewriteResponseFn = ( + response: any, + key: string, + index?: number, + nodeMatchAndParents?: ASTNode[] +) => { // If the key is the query name, then get into the response // and retrieve the targetField, then delete it and place it in the // desired position. - if (key === 'getTheThing') { - const targetField = response.targetField; - delete response.targetField; - Object.assign(response.theThing, { targetField }); + if (nodeMatchAndParents) { + const parentNode = nodeMatchAndParents.slice(-2)[0] as OperationDefinitionNode; + if (parentNode && parentNode.name) { + const queryName = parentNode.name.value; + if (key === queryName) { + const targetField = response.targetField; + delete response.targetField; + Object.assign(response.theThing, { targetField }); + } + } } return response; }; @@ -46,7 +57,8 @@ describe('Custom Rewriter, tests for specific rewriters.', () => { matchesFn, rewriteQueryFn, rewriteResponseFn, - matchAnyPath: true + matchAnyPath: true, + saveNode: true }) ]); From 54e7491fe5b5a62d0cd1b23925eb6ce75d4496e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20P=C3=A9rez=20Manr=C3=ADquez?= Date: Mon, 30 May 2022 14:50:24 -0400 Subject: [PATCH 12/21] Fixes issue with CustomRewriter default matchConditions array --- src/rewriters/CustomRewriter.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/rewriters/CustomRewriter.ts b/src/rewriters/CustomRewriter.ts index ee6100b..76573c4 100644 --- a/src/rewriters/CustomRewriter.ts +++ b/src/rewriters/CustomRewriter.ts @@ -31,8 +31,14 @@ class CustomRewriter extends Rewriter { ) => NodeAndVarDefs; constructor(options: CustomRewriterOpts) { - const { matchesFn, rewriteQueryFn, rewriteResponseFn, ...rewriterOpts } = options; - super({ ...rewriterOpts, matchConditions: [() => true] }); + const { + matchesFn, + rewriteQueryFn, + rewriteResponseFn, + matchConditions = [() => true], + ...rewriterOpts + } = options; + super({ ...rewriterOpts, matchConditions }); this.matchesFn = matchesFn || super.matches; this.rewriteQueryFn = rewriteQueryFn || super.rewriteQuery; this.rewriteResponseFn = rewriteResponseFn || super.rewriteResponse; From a267589bf2801650d2ced39488526700fe94760d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20P=C3=A9rez=20Manr=C3=ADquez?= Date: Tue, 31 May 2022 16:19:53 -0400 Subject: [PATCH 13/21] Adds rewriteVariables to CustomRewriter --- src/rewriters/CustomRewriter.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/rewriters/CustomRewriter.ts b/src/rewriters/CustomRewriter.ts index 76573c4..fba78aa 100644 --- a/src/rewriters/CustomRewriter.ts +++ b/src/rewriters/CustomRewriter.ts @@ -5,6 +5,7 @@ import Rewriter, { RewriterOpts, Variables } from './Rewriter'; interface CustomRewriterOpts extends RewriterOpts { matchesFn?: (nodeAndVarDefs: NodeAndVarDefs, parents: ReadonlyArray) => boolean; rewriteQueryFn?: (nodeAndVarDefs: NodeAndVarDefs, variables: Variables) => NodeAndVarDefs; + rewriteVariablesFn?: (nodeAndVarDefs: NodeAndVarDefs, variables: Variables) => Variables; rewriteResponseFn?: ( response: any, key: string, @@ -23,6 +24,7 @@ class CustomRewriter extends Rewriter { nodeAndVarDefs: NodeAndVarDefs, variables: Variables ) => NodeAndVarDefs; + protected rewriteVariablesFn: (nodeAndVarDefs: NodeAndVarDefs, variables: Variables) => Variables; protected rewriteResponseFn: ( response: any, key: string, @@ -34,6 +36,7 @@ class CustomRewriter extends Rewriter { const { matchesFn, rewriteQueryFn, + rewriteVariablesFn, rewriteResponseFn, matchConditions = [() => true], ...rewriterOpts @@ -41,6 +44,7 @@ class CustomRewriter extends Rewriter { super({ ...rewriterOpts, matchConditions }); this.matchesFn = matchesFn || super.matches; this.rewriteQueryFn = rewriteQueryFn || super.rewriteQuery; + this.rewriteVariablesFn = rewriteVariablesFn || super.rewriteVariables; this.rewriteResponseFn = rewriteResponseFn || super.rewriteResponse; } @@ -60,6 +64,10 @@ class CustomRewriter extends Rewriter { ) { return this.rewriteResponseFn(response, key, index, nodeMatchAndParents); } + + public rewriteVariables(nodeAndVarDefs: NodeAndVarDefs, variables: Variables): Variables { + return this.rewriteVariablesFn(nodeAndVarDefs, variables); + } } export default CustomRewriter; From b7073f0572cc732cf09ef26f90ac01de251f9e88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20P=C3=A9rez=20Manr=C3=ADquez?= Date: Mon, 6 Jun 2022 10:55:21 -0400 Subject: [PATCH 14/21] fixes reviewed code --- src/RewriteHandler.ts | 26 ++++++++++++++------------ src/ast.ts | 8 +------- 2 files changed, 15 insertions(+), 19 deletions(-) diff --git a/src/RewriteHandler.ts b/src/RewriteHandler.ts index f3327b1..05d8c36 100644 --- a/src/RewriteHandler.ts +++ b/src/RewriteHandler.ts @@ -4,7 +4,7 @@ import Rewriter, { Variables } from './rewriters/Rewriter'; interface RewriterMatch { rewriter: Rewriter; - fieldPaths: ReadonlyArray>; + paths: ReadonlyArray>; // TODO: allPaths hasnt been tested for fragments allPaths: ReadonlyArray>; nodeMatchAndParents?: ASTNode[]; @@ -56,8 +56,8 @@ export default class RewriteHandler { } this.matches.push({ rewriter, - fieldPaths, allPaths, + paths: fieldPaths, ...(rewriter.saveNode ? { nodeMatchAndParents: [...parents, rewrittenNodeAndVars.node] } : {}) @@ -80,17 +80,19 @@ export default class RewriteHandler { if (this.hasProcessedResponse) throw new Error('This handler has already returned a response'); this.hasProcessedResponse = true; let rewrittenResponse = response; - this.matches.reverse().forEach(({ rewriter, fieldPaths, allPaths, nodeMatchAndParents }) => { - const paths = rewriter.matchAnyPath ? allPaths : fieldPaths; - paths.forEach(path => { - rewrittenResponse = rewriteResultsAtPath( - rewrittenResponse, - path, - (parentResponse, key, index) => - rewriter.rewriteResponse(parentResponse, key, index, nodeMatchAndParents) - ); + this.matches + .reverse() + .forEach(({ rewriter, paths: fieldPaths, allPaths, nodeMatchAndParents }) => { + const paths = rewriter.matchAnyPath ? allPaths : fieldPaths; + paths.forEach(path => { + rewrittenResponse = rewriteResultsAtPath( + rewrittenResponse, + path, + (parentResponse, key, index) => + rewriter.rewriteResponse(parentResponse, key, index, nodeMatchAndParents) + ); + }); }); - }); return rewrittenResponse; } } diff --git a/src/ast.ts b/src/ast.ts index c035686..9f05f75 100644 --- a/src/ast.ts +++ b/src/ast.ts @@ -259,18 +259,12 @@ export const extractPath = ( ): ReadonlyArray => { const path: string[] = []; parents.forEach((parent: any) => { - if (anyKind) { + if (parent.kind === 'Field' || anyKind) { if (parent.alias) { path.push(parent.alias.value); } else if (parent.name) { path.push(parent.name.value); } - } else if (parent.kind === 'Field') { - if (parent.alias) { - path.push(parent.alias.value); - } else { - path.push(parent.name.value); - } } }); return path; From d0218759354eeefa3b150aca7359fb9ccbdbc481 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20P=C3=A9rez=20Manr=C3=ADquez?= Date: Tue, 7 Jun 2022 10:48:23 -0400 Subject: [PATCH 15/21] variable trim and rename --- src/RewriteHandler.ts | 6 ++---- src/rewriters/Rewriter.ts | 12 ++++-------- test/functional/rewriteCustom.test.ts | 3 +-- 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/src/RewriteHandler.ts b/src/RewriteHandler.ts index 05d8c36..d716db1 100644 --- a/src/RewriteHandler.ts +++ b/src/RewriteHandler.ts @@ -58,9 +58,7 @@ export default class RewriteHandler { rewriter, allPaths, paths: fieldPaths, - ...(rewriter.saveNode - ? { nodeMatchAndParents: [...parents, rewrittenNodeAndVars.node] } - : {}) + nodeMatchAndParents: [...parents, rewrittenNodeAndVars.node] }); } return isMatch; @@ -83,7 +81,7 @@ export default class RewriteHandler { this.matches .reverse() .forEach(({ rewriter, paths: fieldPaths, allPaths, nodeMatchAndParents }) => { - const paths = rewriter.matchAnyPath ? allPaths : fieldPaths; + const paths = rewriter.includeNonFieldPathsInMatch ? allPaths : fieldPaths; paths.forEach(path => { rewrittenResponse = rewriteResultsAtPath( rewrittenResponse, diff --git a/src/rewriters/Rewriter.ts b/src/rewriters/Rewriter.ts index 4287440..d05cb82 100644 --- a/src/rewriters/Rewriter.ts +++ b/src/rewriters/Rewriter.ts @@ -9,8 +9,7 @@ export interface RewriterOpts { fieldName?: string; rootTypes?: RootType[]; matchConditions?: matchCondition[]; - matchAnyPath?: boolean; - saveNode?: boolean; + includeNonFieldPathsInMatch?: boolean; } /** @@ -18,8 +17,7 @@ export interface RewriterOpts { * Extend this class and overwrite its methods to create a new rewriter */ abstract class Rewriter { - public matchAnyPath: boolean = false; - public saveNode: boolean = false; + public includeNonFieldPathsInMatch: boolean = false; protected rootTypes: RootType[] = ['query', 'mutation', 'fragment']; protected fieldName?: string; protected matchConditions?: matchCondition[]; @@ -28,13 +26,11 @@ abstract class Rewriter { fieldName, rootTypes, matchConditions, - matchAnyPath = false, - saveNode = false + includeNonFieldPathsInMatch = false }: RewriterOpts) { this.fieldName = fieldName; this.matchConditions = matchConditions; - this.matchAnyPath = matchAnyPath; - this.saveNode = saveNode; + this.includeNonFieldPathsInMatch = includeNonFieldPathsInMatch; if (!this.fieldName && !this.matchConditions) { throw new Error( 'Neither a fieldName or matchConditions were provided. Please choose to pass either one in order to be able to detect which fields to rewrite.' diff --git a/test/functional/rewriteCustom.test.ts b/test/functional/rewriteCustom.test.ts index afa413f..189873e 100644 --- a/test/functional/rewriteCustom.test.ts +++ b/test/functional/rewriteCustom.test.ts @@ -57,8 +57,7 @@ describe('Custom Rewriter, tests for specific rewriters.', () => { matchesFn, rewriteQueryFn, rewriteResponseFn, - matchAnyPath: true, - saveNode: true + includeNonFieldPathsInMatch: true }) ]); From 0b679676fe044d312ec6eede3d1fbfe376490ee5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20P=C3=A9rez=20Manr=C3=ADquez?= Date: Sun, 19 Jun 2022 11:53:34 -0400 Subject: [PATCH 16/21] adds fix for when rewritten field results are empty arrays --- src/ast.ts | 6 +++ src/rewriters/FieldRewriter.ts | 3 ++ src/rewriters/Rewriter.ts | 6 ++- test/functional/rewriteField.test.ts | 59 ++++++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 1 deletion(-) diff --git a/src/ast.ts b/src/ast.ts index 9f05f75..89b4686 100644 --- a/src/ast.ts +++ b/src/ast.ts @@ -308,6 +308,12 @@ export const rewriteResultsAtPath = ( const newResults = { ...results }; const curResults = results[curPathElm]; + // if results[curPathElm] is an empty array, call the callback response rewriter + // because there's nothing left to do. + if (Array.isArray(curResults) && curResults.length === 0) { + callback(results, curPathElm); + } + if (path.length === 1) { if (Array.isArray(curResults)) { return curResults.reduce( diff --git a/src/rewriters/FieldRewriter.ts b/src/rewriters/FieldRewriter.ts index 57bbae4..b2cd9f4 100644 --- a/src/rewriters/FieldRewriter.ts +++ b/src/rewriters/FieldRewriter.ts @@ -112,6 +112,9 @@ class FieldRewriter extends Rewriter { } } } + // If the element is an empty array, return the response, since + // there's nothing left to rewrite down that path. + if (Array.isArray(element) && element.length === 0) return response; // Undo the nesting in the response so it matches the original query let newElement = element; if (this.objectFieldName) { diff --git a/src/rewriters/Rewriter.ts b/src/rewriters/Rewriter.ts index d05cb82..b38c255 100644 --- a/src/rewriters/Rewriter.ts +++ b/src/rewriters/Rewriter.ts @@ -104,7 +104,11 @@ abstract class Rewriter { // Extract the position if (Array.isArray(element)) { - element = element[index!] || null; + // if element is an empty array do not try to get + // one of its array elements + if (element.length !== 0) { + element = element[index!] || null; + } } return element; diff --git a/test/functional/rewriteField.test.ts b/test/functional/rewriteField.test.ts index 2856c50..2cab655 100644 --- a/test/functional/rewriteField.test.ts +++ b/test/functional/rewriteField.test.ts @@ -456,6 +456,65 @@ describe('Generic Field rewriter', () => { }); }); + it('renames an empty array field ', () => { + 1; + const handler = new RewriteHandler([ + new FieldRewriter({ + fieldName: 'subField', + newFieldName: 'renamedSubField' + }) + ]); + + const query = gqlFmt` + query getTheThing { + theThing { + thingField { + id + subField { + value + } + color + } + } + } + `; + const expectedRewritenQuery = gqlFmt` + query getTheThing { + theThing { + thingField { + id + renamedSubField { + value + } + color + } + } + } + `; + expect(handler.rewriteRequest(query)).toEqual({ + query: expectedRewritenQuery + }); + expect( + handler.rewriteResponse({ + theThing: { + thingField: { + id: 1, + renamedSubField: [], + color: 'blue' + } + } + }) + ).toEqual({ + theThing: { + thingField: { + id: 1, + subField: [], + color: 'blue' + } + } + }); + }); + it('rewrites a scalar field to be a renamed object field with 1 scalar subfield', () => { const handler = new RewriteHandler([ new FieldRewriter({ From 7873cfada5a8bad9a6967246fce12aff75a5fd6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20P=C3=A9rez=20Manr=C3=ADquez?= Date: Mon, 20 Jun 2022 13:27:19 -0400 Subject: [PATCH 17/21] v0.0.16 --- package.json | 4 +- src/RewriteHandler.ts | 10 ++++- src/ast.ts | 26 +++++++++--- test/functional/rewriteField.test.ts | 61 ++++++++++++++++++++++++++++ 4 files changed, 92 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index d2dde01..9569503 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "graphql-query-rewriter", - "version": "0.0.1", + "name": "graphql-query-rewriter-rc", + "version": "0.0.16", "description": "", "keywords": [], "main": "dist/index.umd.js", diff --git a/src/RewriteHandler.ts b/src/RewriteHandler.ts index d716db1..44df1d2 100644 --- a/src/RewriteHandler.ts +++ b/src/RewriteHandler.ts @@ -5,7 +5,12 @@ import Rewriter, { Variables } from './rewriters/Rewriter'; interface RewriterMatch { rewriter: Rewriter; paths: ReadonlyArray>; - // TODO: allPaths hasnt been tested for fragments + // TODO: + // - allPaths hasnt been tested for fragments + // - Give that allPaths includes non-field paths, there might be paths + // that don't match to a key in the results object traversed in + // 'rewriteResultsAtPath'. For now the 'includesNonFieldPaths' flag is passed to + // this function. allPaths: ReadonlyArray>; nodeMatchAndParents?: ASTNode[]; } @@ -87,7 +92,8 @@ export default class RewriteHandler { rewrittenResponse, path, (parentResponse, key, index) => - rewriter.rewriteResponse(parentResponse, key, index, nodeMatchAndParents) + rewriter.rewriteResponse(parentResponse, key, index, nodeMatchAndParents), + rewriter.includeNonFieldPathsInMatch ); }); }); diff --git a/src/ast.ts b/src/ast.ts index 89b4686..d87a46c 100644 --- a/src/ast.ts +++ b/src/ast.ts @@ -300,7 +300,8 @@ interface ResultObj { export const rewriteResultsAtPath = ( results: ResultObj, path: ReadonlyArray, - callback: (parentResult: any, key: string, position?: number) => any + callback: (parentResult: any, key: string, position?: number) => any, + includesNonFieldPaths?: boolean ): ResultObj => { if (path.length === 0) return results; @@ -326,15 +327,30 @@ export const rewriteResultsAtPath = ( } const remainingPath = path.slice(1); - // if the path stops here, just return results without any rewriting - if (curResults === undefined || curResults === null) return results; + if (curResults === undefined || curResults === null) { + // If curResults is undefined or null, and includesNonFieldPaths, + // its likely that curResults is undefined because curPathElm might not be a field path. + if (remainingPath.length && includesNonFieldPaths) { + // Call the callback in case the non-field paths is expected of a rewrite + callback(results, curPathElm); + // Then just continue with the next path + return rewriteResultsAtPath(results, remainingPath, callback, includesNonFieldPaths); + } + // else, if the path stops here, just return results without any rewriting + return results; + } if (Array.isArray(curResults)) { newResults[curPathElm] = curResults.map(result => - rewriteResultsAtPath(result, remainingPath, callback) + rewriteResultsAtPath(result, remainingPath, callback, includesNonFieldPaths) ); } else { - newResults[curPathElm] = rewriteResultsAtPath(curResults, remainingPath, callback); + newResults[curPathElm] = rewriteResultsAtPath( + curResults, + remainingPath, + callback, + includesNonFieldPaths + ); } return newResults; diff --git a/test/functional/rewriteField.test.ts b/test/functional/rewriteField.test.ts index 2cab655..262a93f 100644 --- a/test/functional/rewriteField.test.ts +++ b/test/functional/rewriteField.test.ts @@ -783,4 +783,65 @@ describe('Generic Field rewriter', () => { } }); }); + + it('can traverse full response object when includeNonFieldPathsInMatch is set', () => { + const handler = new RewriteHandler([ + new FieldRewriter({ + fieldName: 'subField', + newFieldName: 'renamedSubField', + includeNonFieldPathsInMatch: true + }) + ]); + + const query = gqlFmt` + query getTheThing { + theThing { + thingField { + id + subField { + value + } + color + } + } + } + `; + const expectedRewritenQuery = gqlFmt` + query getTheThing { + theThing { + thingField { + id + renamedSubField { + value + } + color + } + } + } + `; + expect(handler.rewriteRequest(query)).toEqual({ + query: expectedRewritenQuery + }); + expect( + handler.rewriteResponse({ + theThing: { + thingField: { + id: 1, + renamedSubField: { + value: 'THING' + }, + color: 'blue' + } + } + }) + ).toEqual({ + theThing: { + thingField: { + id: 1, + subField: { value: 'THING' }, + color: 'blue' + } + } + }); + }); }); From ccbb642e9c4452cd513dbb7ed4e8c3e876c7dadd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20P=C3=A9rez=20Manr=C3=ADquez?= Date: Tue, 28 Jun 2022 17:35:25 -0400 Subject: [PATCH 18/21] fixes "rewriteResultsAtPath" for non-field paths --- src/ast.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/ast.ts b/src/ast.ts index d87a46c..7821c73 100644 --- a/src/ast.ts +++ b/src/ast.ts @@ -327,19 +327,19 @@ export const rewriteResultsAtPath = ( } const remainingPath = path.slice(1); - if (curResults === undefined || curResults === null) { - // If curResults is undefined or null, and includesNonFieldPaths, - // its likely that curResults is undefined because curPathElm might not be a field path. - if (remainingPath.length && includesNonFieldPaths) { - // Call the callback in case the non-field paths is expected of a rewrite - callback(results, curPathElm); - // Then just continue with the next path - return rewriteResultsAtPath(results, remainingPath, callback, includesNonFieldPaths); - } - // else, if the path stops here, just return results without any rewriting - return results; + + // If curResults is undefined, and includesNonFieldPaths is true, + // then curResults is not a field path, so call the callback to allow rewrites + // for non-field paths. + if (remainingPath.length && includesNonFieldPaths && curResults === undefined) { + callback(results, curPathElm); + // Then just continue with the next path + return rewriteResultsAtPath(results, remainingPath, callback, includesNonFieldPaths); } + // if the path stops here, just return results without any rewriting + if (curResults === undefined || curResults === null) return results; + if (Array.isArray(curResults)) { newResults[curPathElm] = curResults.map(result => rewriteResultsAtPath(result, remainingPath, callback, includesNonFieldPaths) From a75252386f12b186654f3b432ad92d90f29907d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20P=C3=A9rez=20Manr=C3=ADquez?= Date: Tue, 28 Jun 2022 17:36:40 -0400 Subject: [PATCH 19/21] v0.0.17 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9569503..4a65795 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "graphql-query-rewriter-rc", - "version": "0.0.16", + "version": "0.0.17", "description": "", "keywords": [], "main": "dist/index.umd.js", From 0d313a556e862f58c1c6bfdbc6b1a0d98a3a2532 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20P=C3=A9rez=20Manr=C3=ADquez?= Date: Tue, 28 Jun 2022 19:17:37 -0400 Subject: [PATCH 20/21] v0.0.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4a65795..ef96c32 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "graphql-query-rewriter-rc", - "version": "0.0.17", + "version": "0.0.1", "description": "", "keywords": [], "main": "dist/index.umd.js", From 8ce15adc7c5367955ed68a7ed8dc865b428e7421 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20P=C3=A9rez=20Manr=C3=ADquez?= Date: Tue, 28 Jun 2022 19:18:41 -0400 Subject: [PATCH 21/21] fixes unintended change to package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ef96c32..d2dde01 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "graphql-query-rewriter-rc", + "name": "graphql-query-rewriter", "version": "0.0.1", "description": "", "keywords": [],