├── .circleci └── config.yml ├── .gitignore ├── LICENSE ├── README.md ├── helpers ├── buildContextsPerService.js ├── buildContextsPerService.test.js └── executeQueryPlan.js ├── index.js ├── manual-releases.md ├── package-lock.json ├── package.json └── tests ├── differentContexts.test.js ├── multipleServices.test.js ├── schemaWithSchemaQueryType.test.js └── singleService.test.js /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: 'circleci/node:latest' 6 | steps: 7 | - checkout 8 | - run: 9 | name: install 10 | command: npm install 11 | - run: 12 | name: test 13 | command: npm test 14 | - run: 15 | name: release 16 | command: npm run semantic-release || true 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | .idea 63 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 TheBrain Software House 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # federation-testing-tool 2 | Test your Apollo GraphQL Gateway / Federation micro services. With this package you don't have to worry about the whole complexity that comes with joining the GraphQL federated micro services and preparing them for testing. 3 | 4 | Install it with 5 | ```bash 6 | npm install --save-dev federation-testing-tool 7 | ``` 8 | 9 | Example Usage, for the [Federation Demo From Apollo](https://github.com/apollographql/federation-demo). 10 | 11 | ![data flow](https://xolvio.s3.amazonaws.com/376674669.png) 12 | 13 | Test services in isolation: 14 | ```javascript 15 | const typeDefs = gql` 16 | extend type Product @key(fields: "upc") { 17 | upc: String! @external 18 | weight: Int @external 19 | price: Int @external 20 | inStock: Boolean 21 | shippingEstimate: Int @requires(fields: "price weight") 22 | } 23 | `; 24 | 25 | let inventory = [ 26 | { upc: "1", inStock: true }, 27 | { upc: "2", inStock: false }, 28 | { upc: "3", inStock: true } 29 | ]; 30 | 31 | const resolvers = { 32 | Product: { 33 | __resolveReference(object) { 34 | return { 35 | ...object, 36 | ...inventory.find(product => product.upc === object.upc) 37 | }; 38 | }, 39 | shippingEstimate: object => { 40 | if (object.price > 1000) return 0; 41 | return object.weight * 0.5; 42 | } 43 | } 44 | }; 45 | 46 | const service = { 47 | typeDefs, 48 | resolvers 49 | }; 50 | 51 | describe("Based on the data from the external service", () => { 52 | const query = gql` 53 | { 54 | _getProduct { 55 | inStock 56 | shippingEstimate 57 | } 58 | } 59 | `; 60 | 61 | it("should set the shippingEstimate at 0 for an expensive item and retrieve inStock", async () => { 62 | const mocks = { 63 | Product: () => ({ 64 | upc: "1", 65 | weight: 10, 66 | price: 14000, 67 | }) 68 | }; 69 | 70 | const result = await executeGraphql({ query, mocks, service }); 71 | 72 | expect(result.data._getProduct.shippingEstimate).toEqual(0); 73 | expect(result.data._getProduct).toEqual({ 74 | inStock: true, 75 | shippingEstimate: 0 76 | }); 77 | }); 78 | 79 | it("should calculate the shipping estimate for cheap item", async () => { 80 | const mocks = { 81 | Product: () => ({ 82 | upc: "1", 83 | weight: 10, 84 | price: 10, 85 | }) 86 | }; 87 | 88 | const result = await executeGraphql({ query, mocks, service }); 89 | expect(result.data._getProduct.shippingEstimate).toEqual(5); 90 | }); 91 | }); 92 | ``` 93 | 94 | 95 | Test services together: 96 | ```javascript 97 | const { executeGraphql } = require("federation-testing-tool"); 98 | const { gql } = require("apollo-server"); 99 | 100 | const { typeDefs } = require("./schema"); 101 | const { resolvers } = require("./resolvers"); 102 | 103 | const { typeDefs: typeDefsProducts } = require("../products/schema"); 104 | 105 | const services = [ 106 | { inventory: { typeDefs, resolvers } }, 107 | { 108 | products: { 109 | typeDefs: typeDefsProducts 110 | } 111 | } 112 | ]; 113 | 114 | describe("Based on the data from the external service", () => { 115 | const query = gql` 116 | { 117 | topProducts { 118 | name 119 | inStock 120 | shippingEstimate 121 | } 122 | } 123 | `; 124 | 125 | it("should calculate the shipping estimate", async () => { 126 | const mocks = { 127 | Product: () => ({ 128 | upc: "1", 129 | name: "Table", 130 | weight: 10, 131 | price: 10, 132 | elo: "", 133 | __typename: "Product" 134 | }) 135 | }; 136 | 137 | const result = await executeGraphql({ query, mocks, services }); 138 | expect(result.data.topProducts[0]).toEqual({ 139 | name: "Table", 140 | inStock: true, 141 | shippingEstimate: 5 142 | }); 143 | }); 144 | it("should set the shippingEstimate at 0 for an expensive item", async () => { 145 | const mocks = { 146 | Product: () => ({ 147 | upc: "1", 148 | name: "Table", 149 | weight: 10, 150 | price: 14000, 151 | elo: "", 152 | __typename: "Product" 153 | }) 154 | }; 155 | 156 | const result = await executeGraphql({ query, mocks, services }); 157 | expect(result.data.topProducts[0]).toEqual({ 158 | name: "Table", 159 | inStock: true, 160 | shippingEstimate: 0 161 | }); 162 | }); 163 | }); 164 | 165 | ``` 166 | -------------------------------------------------------------------------------- /helpers/buildContextsPerService.js: -------------------------------------------------------------------------------- 1 | exports.buildContextsPerService = (servicesWithContext = []) => { 2 | return servicesWithContext.reduce((total, current) => { 3 | const serviceName = Object.keys(current)[0]; 4 | if (current[serviceName].context) { 5 | return { ...total, [serviceName]: current[serviceName].context }; 6 | } 7 | return total; 8 | }, {}); 9 | }; 10 | -------------------------------------------------------------------------------- /helpers/buildContextsPerService.test.js: -------------------------------------------------------------------------------- 1 | const { buildContextsPerService } = require("./buildContextsPerService"); 2 | 3 | test("works when with contexts", () => { 4 | const servicesWithContext = [ 5 | { 6 | inventory: { 7 | context: { inventoryContext: true } 8 | } 9 | }, 10 | { 11 | products: { 12 | context: { productsContext: true } 13 | } 14 | }, 15 | { otherServiceWithNoContext: {} } 16 | ]; 17 | const expectedContextsPerService = { 18 | inventory: servicesWithContext[0].inventory.context, 19 | products: servicesWithContext[1].products.context 20 | }; 21 | 22 | expect(buildContextsPerService(servicesWithContext)).toEqual( 23 | expectedContextsPerService 24 | ); 25 | }); 26 | 27 | test("works without contexts", () => { 28 | const servicesWithContext = [ 29 | { 30 | inventory: {} 31 | }, 32 | { 33 | products: {} 34 | }, 35 | { otherServiceWithNoContext: {} } 36 | ]; 37 | 38 | expect(buildContextsPerService(servicesWithContext)).toEqual({}); 39 | }); 40 | -------------------------------------------------------------------------------- /helpers/executeQueryPlan.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | const apollo_server_env_1 = require("apollo-server-env"); 4 | const graphql_1 = require("graphql"); 5 | const apollo_engine_reporting_protobuf_1 = require("apollo-engine-reporting-protobuf"); 6 | const federation_1 = require("@apollo/federation"); 7 | const deepMerge_1 = require("@apollo/gateway/dist/utilities/deepMerge"); 8 | const graphql_2 = require("@apollo/gateway/dist/utilities/graphql"); 9 | async function executeQueryPlan(queryPlan, serviceMap, requestContext, operationContext) { 10 | const errors = []; 11 | const context = { 12 | queryPlan, 13 | operationContext, 14 | serviceMap, 15 | requestContext, 16 | errors, 17 | }; 18 | let data = Object.create(null); 19 | const captureTraces = !!(requestContext.metrics && requestContext.metrics.captureTraces); 20 | if (queryPlan.node) { 21 | const traceNode = await executeNode(context, queryPlan.node, data, [], captureTraces); 22 | if (captureTraces) { 23 | requestContext.metrics.queryPlanTrace = traceNode; 24 | } 25 | } 26 | try { 27 | ({ data } = await graphql_1.execute({ 28 | schema: operationContext.schema, 29 | document: { 30 | kind: graphql_1.Kind.DOCUMENT, 31 | definitions: [ 32 | operationContext.operation, 33 | ...Object.values(operationContext.fragments), 34 | ], 35 | }, 36 | rootValue: data, 37 | variableValues: requestContext.request.variables, 38 | fieldResolver: exports.defaultFieldResolverWithAliasSupport, 39 | })); 40 | } 41 | catch (error) { 42 | throw new Error("instead of catching...") 43 | return { errors: [error] }; 44 | } 45 | return errors.length === 0 ? { data } : { errors, data }; 46 | } 47 | exports.executeQueryPlan = executeQueryPlan; 48 | async function executeNode(context, node, results, path, captureTraces) { 49 | if (!results) { 50 | return new apollo_engine_reporting_protobuf_1.Trace.QueryPlanNode(); 51 | } 52 | switch (node.kind) { 53 | case 'Sequence': { 54 | const traceNode = new apollo_engine_reporting_protobuf_1.Trace.QueryPlanNode.SequenceNode(); 55 | for (const childNode of node.nodes) { 56 | const childTraceNode = await executeNode(context, childNode, results, path, captureTraces); 57 | traceNode.nodes.push(childTraceNode); 58 | } 59 | return new apollo_engine_reporting_protobuf_1.Trace.QueryPlanNode({ sequence: traceNode }); 60 | } 61 | case 'Parallel': { 62 | const childTraceNodes = await Promise.all(node.nodes.map(async (childNode) => executeNode(context, childNode, results, path, captureTraces))); 63 | return new apollo_engine_reporting_protobuf_1.Trace.QueryPlanNode({ 64 | parallel: new apollo_engine_reporting_protobuf_1.Trace.QueryPlanNode.ParallelNode({ 65 | nodes: childTraceNodes, 66 | }), 67 | }); 68 | } 69 | case 'Flatten': { 70 | return new apollo_engine_reporting_protobuf_1.Trace.QueryPlanNode({ 71 | flatten: new apollo_engine_reporting_protobuf_1.Trace.QueryPlanNode.FlattenNode({ 72 | responsePath: node.path.map(id => new apollo_engine_reporting_protobuf_1.Trace.QueryPlanNode.ResponsePathElement(typeof id === 'string' ? { fieldName: id } : { index: id })), 73 | node: await executeNode(context, node.node, flattenResultsAtPath(results, node.path), [...path, ...node.path], captureTraces), 74 | }), 75 | }); 76 | } 77 | case 'Fetch': { 78 | const traceNode = new apollo_engine_reporting_protobuf_1.Trace.QueryPlanNode.FetchNode({ 79 | serviceName: node.serviceName, 80 | }); 81 | try { 82 | await executeFetch(context, node, results, path, captureTraces ? traceNode : null); 83 | } 84 | catch (error) { 85 | context.errors.push(error); 86 | } 87 | return new apollo_engine_reporting_protobuf_1.Trace.QueryPlanNode({ fetch: traceNode }); 88 | } 89 | } 90 | } 91 | async function executeFetch(context, fetch, results, _path, traceNode) { 92 | const logger = context.requestContext.logger || console; 93 | const service = context.serviceMap[fetch.serviceName]; 94 | if (!service) { 95 | throw new Error(`Couldn't find service with name "${fetch.serviceName}"`); 96 | } 97 | const entities = Array.isArray(results) ? results : [results]; 98 | if (entities.length < 1) 99 | return; 100 | let variables = Object.create(null); 101 | if (fetch.variableUsages) { 102 | for (const variableName of Object.keys(fetch.variableUsages)) { 103 | const providedVariables = context.requestContext.request.variables; 104 | if (providedVariables && 105 | typeof providedVariables[variableName] !== 'undefined') { 106 | variables[variableName] = providedVariables[variableName]; 107 | } 108 | } 109 | } 110 | if (!fetch.requires) { 111 | const dataReceivedFromService = await sendOperation(context, fetch.source, variables); 112 | for (const entity of entities) { 113 | deepMerge_1.deepMerge(entity, dataReceivedFromService); 114 | } 115 | } 116 | else { 117 | const requires = fetch.requires; 118 | const representations = []; 119 | const representationToEntity = []; 120 | entities.forEach((entity, index) => { 121 | const representation = executeSelectionSet(entity, requires); 122 | if (representation && representation[graphql_1.TypeNameMetaFieldDef.name]) { 123 | representations.push(representation); 124 | representationToEntity.push(index); 125 | } 126 | }); 127 | if ('representations' in variables) { 128 | throw new Error(`Variables cannot contain key "representations"`); 129 | } 130 | const dataReceivedFromService = await sendOperation(context, fetch.source, { ...variables, representations }); 131 | if (!dataReceivedFromService) { 132 | return; 133 | } 134 | if (!(dataReceivedFromService._entities && 135 | Array.isArray(dataReceivedFromService._entities))) { 136 | throw new Error(`Expected "data._entities" in response to be an array`); 137 | } 138 | const receivedEntities = dataReceivedFromService._entities; 139 | if (receivedEntities.length !== representations.length) { 140 | throw new Error(`Expected "data._entities" to contain ${representations.length} elements`); 141 | } 142 | for (let i = 0; i < entities.length; i++) { 143 | deepMerge_1.deepMerge(entities[representationToEntity[i]], receivedEntities[i]); 144 | } 145 | } 146 | async function sendOperation(context, source, variables) { 147 | var _a, _b; 148 | let http; 149 | if (traceNode) { 150 | http = { 151 | headers: new apollo_server_env_1.Headers({ 'apollo-federation-include-trace': 'ftv1' }), 152 | }; 153 | if (context.requestContext.metrics && 154 | context.requestContext.metrics.startHrTime) { 155 | traceNode.sentTimeOffset = durationHrTimeToNanos(process.hrtime(context.requestContext.metrics.startHrTime)); 156 | } 157 | traceNode.sentTime = dateToProtoTimestamp(new Date()); 158 | } 159 | const response = await service.process({ 160 | request: { 161 | query: source, 162 | variables, 163 | http, 164 | }, 165 | context: context.requestContext.context, 166 | }); 167 | if (response.errors) { 168 | // response.errors.forEach(e => { 169 | // if (e.stack) { 170 | // console.log("e.stack", e.stack) 171 | // } 172 | // }) 173 | const errors = response.errors.map(error => downstreamServiceError(error.message, fetch.serviceName, source, variables, error.extensions, error.path)); 174 | context.errors.push(...response.errors); 175 | } 176 | if (traceNode) { 177 | traceNode.receivedTime = dateToProtoTimestamp(new Date()); 178 | if (response.extensions && response.extensions.ftv1) { 179 | const traceBase64 = response.extensions.ftv1; 180 | let traceBuffer; 181 | let traceParsingFailed = false; 182 | try { 183 | traceBuffer = Buffer.from(traceBase64, 'base64'); 184 | } 185 | catch (err) { 186 | logger.error(`error decoding base64 for federated trace from ${fetch.serviceName}: ${err}`); 187 | traceParsingFailed = true; 188 | } 189 | if (traceBuffer) { 190 | try { 191 | const trace = apollo_engine_reporting_protobuf_1.Trace.decode(traceBuffer); 192 | traceNode.trace = trace; 193 | } 194 | catch (err) { 195 | logger.error(`error decoding protobuf for federated trace from ${fetch.serviceName}: ${err}`); 196 | traceParsingFailed = true; 197 | } 198 | } 199 | if (traceNode.trace) { 200 | const rootTypeName = federation_1.defaultRootOperationNameLookup[context.operationContext.operation.operation]; 201 | (_b = (_a = traceNode.trace.root) === null || _a === void 0 ? void 0 : _a.child) === null || _b === void 0 ? void 0 : _b.forEach((child) => { 202 | child.parentType = rootTypeName; 203 | }); 204 | } 205 | traceNode.traceParsingFailed = traceParsingFailed; 206 | } 207 | } 208 | return response.data; 209 | } 210 | } 211 | function executeSelectionSet(source, selectionSet) { 212 | const result = Object.create(null); 213 | for (const selection of selectionSet.selections) { 214 | switch (selection.kind) { 215 | case graphql_1.Kind.FIELD: 216 | const responseName = graphql_2.getResponseName(selection); 217 | const selectionSet = selection.selectionSet; 218 | if (source === null) { 219 | result[responseName] = null; 220 | break; 221 | } 222 | if (typeof source[responseName] === 'undefined') { 223 | throw new Error(`Field "${responseName}" was not found in response.`); 224 | } 225 | if (Array.isArray(source[responseName])) { 226 | result[responseName] = source[responseName].map((value) => selectionSet ? executeSelectionSet(value, selectionSet) : value); 227 | } 228 | else if (selectionSet) { 229 | result[responseName] = executeSelectionSet(source[responseName], selectionSet); 230 | } 231 | else { 232 | result[responseName] = source[responseName]; 233 | } 234 | break; 235 | case graphql_1.Kind.INLINE_FRAGMENT: 236 | if (!selection.typeCondition) 237 | continue; 238 | const typename = source && source['__typename']; 239 | if (!typename) 240 | continue; 241 | if (typename === selection.typeCondition.name.value) { 242 | deepMerge_1.deepMerge(result, executeSelectionSet(source, selection.selectionSet)); 243 | } 244 | break; 245 | } 246 | } 247 | return result; 248 | } 249 | function flattenResultsAtPath(value, path) { 250 | if (path.length === 0) 251 | return value; 252 | if (value === undefined || value === null) 253 | return value; 254 | const [current, ...rest] = path; 255 | if (current === '@') { 256 | return value.flatMap((element) => flattenResultsAtPath(element, rest)); 257 | } 258 | else { 259 | return flattenResultsAtPath(value[current], rest); 260 | } 261 | } 262 | function downstreamServiceError(message, serviceName, query, variables, extensions, path) { 263 | if (!message) { 264 | message = `Error while fetching subquery from service "${serviceName}"`; 265 | } 266 | extensions = { 267 | code: 'DOWNSTREAM_SERVICE_ERROR', 268 | serviceName, 269 | query, 270 | variables, 271 | ...extensions, 272 | }; 273 | return new graphql_1.GraphQLError(message, undefined, undefined, undefined, path, undefined, extensions); 274 | } 275 | exports.defaultFieldResolverWithAliasSupport = function (source, args, contextValue, info) { 276 | if (typeof source === 'object' || typeof source === 'function') { 277 | const property = source[info.path.key]; 278 | if (typeof property === 'function') { 279 | return source[info.fieldName](args, contextValue, info); 280 | } 281 | return property; 282 | } 283 | }; 284 | function durationHrTimeToNanos(hrtime) { 285 | return hrtime[0] * 1e9 + hrtime[1]; 286 | } 287 | function dateToProtoTimestamp(date) { 288 | const totalMillis = +date; 289 | const millis = totalMillis % 1000; 290 | return new apollo_engine_reporting_protobuf_1.google.protobuf.Timestamp({ 291 | seconds: (totalMillis - millis) / 1000, 292 | nanos: millis * 1e6, 293 | }); 294 | } 295 | //# sourceMappingURL=executeQueryPlan.js.map 296 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const stackTrace = require("stack-trace"); 2 | const { 3 | LocalGraphQLDataSource, 4 | buildOperationContext, 5 | buildQueryPlan, 6 | } = require("@apollo/gateway"); 7 | const { addMocksToSchema, addResolversToSchema } = require("graphql-tools"); 8 | const { print } = require("graphql"); 9 | const { 10 | buildFederatedSchema, 11 | composeAndValidate 12 | } = require("@apollo/federation"); 13 | const clone = require("clone"); 14 | const gql = require("graphql-tag"); 15 | const cloneDeepWith = require("lodash.clonedeepwith"); 16 | const isFunction = require("lodash.isfunction"); 17 | const { executeQueryPlan } = require("./helpers/executeQueryPlan") 18 | 19 | const { 20 | buildContextsPerService 21 | } = require("./helpers/buildContextsPerService"); 22 | 23 | function buildLocalService(modules) { 24 | const schema = buildFederatedSchema(modules); 25 | return new LocalGraphQLDataSource(schema); 26 | } 27 | 28 | const isEmpty = obj => 29 | !obj || (Object.entries(obj).length === 0 && obj.constructor === Object); 30 | 31 | function buildRequestContext(variables, singleContext, contextsPerService) { 32 | let context; 33 | 34 | if (isEmpty(contextsPerService)) { 35 | context = singleContext; 36 | } else { 37 | context = new Proxy( 38 | {}, 39 | { 40 | get: (obj, prop) => { 41 | const trace = stackTrace.get(); 42 | if (trace[1].getFunction() && trace[1].getFunction().__service__) { 43 | return contextsPerService[trace[1].getFunction().__service__][prop]; 44 | } 45 | return prop in obj ? obj[prop] : null; 46 | } 47 | } 48 | ); 49 | } 50 | 51 | return { 52 | cache: undefined, 53 | context, 54 | request: { 55 | variables 56 | } 57 | }; 58 | } 59 | 60 | function prepareProviderService(service) { 61 | let allTypeNames = []; 62 | const typeDefsForMockedService = clone(service.typeDefs); 63 | 64 | typeDefsForMockedService.definitions = typeDefsForMockedService.definitions 65 | .filter( 66 | d => d.name && d.name.value !== "Query" && d.name.value !== "Mutation" 67 | ) 68 | .filter(d => d.kind === "ObjectTypeExtension"); 69 | 70 | typeDefsForMockedService.definitions.forEach(def => { 71 | def.kind = "ObjectTypeDefinition"; 72 | allTypeNames.push(def.name.value); 73 | 74 | def.fields = def.fields.filter(f => 75 | f.directives.find(d => d.name.value === "external") 76 | ); 77 | def.fields.forEach(f => { 78 | f.directives = f.directives.filter(d => d.name.value !== "external"); 79 | }); 80 | }); 81 | 82 | if (allTypeNames.length) { 83 | const typesQueries = allTypeNames.map(n => `_get${n}: ${n}`).join("\n"); 84 | const newTypeDefString = ` 85 | extend type Query { 86 | ${typesQueries} 87 | } 88 | ${print(typeDefsForMockedService)} 89 | `; 90 | 91 | // I'm doing it like this because otherwise IDE screams at me for an incorrect GraphQL string 92 | let newTypeDefs = gql` 93 | ${newTypeDefString} 94 | `; 95 | 96 | return { 97 | __provider: { 98 | typeDefs: newTypeDefs 99 | } 100 | }; 101 | } 102 | return undefined; 103 | } 104 | 105 | const setupSchema = serviceOrServices => { 106 | let services; 107 | if (!serviceOrServices.length) { 108 | services = [ 109 | { 110 | serviceUnderTest: { 111 | resolvers: serviceOrServices.resolvers, 112 | typeDefs: serviceOrServices.typeDefs, 113 | addMocks: serviceOrServices.addMocks 114 | } 115 | } 116 | ]; 117 | const providerService = prepareProviderService(serviceOrServices); 118 | if (providerService) { 119 | services.push(providerService); 120 | } 121 | } else { 122 | services = serviceOrServices; 123 | } 124 | 125 | let serviceMap = {}; 126 | services.forEach(service => { 127 | let serviceName = Object.keys(service)[0]; 128 | if (!service[serviceName].resolvers) { 129 | service[serviceName].addMocks = true; 130 | } 131 | serviceMap[serviceName] = buildLocalService([service[serviceName]]); 132 | serviceMap[serviceName].__addMocks__ = service[serviceName].addMocks; 133 | }); 134 | 135 | let mapForComposeServices = Object.entries(serviceMap).map( 136 | ([serviceName, service]) => ({ 137 | name: serviceName, 138 | typeDefs: service.sdl() 139 | }) 140 | ); 141 | 142 | let composed = composeAndValidate(mapForComposeServices); 143 | 144 | if (composed.errors && composed.errors.length > 0) { 145 | throw new Error(JSON.stringify(composed.errors)); 146 | } 147 | return { schema: composed.schema, serviceMap }; 148 | }; 149 | 150 | function setupMocks(serviceMap, mocks) { 151 | Object.values(serviceMap).forEach(service => { 152 | 153 | let resolvers = {}; 154 | if (service.__addMocks__) { 155 | 156 | Object.entries(mocks).forEach(([type, value]) => { 157 | resolvers[type] = { 158 | __resolveReference() { 159 | return value(); 160 | } 161 | }; 162 | }); 163 | service.schema = addResolversToSchema(service.schema, resolvers); 164 | service.schema = addMocksToSchema({ 165 | schema: service.schema, 166 | preserveResolvers: true, 167 | mocks 168 | }); 169 | 170 | } 171 | }); 172 | } 173 | 174 | function execute( 175 | schema, 176 | query, 177 | mutation, 178 | serviceMap, 179 | variables, 180 | context, 181 | contextsPerService 182 | ) { 183 | const operationContext = buildOperationContext(schema, query || mutation); 184 | const queryPlan = buildQueryPlan(operationContext); 185 | 186 | return executeQueryPlan( 187 | queryPlan, 188 | serviceMap, 189 | buildRequestContext(variables, context, contextsPerService), 190 | operationContext 191 | ); 192 | } 193 | 194 | function validateArguments( 195 | services, 196 | service, 197 | schema, 198 | serviceMap, 199 | query, 200 | mutation 201 | ) { 202 | if (!(services || service)) { 203 | if (!schema) { 204 | throw new Error( 205 | "You need to pass either services array to prepare your schema, or the schema itself, generated by the setupSchema function" 206 | ); 207 | } 208 | if (!serviceMap) { 209 | throw new Error( 210 | "You need to pass the serviceMap generated by the setupSchema function along with your schema" 211 | ); 212 | } 213 | } 214 | if (!(query || mutation)) { 215 | throw new Error("Make sure you pass a query or a mutation"); 216 | } 217 | } 218 | 219 | const executeGraphql = async ({ 220 | query, 221 | mutation, 222 | variables, 223 | context, 224 | services, 225 | mocks = {}, 226 | schema, 227 | serviceMap, 228 | service 229 | }) => { 230 | validateArguments(services, service, schema, serviceMap, query, mutation); 231 | 232 | if (services || service) { 233 | ({ serviceMap, schema } = setupSchema(services || service)); 234 | } 235 | 236 | setupMocks(serviceMap, mocks); 237 | 238 | const contextsPerService = services 239 | ? buildContextsPerService(services) 240 | : null; 241 | 242 | if (services) { 243 | addServiceInformationToResolvers(services); 244 | } 245 | 246 | 247 | const prepareError = new Error(""); 248 | const splitLines = prepareError.stack.split("\n").slice(2); 249 | let result; 250 | try { 251 | result = await execute( 252 | schema, 253 | query, 254 | mutation, 255 | serviceMap, 256 | variables, 257 | context, 258 | contextsPerService 259 | ); 260 | if (result.errors) { 261 | if (result.errors.length === 1) { 262 | result.errors[0].message = result.errors[0].message + `, path: ${result.errors[0].path}` 263 | throw result.errors[0]; 264 | } else { 265 | throw new Error(result.errors.map((e) => `${e.message}, path: ${e.path}`).join(",")); 266 | } 267 | } 268 | } catch (e) { 269 | const smallStack = e.stack.split("\n"); 270 | e.stack = [...smallStack, ...splitLines] 271 | .filter((l) => l.indexOf("node_modules") === -1) 272 | .join("\n"); 273 | e.message = e.message.split("\n")[0]; 274 | throw e; 275 | } 276 | return result; 277 | }; 278 | 279 | function addServiceInformationToResolvers(services) { 280 | services.forEach(s => { 281 | const serviceName = Object.keys(s)[0]; 282 | if (s[serviceName].resolvers) { 283 | s[serviceName].resolvers = cloneDeepWith(s[serviceName].resolvers, el => { 284 | if (isFunction(el)) { 285 | el.__service__ = serviceName; 286 | return el; 287 | } 288 | }); 289 | } 290 | }); 291 | } 292 | 293 | module.exports = { 294 | setupSchema, 295 | executeGraphql 296 | }; 297 | -------------------------------------------------------------------------------- /manual-releases.md: -------------------------------------------------------------------------------- 1 | # manual-releases 2 | 3 | This project has an automated release set up. So things are only released when 4 | there are useful changes in the code that justify a release. But sometimes 5 | things get messed up one way or another and we need to trigger the release 6 | ourselves. When this happens, simply bump the number below and commit that with 7 | the following commit message based on your needs: 8 | 9 | **Major** 10 | 11 | ``` 12 | fix(release): manually release a major version 13 | 14 | There was an issue with a major release, so this manual-releases.md 15 | change is to release a new major version. 16 | 17 | Reference: # 18 | 19 | BREAKING CHANGE: 20 | ``` 21 | 22 | **Minor** 23 | 24 | ``` 25 | feat(release): manually release a minor version 26 | 27 | There was an issue with a minor release, so this manual-releases.md 28 | change is to release a new minor version. 29 | 30 | Reference: # 31 | ``` 32 | 33 | **Patch** 34 | 35 | ``` 36 | fix(release): manually release a patch version 37 | 38 | There was an issue with a patch release, so this manual-releases.md 39 | change is to release a new patch version. 40 | 41 | Reference: # 42 | ``` 43 | 44 | The number of times we've had to do a manual release is: 1 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "federation-testing-tool", 3 | "version": "0.0.0-development", 4 | "description": "Test your Apollo GraphQL Gateway / Federation micro services.", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "jest", 8 | "semantic-release": "semantic-release" 9 | }, 10 | "peerDependencies": { 11 | "@apollo/gateway": "0.x", 12 | "@apollo/federation": "0.x", 13 | "graphql-tools": "6.x" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/xolvio/federation-testing-tool.git" 18 | }, 19 | "keywords": [ 20 | "GraphQL", 21 | "testing", 22 | "Apollo", 23 | "Gateway" 24 | ], 25 | "author": "Lukasz Gandecki ", 26 | "license": "MIT", 27 | "bugs": { 28 | "url": "https://github.com/xolvio/federation-testing-tool/issues" 29 | }, 30 | "homepage": "https://github.com/xolvio/federation-testing-tool#readme", 31 | "devDependencies": { 32 | "@apollo/federation": "0.16.0", 33 | "@apollo/gateway": "0.16.0", 34 | "apollo-engine-reporting-protobuf": "^0.5.0", 35 | "apollo-server-env": "^2.4.3", 36 | "graphql": "^14.3.1", 37 | "graphql-tag": "^2.10.1", 38 | "graphql-tools": "^6.0.9", 39 | "jest": "^24.8.0", 40 | "semantic-release": "^15.13.16" 41 | }, 42 | "dependencies": { 43 | "clone": "^2.1.2", 44 | "lodash.clonedeepwith": "^4.5.0", 45 | "lodash.isfunction": "^3.0.9", 46 | "stack-trace": "0.0.10" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /tests/differentContexts.test.js: -------------------------------------------------------------------------------- 1 | const gql = require("graphql-tag"); 2 | const { executeGraphql } = require("../"); 3 | 4 | const firstTypeDefs = gql` 5 | extend type Query { 6 | getFirstString: String 7 | } 8 | `; 9 | 10 | const secondTypeDefs = gql` 11 | extend type Query { 12 | getSecondString: String 13 | } 14 | `; 15 | 16 | const firstResolvers = { 17 | Query: { 18 | getFirstString: async (_, args, context) => { 19 | const value = await context.getSomeString(); 20 | return value; 21 | } 22 | } 23 | }; 24 | 25 | const secondResolvers = { 26 | Query: { 27 | getSecondString: async (_, args, context, info) => { 28 | return context.getSomeString(); 29 | } 30 | } 31 | }; 32 | 33 | const wait = () => new Promise(resolve => setTimeout(() => resolve(), 0)); 34 | 35 | const firstContext = { 36 | getSomeString: async () => { 37 | await wait(); 38 | return "first string"; 39 | } 40 | }; 41 | 42 | const secondContext = { 43 | getSomeString: function() { 44 | return "second string"; 45 | } 46 | }; 47 | 48 | const services = [ 49 | { 50 | inventory: { 51 | typeDefs: firstTypeDefs, 52 | resolvers: firstResolvers 53 | } 54 | }, 55 | { 56 | products: { 57 | typeDefs: secondTypeDefs, 58 | resolvers: secondResolvers 59 | } 60 | } 61 | ]; 62 | 63 | test("first string", async () => { 64 | const query = gql` 65 | query { 66 | getFirstString 67 | } 68 | `; 69 | const result = await executeGraphql({ 70 | services, 71 | query, 72 | context: firstContext 73 | }); 74 | 75 | expect(result.data.getFirstString).toEqual("first string"); 76 | }); 77 | 78 | test("second string", async () => { 79 | const query = gql` 80 | query { 81 | getSecondString 82 | } 83 | `; 84 | const result = await executeGraphql({ 85 | services, 86 | query, 87 | context: secondContext 88 | }); 89 | 90 | expect(result.data.getSecondString).toEqual("second string"); 91 | }); 92 | 93 | const servicesWithContext = [ 94 | { 95 | inventory: { 96 | typeDefs: firstTypeDefs, 97 | resolvers: firstResolvers, 98 | context: firstContext 99 | } 100 | }, 101 | { 102 | products: { 103 | typeDefs: secondTypeDefs, 104 | resolvers: secondResolvers, 105 | context: secondContext 106 | } 107 | } 108 | ]; 109 | 110 | test("first string with merged context", async () => { 111 | const query = gql` 112 | query { 113 | getFirstString 114 | } 115 | `; 116 | const result = await executeGraphql({ services: servicesWithContext, query }); 117 | expect(result.data.getFirstString).toEqual("first string"); 118 | }); 119 | 120 | test("second string with merged context", async () => { 121 | const query = gql` 122 | query { 123 | getSecondString 124 | } 125 | `; 126 | const result = await executeGraphql({ services: servicesWithContext, query }); 127 | 128 | expect(result.data.getSecondString).toEqual("second string"); 129 | }); 130 | -------------------------------------------------------------------------------- /tests/multipleServices.test.js: -------------------------------------------------------------------------------- 1 | const gql = require("graphql-tag"); 2 | const { executeGraphql } = require("../"); 3 | 4 | const typeDefsProducts = gql` 5 | extend type Query { 6 | topProducts(first: Int = 5): [Product] 7 | } 8 | 9 | type Product @key(fields: "upc") { 10 | upc: String! 11 | name: String 12 | price: Int 13 | weight: Int 14 | } 15 | `; 16 | 17 | 18 | const typeDefsInventory = gql` 19 | extend type Mutation { 20 | addInventoryForProduct(upc: String!, inStock: Boolean): Product 21 | returnContext: String! 22 | } 23 | extend type Product @key(fields: "upc") { 24 | upc: String! @external 25 | weight: Int @external 26 | price: Int @external 27 | inStock: Boolean 28 | shippingEstimate: Float @requires(fields: "price weight") 29 | } 30 | `; 31 | 32 | const resolversInventory = { 33 | Mutation: { 34 | addInventoryForProduct: (_, args) => { 35 | inventory.push(args); 36 | return args; 37 | } 38 | }, 39 | Product: { 40 | __resolveReference(object) { 41 | return { 42 | ...object, 43 | ...inventory.find(product => product.upc === object.upc) 44 | }; 45 | }, 46 | shippingEstimate: object => { 47 | if (object.price > 1000) return 0; 48 | return object.weight * 0.5; 49 | } 50 | } 51 | }; 52 | 53 | const services = [ 54 | { 55 | inventory: { 56 | typeDefs: typeDefsInventory, 57 | resolvers: resolversInventory 58 | } 59 | }, 60 | { 61 | products: { 62 | typeDefs: typeDefsProducts 63 | } 64 | } 65 | ]; 66 | 67 | let inventory; 68 | 69 | beforeEach(() => { 70 | inventory = [ 71 | { upc: "1", inStock: true }, 72 | { upc: "2", inStock: false }, 73 | { upc: "3", inStock: true } 74 | ]; 75 | }); 76 | 77 | describe("Based on the mocked data from the external service", () => { 78 | const query = gql` 79 | { 80 | topProducts { 81 | name 82 | inStock 83 | shippingEstimate 84 | } 85 | } 86 | `; 87 | 88 | it("should construct its own response", async () => { 89 | const mocks = { 90 | Product: () => ({ 91 | upc: "1", 92 | name: "Table", 93 | weight: 10, 94 | price: 10 95 | }) 96 | }; 97 | 98 | const result = await executeGraphql({ query, mocks, services }); 99 | expect(result.data.topProducts[0]).toEqual({ 100 | name: "Table", 101 | inStock: true, 102 | shippingEstimate: 5 103 | }); 104 | }); 105 | it("should construct a different response for a different mock", async () => { 106 | const mocks = { 107 | Product: () => ({ 108 | upc: "1", 109 | name: "Table", 110 | weight: 10, 111 | price: 14000 112 | }) 113 | }; 114 | 115 | const result = await executeGraphql({ query, mocks, services }); 116 | expect(result.data.topProducts[0]).toEqual({ 117 | name: "Table", 118 | inStock: true, 119 | shippingEstimate: 0 120 | }); 121 | }); 122 | 123 | it("should not fail when the mocks are not explicit", async () => { 124 | 125 | const result = await executeGraphql({ query, services }); 126 | 127 | const product = result.data.topProducts[0]; 128 | expect(product).toMatchObject({ 129 | name: "Hello World" 130 | }); 131 | expect(product.inStock).toBeDefined(); 132 | expect(product.shippingEstimate).toBeDefined(); 133 | }); 134 | }); 135 | 136 | // This is broken currently but it's a rare case and you probably should not be writing tests like this, 137 | // using multiple services definitions 138 | test.skip("should allow for using mutations, going across the services", async () => { 139 | const mocks = { 140 | Product: () => ({ 141 | upc: "3", 142 | name: "Hello", 143 | weight: 10, 144 | price: 14000 145 | }) 146 | }; 147 | 148 | const mutation = gql` 149 | mutation addInventoryForProduct($upc: String!, $inStock: Boolean!) { 150 | addInventoryForProduct(upc: $upc, inStock: $inStock) { 151 | name 152 | inStock 153 | } 154 | } 155 | `; 156 | const variables = { 157 | upc: "4", 158 | inStock: false 159 | }; 160 | 161 | const result = await executeGraphql({ mutation, variables, mocks, services }); 162 | const product = result.data.addInventoryForProduct; 163 | expect(product.inStock).toEqual(false); 164 | expect(product.name).toEqual("Hello"); 165 | }); 166 | 167 | // You should probably NOT do tests like this, this is a sanity check for me to make sure everything is connected properly. 168 | test("should allow for using mutations, having all resolvers implemented", async () => { 169 | const mutation = gql` 170 | mutation addInventoryForProduct($upc: String!, $inStock: Boolean!) { 171 | addInventoryForProduct(upc: $upc, inStock: $inStock) { 172 | name 173 | inStock 174 | } 175 | } 176 | `; 177 | const variables = { 178 | upc: "4", 179 | inStock: false 180 | }; 181 | 182 | const newServices = [ 183 | { 184 | inventory: { 185 | typeDefs: typeDefsInventory, 186 | resolvers: resolversInventory 187 | } 188 | }, 189 | { 190 | products: { 191 | resolvers: { 192 | Product: { 193 | __resolveReference(object) { 194 | if (object.upc === "4") { 195 | return { name: "the correct name" }; 196 | } 197 | throw new Error("something not connectected properly"); 198 | } 199 | } 200 | }, 201 | typeDefs: typeDefsProducts 202 | } 203 | } 204 | ]; 205 | 206 | const result = await executeGraphql({ 207 | mutation, 208 | variables, 209 | services: newServices 210 | }); 211 | const product = result.data.addInventoryForProduct; 212 | expect(product.inStock).toEqual(false); 213 | expect(product.name).toEqual("the correct name"); 214 | }); 215 | 216 | // If this test fails make sure you ran 217 | // npx run patch-package 218 | // first, as we still need to patch the @apollo/gateway till the apollo guys release the new version 219 | test("should allow mocking the context and passing it to the resolvers", async () => { 220 | const newServices = [ 221 | { 222 | inventory: { 223 | typeDefs: typeDefsInventory, 224 | resolvers: { 225 | Mutation: { 226 | returnContext: (_, args, context) => context.stringToBeReturned 227 | } 228 | } 229 | } 230 | }, 231 | { 232 | products: { 233 | typeDefs: typeDefsProducts 234 | } 235 | } 236 | ]; 237 | 238 | const mutation = gql` 239 | mutation returnContext { 240 | returnContext 241 | } 242 | `; 243 | const context = { 244 | stringToBeReturned: "Hello Universe!" 245 | }; 246 | const result = await executeGraphql({ 247 | mutation, 248 | context, 249 | services: newServices 250 | }); 251 | 252 | expect(result.data.returnContext).toEqual("Hello Universe!"); 253 | }); 254 | -------------------------------------------------------------------------------- /tests/schemaWithSchemaQueryType.test.js: -------------------------------------------------------------------------------- 1 | const gql = require("graphql-tag"); 2 | const { executeGraphql } = require("../"); 3 | 4 | const schemaWithSchemaQuery = gql` 5 | type Query { 6 | GetUser: User 7 | } 8 | 9 | type User { 10 | id: ID! 11 | name: String 12 | } 13 | 14 | schema { 15 | query: Query 16 | } 17 | `; 18 | 19 | test("It skips the problematic top level schema field", async () => { 20 | const query = gql` 21 | query { 22 | GetUser { 23 | name 24 | } 25 | } 26 | `; 27 | const result = await executeGraphql({ 28 | query, 29 | service: { typeDefs: schemaWithSchemaQuery } 30 | }); 31 | 32 | expect(result).toEqual({ data: { GetUser: { name: "Hello World" } } }); 33 | }); 34 | -------------------------------------------------------------------------------- /tests/singleService.test.js: -------------------------------------------------------------------------------- 1 | const gql = require("graphql-tag"); 2 | const { executeGraphql } = require("../"); 3 | 4 | const typeDefs = gql` 5 | extend type Product @key(fields: "upc") { 6 | upc: String! @external 7 | weight: Int @external 8 | price: Int @external 9 | inStock: Boolean 10 | shippingEstimate: Int @requires(fields: "price weight") 11 | } 12 | `; 13 | 14 | let inventory = [ 15 | { upc: "1", inStock: true }, 16 | { upc: "2", inStock: false }, 17 | { upc: "3", inStock: true } 18 | ]; 19 | 20 | const resolvers = { 21 | Product: { 22 | __resolveReference(object) { 23 | return { 24 | ...object, 25 | ...inventory.find(product => product.upc === object.upc) 26 | }; 27 | }, 28 | shippingEstimate: object => { 29 | if (object.price > 1000) return 0; 30 | return object.weight * 0.5; 31 | } 32 | } 33 | }; 34 | 35 | const service = { 36 | typeDefs, 37 | resolvers 38 | }; 39 | 40 | describe("Based on the data from the external service", () => { 41 | const query = gql` 42 | { 43 | _getProduct { 44 | inStock 45 | shippingEstimate 46 | } 47 | } 48 | `; 49 | 50 | it("should set the shippingEstimate at 0 for an expensive item and retrieve inStock", async () => { 51 | const mocks = { 52 | Product: () => ({ 53 | upc: "1", 54 | weight: 10, 55 | price: 14000, 56 | }) 57 | }; 58 | 59 | const result = await executeGraphql({ query, mocks, service }); 60 | expect(result.data._getProduct.shippingEstimate).toEqual(0); 61 | expect(result.data._getProduct).toEqual({ 62 | inStock: true, 63 | shippingEstimate: 0 64 | }); 65 | }); 66 | 67 | it("should calculate the shipping estimate for cheap item", async () => { 68 | const mocks = { 69 | Product: () => ({ 70 | upc: "1", 71 | weight: 10, 72 | price: 10, 73 | }) 74 | }; 75 | 76 | const result = await executeGraphql({ query, mocks, service }); 77 | expect(result.data._getProduct.shippingEstimate).toEqual(5); 78 | }); 79 | }); 80 | --------------------------------------------------------------------------------