UNPKG

@mercuriusjs/gateway

Version:
627 lines (580 loc) 20.9 kB
'use strict' const { getNamedType, isObjectType, isScalarType, Kind } = require('graphql') const { Factory } = require('single-user-cache') const { buildFederationSchema } = require('@mercuriusjs/federation') const buildServiceMap = require('./service-map') const { makeResolver, createQueryOperation, createFieldResolverOperation, createEntityReferenceResolverOperation, kEntityResolvers } = require('./make-resolver') const { MER_ERR_GQL_GATEWAY_REFRESH, MER_ERR_GQL_GATEWAY_INIT, MER_ERR_SERVICE_RETRY_FAILED } = require('../errors') const findValueTypes = require('./find-value-types') const getQueryResult = require('./get-query-result') function isDefaultType (type) { return [ '__Schema', '__Type', '__Field', '__InputValue', '__EnumValue', '__Directive' ].includes(type) } /** * The gateway resolver methods are responsible to delegate certain parts of the query to a service. * * For each type in the schema defined by the services, it checks each field to decide if it needs a resolver and if so, * what kind of resolver. This is done by getting the current type, its service name (if defined) and the service name * of the field type (if defined). The respective services can be undefined if the field type is a value type or the * type is query, mutation or subscription. * * While derivation logic is documented inline, the following example shall provide an overview. * * * Service 1 (User Service) * * extend Query { * # assign query field resolver to retrieve the user from user service * me: User! * # assign query field resolver to retrieve the user connection from user service * users: UserConnection! * # assign a query field resolver to retrieve the service info (which is a value type) from the user service * userServiceInfo: ServiceInfo! * } * * type ServiceInfo { * name: String! * } * * type UserConnection { * # PageInfo is defined in both services, hence a value type, and resolved from the parent * pageInfo: PageInfo! * edges: [UserEdge!]! * } * * type PageInfo { * hasNextPage: Boolean * } * * type UserEdge { * node: User! * } * * type User @key(fields: "id") { * id: ID! * name: String * } * * * Service 2 (Post Service) * * extend type Query { * # assign query field resolver to retrieve posts from post service * posts: PostConnection! * # assign a query field resolver to retrieve the service info (which is a value type) from the post service * postServiceInfo: ServiceInfo! * } * * type ServiceInfo { * name: String! * } * * type PostConnection { * # PageInfo is defined in both services, hence a value type, and resolved from the parent * pageInfo: PageInfo! * edges: [PostEdge!]! * } * * type PageInfo { * hasNextPage: Boolean * } * * type PostEdge { * node: Post! * } * * type Post @key(fields: "id") { * id: ID! * title: String * content: String * # assign reference entity field resolver to retrieve author from user service * author: User * } * * extend type User @key(fields: "id") { * id: ID! @external * # assign field entity resolver to enable querying posts of a user from the post service * posts: [Post] * } */ function defineResolvers ( schema, typeToServiceMap, serviceMap, typeFieldsToService, entityResolversFactory, lruGatewayResolvers ) { const types = schema.getTypeMap() for (const type of Object.values(types)) { if (isObjectType(type) && !isDefaultType(type.name)) { const serviceForType = typeToServiceMap[type.name] for (const field of Object.values(type.getFields())) { const fieldType = getNamedType(field.type) if ( fieldType.astNode && fieldType.astNode.kind === Kind.ENUM_TYPE_DEFINITION ) { continue } const fieldName = field.name if (!isScalarType(fieldType)) { const serviceForFieldType = typeToServiceMap[fieldType] /* istanbul ignore else */ if ( (serviceForFieldType === null && serviceForType !== null) || (serviceForFieldType !== null && serviceForType !== null && serviceForFieldType === serviceForType) ) { /** * We resolve from the parent in two cases: * - Either there is a service for the type and no service for the field type. * In this case, the field type is a value type and the type is neither a query, mutation nor a subscription. * - Or there is a service for the type and a service for the field type and both refer to the same service. */ field.resolve = (parent, args, context, info) => parent && parent[info.path.key] } else if (serviceForType === null) { /** * If the return type of a query, subscription or mutation is a value type, its service is undefined or null, e.g. for * extend type Query { * userServiceInfo: SomeReturnType! * } * where SomeReturnType is a value type. * In these cases, we get the service from the typeFieldsToService map. */ let service = serviceMap[typeFieldsToService[`${type}-${fieldName}`]] if ( !service && (type.name === 'Query' || type.name === 'Mutation' || type.name === 'Subscription') ) { /** * If there is no service for the type, it is a query, mutation or subscription */ service = serviceMap[serviceForFieldType] } if (!service) { service = serviceMap[serviceForFieldType] if (!service) { /** * If the type is a nested value type, the service can still be null or undefined. * In these cases, we resolve from the parent. */ field.resolve = (parent, args, context, info) => parent && parent[info.path.key] } else { // If the service is not null, then we resolve from the service const isNonNull = field.astNode.type.kind === Kind.NON_NULL_TYPE const leafKind = isNonNull ? field.astNode.type.type.kind : field.astNode.type.kind if (leafKind === Kind.LIST_TYPE) { field.resolve = makeResolver({ service, createOperation: createEntityReferenceResolverOperation, transformData: response => response ? response.json.data._entities : isNonNull ? [] : null, isReference: true, entityResolversFactory, lruGatewayResolvers }) } else { field.resolve = makeResolver({ service, createOperation: createEntityReferenceResolverOperation, transformData: response => response.json.data._entities[0], isReference: true, entityResolversFactory, lruGatewayResolvers }) } } } else if (type.name === 'Subscription') { field.subscribe = makeResolver({ service, createOperation: createQueryOperation, isQuery: true, isSubscription: true, entityResolversFactory, lruGatewayResolvers }) } else { field.resolve = makeResolver({ service, createOperation: createQueryOperation, transformData: response => response.json.data[fieldName], typeToServiceMap, serviceMap, isQuery: true, entityResolversFactory, lruGatewayResolvers }) } } else if ( serviceForType && serviceForFieldType !== null && serviceForFieldType !== serviceForType ) { /** * If there is a service for the field type and a service for the type and it is not the same service, * it is an entity */ // check if the field is default field of the type if (serviceMap[serviceForType].typeMap[type.name].has(fieldName)) { // Check if field is nullable const isNonNull = field.astNode.type.kind === Kind.NON_NULL_TYPE const leafKind = isNonNull ? field.astNode.type.type.kind : field.astNode.type.kind if (leafKind === Kind.LIST_TYPE) { field.resolve = makeResolver({ service: serviceMap[serviceForFieldType], createOperation: createEntityReferenceResolverOperation, transformData: response => response ? response.json.data._entities : isNonNull ? [] : null, isReference: true, entityResolversFactory, lruGatewayResolvers }) } else { field.resolve = makeResolver({ service: serviceMap[serviceForFieldType], createOperation: createEntityReferenceResolverOperation, transformData: response => response.json.data._entities[0], isReference: true, entityResolversFactory, lruGatewayResolvers }) } } else { field.resolve = makeResolver({ service: serviceMap[serviceForFieldType], createOperation: createFieldResolverOperation, // TODO this should be refactored to have a dataloader for a given entity // that merges all different fields for a given entity. transformData: response => response.json.data._entities[0][fieldName], entityResolversFactory, lruGatewayResolvers, skipRequestIfValueExists: true }) } } } else if (typeFieldsToService[`${type}-${fieldName}`]) { const service = serviceMap[typeFieldsToService[`${type}-${fieldName}`]] if (serviceForType === null) { if (type.name === 'Subscription') { field.subscribe = makeResolver({ service, createOperation: createQueryOperation, isQuery: true, isSubscription: true, entityResolversFactory, lruGatewayResolvers }) } else { field.resolve = makeResolver({ service, createOperation: createQueryOperation, transformData: response => response.json.data[fieldName], isQuery: true, entityResolversFactory, lruGatewayResolvers }) } } else { field.resolve = makeResolver({ service, // TODO this should be refactored to have a dataloader for a given entity // that merges all different fields for a given entity. createOperation: createFieldResolverOperation, transformData: response => response.json.data._entities[0][fieldName], entityResolversFactory, lruGatewayResolvers }) } } else { // This implementation is only partially correct. // In case a field is not returned by the parent resolver // and the parent is a reference, we would have no way to // fetch it. This happens only when top-level resolvers // (Query and Mutation) returns a single type. // We directly work around this inside makeResolver. field.resolve = (parent, args, context, info) => { return parent && parent[info.path.key] } } } } } } function defaultErrorHandler (error, service) { if (service.mandatory) { throw error } } async function buildGateway (serviceMap, gatewayOpts, app, lruGatewayResolvers) { const { services, errorHandler = defaultErrorHandler } = gatewayOpts if (typeof services === 'function') { await buildServiceMap(serviceMap, await services(), errorHandler, app.log) } else { await buildServiceMap(serviceMap, services, errorHandler, app.log) } const serviceSDLs = Object.entries(serviceMap).reduce( (acc, [name, value]) => { const { schemaDefinition, error } = value error !== null ? app.log.warn( `Initializing service "${name}" failed with message: "${error.message}"` ) : acc.push(schemaDefinition) return acc }, [] ) if (serviceSDLs.length < 1) { for (const service of Object.values(serviceMap)) { await service.close() } throw new MER_ERR_GQL_GATEWAY_INIT('No valid service SDLs were provided') } const schema = buildFederationSchema(serviceSDLs.join(' '), { isGateway: true }) const typeToServiceMap = {} const typeFieldsToService = {} let allTypes = [] const factory = new Factory() app.decorateReply(kEntityResolvers) app.addHook('onRequest', async function (req, reply) { reply[kEntityResolvers] = factory.create() }) for (const [service, serviceDefinition] of Object.entries(serviceMap)) { for (const type of serviceDefinition.types) { allTypes.push(serviceDefinition.schema.getTypeMap()[type]) typeToServiceMap[type] = service } for (const [type, fields] of Object.entries( serviceDefinition.extensionTypeMap )) { for (const field of fields) { typeFieldsToService[`${type}-${field}`] = service } } /** * TODO: further optimization is possible by merging queries to the same service * when the entities query is for the same type with the same representation values * but for different fields * * This example currently sends 2 requests: * [{ * query: `query EntitiesQuery($representations: [_Any!]!) { * _entities(representations: $representations) { * __typename * ... on Product { * inStock * } * } * }`, * variables: { * representations: [{ __typename: "Product", upc: "1"}, {__typename:"Product", upc:"2"}] * } * }, { * query: `query EntitiesQuery($representations: [_Any!]!) { * _entities(representations: $representations) { * __typename * ... on Product { * inStock * } * } * }`, * variables: { * representations: [{ __typename: "Product", upc: "1"}, {__typename:"Product", upc:"2"}] * } * }] * * but queries should be merged into one and only one service request should be made * * { * query: `query EntitiesQuery($representations: [_Any!]!) { * _entities(representations: $representations) { * __typename * ... on Product { * inStock * shippingEstimate * } * } * }`, * variables: { * representations: [{ __typename: "Product", upc: "1"}, {__typename:"Product", upc:"2"}] * } * } * * However returning the correct response for the two orignal query is not trival hence it remains a future todo * */ factory.add( `${service}Entity`, async queries => { // context is the same for each query, but unfortunately it's not acessible from onRequest // where we do factory.create(). What is a cleaner option? const context = queries[0].context const result = await getQueryResult({ context, queries, serviceDefinition, service }) return result }, query => query.id ) } typeToServiceMap.Query = null typeToServiceMap.Mutation = null typeToServiceMap.Subscription = null const valueTypes = findValueTypes(allTypes) for (const typeName of valueTypes) { typeToServiceMap[typeName] = null } defineResolvers( schema, typeToServiceMap, serviceMap, typeFieldsToService, factory, lruGatewayResolvers ) const close = async () => { for (const service of Object.values(serviceMap)) { await service.close() } } return { schema, serviceMap, subscriptionMap: new Map(), entityResolversFactory: factory, pollingInterval: gatewayOpts.pollingInterval, serviceFn: typeof gatewayOpts.services === 'function' ? gatewayOpts.services : undefined, async refresh (isRetry) { const failedMandatoryServices = [] if (this._serviceSDLs === undefined) { this._serviceSDLs = serviceSDLs.join(' ') } if (this.serviceFn) { const newServices = await this.serviceFn() const oldServices = Object.keys(serviceMap) const addedServices = newServices.filter(({ name }) => !oldServices.includes(name)) const deletedServices = oldServices.filter(name => !newServices.find(service => service.name === name)) for (const name of deletedServices) { serviceMap[name].close().catch(() => {}) delete serviceMap[name] } await buildServiceMap(serviceMap, addedServices, errorHandler, app.log) } const $refreshResult = await Promise.allSettled( Object.values(serviceMap).map(service => service.refresh().catch(err => { // If non-mandatory service or if retry count has exceeded for mandatory service then throw if (!service.mandatory || !isRetry) { errorHandler(err, service) } // If service is mandatory and retry count has not exceeded then add to service to // failedMandatoryServices so it can be returned for retrying if (service.mandatory) { failedMandatoryServices.push(service) } }) ) ) if (failedMandatoryServices.length > 0) { const serviceNames = failedMandatoryServices.map( service => service.name ) const err = new MER_ERR_SERVICE_RETRY_FAILED(serviceNames.join(', ')) err.failedServices = serviceNames throw err } const rejectedResults = $refreshResult .filter(({ status }) => status === 'rejected') .map(({ reason }) => reason) if (rejectedResults.length) { const err = new MER_ERR_GQL_GATEWAY_REFRESH() err.errors = rejectedResults throw err } const _serviceSDLs = Object.values(serviceMap) .map(service => service.schemaDefinition) .join(' ') if (this._serviceSDLs === _serviceSDLs) { return null } this._serviceSDLs = _serviceSDLs allTypes = [] for (const [service, serviceDefinition] of Object.entries(serviceMap)) { for (const type of serviceDefinition.types) { allTypes.push(serviceDefinition.schema.getTypeMap()[type]) typeToServiceMap[type] = service } for (const [type, fields] of Object.entries( serviceDefinition.extensionTypeMap )) { for (const field of fields) { typeFieldsToService[`${type}-${field}`] = service } } } await Promise.allSettled( Object.values(serviceMap).map(service => service.reconnectSubscription() ) ) const schema = buildFederationSchema(_serviceSDLs, { isGateway: true }) typeToServiceMap.Query = null typeToServiceMap.Mutation = null typeToServiceMap.Subscription = null const valueTypes = findValueTypes(allTypes) for (const typeName of valueTypes) { typeToServiceMap[typeName] = null } defineResolvers(schema, typeToServiceMap, serviceMap, typeFieldsToService, factory, lruGatewayResolvers) this.schema = schema app.graphql.replaceSchema(this.schema) return schema }, close } } module.exports = buildGateway