UNPKG

graphql-fields-list

Version:

Extracts and returns list of fields requested from graphql resolver info object

624 lines (537 loc) 15 kB
/*! * ISC License * * Copyright (c) 2018-present, Mykhailo Stadnyk <mikhus@gmail.com> * * Permission to use, copy, modify, and/or distribute this software for any * purpose with or without fee is hereby granted, provided that the above * copyright notice and this permission notice appear in all copies. * * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. */ import { ArgumentNode, DirectiveNode, SelectionNode, FragmentDefinitionNode, GraphQLResolveInfo, FieldNode, } from 'graphql'; /** * Pre-compiled wildcard replacement regexp * * @type {RegExp} */ const RX_AST: RegExp = /\*/g; /** * Fragment item type * * @access public */ export interface FragmentItem { [name: string]: FragmentDefinitionNode; } /** * Field names transformation map interface * * @access public */ export interface FieldNamesMap { [name: string]: string; } /** * fieldsList options argument interface * * @access public */ export interface FieldsListOptions { /** * Path to a tree branch which should be mapped during fields extraction * @type {string} */ path?: string; /** * Transformation rules which should be used to re-name field names * @type {FieldNamesMap} */ transform?: FieldNamesMap; /** * Flag which turns on/off GraphQL directives checks on a fields * and take them into account during fields analysis * @type {boolean} */ withDirectives?: boolean; /** * Flag which turns on/off whether to return the parent fields or not * @type {boolean} */ keepParentField?: boolean; /** * Fields skip rule patterns. Usually used to ignore part of request field * subtree. For example if query looks like: * profiles { * id * users { * name * email * password * } * } * and you doo n not care about users, it can be done like: * fieldsList(info, { skip: ['users'] }); // or * fieldsProjection(info, { skip: ['users.*'] }); // more obvious notation * * If you want to skip only exact fields, it can be done as: * fieldsMap(info, { skip: ['users.email', 'users.password'] }) */ skip?: string[]; } /** * Type definition for variables values map * * @access public */ export interface VariablesValues { [name: string]: any; } /** * Fields projection object, where keys are dot-notated field paths * ended-up with a truthy (1) value * * @access public */ export interface FieldsProjection { [name: string]: 1; } /** * Traverse query nodes tree options arg interface * @access private */ interface TraverseOptions { fragments: FragmentItem; vars: any; withVars?: boolean; } /** * Retrieves a list of nodes from a given selection (either fragment or * selection node) * * @param {FragmentDefinitionNode | FieldNode} selection * @return {ReadonlyArray<FieldNode>} * @access private */ function getNodes( selection: FragmentDefinitionNode | SelectionNode, ): ReadonlyArray<SelectionNode> { return (selection as any)?.selectionSet?.selections || [] as ReadonlyArray<SelectionNode> ; } /** * Checks if a given directive name and value valid to return a field * * @param {string} name * @param {boolean} value * @return boolean * @access private */ function checkValue(name: string, value: boolean): boolean { return name === 'skip' ? !value : name === 'include' ? value : true ; } /** * Checks if a given directive arg allows to return field * * @param {string} name - directive name * @param {ArgumentNode} arg * @param {VariablesValues} vars * @return {boolean} * @access private */ function verifyDirectiveArg( name: string, arg: ArgumentNode, vars: VariablesValues ): boolean { switch (arg.value.kind) { case 'BooleanValue': return checkValue(name, arg.value.value); case 'Variable': return checkValue(name, vars[arg.value.name.value]); } return true; } /** * Checks if a given directive allows to return field * * @param {DirectiveNode} directive * @param {VariablesValues} vars * @return {boolean} * @access private */ function verifyDirective( directive: DirectiveNode, vars: VariablesValues, ): boolean { const directiveName: string = directive.name.value; if (!~['include', 'skip'].indexOf(directiveName)) { return true; } let args: any[] = directive.arguments as any[]; if (!(args && args.length)) { args = []; } for (const arg of args) { if (!verifyDirectiveArg(directiveName, arg, vars)) { return false; } } return true; } /** * Checks if a given list of directives allows to return field * * @param {ReadonlyArray<DirectiveNode>} directives * @param {VariablesValues} vars * @return {boolean} * @access private */ function verifyDirectives( directives: ReadonlyArray<DirectiveNode> | undefined, vars: VariablesValues, ): boolean { if (!directives || !directives.length) { return true; } vars = vars || {}; for (const directive of directives) { if (!verifyDirective(directive, vars)) { return false; } } return true; } type SkipValue = boolean | SkipTree; type SkipTree = { [key: string]: SkipValue }; /** * Checks if a given node is inline fragment and process it, * otherwise does nothing and returns false. * * @param {SelectionNode} node * @param {MapResult | MapResultKey} root * @param {*} skip * @param {TraverseOptions} opts */ function verifyInlineFragment( node: SelectionNode, root: MapResult | MapResultKey, opts: TraverseOptions, skip: SkipValue, ): boolean { if (node.kind === 'InlineFragment') { const nodes = getNodes(node); nodes.length && traverse(nodes, root, opts, skip); return true; } return false; } /** * Builds skip rules tree from a given skip option argument * * @param {string[]} skip - skip option arguments * @return {SkipTree} - skip rules tree */ function skipTree(skip: string[]): SkipTree { const tree: SkipTree = {}; for (const pattern of skip) { const props = pattern.split('.'); let propTree: SkipTree = tree; for (let i = 0, s = props.length; i < s; i++) { const prop = props[i]; const all = props[i + 1] === '*'; if (!propTree[prop]) { propTree[prop] = i === s - 1 || all ? true : {}; all && i++; } propTree = propTree[prop] as SkipTree; } } return tree; } /** * * @param node * @param skip */ function verifySkip(node: string, skip: SkipValue): SkipValue { if (!skip) { return false; } // true['string'] is a valid operation is JS resulting in `undefined` if ((skip as SkipTree)[node]) { return (skip as SkipTree)[node]; } // lookup through wildcard patterns let nodeTree: SkipValue = false; const patterns = Object.keys(skip).filter( pattern => ~pattern.indexOf('*'), ); for (const pattern of patterns) { const rx: RegExp = new RegExp(pattern.replace(RX_AST, '.*')); if (rx.test(node)) { nodeTree = (skip as SkipTree)[pattern]; // istanbul ignore else if (nodeTree === true) { break; } } } return nodeTree; } export type MapResultKey = false | MapResult; export type MapResult = { [key: string]: MapResultKey }; /** * Traverses recursively given nodes and fills-up given root tree with * a requested field names * * @param {ReadonlyArray<FieldNode>} nodes * @param {MapResult | MapResultKey} root * @param {TraverseOptions} opts * @param {SkipValue} skip * @return {MapResult} * @access private */ function traverse( nodes: ReadonlyArray<SelectionNode>, root: MapResult | MapResultKey, opts: TraverseOptions, skip: SkipValue, ): MapResult | MapResultKey { for (const node of nodes) { if (opts.withVars && !verifyDirectives(node.directives, opts.vars)) { continue; } if (verifyInlineFragment(node, root, opts, skip)) { continue; } const name = (node as FieldNode).name.value; if (opts.fragments[name]) { traverse(getNodes(opts.fragments[name]), root, opts, skip); continue; } const nodes = getNodes(node); const nodeSkip = verifySkip(name, skip); if (nodeSkip !== true) { (root as MapResult)[name] = (root as MapResult)[name] || ( nodes.length ? {} : false ); nodes.length && traverse( nodes, (root as MapResult)[name], opts, nodeSkip, ); } } return root; } /** * Retrieves and returns a branch from a given tree by a given path * * @param {MapResult} tree * @param {string} [path] * @return {MapResult} * @access private */ function getBranch(tree: MapResult, path?: string): MapResult { if (!path) { return tree; } for (const fieldName of path.split('.')) { const branch = tree[fieldName]; if (!branch) { return {}; } tree = branch; } return tree; } /** * Verifies if a given info object is valid. If valid - returns * proper FieldNode object for given resolver node, otherwise returns null. * * @param {GraphQLResolveInfo} info * @return {FieldNode | null} * @access private */ function verifyInfo(info: GraphQLResolveInfo): SelectionNode | null { if (!info) { return null; } if (!info.fieldNodes && (info as any).fieldASTs) { (info as any).fieldNodes = (info as any).fieldASTs; } if (!info.fieldNodes) { return null; } return verifyFieldNode(info); } /** * Verifies if a proper fieldNode existing on given info object * * @param {GraphQLResolveInfo} info * @return {FieldNode | null} * @access private */ function verifyFieldNode(info: GraphQLResolveInfo): FieldNode | null { const fieldNode: FieldNode | undefined = info.fieldNodes.find( (node: FieldNode) => node && node.name && node.name.value === info.fieldName ); if (!(fieldNode && fieldNode.selectionSet)) { return null; } return fieldNode; } /** * Parses input options and returns prepared options object * * @param {FieldsListOptions} options * @return {FieldsListOptions} * @access private */ function parseOptions(options?: FieldsListOptions): FieldsListOptions { if (!options) { return {}; } if (options.withDirectives === undefined) { options.withDirectives = true; } return options; } /** * Extracts and returns requested fields tree. * May return `false` if path option is pointing to leaf of tree * * @param {GraphQLResolveInfo} info * @param {FieldsListOptions} options * @return {MapResult} * @access public */ export function fieldsMap( info: GraphQLResolveInfo, options?: FieldsListOptions, ): MapResult { const fieldNode = verifyInfo(info); if (!fieldNode) { return {}; } const { path, withDirectives, skip } = parseOptions(options); const tree = traverse(getNodes(fieldNode), {}, { fragments: info.fragments, vars: info.variableValues, withVars: withDirectives, }, skipTree(skip || []), ) as MapResult; return getBranch(tree, path); } /** * Extracts list of selected fields from a given GraphQL resolver info * argument and returns them as an array of strings, using the given * extraction options. * * @param {GraphQLResolveInfo} info - GraphQL resolver info object * @param {FieldsListOptions} [options] - fields list extraction options * @return {string[]} - array of field names * @access public */ export function fieldsList( info: GraphQLResolveInfo, options: FieldsListOptions = {}, ): string[] { return Object.keys(fieldsMap(info, options)).map((field: string) => (options.transform || {})[field] || field, ); } /** * Combines parent path with child name to fully-qualified dot-notation path * of a child * * @param {string} parent * @param {string} child * @return {string} * @access private */ function toDotNotation(parent: string, child: string): string { return `${parent ? parent + '.' : ''}${child}` } /** * Extracts projection of selected fields from a given GraphQL resolver info * argument and returns flat fields projection object, where keys are object * paths in dot-notation form. * * @param {GraphQLResolveInfo} info - GraphQL resolver info object * @param {FieldsListOptions} options - fields list extraction options * @return {FieldsProjection} - fields projection object * @access public */ export function fieldsProjection( info: GraphQLResolveInfo, options?: FieldsListOptions, ): FieldsProjection { const tree = fieldsMap(info, options); const stack: any[] = []; const map: FieldsProjection = {}; const transform = (options || {}).transform || {}; stack.push({ node: '', tree }); while (stack.length) { for (const j of Object.keys(stack[0].tree)) { if (stack[0].tree[j]) { const nodeDottedName = toDotNotation(stack[0].node, j); stack.push({ node: nodeDottedName, tree: stack[0].tree[j], }); if (options?.keepParentField) { map[nodeDottedName] = 1; } continue; } let dotName = toDotNotation(stack[0].node, j); if (transform[dotName]) { dotName = transform[dotName]; } map[dotName] = 1; } stack.shift(); } return map; } // istanbul ignore next if (process.env['IS_UNIT_TEST']) { // noinspection JSUnusedGlobalSymbols Object.assign(module.exports, { getNodes, traverse, getBranch, verifyDirectives, verifyDirective, verifyDirectiveArg, checkValue, verifyInfo, verifyFieldNode, verifyInlineFragment, verifySkip, parseOptions, toDotNotation, }); }