@mercuriusjs/federation
Version:
A plugin for mercurius federation
446 lines (385 loc) • 13.8 kB
JavaScript
/*
* The MIT License (MIT)
*
* Copyright (c) 2016-2020 Meteor Development Group, Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
const {
GraphQLSchema,
GraphQLObjectType,
Kind,
extendSchema,
parse,
print,
isTypeDefinitionNode,
isTypeExtensionNode,
isObjectType,
isInterfaceType,
isSpecifiedDirective,
defaultTypeResolver,
GraphQLDirective
} = require('graphql')
const { validateSDL } = require('graphql/validation/validate')
const compositionRules = require('./compositionRules')
const mergeDocumentNode = require('./mergeDocumentNode')
const {
MER_ERR_GQL_INVALID_SCHEMA,
MER_ERR_GQL_FEDERATION_INVALID_SCHEMA,
MER_ERR_GQL_FEDERATION_DUPLICATE_DIRECTIVE
} = require('./errors')
const { hasExtensionDirective } = require('./util')
const BASE_FEDERATION_TYPES = `
scalar _Any
scalar _FieldSet
directive @external on FIELD_DEFINITION
directive @requires(fields: _FieldSet!) on FIELD_DEFINITION
directive @provides(fields: _FieldSet!) on FIELD_DEFINITION
directive @key(fields: _FieldSet!) on OBJECT | INTERFACE
directive @extends on OBJECT | INTERFACE
`
const FEDERATION_SCHEMA = `
${BASE_FEDERATION_TYPES}
type _Service {
sdl: String
}
`
const extensionKindToDefinitionKind = {
[Kind.SCALAR_TYPE_EXTENSION]: Kind.SCALAR_TYPE_DEFINITION,
[Kind.OBJECT_TYPE_EXTENSION]: Kind.OBJECT_TYPE_DEFINITION,
[Kind.INTERFACE_TYPE_EXTENSION]: Kind.INTERFACE_TYPE_DEFINITION,
[Kind.UNION_TYPE_EXTENSION]: Kind.UNION_TYPE_DEFINITION,
[Kind.ENUM_TYPE_EXTENSION]: Kind.ENUM_TYPE_DEFINITION,
[Kind.INPUT_OBJECT_TYPE_EXTENSION]: Kind.INPUT_OBJECT_TYPE_DEFINITION
}
const definitionKindToExtensionKind = {
[Kind.SCALAR_TYPE_DEFINITION]: Kind.SCALAR_TYPE_EXTENSION,
[Kind.OBJECT_TYPE_DEFINITION]: Kind.OBJECT_TYPE_EXTENSION,
[Kind.INTERFACE_TYPE_DEFINITION]: Kind.INTERFACE_TYPE_EXTENSION,
[Kind.UNION_TYPE_DEFINITION]: Kind.UNION_TYPE_EXTENSION,
[Kind.ENUM_TYPE_DEFINITION]: Kind.ENUM_TYPE_EXTENSION,
[Kind.INPUT_OBJECT_TYPE_DEFINITION]: Kind.INPUT_OBJECT_TYPE_EXTENSION
}
function removeExternalFields (typeDefinition) {
return {
...typeDefinition,
fields: typeDefinition.fields.filter(
fieldDefinition =>
!fieldDefinition.directives.some(
directive => directive.name.value === 'external'
)
)
}
}
function getStubTypes (schemaDefinitions, isGateway) {
const definitionsMap = {}
const extensionsMap = {}
const extensions = []
const directiveDefinitions = []
for (const definition of schemaDefinitions) {
if (definition.kind === 'SchemaDefinition' || definition.kind === 'SchemaExtension') {
continue
}
const typeName = definition.name.value
const isTypeExtensionByDirective = hasExtensionDirective(definition)
/* istanbul ignore else we are not interested in nodes that does not match if statements */
if (isTypeDefinitionNode(definition) && !isTypeExtensionByDirective) {
definitionsMap[typeName] = definition
} else if (
isTypeExtensionNode(definition) ||
(isTypeDefinitionNode(definition) && isTypeExtensionByDirective)
) {
extensionsMap[typeName] = {
kind: isTypeExtensionByDirective
? definition.kind
: extensionKindToDefinitionKind[definition.kind],
name: definition.name
}
if (isTypeExtensionByDirective) {
definition.kind = definitionKindToExtensionKind[definition.kind]
}
extensions.push(isGateway ? removeExternalFields(definition) : definition)
} else if (definition.kind === Kind.DIRECTIVE_DEFINITION) {
directiveDefinitions.push(definition)
}
}
return {
typeStubs: Object.keys(extensionsMap)
.filter(extensionTypeName => !definitionsMap[extensionTypeName])
.map(extensionTypeName => extensionsMap[extensionTypeName]),
extensions,
definitions: [...directiveDefinitions, ...Object.values(definitionsMap)]
}
}
function gatherDirectives (type) {
let directives = []
/* istanbul ignore else seems that extensionASTNodes is always provided in graphql16+, we are keeping this check for safety */
if (type.extensionASTNodes) {
for (const node of type.extensionASTNodes) {
if (node.directives?.length) {
directives = directives.concat(node.directives)
}
}
}
if (type.astNode && type.astNode.directives) {
directives = directives.concat(type.astNode.directives)
}
return directives
}
function typeIncludesDirective (type, directiveName) {
const directives = gatherDirectives(type)
return directives.some(directive => directive.name.value === directiveName)
}
function addTypeNameToResult (result, typename) {
/* istanbul ignore else when result is null or not an object we return original result */
if (result !== null && typeof result === 'object') {
Object.defineProperty(result, '__typename', {
value: typename
})
}
return result
}
function resolveType (reference, context, info) {
const { __typename } = reference
const type = info.schema.getType(__typename)
if (!type || !(isObjectType(type) || isInterfaceType(type))) {
throw new MER_ERR_GQL_FEDERATION_INVALID_SCHEMA(__typename)
}
if (!isInterfaceType(type)) {
return type
}
const resolveTypeFn = type.resolveType || defaultTypeResolver
const resolveType = resolveTypeFn({
...reference,
__typename: undefined
}, context, info, type)
if (typeof resolveType !== 'string' && typeof resolveType.then === 'function') {
return resolveType.then((resolvedTypename) =>
info.schema.getType(resolvedTypename)
)
}
return info.schema.getType(resolveType)
}
function addEntitiesResolver (schema) {
const entityTypes = Object.values(schema.getTypeMap()).filter(
type => isObjectType(type) && typeIncludesDirective(type, 'key')
)
if (entityTypes.length > 0) {
schema = extendSchema(
schema,
parse(`
union _Entity = ${entityTypes.join(' | ')}
extend type Query {
_entities(representations: [_Any!]!): [_Entity]!
}
`),
{
assumeValid: true
}
)
const query = schema.getType('Query')
const queryFields = query.getFields()
queryFields._entities = {
...queryFields._entities,
resolve: (_source, { representations }, context, info) => {
return representations.map(reference => {
const type = resolveType(reference, context, info)
if (type && typeof type.then === 'function') {
return type.then((resolvedType) => {
const resolveReference = resolvedType.resolveReference ||
function defaultResolveReference () {
return reference
}
const result = resolveReference(reference, {}, context, info)
if (result && typeof result.then === 'function') {
return result.then((x) =>
addTypeNameToResult(x, resolvedType.name))
}
return addTypeNameToResult(result, resolvedType.name)
})
}
const resolveReference = type.resolveReference
? type.resolveReference
: function defaultResolveReference () {
return reference
}
const result = resolveReference(reference, {}, context, info)
if (result && 'then' in result && typeof result.then === 'function') {
return result.then((x) =>
addTypeNameToResult(x, type.name))
}
return addTypeNameToResult(result, type.name)
})
}
}
}
return schema
}
function addServiceResolver (schema, originalSchemaSDL) {
schema = extendSchema(
schema,
parse(`
extend type Query {
_service: _Service!
}
`),
{
assumeValid: true
}
)
const query = schema.getType('Query')
const queryFields = query.getFields()
queryFields._service = {
...queryFields._service,
resolve: () => ({ sdl: typeof originalSchemaSDL === 'string' ? originalSchemaSDL : originalSchemaSDL.loc.source.body })
}
return schema
}
function buildGatewaySchemaDirectives (schema) {
// Remove service federation directives which should not be present in the gateway
const federationDirectiveNamesToRemove = parse(BASE_FEDERATION_TYPES)
.definitions.filter(definition => definition.kind === 'DirectiveDefinition')
// Keep `@requires` directive as it can be used in the gateway schema
.filter(definition => definition.name.value !== 'requires')
.map(definition => definition.name.value)
const directives = schema
.getDirectives()
.filter(
directive => !federationDirectiveNamesToRemove.includes(directive.name)
)
// De-duplicate custom directives
const gatewayDirectives = []
for (const directive of directives) {
if (!isSpecifiedDirective(directive)) {
const directiveToCompareAgainst = gatewayDirectives.find(
gatewayDirective => gatewayDirective.name === directive.name
)
if (directiveToCompareAgainst instanceof GraphQLDirective) {
const directiveSDL = print(directive.astNode)
const duplicatedDirectiveSDL = print(directiveToCompareAgainst.astNode)
if (directiveSDL !== duplicatedDirectiveSDL) {
throw new MER_ERR_GQL_FEDERATION_DUPLICATE_DIRECTIVE(directive.name)
}
} else {
gatewayDirectives.push(directive)
}
} else {
gatewayDirectives.push(directive)
}
}
return gatewayDirectives
}
function buildFederationSchema (schema, { isGateway } = {}) {
if (Array.isArray(schema)) {
schema = mergeDocumentNode(schema)
} else if (typeof schema === 'object') {
schema = mergeDocumentNode([schema])
}
let federationSchema = new GraphQLSchema({
query: undefined
})
federationSchema = extendSchema(
federationSchema,
parse(isGateway ? BASE_FEDERATION_TYPES : FEDERATION_SCHEMA),
{ assumeValidSDL: true }
)
const parsedOriginalSchema = typeof schema === 'string' ? parse(schema) : schema
const { typeStubs, extensions, definitions } = getStubTypes(
parsedOriginalSchema.definitions,
isGateway
)
// before we validate the federationSchema, we want to remove the _service field from the extended query,
// if there is one, as this field should be excluded from the validation to provide broader support
// for different federation implementations => https://github.com/mercurius-js/mercurius/issues/643
const filteredExtensions = extensions.map(extension => {
if (extension.name.value === 'Query') {
extension.fields = extension.fields.filter(
field => field.name.value !== '_service'
)
}
return extension
})
// Add type stubs - only needed for federation
federationSchema = extendSchema(
federationSchema,
{
kind: Kind.DOCUMENT,
definitions: typeStubs
},
{ assumeValidSDL: true }
)
// Add default type definitions
federationSchema = extendSchema(
federationSchema,
{
kind: Kind.DOCUMENT,
definitions
},
{ assumeValidSDL: true }
)
// Add all extensions
const extensionsDocument = {
kind: Kind.DOCUMENT,
definitions: filteredExtensions
}
// instead of relying on extendSchema internal validations
// we run validations in our code so that we can use slightly different rules
// as extendSchema internal rules are meant for regular usage
// and federated schemas have different constraints
const errors = validateSDL(
extensionsDocument,
federationSchema,
compositionRules
)
if (errors.length === 1) {
throw errors[0]
} else if (errors.length > 1) {
const err = new MER_ERR_GQL_INVALID_SCHEMA()
err.errors = errors
throw err
}
federationSchema = extendSchema(federationSchema, extensionsDocument, {
assumeValidSDL: true
})
if (!federationSchema.getType('Query')) {
federationSchema = new GraphQLSchema({
...federationSchema.toConfig(),
query: new GraphQLObjectType({
name: 'Query',
fields: {}
})
})
}
if (!isGateway) {
federationSchema = addEntitiesResolver(federationSchema)
federationSchema = addServiceResolver(federationSchema, schema)
}
const federationSchemaConfig = federationSchema.toConfig()
return new GraphQLSchema({
...federationSchemaConfig,
query: federationSchema.getType('Query'),
mutation: federationSchema.getType('Mutation'),
subscription: federationSchema.getType('Subscription'),
directives: isGateway
? buildGatewaySchemaDirectives(federationSchema)
: federationSchemaConfig.directives
})
}
module.exports = buildFederationSchema