UNPKG

@tmeasday/aer-limited

Version:

Do not use if you don't know what this does.

243 lines (231 loc) 9.6 kB
// XXX maybe this should just be its own graphql-signature package // In Engine, we want to group requests making the same query together, and // treat different queries distinctly. But what does it mean for two queries to // be "the same"? And what if you don't want to send the full text of the query // to Apollo Engine's servers, either because it contains sensitive data or // because it contains extraneous operations or fragments? // // To solve these problems, EngineReportingAgent has the concept of // "signatures". We don't (by default) send the full query string of queries to // the Engine servers. Instead, each trace has its query string's "signature". // // You can specify any function mapping a GraphQL query AST (DocumentNode) to // string as your signature algorithm by providing it as the 'signature' option // to the EngineReportingAgent constructor. Ideally, your signature should be a // valid GraphQL query, though as of now the Engine servers do not re-parse your // signature and do not expect it to match the execution tree in the trace. // // This file provides several useful building blocks for writing your own // signature function. These are: // // - dropUnusedDefinitions, which removes operations and fragments that // aren't going to be used in execution // - hideLiterals, which replaces all numeric and string literals as well // as list and object input values with "empty" values // - removeAliases, which removes field aliasing from the query // - sortAST, which sorts the children of most multi-child nodes // consistently // - printWithReducedWhitespace, a variant on graphql-js's 'print' // which gets rid of unneeded whitespace // // defaultSignature consists of applying all of these building blocks. // // Historical note: the default signature algorithm of the Go engineproxy // performed all of the above operations, and the Engine servers then re-ran a // mostly identical signature implementation on received traces. This was // primarily to deal with edge cases where some users used literal interpolation // instead of GraphQL variables, included randomized alias names, etc. In // addition, the servers relied on the fact that dropUnusedDefinitions had been // called in order (and that the signature could be parsed as GraphQL) to // extract the name of the operation for display. This caused confusion, as the // query document shown in the Engine UI wasn't the same as the one actually // sent. apollo-engine-reporting uses a new reporting API which requires it to // explicitly include the operation name with each signature; this means that // the server no longer needs to parse the signature or run its own signature // algorithm on it, and the details of the signature algorithm are now up to the // reporting agent. import { sortBy, ListIteratee } from 'lodash'; import { print, visit, DocumentNode, OperationDefinitionNode, SelectionSetNode, FieldNode, FragmentSpreadNode, InlineFragmentNode, FragmentDefinitionNode, DirectiveNode, IntValueNode, FloatValueNode, StringValueNode, ListValueNode, ObjectValueNode, separateOperations, } from 'graphql'; // Replace numeric, string, list, and object literals with "empty" // values. Leaves enums alone (since there's no consistent "zero" enum). This // can help combine similar queries if you substitute values directly into // queries rather than use GraphQL variables, and can hide sensitive data in // your query (say, a hardcoded API key) from Engine servers, but in general // avoiding those situations is better than working around them. export function hideLiterals(ast: DocumentNode): DocumentNode { return visit(ast, { IntValue(node: IntValueNode): IntValueNode { return { ...node, value: '0' }; }, FloatValue(node: FloatValueNode): FloatValueNode { return { ...node, value: '0' }; }, StringValue(node: StringValueNode): StringValueNode { return { ...node, value: '', block: false }; }, ListValue(node: ListValueNode): ListValueNode { return { ...node, values: [] }; }, ObjectValue(node: ObjectValueNode): ObjectValueNode { return { ...node, fields: [] }; }, }); } // A GraphQL query may contain multiple named operations, with the operation to // use specified separately by the client. This transformation drops unused // operations from the query, as well as any fragment definitions that are not // referenced. (In general we recommend that unused definitions are dropped on // the client before sending to the server to save bandwidth and parsing time.) export function dropUnusedDefinitions( ast: DocumentNode, operationName: string, ): DocumentNode { const separated = separateOperations(ast)[operationName]; if (!separated) { // If the given operationName isn't found, just make this whole transform a // no-op instead of crashing. return ast; } return separated; } // Like lodash's sortBy, but sorted(undefined) === undefined rather than []. It // is a stable non-in-place sort. function sorted<T>( items: ReadonlyArray<T> | undefined, ...iteratees: Array<ListIteratee<T>> ): Array<T> | undefined { if (items) { return sortBy(items, ...iteratees); } return undefined; } // sortAST sorts most multi-child nodes alphabetically. Using this as part of // your signature calculation function may make it easier to tell the difference // between queries that are similar to each other, and if for some reason your // GraphQL client generates query strings with elements in nondeterministic // order, it can make sure the queries are treated as identical. export function sortAST(ast: DocumentNode): DocumentNode { return visit(ast, { OperationDefinition( node: OperationDefinitionNode, ): OperationDefinitionNode { return { ...node, variableDefinitions: sorted( node.variableDefinitions, 'variable.name.value', ), }; }, SelectionSet(node: SelectionSetNode): SelectionSetNode { return { ...node, // Define an ordering for field names in a SelectionSet. Field first, // then FragmentSpread, then InlineFragment. By a lovely coincidence, // the order we want them to appear in is alphabetical by node.kind. // Use sortBy instead of sorted because 'selections' is not optional. selections: sortBy(node.selections, 'kind', 'name.value'), }; }, Field(node: FieldNode): FieldNode { return { ...node, arguments: sorted(node.arguments, 'name.value'), }; }, FragmentSpread(node: FragmentSpreadNode): FragmentSpreadNode { return { ...node, directives: sorted(node.directives, 'name.value') }; }, InlineFragment(node: InlineFragmentNode): InlineFragmentNode { return { ...node, directives: sorted(node.directives, 'name.value') }; }, FragmentDefinition(node: FragmentDefinitionNode): FragmentDefinitionNode { return { ...node, directives: sorted(node.directives, 'name.value'), variableDefinitions: sorted( node.variableDefinitions, 'variable.name.value', ), }; }, Directive(node: DirectiveNode): DirectiveNode { return { ...node, arguments: sorted(node.arguments, 'name.value') }; }, }); } // removeAliases gets rid of GraphQL aliases, a feature by which you can tell a // server to return a field's data under a different name from the field // name. Maybe this is useful if somebody somewhere inserts random aliases into // their queries. export function removeAliases(ast: DocumentNode): DocumentNode { return visit(ast, { Field(node: FieldNode): FieldNode { return { ...node, alias: undefined, }; }, }); } // Like the graphql-js print function, but deleting whitespace wherever // feasible. Specifically, all whitespace (outside of string literals) is // reduced to at most one space, and even that space is removed anywhere except // for between two alphanumerics. export function printWithReducedWhitespace(ast: DocumentNode): string { // In a GraphQL AST (which notably does not contain comments), the only place // where meaningful whitespace (or double quotes) can exist is in // StringNodes. So to print with reduced whitespace, we: // - temporarily sanitize strings by replacing their contents with hex // - use the default GraphQL printer // - minimize the whitespace with a simple regexp replacement // - convert strings back to their actual value // We normalize all strings to non-block strings for simplicity. const sanitizedAST = visit(ast, { StringValue(node: StringValueNode): StringValueNode { return { ...node, value: Buffer.from(node.value, 'utf8').toString('hex'), block: false, }; }, }); const withWhitespace = print(sanitizedAST); const minimizedButStillHex = withWhitespace .replace(/\s+/g, ' ') .replace(/([^_a-zA-Z0-9]) /g, (_, c) => c) .replace(/ ([^_a-zA-Z0-9])/g, (_, c) => c); return minimizedButStillHex.replace(/"([a-f0-9]+)"/g, (_, hex) => JSON.stringify(Buffer.from(hex, 'hex').toString('utf8')), ); } // The default signature function consists of removing unused definitions // and whitespace. // XXX consider caching somehow export function defaultSignature( ast: DocumentNode, operationName: string, ): string { return printWithReducedWhitespace( sortAST( removeAliases(hideLiterals(dropUnusedDefinitions(ast, operationName))), ), ); }