diff --git a/.gitignore b/.gitignore index db9277b..3ea62c7 100644 --- a/.gitignore +++ b/.gitignore @@ -83,6 +83,7 @@ Thumbs.db .LSOverride .Spotlight-V100 .Trashes -package-lock.json -.vscode/launch.json *.swp + +# Package lock +package-lock.json \ No newline at end of file diff --git a/.nycrc b/.nycrc index 9c37fef..47b8989 100644 --- a/.nycrc +++ b/.nycrc @@ -6,5 +6,6 @@ "lines": 80, "exclude": [ "**/__tests__.js" - ] + ], + "reporter": ["html", "text"] } \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..5e05ff6 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,26 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Attach by Process ID", + "processId": "${command:PickProcess}", + "request": "attach", + "skipFiles": [ + "/**" + ], + "type": "node" + }, + { + "type": "node", + "request": "launch", + "name": "debug tests", + "skipFiles": [ + "/**" + ], + "program": "${workspaceFolder}/lib/__tests__.js" + }, + ] +} \ No newline at end of file diff --git a/README.md b/README.md index 64b8ab4..e0527c5 100644 --- a/README.md +++ b/README.md @@ -2,68 +2,76 @@ # GraphQL schema components. -This project is designed to make npm module or component based Node.js development of graphql schemas easy. +This project is designed to faciliate componentized or modularized development of GraphQL schemas. Read more about the idea [here](https://medium.com/expedia-group-tech/graphql-component-architecture-principles-homeaway-ede8a58d6fde). -`graphql-component` lets you built a schema progressively through a tree of graphql schema dependencies. +`graphql-component` lets you build a schema progressively through a tree (faciliated through `imports`) of GraphQLComponent instances. Each GraphQLComponent instance encapsulates an executable GraphQL schema, specifically a `graphql-js` GraphQLSchema object. See the API below, but the encapsulated schema is accessible through a simple `schema` getter on a given `GraphQLComponent` instance. -### Repository structure +Generally speaking, each instance of `GraphQLComponent` has reference to an instance of [`GraphQLSchema`](https://graphql.org/graphql-js/type/#graphqlschema). This instance of `GraphQLSchema` is built in a several ways, depending on the options passed to a given `GraphQLComponent`'s constructor. -- `lib` - the graphql-component code. -- `test/examples/example-listing/property-component` - a component implementation for `Property`. -- `test/examples/example-listing/reviews-component` - a component implementation for `Reviews`. -- `test/examples/example-listing/listing-component` - a component implementation composing `Property` and `Reviews` into a new `Listing`. -- `test/examples/example-listing/server` - the "application". +* when a `GraphQLComponent` instance has `imports` (ie. other `GraphQLComponent` instances or component configuration objects) [graphql-tools stitchSchemas()](https://www.graphql-tools.com/docs/schema-stitching/) is used to create a "gateway" or aggregate schema that is the combination of the underlying imported schemas, and the typeDefs/resolvers passed to the root or importing `GraphQLComponent` +* when a `GraphQLComponent` has no imports, graphql-tools' `makeExecuteableSchema({typeDefs, resolvers})` is used to generate an executable GraphQL schema using the passed/required inputs. + +It's worth noting that `GraphQLComponent` can also be used to construct componentized Apollo Federated schemas. That is, if you pass the `federation: true` flag to a GraphQLComponent constructor, `@apollo/federation`'s [buildSubgraphSchema()](https://www.apollographql.com/docs/federation/api/apollo-subgraph/) is used in lieu of graphql-tools `makeExecutableSchema({...})` and the above still schema construction rule applies. The general use case here might be to help modularize an individual federated subschema service implementation. ### Running the examples -composition: - * can be run with `npm run composition-example` - * can be run with mocks with `npm run mock-composition-example` +local schema composition: + * can be run with `npm run start-composition` -federation: - * can be run with `npm run federation-example` +federation (2 subschema services implemented via `GraphQLComponent` and a vanilla Apollo Gateway): + * can be run with `npm run start-federation` -### Debug output +### Repository structure + +- `lib` - the graphql-component code. +- `examples/composition` - a simple example of composition using `graphql-component` +- `examples/federation` - a simple example of building a federated schema using `graphql-component` -Generally enable debug logging with `DEBUG=graphql-component:*` +### Running examples: +* composition: `npm run start-composition` +* fedration: `npm run start-federation` +* go to `localhost:4000/graphql` + * for composition this will bring up the GraphQL Playground for a plain old Apollo Server + * for the federation example this will bring up the GraphQL Playground for an Apollo Federated Gateway -### Activating mocks +### Debug output -To intercept resolvers with mocks execute this app with `GRAPHQL_MOCK=1` enabled or simply run `npm start-mock`. +`GraphQLComponent` uses [debug]() for local stdout based debug logging. Enable all debug logging with the node environment variable `DEBUG=graphql-component:*`. Generally speaking, most debug output occurs during `GraphQLComponent` construction. # API - - `GraphQLComponent(options)` - the component class, which may also be extended. Its options include: - - `types` - a string or array of strings representing typeDefs and rootTypes. - - `resolvers` - an object containing resolver functions. + - `types` - a string or array of strings of GraphQL SDL defining the type definitions for this component + - `resolvers` - a resolver map (ie. a two level map whose first level keys are types from the SDL, mapped to objects, whose keys are fields on those types and values are resolver functions) - `imports` - an optional array of imported components for the schema to be merged with. - `context` - an optional object { namespace, factory } for contributing to context. - `directives` - an optional object containing custom schema directives. - - `useMocks` - enable mocks. - - `preserveResolvers` - dont replace provided actual resolvers with mocks (custom or default), enables mocking parts of a schema - - `mocks` - an optional object containing mock types. + - `mocks` - a boolean (to enable default mocks) or an object to pass in custom mocks - `dataSources` - an array of data sources instances to make available on `context.dataSources` . - `dataSourceOverrides` - overrides for data sources in the component tree. - - `federation` - enable building a federated schema (default: `false`). + - `federation` - make this component's schema an Apollo Federated schema (default: `false`). - `makeExecutableSchema` - an optional custom implementation of the function which builds the executable schema from the provided type definitions, imports and resolvers. Defaults to the version provided by the [`graphql-tools`](https://www.graphql-tools.com/docs/generate-schema) package. Not used when `federation` is set to `true`. -- `GraphQLComponent.delegateToComponent(component, options)` - helper for delegating an operation to another component's schema and returning the GraphQL result. When called from a resolver, this function will examine the passed `info` object and will automatically forward the remaining operation selection set (or a limited subset of the selection set) to a root type field in the input component's schema. This function will automatically prune out fields in the delegated selection set that are not defined in the schema (component) being delegated to. + +- `static GraphQLComponent.delegateToComponent(component, options)` - a wrapper function that utilizes `graphql-tools` `delegateToSchema()` to delegate the calling resolver's selection set to a root type field (`Query`, `Mutuation`) of another `GraphQLComponent`'s schema - `component` (instance of `GraphQLComponent`) - the component's whose schema will be the target of the delegated operation - - `options` (`Object`) - - `contextValue` (required) - the `context` object from resolver that calls `delegateToComponent` + - `options` (`object`) + - `operation` (optional, can be inferred from `info`): `query` or `mutation` + - `fieldName` (optional, can be inferred if target field has same name as calling resolver's field): the target root type (`Query`, `Mutation`) field in the target `GraphQLComponent`'s schema + - `context` (required) - the `context` object from resolver that calls `delegateToComponent` - `info` (required) - the `info` object from the resolver that calls `delegateToComponent` - - `targetRootField` (`string`, optional) - if the calling resolver's field name is different from the root field name on the delegatee, you can specify the desired root field on the delegatee that you want to execute - - `subPath` (`string`, optional)- a dot separated path into the incoming selection set (from the calling resolver) that represents the root of the delegated selection set (limits delegated selection set) - - `args` (`object`, optional) - an object literal whose keys/values are passed as args to the delegatee's target field resolver. By default, the resolver's args from which `delegateToComponent` is called will be passed if the target field has an argument of the same name. Otherwise, arguments passed via the `args` object will override the calling resolver's args of the same name. + - `args` (`object`, optional) - an object literal whose keys/values are passed as args to the delegatee's target field resolver. By default, the resolver's args from which `delegateToComponent` is called will be passed if the target field has an argument of the same name. Otherwise, arguments passed via the `args` object will override the calling resolver's args of the same name. + - `transforms` (optional `Array`): Transform being a valid `graphql-tools` transform + + - please see `graphql-tools` [delegateToSchema](https://www.graphql-tools.com/docs/schema-delegation/#delegatetoschema) documentation for more details on available `options` since the delegateToComponent fuctions is simply an adapter for the `GraphQLComponent` API. -A new GraphQLComponent instance has the following API: +A GraphQLComponent instance (ie, `new GraphQLComponent({...})`) has the following API: -- `schema` - getter that returns an executable schema representing the entire component tree. -- `context` - context function that build context for all components in the tree. +- `schema` - getter that this component's `GraphQLSchema` object (ie. the "executable" schema that is constructed as described above) +- `context` - context function that builds context for all components in the tree. - `types` - this component's types. - `resolvers` - this component's resolvers. -- `imports` - this component's imported components or a import configuration. +- `imports` - this component's imported components in the form of import configuration objects - `mocks` - custom mocks for this component. - `directives` - this component's directives. - `dataSources` - this component's data source(s), if any. @@ -89,16 +97,14 @@ const types = require('./types'); const mocks = require('./mocks'); class PropertyComponent extends GraphQLComponent { - constructor({ useMocks, preserveResolvers }) { - super({ types, resolvers, mocks, useMocks, preserveResolvers }); + constructor({ types, resolvers }) { + super({ types, resolvers }); } } module.exports = PropertyComponent; ``` -This will allow for configuration (in this example, `useMocks` and `preserveResolvers`) as well as instance data per component (such as data base clients, etc). - ### Aggregation Example to merge multiple components: @@ -122,11 +128,11 @@ const server = new ApolloServer({ Imports can be a configuration object supplying the following properties: - `component` - the component instance to import. -- `exclude` - fields, if any, to exclude. +- `exclude` - fields on types to exclude from the component being imported, if any. ### Exclude -You can exclude root fields from imported components: +You can exclude whole types or individual fields on types. ```javascript const { schema, context } = new GraphQLComponent({ @@ -143,7 +149,7 @@ const { schema, context } = new GraphQLComponent({ }); ``` -This will keep from leaking unintended surface area. But you can still delegate calls to the component's schema to enable it from the API you do expose. +The excluded types will not appear in the aggregate or gateway schema exposed by the root component, but are still present in the schema encapsulated by the underlying component. This can keep from leaking unintended API surface area, if desired. You can still delegate calls to imported component's schema to utilize the excluded field under the covers. ### Data Source support diff --git a/examples/composition/listing-component/index.js b/examples/composition/listing-component/index.js index 352cf66..0a7fe4a 100644 --- a/examples/composition/listing-component/index.js +++ b/examples/composition/listing-component/index.js @@ -5,7 +5,6 @@ const Property = require('../property-component'); const Reviews = require('../reviews-component'); const resolvers = require('./resolvers'); const types = require('./types'); -const mocks = require('./mocks'); class ListingComponent extends GraphQLComponent { constructor(options) { @@ -15,17 +14,7 @@ class ListingComponent extends GraphQLComponent { super ({ types, resolvers, - mocks, - imports: [ - { - component: propertyComponent, - //exclude: ['Query.*'] - }, - { - component: reviewsComponent, - //exclude: ['Query.*'] - } - ] , + imports: [propertyComponent, reviewsComponent], ...options }); diff --git a/examples/composition/listing-component/mocks.js b/examples/composition/listing-component/mocks.js deleted file mode 100644 index 004014f..0000000 --- a/examples/composition/listing-component/mocks.js +++ /dev/null @@ -1,18 +0,0 @@ -'use strict'; - -const casual = require('casual'); - -const mocks = (importedMocks) => { - return { - Listing: () => ({ - id: casual.uuid, - geo: importedMocks.Property().geo, - reviews: [ - importedMocks.Review(), - importedMocks.Review() - ] - }) - }; -}; - -module.exports = mocks; diff --git a/examples/composition/listing-component/resolvers.js b/examples/composition/listing-component/resolvers.js index 1efe1d2..004c891 100644 --- a/examples/composition/listing-component/resolvers.js +++ b/examples/composition/listing-component/resolvers.js @@ -1,19 +1,33 @@ 'use strict'; +const GraphQLComponent = require('../../../lib'); + const resolvers = { Query: { - async listing(_, { id }, { dataSources }) { - const [property, reviews] = await Promise.all([ - dataSources.PropertyDataSource.getPropertyById(id), - dataSources.ReviewsDataSource.getReviewsByPropertyId(id) - ]); - - return { - id, - propertyId: property.id, - geo: property.geo, - reviews - }; + async listing(_, { id }) { + return { id }; + } + }, + Listing: { + property(root, args, context, info) { + return GraphQLComponent.delegateToComponent(this.propertyComponent, { + args: { + id: root.id + }, + context, + info + }) + }, + reviews(root, args, context, info) { + return GraphQLComponent.delegateToComponent(this.reviewsComponent, { + operation: 'query', + fieldName: 'reviewsByPropertyId', + args: { + propertyId: root.id + }, + context, + info + }) } } }; diff --git a/examples/composition/listing-component/schema.graphql b/examples/composition/listing-component/schema.graphql index 7b59bcf..c3edef3 100644 --- a/examples/composition/listing-component/schema.graphql +++ b/examples/composition/listing-component/schema.graphql @@ -1,12 +1,10 @@ -# A listing type Listing { id: ID! - propertyId: ID! - geo: [String] + property: Property reviews: [Review] } + type Query { - # Listing by id listing(id: ID!) : Listing } diff --git a/examples/composition/property-component/datasource.js b/examples/composition/property-component/datasource.js index 6132c78..0c8ce72 100644 --- a/examples/composition/property-component/datasource.js +++ b/examples/composition/property-component/datasource.js @@ -1,12 +1,14 @@ 'use strict'; +const propertiesDB = { + 1: { id: 1, geo: ['41.40338', '2.17403']}, + 2: { id: 2, geo: ['111.1111', '222.2222']} +} + class PropertyDataSource { getPropertyById(context, id) { - return { - id, - geo: ['41.40338', '2.17403'] - }; + return propertiesDB[id]; } -}; +} module.exports = PropertyDataSource; \ No newline at end of file diff --git a/examples/composition/property-component/index.js b/examples/composition/property-component/index.js index bd45da6..45f15e8 100644 --- a/examples/composition/property-component/index.js +++ b/examples/composition/property-component/index.js @@ -4,11 +4,10 @@ const GraphQLComponent = require('../../../lib/index'); const PropertyDataSource = require('./datasource'); const resolvers = require('./resolvers'); const types = require('./types'); -const mocks = require('./mocks'); class PropertyComponent extends GraphQLComponent { constructor({ dataSources = [new PropertyDataSource()], ...options } = {}) { - super({ types, resolvers, mocks, dataSources, ...options }); + super({ types, resolvers, dataSources, ...options }); } } diff --git a/examples/composition/property-component/mocks.js b/examples/composition/property-component/mocks.js deleted file mode 100644 index 6aceb09..0000000 --- a/examples/composition/property-component/mocks.js +++ /dev/null @@ -1,14 +0,0 @@ -'use strict'; - -const casual = require('casual'); - -const mocks = (importedMocks) => { - return { - Property: () => ({ - id: casual.uuid, - geo: [`${casual.latitude}`, `${casual.longitude}`] - }) - } -}; - -module.exports = mocks; diff --git a/examples/composition/property-component/schema.graphql b/examples/composition/property-component/schema.graphql index d266cba..b8a8f0b 100644 --- a/examples/composition/property-component/schema.graphql +++ b/examples/composition/property-component/schema.graphql @@ -1,10 +1,8 @@ - -# A listing type Property { id: ID! geo: [String] } + type Query { - # Property by id property(id: ID!) : Property } diff --git a/examples/composition/reviews-component/datasource.js b/examples/composition/reviews-component/datasource.js index 1910e60..45a6951 100644 --- a/examples/composition/reviews-component/datasource.js +++ b/examples/composition/reviews-component/datasource.js @@ -1,12 +1,14 @@ 'use strict'; +// reviews indexed by property id +const reviewsDB = { + 1: [ { id: 'rev-id-1-a', content: 'this property was great'}, { id: 'rev-id-1-b', content: 'this property was terrible'}], + 2: [ { id: 'rev-id-2-a', content: 'This property was amazing for our extended family'}, { id: 'rev-id-2-b', content: 'I loved the proximity to the beach'}, { id: 'rev-id-2-c', content: 'The bed was not comfortable at all'}] +} + class ReviewsDataSource { getReviewsByPropertyId(context, propertyId) { - return [{ - id: 1, - propertyId: 1, - content: 'content for review' - }]; + return reviewsDB[propertyId] } }; diff --git a/examples/composition/reviews-component/index.js b/examples/composition/reviews-component/index.js index b2121c5..74ddea5 100644 --- a/examples/composition/reviews-component/index.js +++ b/examples/composition/reviews-component/index.js @@ -4,11 +4,10 @@ const GraphQLComponent = require('../../../lib/index'); const ReviewsDataSource = require('./datasource'); const resolvers = require('./resolvers'); const types = require('./types'); -const mocks = require('./mocks'); class ReviewsComponent extends GraphQLComponent { constructor({ dataSources = [new ReviewsDataSource()], ...options } = {}) { - super({ types, resolvers, mocks, dataSources, ...options }); + super({ types, resolvers, dataSources, ...options }); } } diff --git a/examples/composition/reviews-component/mocks.js b/examples/composition/reviews-component/mocks.js deleted file mode 100644 index 87c0f6f..0000000 --- a/examples/composition/reviews-component/mocks.js +++ /dev/null @@ -1,15 +0,0 @@ -'use strict'; - -const Casual = require('casual'); - -const mocks = (importedMocks) => { - return { - Review: () => ({ - id: Casual.uuid, - propertyId: Casual.uuid, - content: Casual.description - }) - }; -}; - -module.exports = mocks; diff --git a/examples/composition/reviews-component/schema.graphql b/examples/composition/reviews-component/schema.graphql index 2b89eff..9983e77 100644 --- a/examples/composition/reviews-component/schema.graphql +++ b/examples/composition/reviews-component/schema.graphql @@ -1,11 +1,8 @@ - -# A review type Review { id: ID! - propertyId: ID! content: String! } + type Query { - # Reviews by property id reviewsByPropertyId(propertyId: ID!) : [Review] } diff --git a/examples/composition/server/index.js b/examples/composition/server/index.js index 203a94a..f85544c 100644 --- a/examples/composition/server/index.js +++ b/examples/composition/server/index.js @@ -2,24 +2,7 @@ const { ApolloServer } = require('apollo-server'); const ListingComponent = require('../listing-component'); -const { schema, context} = new ListingComponent({ - useMocks: !!process.env.GRAPHQL_MOCK, - preserveResolvers: true, - //Data source overriding - dataSourceOverrides: [ - new class MockDataSource { - static get name() { - return 'PropertyDataSource'; - } - getPropertyById(context, id) { - return { - id: 'override id', - geo: ['lat', 'long'] - }; - } - } - ] -}); +const { schema, context } = new ListingComponent(); const server = new ApolloServer({ schema, context, tracing: true }); diff --git a/examples/federation/gateway.js b/examples/federation/gateway.js new file mode 100644 index 0000000..0a3da29 --- /dev/null +++ b/examples/federation/gateway.js @@ -0,0 +1,22 @@ +const { ApolloServer } = require('apollo-server'); +const { ApolloGateway } = require('@apollo/gateway'); + + +const run = async function() { + const gateway = new ApolloGateway({ + serviceList: [ + { name: 'property', url: 'http://localhost:4001' }, + { name: 'reviews', url: 'http://localhost:4002' } + ] + }); + + const server = new ApolloServer({ + gateway, + subscriptions: false + }); + + const { url } = await server.listen({port: 4000}); + console.log(`🚀 Gateway ready at ${url}`); +} + +module.exports = { run }; \ No newline at end of file diff --git a/examples/federation/property-service/datasource.js b/examples/federation/property-service/datasource.js index 6132c78..0c8ce72 100644 --- a/examples/federation/property-service/datasource.js +++ b/examples/federation/property-service/datasource.js @@ -1,12 +1,14 @@ 'use strict'; +const propertiesDB = { + 1: { id: 1, geo: ['41.40338', '2.17403']}, + 2: { id: 2, geo: ['111.1111', '222.2222']} +} + class PropertyDataSource { getPropertyById(context, id) { - return { - id, - geo: ['41.40338', '2.17403'] - }; + return propertiesDB[id]; } -}; +} module.exports = PropertyDataSource; \ No newline at end of file diff --git a/examples/federation/property-service/index.js b/examples/federation/property-service/index.js index d9da2f7..a6b7c78 100644 --- a/examples/federation/property-service/index.js +++ b/examples/federation/property-service/index.js @@ -5,29 +5,29 @@ const GraphQLComponent = require('../../../lib'); const PropertyDataSource = require('./datasource'); const resolvers = require('./resolvers'); const types = require('./types'); -const mocks = require('./mocks'); class PropertyComponent extends GraphQLComponent { - constructor({ dataSources = [new PropertyDataSource()], ...options } = {}) { - super({ types, resolvers, mocks, dataSources, ...options, federation: true }); + constructor(options) { + super(options); } } -const startPropertyService = async () => { - const { schema, context } = new PropertyComponent(); +const run = async function () { + const { schema, context } = new PropertyComponent({ + types, + resolvers, + dataSources: [new PropertyDataSource()], + federation: true + }); const server = new ApolloServer({ schema, context, - introspection: true, subscriptions: false, - playground: false }); - const { url } = await server.listen({port: 4001}); - console.log(`🚀 property service ready at ${url}`); + const { url } = await server.listen({port: 4001}) + console.log(`🚀 Property service ready at ${url}`) } -module.exports = startPropertyService; - - +module.exports = { run }; \ No newline at end of file diff --git a/examples/federation/property-service/mocks.js b/examples/federation/property-service/mocks.js deleted file mode 100644 index 6aceb09..0000000 --- a/examples/federation/property-service/mocks.js +++ /dev/null @@ -1,14 +0,0 @@ -'use strict'; - -const casual = require('casual'); - -const mocks = (importedMocks) => { - return { - Property: () => ({ - id: casual.uuid, - geo: [`${casual.latitude}`, `${casual.longitude}`] - }) - } -}; - -module.exports = mocks; diff --git a/examples/federation/property-service/resolvers.js b/examples/federation/property-service/resolvers.js index 8872b5c..6783afe 100644 --- a/examples/federation/property-service/resolvers.js +++ b/examples/federation/property-service/resolvers.js @@ -7,10 +7,10 @@ const resolvers = { } }, Property: { - __resolveReference(ref, {dataSources}) { + __resolveReference(ref, { dataSources }) { return dataSources.PropertyDataSource.getPropertyById(ref.id); } } }; -module.exports = resolvers; +module.exports = resolvers; \ No newline at end of file diff --git a/examples/federation/property-service/schema.graphql b/examples/federation/property-service/schema.graphql index 7338db2..b7364fb 100644 --- a/examples/federation/property-service/schema.graphql +++ b/examples/federation/property-service/schema.graphql @@ -1,10 +1,8 @@ - -# A listing type Property @key(fields: "id") { id: ID! geo: [String] } + type Query { - # Property by id property(id: ID!) : Property -} +} \ No newline at end of file diff --git a/examples/federation/property-service/types.js b/examples/federation/property-service/types.js index fed9e29..46e4320 100644 --- a/examples/federation/property-service/types.js +++ b/examples/federation/property-service/types.js @@ -5,4 +5,4 @@ const path = require('path'); const types = fs.readFileSync(path.resolve(path.join(__dirname, 'schema.graphql')), 'utf-8'); -module.exports = types; +module.exports = types; \ No newline at end of file diff --git a/examples/federation/reviews-service/datasource.js b/examples/federation/reviews-service/datasource.js index 43fe163..3b63177 100644 --- a/examples/federation/reviews-service/datasource.js +++ b/examples/federation/reviews-service/datasource.js @@ -1,12 +1,15 @@ 'use strict'; +// reviews indexed by property id +const reviewsDB = { + 1: [ { id: 'rev-id-1-a', content: 'this property was great'}, { id: 'rev-id-1-b', content: 'this property was terrible'}], + 2: [ { id: 'rev-id-2-a', content: 'This property was amazing for our extended family'}, { id: 'rev-id-2-b', content: 'I loved the proximity to the beach'}, { id: 'rev-id-2-c', content: 'The bed was not comfortable at all'}] +} + class ReviewsDataSource { getReviewsByPropertyId(context, propertyId) { - return [{ - id: 'rev-id-1', - content: 'content for review' - }]; + return reviewsDB[propertyId]; } -}; +} module.exports = ReviewsDataSource; \ No newline at end of file diff --git a/examples/federation/reviews-service/index.js b/examples/federation/reviews-service/index.js index cab34b8..2600fe4 100644 --- a/examples/federation/reviews-service/index.js +++ b/examples/federation/reviews-service/index.js @@ -5,29 +5,30 @@ const GraphQLComponent = require('../../../lib'); const ReviewsDataSource = require('./datasource'); const resolvers = require('./resolvers'); const types = require('./types'); -const mocks = require('./mocks'); class ReviewsComponent extends GraphQLComponent { - constructor({ dataSources = [new ReviewsDataSource()], ...options } = {}) { - super({ types, resolvers, mocks, dataSources, ...options, federation: true }); + constructor(options) { + super(options); } } -const startReviewsService = async () => { - const { schema, context } = new ReviewsComponent(); +const run = async function () { + const { schema, context } = new ReviewsComponent({ + types, + resolvers, + dataSources: [new ReviewsDataSource()], + federation: true + }); const server = new ApolloServer({ schema, context, - introspection: true, - subscriptions: false, - playground: false + subscriptions: false }); - const { url } = await server.listen({port: 4002}); - console.log(`🚀 reviews service ready at ${url}`); -} - -module.exports = startReviewsService; + const { url } = await server.listen({port: 4002}) + console.log(`🚀 Reviews service ready at ${url}`) +} +module.exports = { run }; \ No newline at end of file diff --git a/examples/federation/reviews-service/mocks.js b/examples/federation/reviews-service/mocks.js deleted file mode 100644 index 87c0f6f..0000000 --- a/examples/federation/reviews-service/mocks.js +++ /dev/null @@ -1,15 +0,0 @@ -'use strict'; - -const Casual = require('casual'); - -const mocks = (importedMocks) => { - return { - Review: () => ({ - id: Casual.uuid, - propertyId: Casual.uuid, - content: Casual.description - }) - }; -}; - -module.exports = mocks; diff --git a/examples/federation/reviews-service/resolvers.js b/examples/federation/reviews-service/resolvers.js index 5cfa325..4527f98 100644 --- a/examples/federation/reviews-service/resolvers.js +++ b/examples/federation/reviews-service/resolvers.js @@ -6,11 +6,11 @@ const resolvers = { return dataSources.ReviewsDataSource.getReviewsByPropertyId(propertyId); } }, - Review: { - property() { - return { __typename: 'Property', id: 1 }; + Property: { + reviews(root, _args, { dataSources }) { + return dataSources.ReviewsDataSource.getReviewsByPropertyId(root.id); } } }; -module.exports = resolvers; +module.exports = resolvers; \ No newline at end of file diff --git a/examples/federation/reviews-service/schema.graphql b/examples/federation/reviews-service/schema.graphql index 2fb73d1..f934f7f 100644 --- a/examples/federation/reviews-service/schema.graphql +++ b/examples/federation/reviews-service/schema.graphql @@ -1,16 +1,13 @@ - -# A review -type Review @key(fields: "id") { +type Review { id: ID! content: String! - property: Property } extend type Property @key(fields: "id") { id: ID! @external + reviews: [Review] @requires(fields: "id") } type Query { - # Reviews by property id - reviewsByPropertyId(propertyId: ID!) : [Review] -} + reviewsByPropertyId(propertyId: ID): [Review] +} \ No newline at end of file diff --git a/examples/federation/reviews-service/types.js b/examples/federation/reviews-service/types.js index fed9e29..46e4320 100644 --- a/examples/federation/reviews-service/types.js +++ b/examples/federation/reviews-service/types.js @@ -5,4 +5,4 @@ const path = require('path'); const types = fs.readFileSync(path.resolve(path.join(__dirname, 'schema.graphql')), 'utf-8'); -module.exports = types; +module.exports = types; \ No newline at end of file diff --git a/examples/federation/run-federation-example.js b/examples/federation/run-federation-example.js index a2ab10f..0cf76f1 100644 --- a/examples/federation/run-federation-example.js +++ b/examples/federation/run-federation-example.js @@ -1,11 +1,11 @@ -const startReviewsService = require('./reviews-service'); -const startPropertyService = require('./property-service'); -const startGateway = require('./gateway'); +const { run: runReviewsService } = require('./reviews-service'); +const { run: runPropertyService } = require('./property-service'); +const { run: runGateway } = require('./gateway'); const start = async () => { - await startReviewsService(); - await startPropertyService(); - await startGateway(); + await runReviewsService(); + await runPropertyService(); + await runGateway(); } start(); \ No newline at end of file diff --git a/lib/__tests__.js b/lib/__tests__.js index c2a79aa..dc5dce7 100644 --- a/lib/__tests__.js +++ b/lib/__tests__.js @@ -7,7 +7,7 @@ const graphql = require('graphql'); const GraphQLComponent = require('./index'); const sinon = require('sinon'); -Test('component API', (t) => { +Test('GraphQLComponent instance API (getters/setters)', (t) => { t.test('component name (anonymous constructor)', (st) => { const component = new GraphQLComponent(); @@ -22,27 +22,6 @@ Test('component API', (t) => { st.end(); }); - t.test('component id', (st) => { - const component = new GraphQLComponent(); - t.ok(component.id, `got component's id`); - st.end(); - }); - - t.test('isComponent with config object', (st) => { - st.notOk(GraphQLComponent.isComponent(Object.create({ component: new GraphQLComponent, exclude: ['Query.a'] })), 'is not a component'); - st.end(); - }); - - t.test('isComponent with new base class instance', (st) => { - st.ok(GraphQLComponent.isComponent(new GraphQLComponent()), 'is a component'); - st.end(); - }); - - t.test('isComponent with new subclass', (st) => { - t.ok(GraphQLComponent.isComponent(new class extends GraphQLComponent { }), 'is a component'); - st.end(); - }); - t.test('component context', (st) => { const component = new GraphQLComponent(); const context = component.context; @@ -55,11 +34,11 @@ Test('component API', (t) => { const component = new GraphQLComponent({ types: `type Query { a: String }`, imports: [new GraphQLComponent({ - types: `type Query { b: B} type B { someField: String}`} + types: `type Query { b: B } type B { someField: String}`} )] }); - st.deepEquals(component.types, [`type Query { a: String }`], `only the component's own types are returned (no imports)`); + st.deepEquals(component.types, [`type Query { a: String }`], `only the component's own types are returned`); st.end(); }); @@ -97,19 +76,6 @@ Test('component API', (t) => { ] }); st.equals(root.imports.length, 1, `only component's own imports are returned`); - st.equals(childThatAlsoHasImports.id, root.imports[0].component.id, `id of only import matches import instance's id`); - st.end(); - }); - - t.test('component mocks', (st) => { - const component = new GraphQLComponent({ - mocks: { Query: { mockA() { return 'mockAString' }}}, - imports: [new GraphQLComponent({ - mocks: { Query: { mockB() { return 'mockBString' }}} - })] - }); - - st.equals(Object.keys(component.mocks).length, 1, `only component's own mocks are returned`); st.end(); }); @@ -138,80 +104,995 @@ Test('component API', (t) => { }); }); -Test('mocks', async (t) => { - t.plan(3); +Test(`graphql-tools accessible from GraphQLComponent resolver 'this'`, async (t) => { + const component = new GraphQLComponent({ + types: ` + type Query { + foo: Foo + } - const componentA = new GraphQLComponent({ - types: [` - type A { - value: String + type Foo { + name: String + } + `, + resolvers: { + Query: { + foo() { + t.ok(this.graphqlTools, 'graphqlTools is defined on resolver this'); } - type Query { - a: A + } + } + }); + + const document = gql` + query { + foo { + name + } + } + `; + + await graphql.execute({ + document, + schema: component.schema, + contextValue: {} + }); + t.end(); +}); + +// mocks tests +Test(`default mocks applied to component's schema when mocks passed as boolean`, (t) => { + const mockedSchemaComponent = new GraphQLComponent({ + types: ` + type Query { + foo: Foo + } + + type Foo { + a: Int + b: Float + c: String + d: Boolean + } + `, + mocks: true + }); + + const document = gql` + query { + foo { + a + b + c + d + } + } + `; + + const { data: { foo } } = graphql.execute({ + document, + schema: mockedSchemaComponent.schema, + contextValue: {} + }); + + t.ok(typeof foo.a === 'number', 'Foo.a is random number'); + t.ok(typeof foo.b === 'number', 'Foo.b is random number'); + t.equal(foo.c, 'Hello World', 'Foo.c is Hello World'); + t.ok(typeof foo.d === 'boolean', 'Foo.d is boolean'); + t.end(); +}); + +Test(`default mocks applied only to imported component's schema`, async (t) => { + const mockedSchemaComponent = new GraphQLComponent({ + types: ` + type Query { + foo: Foo + } + + type Foo { + a: Int + b: Float + c: String + d: Boolean + } + `, + mocks: true + }); + + const composite = new GraphQLComponent({ + types: ` + type Query { + bar: Bar + } + + type Bar { + barField: String + f: Foo + } + `, + resolvers: { + Query: { + bar() { + return { + barField: 'barField', + } + } + }, + Bar: { + f(root, args, context, info) { + return GraphQLComponent.delegateToComponent(mockedSchemaComponent, { + operation: 'query', + fieldName: 'foo', + context, + info + }); } - `], - mocks: { - A: () => ({ value: 'a' }) } - }); + }, + imports: [mockedSchemaComponent] + }); - const componentB = new GraphQLComponent({ - types: [` - type B { - value: String + const document = gql` + query { + bar { + barField + f { + a + b + c + d } - type Query { - b: B + } + } + `; + + const { data: { bar: { barField, f }} } = await graphql.execute({ + document, + schema: composite.schema, + contextValue: {} + }); + + t.equals(barField, 'barField', 'non-mocked value in root component is present') + t.ok(typeof f.a === 'number', 'Foo.a is random number'); + t.ok(typeof f.b === 'number', 'Foo.b is random number'); + t.equal(f.c, 'Hello World', 'Foo.c is Hello World'); + t.ok(typeof f.d === 'boolean', 'Foo.d is boolean'); + t.end(); +}); + +Test('default mocks applied to imported and composite component', async (t) => { + const mockedSchemaComponent = new GraphQLComponent({ + types: ` + type Query { + foo: Foo + } + + type Foo { + a: Int + b: Float + c: String + d: Boolean + } + `, + mocks: true + }); + + const composite = new GraphQLComponent({ + types: ` + type Query { + bar: Bar + } + + type Bar { + barField: String + f: Foo + compositeMockedField: String + } + `, + resolvers: { + Query: { + bar() { + return { + barField: 'barField', + } + } + }, + Bar: { + f(root, args, context, info) { + return GraphQLComponent.delegateToComponent(mockedSchemaComponent, { + operation: 'query', + fieldName: 'foo', + context, + info + }); } - `], - imports: [ - componentA - ], - mocks: { - B: () => ({ value: 'b' }) } - }); + }, + imports: [mockedSchemaComponent], + mocks: true + }); - const componentC = new GraphQLComponent({ - types: [` - type C { - value: String + const document = gql` + query { + bar { + barField + f { + a + b + c + d } - type Query { - c: C + compositeMockedField + } + } + `; + + const { data: { bar: { barField, f, compositeMockedField }} } = await graphql.execute({ + document, + schema: composite.schema, + contextValue: {} + }); + + t.equals(barField, 'barField', 'non-mocked value in root component is present'); + t.equals(compositeMockedField, 'Hello World', 'compositeMockedField is Hello World'); + t.ok(typeof f.a === 'number', 'Foo.a is random number'); + t.ok(typeof f.b === 'number', 'Foo.b is random number'); + t.equal(f.c, 'Hello World', 'Foo.c is Hello World'); + t.ok(typeof f.d === 'boolean', 'Foo.d is boolean'); + t.end(); +}); + +Test(`custom mocks applied to component's schema when mocks passed as object`, (t) => { + const mockedSchemaComponent = new GraphQLComponent({ + types: ` + type Query { + foo: Foo + } + + type Foo { + a: Int + b: Float + c: String + d: Boolean + } + `, + mocks: { + Int: () => 123456789, + Float: () => 3.1415926, + String: () => 'custom string', + Boolean: () => false + } + }); + + const document = gql` + query { + foo { + a + b + c + d + } + } + `; + + const { data: { foo } } = graphql.execute({ + document, + schema: mockedSchemaComponent.schema, + contextValue: {} + }); + + t.equal(foo.a, 123456789, 'Foo.a is a custom mocked Int'); + t.equal(foo.b, 3.1415926, 'Foo.b is custom mocked Float'); + t.equal(foo.c, 'custom string', 'Foo.c is a custom mocked string'); + t.equal(foo.d, false, 'Foo.d is a custom mocked boolean (false)'); + t.end(); +}); + +// delegate tests +Test('delegate from root-type resolver', async (t) => { + const primitive = new GraphQLComponent({ + types: ` + type Query { + foo: Foo + } + + type Foo { + a: String + } + `, + resolvers: { + Query: { + foo() { + return { a: 'a' }; + } + } + } + }); + + const composite = new GraphQLComponent({ + types: ` + type Query { + bar: Foo + } + + type Foo { + b: Int + } + `, + resolvers: { + Query: { + async bar(root, args, context, info) { + const subFoo = await GraphQLComponent.delegateToComponent(primitive, { + operation: 'query', + fieldName: 'foo', + context, + info + }); + + return { ...subFoo, b: 1 }; + } + } + }, + imports: [primitive] + }); + + const document = gql` + query { + bar { + a + b + } + } + `; + + const { data, errors } = await graphql.execute({ + schema: composite.schema, + document, + contextValue: {} + }); + + t.deepEqual(data, { bar: { a: 'a', b: 1}}, 'expected result'); + t.notOk(errors, 'no errors') + t.end(); +}); + +Test('delegate from non root-type resolver', async (t) => { + const primitive = new GraphQLComponent({ + types: ` + type Query { + foo: Foo + } + + type Foo { + a: String + } + `, + resolvers: { + Query: { + foo() { + return { a: 'a' }; + } + } + } + }); + + const composite = new GraphQLComponent({ + types: ` + type Query { + bar: Bar + } + + type Bar { + barField: String + foo: Foo + } + `, + resolvers: { + Query: { + async bar() { + return { barField: 'barField' }; } - `], - imports: [ - componentB - ], - mocks: { - C: () => ({ value: 'c' }) }, - useMocks: true - }); + Bar: { + foo(root, args, context, info) { + return GraphQLComponent.delegateToComponent(primitive, { + operation: 'query', + fieldName: 'foo', + context, + info + }); + } + } + }, + imports: [primitive] + }); - const document = gql` - query { - a { value } - b { value } - c { value } + const document = gql` + query { + bar { + barField + foo { + a + } } - `; + } + `; - const { data } = await graphql.execute({ - document, - schema: componentC.schema, - rootValue: undefined, - contextValue: {} - }); + const { data, errors } = await graphql.execute({ + schema: composite.schema, + document, + contextValue: {} + }); + + t.deepEqual(data, { bar: { barField: 'barField', foo: { a: 'a'}}}, 'expected result'); + t.notOk(errors, 'no errors') + t.end(); +}); + +Test('delegate results in non-root type field resolver running in delegatee', async (t) => { + let fooFieldResolverCallCount = 0; + const primitive = new GraphQLComponent({ + types: ` + type Query { + foo: Foo + } + + type Foo { + a: String + fprime: FooPrime + } + + type FooPrime { + prime: String + } + `, + resolvers: { + Query: { + foo() { + return { a: 'a', somethingToTransform: 'hello' }; + } + }, + Foo: { + fprime(root) { + fooFieldResolverCallCount += 1; + if (root.somethingToTransform) { + return { prime: root.somethingToTransform }; + } + } + } + } + }); + + const composite = new GraphQLComponent({ + types: ` + type Query { + bar: Foo + } - t.equal(data.a.value, 'a', 'returns Component A\'s mock'); - t.equal(data.b.value, 'b', 'returns Component B\'s mock'); - t.equal(data.c.value, 'c', 'returns Component C\'s mock'); + type Foo { + compositeFooField: String + } + `, + resolvers: { + Query: { + async bar(root, args, context, info) { + const subFoo = await GraphQLComponent.delegateToComponent(primitive, { + operation: 'query', + fieldName: 'foo', + context, + info + }); + return { ...subFoo, compositeFooField: 'compositeFooField' }; + } + } + }, + imports: [primitive] + }); + + const document = gql` + query { + bar { + compositeFooField + a + fprime { + prime + } + } + } + `; + + const { data, errors } = await graphql.execute({ + schema: composite.schema, + document, + contextValue: {} + }); + + t.notOk(errors, 'no errors'); + t.deepEqual(data, { bar: { compositeFooField: 'compositeFooField', a: 'a', fprime: { prime: 'hello'}}}, 'expected result'); + t.equal(fooFieldResolverCallCount, 1, 'non root type field resolver in delegatee only called once'); + t.end(); }); -Test('federated schema', (t) => { +Test('delegation resolves nested abstract type resolved without error', async (t) => { + let resolveTypeCount = 0; + let materialNonRootResolverCount = 0; + const primitive = new GraphQLComponent({ + types: ` + type Query { + thingsById(id: ID): ThingsConnection + } + type ThingsConnection { + edges: [ThingEdge] + } + + type ThingEdge { + node: Thing + } + + interface Thing { + id: ID + } + + type Book implements Thing { + id: ID + title: String + } + + type Mug implements Thing { + id: ID + material: String + } + `, + resolvers: { + Query: { + thingsById() { + return { + edges: [ + { + node: { + id: 1, + title: 'A tale of two cities' + } + }, + { + node: { + id: 2, + } + } + ] + } + } + }, + Thing: { + __resolveType(result) { + resolveTypeCount += 1; + if (result.title) { + return 'Book'; + } + return 'Mug'; + } + }, + Mug: { + material() { + materialNonRootResolverCount += 1; + return 'ceramic'; + } + } + } + }); + + const composite = new GraphQLComponent({ + types: ` + type Query { + foo: Foo + } + + type Foo { + things: ThingsConnection + } + `, + resolvers: { + Query: { + async foo() { + return {}; + } + }, + Foo: { + things(_root, _args, context, info) { + return GraphQLComponent.delegateToComponent(primitive, { + operation: 'query', + fieldName: 'thingsById', + info, + context + }); + } + } + }, + imports: [primitive] + }); + + const document = gql` + query { + foo { + things { + edges { + node { + id + ... on Book { + title + } + ... on Mug { + material + } + } + } + } + } + } + ` + + const { data, errors } = await graphql.execute({ + document, + schema: composite.schema, + contextValue: {} + }); + const expectedResult = { + foo: { + things: { + edges: [ + { + node: { + id: '1', + title: 'A tale of two cities' + } + }, + { + node: { + id: '2', + material: 'ceramic' + } + } + ] + } + } + } + t.deepEquals(data, expectedResult, 'data is resolved as expected'); + t.equals(resolveTypeCount, 2, '__resolveType called once per item as expected'); + t.equals(materialNonRootResolverCount, 1, 'Mug non-root resolver is only executed 1 time as expected'); + t.notOk(errors, 'no errors'); + t.end(); +}); + +Test('error from delegatee propagated back to delegator and abstracted (looks like it came from resolver that called delegate)', async (t) => { + const primitive = new GraphQLComponent({ + types: ` + type Query { + foo: Foo + } + + type Foo { + b: String + } + `, + resolvers: { + Query: { + foo() { + throw new Error('db retrieval error'); + } + } + } + }); + + const composite = new GraphQLComponent({ + types: ` + + type Query { + bar: Bar + } + + type Bar { + a: String + b: String + } + `, + resolvers: { + Query: { + bar(root, args, context, info) { + return GraphQLComponent.delegateToComponent(primitive, { + query: 'operation', + fieldName: 'foo', + context, + info + }); + } + } + } + }); + + const document = gql` + query { + bar { + a + b + } + } + `; + + const { data, errors } = await graphql.execute({ + schema: composite.schema, + document, + contextValue: {} + }); + + t.notOk(data.bar, 'bar is null as expected'); + t.equals(errors.length, 1, '1 error as expected'); + t.equals(errors[0].message, 'db retrieval error'); + t.deepEqual(errors[0].path, ['bar'], 'error path appears as though error came from delegateToComponent calling resolver'); + t.end(); +}); + +Test('delegateToComponent maintains backwards compatibility for changed option keys (contextValue and targetRootField)', async (t) => { + const primitive = new GraphQLComponent({ + types: ` + type Query { + foo: Foo + } + + type Foo { + a: String + } + `, + resolvers: { + Query: { + foo() { + return { a: 'a' }; + } + } + } + }); + + const composite = new GraphQLComponent({ + types: ` + type Query { + bar: Foo + } + + type Foo { + b: Int + } + `, + resolvers: { + Query: { + async bar(root, args, context, info) { + const subFoo = await GraphQLComponent.delegateToComponent(primitive, { + operation: 'query', + targetRootField: 'foo', + contextValue: context, + info + }); + + return { ...subFoo, b: 1 }; + } + } + }, + imports: [primitive] + }); + + const document = gql` + query { + bar { + a + b + } + } + `; + + const { data, errors } = await graphql.execute({ + schema: composite.schema, + document, + contextValue: {} + }); + + t.deepEqual(data, { bar: { a: 'a', b: 1}}, 'expected result'); + t.notOk(errors, 'no errors') + t.end(); +}); + +// directive tests + +// TODO: implement directive tests to test directive behavior between imports, etc + +// federation tests +Test('components with federated schemas can be stitched locally by importing root', (t) => { + const fedComponent1 = new GraphQLComponent({ + types: ` + type Query { + property(id: ID!): Property + } + + type Property @key(fields: "id") { + id: ID! + geo: [String] + } + `, + resolvers: { + Query: { + property(_, {id}) { + return { + id, + geo: ['lat', 'long'] + } + } + } + }, + federation: true + }); + + const fedComponent2 = new GraphQLComponent({ + types: ` + type Query { + reviews(propertyId: ID!): [Review] + } + + type Review @key(fields: "id") { + id: ID! + content: String + } + + type Property { + addedPropertyField: String + } + `, + resolvers: { + Query: { + reviews() { + return { + id: 'rev-id-1', + content: 'some-content' + } + } + }, + }, + federation: true + }); + + const emptyRoot = new GraphQLComponent({ + imports: [fedComponent1, fedComponent2] + }); + + const { schema } = emptyRoot; + + console.log(graphql.printSchema(schema)); + const _serviceType = schema.getType('_Service'); + const _serviceTypeFields = _serviceType.getFields(); + const _entityType = schema.getType('_Entity'); + const _entityTypeTypes = _entityType.getTypes(); + const _anyScalar = schema.getType('_Any'); + const queryFields = schema.getType('Query').getFields(); + const propertyTypeFields = schema.getType('Property').getFields(); + + t.ok(_serviceType, 'federated _Service type exists'); + t.ok(_serviceTypeFields['sdl'], '_Service type sdl field exists'); + t.ok(_entityType, 'federated _Entity type exists'); + t.equals(_entityTypeTypes.length, 2, '2 entities'); + t.deepEqual(_entityTypeTypes.map((e) => e.name), ['Property', 'Review'], 'entities are Property and Review as declared via @keys directive'); + t.ok(_anyScalar, 'scalar _Any exists'); + t.deepEqual(Object.keys(queryFields), ['_entities', '_service', 'property', 'reviews'], 'federated query fields and user declared query fields are present'); + t.deepEqual(Object.keys(propertyTypeFields), ['id', 'geo', 'addedPropertyField'], 'Property type fields is union between imported components (ie. property type is merged)'); + t.end(); +}); + +Test(`importing root specifies 'federation: true' results in all components creating federated schemas`, (t) => { + const fedComponent1 = new GraphQLComponent({ + types: ` + type Query { + property(id: ID!): Property + } + + type Property @key(fields: "id") { + id: ID! + geo: [String] + } + `, + resolvers: { + Query: { + property(_, {id}) { + return { + id, + geo: ['lat', 'long'] + } + } + } + }, + }); + + const fedComponent2 = new GraphQLComponent({ + types: ` + type Query { + reviews(propertyId: ID!): [Review] + } + + type Review @key(fields: "id") { + id: ID! + content: String + } + + type Property { + addedPropertyField: String + } + `, + resolvers: { + Query: { + reviews() { + return { + id: 'rev-id-1', + content: 'some-content' + } + } + }, + }, + }); + + const emptyRoot = new GraphQLComponent({ + imports: [fedComponent1, fedComponent2], + federation: true + }); + + const { schema: fedComponent1Schema } = fedComponent1; + const _serviceTypeFedComponent1 = fedComponent1Schema.getType('_Service'); + const _serviceTypeFieldsFedComponent1 = _serviceTypeFedComponent1.getFields(); + const _entityTypeFedComponent1 = fedComponent1Schema.getType('_Entity'); + const _entityTypeTypesFedComponent1 = _entityTypeFedComponent1.getTypes(); + const _anyScalarFedComponent1 = fedComponent1Schema.getType('_Any'); + const queryFieldsFedComponent1 = fedComponent1Schema.getType('Query').getFields(); + + t.ok(_serviceTypeFedComponent1, `federated _Service type exists in fedComponent1's federated schema`); + t.ok(_serviceTypeFieldsFedComponent1['sdl'], `_Service type sdl field exists in fedComponent1's federated schema`); + t.ok(_entityTypeFedComponent1, `federated _Entity type exists in fedComponent1's federated schema`); + t.equals(_entityTypeTypesFedComponent1.length, 1, `1 entity in fedComponent1's federated schema`); + t.ok(_anyScalarFedComponent1, `scalar _Any exists in fedComponent1's federated schema`); + t.deepEqual(Object.keys(queryFieldsFedComponent1), ['_entities', '_service', 'property'], `federated query fields and user declared query fields are present in fedComponent1's federated schema`); + t.ok(fedComponent1._federation, `federation flag is true in imported component, even though it was not set in imported component's constructor`); + + const { schema: fedComponent2Schema } = fedComponent1; + const _serviceTypeFedComponent2 = fedComponent2Schema.getType('_Service'); + const _serviceTypeFieldsFedComponent2 = _serviceTypeFedComponent2.getFields(); + const _entityTypeFedComponent2 = fedComponent2Schema.getType('_Entity'); + const _entityTypeTypesFedComponent2 = _entityTypeFedComponent2.getTypes(); + const _anyScalarFedComponent2 = fedComponent2Schema.getType('_Any'); + const queryFieldsFedComponent2 = fedComponent2Schema.getType('Query').getFields(); + + t.ok(_serviceTypeFedComponent2, `federated _Service type exists in fedComponent2's federated schema`); + t.ok(_serviceTypeFieldsFedComponent2['sdl'], `_Service type sdl field exists in fedComponent2's federated schema`); + t.ok(_entityTypeFedComponent2, `federated _Entity type exists in fedComponent1's federated schema`); + t.equals(_entityTypeTypesFedComponent2.length, 1, `1 entity in fedComponent2's federated schema`); + t.ok(_anyScalarFedComponent2, `scalar _Any exists in fedComponent1's federated schema`); + t.deepEqual(Object.keys(queryFieldsFedComponent2), ['_entities', '_service', 'property'], `federated query fields and user declared query fields are present in fedComponent2's federated schema`); + t.ok(fedComponent2._federation, `federation flag is true in imported component, even though it was not set in imported component's constructor`); + + const { schema } = emptyRoot; + + const _serviceType = schema.getType('_Service'); + const _serviceTypeFields = _serviceType.getFields(); + const _entityType = schema.getType('_Entity'); + const _entityTypeTypes = _entityType.getTypes(); + const _anyScalar = schema.getType('_Any'); + const queryFields = schema.getType('Query').getFields(); + const propertyTypeFields = schema.getType('Property').getFields(); + + t.ok(_serviceType, 'federated _Service type exists in root federated schema'); + t.ok(_serviceTypeFields['sdl'], '_Service type sdl field exists in root federated schema'); + t.ok(_entityType, 'federated _Entity type exists in root federated schema'); + t.equals(_entityTypeTypes.length, 2, '2 entities in root federated schema'); + t.deepEqual(_entityTypeTypes.map((e) => e.name), ['Property', 'Review'], 'entities are Property and Review as declared via @keys directive in root federated schema in root federated schema'); + t.ok(_anyScalar, 'scalar _Any exists'); + t.deepEqual(Object.keys(queryFields), ['_entities', '_service', 'property', 'reviews'], 'federated query fields and user declared query fields are present in root federated schema'); + t.deepEqual(Object.keys(propertyTypeFields), ['id', 'geo', 'addedPropertyField'], 'Property type fields is union between imported components (ie. property type is merged) in root federated schema'); + t.end(); +}) + +Test('federated schema can include custom directive', (t) => { class CustomDirective extends SchemaDirectiveVisitor { // required for our dummy "custom" directive (ie. implement the SchemaDirectiveVisitor interface) visitFieldDefinition() { @@ -220,8 +1101,7 @@ Test('federated schema', (t) => { } const component = new GraphQLComponent({ - types: [ - ` + types: ` directive @custom on FIELD_DEFINITION type Query { @@ -235,8 +1115,7 @@ Test('federated schema', (t) => { id: ID! @external newProp: String } - ` - ], + `, resolvers: { Query: { property(_, { id }) { @@ -246,17 +1125,12 @@ Test('federated schema', (t) => { } } }, - Property: { - __resolveReference() { - - } - } }, directives: { custom: CustomDirective }, federation: true }); - t.test('create federated schema', (t) => { + t.test('federated schema created without error', (t) => { t.plan(1); t.doesNotThrow(() => { component.schema; @@ -277,35 +1151,6 @@ Test('federated schema', (t) => { }); }); -Test('integration: data source', (t) => { - - t.test('component and context injection', async (t) => { - t.plan(4); - - class DataSource { - static get name() { - return 'TestDataSource'; - } - test(...args) { - t.equal(args.length, 2, 'added additional arg'); - t.equal(args[0].data, 'test', 'injected the right data'); - t.equal(args[1], 'test', 'data still passed to original call'); - } - } - - const { context } = new GraphQLComponent({ - dataSources: [new DataSource()] - }); - - const globalContext = await context({ data: 'test' }); - - t.ok(globalContext.dataSources && globalContext.dataSources.TestDataSource, 'dataSource added to context'); - - globalContext.dataSources.TestDataSource.test('test'); - }); - -}); - Test('custom makeExecutableSchema option', (t) => { const baseComponentOptions = { @@ -413,5 +1258,5 @@ Test('custom makeExecutableSchema option', (t) => { st.doesNotThrow(() => componentA.schema, 'builds schema correctly'); st.equal(customMakeExecutableSchema.calledOnce, false, 'does not call custom makeExecutableSchema'); }); - -}); + +}); \ No newline at end of file diff --git a/lib/datasource/__tests__.js b/lib/datasource/__tests__.js index d2172bf..db95ef5 100644 --- a/lib/datasource/__tests__.js +++ b/lib/datasource/__tests__.js @@ -2,6 +2,7 @@ const Test = require('tape'); const { intercept, createDataSourceInjection } = require('./index'); +const GraphQLComponent = require('../index'); Test('intercepts', (t) => { @@ -46,7 +47,6 @@ Test('intercepts', (t) => { }); Test('injection', (t) => { - t.test('dataSource injection function empty', (t) => { t.plan(1); @@ -81,11 +81,11 @@ Test('injection', (t) => { const injection = createDataSourceInjection(component); const globalContext = { data: 'test' }; - + globalContext.dataSources = injection(globalContext); t.ok(globalContext.dataSources && globalContext.dataSources.TestDataSourceInjection, 'dataSource added to context'); - + globalContext.dataSources.TestDataSourceInjection.test('test'); }); @@ -115,11 +115,11 @@ Test('injection', (t) => { const injection = createDataSourceInjection(component, [new DataSource()]); const globalContext = { data: 'test' }; - + globalContext.dataSources = injection(globalContext); t.ok(globalContext.dataSources && globalContext.dataSources.TestDataSourceInjection, 'dataSource added to context'); - + globalContext.dataSources.TestDataSourceInjection.test('test'); }); @@ -141,5 +141,33 @@ Test('injection', (t) => { injection({}); }, 'no exception thrown'); }); +}); + +Test('integration: data source', (t) => { + + t.test('component and context injection', async (t) => { + t.plan(4); + + class DataSource { + static get name() { + return 'TestDataSource'; + } + test(...args) { + t.equal(args.length, 2, 'added additional arg'); + t.equal(args[0].data, 'test', 'injected the right data'); + t.equal(args[1], 'test', 'data still passed to original call'); + } + } + + const { context } = new GraphQLComponent({ + dataSources: [new DataSource()] + }); + + const globalContext = await context({ data: 'test' }); + + t.ok(globalContext.dataSources && globalContext.dataSources.TestDataSource, 'dataSource added to context'); + + globalContext.dataSources.TestDataSource.test('test'); + }); }); \ No newline at end of file diff --git a/lib/datasource/index.js b/lib/datasource/index.js index c02e166..0946096 100644 --- a/lib/datasource/index.js +++ b/lib/datasource/index.js @@ -1,6 +1,6 @@ 'use strict'; -const debug = require('debug')('graphql-component:dataSource'); +const debug = require('debug')('graphql-component:datasource'); const intercept = function (instance, context) { debug(`intercepting ${instance.constructor.name}`); @@ -11,7 +11,7 @@ const intercept = function (instance, context) { return target[key]; } const original = target[key]; - + return function (...args) { return original.call(instance, context, ...args); }; @@ -31,7 +31,7 @@ const createDataSourceInjection = function (root, dataSourceOverrides = []) { debug(`overriding datasource ${override.constructor.name}`); dataSources[override.constructor.name] = intercept(override, context); } - + if (root.dataSources && root.dataSources.length > 0) { for (const dataSource of root.dataSources) { const name = dataSource.constructor.name; diff --git a/lib/delegate/__tests__.js b/lib/delegate/__tests__.js deleted file mode 100644 index df40d80..0000000 --- a/lib/delegate/__tests__.js +++ /dev/null @@ -1,2341 +0,0 @@ -const Test = require('tape'); -const gql = require('graphql-tag'); -const graphql = require('graphql'); -const GraphQLComponent = require('../'); - -Test('contextValue not passed to delegateToComponent', async (t) => { - const primitive = new GraphQLComponent({ - types: ` - type A { - aField: String - anotherAField: String - } - type Query { - a: A - } - `, - resolvers: { - Query: { - a() { - return { - aField: 'a field', - anotherAField: 'another a field' - } - } - } - } - }); - - const composite = new GraphQLComponent({ - types: ` - type A { - addedField: String - } - type Query { - a: A - } - `, - resolvers: { - Query: { - a: async function (_, _args, context, info) { - return GraphQLComponent.delegateToComponent(primitive, { - info - }); - } - }, - A: { - addedField() { - return 'added field' - } - } - }, - imports: [ - primitive - ] - }); - - const document = gql` - query { - a { - aField - addedField - } - } - `; - - const result = await graphql.execute({ - document, - schema: composite.schema, - rootValue: undefined, - contextValue: {} - }); - t.equals(result.data.a, null, 'expected null response'); - t.equals(result.errors[0].message, 'delegateToComponent requires the contextValue from the calling resolver', 'meaningful error message regarding required contextValue is propagated'); - t.end(); -}); - -Test('info not passed to delegateToComponent', async (t) => { - const primitive = new GraphQLComponent({ - types: ` - type A { - aField: String - anotherAField: String - } - type Query { - a: A - } - `, - resolvers: { - Query: { - a() { - return { - aField: 'a field', - anotherAField: 'another a field' - } - } - } - } - }); - - const composite = new GraphQLComponent({ - types: ` - type A { - addedField: String - } - type Query { - a: A - } - `, - resolvers: { - Query: { - a: async function (_, _args, context) { - return GraphQLComponent.delegateToComponent(primitive, { - contextValue: context - }); - } - }, - A: { - addedField() { - return 'added field' - } - } - }, - imports: [ - primitive - ] - }); - - const document = gql` - query { - a { - aField - addedField - } - } - `; - - const result = await graphql.execute({ - document, - schema: composite.schema, - rootValue: undefined, - contextValue: {} - }); - t.equals(result.data.a, null, 'expected null response'); - t.equals(result.errors[0].message, 'delegateToComponent requires the info object from the calling resolver', 'meaningful error message regarding required info object is propagated'); - t.end(); -}); - -Test('composite component delegates from root type resolver to primitive component field with same name, no sub path', async (t) => { - const primitive = new GraphQLComponent({ - types: ` - type A { - aField: String - anotherAField: String - } - type Query { - a: A - } - `, - resolvers: { - Query: { - a() { - return { - aField: 'a field', - anotherAField: 'another a field' - } - } - } - } - }); - - const composite = new GraphQLComponent({ - types: ` - type A { - addedField: String - } - type Query { - a: A - } - `, - resolvers: { - Query: { - a: async function (_, _args, context, info) { - return GraphQLComponent.delegateToComponent(primitive, { - contextValue: context, - info - }); - } - }, - A: { - addedField() { - return 'added field' - } - } - }, - imports: [ - primitive - ] - }); - - const document = gql` - query { - composite1: a { - aField - addedField - } - - composite2: a { - anotherAField - addedField - } - } - `; - - const result = await graphql.execute({ - document, - schema: composite.schema, - rootValue: undefined, - contextValue: {} - }); - - t.ok(!result.errors, 'no errors'); - - const { composite1, composite2 } = result.data; - - t.deepEqual(composite1, { aField: 'a field', addedField: 'added field' }, 'received correct first result'); - t.deepEqual(composite2, { anotherAField: 'another a field', addedField: 'added field' }, 'received correct second result'); - t.end(); -}); - -Test('composite delegates from root type resolver to primitive component field with different name, no sub path', async (t) => { - const primitive = new GraphQLComponent({ - types: ` - type A { - aField: String - anotherAField: String - } - type Query { - a: A - } - `, - resolvers: { - Query: { - a() { - return { - aField: 'a field', - anotherAField: 'another a field' - } - } - } - } - }); - - const composite = new GraphQLComponent({ - types: ` - type A { - addedField: String - } - type Query { - b: A - } - `, - resolvers: { - Query: { - b: async function (_, _args, context, info) { - return GraphQLComponent.delegateToComponent(primitive, { - targetRootField: 'a', - contextValue: context, - info - }); - } - }, - A: { - addedField() { - return 'added field' - } - } - }, - imports: [ - primitive - ] - }); - - const document = gql` - query { - composite1: b { - aField - addedField - } - - composite2: b { - anotherAField - addedField - } - } - `; - - const result = await graphql.execute({ - document, - schema: composite.schema, - rootValue: undefined, - contextValue: {} - }); - - t.ok(!result.errors, 'no errors'); - - const { composite1, composite2 } = result.data; - - t.deepEqual(composite1, { aField: 'a field', addedField: 'added field' }, 'received correct first result'); - t.deepEqual(composite2, { anotherAField: 'another a field', addedField: 'added field' }, 'received correct second result'); - t.end(); -}); - -Test('composite component delegates from root type resolver to primitive component field with different name, with sub path', async (t) => { - const primitive = new GraphQLComponent({ - types: ` - type A { - aField: String - anotherAField: String - } - type Query { - a: A - } - `, - resolvers: { - Query: { - a(_root, _args, _context, info) { - const selections = info.fieldNodes[0].selectionSet.selections.map((selectionNode) => { return selectionNode.name.value}); - t.equals(selections.indexOf('bField'), -1, 'parent field not in sub path not included in child selection set'); - return { - aField: 'a field', - anotherAField: 'another a field' - } - } - } - } - }); - - const composite = new GraphQLComponent({ - types: ` - type Query { - b: B - } - type A { - addedField: String - } - type B { - bField: String - a: A - } - `, - resolvers: { - Query: { - b: async function (_, _args, context, info) { - const a = GraphQLComponent.delegateToComponent(primitive, { - targetRootField: 'a', - subPath: 'a', - contextValue: context, - info - }); - return { - bField: 'b field', - a - } - } - }, - A: { - addedField() { - return 'added field'; - } - } - }, - imports: [ - primitive - ] - }); - - const document = gql` - query { - composite1: b { - bField - a { - aField - anotherAField - } - } - - composite2: b { - bField - a { - anotherAField - addedField - } - } - } - `; - - const result = await graphql.execute({ - document, - schema: composite.schema, - rootValue: undefined, - contextValue: {} - }); - - t.notOk(result.errors, 'no errors'); - - const { composite1, composite2 } = result.data; - - t.deepEqual(composite1, { bField: 'b field', a: { aField: 'a field', anotherAField: 'another a field' }}, 'received correct first result'); - t.deepEqual(composite2, { bField: 'b field', a: { anotherAField: 'another a field', addedField: 'added field' }}, 'received correct second result'); - t.end(); -}); - -Test('composite component delegates from non-root type resolver to primitive component field with same name, no sub path', async (t) => { - const primitive = new GraphQLComponent({ - types: ` - type A { - aField: String - anotherAField: String - } - type Query { - a: A - } - `, - resolvers: { - Query: { - a() { - return { - aField: 'a field', - anotherAField: 'another a field' - } - } - } - } - }); - - const composite = new GraphQLComponent({ - types: ` - type B { - a: A - } - type A { - addedField: String - } - type Query { - b: B - } - `, - resolvers: { - Query: { - b: async function () { - return {}; - } - }, - B: { - a(_, _args, context, info) { - return GraphQLComponent.delegateToComponent(primitive, { - contextValue: context, - info - }); - } - }, - A: { - addedField() { - return 'added field' - } - } - }, - imports: [ - primitive - ] - }); - - const document = gql` - query { - composite1: b { - a { - aField - addedField - } - } - composite2: b { - a { - anotherAField - addedField - } - } - }`; - - const result = await graphql.execute({ - document, - schema: composite.schema, - rootValue: undefined, - contextValue: {} - }); - - t.ok(!result.errors, 'no errors'); - - const { composite1, composite2 } = result.data; - - t.deepEqual(composite1, { a: { aField: 'a field', addedField: 'added field', }}, 'received correct first result'); - t.deepEqual(composite2, { a: { anotherAField: 'another a field', addedField: 'added field' }}, 'received correct second result'); - t.end(); -}); - -Test('composite component delegates from non-root type resolver to primitive component field with different name, no sub path', async (t) => { - const primitive = new GraphQLComponent({ - types: ` - type A { - aField: String - anotherAField: String - } - type Query { - a: A - } - `, - resolvers: { - Query: { - a() { - return { - aField: 'a field', - anotherAField: 'another a field' - } - } - } - } - }); - - const composite = new GraphQLComponent({ - types: ` - type B { - bField: A - } - type A { - addedField: String - } - type Query { - b: B - } - `, - resolvers: { - Query: { - b: async function () { - return {}; - } - }, - B: { - bField(_, args, context, info) { - return GraphQLComponent.delegateToComponent(primitive, { - targetRootField: 'a', - contextValue: context, - info - }); - } - }, - A: { - addedField() { - return 'added field' - } - } - }, - imports: [ - primitive - ] - }); - - const document = gql` - query { - composite1: b { - bField { - aField - addedField - } - } - composite2: b { - bField { - anotherAField - addedField - } - } - }`; - - const result = await graphql.execute({ - document, - schema: composite.schema, - rootValue: undefined, - contextValue: {} - }); - - t.ok(!result.errors, 'no errors'); - - const { composite1, composite2 } = result.data; - - t.deepEqual(composite1, { bField: { aField: 'a field', addedField: 'added field', }}, 'received correct first result'); - t.deepEqual(composite2, { bField: { anotherAField: 'another a field', addedField: 'added field' }}, 'received correct second result'); - t.end(); -}); - -Test('composite component delegates from non-root type resolver to primitive component field with different name, with sub path', async (t) => { - const primitive = new GraphQLComponent({ - types: ` - type A { - aField: String - anotherAField: String - } - type Query { - a: A - } - `, - resolvers: { - Query: { - a(_root, _args, _context, info) { - const selections = info.fieldNodes[0].selectionSet.selections.map((selectionNode) => { return selectionNode.name.value}); - t.equals(selections.indexOf('compositeBField'), -1, 'parent field not in sub path not included in child selection set'); - return { - aField: 'a field', - anotherAField: 'another a field' - } - } - } - } - }); - - const composite = new GraphQLComponent({ - types: ` - type Query { - b: B - } - type A { - addedField: String - } - type CompositeB { - compositeBField: String - a: A - } - type B { - compositeB: CompositeB - } - `, - resolvers: { - Query: { - b: async function (){ - return {} - } - }, - B: { - async compositeB(_root, _args, context, info) { - const a = GraphQLComponent.delegateToComponent(primitive, { - targetRootField: 'a', - subPath: 'a', - contextValue: context, - info - }); - return { compositeBField: 'composite b field', a } - } - }, - A: { - addedField() { - return 'added field' - } - } - }, - imports: [ - primitive - ] - }); - - const document = gql` - query { - b { - compositeB { - compositeBField - a { - aField - anotherAField - addedField - } - } - } - } - `; - - const result = await graphql.execute({ - document, - schema: composite.schema, - rootValue: undefined, - contextValue: {} - }); - - t.notOk(result.errors, 'no errors'); - t.deepEqual(result.data, { b: { compositeB: { compositeBField: 'composite b field', a: { aField: 'a field', anotherAField: 'another a field', addedField: 'added field'}}}}, 'complex delegation result resolved successfully'); - t.end(); -}); - -Test('delegateToComponent - nested abstract type is resolved without error', async (t) => { - let resolveTypeCount = 0; - let materialNonRootResolverCount = 0; - const primitive = new GraphQLComponent({ - types: ` - type Query { - thingsById(id: ID): ThingsConnection - } - - type ThingsConnection { - edges: [ThingEdge] - } - - type ThingEdge { - node: Thing - } - - interface Thing { - id: ID - } - - type Book implements Thing { - id: ID - title: String - } - - type Mug implements Thing { - id: ID - material: String - } - `, - resolvers: { - Query: { - thingsById() { - return { - edges: [ - { - node: { - id: 1, - title: 'A tale of two cities' - } - }, - { - node: { - id: 2, - } - } - ] - } - } - }, - Thing: { - __resolveType(result) { - resolveTypeCount += 1; - if (result.title) { - return 'Book'; - } - return 'Mug'; - } - }, - Mug: { - material() { - materialNonRootResolverCount += 1; - return 'ceramic'; - } - } - } - }); - - const composite = new GraphQLComponent({ - types: ` - type Query { - foo: Foo - } - - type Foo { - things: ThingsConnection - } - `, - resolvers: { - Query: { - async foo(_root, _args, context, info) { - const result = await GraphQLComponent.delegateToComponent(primitive, { - targetRootField: 'thingsById', - info, - contextValue: context, - subPath: 'things' - }); - return {things: result}; - } - } - }, - imports: [primitive] - }); - - const document = gql` - query { - foo { - things { - edges { - node { - id - ... on Book { - title - } - ... on Mug { - material - } - } - } - } - } - } - ` - - const { data, errors } = await graphql.execute({ - document, - schema: composite.schema, - contextValue: {} - }); - const expectedResult = { - foo: { - things: { - edges: [ - { - node: { - id: '1', - title: 'A tale of two cities' - } - }, - { - node: { - id: '2', - material: 'ceramic' - } - } - ] - } - } - } - t.deepEquals(data, expectedResult, 'data is resolved as expected'); - t.equals(resolveTypeCount, 2, '__resolveType called once per item as expected'); - t.equals(materialNonRootResolverCount, 1, 'Mug non-root resolver is only executed 1 time as expected'); - t.notOk(errors, 'no errors'); - t.end(); -}); - -// argument forwarding tests: - -/* - * case 1: target field has no arguments and calling resolver has no arguments - * result: no arguments are forwarded even if caller of delegateToComponent - * provides them -*/ -Test('delegateToComponent - case 1 - no args provided to delegateToComponent', async (t) => { - const reviews = new GraphQLComponent({ - types: ` - type Review { - id: ID - content: String - } - - type Query { - reviews: [Review] - } - `, - resolvers: { - Query: { - reviews(_root, args) { - t.equals(Object.keys(args).length, 0, 'no args forwarded to target field'); - return [{ id: 'revid', content: 'some review content'}]; - } - } - } - }); - - const property = new GraphQLComponent({ - types: ` - type Property { - id: ID - reviews: [Review] - } - - type Query { - property: Property - } - `, - resolvers: { - Query: { - async property(_root, _args, context, info) { - const revs = await GraphQLComponent.delegateToComponent(reviews, { - targetRootField: 'reviews', - subPath: 'reviews', - info, - contextValue: context - }) - return { id: '1', reviews: revs }; - } - } - }, - imports: [reviews] - }); - - const result = await graphql.execute({ - document: gql` - query { - property { - id - reviews { - id - content - } - } - } - `, - schema: property.schema, - contextValue: {} - }); - t.deepEqual(result.data, { property: { id: '1', reviews: [{ id: 'revid', content: 'some review content'}]}}, 'propery reviews successfully resolved'); - t.end(); -}); - -Test('delegateToComponent - case 1 - args provided to delegateToComponent', async (t) => { - const reviews = new GraphQLComponent({ - types: ` - type Review { - id: ID - content: String - } - - type Query { - reviews: [Review] - } - `, - resolvers: { - Query: { - reviews(_root, args) { - t.equals(Object.keys(args).length, 0, 'no args forwarded to target field'); - return [{ id: 'revid', content: 'some review content'}]; - } - } - } - }); - - const property = new GraphQLComponent({ - types: ` - type Property { - id: ID - reviews: [Review] - } - - type Query { - property: Property - } - `, - resolvers: { - Query: { - async property(_root, _args, context, info) { - const revs = await GraphQLComponent.delegateToComponent(reviews, { - targetRootField: 'reviews', - subPath: 'reviews', - info, - contextValue: context, - args: { - foo: 'bar' - } - }) - return { id: '1', reviews: revs }; - } - } - }, - imports: [reviews] - }); - - const result = await graphql.execute({ - document: gql` - query { - property { - id - reviews { - id - content - } - } - } - `, - schema: property.schema, - contextValue: {} - }); - t.deepEqual(result.data, { property: { id: '1', reviews: [{ id: 'revid', content: 'some review content'}]}}, 'propery reviews successfully resolved'); - t.end(); -}) - -/* - * case 2: target field has no arguments and calling resolver has arguments - * result: no arguments are forwarded from calling resolver or from the caller - * of delegateToComponent if provided -*/ -Test('delegateToComponent - case 2 - no args provided to delegateToComponent', async (t) => { - const reviews = new GraphQLComponent({ - types: ` - type Review { - id: ID - content: String - } - - type Query { - reviews: [Review] - } - `, - resolvers: { - Query: { - reviews(_root, args) { - t.equals(Object.keys(args).length, 0, 'no args forwarded to target field'); - return [{ id: 'revid', content: 'some review content'}]; - } - } - } - }); - - const property = new GraphQLComponent({ - types: ` - type Property { - id: ID - reviews: [Review] - } - - type Query { - propertyById(id: ID): Property - } - `, - resolvers: { - Query: { - async propertyById(_root, args, context, info) { - t.ok(args.id, 'argument present in delegating resolver'); - const revs = await GraphQLComponent.delegateToComponent(reviews, { - targetRootField: 'reviews', - subPath: 'reviews', - info, - contextValue: context - }) - return { id: '1', reviews: revs }; - } - } - }, - imports: [reviews] - }); - - const result = await graphql.execute({ - document: gql` - query { - propertyById(id: 1) { - id - reviews { - id - content - } - } - } - `, - schema: property.schema, - contextValue: {} - }); - t.deepEqual(result.data, { propertyById: { id: '1', reviews: [{ id: 'revid', content: 'some review content'}]}}, 'propery reviews successfully resolved'); - t.end(); -}); - -Test('delegateToComponent - case 2 - args provided to delegateToComponent', async (t) => { - const reviews = new GraphQLComponent({ - types: ` - type Review { - id: ID - content: String - } - - type Query { - reviews: [Review] - } - `, - resolvers: { - Query: { - reviews(_root, args) { - t.equals(Object.keys(args).length, 0, 'no args forwarded to target field'); - return [{ id: 'revid', content: 'some review content'}]; - } - } - } - }); - - const property = new GraphQLComponent({ - types: ` - type Property { - id: ID - reviews: [Review] - } - - type Query { - propertyById(id: ID): Property - } - `, - resolvers: { - Query: { - async propertyById(_root, args, context, info) { - t.ok(args.id, 'argument present in delegating resolver'); - const revs = await GraphQLComponent.delegateToComponent(reviews, { - targetRootField: 'reviews', - subPath: 'reviews', - info, - contextValue: context, - args: { - foo: 'bar' - } - }) - return { id: '1', reviews: revs }; - } - } - }, - imports: [reviews] - }); - - const result = await graphql.execute({ - document: gql` - query { - propertyById(id: 1) { - id - reviews { - id - content - } - } - } - `, - schema: property.schema, - contextValue: {} - }); - t.deepEqual(result.data, { propertyById: { id: '1', reviews: [{ id: 'revid', content: 'some review content'}]}}, 'propery reviews successfully resolved'); - t.end(); -}); - -/* -* case 3: target field has arguments and calling resolver has arguments -* result: matching args to the target field provided by the caller of -* delegateToComponent take priority and are forwarded, otherwise falling back -* to matching args from the calling resolver, no other args are forwarded -*/ - -Test('delegateToComponent - case 3 - calling resolver has matching args/extra args, no args provided to delegateToComponent', async (t) => { - const reviews = new GraphQLComponent({ - types: ` - type Review { - id: ID - content: String - } - - type Query { - reviewsByPropertyId(id: ID): [Review] - } - `, - resolvers: { - Query: { - reviewsByPropertyId(_root, args) { - t.equals(Object.keys(args).length, 1, '1 arg forwarded to target field'); - t.ok(args.id, 'id arg from calling resolver forwarded'); - t.notOk(args.cached, 'cached arg from calling resolver is not forwarded'); - return [{ id: 'revid', content: 'some review content'}]; - } - } - } - }); - - const property = new GraphQLComponent({ - types: ` - type Property { - id: ID - reviews: [Review] - } - - type Query { - propertyById(id: ID!, cached: Boolean!): Property - } - `, - resolvers: { - Query: { - async propertyById(_root, args, context, info) { - t.ok(args.id, 'id argument present in delegating resolver'); - t.ok(args.cached, 'cached argument present in resolver'); - const revs = await GraphQLComponent.delegateToComponent(reviews, { - targetRootField: 'reviewsByPropertyId', - subPath: 'reviews', - info, - contextValue: context, - }) - return { id: '1', reviews: revs }; - } - } - }, - imports: [reviews] - }); - - const result = await graphql.execute({ - document: gql` - query { - propertyById(id: 1, cached: true) { - id - reviews { - id - content - } - } - } - `, - schema: property.schema, - contextValue: {} - }); - t.deepEqual(result.data, { propertyById: { id: '1', reviews: [{ id: 'revid', content: 'some review content'}]}}, 'propery reviews successfully resolved'); - t.end(); -}); - -Test('delegateToComponent - case 3 - calling resolver has matching args/extra args, rest of target args provided by delegateToComponent caller', async (t) => { - const reviews = new GraphQLComponent({ - types: ` - type Review { - id: ID - content: String - } - - type Query { - reviewsByPropertyId(id: ID, foo: String, bar: String): [Review] - } - `, - resolvers: { - Query: { - reviewsByPropertyId(_root, args) { - t.equals(Object.keys(args).length, 3, '3 args forwarded to target field'); - t.ok(args.id, 'id arg from calling resolver forwarded'); - t.equals(args.id, '1', 'args.id value is 1 from calling resolver'); - t.equals(args.foo, 'foo', 'args.foo provided by delegateToComponent caller is passed with expected value'); - t.equals(args.bar, 'bar', 'args.bar provided by delegateToComponent caller is passed with expected value'); - t.notOk(args.cached, 'args.cached from calling resolver is not forwarded'); - return [{ id: 'revid', content: 'some review content'}]; - } - } - } - }); - - const property = new GraphQLComponent({ - types: ` - type Property { - id: ID - reviews: [Review] - } - - type Query { - propertyById(id: ID!, cached: Boolean!): Property - } - `, - resolvers: { - Query: { - async propertyById(_root, args, context, info) { - t.ok(args.id, 'id argument present in delegating resolver'); - t.ok(args.cached, 'cached argument present in resolver'); - const revs = await GraphQLComponent.delegateToComponent(reviews, { - targetRootField: 'reviewsByPropertyId', - subPath: 'reviews', - info, - contextValue: context, - args: { - foo: 'foo', - bar: 'bar' - } - }); - return { id: '1', reviews: revs }; - } - } - }, - imports: [reviews] - }); - - const result = await graphql.execute({ - document: gql` - query { - propertyById(id: 1, cached: true) { - id - reviews { - id - content - } - } - } - `, - schema: property.schema, - contextValue: {} - }); - t.deepEqual(result.data, { propertyById: { id: '1', reviews: [{ id: 'revid', content: 'some review content'}]}}, 'propery reviews successfully resolved'); - t.end(); -}); - -Test('delegateToComponent - case 3 - calling resolver has a matching arg/no extra args, but matching arg is overridden by arg passed to delegateToComponent', async (t) => { - const reviews = new GraphQLComponent({ - types: ` - type Review { - id: ID - content: String - } - - type Query { - reviewsByPropertyId(id: ID): [Review] - } - `, - resolvers: { - Query: { - reviewsByPropertyId(_root, args) { - t.equals(Object.keys(args).length, 1, '1 arg forwarded to target field'); - t.equals(args.id, '2', 'id arg from calling resolver forwarded and has overridden value'); - return [{ id: 'revid', content: 'some review content'}]; - } - } - } - }); - - const property = new GraphQLComponent({ - types: ` - type Property { - id: ID - reviews: [Review] - } - - type Query { - propertyById(id: ID!): Property - } - `, - resolvers: { - Query: { - async propertyById(_root, args, context, info) { - t.ok(args.id, 'id argument present in delegating resolver'); - const revs = await GraphQLComponent.delegateToComponent(reviews, { - targetRootField: 'reviewsByPropertyId', - subPath: 'reviews', - info, - contextValue: context, - args: { - id: '2' - } - }) - return { id: '1', reviews: revs }; - } - } - }, - imports: [reviews] - }); - - const result = await graphql.execute({ - document: gql` - query { - propertyById(id: 1) { - id - reviews { - id - content - } - } - } - `, - schema: property.schema, - contextValue: {} - }); - t.deepEqual(result.data, { propertyById: { id: '1', reviews: [{ id: 'revid', content: 'some review content'}]}}, 'propery reviews successfully resolved'); - t.end(); -}); - -/* -* case 4: target field has arguments and calling resolver has no arguments -* result: caller of delegateToComponent must provide args to forward and will -* be forwarded if provided -*/ - -Test('delegateToComponent - case 4 - delegateToComponent caller provides all args to target field', async (t) => { - const reviews = new GraphQLComponent({ - types: ` - type Review { - id: ID - content: String - } - - type Query { - reviewsById(id: ID!, foo: String!): [Review] - } - `, - resolvers: { - Query: { - reviewsById(_root, args) { - t.equals(Object.keys(args).length, 2, '2 args forwarded to target field'); - t.equals(args.id, '2', 'id arg forwarded and has value passed to delegateToComponent'); - t.equals(args.foo, 'foo', 'foo arg forwarded and has value passed to delegateToComponent'); - return [{ id: 'revid', content: 'some review content'}]; - } - } - } - }); - - const property = new GraphQLComponent({ - types: ` - type Property { - id: ID - reviews: [Review] - } - - type Query { - property: Property - } - `, - resolvers: { - Query: { - async property(_root, args, context, info) { - t.equals(Object.keys(args).length, 0, 'no args present in delegateing resolver'); - const revs = await GraphQLComponent.delegateToComponent(reviews, { - targetRootField: 'reviewsById', - subPath: 'reviews', - info, - contextValue: context, - args: { - id: '2', - foo: 'foo' - } - }) - return { id: '1', reviews: revs }; - } - } - }, - imports: [reviews] - }); - - const result = await graphql.execute({ - document: gql` - query { - property { - id - reviews { - id - content - } - } - } - `, - schema: property.schema, - contextValue: {} - }); - t.deepEqual(result.data, { property: { id: '1', reviews: [{ id: 'revid', content: 'some review content'}]}}, 'propery reviews successfully resolved'); - t.end(); -}); - -Test('delegateToComponent - user provided args of various types: ID (as Int), ID (as string), String, Int, Float, Boolean, enum, input object are passed', async (t) => { - - const reviewsComponent = new GraphQLComponent({ - types: ` - type Review { - id: ID - content: String - } - - input Dates { - from: String - to: String - } - - enum Status { - PENDING - COMPLETE - } - - type Query { - reviewsByPropertyId( - intID: ID! - stringID: ID! - bool: Boolean! - int: Int! - float: Float! - string: String! - status: Status! - dates: Dates! - arrayOfIntID: [ID!]! - arrayOfStringID: [ID!]! - arrayOfInt: [Int!]! - arrayOfFloat: [Float!]! - arrayOfString: [String!]! - arrayOfEnum: [Status!]! - arrayOfObj: [Dates!]! - ): [Review] - } - `, - resolvers: { - Query: { - reviewsByPropertyId(_root, args) { - t.equals(Object.keys(args).length, 15, 'exactly 15 args passed'); - t.equals(args.intID, '2', 'intID arg from delegateToComponent coerced and passed through as expected'); - t.equals(args.stringID, '9', 'stringID arg from delegateToComponent coerced and passed through as expected'); - t.equals(args.bool, true, 'bool arg from delegateToComponent coerced and passed through as expected'); - t.equals(args.int, 101, 'int arg from delegateToComponent coerced and passed through as expected'); - t.equals(args.float, 49.2, 'float arg from delegateToComponent coerced and passed through as expected'); - t.equals(args.string, 'foobar', 'string arg from delegateToComponent coerced and passed through as expected'); - t.equals(args.status, 'COMPLETE', 'status arg from delegateToComponent coerced and passed through as expected'); - t.deepEqual(args.dates, { from: 'from-date', to: 'to-date' }, 'dates arg from delegateToComponent coerced and passed through as expected'); - t.deepEqual(args.arrayOfIntID, ['1', '2'], 'arrayOfIDInt arg from delegateToComponent coerced and passed through as expected'); - t.deepEqual(args.arrayOfStringID, ['3a', '4a'], 'arrayOfIDString arg from delegateToComponent coerced and passed through as expected'); - t.deepEqual(args.arrayOfInt, [5, 6], 'arrayOfInt arg from delegateToComponent coerced and passed through as expected'); - t.deepEqual(args.arrayOfFloat, [7.0, 8.0], 'arrayOfFloat arg from delegateToComponent coerced and passed through as expected'); - t.deepEqual(args.arrayOfString, ['hello', 'goodbye'], 'arrayOfString arg from delegateToComponent coerced and passed through as expected'); - t.deepEqual(args.arrayOfEnum, ['COMPLETE', 'PENDING'], 'arrayOfEnum arg from delegateToComponent coerced and passed through as expected'); - t.deepEqual(args.arrayOfObj, [{ from: 'from-date-1', to: 'to-date-1' }, { from: 'from-date-2', to: 'to-date-2' }], 'arrayOfObj arg from delegateToComponent coerced and passed through as expected'); - return [{ id: 'revid', content: 'some review content'}]; - } - } - } - }); - - const property = new GraphQLComponent({ - types: ` - type Property { - id: ID - reviews: [Review] - } - - type Query { - propertyById(id: ID): Property - } - `, - resolvers: { - Query: { - propertyById() { - return { id: 1 }; - } - }, - Property: { - async reviews(_root, _args, context, info) { - const reviews = await GraphQLComponent.delegateToComponent(reviewsComponent, { - info, - contextValue: context, - targetRootField: 'reviewsByPropertyId', - args: { - intID: 2, - stringID: '9', - bool: true, - int: 101, - float: 49.2, - string: 'foobar', - status: 'COMPLETE', - dates: { from: 'from-date', to: 'to-date' }, - arrayOfIntID: [1, 2], - arrayOfStringID: ['3a', '4a'], - arrayOfInt: [5, 6], - arrayOfFloat: [7.0, 8.0], - arrayOfString: ['hello', 'goodbye'], - arrayOfEnum: ['COMPLETE', 'PENDING'], - arrayOfObj: [{ from: 'from-date-1', to: 'to-date-1'}, {from: 'from-date-2', to: 'to-date-2'}] - } - }); - return reviews; - } - } - }, - imports: [reviewsComponent] - }); - - const result = await graphql.execute({ - document: gql` - query { - propertyById(id: 1) { - id - reviews { - id - content - } - } - } - `, - schema: property.schema, - contextValue: {} - }); - t.deepEqual(result.data, { propertyById: { id: '1', reviews: [{ id: 'revid', content: 'some review content'}]}}, 'propery reviews successfully resolved'); - t.end(); -}); - -Test('delegateToComponent - user passes wrong type for arg', async (t) => { - const reviewsComponent = new GraphQLComponent({ - types: ` - type Review { - id: ID - content: String - } - - type Query { - reviewsByPropertyId(id: ID!): [Review] - } - `, - resolvers: { - Query: { - reviewsByPropertyId() { - return [{ id: 'revid', content: 'some review content'}]; - } - } - } - }); - - const property = new GraphQLComponent({ - types: ` - type Property { - id: ID - reviews: [Review] - } - - type Query { - propertyById(id: ID): Property - } - `, - resolvers: { - Query: { - propertyById() { - return { id: 1 }; - } - }, - Property: { - async reviews(_root, _args, context, info) { - const reviews = await GraphQLComponent.delegateToComponent(reviewsComponent, { - info, - contextValue: context, - targetRootField: 'reviewsByPropertyId', - args: { - id: true - } - }); - return reviews; - } - } - }, - imports: [reviewsComponent] - }); - - const result = await graphql.execute({ - document: gql` - query { - propertyById(id: 1) { - id - reviews { - id - content - } - } - } - `, - schema: property.schema, - contextValue: {} - }); - t.deepEqual(result.data, { propertyById: { id: '1', reviews: null } }, 'property partially resolves, reviews null'); - t.equal(result.errors[0].message, 'Invalid value true: Expected type ID. ID cannot represent value: true', 'type mismatch error propagated'); - t.end(); -}); - -Test('delegateToComponent - target field non-nullable arg is not passed', async (t) => { - const reviewsComponent = new GraphQLComponent({ - types: ` - type Review { - id: ID - content: String - } - - type Query { - reviewsByPropertyId(id: ID!): [Review] - } - `, - resolvers: { - Query: { - reviewsByPropertyId() { - return [{ id: 'revid', content: 'some review content'}]; - } - } - } - }); - - const property = new GraphQLComponent({ - types: ` - type Property { - id: ID - reviews: [Review] - } - - type Query { - propertyById(id: ID): Property - } - `, - resolvers: { - Query: { - propertyById() { - return { id: 1 }; - } - }, - Property: { - async reviews(_root, _args, context, info) { - const reviews = await GraphQLComponent.delegateToComponent(reviewsComponent, { - info, - contextValue: context, - targetRootField: 'reviewsByPropertyId' - }); - return reviews; - } - } - }, - imports: [reviewsComponent] - }); - - const result = await graphql.execute({ - document: gql` - query { - propertyById(id: 1) { - id - reviews { - id - content - } - } - } - `, - schema: property.schema, - contextValue: {} - }); - t.deepEqual(result.data, { propertyById: { id: '1', reviews: null } }, 'property partially resolves, reviews null'); - t.equal(result.errors.length, 1, '1 error returned') - t.equal(result.errors[0].message, `Argument "id" of required type "ID!" was not provided.`, 'required arg error message is propagated'); - t.deepEqual(result.errors[0].path, ['propertyById', 'reviews'], 'error path is as expected'); - t.end(); -}); - -Test('delegateToComponent - with errors (selection set order doesnt matter)', async (t) => { - const primitive = new GraphQLComponent({ - types: ` - type Query { - foo: Foo - } - - type Foo { - a: String! - b: String! - } - `, - resolvers: { - Query: { - foo() { - return { a: 'a', b: null }; - } - } - } - }); - - const composite = new GraphQLComponent({ - types: ` - type Query { - bar: Foo - } - - type Foo { - c: String - } - `, - resolvers: { - Query: { - async bar(_root, _args, context, info) { - return GraphQLComponent.delegateToComponent(primitive, { - targetRootField: 'foo', - contextValue: context, - info - }); - } - }, - Foo: { - c() { - return 'c'; - } - } - }, - imports: [primitive] - }); - - const document = gql` - query { - abc: bar { - a - b - c - } - bca: bar { - b - c - a - }, - } - `; - - const result = await graphql.execute({ - document, - schema: composite.schema, - contextValue: {} - }); - t.equal,(result.data.abc, null, 'abc query resolves as expected'); - t.equal(result.data.bca, null, 'bca query resolves as expected'); - t.equal(result.errors.length, 2, '2 errors returned'); - t.equal(result.errors[0].message, result.errors[1].message, 'error messages are equal (Foo.b not nullable) regardless of differing selection set ordering'); - t.deepEqual(result.errors[0].path, ['abc', 'b'], 'first error has path as expected'); - t.deepEqual(result.errors[1].path, ['bca', 'b'], 'second error has path as expected'); - t.end(); -}); - -Test('delegateToComponent - with errors - delegate graphql data result is completely null (return type of target field is not nullable)', async (t) => { - const primitive = new GraphQLComponent({ - types: ` - type Query { - foo: Foo! - } - - type Foo { - a: String! - b: String! - } - `, - resolvers: { - Query: { - foo() { - return { a: 'a', b: null }; - } - } - } - }); - - const composite = new GraphQLComponent({ - types: ` - type Query { - bar: Foo - } - - type Foo { - c: String - } - `, - resolvers: { - Query: { - async bar(_root, _args, context, info) { - return GraphQLComponent.delegateToComponent(primitive, { - targetRootField: 'foo', - contextValue: context, - info - }); - } - }, - Foo: { - c() { - return 'c'; - } - } - }, - imports: [primitive] - }); - - const document = gql` - query { - bar { - a - b - c - } - } - `; - - const result = await graphql.execute({ - document, - schema: composite.schema, - contextValue: {} - }); - t.equal(result.data.bar, null, 'query resolves as expected'); - t.equal(result.errors.length, 1, '1 error returned'); - t.equal(result.errors[0].message, 'Cannot return null for non-nullable field Foo.b.', 'expected error is propagated regardless of completely null delegate result'); - t.deepEqual(result.errors[0].path, ['bar', 'b'], `error's path has expected value`); - t.end(); -}); - -Test('delegateToComponent - errors merged as expected for non-nullable list that allows nullable items', async (t) => { - const primitive = new GraphQLComponent({ - types: ` - type Query { - foos: [Foo]! - } - - type Foo { - a: String! - } - `, - resolvers: { - Query: { - foos() { - return [ { a: 'bar'} , {}, { a: 'baz'} ]; - } - } - } - }); - - const composite = new GraphQLComponent({ - types: ` - type Query { - bar: Bar - } - - type Bar { - foos: [Foo]! - } - `, - resolvers: { - Query: { - async bar(_root, _args, context, info) { - const foos = await GraphQLComponent.delegateToComponent(primitive, { - info, - contextValue: context, - targetRootField: 'foos', - subPath: 'foos' - }); - return { foos }; - } - } - }, - imports: [primitive] - }); - - const document = gql` - query { - bar { - foos { - a - } - } - } - `; - - const result = await graphql.execute({ - document, - schema: composite.schema, - contextValue: {} - }); - - t.deepEqual(result.data.bar.foos[0], { a: 'bar' }, 'first item of list resolved as expected'); - t.deepEqual(result.data.bar.foos[1], null, 'second item is null as expected'); - t.deepEqual(result.data.bar.foos[2], { a: 'baz' }, 'third item of list resolved as expected'); - t.equal(result.errors.length, 1, 'one error returned'); - t.equal(result.errors[0].message, 'Cannot return null for non-nullable field Foo.a.'); - t.deepEqual(result.errors[0].path, ['bar', 'foos', 1, 'a'], `error's path has expected value`); - t.end(); -}); - -Test('delegateToComponent - with errors - verify error path when delegation occurs from non-root resolver', async (t) => { - const primitive = new GraphQLComponent({ - types: ` - type Query { - a: A - } - - type A { - aField: String - } - `, - resolvers: { - Query: { - a() { - throw new Error('error retrieving A'); - } - } - } - }); - - const composite = new GraphQLComponent({ - types: ` - type Query { - b: B - } - - type B { - a: A - bField: String - } - `, - resolvers: { - Query: { - b() { - return { bField: 'bField' }; - } - }, - B: { - a(_root, _args, context, info) { - return GraphQLComponent.delegateToComponent(primitive, { - contextValue: context, - info, - subPath: 'a' - }); - } - } - }, - imports: [primitive] - }); - - const document = gql` - query { - b { - a { - aField - } - bField - } - } - `; - - const result = await graphql.execute({ - schema: composite.schema, - document, - contextValue: {} - }); - t.equal(result.data.b.a, null, 'b.a is null as expected due to error'); - t.equal(result.data.b.bField, 'bField', `b's field resolved as expected`); - t.equal(result.errors.length, 1, '1 error returned'); - t.equal(result.errors[0].message, 'error retrieving A'); - t.deepEqual(result.errors[0].path, ['b', 'a']); - t.end(); -}); - -Test(`delegateToComponent - variable in outer query for type that doesn't exist in schema being delegated to`, async (t) => { - const primitive = new GraphQLComponent({ - types: ` - type Query { - a(aone: Int, atwo: String): A - } - - type A { - aField: String - } - `, - resolvers: { - Query: { - a() { - return { aField: 'aField' }; - } - } - } - }); - - const composite = new GraphQLComponent({ - types: ` - type Query { - b(bone: Int, btwo: C): B - } - - type B { - a: A - bField: String - } - - enum C { - CONE - CTWO - } - `, - resolvers: { - Query: { - async b(_root, args, context, info) { - const a = await GraphQLComponent.delegateToComponent(primitive, { - contextValue: context, - info, - targetRootField: 'a', - subPath: 'a', - args: { - aone: args.bone, - atwo: args.btwo - } - }); - return { a, bField: 'bField' }; - } - } - }, - imports: [primitive] - }); - - const document = gql` - query something($first: Int, $second: C, $third: Boolean) { - b(bone: $first, btwo: $second) { - a { - aField - } - bField - } - } - `; - - const result = await graphql.execute({ - schema: composite.schema, - document, - contextValue: {}, - variableValues: { - first: 1, - second: 'CONE', - third: true - } - }); - - t.notOk(result.errors, 'no errors'); - t.deepEqual(result.data.b, { a: { aField: 'aField'}, bField: 'bField' }, 'result resolved as expected'); - t.end(); -}); - -Test(`delegateToComponent - variables are present in delegated selection set`, async (t) => { - const primitive = new GraphQLComponent({ - types: ` - type Query { - a(aone: Int, atwo: String): A - } - - type A { - aField: String - } - `, - resolvers: { - Query: { - a(_root, args, _context, info) { - t.equal(args.aone, 1, 'variable from outer query is passed from non-root resolver who called delegate'); - t.equal(args.atwo, 1, 'variable from outer query is passed from non-root resolver who called delegate'); - t.equal(info.operation.variableDefinitions.length, 1, 'only 1 variable definition forwarded'); - t.equal(info.operation.variableDefinitions[0].variable.name.value, 'first', '$first variable definition is forwarded as it is only one used'); - return { aField: 'aField' }; - } - } - } - }); - - const composite = new GraphQLComponent({ - types: ` - type Query { - b(bone: Int, btwo: C): B - } - - type B { - a(aone: Int, atwo: Int): A - bField: String - } - - enum C { - CONE - CTWO - } - `, - resolvers: { - Query: { - async b() { - return { bField: 'bField' }; - } - }, - B: { - async a(root, args, context, info) { - const a = await GraphQLComponent.delegateToComponent(primitive, { - contextValue: context, - info - }); - return a; - } - } - }, - imports: [primitive] - }); - - const document = gql` - query something($first: Int, $second: C, $third: Boolean) { - b(bone: $first, btwo: $second) { - a(aone: $first, atwo: $first) { - aField - } - bField - } - } - `; - - const result = await graphql.execute({ - schema: composite.schema, - document, - contextValue: {}, - variableValues: { - first: 1, - second: 'CONE', - third: true - } - }); - - t.notOk(result.errors, 'no errors'); - t.deepEqual(result.data.b, { a: { aField: 'aField'}, bField: 'bField' }, 'result resolved as expected'); - t.end(); -}); - -Test('delegateToComponent - delegated selection set from root resolver contains fields that are not in schema being delegated to (pruning)', async (t) => { - const primitive = new GraphQLComponent({ - types: ` - type Query { - a(foo: String): A - } - - type A { - aField1: String - } - - type B { - bField: String - } - `, - resolvers: { - Query: { - a(_root, _args, _context, info) { - t.equal(info.fieldNodes[0].selectionSet.selections.length, 1, 'only 1 field in the selection set'); - t.equal(info.fieldNodes[0].selectionSet.selections[0].name.value, 'aField1', 'expected only aField1 in selection set'); - return { aField1: 'aField1'} - } - } - } - }); - - const composite = new GraphQLComponent({ - types: ` - type Query { - aById(id: ID): A - } - - type A { - b(bArg: Int!): B - } - `, - resolvers: { - Query: { - async aById(root, args, context, info) { - const a = await GraphQLComponent.delegateToComponent(primitive, { - info, - contextValue: context, - targetRootField: 'a' - }) - return { ...a, b: { bField: 'bField' }}; - } - } - }, - imports: [primitive] - }); - - const document = gql` - query aQuery($id: ID, $barg: Int!) { - aById(id: $id) { - aField1 - b(bArg: $barg) { - bField - } - } - } - `; - - const result = await graphql.execute({ - document, - schema: composite.schema, - contextValue: {}, - variableValues: { - id: 1, - barg: 2 - } - }); - t.notOk(result.errors, 'no errors'); - t.deepEquals(result.data, { aById: { aField1: 'aField1', b: { bField: 'bField'}}}, 'result resolved as expected'); - t.end(); -}); - -Test('delegateToComponent - delegated selection set from non-root resolver contains fields that are not in schema being delegated to (pruning)', async (t) => { - const primitive = new GraphQLComponent({ - types: ` - type Query { - a: A - } - - type A { - aField1: String - } - `, - resolvers: { - Query: { - a(_root, _args, _context, info) { - t.equal(info.fieldNodes[0].selectionSet.selections.length, 1, 'only 1 field in the selection set'); - t.equal(info.fieldNodes[0].selectionSet.selections[0].name.value, 'aField1', 'expected only aField1 in selection set'); - return { aField1: 'aField1'}; - } - } - } - }); - - const composite = new GraphQLComponent({ - types: ` - type Query { - b: B - } - - type B { - a: APrime - } - - type APrime { - aField1: String - aPrimeField1: String - } - `, - resolvers: { - Query: { - async b() { - return {}; - } - }, - B: { - async a(_root, _args, context, info) { - const a = await GraphQLComponent.delegateToComponent(primitive, { - contextValue: context, - info - }); - return {...a, aPrimeField1: 'aPrimeField1'}; - } - } - }, - imports: [primitive] - }); - - const document = gql` - query { - b { - a { - aField1 - aPrimeField1 - } - } - } - `; - - const result = await graphql.execute({ - document, - schema: composite.schema, - contextValue: {} - }); - t.notOk(result.errors, 'no errors'); - t.deepEquals(result.data, { b: { a: { aField1: 'aField1', aPrimeField1: 'aPrimeField1'}}}, 'result resolved as expected'); - t.end(); -}); \ No newline at end of file diff --git a/lib/delegate/index.js b/lib/delegate/index.js deleted file mode 100644 index 8f1f5cf..0000000 --- a/lib/delegate/index.js +++ /dev/null @@ -1,367 +0,0 @@ - -const { - Kind, - execute, - subscribe, - isAbstractType, - isObjectType, - print, - astFromValue, - coerceInputValue, - TypeInfo, - TypeNameMetaFieldDef, - visit, - visitWithTypeInfo, -} = require('graphql'); -const set = require('lodash.set'); -const get = require('lodash.get'); -const debug = require('debug')('graphql-component:delegate'); - -/** - * extracts the Array at the given path - * @param {String} path - dot seperated string defining the path to the sub - * selection - * @param {Array} selections - an array of SelectionNode objects - * @returns {Array} - an array of SelectionNode objects - * representing the sub selection set at the given sub path - */ -const getSelectionsForSubPath = function(path, selections) { - if (!path) { - return selections; - } - const parsedPath = path.split('.'); - const getSelections = function (name, selections) { - for (const selection of selections) { - if ((selection.name && selection.name.value === name) || (selection.alias && selection.alias.value === name)) { - return selection.selectionSet && selection.selectionSet.selections; - } - } - }; - - let pathSegment = parsedPath.shift(); - let subSelections = getSelections(pathSegment, selections); - while (parsedPath.length > 0 && !subSelections) { - pathSegment = parsedPath.shift(); - subSelections = getSelections(pathSegment, subSelections); - } - - if (subSelections) { - return subSelections; - } - return selections; -} - -const createSubOperationDocument = function (component, targetRootField, args, subPath, info) { - - // grab the selections starting at the calling resolver forward - let selections = []; - for (const fieldNode of info.fieldNodes) { - if (fieldNode.selectionSet && fieldNode.selectionSet.selections) { - selections.push(...fieldNode.selectionSet.selections); - } - } - - // reduce the selection set to the specified sub path if provided - selections = getSelectionsForSubPath(subPath, selections); - - let targetRootTypeFields; - if (info.operation.operation === 'query') { - targetRootTypeFields = component.schema.getQueryType().getFields(); - } - else if (info.operation.operation === 'mutation') { - targetRootTypeFields = component.schema.getMutationType().getFields(); - } - else if (info.operation.operation === 'subscription') { - targetRootTypeFields = component.schema.getSubscriptionType().getFields(); - } - - // get the arguments defined by the target root field - const definedRootFieldArgs = []; - for (const [fieldName, fieldValue] of Object.entries(targetRootTypeFields)) { - if (fieldName === targetRootField) { - definedRootFieldArgs.push(...fieldValue.args); - } - } - - const targetRootFieldArguments = []; - // skip argument processing if the target root field doesn't have any arguments - if (definedRootFieldArgs.length > 0) { - - const callingResolverArgs = []; - for (const fieldNode of info.fieldNodes) { - if (fieldNode.arguments && fieldNode.arguments.length > 0) { - callingResolverArgs.push(...fieldNode.arguments); - } - } - - for (const definedArg of definedRootFieldArgs) { - if (args[definedArg.name]) { - // coerceInputValue: https://github.com/graphql/graphql-js/blob/v14.7.0/src/utilities/coerceInputValue.js - // is used to take the JS value provided by the caller of - // delegateToComponent and coerce it to the JS value associated with - // the type of the associated GraphQL argument - this will throw an - // error if there is a type mismatch - you wont know until query - // execution time if an error will occur here - const coercedArgValue = coerceInputValue(args[definedArg.name], definedArg.type); - const argValueNode = astFromValue(coercedArgValue, definedArg.type); - targetRootFieldArguments.push({ - kind: Kind.ARGUMENT, - name: { kind: Kind.NAME, value: definedArg.name }, - value: argValueNode - }); - } - else { - // search the calling resolver's arguments for an arg with the same - // name as the target root field's defined argument - const matchingArgIdx = callingResolverArgs.findIndex((argNode) => { - return argNode.name.value === definedArg.name; - }); - - if (matchingArgIdx !== -1) { - targetRootFieldArguments.push(callingResolverArgs[matchingArgIdx]); - } - } - } - } - - const targetRootFieldNode = { - kind: Kind.FIELD, - arguments: targetRootFieldArguments, - name: { kind: Kind.NAME, value: targetRootField }, - selectionSet: { kind: Kind.SELECTION_SET, selections } - }; - - const operationDefinition = { - kind: Kind.OPERATION_DEFINITION, - operation: info.operation.operation, - selectionSet: { kind: Kind.SELECTION_SET, selections: [targetRootFieldNode]} - }; - - const definitions = [operationDefinition]; - - for (const [, fragmentDefinition] of Object.entries(info.fragments)) { - definitions.push(fragmentDefinition); - } - - // assemble the document object which includes the operation definition - // and fragment definitions (if present) - const document = { kind: Kind.DOCUMENT, definitions }; - - const typeInfo = new TypeInfo(component.schema); - const visitFunctions = {}; - // if the schema we are delegating to has abstract types - // add a visitor function that traverses the selection sets - // and adds __typename to the selection set for return types - // that are abstract - if (component.schema.toConfig().types.some((type) => isAbstractType(type))) { - visitFunctions[Kind.SELECTION_SET] = function (node) { - const parentType = typeInfo.getParentType(); - let nodeSelections = node.selections; - if (parentType && isAbstractType(parentType)) { - nodeSelections = nodeSelections.concat({ - kind: Kind.FIELD, - name: { - kind: Kind.NAME, - value: '__typename' - } - }); - } - - if (nodeSelections !== node.selections) { - return { - ...node, - selections: nodeSelections - } - } - } - } - - // prune selection set fields that are not defined in the target schema - visitFunctions[Kind.FIELD] = function (node) { - const parentType = typeInfo.getParentType(); - if (isObjectType(parentType) || isAbstractType(parentType)) { - const parentTypeFields = parentType.getFields(); - const field = node.name.value === '__typename' ? TypeNameMetaFieldDef : parentTypeFields[node.name.value]; - - if (!field) { - return null; - } - } - } - - // if the outer operation has variable definitions, determine - // which ones are used in the delegated document and prune out - // any variable definitions that aren't used - const variableDefinitions = new Set(); - if (info.operation.variableDefinitions.length > 0) { - visitFunctions[Kind.VARIABLE] = function (node) { - const matchingVarDef = info.operation.variableDefinitions.find((varDef) => varDef.variable.name.value === node.name.value); - if (matchingVarDef) { - variableDefinitions.add(matchingVarDef); - } - } - } - - // modify the above constructed document via visit() functions - const modifiedDelegateDocument = visit(document, visitWithTypeInfo(typeInfo, visitFunctions)); - - // TODO: there may be a more elegant way to add the variable definitions to - // the document, but we do know that the operation definition is the first - // definition in the definitions array due to the above construction - modifiedDelegateDocument.definitions[0].variableDefinitions = Array.from(variableDefinitions); - return modifiedDelegateDocument; -} - -/** - * merges errors in the input errors array into data based on the error path - * @param {object} data - the data portion of a graphql result - * @param {Array} errors - the errors portions of a graphql result - * @param {GraphQLResolveInfo} info - the info object from the resolver who - * called delegateToComponent - * @returns - nothing - modifies data parameter by reference - */ -const mergeErrors = function (data, errors, info) { - // use info to build the path tha was traversed prior to the delegateToComponent call - const prePath = []; - let curr = info.path; - while (curr) { - prePath.unshift(curr.key); - curr = curr.prev; - } - - for (let error of errors) { - - const { path } = error; - // errors can occur via graphql.execute() that occur before - // actual execution occurs with which the error won't have a path - // in which case we'll just throw it and fail fast. - if (path) { - let depth = 1; - while (depth <= path.length) { - if (!get(data, path.slice(0, depth))) { - break; - } - depth++; - } - - // merge the error in at a slice path (this is to handle nullability) - set(data, path.slice(0, depth), error); - - // modify the error's path - const returnTypeASTNode = info.returnType.astNode ? info.returnType.astNode : info.returnType.ofType.astNode; - // if the first segment of the error path is a field on the return type - // of the calling resolver it will remain part of the adjusted path - // otherwise we will remove it since that first segment represents - // the resolver field we delegated to which is a detail we want to - // abstract away from the outer operation - if (!returnTypeASTNode.fields.find((field) => field.name.value === error.path[0])) { - error.path.shift(); - } - error.path.unshift(...prePath); - } - else { - throw error; - } - } -} - -/** - * executes (delegates) a graphql operation on the input component's schema - * @param {GraphQLComponent} component - the component to delegate execution to - * @param {object} options - an options object to customize the delegated - * operation - * @param {GraphQLResolveInfo} options.info - the info object from the calling resolver - * @param {object} options.contextValue - the context object from the calling - * resolver - * @param {string} [options.targetRootField] - the name of the root type field - * the delegated operation will execute. Defaults to the field name of the - * calling resolver - * @param {string} [options.subPath] - a dot separated string to limit the - * delegated selection set to a given path in the calling resolver's return type - * @param {object} [options.args] - an object literal whose keys/values are - * passed as args to the delegatee's target field resolver. - * @returns the result of the delegated operation to the targetRootField with - * any errors merged into the result at their given path - */ -const delegateToComponent = async function (component, options) { - let { - subPath, - contextValue, - info, - targetRootField, - args = {} - } = options; - - if (!contextValue) { - throw new Error('delegateToComponent requires the contextValue from the calling resolver'); - } - - if (!info) { - throw new Error('delegateToComponent requires the info object from the calling resolver'); - } - - // default the target root field to be the name of the calling resolver - if (!targetRootField) { - targetRootField = info.fieldName; - } - - const document = createSubOperationDocument(component, targetRootField, args, subPath, info); - - - debug(`delegating ${print(document)} to ${component.name}`); - - if (info.operation.operation === 'query' || info.operation.operation === 'mutation') { - let { data, errors = []} = await execute({ - document, - schema: component.schema, - rootValue: info.rootValue, - contextValue, - variableValues: info.variableValues - }); - - if (!data) { - data = {}; - } - - if (errors.length > 0) { - mergeErrors(data, errors, info); - } - return data[targetRootField]; - } - - const result = await subscribe({ - document, - schema: component.schema, - rootValue: info.rootValue, - contextValue, - variableValues: info.variableValues - }); - - if (Symbol.asyncIterator in result) { - return { - async next() { - const nextResult = await result.next(); - if (nextResult.done) { - return nextResult; - } - - let { value: { data, errors = []}} = nextResult; - - if (!data) { - data = {}; - } - - if (errors.length > 0) { - mergeErrors(data, errors, info); - } - - return { done: false, value: { [targetRootField]: nextResult.value.data[targetRootField]}}; - }, - [Symbol.asyncIterator]() { - return this; - } - } - } -}; - -module.exports = { delegateToComponent }; diff --git a/lib/imports/__tests__.js b/lib/imports/__tests__.js deleted file mode 100644 index b2dcd9a..0000000 --- a/lib/imports/__tests__.js +++ /dev/null @@ -1,320 +0,0 @@ -'use strict'; - -const Test = require('tape'); -const GraphQLComponent = require('../index'); -const { buildDependencyTree, filterTypes } = require('./index'); -const { buildASTSchema, execute } = require('graphql'); -const gql = require('graphql-tag'); -const { SchemaDirectiveVisitor } = require('graphql-tools'); - -Test('buildDependencyTree - ordering', (t) => { - const tree = new GraphQLComponent({ - types: ` - type A { - val: String - } - - type Query { - a: A - } - `, - resolvers: { - Query: { - a() {} - } - }, - imports: [ - new GraphQLComponent({ - types: ` - type B { - val: String - } - - type Query { - a: A - } - `, - resolvers: { - Query: { - b(){} - } - }, - imports: [new GraphQLComponent({ - types: ` - type C { - val: String - } - - type Query { - c: C - } - `, - resolvers: { - Query: { - c(){} - } - } - })] - }) - ] - }); - - const { importedTypes, importedResolvers } = buildDependencyTree(tree); - - t.equal(importedTypes.length, 3, '3 imported types'); - t.equal(importedResolvers.length, 3, '3 imported resolvers'); - - t.equal(importedTypes[0].definitions[0].name.value, 'C', 'C type ordered properly'); - t.equal(importedTypes[1].definitions[0].name.value, 'B', 'B type ordered properly'); - t.equal(importedTypes[2].definitions[0].name.value, 'A', 'A type ordered properly'); - - t.ok(importedResolvers[0].Query.c, 'c resolvers ordered properly'); - t.ok(importedResolvers[1].Query.b, 'b resolvers ordered properly'); - t.ok(importedResolvers[2].Query.a, 'a resolvers ordered properly'); - t.end(); -}); - -Test('buildDependencyTree - directive not implemented', (t) => { - const root = new GraphQLComponent({ - types: ` - directive @foo on FIELD_DEFINITION - - type Query { - a: A @foo - } - - type A { - value: String - } - `, - resolvers: { - Query: { - a(){} - } - } - }); - - try { - buildDependencyTree(root); - } catch (e) { - t.equals(e.message, 'GraphQLComponent defined directive: @foo but did not provide an implementation', 'error thrown with expected error message'); - } - t.end(); -}); - -Test('buildDependencyTree - components with directive collisions', (t) => { - const imp = new GraphQLComponent({ - types: ` - directive @duplicate on FIELD_DEFINITION - - type Bar { - value: String - } - - type Query { - bar: Bar @duplicate - } - `, - Query: { - bar(){} - }, - directives: { - duplicate: class extends SchemaDirectiveVisitor { - visitFieldDefinition() {} - } - } - }); - - const root = new GraphQLComponent({ - types: ` - directive @duplicate on FIELD_DEFINITION - directive @foo on FIELD_DEFINITION - - type Foo { - value: String - } - - type Query { - foo: Foo @duplicate @foo - } - `, - resolvers: { - Query: { - foo() {} - } - }, - imports: [imp], - directives: { - duplicate: class extends SchemaDirectiveVisitor { - visitFieldDefinition(){} - }, - foo: class extends SchemaDirectiveVisitor { - visitFieldDefinition(){} - } - } - }); - - const { importedDirectives } = buildDependencyTree(root); - t.ok(importedDirectives['duplicate'], `root's original duplicate directive imported intact`); - t.ok(importedDirectives[`duplicate_${imp.id}`], `imp's conflicting directive is namespaced`); - t.ok(importedDirectives['foo'], `root's non-conflicting diretive imported intact`); - t.end(); -}); - -Test('integration - directive collisions', (t) => { - let impDuplicateExecuted = 0; - let rootDuplicateExecuted = 0; - let rootFooExecuted = 0; - const imp = new GraphQLComponent({ - types: ` - directive @duplicate on FIELD_DEFINITION - - type Bar { - value: String - } - - type Query { - bar: Bar @duplicate - } - `, - Query: { - bar(){} - }, - directives: { - duplicate: class extends SchemaDirectiveVisitor { - visitFieldDefinition() { - impDuplicateExecuted += 1; - } - } - } - }); - - const root = new GraphQLComponent({ - types: ` - directive @duplicate on FIELD_DEFINITION - directive @foo on FIELD_DEFINITION - - type Foo { - value: String - } - - type Query { - foo: Foo @duplicate @foo - } - `, - resolvers: { - Query: { - foo() {} - } - }, - imports: [imp], - directives: { - duplicate: class extends SchemaDirectiveVisitor { - visitFieldDefinition(){ - rootDuplicateExecuted += 1; - } - }, - foo: class extends SchemaDirectiveVisitor { - visitFieldDefinition(){ - rootFooExecuted += 1; - } - } - } - }); - - const document = gql` - query { - foo { - value - } - bar { - value - } - } - `; - - // execute the above queries to see that the directives executed - execute({ - document, - schema: root.schema, - contextValue: {} - }); - - t.equal(rootDuplicateExecuted, 1, 'root @duplicate directive executed one time as expected'); - t.equal(rootFooExecuted, 1, 'root @duplicate directive executed one time as expected') - t.equal(impDuplicateExecuted, 1, 'imp @duplicate directive executed one time as expected'); - t.end(); -}); - -Test('filterTypes - simple exclusion', (t) => { - const types = gql` - type B { - value: String - } - - type A { - value: String - } - - type Query { - a: A - b: B - } - `; - - const filteredTypes = filterTypes([types], [['Query', 'b']]); - const schema = buildASTSchema(filteredTypes[0]); - t.ok(schema.getType('A'), 'type A exists'); - t.ok(schema.getType('B'), 'type B exists'); - t.ok(schema.getType('Query').getFields().a, 'Query.a exists'); - t.notOk(schema.getType('Query').getFields().b, 'Query.b has been excluded'); - t.end(); -}); - -Test('filterTypes - asterisk exclusion', (t) => { - const types = gql` - type B { - value: String - } - - type A { - value: String - } - - type Query { - a: A - b: B - } - `; - - const filteredTypes = filterTypes([types], [['Query', '*']]); - const schema = buildASTSchema(filteredTypes[0]); - t.ok(schema.getType('A'), 'type A exists'); - t.ok(schema.getType('B'), 'type B exists'); - t.notOk(schema.getType('Query'), 'Query type has been completed excluded'); - t.end(); -}); - -Test('filterTypes - one by one exclusion', (t) => { - const types = gql` - type B { - value: String - } - - type A { - value: String - } - - type Query { - a: A - b: B - } - `; - - const filteredTypes = filterTypes([types], [['Query', 'a'], ['Query', 'b']]); - const schema = buildASTSchema(filteredTypes[0]); - t.ok(schema.getType('A'), 'type A exists'); - t.ok(schema.getType('B'), 'type B exists'); - t.notOk(schema.getType('Query'), 'Query type has been completed excluded'); - t.end(); -}) \ No newline at end of file diff --git a/lib/imports/index.js b/lib/imports/index.js deleted file mode 100644 index 5f0f6ed..0000000 --- a/lib/imports/index.js +++ /dev/null @@ -1,165 +0,0 @@ -'use strict'; - -const graphql = require('graphql'); -const { importResolvers } = require('../resolvers'); - -const debug = require('debug')('graphql-component:imports'); - -const parseExclude = function (exclude) { - const excludes = []; - - if (exclude && exclude.length > 0) { - excludes.push(...exclude.map((filter) => filter.split('.'))); - } - - return excludes; -} - -const check = function (type, fieldName, excludes) { - return excludes.map(([root, name]) => { - if (root === '*') { - return true; - } - return type === root && (name === '' || name === '*' || name === fieldName); - }).some(check => check); -}; - -const filterTypes = function (types, excludes) { - if (!excludes || excludes.length < 1) { - return types; - } - - for (const doc of types) { - const { definitions } = doc; - // iterate through the definitions backwards - such that in the case - // where we need to modify the definitions array itself, everything - // stays in sync - for (let i = definitions.length - 1; i >= 0; --i) { - const def = definitions[i] - if (def.kind === 'ObjectTypeDefinition' && ['Query', 'Mutation', 'Subscription'].indexOf(def.name.value) > -1) { - def.fields = def.fields.filter((field) => { - if (check(def.name.value, field.name.value, excludes)) { - debug(`excluding ${def.name.value}.${field.name.value} from import`); - return false; - } - return true; - }); - // all of the fields of this definition were removed, so remove the definition from the document - if (def.fields.length === 0) { - definitions.splice(i, 1); - } - } - } - } - - return types; -}; - -const importDirectives = function (typedefDocuments, component, importedDirectives) { - const { directives: componentDirectives } = component; - const result = {}; - - for (let [directiveName, directiveImplementation] of Object.entries(componentDirectives)) { - // conflict detected - namespace the current component's directive - if (importedDirectives[directiveName]) { - const newDirectiveName = `${directiveName}_${component.id}`; - for (let document of typedefDocuments) { - for (let definition of document.definitions) { - namespaceDirectiveInAST(definition, directiveName, newDirectiveName); - } - } - result[newDirectiveName] = directiveImplementation; - } - else { - result[directiveName] = directiveImplementation; - } - } - - return result; -} - -// has side effects - modifies the input astNode -const namespaceDirectiveInAST = function (astNode, originalDirectiveName, newDirectiveName) { - // base case - if ((astNode.kind === 'DirectiveDefinition' || astNode.kind === 'Directive') && astNode.name.value === originalDirectiveName) { - astNode.name.value = newDirectiveName; - } - - if (astNode.directives && astNode.directives.length > 0) { - for (let directiveNode of astNode.directives) { - namespaceDirectiveInAST(directiveNode, originalDirectiveName, newDirectiveName); - } - } - - if (astNode.fields && astNode.fields.length > 0) { - for (let fieldNode of astNode.fields) { - namespaceDirectiveInAST(fieldNode, originalDirectiveName, newDirectiveName); - } - } -} - -const checkForDirectiveImplementations = function (typeDefDocuments, component) { - const { directives } = component; - for (let document of typeDefDocuments) { - for (let typedef of document.definitions) { - if (typedef.kind === 'DirectiveDefinition') { - if (!directives[typedef.name.value]) { - throw new Error(`${component.name} defined directive: @${typedef.name.value} but did not provide an implementation`); - } - } - } - } -} - -const buildDependencyTree = function (root) { - const importedTypes = []; - const importedResolvers = []; - const importedMocks = []; - let importedDirectives = {}; - - const visited = new Set(); - const queue = [{component: root}]; - - while (queue.length > 0) { - const current = queue.shift(); - - const { component, exclude } = current; - - if (visited.has(component.id)) { - continue; - } - - const excludes = parseExclude(exclude); - - // import types - const types = filterTypes(component.types.map((type) => graphql.parse(type)), excludes); - importedTypes.unshift(...types); - - // import diretives - checkForDirectiveImplementations(types, component); - - if (Object.keys(component.directives).length > 0 && Object.keys(importedDirectives).length > 0) { - const importedComponentDirectives = importDirectives(types, component, importedDirectives); - importedDirectives = { ...importedDirectives, ...importedComponentDirectives }; - } - else { - importedDirectives = { ...importedDirectives, ...component.directives }; - } - - // import resolvers from imported component - const resolvers = importResolvers(component, excludes); - importedResolvers.unshift(resolvers); - - // imports mocks from imported component - if (component.mocks) { - importedMocks.unshift(component.mocks); - } - - visited.add(component.id); - queue.push(...component.imports); - } - - return { importedTypes, importedResolvers, importedMocks, importedDirectives }; -}; - -module.exports = { buildDependencyTree, filterTypes }; \ No newline at end of file diff --git a/lib/index.d.ts b/lib/index.d.ts index 66ba56d..7bb157d 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -1,5 +1,5 @@ -import { GraphQLResolveInfo } from 'graphql'; -import { IExecutableSchemaDefinition } from '@graphql-tools/schema'; +import { GraphQLSchema } from 'graphql'; +import { IDelegateToSchemaOptions, IExecutableSchemaDefinition } from 'graphql-tools' interface GraphQLComponentConfigObject { component: GraphQLComponent; @@ -7,16 +7,10 @@ interface GraphQLComponentConfigObject { } interface GraphQLComponentOptions { - types?: string[]; + types?: string | string[]; resolvers?: object; - imports?: GraphQLComponent[] | GraphQLComponentConfigObject[]; - mocks?: (importedMocks: any) => any; + mocks?: boolean | object; directives?: any; - context?: any; - useMocks?: boolean; - preserveResolvers?: boolean; - dataSources?: any[]; - dataSourceOverrides?: any; federation?: boolean; makeExecutableSchema?: ({ typeDefs, @@ -28,21 +22,17 @@ interface GraphQLComponentOptions { updateResolversInPlace, schemaExtensions }) => IExecutableSchemaDefinition; + imports?: GraphQLComponent[] | GraphQLComponentConfigObject[]; + context?: any; + dataSources?: any[]; + dataSourceOverrides?: any; } export default class GraphQLComponent { constructor(options?: GraphQLComponentOptions); - static isComponent(check: any): any; - static delegateToComponent(component: GraphQLComponent, options: { - info: GraphQLResolveInfo; - contextValue: any; - targetRootField?: string; - subPath?: string; - args?: object; - }): Promise + static delegateToComponent(component: GraphQLComponent, options: IDelegateToSchemaOptions): Promise readonly name: string; - readonly id: string; - readonly schema: any; + readonly schema: GraphQLSchema; readonly context: { (arg: any): Promise; use(name: any, fn: any): void; diff --git a/lib/index.js b/lib/index.js index 9688ed9..1bd4780 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,16 +1,20 @@ 'use strict'; const { buildFederatedSchema } = require('@apollo/federation'); -const { mergeResolvers, mergeTypeDefs } = require('@graphql-tools/merge'); -const { makeExecutableSchema: defaultMakeExecutableSchema } = require('@graphql-tools/schema'); -const { addMocksToSchema } = require('@graphql-tools/mock'); -const { SchemaDirectiveVisitor } = require('@graphql-tools/utils'); + +const { + stitchSchemas, + delegateToSchema, + mergeTypeDefs, + addMocksToSchema, + makeExecutableSchema: defaultMakeExecutableSchema, + SchemaDirectiveVisitor +} = require('graphql-tools'); + const { bindResolvers } = require('./resolvers'); -const { buildDependencyTree } = require('./imports'); const { wrapContext, createContext } = require('./context'); const { createDataSourceInjection } = require('./datasource'); -const { delegateToComponent } = require('./delegate'); -const cuid = require('cuid'); +const { exclusions } = require('./transforms'); const debug = require('debug')('graphql-component:schema'); @@ -18,85 +22,100 @@ class GraphQLComponent { constructor({ types = [], resolvers = {}, - imports = [], - dataSources = [], mocks = undefined, directives = {}, - context = undefined, - useMocks = false, - preserveResolvers = false, - dataSourceOverrides = [], federation = false, - makeExecutableSchema = undefined + makeExecutableSchema = undefined, + imports = [], + context = undefined, + dataSources = [], + dataSourceOverrides = [] } = {}) { - debug(`creating component`); - - //This is really only used for avoiding directive collisions - this._id = cuid().slice(12); - - this._schema = undefined; - - this._federation = federation; + debug(`creating a GraphQLComponent instance`); this._types = Array.isArray(types) ? types : [types]; this._resolvers = bindResolvers(this, resolvers); - this._imports = imports && imports.length > 0 ? imports.map((i) => GraphQLComponent.isComponent(i) ? { component: i, exclude: [] } : i) : []; - - this._dataSources = dataSources; + this._mocks = mocks; this._directives = directives; - this._context = createContext(this, context); + this._federation = federation; - this._dataSourceInjection = createDataSourceInjection(this, dataSourceOverrides); + this._makeExecutableSchema = makeExecutableSchema; - this._useMocks = useMocks; + this._imports = imports && imports.length > 0 ? imports.map((i) => { + // check for a GraphQLComponent instance to construct a configuration object from it + if (i instanceof GraphQLComponent) { + // if the importing component (ie. this component) has federation set to true - set federation: true + // for all of its imported components + if (this._federation === true) { + i.federation = true; + } + return { component: i, exclude: [] }; + } + // check for a configuration object and simply return it + else if (((typeof i === 'function') || (typeof i === 'object')) && i.component) { + // if the importing component (ie. this component) has federation set to true - set federation: true + // for all of its imported components + if (this._federation === true) { + i.component.federation = true; + } + return i; + } + throw new Error(`import in ${this.name} not an instance of GraphQLComponent or component configuration object: { component: , exclude: [] }`); + }) : []; - this._mocks = mocks; + this._context = createContext(this, context); - this._preserveResolvers = preserveResolvers; + this._dataSources = dataSources; - this._makeExecutableSchema = makeExecutableSchema; - } + this._schema = undefined; - get name() { - return this.constructor.name; - } - - get id() { - return this._id; - } + this._dataSourceInjection = createDataSourceInjection(this, dataSourceOverrides); - static isComponent(check) { - return check && check._types && check._resolvers && check._imports; + this.graphqlTools = require('graphql-tools'); } - static delegateToComponent(...args) { - return delegateToComponent(...args); + get name() { + return this.constructor.name; } - makeFederatedSchemaWithDirectives({typeDefs, resolvers, schemaDirectives}) { - const federatedSchema = buildFederatedSchema({ - typeDefs, - resolvers - }); + static delegateToComponent(component, options) { + options.schema = component.schema; + // adapt v2 delegate options to v3 options to maintain backwards compatibility + if (options.contextValue) { + options.context = options.contextValue; + delete options.contextValue; + } - // Add any custom schema directives - if (schemaDirectives) { - SchemaDirectiveVisitor.visitSchemaDirectives(federatedSchema, schemaDirectives); + if (options.targetRootField) { + options.fieldName = options.targetRootField; + delete options.targetRootField; } - return federatedSchema; + return delegateToSchema(options); } + _getMakeSchemaFunction() { if (this._federation) { - return this.makeFederatedSchemaWithDirectives; - } else if (this._makeExecutableSchema) { + return (schemaConfig) => { + const schema = buildFederatedSchema(schemaConfig); + + // allows a federated schema to have custom directives using the old class based directive implementation + if (this._directives) { + SchemaDirectiveVisitor.visitSchemaDirectives(schema, this._directives); + } + + return schema; + }; + } + if (this._makeExecutableSchema) { return this._makeExecutableSchema } + return defaultMakeExecutableSchema; } @@ -105,28 +124,50 @@ class GraphQLComponent { return this._schema; } - const { importedTypes, importedResolvers, importedMocks, importedDirectives } = buildDependencyTree(this); - - const typeDefs = mergeTypeDefs([...importedTypes]); - const resolvers = mergeResolvers([...importedResolvers]); - const schemaDirectives = importedDirectives; - const makeSchema = this._getMakeSchemaFunction(); - - let schema = makeSchema({ - typeDefs, - resolvers, - schemaDirectives - }); - - debug(`created ${this.name} schema`); - - if (this._useMocks) { - debug(`adding mocks, preserveResolvers=${this._preserveResolvers}`); + if (this._imports.length > 0) { + // iterate through the imports and construct subschema configuration objects + const subschemas = this._imports.map((imp) => { + const { component, exclude } = imp; + return { + schema: component.schema, + transforms: exclusions(exclude) + } + }); + + // construct an aggregate schema from the schemas of imported + // components and this component's types/resolvers (if present) + this._schema = stitchSchemas({ + subschemas, + typeDefs: this._types, + resolvers: this._resolvers, + schemaDirectives: this._directives + }); + } + else { + const schemaConfig = { + typeDefs: mergeTypeDefs(this._types), + resolvers: this._resolvers, + schemaDirectives: this._directives + } + + const makeSchema = this._getMakeSchemaFunction(); + + this._schema = makeSchema(schemaConfig); + } - schema = addMocksToSchema({schema, mocks: Object.assign({}, ...importedMocks), preserveResolvers: this._preserveResolvers}); + if (this._mocks !== undefined && typeof this._mocks === 'boolean' && this._mocks === true) { + debug(`adding default mocks to the schema for ${this.name}`); + // if mocks are a boolean support simply applying default mocks + this._schema = addMocksToSchema({schema: this._schema, preserveResolvers: true}); + } + else if (this._mocks !== undefined && typeof this._mocks === 'object') { + debug(`adding custom mocks to the schema for ${this.name}`); + // else if mocks is an object, that means the user provided + // custom mocks, with which we pass them to addMocksToSchema so they are applied + this._schema = addMocksToSchema({schema: this._schema, mocks: this._mocks, preserveResolvers: true}); } - this._schema = schema; + debug(`created schema for ${this.name}`); return this._schema; } @@ -147,10 +188,6 @@ class GraphQLComponent { return this._imports; } - get mocks() { - return this._mocks; - } - get directives() { return this._directives; } @@ -158,6 +195,10 @@ class GraphQLComponent { get dataSources() { return this._dataSources; } + + set federation(flag) { + this._federation = flag; + } } module.exports = GraphQLComponent; diff --git a/lib/resolvers/__tests__.js b/lib/resolvers/__tests__.js index 7123134..4434789 100644 --- a/lib/resolvers/__tests__.js +++ b/lib/resolvers/__tests__.js @@ -4,13 +4,8 @@ const Test = require('tape'); const { GraphQLScalarType } = require('graphql'); const { memoize, - filterResolvers, bindResolvers, - importResolvers } = require('./index'); -const GraphQLComponent = require('../index'); -const graphql = require('graphql'); -const gql = require('graphql-tag'); Test('memoize()', (t) => { t.test('memoize() a resolver function', (st) => { @@ -126,146 +121,6 @@ Test('memoize()', (t) => { }); }); -Test('filterResolvers()', (t) => { - t.test('exclusions argument is undefined', (st) => { - const resolvers = { - Query: { - foo() { } - } - } - const transformedResolvers = filterResolvers(resolvers); - st.deepEqual(transformedResolvers, resolvers, 'object content returned from filterResolvers is equal to input resolver object content'); - st.end(); - }); - - t.test('exclusions argument is an empty array', (st) => { - const resolvers = { - Query: { - foo() { } - } - } - const transformedResolvers = filterResolvers(resolvers, []); - st.deepEqual(transformedResolvers, resolvers, 'object content returned from filterResolvers is equal to input resolver object content'); - st.end(); - }); - - t.test(`exclude all types via '*'`, (st) => { - const resolvers = { - Query: { - foo() {} - }, - Mutation: { - baz() {} - }, - SomeType: { - bar() {} - } - }; - - const transformedResolvers = filterResolvers(resolvers, [['*']]); - st.deepEqual(transformedResolvers, {}, 'results in an empty resolver map being returned'); - st.ok(resolvers.Query.foo, 'original resolver map maintains structure despite exclusions'); - st.ok(resolvers.Mutation.baz, 'original resolver map maintains structure despite exclusions') - st.ok(resolvers.SomeType.bar, 'original resolver map maintains structure despite exclusions'); - st.end(); - }); - - t.test(`exclude a entire type by specifying 'Type' exclusion)`, (st) => { - const resolvers = { - Query: { - foo() {} - }, - SomeType: { - bar() {} - } - }; - - const transformedResolvers = filterResolvers(resolvers, [['SomeType']]); - st.notOk(transformedResolvers.SomeType, 'entire specified type is excluded'); - st.ok(transformedResolvers.Query.foo, 'other non-excluded type remains'); - st.ok(resolvers.Query.foo, 'original resolver map maintains structure despite exclusions'); - st.ok(resolvers.SomeType.bar, 'original resolver map maintains structure despite exclusions'); - st.end(); - }); - - t.test(`exclude an entire type by specifying 'Type.' exclusion`, (st) => { - const resolvers = { - Query: { - foo() {} - }, - SomeType: { - bar() {} - } - }; - - const transformedResolvers = filterResolvers(resolvers, [['SomeType', '']]); - st.notOk(transformedResolvers.SomeType,'entire specified type is excluded'); - st.ok(transformedResolvers.Query.foo, 'other non-excluded type remains'); - st.ok(resolvers.Query.foo, 'original resolver map maintains structure despite exclusions'); - st.ok(resolvers.SomeType.bar, 'original resolver map maintains structure despite exclusions'); - st.end(); - }); - - t.test(`exclude an entire type by specifying 'Type.*' exclusion`, (st) => { - const resolvers = { - Query: { - foo() {} - }, - SomeType: { - bar() {} - } - }; - - const transformedResolvers = filterResolvers(resolvers, [['SomeType', '*']]); - st.notOk(transformedResolvers.SomeType, 'entire specified type is excluded'); - st.ok(transformedResolvers.Query.foo, 'other non-excluded type remains'); - st.ok(resolvers.Query.foo, 'original resolver map maintains structure despite exclusions'); - st.ok(resolvers.SomeType.bar, 'original resolver map maintains structure despite exclusions'); - st.end(); - }); - - t.test(`exclude an individual field on a type`, (st) => { - const resolvers = { - Query: { - foo() {} - }, - SomeType: { - bar() {}, - a() {} - } - }; - - const transformedResolvers = filterResolvers(resolvers, [['SomeType', 'bar']]); - st.notOk(transformedResolvers.SomeType.bar, 'specified field on specified type is removed'); - st.ok(transformedResolvers.SomeType.a, 'non-excluded field on specified type remains'); - st.ok(transformedResolvers.Query.foo, 'non-exluded type remains'); - st.ok(resolvers.Query.foo, 'original resolver map maintains structure despite exclusions'); - st.ok(resolvers.SomeType.bar, 'original resolver map maintains structure despite exclusions'); - st.ok(resolvers.SomeType.a, 'original resolver map maintains structure despite exclusions'); - st.end(); - }); - - t.test('exclude all fields on a type via 1 by 1 exclusion', (st) => { - const resolvers = { - Query: { - foo() {} - }, - SomeType: { - bar() {}, - a() {} - } - }; - - const transformedResolvers = filterResolvers(resolvers, [['SomeType', 'bar'], ['SomeType', 'a']]); - st.notOk(transformedResolvers.SomeType, 'specified type is completely removed because all of its fields were removed'); - st.ok(transformedResolvers.Query.foo, 'non-exluded type remains'); - st.ok(resolvers.Query.foo, 'original resolver map maintains structure despite exclusions'); - st.ok(resolvers.SomeType.bar, 'original resolver map maintains structure despite exclusions'); - st.ok(resolvers.SomeType.a, 'original resolver map maintains structure despite exclusions'); - st.end(); - }) -}); - Test('bindResolvers()', (t) => { t.test('bind Query field resolver function', (st) => { const resolvers = { @@ -367,115 +222,4 @@ Test('bindResolvers()', (t) => { }); }); -Test('importResolvers()', (t) => { - t.test(`import component's resolvers`, (st) => { - const component = new GraphQLComponent({ - types: ` - type Query { - someQuery: SomeType - } - type SomeType { - someField: String - anotherField: String - } - `, - resolvers: { - Query: { - someQuery() { - return { someField: 'hello' } - } - }, - SomeType: { - anotherField() { - return 'foo'; - } - } - } - }); - - const importedResolvers = importResolvers(component); - st.ok(importedResolvers.Query.someQuery, 'Query.someQuery is imported'); - st.ok(importedResolvers.SomeType.anotherField, 'non-root type resolver SomeType.anotherField is imported'); - st.end(); - }); - - t.test(`import component's resolvers with exclusion`, (st) => { - const component = new GraphQLComponent({ - types: ` - type Query { - someQuery: SomeType - someOtherQuery: String - } - type SomeType { - someField: String - } - `, - resolvers: { - Query: { - someQuery() { - return { someField: 'hello' } - }, - someOtherQuery() { - return 'hello'; - } - } - } - }); - - const importedResolvers = importResolvers(component, [['Query', 'someOtherQuery']]); - st.notOk(importedResolvers.Query.someOtherQuery, 'Query.someOtherQuery was excluded'); - st.end(); - }); -}) - -Test('integration - importing resolvers properly handles enum remaps', async (t) => { - const component = new GraphQLComponent({ - imports: [new GraphQLComponent({ - types: ` - type Query { - foo: Foo - } - - type Foo { - id: ID - bar: Bar - } - enum Bar { - GOOD - BAD - } - `, - resolvers: { - Query: { - foo() { - return { id: 1, bar: 'good' } - } - }, - Bar: { - GOOD: 'good', - BAD: 'bad' - } - } - })] - }); - - const document = gql` - query { - foo { - id - bar - } - } - ` - - const { data, errors } = graphql.execute({ - document, - schema: component.schema, - contextValue: {} - }); - t.deepEqual(data, { foo: { id: '1', bar: 'GOOD'} }, 'schema with enum remap is resolved as expected'); - t.notOk(errors, 'no errors'); - t.end(); -}); - diff --git a/lib/resolvers/index.js b/lib/resolvers/index.js index 3abb3bc..329709e 100644 --- a/lib/resolvers/index.js +++ b/lib/resolvers/index.js @@ -2,7 +2,6 @@ const debug = require('debug')('graphql-component:resolver'); const { GraphQLScalarType } = require('graphql'); -const cloneDeep = require('lodash.clonedeep'); /** * memoizes resolver functions such that calls of an identical resolver (args/context/path) within the same request context are avoided @@ -16,10 +15,10 @@ const cloneDeep = require('lodash.clonedeep'); * whose closure scope contains a WeakMap to achieve memoization of the wrapped * input resolver function */ -const memoize = function (parentType, fieldName, resolve) { + const memoize = function (parentType, fieldName, resolve) { const _cache = new WeakMap(); - return function (_, args, context, info) { + return function _memoizedResolver(_, args, context, info) { const path = info && info.path && info.path.key; const key = `${path}_${JSON.stringify(args)}`; @@ -49,150 +48,49 @@ const memoize = function (parentType, fieldName, resolve) { }; /** - * excludes types and/or fields on types from an input resolver map - * @param {Object} resolvers - the input resolver map to filter - * @param {Array} excludes - an array of exclusions in the general form - * of "type.field". This format supports excluding all types ('*'), an entire - * type and the fields it encloses ('type', 'type.', 'type.*'), and individual - * fields on a type ('type.somefield'). - * @returns {Object} - a new resolver map with the applied exclusions - */ -const filterResolvers = function (resolvers, excludes) { - if (!excludes || excludes.length < 1) { - return resolvers; - } - - let filteredResolvers = cloneDeep(resolvers); - - for (const [type, field] of excludes) { - if (type === '*') { - filteredResolvers = {}; - break; - } - if (!field || field === '' || field === '*') { - delete filteredResolvers[type]; - continue; - } - delete filteredResolvers[type][field]; - // covers the case where all fields of a type were specified 1 by 1, which - // should result in the entire type being removed - if (Object.keys(filteredResolvers[type]).length === 0) { - delete filteredResolvers[type]; - } - } - - return filteredResolvers; -}; - -/** - * binds an object context to resolver functions in the input resolver map + * make 'this' in resolver functions equal to the input bindContext * @param {Object} bind - the object context to bind to resolver functions * @param {Object} resolvers - the resolver map containing the resolver * functions to bind * @returns {Object} - an object identical in structure to the input resolver * map, except with resolver function bound to the input argument bind */ -const bindResolvers = function (bind, resolvers = {}) { - const bound = {}; - - for (const [type, fieldResolvers] of Object.entries(resolvers)) { - if (fieldResolvers instanceof GraphQLScalarType) { - bound[type] = fieldResolvers; +const bindResolvers = function (bindContext, resolvers = {}) { + const boundResolvers = {}; + + for (const [type, fields] of Object.entries(resolvers)) { + // dont bind an object that is an instance of a graphql scalar + if (fields instanceof GraphQLScalarType) { + debug(`not binding ${type}'s fields since ${type}'s fields are an instance of GraphQLScalarType`) + boundResolvers[type] = fields; continue; } - if (!bound[type]) { - bound[type] = {}; + if (!boundResolvers[type]) { + boundResolvers[type] = {}; } - for (const [field, resolverFunc] of Object.entries(fieldResolvers)) { - if (bound[type][field]) { - continue; - } - + for (const [field, resolver] of Object.entries(fields)) { if (['Query', 'Mutation'].indexOf(type) > -1) { debug(`memoized ${type}.${field}`); - bound[type][field] = memoize(type, field, resolverFunc.bind(bind)); - } else { - // for types other than Query/Mutation - conditionally bind since in - // some cases (Subscriptions and enum remaps) resolverFunc will be - // an object instead of a function - bound[type][field] = typeof resolverFunc === 'function' ? resolverFunc.bind(bind) : resolverFunc; - } - } - } - return bound; -}; - -/** - * wraps non root type resolvers from the input resolver map that quick returns - * if __typename is detected - * @param {object} resolvers - the input resolver map with which non root - * resolvers will be wrapped - * @returns {object} the resulting resolver map with non root type resolvers - * wrapped - */ -const wrapNonRootTypeResolvers = function (resolvers) { - const result = {}; - - for (const [type, fieldResolvers] of Object.entries(resolvers)) { - // skip over resolvers on root types - if (['Query', 'Mutation', 'Subscription'].indexOf(type) === -1) { - if (!result[type]) { - result[type] = {}; + boundResolvers[type][field] = memoize(type, field, resolver.bind(bindContext)); } - for (const [field, resolver] of Object.entries(fieldResolvers)) { - // handle non-root types that are enum remaps - if (typeof resolver !== 'function') { - result[type][field] = resolver; - } - // explicitly wrap __resolveType to short circuit if __typename is already resolved/present (on root) - // this is to prevent double execution of the __resolveType resolver in delegation situations - // when the delegatee resolves an interface type - else if (field === '__resolveType') { - result[type][field] = function (root, ...args) { - if (root.__typename) { - return root.__typename; - } - return resolver(root, ...args); - } - } - // explicitly do not wrap __resolveReference - which is a special resolver for cross - // service resolution of entities in federated schemas - else if (field === '__resolveReference') { - result[type][field] = resolver; + else { + // only bind resolvers that are functions + if (typeof resolver === 'function') { + debug(`binding ${type}.${field}`); + boundResolvers[type][field] = resolver.bind(bindContext); } else { - // otherwise wrap all other non-root field resolvers in an attempt to short circuit - // and prevent double execution of non-root type field resolvers in delegation situations - result[type][field] = function (root, args, context, info) { - if (root[field]) { - return root[field]; - } - return resolver(root, args, context, info); - } + debug(`not binding ${type}.${field} since ${field} is not mapped to a function`); + boundResolvers[type][field] = resolver; } } } - else { - result[type] = fieldResolvers; - } } - return result; + return boundResolvers; } -/** - * returns a resolver map representing imported resolvers from the input - * component - * @param {GraphQLComponent} component - the component to import resolvers from - * @param {Array>} excludes - resolvers to exclude from the input - * component - * @return {Object} - imported resolvers - */ -const importResolvers = function (component, excludes) { - const filteredResolvers = filterResolvers(component.resolvers, excludes); - return wrapNonRootTypeResolvers(filteredResolvers); -} +module.exports = { bindResolvers, memoize }; -module.exports = { memoize, filterResolvers, bindResolvers, importResolvers}; \ No newline at end of file diff --git a/lib/transforms/__tests__.js b/lib/transforms/__tests__.js new file mode 100644 index 0000000..e3b238b --- /dev/null +++ b/lib/transforms/__tests__.js @@ -0,0 +1,43 @@ +'use strict'; + +const { exclusions } = require('./index.js'); +const Test = require('tape'); +const { FilterTypes, FilterObjectFields } = require('graphql-tools'); + +Test('exclusions() throws an error if passed a malformed exclusion', (t) => { + try { + exclusions(['mal.form.ed']); + } catch (e) { + t.equals(e.message, `'mal.form.ed' is malformed, should be of form 'type[.[field]]'`) + } + t.end(); +}); + +Test('exclusions() simply returns exclusions passed as objects', (t) => { + const filterType = new FilterTypes(); + const result = exclusions([filterType]); + console.log(result); + t.equals(result.length, 1, '1 transform is returned') + t.equals(result[0], filterType, 'transform is returned as is, since it was an object'); + t.end(); +}); + +Test(`exclusions() converts 'Type' only exclusion to FilterTypes transform`, (t) => { + const result = exclusions(['Query']); + t.ok(result[0] instanceof FilterTypes, 'resulting transform is an instance of graphql-tools FilterTypes'); + t.end(); +}); + +Test(`exclusions() converts 'Type.*' exclusion to FilterTypes transform`, (t) => { + const result = exclusions(['Query.*']); + t.ok(result[0] instanceof FilterTypes, 'resulting transform is an instance of graphql-tools FilterTypes'); + t.end(); +}); + +Test(`exclusions() converts 'Type.field' exclusion to FilterObjectFields transform`, (t) => { + const result = exclusions(['Query.foo']); + t.ok(result[0] instanceof FilterObjectFields, 'resulting transform is instance of graphql-tools FilterObjectFields'); + t.end(); +}); + +// TODO: actual type exclusions tests on GraphQLComponent instances here \ No newline at end of file diff --git a/lib/transforms/index.js b/lib/transforms/index.js new file mode 100644 index 0000000..a1f742e --- /dev/null +++ b/lib/transforms/index.js @@ -0,0 +1,47 @@ +'use strict'; + +const { FilterTypes, FilterObjectFields } = require('graphql-tools'); + +const exclusions = function(exclusions) { + return exclusions.map((exclusion) => { + if (typeof exclusion === 'string') { + const parts = exclusion.split('.'); + let type; + let field; + if (parts.length === 1) { + type = parts[0]; + } + else if (parts.length === 2) { + type = parts[0]; + field = parts[1]; + } + else { + throw new Error(`'${exclusion}' is malformed, should be of form 'type[.[field]]'`) + } + + // specific type/field exclusion such as 'Query.foo' + if (type && field && field !== '*') { + return new FilterObjectFields((typeName, fieldName) => { + if (typeName === type && field === fieldName) { + return false; + } + return true; + }) + } + // type only exclusion (such as 'Query') or type and all fields exclusions (such as 'Query.*') + else if (type && !field || (type && field && field === '*')) { + return new FilterTypes(graphqlObjectType => { + if (graphqlObjectType.name === type) { + return false; + } + return true; + }) + } + // assume that someone passed in a valid graphql-tools transform + } else if (typeof exclusion === 'object') { + return exclusion; + } + }); +} + +module.exports = { exclusions }; \ No newline at end of file diff --git a/package.json b/package.json index 1a0f3d0..1c99d76 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "graphql-component", - "version": "2.2.0", - "description": "Build graphql schema with components", + "version": "3.0.0", + "description": "Build, customize and compose GraphQL schemas in a componentized fashion", "keywords": [ "graphql", "schema-stitching", @@ -12,9 +12,8 @@ "main": "lib/index.js", "scripts": { "test": "tape lib/*/**/__tests__.js lib/__tests__.js", - "composition-example": "DEBUG=graphql-component:* node examples/composition/server/index.js", - "mock-composition-example": "DEBUG=graphql-component:* GRAPHQL_MOCK=1 node examples/composition/server/index.js", - "federation-example": "DEBUG=graphql-component:* node examples/federation/run-federation-example.js", + "start-composition": "DEBUG=graphql-component:* node examples/composition/server/index.js", + "start-federation": "DEBUG=graphql-component:* node examples/federation/run-federation-example.js", "lint": "eslint lib", "cover": "nyc npm test" }, @@ -22,24 +21,20 @@ "repository": "https://github.com/ExpediaGroup/graphql-component", "license": "MIT", "dependencies": { - "@apollo/federation": "^0.20.4", - "cuid": "^2.1.8", - "debug": "^4.1.1", - "graphql-tools": "^6.0.10", - "lodash.clonedeep": "^4.5.0", - "lodash.get": "^4.4.2", - "lodash.set": "^4.3.2" + "@apollo/federation": "^0.28.0", + "debug": "^4.3.1", + "graphql-tools": "^7.0.5" }, "peerDependencies": { - "graphql": "^14.0.0" + "graphql": "^15.0.0" }, "devDependencies": { - "@apollo/gateway": "^0.10.8", - "apollo-server": "^2.13.1", + "@apollo/gateway": "^0.28.2", + "apollo-server": "^2.25.0", "casual": "^1.6.0", "eslint": "^6.5.1", - "graphql": "^14.0.0 ", - "graphql-tag": "^2.10.3", + "graphql": "^15.0.0", + "graphql-tag": "^2.12.4", "nyc": "^14.1.1", "sinon": "^12.0.1", "tape": "^4.9.1"