UNPKG

@mercuriusjs/gateway

Version:
803 lines (708 loc) 22 kB
'use strict' const { getNamedType, print, parse, Kind } = require('graphql') const { preGatewayExecutionHandler, preGatewaySubscriptionExecutionHandler } = require('../handlers') const { collect } = require('../collectors') const { MER_ERR_GQL_GATEWAY_MISSING_KEY_DIRECTIVE } = require('../errors') const kEntityResolvers = Symbol('mercurius.entity-resolvers') function getFieldType (schema, type, fieldName) { return getNamedType(schema.getType(type).getFields()[fieldName].type) } function getInlineFragmentType (schema, type) { return getNamedType(schema.getType(type)) } function getDirectiveSelection (node, directiveName) { if (!node || !node.astNode) { return [] } const directive = node.astNode.directives.find( directive => directive.name.value === directiveName ) if (!directive) { return [] } const query = parse(`{ ${directive.arguments[0].value.value} }`) return query.definitions[0].selectionSet.selections } function getDirectiveRequiresSelection (selections, type) { if ( !type.extensionASTNodes || type.extensionASTNodes.length === 0 || !type.extensionASTNodes[0].fields[0] || !type.extensionASTNodes[0].fields[0].directives[0] ) { return [] } const requires = [] const selectedFields = selections.map(selection => selection.name.value) for (let i = 0; i < type.extensionASTNodes.length; i++) { for (let j = 0; j < type.extensionASTNodes[i].fields.length; j++) { const field = type.extensionASTNodes[i].fields[j] if (!selectedFields.includes(field.name.value) || !field.directives) { continue } const directive = field.directives.find(d => d.name.value === 'requires') if (!directive) { continue } // assumes arguments is always present, might require a custom error in case it is not const query = parse(`{ ${directive.arguments[0].value.value} }`) requires.push(...query.definitions[0].selectionSet.selections) } } return requires } function collectServiceTypeFields (selections, service, type, schema) { return [ ...selections .filter( selection => selection.kind === Kind.INLINE_FRAGMENT || selection.kind === Kind.FRAGMENT_SPREAD || service.typeMap[type].has(selection.name.value) ) .map(selection => { if (selection.selectionSet && selection.selectionSet.selections) { if (selection.kind === Kind.INLINE_FRAGMENT) { const inlineFragmentType = getInlineFragmentType( schema, selection.typeCondition.name.value ) const requiredFields = [] for (const field of Object.values(inlineFragmentType.getFields())) { requiredFields.push(...getDirectiveSelection(field, 'requires')) } return { ...selection, selectionSet: { kind: Kind.SELECTION_SET, selections: collectServiceTypeFields( [...selection.selectionSet.selections, ...requiredFields], service, inlineFragmentType, schema ) } } } const fieldType = getFieldType(schema, type, selection.name.value) const requiredFields = [] if (fieldType.getFields) { for (const field of Object.values(fieldType.getFields())) { requiredFields.push(...getDirectiveSelection(field, 'requires')) } } return { ...selection, selectionSet: { kind: Kind.SELECTION_SET, selections: collectServiceTypeFields( [...selection.selectionSet.selections, ...requiredFields], service, fieldType, schema ) } } } return selection }), { kind: Kind.FIELD, name: { kind: Kind.NAME, value: '__typename' }, arguments: [], directives: [] }, ...getDirectiveSelection(type, 'key'), ...getDirectiveRequiresSelection(selections, type) ] } function createQueryOperation ({ fieldName, selections, variableDefinitions, args, operation }) { return { kind: Kind.DOCUMENT, definitions: [ { kind: Kind.OPERATION_DEFINITION, operation, name: { kind: Kind.NAME, value: `Query_${fieldName}` }, variableDefinitions, directives: [], selectionSet: { kind: Kind.SELECTION_SET, selections: [ { kind: Kind.FIELD, name: { kind: Kind.NAME, value: fieldName }, arguments: args, directives: [], selectionSet: { kind: Kind.SELECTION_SET, selections } } ] } } ] } } function createEntityReferenceResolverOperation ({ returnType, selections, variableDefinitions }) { return { kind: Kind.DOCUMENT, definitions: [ { kind: Kind.OPERATION_DEFINITION, operation: 'query', name: { kind: Kind.NAME, value: 'EntitiesQuery' }, variableDefinitions: [ ...variableDefinitions, { kind: Kind.VARIABLE_DEFINITION, variable: { kind: Kind.VARIABLE, name: { kind: Kind.NAME, value: 'representations' } }, type: { kind: Kind.NON_NULL_TYPE, type: { kind: Kind.LIST_TYPE, type: { kind: Kind.NON_NULL_TYPE, type: { kind: Kind.NAMED_TYPE, name: { kind: Kind.NAME, value: '_Any' } } } } }, directives: [] } ], directives: [], selectionSet: { kind: Kind.SELECTION_SET, selections: [ { kind: Kind.FIELD, name: { kind: Kind.NAME, value: '_entities' }, arguments: [ { kind: Kind.ARGUMENT, name: { kind: Kind.NAME, value: 'representations' }, value: { kind: Kind.VARIABLE, name: { kind: Kind.NAME, value: 'representations' } } } ], directives: [], selectionSet: { kind: Kind.SELECTION_SET, selections: [ { kind: Kind.FIELD, name: { kind: Kind.NAME, value: '__typename' }, arguments: [], directives: [] }, { kind: Kind.INLINE_FRAGMENT, typeCondition: { kind: Kind.NAMED_TYPE, name: { kind: Kind.NAME, value: returnType } }, directives: [], selectionSet: { kind: Kind.SELECTION_SET, selections } } ] } } ] } } ] } } function createFieldResolverOperation ({ parentType, fieldName, selections, args, variableDefinitions }) { return createEntityReferenceResolverOperation({ returnType: parentType, variableDefinitions, selections: [ { kind: Kind.FIELD, name: { kind: Kind.NAME, value: fieldName }, directives: [], selectionSet: { kind: Kind.SELECTION_SET, selections }, arguments: args } ] }) } function collectVariableNames (acc, fields) { for (const field of fields) { if (field.value.kind === Kind.VARIABLE) { acc.push(field.value.name.value) } else if (field.value.kind === Kind.OBJECT) { collectVariableNames(acc, field.value.fields) } } } function collectArgumentNames (fieldNode) { const argumentNames = [] if (fieldNode.arguments) { for (const argument of fieldNode.arguments) { /* istanbul ignore else if there is no arguments property we return empty array */ if (argument.value.kind === Kind.VARIABLE) { argumentNames.push(argument.value.name.value) } else if (argument.value.kind === Kind.OBJECT) { collectVariableNames(argumentNames, argument.value.fields) } else if (argument.value.kind === Kind.LIST) { /* c8 ignore next 3 */ // TODO: Support GraphQL List } } } return argumentNames } function collectArgumentsWithVariableValues (selections) { const argumentNames = [] for (const selection of selections) { argumentNames.push(...collectArgumentNames(selection)) if (selection.directives.length > 0) { for (const directive of selection.directives) { argumentNames.push(...collectArgumentNames(directive)) } } if (selection.selectionSet && selection.selectionSet.selections) { argumentNames.push( ...collectArgumentsWithVariableValues(selection.selectionSet.selections) ) } } return argumentNames } function getFragmentNamesInSelection (selections) { const fragmentsInSelection = [] for (const selection of selections) { if (selection.kind === Kind.FRAGMENT_SPREAD) { fragmentsInSelection.push(selection.name.value) } if (selection.selectionSet) { fragmentsInSelection.push( ...getFragmentNamesInSelection(selection.selectionSet.selections) ) } } return fragmentsInSelection } function collectFragmentsToInclude (usedFragments, fragments, service, schema) { const visitedFragments = new Set() const result = [] for (const fragmentName of usedFragments) { visitedFragments.add(fragmentName) const fragment = fragments[fragmentName] const selections = collectServiceTypeFields( fragment.selectionSet.selections, service, fragment.typeCondition.name.value, schema ) result.push({ ...fragment, selectionSet: { kind: Kind.SELECTION_SET, selections } }) const fragmentsInSelections = getFragmentNamesInSelection( selections ).filter(fragmentName => !visitedFragments.has(fragmentName)) result.push( ...collectFragmentsToInclude( fragmentsInSelections, fragments, service, schema ) ) } return result } function generatePathKey (path) { const keys = [] if (path.prev) { keys.push(...generatePathKey(path.prev)) } keys.push(path.key) return keys } /** * Creates a resolver function for a fields type * * There are 3 options: * - Query field resolver: when the service of the type is null * - Reference entity resolver: when the service of type defined the field on the type * - Field entity resolver: when the field was added through type extension in the service of the field's type * */ function makeResolver ({ service, createOperation, transformData, isQuery, isReference, isSubscription, typeToServiceMap, serviceMap, entityResolversFactory, lruGatewayResolvers, skipRequestIfValueExists }) { return async function (parent, args, context, info) { const { fieldNodes, returnType, fieldName, parentType, operation: originalOperation, variableValues, fragments, schema } = info if (isReference && !parent[fieldName]) return null // Get the actual type as the returnType can be NonNull or List as well const type = getNamedType(returnType) const queryId = generatePathKey(info.path).join('.') const resolverKey = `${queryId}.${type.toString()}` const { reply, __currentQuery, pubsub } = context const cached = lruGatewayResolvers != null && lruGatewayResolvers.get(`${__currentQuery}_${resolverKey}`) let variableNamesToDefine let operation let query let selections // verify and return the value if is already available in the parent if (parent && parent[fieldName] && skipRequestIfValueExists) { return parent[fieldName] } if (cached) { variableNamesToDefine = cached.variableNamesToDefine query = cached.query operation = cached.operation } else { // Remove items from selections that are not defined in the service selections = fieldNodes[0].selectionSet ? collectServiceTypeFields( fieldNodes[0].selectionSet.selections, service, type, schema ) : [] // collect all variable names that are used in selection variableNamesToDefine = new Set( collectArgumentsWithVariableValues(selections) ) collectArgumentNames(fieldNodes[0]).map(argumentName => variableNamesToDefine.add(argumentName) ) const variablesToDefine = originalOperation.variableDefinitions.filter( definition => variableNamesToDefine.has(definition.variable.name.value) ) // create the operation that will be sent to the service operation = createOperation({ returnType: type, parentType, fieldName, selections, isQuery, isReference, variableDefinitions: variablesToDefine, args: fieldNodes[0].arguments, operation: originalOperation.operation }) query = print(operation) // check if fragments are used in the original query const usedFragments = getFragmentNamesInSelection(selections) const fragmentsToDefine = collectFragmentsToInclude( usedFragments, fragments, service, schema ) query = appendFragments(query, fragmentsToDefine) if (lruGatewayResolvers != null) { lruGatewayResolvers.set(`${__currentQuery}_${resolverKey}`, { query, operation, variableNamesToDefine }) } } const variables = {} // Add variables to payload for (const [variableName, variableValue] of Object.entries( variableValues )) { if (variableNamesToDefine.has(variableName)) { variables[variableName] = variableValue } } if (isReference) { if (parent[fieldName] instanceof Array) { variables.representations = parent[fieldName].map(ref => removeNonIdProperties(ref, type) ) } else { variables.representations = [ removeNonIdProperties(parent[fieldName], type) ] } } else if (!isQuery && !isSubscription) { variables.representations = [ { ...removeNonIdProperties(parent, parentType), ...getRequiredFields( parent, schema.getType(parentType).getFields()[fieldName] ) } ] } if (isSubscription) { if (context.gateway.preGatewaySubscriptionExecution !== null) { await preGatewaySubscriptionExecutionHandler({ schema, document: operation, context, service }) } const subscriptionId = service.createSubscription( query, variables, pubsub.publish.bind(pubsub), context ) context.gateway.subscriptionMap.set(context.id, { serviceName: service.name, subscriptionId }) return pubsub.subscribe(`${service.name}_${subscriptionId}`) } const entityResolvers = reply?.[kEntityResolvers] || entityResolversFactory.create() if (isQuery) { // Trigger preGatewayExecution hook let modifiedQuery if (context.gateway.preGatewayExecution !== null) { ;({ modifiedQuery } = await preGatewayExecutionHandler({ schema, document: operation, context, service })) } const response = await service.sendRequest({ method: 'POST', body: JSON.stringify({ query: modifiedQuery || query, variables }), originalRequestHeaders: reply ? reply.request.headers : {}, context }) const collectors = service.collectors if (collectors) { collect({ collectors, context, queryId, response, serviceName: service.name }) } service.setResponseHeaders(reply || {}) const transformed = transformData(response) // TODO support union types const transformedTypeName = Array.isArray(transformed) ? transformed.length > 0 && transformed[0].__typename : transformed && transformed.__typename if (typeToServiceMap) { // If the type is defined in the typeToServiceMap, we need to resolve the type if the type is a reference // and it is fullfilled by another service const targetService = typeToServiceMap[transformedTypeName] // targetService can be null if it is a value type or not defined anywhere if (targetService && targetService !== service.name) { selections = collectServiceTypeFields( fieldNodes[0].selectionSet.selections, serviceMap[targetService], type, schema ) const toFill = Array.isArray(transformed) ? transformed : [transformed] variables.representations = toFill.map(ref => removeNonIdProperties(ref, schema.getType(transformedTypeName)) ) operation = createEntityReferenceResolverOperation({ returnType: transformedTypeName, selections, variableDefinitions: [] }) const existingValues = Object.keys(variables.representations[0]) const fieldsInRequest = selections.map(sel => sel.name.value).filter(value => !existingValues.includes(value)) const queryBySelections = print(operation) const usedFragments = getFragmentNamesInSelection(selections) const fragmentsToDefine = collectFragmentsToInclude( usedFragments, fragments, serviceMap[targetService], schema ) const finalQuery = appendFragments(queryBySelections, fragmentsToDefine) let entities if (!fieldsInRequest.length && finalQuery === queryBySelections) { entities = variables.representations } else { // We are completely skipping the resolver logic in this case to avoid expensive // multiple requests to the other service, one for each field. Our current logic // for the entities data loaders would not work in this case as we would need to // resolve each field individually. Therefore we are short-cricuiting it and // just issuing the request. A different algorithm based on the graphql executor // is possible but it would be significantly slower and difficult to prepare. const response2 = await entityResolvers[`${targetService}Entity`]({ document: operation, query: finalQuery, variables, context, id: queryId }) entities = response2.json.data._entities } for (let i = 0; i < entities.length; i++) { Object.assign(toFill[i], entities[i]) } } } return transformed } // This method is declared in gateway.js inside of onRequest // hence it's unique per request. const response = await entityResolvers[`${service.name}Entity`]({ document: operation, query, variables, context, id: queryId }) return transformData(response) } } function removeNonIdProperties (obj, type) { const keyDirective = type.astNode.directives.find(d => d.name.value === 'key') if (!keyDirective) { throw new MER_ERR_GQL_GATEWAY_MISSING_KEY_DIRECTIVE(type.name) } const idFields = keyDirective.arguments[0].value.value.split(' ') const result = { __typename: obj.__typename } for (const id of idFields) { result[id] = obj[id] } return result } function getRequiredFields (obj, field) { const requiresDirective = field.astNode.directives.find( d => d.name.value === 'requires' ) const result = {} if (!requiresDirective) { return result } const requiredFields = requiresDirective.arguments[0].value.value.split(' ') for (const requiredField of requiredFields) { result[requiredField] = obj[requiredField] } return result } function appendFragments (query, fragmentsToDefine) { /* istanbul ignore else */ if (fragmentsToDefine.length > 0) { const fragmentsIncluded = new Set() for (const fragment of fragmentsToDefine) { if (!fragmentsIncluded.has(fragment.name.value)) { query += `\n${print(fragment)}` fragmentsIncluded.add(fragment.name.value) } } } return query } module.exports = { makeResolver, createQueryOperation, createFieldResolverOperation, createEntityReferenceResolverOperation, kEntityResolvers }