diff --git a/index.js b/index.js index 0e062133..bd70a24e 100644 --- a/index.js +++ b/index.js @@ -1,7 +1,7 @@ 'use strict' const fp = require('fastify-plugin') -let LRU = require("tiny-lru"); +let LRU = require('tiny-lru') const routes = require('./lib/routes') const { compileQuery, isCompiledQuery } = require('graphql-jit') const { Factory } = require('single-user-cache') diff --git a/lib/gateway.js b/lib/gateway.js index 7cde48d0..14591f8e 100644 --- a/lib/gateway.js +++ b/lib/gateway.js @@ -4,7 +4,8 @@ const { getNamedType, isObjectType, isScalarType, - Kind + Kind, + GraphQLError } = require('graphql') const { Factory } = require('single-user-cache') const buildFederatedSchema = require('./federation') @@ -16,7 +17,7 @@ const { createEntityReferenceResolverOperation, kEntityResolvers } = require('./gateway/make-resolver') -const { MER_ERR_GQL_GATEWAY_REFRESH, MER_ERR_GQL_GATEWAY_INIT, MER_ERR_SERVICE_RETRY_FAILED } = require('./errors') +const { MER_ERR_GQL_GATEWAY_REFRESH, MER_ERR_GQL_GATEWAY_INIT, MER_ERR_SERVICE_RETRY_FAILED, MER_ERR_GQL_INVALID_SCHEMA } = require('./errors') const findValueTypes = require('./gateway/find-value-types') const getQueryResult = require('./gateway/get-query-result') @@ -285,6 +286,93 @@ function defineResolvers (schema, typeToServiceMap, serviceMap, typeFieldsToServ } } +function checkForConflictingSchemas (serviceMap, valueTypes) { + const existingTypes = new Map() + const errors = [] + + Object.entries(serviceMap).forEach(([_, value]) => { + const types = value.schema.getTypeMap() + for (const type of Object.values(types)) { + if (valueTypes.includes(type.name)) { + continue + } + + const check = isObjectType(type) && !isDefaultType(type.name) && type.name !== '_Service' + if (!check) { + continue + } + + const isExtension = type.extensionASTNodes.length && type.extensionASTNodes[0].name.value === type.name + + const doesNotExistIsExtension = !existingTypes.has(type.name) && isExtension + const doesNotExistIsNotExtension = !existingTypes.has(type.name) && !isExtension + const existsAndIsNotExtension = existingTypes.has(type.name) && !isExtension + const existsAndHasExtension = existingTypes.has(type.name) && isExtension + + if (doesNotExistIsExtension || existsAndHasExtension) { + continue + } else if (existsAndIsNotExtension) { + const firstType = existingTypes.get(type.name, type) + + const firstTypeFields = firstType.getFields() + const firstTypeFieldNames = Object.keys(firstTypeFields) + + const fields = type.getFields() + const fieldNames = Object.keys(fields) + + // Here we want to ensure that the types + // are the same + let identical = true + + if (fieldNames.length !== firstTypeFieldNames.length) { + identical = false + } else { + for (let i = 0; i < fieldNames.length; i++) { + const fieldName = fieldNames[i] + const currentField = fields[fieldName] + const firstTypeField = firstTypeFields[fieldName] + + const currentFieldType = currentField.astNode.type.type.name.value + const firstTypeFieldType = firstTypeField.astNode.type.type.name.value + // Ensure they are the same type + if (currentFieldType !== firstTypeFieldType) { + identical = false + break + } + + // Ensure they are both same nullability + if (currentField.astNode.type.kind !== firstTypeField.astNode.type.kind) { + identical = false + break + } + } + } + + if (!identical) { + const error = new Error(`Type ${type.name} may only be defined once in the schema`) + const gqlError = new GraphQLError( + error.message, + error.nodes, + error.source, + error.positions, + error.path, + error.originalError, + error.extensions + ) + gqlError.locations = error.locations + gqlError.name = error.name + + errors.push(error) + } + } else if (doesNotExistIsNotExtension) { + existingTypes.set(type.name, type) + } + } + }) + + return errors +} + function defaultErrorHandler (error, service) { if (service.mandatory) { throw error @@ -409,6 +497,14 @@ async function buildGateway (gatewayOpts, app) { typeToServiceMap[typeName] = null } + const conflictErrors = checkForConflictingSchemas(serviceMap, valueTypes) + + if (conflictErrors.length > 0) { + const err = new MER_ERR_GQL_INVALID_SCHEMA() + err.errors = conflictErrors + throw err + } + defineResolvers(schema, typeToServiceMap, serviceMap, typeFieldsToService) return { diff --git a/lib/persistedQueryDefaults.js b/lib/persistedQueryDefaults.js index ac9fe424..0852bdd1 100644 --- a/lib/persistedQueryDefaults.js +++ b/lib/persistedQueryDefaults.js @@ -1,10 +1,10 @@ 'use strict' const crypto = require('crypto') -let LRU = require("tiny-lru") +let LRU = require('tiny-lru') // Required for module bundlers -LRU = typeof LRU === "function" ? LRU : LRU.default +LRU = typeof LRU === 'function' ? LRU : LRU.default const persistedQueryDefaults = { prepared: (persistedQueries) => ({ diff --git a/package.json b/package.json index aed08eea..092a5c3d 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "lint:standard": "standard | snazzy", "lint:typescript": "standard --parser @typescript-eslint/parser --plugin @typescript-eslint/eslint-plugin test/types/*.ts", "typescript": "tsd", - "test": "npm run lint && npm run unit && npm run typescript" + "test": "npm run lint && npm run unit && npm run typescript", + "test:one": "tap test/gateway/value-types.js" }, "repository": { "type": "git", diff --git a/test/gateway/schema.js b/test/gateway/schema.js index 13bdecd7..64ffcf8e 100644 --- a/test/gateway/schema.js +++ b/test/gateway/schema.js @@ -202,7 +202,7 @@ test('It builds the gateway schema correctly', async (t) => { content ...AuthorFragment } - + fragment AuthorFragment on Post { author { ...UserFragment @@ -1211,7 +1211,7 @@ test('Should handle union with InlineFragment', async (t) => { ...ShelveInfos } } - + fragment ShelveInfos on Shelve { id products { @@ -1428,7 +1428,7 @@ test('Should handle interface', async (t) => { ...ShelveInfos } } - + fragment ShelveInfos on Shelve { id products { @@ -2167,7 +2167,7 @@ test('Uses the supplied schema for federation rather than fetching it remotely', content ...AuthorFragment } - + fragment AuthorFragment on Post { author { ...UserFragment @@ -2655,7 +2655,7 @@ test('It builds the gateway schema correctly with two services query extension h content ...AuthorFragment } - + fragment AuthorFragment on Post { author { ...UserFragment @@ -2754,3 +2754,420 @@ test('It builds the gateway schema correctly with two services query extension h } }) }) + +test('Value types should not throw', async (t) => { + const orders = { + o1: { + id: 'o1', + name: 'Order 1' + } + } + + const [orderService, orderServicePort] = await createService(t, ` + extend type Query { + getOrder: Order + } + + type Order { + name: String + } + `, { + Query: { + getOrder: (root, args, context, info) => { + return orders.o1 + } + }, + Order: { + __resolveReference: (order, args, context, info) => { + return orders[order.id] + } + } + }) + + const [conflictingService, conflictingServicePort] = await createService(t, ` + type Order { + name: String + } + `, { + Order: { + __resolveReference: (order, args, context, info) => { + return orders[order.id] + } + } + }) + + const gateway = Fastify() + t.teardown(async () => { + await gateway.close() + await orderService.close() + await conflictingService.close() + }) + + await gateway.register(GQL, { + gateway: { + services: [{ + name: 'order', + url: `http://localhost:${orderServicePort}/graphql`, + rewriteHeaders: (headers) => { + if (headers.authorization) { + return { + authorization: headers.authorization + } + } + } + }, { + name: 'conflicting-order', + url: `http://localhost:${conflictingServicePort}/graphql` + }] + } + }) +}) + +test('Types that extend correctly from another node should not throw an error', async (t) => { + const orders = { + o1: { + id: 'o1', + name: 'Order 1' + } + } + + const [orderService, orderServicePort] = await createService(t, ` + extend type Query { + getOrder: Order + } + + type Order @key(fields: "id") { + id: ID! + name: String + } + `, { + Query: { + getOrder: (root, args, context, info) => { + return orders.o1 + } + }, + Order: { + __resolveReference: (order, args, context, info) => { + return orders[order.id] + } + } + }) + + const [conflictingService, conflictingServicePort] = await createService(t, ` + extend type Order { + surname: String + } + `, { + Order: { + __resolveReference: (order, args, context, info) => { + return orders[order.id] + } + } + }) + + const gateway = Fastify() + t.teardown(async () => { + await gateway.close() + await orderService.close() + await conflictingService.close() + }) + + await gateway.register(GQL, { + gateway: { + services: [{ + name: 'order', + url: `http://localhost:${orderServicePort}/graphql`, + rewriteHeaders: (headers) => { + if (headers.authorization) { + return { + authorization: headers.authorization + } + } + } + }, { + name: 'conflicting-order', + url: `http://localhost:${conflictingServicePort}/graphql` + }] + } + }) +}) + +test('The order of extension for types with the same name should not matter', async (t) => { + const orders = { + o1: { + id: 'o1', + name: 'Order 1' + } + } + + const [orderService, orderServicePort] = await createService(t, ` + extend type Query { + getOrder: Order + } + + extend type Order { + surname: String + } + `, { + Query: { + getOrder: (root, args, context, info) => { + return orders.o1 + } + }, + Order: { + __resolveReference: (order, args, context, info) => { + return orders[order.id] + } + } + }) + + const [conflictingService, conflictingServicePort] = await createService(t, ` + type Order @key(fields: "id") { + id: ID! + name: String + } + `, { + Order: { + __resolveReference: (order, args, context, info) => { + return orders[order.id] + } + } + }) + + const gateway = Fastify() + t.teardown(async () => { + await gateway.close() + await orderService.close() + await conflictingService.close() + }) + + await gateway.register(GQL, { + gateway: { + services: [{ + name: 'order', + url: `http://localhost:${orderServicePort}/graphql`, + rewriteHeaders: (headers) => { + if (headers.authorization) { + return { + authorization: headers.authorization + } + } + } + }, { + name: 'conflicting-order', + url: `http://localhost:${conflictingServicePort}/graphql` + }] + } + }) +}) + +test('Types with duplicate names that are value types should not throw', async (t) => { + const orders = { + o1: { + id: 'o1', + name: 'Order 1' + } + } + + const [orderService, orderServicePort] = await createService(t, ` + extend type Query { + getOrder: Order + } + + type Order @key(fields: "id") { + id: ID! + name: String + } + `, { + Query: { + getOrder: (root, args, context, info) => { + return orders.o1 + } + }, + Order: { + __resolveReference: (order, args, context, info) => { + return orders[order.id] + } + } + }) + + const [conflictingService, conflictingServicePort] = await createService(t, ` + type Order { + id: ID! + name: String + } + `, { + Order: { + __resolveReference: (order, args, context, info) => { + return orders[order.id] + } + } + }) + + const gateway = Fastify() + t.teardown(async () => { + await gateway.close() + await orderService.close() + await conflictingService.close() + }) + + await gateway.register(GQL, { + gateway: { + services: [{ + name: 'order', + url: `http://localhost:${orderServicePort}/graphql`, + rewriteHeaders: (headers) => { + if (headers.authorization) { + return { + authorization: headers.authorization + } + } + } + }, { + name: 'conflicting-order', + url: `http://localhost:${conflictingServicePort}/graphql` + }] + } + }) +}) + +test('Types that duplicate names that are not value types should throw', async (t) => { + const orders = { + o1: { + id: 'o1', + name: 'Order 1' + } + } + + const [orderService, orderServicePort] = await createService(t, ` + extend type Query { + getOrder: Order + } + + type Order @key(fields: "id") { + id: ID! + name: Int + } + `, { + Query: { + getOrder: (root, args, context, info) => { + return orders.o1 + } + }, + Order: { + __resolveReference: (order, args, context, info) => { + return orders[order.id] + } + } + }) + + const [conflictingService, conflictingServicePort] = await createService(t, ` + type Order @key(fields: "id") { + id: ID! + name: String + surname: String + } + `, { + Order: { + __resolveReference: (order, args, context, info) => { + return orders[order.id] + } + } + }) + + const gateway = Fastify() + t.teardown(async () => { + await gateway.close() + await orderService.close() + await conflictingService.close() + }) + + t.rejects(() => gateway.register(GQL, { + gateway: { + services: [{ + name: 'order', + url: `http://localhost:${orderServicePort}/graphql`, + rewriteHeaders: (headers) => { + if (headers.authorization) { + return { + authorization: headers.authorization + } + } + } + }, { + name: 'conflicting-order', + url: `http://localhost:${conflictingServicePort}/graphql` + }] + } + })) +}) + +test('Types that extend a type from another node, but have clashing fields should throw an error', async (t) => { + const orders = { + o1: { + id: 'o1', + name: 'Order 1' + } + } + + const [orderService, orderServicePort] = await createService(t, ` + extend type Query { + getOrder: Order + } + + type Order @key(fields: "id") { + id: ID! + name: String + } + `, { + Query: { + getOrder: (root, args, context, info) => { + return orders.o1 + } + }, + Order: { + __resolveReference: (order, args, context, info) => { + return orders[order.id] + } + } + }) + + const [conflictingService, conflictingServicePort] = await createService(t, ` + extend type Order { + name: String + } + `, { + Order: { + __resolveReference: (order, args, context, info) => { + return orders[order.id] + } + } + }) + + const gateway = Fastify() + t.teardown(async () => { + await gateway.close() + await orderService.close() + await conflictingService.close() + }) + + t.rejects(() => gateway.register(GQL, { + gateway: { + services: [{ + name: 'order', + url: `http://localhost:${orderServicePort}/graphql`, + rewriteHeaders: (headers) => { + if (headers.authorization) { + return { + authorization: headers.authorization + } + } + } + }, { + name: 'conflicting-order', + url: `http://localhost:${conflictingServicePort}/graphql` + }] + } + }) + ) +})