diff --git a/.npmignore b/.npmignore index f27a6939860..927190ca980 100644 --- a/.npmignore +++ b/.npmignore @@ -2,6 +2,7 @@ !dist/ !dist/* !dist/stitching/* +!dist/transforms/* !package.json !*.md !*.png diff --git a/.travis.yml b/.travis.yml index 950bb2d2009..65a50a948ea 100644 --- a/.travis.yml +++ b/.travis.yml @@ -20,6 +20,5 @@ script: sudo: false env: - - GRAPHQL_VERSION='^0.11' - GRAPHQL_VERSION='^0.12' - GRAPHQL_VERSION='^0.13' diff --git a/CHANGELOG.md b/CHANGELOG.md index b13e52afc03..6dd0b8b2240 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,18 @@ # Change log -### vNEXT - -* add commentDescription to printSchema call to match other uses [PR #745](https://github.com/apollographql/graphql-tools/pull/745) -* Add `createResolver` option to `makeRemoteExecutableSchema` [PR #734](https://github.com/apollographql/graphql-tools/pull/734) +### v3.0.0 (prerelease) + +* Schema transforms and delegation + * Substantial rewrite of internals of `mergeSchemas` and `delegateToSchema` + * A new API for schema transforms has been introduced: [Docs](https://www.apollographql.com/docs/graphql-tools/schema-transforms.html) + * `delegateToSchema` is now a public API [Docs](https://www.apollographql.com/docs/graphql-tools/schema-delegation.html) + * `delegateToSchema` now accepts an object of named parameters; positional arguments are deprecated + * `delegateToSchema` no longer accepts `fragmentReplacements`, instead accepting `transforms` + * `info.mergeInfo.delegateToSchema` is now the preferred delegation API, rather than `info.mergeInfo.delegate` (deprecated) + +* Other changes + * add commentDescription to printSchema call to match other uses [PR #745](https://github.com/apollographql/graphql-tools/pull/745) + * Add `createResolver` option to `makeRemoteExecutableSchema` [PR #734](https://github.com/apollographql/graphql-tools/pull/734) ### v2.24.0 diff --git a/docs/_config.yml b/docs/_config.yml index e5e83e7bab6..7016cf4878f 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -12,8 +12,10 @@ sidebar_categories: - scalars - mocking - connectors - - remote-schemas - schema-directives + - schema-delegation + - remote-schemas + - schema-transforms - schema-stitching Related: - title: Monitoring and caching diff --git a/docs/source/schema-delegation.md b/docs/source/schema-delegation.md new file mode 100644 index 00000000000..bd7f6fec8b2 --- /dev/null +++ b/docs/source/schema-delegation.md @@ -0,0 +1,200 @@ +--- +title: Schema delegation +description: Forward queries to other schemas automatically +--- + +Schema delegation is a way to automatically forward a query (or a part of a query) from a parent schema to another schema (called a _subschema_) that is able to execute the query. Delegation is useful when the parent schema shares a significant part of its data model with the subschema. For example, the parent schema might be powering a GraphQL gateway that connects multiple existing endpoints together, each with its own schema. This kind of architecture could be implemented using schema delegation. + +The `graphql-tools` package provides several related tools for managing schema delegation: + +* [Remote schemas](./remote-schemas.html) - turning a remote GraphQL endpoint into a local schema +* [Schema transforms](./schema-transforms.html) - modifying existing schemas to make delegation easier +* [Schema stitching](./schema-stitching) - merging multiple schemas into one + +Delegation is performed by one function, `delegateToSchema`, called from within a resolver function of the parent schema. The `delegateToSchema` function sends the query subtree received by the parent resolver to a subschema that knows how to execute it, then returns the result as if the parent resolver had executed the query. + +

Motivational example

+ +Let's consider two schemas, a subschema and a parent schema that reuses parts of a subschema. While the parent schema reuses the *definitions* of the subschema, we want to keep the implementations separate, so that the subschema can be tested independently, or even used as a remote service. + +```graphql +# Subschema +type Repository { + id: ID! + url: String + issues: [Issue] + userId: ID! +} + +type Issue { + id: ID! + text: String! + repository: Repository! +} + +type Query { + repositoryById(id: ID!): Repository + repositoriesByUserId(id: ID!): [Repository] +} + +# Parent schema +type Repository { + id: ID! + url: String + issues: [Issue] + userId: ID! + user: User +} + +type Issue { + id: ID! + text: String! + repository: Repository! +} + +type User { + id: ID! + username: String + repositories: [Repository] +} + +type Query { + userById(id: ID!): User +} +``` + +Suppose we want the parent schema to delegate retrieval of repositories to the subschema, in order to execute queries such as this one: + +```graphql +query { + userById(id: "1") { + id + username + repositories { + id + url + user + issues { + text + } + } + } +} +``` + +The resolver function for the `repositories` field of the `User` type would be responsible for the delegation, in this case. While it's possible to call a remote GraphQL endpoint or resolve the data manually, this would require us to transform the query manually, or always fetch all possible fields, which could lead to overfetching. Delegation automatically extracts the appropriate query to send to the subschema: + +```graphql +# To the subschema +query($id: ID!) { + repositoriesByUserId(id: $id) { + id + url + issues { + text + } + } +} +``` + +Delegation also removes the fields that don't exist on the subschema, such as `user`. This field would be retrieved from the parent schema using normal GraphQL resolvers. + +

API

+ +

delegateToSchema

+ +The `delegateToSchema` method can be found on the `info.mergeInfo` object within any resolver function, and should be called with the following named options: + +``` +delegateToSchema(options: { + schema: GraphQLSchema; + operation: 'query' | 'mutation' | 'subscription'; + fieldName: string; + args?: { [key: string]: any }; + context: { [key: string]: any }; + info: GraphQLResolveInfo; + transforms?: Array; +}): Promise +``` + +#### schema: GraphQLSchema + +A subschema to delegate to. + +#### operation: 'query' | 'mutation' | 'subscription' + +The operation type to use during the delegation. + +#### fieldName: string + +A root field in a subschema from which the query should start. + +#### args: { [key: string]: any } + +Additional arguments to be passed to the field. Arguments passed to the field that is being resolved will be preserved if the subschema expects them, so you don't have to pass existing arguments explicitly, though you could use the additional arguments to override the existing ones. For example: + +```graphql +# Subschema + +type Booking { + id: ID! +} + +type Query { + bookingsByUser(userId: ID!, limit: Int): [Booking] +} + +# Schema + +type User { + id: ID! + bookings(limit: Int): [Booking] +} + +type Booking { + id: ID! +} +``` + +If we delegate at `User.bookings` to `Query.bookingsByUser`, we want to preserve the `limit` argument and add an `userId` argument by using the `User.id`. So the resolver would look like the following: + +```js +const resolvers = { + User: { + bookings(parent, args, context, info) { + return info.mergeInfo.delegateToSchema({ + schema: subschema, + operation: 'query', + fieldName: 'bookingsByUser', + args: { + userId: parent.id, + }, + context, + info, + ); + }, + ... + }, + ... +}; +``` + +#### context: { [key: string]: any } + +GraphQL context that is going to be past to subschema execution or subsciption call. + +#### info: GraphQLResolveInfo + +GraphQL resolve info of the current resolver. Provides access to the subquery that starts at the current resolver. + +Also provides the `info.mergeInfo.delegateToSchema` function discussed above. + +#### transforms: Array + +[Transforms](./transforms.html) to apply to the query and results. Should be the same transforms that were used to transform the schema, if any. After transformation, `transformedSchema.transforms` contains the transforms that were applied. + +

Additional considerations

+ +### Aliases + +Delegation preserves aliases that are passed from the parent query. However that presents problems, because default GraphQL resolvers retrieve field from parent based on their name, not aliases. This way results with aliases will be missing from the delegated result. `mergeSchemas` and `transformSchemas` go around that by using `src/stitching/defaultMergedResolver` for all fields without explicit resolver. When building new libraries around delegation, one should consider how the aliases will be handled. diff --git a/docs/source/schema-stitching.md b/docs/source/schema-stitching.md index 281faec8e84..1d925b6a237 100644 --- a/docs/source/schema-stitching.md +++ b/docs/source/schema-stitching.md @@ -3,21 +3,21 @@ title: Schema stitching description: Combining multiple GraphQL APIs into one --- -Schema stitching is the ability to create a single GraphQL schema from multiple underlying GraphQL APIs. +Schema stitching is the process of creating a single GraphQL schema from multiple underlying GraphQL APIs. -One of the main benefits of GraphQL is that you can query all of your data as part of one schema, and get everything you need in one request. But as your schema grows, it might become cumbersome to manage it all as one codebase, and it starts to make sense to split it into different modules. You may also want to decompose your schema into separate microservices, which can be developed and deployed independently. +One of the main benefits of GraphQL is that we can query all of our data as part of one schema, and get everything we need in one request. But as the schema grows, it might become cumbersome to manage it all as one codebase, and it starts to make sense to split it into different modules. We may also want to decompose your schema into separate microservices, which can be developed and deployed independently. -In both cases, you use `mergeSchemas` to combine multiple GraphQL schemas together and produce a merged schema that knows how to delegate parts of the query to the relevant subschemas. These subschemas can be either local to the server, or running on a remote server. They can even be services offered by 3rd parties, allowing you to connect to external data and create mashups. +In both cases, we use `mergeSchemas` to combine multiple GraphQL schemas together and produce a merged schema that knows how to delegate parts of the query to the relevant subschemas. These subschemas can be either local to the server, or running on a remote server. They can even be services offered by 3rd parties, allowing us to connect to external data and create mashups.

Working with remote schemas

-In order to merge with a remote schema, you should first use [makeRemoteExecutableSchema](./remote-schemas.html) to create a local proxy for the schema that knows how to call the remote endpoint. You can then merge with that proxy the same way you would merge with a locally implemented schema. +In order to merge with a remote schema, we first call [makeRemoteExecutableSchema](./remote-schemas.html) to create a local proxy for the schema that knows how to call the remote endpoint. We then merge that local proxy schema the same way we would merge any other locally implemented schema.

Basic example

In this example we'll stitch together two very simple schemas. It doesn't matter whether these are local or proxies created with `makeRemoteExecutableSchema`, because the merging itself would be the same. -In this case, we're dealing with two schemas that implement a system with authors and "chirps" - small snippets of text that they can post. +In this case, we're dealing with two schemas that implement a system with users and "chirps"—small snippets of text that users can post. ```js import { @@ -26,9 +26,9 @@ import { mergeSchemas, } from 'graphql-tools'; -// Mocked chirp schema; we don't want to worry about the schema -// implementation right now since we're just demonstrating -// schema stitching +// Mocked chirp schema +// We don't worry about the schema implementation right now since we're just +// demonstrating schema stitching. const chirpSchema = makeExecutableSchema({ typeDefs: ` type Chirp { @@ -60,17 +60,19 @@ const authorSchema = makeExecutableSchema({ ` }); -// This function call adds the mocks to your schema! addMockFunctionsToSchema({ schema: authorSchema }); export const schema = mergeSchemas({ - schemas: [chirpSchema, authorSchema], + schemas: [ + chirpSchema, + authorSchema, + ], }); ``` [Run the above example on Launchpad.](https://launchpad.graphql.com/1nkk8vqj9) -This gives you a new schema with the root fields on `Query` from both schemas: +This gives us a new schema with the root fields on `Query` from both schemas (along with the `User` and `Chirp` types): ```graphql type Query { @@ -80,13 +82,13 @@ type Query { } ``` -That means you now have a single schema that allows you to ask for `userById` and `chirpsByAuthorId` in one query for example. +We now have a single schema that supports asking for `userById` and `chirpsByAuthorId` in the same query!

Adding resolvers between schemas

-Proxying the root fields is a great start, but many cases however you'll want to add the ability to navigate from one schema to another. In this example, you might want to be able to get from a particular author to their chirps, or from a chirp to its author. This is more than a convenience once you move beyond querying for objects by a specific id. If you want to get the authors for the `latestChirps` for example, you have no way of knowing the `authorId`s in advance, so you wouldn't be able to get the authors in the same query. +Combining existing root fields is a great start, but in practice we will often want to introduce additional fields for working with the relationships between types that came from different subschemas. For example, we might want to go from a particular user to their chirps, or from a chirp to its author. Or we might want to query a `latestChirps` field and then get the author of each of those chirps. If the only way to obtain a chirp's author is to call the `userById(id)` root query field with the `authorId` of a given chirp, and we don't know the chirp's `authorId` until we receive the GraphQL response, then we won't be able to obtain the authors as part of the same query. -To add the ability to navigate between types, you need to extend existing types with fields that can take you from one to the other. You can do that the same way you add the other parts of the schema: +To add this ability to navigate between types, we need to _extend_ existing types with new fields that translate between the types: ```js const linkTypeDefs = ` @@ -104,66 +106,177 @@ We can now merge these three schemas together: ```js mergeSchemas({ - schemas: [chirpSchema, authorSchema, linkTypeDefs], + schemas: [ + chirpSchema, + authorSchema, + linkTypeDefs, + ], }); ``` -You won't be able to query `User.chirps` or `Chirp.author` yet however, because the merged schema doesn't have resolvers defined for these fields. We'll have to define our own implementation of these. +We won't be able to query `User.chirps` or `Chirp.author` yet, however, because we still need to define resolvers for these new fields. -So what should these resolvers look like? +How should these resolvers be implemented? When we resolve `User.chirps` or `Chirp.author`, we want to _delegate_ to the relevant root fields. To get from a user to the user's chirps, for example, we'll want to use the `id` of the user to call `Query.chirpsByAuthorId`. And to get from a chirp to its author, we can use the chirp's `authorId` field to call the existing `Query.userById` field. -When we resolve `User.chirps` or `Chirp.author`, we want to delegate to the relevant root fields. To get from a user to its chirps for example, we'll want to use the `id` of the user to call `chirpsByAuthorId`. And to get from a chirp to its author, we can use the chirp's `authorId` field to call into `userById`. +Resolvers for fields in schemas created by `mergeSchema` have access to a handy `delegateToSchema` function (exposed via `info.mergeInfo.delegateToSchema`) that allows forwarding parts of queries (or even whole new queries) to one of the subschemas that was passed to `mergeSchemas`. -Resolvers specified as part of `mergeSchema` have access to a `delegate` function that allows you to delegate to root fields. +In order to delegate to these root fields, we'll need to make sure we've actually requested the `id` of the user or the `authorId` of the chirp. To avoid forcing users to add these fields to their queries manually, resolvers on a merged schema can define a `fragment` property that specifies the required fields, and they will be added to the query automatically. -In order to delegate to these root fields, we'll need to make sure we've actually requested the `id` of the user or the `authorId` of the chirp. To avoid forcing users to add these to their queries manually, resolvers on a merged schema can define a fragment that specifies the required fields, and these will be added to the query automatically. - -A complete implementation of schema stitching for these schemas would look like this: +A complete implementation of schema stitching for these schemas might look like this: ```js -mergeSchemas({ - schemas: [chirpSchema, authorSchema, linkTypeDefs], - resolvers: mergeInfo => ({ +const mergedSchema = mergeSchemas({ + schemas: [ + chirpSchema, + authorSchema, + linkTypeDefs, + ], + resolvers: { User: { chirps: { fragment: `fragment UserFragment on User { id }`, - resolve(parent, args, context, info) { - const authorId = parent.id; - return mergeInfo.delegate( - 'query', - 'chirpsByAuthorId', - { - authorId, + resolve(user, args, context, info) { + return info.mergeInfo.delegateToSchema({ + schema: chirpSchema, + operation: 'query', + fieldName: 'chirpsByAuthorId', + args: { + authorId: user.id, }, context, info, - ); + }); }, }, }, Chirp: { author: { fragment: `fragment ChirpFragment on Chirp { authorId }`, - resolve(parent, args, context, info) { - const id = parent.authorId; - return mergeInfo.delegate( - 'query', - 'userById', - { - id, + resolve(chirp, args, context, info) { + return info.mergeInfo.delegateToSchema({ + schema: authorSchema, + operation: 'query', + fieldName: 'userById', + args: { + id: chirp.authorId, }, context, info, - ); + }); }, }, }, - }), + }, }); ``` [Run the above example on Launchpad.](https://launchpad.graphql.com/8r11mk9jq) +

Using with Transforms

+ +Often, when creating a GraphQL gateway that combines multiple existing schemas, we might want to modify one of the schemas. The most common tasks include renaming some of the types, and filtering the root fields. By using [transforms](./schema-transforms) with schema stitching, we can easily tweak the subschemas before merging them together. + +Before, when we were simply merging schemas without first transforming them, we would typically delegate directly to one of the merged schemas. Once we add transforms to the mix, there are times when we want to delegate to fields of the new, transformed schemas, and other times when we want to delegate to the original, untransformed schemas. + +For example, suppose we transform the `chirpSchema` by removing the `chirpsByAuthorId` field and add a `Chirp_` prefix to all types and field names, in order to make it very clear which types and fields came from `chirpSchema`: + +```ts +import { + makeExecutableSchema, + addMockFunctionsToSchema, + mergeSchemas, + transformSchema, + FilterRootFields, + RenameTypes, + RenameRootFields, +} from 'graphql-tools'; + +// Mocked chirp schema; we don't want to worry about the schema +// implementation right now since we're just demonstrating +// schema stitching +const chirpSchema = makeExecutableSchema({ + typeDefs: ` + type Chirp { + id: ID! + text: String + authorId: ID! + } + + type Query { + chirpById(id: ID!): Chirp + chirpsByAuthorId(authorId: ID!): [Chirp] + } + ` +}); + +addMockFunctionsToSchema({ schema: chirpSchema }); + +// create transform schema + +const transformedChirpSchema = transformSchema(chirpSchema, [ + new FilterRootFields( + (operation: string, rootField: string) => rootField !== 'chirpsByAuthorId' + ), + new RenameTypes((name: string) => `Chirp_${name}`), + new RenameRootFields((name: string) => `Chirp_${name}`), +]); +``` + +Now we have a schema that has all fields and types prefixed with `Chirp_` and has only the `chirpById` root field. Note that the original schema has not been modified, and remains fully functional. We've simply created a new, slightly different schema, which hopefully will be more convenient for merging with our other subschemas. + +Now let's implement the resolvers: + +```js +const mergedSchema = mergeSchemas({ + schemas: [ + transformedChirpSchema, + authorSchema, + linkTypeDefs, + ], + resolvers: { + User: { + chirps: { + fragment: `fragment UserFragment on User { id }`, + resolve(user, args, context, info) { + return info.mergeInfo.delegateToSchema({ + schema: chirpSchema, + operation: 'query', + fieldName: 'chirpsByAuthorId', + args: { + authorId: user.id, + }, + context, + info, + transforms: transformedChirpSchema.transforms, + }); + }, + }, + }, + Chirp_Chirp: { + author: { + fragment: `fragment ChirpFragment on Chirp { authorId }`, + resolve(chirp, args, context, info) { + return info.mergeInfo.delegateToSchema({ + schema: authorSchema, + operation: 'query', + fieldName: 'userById', + args: { + id: chirp.authorId, + }, + context, + info, + }); + }, + }, + }, + }, +}); +``` + +Notice that `resolvers.Chirp_Chirp` has been renamed from just `Chirp`, but `resolvers.Chirp_Chirp.author.fragment` still refers to the original `Chirp` type and `authorId` field, rather than `Chirp_Chirp` and `Chirp_authorId`. + +Also, when we call `info.mergeInfo.delegateToSchema` in the `User.chirps` resolvers, we can delegate to the original `chirpsByAuthorId` field, even though it has been filtered out of the final schema. That's because we're delegating to the original `chirpSchema`, which has not been modified by the transforms. +

Complex example

For a more complicated example involving properties and bookings, with implementations of all of the resolvers, check out the Launchpad links below: @@ -176,14 +289,22 @@ For a more complicated example involving properties and bookings, with implement

mergeSchemas

-``` +```ts mergeSchemas({ - schemas: Array, - resolvers?: (mergeInfo: MergeInfo) => IResolvers, + schemas: Array>; + resolvers?: Array | IResolvers; onTypeConflict?: ( left: GraphQLNamedType, - right: GraphQLNamedType - ) => GraphQLNamedType + right: GraphQLNamedType, + info?: { + left: { + schema?: GraphQLSchema; + }; + right: { + schema?: GraphQLSchema; + }; + }, + ) => GraphQLNamedType; }) ``` @@ -191,68 +312,97 @@ This is the main function that implements schema stitching. Read below for a des #### schemas -`schemas` is an array of either `GraphQLSchema` objects or strings. For strings, only `extend type` declarations will be used. Passing strings is useful to add fields to existing types to link schemas together, as described in the example above. +`schemas` is an array of `GraphQLSchema` objects, schema strings, or lists of `GraphQLNamedType`s. Strings can contain type extensions or GraphQL types, which will be added to resulting schema. Note that type extensions are always applied last, while types are defined in the order in which they are provided. #### resolvers -`resolvers` is an optional function that takes one argument - `mergeInfo` - and returns resolvers in the same format as [makeExecutableSchema](./resolvers.html). One addition to the resolver format is the possibility to specify a `fragment` for a resolver. `fragment` must be a GraphQL fragment definition, and allows you to specify which fields from the parent schema are required for the resolver to function correctly. +`resolvers` accepts resolvers in same format as [makeExecutableSchema](./resolvers.html). It can also take an Array of resolvers. One addition to the resolver format is the possibility to specify a `fragment` for a resolver. The `fragment` must be a GraphQL fragment definition string, specifying which fields from the parent schema are required for the resolver to function properly. ```js -resolvers: mergeInfo => ({ +resolvers: { Booking: { property: { fragment: 'fragment BookingFragment on Booking { propertyId }', resolve(parent, args, context, info) { - return mergeInfo.delegate( - 'query', - 'propertyById', - { + return mergeInfo.delegateToSchema({ + schema: bookingSchema, + operation: 'query', + fieldName: 'propertyById', + args: { id: parent.propertyId, }, context, info, - ); + }); }, }, }, -}) +} ``` -#### mergeInfo and delegate +#### mergeInfo and delegateToSchema -`mergeInfo` currently is an object with one property - `delegate`. It looks like this: +The `info.mergeInfo` object provides the `delegateToSchema` method: ```js type MergeInfo = { - delegate( - operation: 'query' | 'mutation', - rootFieldName: string, - args: any, - context: any, - info: GraphQLResolveInfo - ) => any + delegateToSchema(options: IDelegateToSchemaOptions): any; +} + +interface IDelegateToSchemaOptions { + schema: GraphQLSchema; + operation: Operation; + fieldName: string; + args?: { + [key: string]: any; + }; + context: TContext; + info: GraphQLResolveInfo; + transforms?: Array; } ``` -`delegate` takes the operation type (`query` or `mutation`) and root field names, together with the GraphQL execution context -and resolve info, as well as arguments for the root field. It delegates to -one of the merged schema and makes sure that only relevant fields are requested. +As described in the documentation above, `info.mergeInfo.delegateToSchema` allows delegating to any `GraphQLSchema` object, optionally applying transforms in the process. See [Schema Delegation](./schema-delegation.html) and the [*Using with transforms*](#using-with-transforms) section of this document. + +#### onTypeConflict ```js -mergeInfo.delegate( - 'query', - 'propertyById', - { - id: parent.id, +type OnTypeConflict = ( + left: GraphQLNamedType, + right: GraphQLNamedType, + info?: { + left: { + schema?: GraphQLSchema; + }; + right: { + schema?: GraphQLSchema; + }; }, - context, - info, -); +) => GraphQLNamedType; ``` -#### onTypeConflict +The `onTypeConflict` option to `mergeSchemas` allows customization of type resolving logic. + +The default behavior of `mergeSchemas` is to take the first encountered type of all the types with the same name. If there are conflicts, `onTypeConflict` enables explicit selection of the winning type. + +For example, here's how we could select the last type among multiple types with the same name: + +```js +const onTypeConflict = (left, right) => right; +``` + +And here's how we might select the type whose schema has the latest `version`: + +```js +const onTypeConflict = (left, right, info) => { + if (info.left.schema.version >= info.right.schema.version) { + return left; + } else { + return right; + } +} +``` -`onTypeConflict` lets you customize type resolving logic. The default logic is to -take the first encountered type of all the types with the same name. This -method allows customization of this behavior, for example by taking another type or -merging types together. +When using schema transforms, `onTypeConflict` is often unnecessary, since transforms can be used to prevent conflicts before merging schemas. However, if you're not using schema transforms, `onTypeConflict` can be a quick way to make `mergeSchemas` produce more desirable results. diff --git a/docs/source/schema-transforms.md b/docs/source/schema-transforms.md new file mode 100644 index 00000000000..399273a98e7 --- /dev/null +++ b/docs/source/schema-transforms.md @@ -0,0 +1,223 @@ +--- +title: Schema transforms +description: Automatically transforming schemas +--- + +Schema transforms are a tool for making modified copies of `GraphQLSchema` objects, while preserving the possibility of delegating back to original schema. + +Transforms are useful when working with [remote schemas](./remote-schemas.html), building GraphQL gateways that combine multiple schemas, and/or using [schema stitching](./schema-stitching.html) to combine schemas together without conflicts between types or fields. + +While it's possible to modify a schema by hand, the manual approach requires a deep understanding of all the relationships between `GraphQLSchema` properties, which makes it error-prone and labor-intensive. Transforms provide a generic abstraction over all those details, which improves code quality and saves time, not only now but also in the future, because transforms are designed to be reused again and again. + +Each `Transform` may define three different kinds of transform functions: + +```ts +interface Transform = { + transformSchema?: (schema: GraphQLSchema) => GraphQLSchema; + transformRequest?: (request: Request) => Request; + transformResult?: (result: Result) => Result; +}; +``` + +The most commonly used transform function is `transformSchema`. However, some transforms require modifying incoming requests and/or outgoing results as well, especially if `transformSchema` adds or removes types or fields, since such changes require mapping new types/fields to the original types/fields at runtime. + +For example, let's consider changing the name of the type in a simple schema. Imagine we've written a function that takes a `GraphQLSchema` and replaces all instances of type `Test` with `NewTest`. + +```graphql +# old schema +type Test { + id: ID! + name: String +} + +type Query { + returnTest: Test +} + +# new schema + +type NewTest { + id: ID! + name: String +} + +type Query { + returnTest: NewTest +} +``` + +At runtime, we want the `NewTest` type to be automatically mapped to the old `Test` type. + +At first glance, it might seem as though most queries work the same way as before: + +```graphql +query { + returnTest { + id + name + } +} +``` + +Since the fields of the type have not changed, delegating to the old schema is relatively easy here. + +However, the new name begins to matter more when fragments and variables are used: + +```graphql +query { + returnTest { + id + ... on NewTest { + name + } + } +} +``` + +Since the `NewTest` type did not exist on old schema, this fragment will not match anything in the old schema, so it will be filtered out during delegation. + +What we need is a `transformRequest` function that knows how to rename any occurrences of `NewTest` to `Test` before delegating to the old schema. + +By the same reasoning, we also need a `transformResult` function, because any results contain a `__typename` field whose value is `Test`, that name needs to be updated to `NewTest` in the final result. + +

API

+ +

Transform

+ +```ts +interface Transform = { + transformSchema?: (schema: GraphQLSchema) => GraphQLSchema; + transformRequest?: (request: Request) => Request; + transformResult?: (result: Result) => Result; +}; + +type Request = { + document: DocumentNode; + variables: Record; + extensions?: Record; +}; + +type Result = ExecutionResult & { + extensions?: Record; +}; +``` + +

transformSchema

+ +Given a `GraphQLSchema` and an array of `Transform` objects, produce a new schema with those transforms applied. + +Delegating resolvers will also be generated to map from new schema root fields to old schema root fields. Often these automatic resolvers are sufficient, so you don't have to implement your own. + +

Built-in transforms

+ +Built-in transforms are ready-made classes implementing the `Transform` interface. They are intended to cover many of the most common schema transformation use cases, but they also serve as examples of how to implement transforms for your own needs. + +### Modifying types + +* `FilterTypes(filter: (type: GraphQLNamedType) => boolean)`: Remove all types for which the `filter` function returns `false`. + +* `RenameTypes(renamer, options?)`: Rename types by applying `renamer` to each type name. If `renamer` returns `undefined`, the name will be left unchanged. Options controls whether built-in types and scalars are renamed. Root objects are never renamed by this transform. + +```ts +RenameTypes( + (name: string) => string | void, + options?: { + renameBuiltins: Boolean; + renameScalars: Boolean; + }, +) +``` + +### Modifying root fields + +* `TransformRootFields(transformer: RootTransformer)`: Given a transformer, abritrarily transform root fields. The `transformer` can return a `GraphQLFieldConfig` definition, a object with new `name` and a `field`, `null` to remove the field, or `undefined` to leave the field unchanged. + +```ts +TransformRootFields(transformer: RootTransformer) + +type RootTransformer = ( + operation: 'Query' | 'Mutation' | 'Subscription', + fieldName: string, + field: GraphQLField, +) => + | GraphQLFieldConfig + | { name: string; field: GraphQLFieldConfig } + | null + | void; +``` + +* `FilterRootFields(filter: RootFilter)`: Like `FilterTypes`, removes root fields for which the `filter` function returns `false`. + +```ts +FilterRootFields(filter: RootFilter) + +type RootFilter = ( + operation: 'Query' | 'Mutation' | 'Subscription', + fieldName: string, + field: GraphQLField, +) => boolean; +``` + +* `RenameRootFields(renamer)`: Rename root fields, by applying the `renamer` function to their names. + +```ts +RenameRootFields( + renamer: ( + operation: 'Query' | 'Mutation' | 'Subscription', + name: string, + field: GraphQLField, + ) => string, +) +``` + +### Other + +* `ExractField({ from: Array, to: Array })` - move selection at `from` path to `to` path. + +* `WrapQuery( + path: Array, + wrapper: QueryWrapper, + extractor: (result: any) => any, + )` - wrap a selection at `path` using function `wrapper`. Apply `extractor` at the same path to get the result. This is used to get a result nested inside other result + +```js +transforms: [ + // Wrap document takes a subtree as an AST node + new WrapQuery( + // path at which to apply wrapping and extracting + ['userById'], + (subtree: SelectionSetNode) => ({ + // we create a wrapping AST Field + kind: Kind.FIELD, + name: { + kind: Kind.NAME, + // that field is `address` + value: 'address', + }, + // Inside the field selection + selectionSet: subtree, + }), + // how to process the data result at path + result => result && result.address, + ), +], +``` + +* `ReplaceFieldWithFragment(targetSchema: GraphQLSchema, mapping: FieldToFragmentMapping)`: Replace the given fields with an inline fragment. Used by `mergeSchemas` to handle the `fragment` option. + +```ts +type FieldToFragmentMapping = { + [typeName: string]: { [fieldName: string]: InlineFragmentNode }; +}; +``` + +

delegateToSchema transforms

+ +The following transforms are automatically applied by `delegateToSchema` during schema delegation, to translate between new and old types and fields: + +* `AddArgumentsAsVariables`: Given a schema and arguments passed to a root field, make those arguments document variables. +* `FilterToSchema`: Given a schema and document, remove all fields, variables and fragments for types that don't exist in that schema. +* `AddTypenameToAbstract`: Add `__typename` to all abstract types in the document. +* `CheckResultAndHandleErrors`: Given a result from a subschema, propagate errors so that they match the correct subfield. Also provide the correct key if aliases are used. + +By passing a custom `transforms` array to `delegateToSchema`, it's possible to run additional transforms before these default transforms, though it is currently not possible to disable the default transforms. diff --git a/package.json b/package.json index 603f0aba9a9..c0f9139965e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "graphql-tools", - "version": "2.24.0", + "version": "3.0.0-alpha.16", "description": "Useful tools to create and manipulate GraphQL schemas.", "main": "dist/index.js", "typings": "dist/index.d.ts", @@ -55,12 +55,12 @@ "uuid": "^3.1.0" }, "peerDependencies": { - "graphql": "^0.11.0 || ^0.12.0 || ^0.13.0" + "graphql": "^0.12.0 || ^0.13.0" }, "devDependencies": { "@types/chai": "4.0.10", - "@types/dateformat": "^1.0.1", "@types/graphql": "0.11.7", + "@types/dateformat": "^1.0.1", "@types/mocha": "^2.2.44", "@types/node": "^8.0.47", "@types/uuid": "^3.4.3", diff --git a/src/Interfaces.ts b/src/Interfaces.ts index c016c26791c..647e16a6f53 100644 --- a/src/Interfaces.ts +++ b/src/Interfaces.ts @@ -8,12 +8,11 @@ import { GraphQLIsTypeOfFn, GraphQLTypeResolver, GraphQLScalarType, + GraphQLNamedType, DocumentNode, } from 'graphql'; -import { - SchemaDirectiveVisitor, -} from './schemaVisitor'; +import { SchemaDirectiveVisitor } from './schemaVisitor'; /* TODO: Add documentation */ @@ -34,12 +33,29 @@ export interface IAddResolveFunctionsToSchemaOptions { } export interface IResolverOptions { + fragment?: string; resolve?: IFieldResolver; subscribe?: IFieldResolver; __resolveType?: GraphQLTypeResolver; __isTypeOf?: GraphQLIsTypeOfFn; } +export type Transform = { + transformSchema?: (schema: GraphQLSchema) => GraphQLSchema; + transformRequest?: (originalRequest: Request) => Request; + transformResult?: (result: Result) => Result; +}; + +export interface IDelegateToSchemaOptions { + schema: GraphQLSchema; + operation: Operation; + fieldName: string; + args?: { [key: string]: any }; + context: TContext; + info: GraphQLResolveInfo; + transforms?: Array; +} + export type MergeInfo = { delegate: ( type: 'query' | 'mutation' | 'subscription', @@ -47,7 +63,9 @@ export type MergeInfo = { args: { [key: string]: any }, context: { [key: string]: any }, info: GraphQLResolveInfo, + transforms?: Array, ) => any; + delegateToSchema(options: IDelegateToSchemaOptions): any; }; export type IFieldResolver = ( @@ -70,6 +88,11 @@ export interface IResolvers { | GraphQLScalarType | IEnumResolver; } +export type IResolversParameter = + | Array IResolvers)> + | IResolvers + | ((mergeInfo: MergeInfo) => IResolvers); + export interface ILogger { log: (message: string | Error) => void; } @@ -78,9 +101,13 @@ export interface IConnectorCls { new (context?: TContext): any; } export type IConnectorFn = (context?: TContext) => any; -export type IConnector = IConnectorCls | IConnectorFn; +export type IConnector = + | IConnectorCls + | IConnectorFn; -export type IConnectors = { [key: string]: IConnector }; +export type IConnectors = { + [key: string]: IConnector; +}; export interface IExecutableSchemaDefinition { typeDefs: ITypeDefinitions; @@ -136,11 +163,40 @@ export interface IMockServer { ) => Promise; } +export type MergeTypeCandidate = { + schema?: GraphQLSchema; + type: GraphQLNamedType; +}; + +export type TypeWithResolvers = { + type: GraphQLNamedType; + resolvers?: IResolvers; +}; + +export type VisitTypeResult = GraphQLNamedType | TypeWithResolvers | null; + +export type VisitType = ( + name: string, + candidates: Array, +) => VisitTypeResult; + +export type Operation = 'query' | 'mutation' | 'subscription'; + +export type Request = { + document: DocumentNode; + variables: Record; + extensions?: Record; +}; + +export type Result = ExecutionResult & { + extensions?: Record; +}; + export type ResolveType = (type: T) => T; export type GraphQLParseOptions = { - noLocation?: boolean, - allowLegacySDLEmptyFields?: boolean, - allowLegacySDLImplementsInterfaces?: boolean, - experimentalFragmentVariables?: boolean, + noLocation?: boolean; + allowLegacySDLEmptyFields?: boolean; + allowLegacySDLImplementsInterfaces?: boolean; + experimentalFragmentVariables?: boolean; }; diff --git a/src/implementsAbstractType.ts b/src/implementsAbstractType.ts new file mode 100644 index 00000000000..b54b51db073 --- /dev/null +++ b/src/implementsAbstractType.ts @@ -0,0 +1,20 @@ +import { + GraphQLType, + GraphQLSchema, + doTypesOverlap, + isCompositeType, +} from 'graphql'; + +export default function implementsAbstractType( + schema: GraphQLSchema, + typeA: GraphQLType, + typeB: GraphQLType, +) { + if (typeA === typeB) { + return true; + } else if (isCompositeType(typeA) && isCompositeType(typeB)) { + return doTypesOverlap(schema, typeA, typeB); + } else { + return false; + } +} diff --git a/src/index.ts b/src/index.ts index fa743095b3a..2a74c8b4c65 100755 --- a/src/index.ts +++ b/src/index.ts @@ -2,4 +2,5 @@ export * from './Interfaces'; export * from './schemaGenerator'; export * from './mock'; export * from './stitching'; -export * from './schemaVisitor'; +export * from './transforms'; +export { SchemaDirectiveVisitor } from './schemaVisitor'; diff --git a/src/isEmptyObject.ts b/src/isEmptyObject.ts index a67d6713160..4533d20ef86 100644 --- a/src/isEmptyObject.ts +++ b/src/isEmptyObject.ts @@ -1,4 +1,4 @@ -export default function isEmptyObject(obj: Object): Boolean { +export default function isEmptyObject(obj: Object): boolean { if (!obj) { return true; } diff --git a/src/isSpecifiedScalarType.ts b/src/isSpecifiedScalarType.ts new file mode 100644 index 00000000000..0fec7d93544 --- /dev/null +++ b/src/isSpecifiedScalarType.ts @@ -0,0 +1,32 @@ +import { + GraphQLString, + GraphQLInt, + GraphQLFloat, + GraphQLBoolean, + GraphQLID, + GraphQLScalarType, + isNamedType, +} from 'graphql'; + +// FIXME: Replace with https://github.com/graphql/graphql-js/blob/master/src/type/scalars.js#L139 + +export const specifiedScalarTypes: Array = [ + GraphQLString, + GraphQLInt, + GraphQLFloat, + GraphQLBoolean, + GraphQLID, +]; + +export default function isSpecifiedScalarType(type: any): boolean { + return ( + isNamedType(type) && + // Would prefer to use specifiedScalarTypes.some(), however %checks needs + // a simple expression. + (type.name === GraphQLString.name || + type.name === GraphQLInt.name || + type.name === GraphQLFloat.name || + type.name === GraphQLBoolean.name || + type.name === GraphQLID.name) + ); +} diff --git a/src/mergeDeep.ts b/src/mergeDeep.ts index e970535cce0..599ab656773 100644 --- a/src/mergeDeep.ts +++ b/src/mergeDeep.ts @@ -16,6 +16,6 @@ export default function mergeDeep(target: any, source: any): any { return output; } -function isObject(item: any): Boolean { +function isObject(item: any): boolean { return item && typeof item === 'object' && !Array.isArray(item); } diff --git a/src/stitching/TypeRegistry.ts b/src/stitching/TypeRegistry.ts deleted file mode 100644 index 04ecb7ee210..00000000000 --- a/src/stitching/TypeRegistry.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { - GraphQLSchema, - GraphQLNonNull, - GraphQLList, - GraphQLNamedType, - GraphQLType, - isNamedType, - getNamedType, - InlineFragmentNode, - Kind, - parse, - OperationDefinitionNode, -} from 'graphql'; - -export default class TypeRegistry { - public fragmentReplacements: { - [typeName: string]: { [fieldName: string]: InlineFragmentNode }; - }; - private types: { [key: string]: GraphQLNamedType }; - private schemaByField: { - query: { [key: string]: GraphQLSchema }; - mutation: { [key: string]: GraphQLSchema }; - subscription: { [key: string]: GraphQLSchema }; - }; - - constructor() { - this.types = {}; - this.schemaByField = { - query: {}, - mutation: {}, - subscription: {}, - }; - this.fragmentReplacements = {}; - } - - public getSchemaByField( - operation: 'query' | 'mutation' | 'subscription', - fieldName: string, - ): GraphQLSchema { - return this.schemaByField[operation][fieldName]; - } - - public getAllTypes(): Array { - return Object.keys(this.types).map(name => this.types[name]); - } - - public getType(name: string): GraphQLNamedType { - if (!this.types[name]) { - throw new Error(`No such type: ${name}`); - } - return this.types[name]; - } - - public resolveType(type: T): T { - if (type instanceof GraphQLList) { - return new GraphQLList(this.resolveType(type.ofType)) as T; - } else if (type instanceof GraphQLNonNull) { - return new GraphQLNonNull(this.resolveType(type.ofType)) as T; - } else if (isNamedType(type)) { - return this.getType(getNamedType(type).name) as T; - } else { - return type; - } - } - - public addSchema(schema: GraphQLSchema) { - const query = schema.getQueryType(); - if (query) { - const fieldNames = Object.keys(query.getFields()); - fieldNames.forEach(field => { - this.schemaByField.query[field] = schema; - }); - } - - const mutation = schema.getMutationType(); - if (mutation) { - const fieldNames = Object.keys(mutation.getFields()); - fieldNames.forEach(field => { - this.schemaByField.mutation[field] = schema; - }); - } - - const subscription = schema.getSubscriptionType(); - if (subscription) { - const fieldNames = Object.keys(subscription.getFields()); - fieldNames.forEach(field => { - this.schemaByField.subscription[field] = schema; - }); - } - } - - public addType( - name: string, - type: GraphQLNamedType, - onTypeConflict?: ( - leftType: GraphQLNamedType, - rightType: GraphQLNamedType, - ) => GraphQLNamedType, - ): void { - if (this.types[name]) { - if (onTypeConflict) { - type = onTypeConflict(this.types[name], type); - } else { - throw new Error(`Type name conflict: ${name}`); - } - } - this.types[name] = type; - } - - public addFragment(typeName: string, fieldName: string, fragment: string) { - if (!this.fragmentReplacements[typeName]) { - this.fragmentReplacements[typeName] = {}; - } - this.fragmentReplacements[typeName][ - fieldName - ] = parseFragmentToInlineFragment(fragment); - } -} - -function parseFragmentToInlineFragment( - definitions: string, -): InlineFragmentNode { - if (definitions.trim().startsWith('fragment')) { - const document = parse(definitions); - for (const definition of document.definitions) { - if (definition.kind === Kind.FRAGMENT_DEFINITION) { - return { - kind: Kind.INLINE_FRAGMENT, - typeCondition: definition.typeCondition, - selectionSet: definition.selectionSet, - }; - } - } - } - - const query = parse(`{${definitions}}`).definitions[0] as OperationDefinitionNode; - for (const selection of query.selectionSet.selections) { - if (selection.kind === Kind.INLINE_FRAGMENT) { - return selection; - } - } - - throw new Error('Could not parse fragment'); -} diff --git a/src/stitching/delegateToSchema.ts b/src/stitching/delegateToSchema.ts index 15f899891ee..e5f95c903ba 100644 --- a/src/stitching/delegateToSchema.ts +++ b/src/stitching/delegateToSchema.ts @@ -1,585 +1,157 @@ import { + ArgumentNode, DocumentNode, FieldNode, FragmentDefinitionNode, - FragmentSpreadNode, - GraphQLField, - GraphQLInputType, - GraphQLInterfaceType, - GraphQLList, - GraphQLNamedType, - GraphQLNonNull, - GraphQLObjectType, - GraphQLResolveInfo, - GraphQLSchema, - GraphQLType, - GraphQLUnionType, - InlineFragmentNode, Kind, OperationDefinitionNode, SelectionSetNode, - TypeNameMetaFieldDef, - TypeNode, - VariableDefinitionNode, - VariableNode, - execute, - visit, + SelectionNode, subscribe, + execute, validate, + VariableDefinitionNode, + GraphQLSchema, } from 'graphql'; -import { checkResultAndHandleErrors } from './errors'; -export default async function delegateToSchema( - schema: GraphQLSchema, - fragmentReplacements: { - [typeName: string]: { [fieldName: string]: InlineFragmentNode }; - }, - operation: 'query' | 'mutation' | 'subscription', - fieldName: string, - args: { [key: string]: any }, - context: { [key: string]: any }, - info: GraphQLResolveInfo, -): Promise { - let type; - if (operation === 'mutation') { - type = schema.getMutationType(); - } else if (operation === 'subscription') { - type = schema.getSubscriptionType(); - } else { - type = schema.getQueryType(); - } - if (type) { - const graphqlDoc: DocumentNode = createDocument( - schema, - fragmentReplacements, - type, - fieldName, - operation, - info.fieldNodes, - info.fragments, - info.operation.variableDefinitions, - ); - - const errors = validate(schema, graphqlDoc); - if (errors.length > 0) { - throw errors; - } +import { + Operation, + Request, + IDelegateToSchemaOptions, +} from '../Interfaces'; - const operationDefinition = graphqlDoc.definitions.find( - ({ kind }) => kind === Kind.OPERATION_DEFINITION, +import { + applyRequestTransforms, + applyResultTransforms, +} from '../transforms/transforms'; + +import AddArgumentsAsVariables from '../transforms/AddArgumentsAsVariables'; +import FilterToSchema from '../transforms/FilterToSchema'; +import AddTypenameToAbstract from '../transforms/AddTypenameToAbstract'; +import CheckResultAndHandleErrors from '../transforms/CheckResultAndHandleErrors'; + +export default function delegateToSchema( + options: IDelegateToSchemaOptions | GraphQLSchema, + ...args: any[], +): Promise { + if (options instanceof GraphQLSchema) { + throw new Error( + 'Passing positional arguments to delegateToSchema is a deprecated. ' + + 'Please pass named parameters instead.' ); - let variableValues = {}; - if ( - operationDefinition && - operationDefinition.kind === Kind.OPERATION_DEFINITION && - operationDefinition.variableDefinitions && - Array.isArray(operationDefinition.variableDefinitions) - ) { - for (const definition of operationDefinition.variableDefinitions) { - const key = definition.variable.name.value; - // (XXX) This is kinda hacky - const actualKey = key.startsWith('_') ? key.slice(1) : key; - variableValues[key] = args[actualKey] != null ? args[actualKey] : info.variableValues[key]; - } - } - - if (operation === 'query' || operation === 'mutation') { - const result = await execute( - schema, - graphqlDoc, - info.rootValue, - context, - variableValues, - ); - return checkResultAndHandleErrors(result, info, fieldName); - } - - if (operation === 'subscription') { - return subscribe( - schema, - graphqlDoc, - info.rootValue, - context, - variableValues, - ); - } } - - throw new Error('Could not forward to merged schema'); + return delegateToSchemaImplementation(options); } -export function createDocument( - schema: GraphQLSchema, - fragmentReplacements: { - [typeName: string]: { [fieldName: string]: InlineFragmentNode }; - }, - type: GraphQLObjectType, - rootFieldName: string, - operation: 'query' | 'mutation' | 'subscription', - selections: Array, - fragments: { [fragmentName: string]: FragmentDefinitionNode }, - variableDefinitions?: Array, -): DocumentNode { - const rootField = type.getFields()[rootFieldName]; - const newVariables: Array<{ arg: string; variable: string }> = []; - const rootSelectionSet = { - kind: Kind.SELECTION_SET, - // (XXX) This (wrongly) assumes only having one fieldNode - selections: selections.map(selection => { - if (selection.kind === Kind.FIELD) { - const { selection: newSelection, variables } = processRootField( - selection, - rootFieldName, - rootField, - ); - newVariables.push(...variables); - return newSelection; - } else { - return selection; - } - }), - }; - const newVariableDefinitions: VariableDefinitionNode[] = []; - newVariables.forEach(({ arg, variable }) => { - if (newVariableDefinitions.find(newVarDef => newVarDef.variable.name.value === variable)) { - return; - } - const argDef = rootField.args.find(rootArg => rootArg.name === arg); - if (!argDef) { - throw new Error('Unexpected missing arg'); - } - const typeName = typeToAst(argDef.type); - newVariableDefinitions.push({ - kind: Kind.VARIABLE_DEFINITION, - variable: { - kind: Kind.VARIABLE, - name: { - kind: Kind.NAME, - value: variable, - }, - }, - type: typeName, - }); - }); - - const { - selectionSet, - fragments: processedFragments, - usedVariables, - } = filterSelectionSetDeep( - schema, - fragmentReplacements, - type, - rootSelectionSet, - fragments, +async function delegateToSchemaImplementation( + options: IDelegateToSchemaOptions, +): Promise { + const { info, args = {} } = options; + const rawDocument: DocumentNode = createDocument( + options.fieldName, + options.operation, + info.fieldNodes, + Object.keys(info.fragments).map( + fragmentName => info.fragments[fragmentName], + ), + info.operation.variableDefinitions, ); - const operationDefinition: OperationDefinitionNode = { - kind: Kind.OPERATION_DEFINITION, - operation, - variableDefinitions: [ - ...(variableDefinitions || []).filter( - variableDefinition => - usedVariables.indexOf(variableDefinition.variable.name.value) !== -1, - ), - ...newVariableDefinitions - ], - selectionSet, + const rawRequest: Request = { + document: rawDocument, + variables: info.variableValues as Record, }; - const newDoc: DocumentNode = { - kind: Kind.DOCUMENT, - definitions: [operationDefinition, ...processedFragments], - }; + const transforms = [ + ...(options.transforms || []), + new AddArgumentsAsVariables(options.schema, args), + new FilterToSchema(options.schema), + new AddTypenameToAbstract(options.schema), + new CheckResultAndHandleErrors(info, options.fieldName), + ]; - return newDoc; -} + const processedRequest = applyRequestTransforms(rawRequest, transforms); -function processRootField( - selection: FieldNode, - rootFieldName: string, - rootField: GraphQLField, -): { - selection: FieldNode; - variables: Array<{ arg: string; variable: string }>; -} { - const existingArguments = selection.arguments || []; - const existingArgumentNames = existingArguments.map(arg => arg.name.value); - const allowedArguments = rootField.args.map(arg => arg.name); - const missingArgumentNames = difference( - allowedArguments, - existingArgumentNames, - ); - const extraArguments = difference(existingArgumentNames, allowedArguments); - const filteredExistingArguments = existingArguments.filter( - arg => extraArguments.indexOf(arg.name.value) === -1, - ); - const variables: Array<{ arg: string; variable: string }> = []; - const missingArguments = missingArgumentNames.map(name => { - // (XXX): really needs better var generation - const variableName = `_${name}`; - variables.push({ - arg: name, - variable: variableName, - }); - return { - kind: Kind.ARGUMENT, - name: { - kind: Kind.NAME, - value: name, - }, - value: { - kind: Kind.VARIABLE, - name: { - kind: Kind.NAME, - value: variableName, - }, - }, - }; - }); + const errors = validate(options.schema, processedRequest.document); + if (errors.length > 0) { + throw errors; + } - return { - selection: { - kind: Kind.FIELD, - alias: null, - arguments: [...filteredExistingArguments, ...missingArguments], - selectionSet: selection.selectionSet, - name: { - kind: Kind.NAME, - value: rootFieldName, - }, - }, - variables, - }; + if (options.operation === 'query' || options.operation === 'mutation') { + return applyResultTransforms( + await execute( + options.schema, + processedRequest.document, + info.rootValue, + options.context, + processedRequest.variables, + ), + transforms, + ); + } + + if (options.operation === 'subscription') { + // apply result processing ??? + return subscribe( + options.schema, + processedRequest.document, + info.rootValue, + options.context, + processedRequest.variables, + ); + } } -function filterSelectionSetDeep( - schema: GraphQLSchema, - fragmentReplacements: { - [typeName: string]: { [fieldName: string]: InlineFragmentNode }; - }, - type: GraphQLType, - selectionSet: SelectionSetNode, - fragments: { [fragmentName: string]: FragmentDefinitionNode }, -): { - selectionSet: SelectionSetNode; - fragments: Array; - usedVariables: Array; -} { - const validFragments: Array = []; - Object.keys(fragments).forEach(fragmentName => { - const fragment = fragments[fragmentName]; - const typeName = fragment.typeCondition.name.value; - const innerType = schema.getType(typeName); - if (innerType) { - validFragments.push(fragment); - } +function createDocument( + targetField: string, + targetOperation: Operation, + originalSelections: Array, + fragments: Array, + variables: Array, +): DocumentNode { + let selections: Array = []; + let args: Array = []; + + originalSelections.forEach((field: FieldNode) => { + const fieldSelections = field.selectionSet + ? field.selectionSet.selections + : []; + selections = selections.concat(fieldSelections); + args = args.concat(field.arguments || []); }); - let { - selectionSet: newSelectionSet, - usedFragments: remainingFragments, - usedVariables, - } = filterSelectionSet( - schema, - fragmentReplacements, - type, - selectionSet, - validFragments, - ); - const newFragments = {}; - // (XXX): So this will break if we have a fragment that only has link fields - while (remainingFragments.length > 0) { - const name = remainingFragments.pop(); - if (newFragments[name]) { - continue; - } else { - const nextFragment = fragments[name]; - if (!name) { - throw new Error(`Could not find fragment ${name}`); - } - const typeName = nextFragment.typeCondition.name.value; - const innerType = schema.getType(typeName); - if (!innerType) { - continue; - } - const { - selectionSet: fragmentSelectionSet, - usedFragments: fragmentUsedFragments, - usedVariables: fragmentUsedVariables, - } = filterSelectionSet( - schema, - fragmentReplacements, - innerType, - nextFragment.selectionSet, - validFragments, - ); - remainingFragments = union(remainingFragments, fragmentUsedFragments); - usedVariables = union(usedVariables, fragmentUsedVariables); - newFragments[name] = { - kind: Kind.FRAGMENT_DEFINITION, - name: { - kind: Kind.NAME, - value: name, - }, - typeCondition: nextFragment.typeCondition, - selectionSet: fragmentSelectionSet, - }; - } + let selectionSet = null; + if (selections.length > 0) { + selectionSet = { + kind: Kind.SELECTION_SET, + selections: selections, + }; } - const newFragmentValues: Array = Object.keys( - newFragments, - ).map(name => newFragments[name]); - return { - selectionSet: newSelectionSet, - fragments: newFragmentValues, - usedVariables, - }; -} -function filterSelectionSet( - schema: GraphQLSchema, - fragmentReplacements: { - [typeName: string]: { [fieldName: string]: InlineFragmentNode }; - }, - type: GraphQLType, - selectionSet: SelectionSetNode, - validFragments: Array, -): { - selectionSet: SelectionSetNode; - usedFragments: Array; - usedVariables: Array; -} { - const usedFragments: Array = []; - const usedVariables: Array = []; - const typeStack: Array = [type]; - const filteredSelectionSet = visit(selectionSet, { - [Kind.FIELD]: { - enter(node: FieldNode): null | undefined | FieldNode { - let parentType: GraphQLNamedType = resolveType( - typeStack[typeStack.length - 1], - ); - if ( - parentType instanceof GraphQLObjectType || - parentType instanceof GraphQLInterfaceType - ) { - const fields = parentType.getFields(); - const field = - node.name.value === '__typename' - ? TypeNameMetaFieldDef - : fields[node.name.value]; - if (!field) { - return null; - } else { - typeStack.push(field.type); - } - } else if ( - parentType instanceof GraphQLUnionType && - node.name.value === '__typename' - ) { - typeStack.push(TypeNameMetaFieldDef.type); - } - }, - leave() { - typeStack.pop(); - }, + const rootField: FieldNode = { + kind: Kind.FIELD, + alias: null, + arguments: args, + selectionSet, + name: { + kind: Kind.NAME, + value: targetField, }, - [Kind.SELECTION_SET]( - node: SelectionSetNode, - ): SelectionSetNode | null | undefined { - const parentType: GraphQLType = resolveType( - typeStack[typeStack.length - 1], - ); - const parentTypeName = parentType.name; - let selections = node.selections; - if ( - (parentType instanceof GraphQLInterfaceType || - parentType instanceof GraphQLUnionType) && - !selections.find( - _ => - (_ as FieldNode).kind === Kind.FIELD && - (_ as FieldNode).name.value === '__typename', - ) - ) { - selections = selections.concat({ - kind: Kind.FIELD, - name: { - kind: Kind.NAME, - value: '__typename', - }, - }); - } - - if (fragmentReplacements[parentTypeName]) { - selections.forEach(selection => { - if (selection.kind === Kind.FIELD) { - const name = selection.name.value; - const fragment = fragmentReplacements[parentTypeName][name]; - if (fragment) { - selections = selections.concat(fragment); - } - } - }); - } + }; + const rootSelectionSet: SelectionSetNode = { + kind: Kind.SELECTION_SET, + selections: [rootField], + }; - if (selections !== node.selections) { - return { - ...node, - selections, - }; - } - }, - [Kind.FRAGMENT_SPREAD](node: FragmentSpreadNode): null | undefined { - const fragmentFiltered = validFragments.filter( - frg => frg.name.value === node.name.value, - ); - const fragment = fragmentFiltered[0]; - if (fragment) { - if (fragment.typeCondition) { - const innerType = schema.getType(fragment.typeCondition.name.value); - const parentType: GraphQLNamedType = resolveType( - typeStack[typeStack.length - 1], - ); - if (!implementsAbstractType(parentType, innerType)) { - return null; - } - } - usedFragments.push(node.name.value); - return; - } else { - return null; - } - }, - [Kind.INLINE_FRAGMENT]: { - enter(node: InlineFragmentNode): null | undefined { - if (node.typeCondition) { - const innerType = schema.getType(node.typeCondition.name.value); - const parentType: GraphQLNamedType = resolveType( - typeStack[typeStack.length - 1], - ); - if (implementsAbstractType(parentType, innerType)) { - typeStack.push(innerType); - } else { - return null; - } - } - }, - leave(node: InlineFragmentNode): null | undefined { - if (node.typeCondition) { - const innerType = schema.getType(node.typeCondition.name.value); - if (innerType) { - typeStack.pop(); - } else { - return null; - } - } - }, - }, - [Kind.VARIABLE](node: VariableNode) { - usedVariables.push(node.name.value); - }, - }); + const operationDefinition: OperationDefinitionNode = { + kind: Kind.OPERATION_DEFINITION, + operation: targetOperation, + variableDefinitions: variables, + selectionSet: rootSelectionSet, + }; return { - selectionSet: filteredSelectionSet, - usedFragments, - usedVariables, + kind: Kind.DOCUMENT, + definitions: [operationDefinition, ...fragments], }; } - -function resolveType(type: GraphQLType): GraphQLNamedType { - let lastType = type; - while ( - lastType instanceof GraphQLNonNull || - lastType instanceof GraphQLList - ) { - lastType = lastType.ofType; - } - return lastType; -} - -function implementsAbstractType( - parent: GraphQLType, - child: GraphQLType, - bail: boolean = false, -): boolean { - if (parent === child) { - return true; - } else if ( - parent instanceof GraphQLInterfaceType && - child instanceof GraphQLObjectType - ) { - return child.getInterfaces().indexOf(parent) !== -1; - } else if ( - parent instanceof GraphQLInterfaceType && - child instanceof GraphQLInterfaceType - ) { - return true; - } else if ( - parent instanceof GraphQLUnionType && - child instanceof GraphQLObjectType - ) { - return parent.getTypes().indexOf(child) !== -1; - } else if (parent instanceof GraphQLObjectType && !bail) { - return implementsAbstractType(child, parent, true); - } - - return false; -} - -function typeToAst(type: GraphQLInputType): TypeNode { - if (type instanceof GraphQLNonNull) { - const innerType = typeToAst(type.ofType); - if ( - innerType.kind === Kind.LIST_TYPE || - innerType.kind === Kind.NAMED_TYPE - ) { - return { - kind: Kind.NON_NULL_TYPE, - type: innerType, - }; - } else { - throw new Error('Incorrent inner non-null type'); - } - } else if (type instanceof GraphQLList) { - return { - kind: Kind.LIST_TYPE, - type: typeToAst(type.ofType), - }; - } else { - return { - kind: Kind.NAMED_TYPE, - name: { - kind: Kind.NAME, - value: type.toString(), - }, - }; - } -} - -function union(...arrays: Array>): Array { - const cache: { [key: string]: Boolean } = {}; - const result: Array = []; - arrays.forEach(array => { - array.forEach(item => { - if (!cache[item]) { - cache[item] = true; - result.push(item); - } - }); - }); - return result; -} - -function difference( - from: Array, - ...arrays: Array> -): Array { - const cache: { [key: string]: Boolean } = {}; - arrays.forEach(array => { - array.forEach(item => { - cache[item] = true; - }); - }); - return from.filter(item => !cache[item]); -} diff --git a/src/stitching/index.ts b/src/stitching/index.ts index 6672ce8964b..72724c6fb99 100644 --- a/src/stitching/index.ts +++ b/src/stitching/index.ts @@ -1,7 +1,7 @@ import makeRemoteExecutableSchema, { createResolver as defaultCreateRemoteResolver } from './makeRemoteExecutableSchema'; import introspectSchema from './introspectSchema'; import mergeSchemas from './mergeSchemas'; -import delegateToSchema, { createDocument } from './delegateToSchema'; +import delegateToSchema from './delegateToSchema'; import defaultMergedResolver from './defaultMergedResolver'; export { @@ -11,7 +11,6 @@ export { // Those are currently undocumented and not part of official API, // but exposed for the community use delegateToSchema, - createDocument, defaultMergedResolver, defaultCreateRemoteResolver }; diff --git a/src/stitching/makeRemoteExecutableSchema.ts b/src/stitching/makeRemoteExecutableSchema.ts index 43c7237b3e2..e8444854441 100644 --- a/src/stitching/makeRemoteExecutableSchema.ts +++ b/src/stitching/makeRemoteExecutableSchema.ts @@ -18,7 +18,6 @@ import { buildSchema, printSchema, Kind, - ValueNode, GraphQLResolveInfo, DocumentNode } from 'graphql'; @@ -26,6 +25,7 @@ import linkToFetcher, { execute } from './linkToFetcher'; import isEmptyObject from '../isEmptyObject'; import { IResolvers, IResolverObject } from '../Interfaces'; import { makeExecutableSchema } from '../schemaGenerator'; +import { recreateType } from './schemaRecreation'; import resolveParentFromTypename from './resolveFromParentTypename'; import defaultMergedResolver from './defaultMergedResolver'; import { checkResultAndHandleErrors } from './errors'; @@ -118,7 +118,10 @@ export default function makeRemoteExecutableSchema({ const typeMap = schema.getTypeMap(); const types = Object.keys(typeMap).map(name => typeMap[name]); for (const type of types) { - if (type instanceof GraphQLInterfaceType || type instanceof GraphQLUnionType) { + if ( + type instanceof GraphQLInterfaceType || + type instanceof GraphQLUnionType + ) { resolvers[type.name] = { __resolveType(parent, context, info) { return resolveParentFromTypename(parent, info.schema); @@ -134,7 +137,11 @@ export default function makeRemoteExecutableSchema({ type === GraphQLInt ) ) { - resolvers[type.name] = createPassThroughScalar(type); + resolvers[type.name] = recreateType( + type, + (name: string) => null, + false, + ) as GraphQLScalarType; } } else if ( type instanceof GraphQLObjectType && @@ -159,7 +166,9 @@ export default function makeRemoteExecutableSchema({ export function createResolver(fetcher: Fetcher): GraphQLFieldResolver { return async (root, args, context, info) => { - const fragments = Object.keys(info.fragments).map(fragment => info.fragments[fragment]); + const fragments = Object.keys(info.fragments).map( + fragment => info.fragments[fragment], + ); const document = { kind: Kind.DOCUMENT, definitions: [info.operation, ...fragments], @@ -173,9 +182,14 @@ export function createResolver(fetcher: Fetcher): GraphQLFieldResolver }; } -function createSubscriptionResolver(name: string, link: ApolloLink): ResolverFn { +function createSubscriptionResolver( + name: string, + link: ApolloLink, +): ResolverFn { return (root, args, context, info) => { - const fragments = Object.keys(info.fragments).map(fragment => info.fragments[fragment]); + const fragments = Object.keys(info.fragments).map( + fragment => info.fragments[fragment], + ); const document = { kind: Kind.DOCUMENT, definitions: [info.operation, ...fragments], @@ -192,51 +206,3 @@ function createSubscriptionResolver(name: string, link: ApolloLink): ResolverFn return observableToAsyncIterable(observable); }; } - -function createPassThroughScalar({ - name, - description, -}: { - name: string; - description: string; -}): GraphQLScalarType { - return new GraphQLScalarType({ - name: name, - description: description, - serialize(value) { - return value; - }, - parseValue(value) { - return value; - }, - parseLiteral(ast) { - return parseLiteral(ast); - }, - }); -} - -function parseLiteral(ast: ValueNode): any { - switch (ast.kind) { - case Kind.STRING: - case Kind.BOOLEAN: { - return ast.value; - } - case Kind.INT: - case Kind.FLOAT: { - return parseFloat(ast.value); - } - case Kind.OBJECT: { - const value = Object.create(null); - ast.fields.forEach(field => { - value[field.name.value] = parseLiteral(field.value); - }); - - return value; - } - case Kind.LIST: { - return ast.values.map(parseLiteral); - } - default: - return null; - } -} diff --git a/src/stitching/mergeSchemas.ts b/src/stitching/mergeSchemas.ts index b238483e958..c55cc8fb270 100644 --- a/src/stitching/mergeSchemas.ts +++ b/src/stitching/mergeSchemas.ts @@ -1,28 +1,30 @@ import { DocumentNode, GraphQLField, - GraphQLFieldMap, + GraphQLInputObjectType, + GraphQLInterfaceType, GraphQLNamedType, GraphQLObjectType, GraphQLResolveInfo, GraphQLScalarType, GraphQLSchema, - GraphQLType, - buildASTSchema, + GraphQLString, extendSchema, getNamedType, isNamedType, parse, } from 'graphql'; -import TypeRegistry from './TypeRegistry'; import { + IDelegateToSchemaOptions, + IFieldResolver, IResolvers, MergeInfo, - IFieldResolver, - UnitOrList, + MergeTypeCandidate, + TypeWithResolvers, + VisitType, + VisitTypeResult, + IResolversParameter, } from '../Interfaces'; -import isEmptyObject from '../isEmptyObject'; -import mergeDeep from '../mergeDeep'; import { extractExtensionDefinitions, addResolveFunctionsToSchema, @@ -33,221 +35,251 @@ import { createResolveType, } from './schemaRecreation'; import delegateToSchema from './delegateToSchema'; -import typeFromAST from './typeFromAST'; +import typeFromAST, { GetType } from './typeFromAST'; +import { + Transform, + ExpandAbstractTypes, + ReplaceFieldWithFragment, +} from '../transforms'; +import mergeDeep from '../mergeDeep'; -const backcompatOptions = { commentDescriptions: true }; +export type OnTypeConflict = ( + left: GraphQLNamedType, + right: GraphQLNamedType, + info?: { + left: { + schema?: GraphQLSchema; + }; + right: { + schema?: GraphQLSchema; + }; + }, +) => GraphQLNamedType; export default function mergeSchemas({ schemas, onTypeConflict, resolvers, }: { - schemas: Array; - onTypeConflict?: ( - left: GraphQLNamedType, - right: GraphQLNamedType, - ) => GraphQLNamedType; - resolvers?: UnitOrList IResolvers)>; + schemas: Array>; + onTypeConflict?: OnTypeConflict; + resolvers?: IResolversParameter; }): GraphQLSchema { - if (!onTypeConflict) { - onTypeConflict = defaultOnTypeConflict; + let visitType: VisitType = defaultVisitType; + if (onTypeConflict) { + console.warn( + '`onTypeConflict` is deprecated. Use schema transforms to customize merging logic.', + ); + visitType = createVisitTypeFromOnTypeConflict(onTypeConflict); } - let queryFields: GraphQLFieldMap = {}; - let mutationFields: GraphQLFieldMap = {}; - let subscriptionFields: GraphQLFieldMap = {}; + return mergeSchemasImplementation({ schemas, visitType, resolvers }); +} + +function mergeSchemasImplementation({ + schemas, + visitType, + resolvers, +}: { + schemas: Array>; + visitType?: VisitType; + resolvers?: IResolversParameter; +}): GraphQLSchema { + const allSchemas: Array = []; + const typeCandidates: { [name: string]: Array } = {}; + const types: { [name: string]: GraphQLNamedType } = {}; + const extensions: Array = []; + const fragments: Array<{ + field: string; + fragment: string; + }> = []; - const typeRegistry = new TypeRegistry(); + if (!visitType) { + visitType = defaultVisitType; + } const resolveType = createResolveType(name => { - return typeRegistry.getType(name); + if (types[name] === undefined) { + throw new Error(`Can't find type ${name}.`); + } + return types[name]; }); - const mergeInfo: MergeInfo = createMergeInfo(typeRegistry); - - const actualSchemas: Array = []; - const typeFragments: Array = []; - const extensions: Array = []; - let fullResolvers: IResolvers = {}; + const createNamedStub: GetType = (name, type) => { + let constructor: any; + if (type === 'object') { + constructor = GraphQLObjectType; + } else if (type === 'interface') { + constructor = GraphQLInterfaceType; + } else { + constructor = GraphQLInputObjectType; + } + return new constructor({ + name, + fields: { + __fake: { + type: GraphQLString, + }, + }, + }); + }; schemas.forEach(schema => { if (schema instanceof GraphQLSchema) { - actualSchemas.push(schema); + allSchemas.push(schema); + const queryType = schema.getQueryType(); + const mutationType = schema.getMutationType(); + const subscriptionType = schema.getSubscriptionType(); + if (queryType) { + addTypeCandidate(typeCandidates, 'Query', { + schema, + type: queryType, + }); + } + if (mutationType) { + addTypeCandidate(typeCandidates, 'Mutation', { + schema, + type: mutationType, + }); + } + if (subscriptionType) { + addTypeCandidate(typeCandidates, 'Subscription', { + schema, + type: subscriptionType, + }); + } + + const typeMap = schema.getTypeMap(); + Object.keys(typeMap).forEach(typeName => { + const type: GraphQLNamedType = typeMap[typeName]; + if ( + isNamedType(type) && + getNamedType(type).name.slice(0, 2) !== '__' && + type !== queryType && + type !== mutationType && + type !== subscriptionType + ) { + addTypeCandidate(typeCandidates, type.name, { + schema, + type: type, + }); + } + }); } else if (typeof schema === 'string') { let parsedSchemaDocument = parse(schema); - try { - // TODO fix types https://github.com/apollographql/graphql-tools/issues/542 - const actualSchema = (buildASTSchema as any)( - parsedSchemaDocument, - backcompatOptions, - ); - if (actualSchema.getQueryType()) { - actualSchemas.push(actualSchema); + parsedSchemaDocument.definitions.forEach(def => { + const type = typeFromAST(def, createNamedStub); + if (type) { + addTypeCandidate(typeCandidates, type.name, { + type: type, + }); } - } catch (e) { - typeFragments.push(parsedSchemaDocument); - } - parsedSchemaDocument = extractExtensionDefinitions(parsedSchemaDocument); - if (parsedSchemaDocument.definitions.length > 0) { - extensions.push(parsedSchemaDocument); + }); + + const extensionsDocument = extractExtensionDefinitions( + parsedSchemaDocument, + ); + if (extensionsDocument.definitions.length > 0) { + extensions.push(extensionsDocument); } + } else if (Array.isArray(schema)) { + schema.forEach(type => { + addTypeCandidate(typeCandidates, type.name, { + type: type, + }); + }); + } else { + throw new Error(`Invalid schema passed`); } }); - actualSchemas.forEach(schema => { - typeRegistry.addSchema(schema); - const queryType = schema.getQueryType(); - const mutationType = schema.getMutationType(); - const subscriptionType = schema.getSubscriptionType(); - - const typeMap = schema.getTypeMap(); - Object.keys(typeMap).forEach(typeName => { - const type: GraphQLType = typeMap[typeName]; - if ( - isNamedType(type) && - getNamedType(type).name.slice(0, 2) !== '__' && - type !== queryType && - type !== mutationType && - type !== subscriptionType - ) { - const newType = recreateType(type, resolveType); - typeRegistry.addType(newType.name, newType, onTypeConflict); - } - }); + const mergeInfo = createMergeInfo(allSchemas, fragments); - Object.keys(queryType.getFields()).forEach(name => { - if (!fullResolvers.Query) { - fullResolvers.Query = {}; + if (!resolvers) { + resolvers = {}; + } else if (typeof resolvers === 'function') { + console.warn( + 'Passing functions as resolver parameter is deprecated. Use `info.mergeInfo` instead.', + ); + resolvers = resolvers(mergeInfo); + } else if (Array.isArray(resolvers)) { + resolvers = resolvers.reduce((left, right) => { + if (typeof right === 'function') { + console.warn( + 'Passing functions as resolver parameter is deprecated. Use `info.mergeInfo` instead.', + ); + right = right(mergeInfo); } - fullResolvers.Query[name] = createDelegatingResolver( - mergeInfo, - 'query', - name, - ); - }); + return mergeDeep(left, right); + }, {}); + } - queryFields = { - ...queryFields, - ...queryType.getFields(), - }; + let generatedResolvers = {}; - if (mutationType) { - if (!fullResolvers.Mutation) { - fullResolvers.Mutation = {}; + Object.keys(typeCandidates).forEach(typeName => { + const resultType: VisitTypeResult = visitType( + typeName, + typeCandidates[typeName], + ); + if (resultType === null) { + types[typeName] = null; + } else { + let type: GraphQLNamedType; + let typeResolvers: IResolvers; + if (isNamedType(resultType)) { + type = resultType; + } else if ((resultType).type) { + type = (resultType).type; + typeResolvers = (resultType).resolvers; + } else { + throw new Error('Invalid `visitType` result for type "${typeName}"'); } - Object.keys(mutationType.getFields()).forEach(name => { - fullResolvers.Mutation[name] = createDelegatingResolver( - mergeInfo, - 'mutation', - name, - ); - }); - - mutationFields = { - ...mutationFields, - ...mutationType.getFields(), - }; - } - - if (subscriptionType) { - if (!fullResolvers.Subscription) { - fullResolvers.Subscription = {}; + types[typeName] = recreateType(type, resolveType, false); + if (typeResolvers) { + generatedResolvers[typeName] = typeResolvers; } - Object.keys(subscriptionType.getFields()).forEach(name => { - fullResolvers.Subscription[name] = { - subscribe: createDelegatingResolver(mergeInfo, 'subscription', name), - }; - }); - - subscriptionFields = { - ...subscriptionFields, - ...subscriptionType.getFields(), - }; } }); - typeFragments.forEach(document => { - document.definitions.forEach(def => { - const type = typeFromAST(typeRegistry, def); - if (type) { - typeRegistry.addType(type.name, type, onTypeConflict); - } + let mergedSchema = new GraphQLSchema({ + query: types.Query as GraphQLObjectType, + mutation: types.Mutation as GraphQLObjectType, + subscription: types.Subscription as GraphQLObjectType, + types: Object.keys(types).map(key => types[key]), + }); + + extensions.forEach(extension => { + mergedSchema = (extendSchema as any)(mergedSchema, extension, { + commentDescriptions: true, }); }); - let passedResolvers = {}; - if (resolvers) { - if (typeof resolvers === 'function') { - passedResolvers = resolvers(mergeInfo); - } else if (Array.isArray(resolvers)) { - passedResolvers = resolvers - .map( - resolver => - typeof resolver === 'function' ? resolver(mergeInfo) : resolver, - ) - .reduce(mergeDeep, {}); - } else { - passedResolvers = { ...resolvers }; - } + if (!resolvers) { + resolvers = {}; + } else if (Array.isArray(resolvers)) { + resolvers = resolvers.reduce(mergeDeep, {}); } - Object.keys(passedResolvers).forEach(typeName => { - const type = passedResolvers[typeName]; + Object.keys(resolvers).forEach(typeName => { + const type = resolvers[typeName]; if (type instanceof GraphQLScalarType) { return; } Object.keys(type).forEach(fieldName => { const field = type[fieldName]; if (field.fragment) { - typeRegistry.addFragment(typeName, fieldName, field.fragment); + fragments.push({ + field: fieldName, + fragment: field.fragment, + }); } }); }); - fullResolvers = mergeDeep(fullResolvers, passedResolvers); - - const query = new GraphQLObjectType({ - name: 'Query', - fields: () => fieldMapToFieldConfigMap(queryFields, resolveType), - }); - - let mutation; - if (!isEmptyObject(mutationFields)) { - mutation = new GraphQLObjectType({ - name: 'Mutation', - fields: () => fieldMapToFieldConfigMap(mutationFields, resolveType), - }); - } - - let subscription; - if (!isEmptyObject(subscriptionFields)) { - subscription = new GraphQLObjectType({ - name: 'Subscription', - fields: () => fieldMapToFieldConfigMap(subscriptionFields, resolveType), - }); - } - - typeRegistry.addType('Query', query); - typeRegistry.addType('Mutation', mutation); - typeRegistry.addType('Subscription', subscription); - - let mergedSchema = new GraphQLSchema({ - query, - mutation, - subscription, - types: typeRegistry.getAllTypes(), + addResolveFunctionsToSchema({ + schema: mergedSchema, + resolvers: mergeDeep(generatedResolvers, resolvers), }); - extensions.forEach(extension => { - // TODO fix types https://github.com/apollographql/graphql-tools/issues/542 - mergedSchema = (extendSchema as any)( - mergedSchema, - extension, - backcompatOptions, - ); - }); - - addResolveFunctionsToSchema({ schema: mergedSchema, resolvers: fullResolvers }); - forEachField(mergedSchema, field => { if (field.resolve) { const fieldResolver = field.resolve; @@ -256,19 +288,25 @@ export default function mergeSchemas({ return fieldResolver(parent, args, context, newInfo); }; } + if (field.subscribe) { + const fieldResolver = field.subscribe; + field.subscribe = (parent, args, context, info) => { + const newInfo = { ...info, mergeInfo }; + return fieldResolver(parent, args, context, newInfo); + }; + } }); return mergedSchema; } -function defaultOnTypeConflict( - left: GraphQLNamedType, - right: GraphQLNamedType, -): GraphQLNamedType { - return left; -} - -function createMergeInfo(typeRegistry: TypeRegistry): MergeInfo { +function createMergeInfo( + allSchemas: Array, + fragments: Array<{ + field: string; + fragment: string; + }>, +): MergeInfo { return { delegate( operation: 'query' | 'mutation' | 'subscription', @@ -276,34 +314,83 @@ function createMergeInfo(typeRegistry: TypeRegistry): MergeInfo { args: { [key: string]: any }, context: { [key: string]: any }, info: GraphQLResolveInfo, - ): any { - const schema = typeRegistry.getSchemaByField(operation, fieldName); - if (!schema) { - throw new Error( - `Cannot find subschema for root field ${operation}.${fieldName}`, - ); - } - const fragmentReplacements = typeRegistry.fragmentReplacements; - return delegateToSchema( + transforms?: Array, + ) { + console.warn( + '`mergeInfo.delegate` is deprecated. ' + + 'Use `mergeInfo.delegateToSchema and pass explicit schema instances.', + ); + const schema = guessSchemaByRootField(allSchemas, operation, fieldName); + const expandTransforms = new ExpandAbstractTypes(info.schema, schema); + const fragmentTransform = new ReplaceFieldWithFragment(schema, fragments); + return delegateToSchema({ schema, - fragmentReplacements, operation, fieldName, args, context, info, - ); + transforms: [ + ...(transforms || []), + expandTransforms, + fragmentTransform, + ], + }); + }, + + delegateToSchema(options: IDelegateToSchemaOptions) { + return delegateToSchema({ + ...options, + transforms: [ + ...(options.transforms || []), + new ExpandAbstractTypes(options.info.schema, options.schema), + new ReplaceFieldWithFragment(options.schema, fragments), + ], + }); }, }; } +function guessSchemaByRootField( + schemas: Array, + operation: 'query' | 'mutation' | 'subscription', + fieldName: string, +): GraphQLSchema { + for (const schema of schemas) { + let rootObject: GraphQLObjectType; + if (operation === 'subscription') { + rootObject = schema.getSubscriptionType(); + } else if (operation === 'mutation') { + rootObject = schema.getMutationType(); + } else { + rootObject = schema.getQueryType(); + } + if (rootObject) { + const fields = rootObject.getFields(); + if (fields[fieldName]) { + return schema; + } + } + } + throw new Error( + `Could not find subschema with field \`{operation}.{fieldName}\``, + ); +} + function createDelegatingResolver( - mergeInfo: MergeInfo, + schema: GraphQLSchema, operation: 'query' | 'mutation' | 'subscription', fieldName: string, ): IFieldResolver { return (root, args, context, info) => { - return mergeInfo.delegate(operation, fieldName, args, context, info); + return info.mergeInfo.delegateToSchema({ + schema, + operation, + fieldName, + args, + context, + info, + }); }; } @@ -330,3 +417,99 @@ function forEachField(schema: GraphQLSchema, fn: FieldIteratorFn): void { } }); } + +function addTypeCandidate( + typeCandidates: { [name: string]: Array }, + name: string, + typeCandidate: MergeTypeCandidate, +) { + if (!typeCandidates[name]) { + typeCandidates[name] = []; + } + typeCandidates[name].push(typeCandidate); +} + +function createVisitTypeFromOnTypeConflict( + onTypeConflict: OnTypeConflict, +): VisitType { + return (name, candidates) => + defaultVisitType(name, candidates, cands => + cands.reduce((prev, next) => { + const type = onTypeConflict(prev.type, next.type, { + left: { + schema: prev.schema, + }, + right: { + schema: prev.schema, + }, + }); + if (prev.type === type) { + return prev; + } else if (next.type === type) { + return next; + } else { + return { + schemaName: 'unknown', + type, + }; + } + }), + ); +} + +const defaultVisitType = ( + name: string, + candidates: Array, + candidateSelector?: ( + candidates: Array, + ) => MergeTypeCandidate, +) => { + if (!candidateSelector) { + candidateSelector = cands => cands[cands.length - 1]; + } + const resolveType = createResolveType((_, type) => type); + if (name === 'Query' || name === 'Mutation' || name === 'Subscription') { + let fields = {}; + let operationName: 'query' | 'mutation' | 'subscription'; + switch (name) { + case 'Query': + operationName = 'query'; + break; + case 'Mutation': + operationName = 'mutation'; + break; + case 'Subscription': + operationName = 'subscription'; + break; + default: + break; + } + const resolvers = {}; + const resolverKey = + operationName === 'subscription' ? 'subscribe' : 'resolve'; + candidates.forEach(({ type: candidateType, schema }) => { + const candidateFields = (candidateType as GraphQLObjectType).getFields(); + fields = { ...fields, ...candidateFields }; + Object.keys(candidateFields).forEach(fieldName => { + resolvers[fieldName] = { + [resolverKey]: createDelegatingResolver( + schema, + operationName, + fieldName, + ), + }; + }); + }); + const type = new GraphQLObjectType({ + name, + fields: fieldMapToFieldConfigMap(fields, resolveType, false), + }); + return { + type, + resolvers, + }; + } else { + const candidate = candidateSelector(candidates); + return candidate.type; + } +}; diff --git a/src/stitching/resolvers.ts b/src/stitching/resolvers.ts new file mode 100644 index 00000000000..9a41972c468 --- /dev/null +++ b/src/stitching/resolvers.ts @@ -0,0 +1,102 @@ +import { + GraphQLSchema, + GraphQLFieldResolver, + GraphQLObjectType, +} from 'graphql'; +import { IResolvers, Operation } from '../Interfaces'; +import delegateToSchema from './delegateToSchema'; +import { Transform } from '../transforms/index'; + +export type Mapping = { + [typeName: string]: { + [fieldName: string]: { + name: string; + operation: Operation; + }; + }; +}; + +export function generateProxyingResolvers( + targetSchema: GraphQLSchema, + transforms: Array, + mapping: Mapping, +): IResolvers { + const result = {}; + Object.keys(mapping).forEach(name => { + result[name] = {}; + const innerMapping = mapping[name]; + Object.keys(innerMapping).forEach(from => { + const to = innerMapping[from]; + const resolverType = + to.operation === 'subscription' ? 'subscribe' : 'resolve'; + result[name][from] = { + [resolverType]: createProxyingResolver( + targetSchema, + to.operation, + to.name, + transforms, + ), + }; + }); + }); + return result; +} + +export function generateSimpleMapping(targetSchema: GraphQLSchema): Mapping { + const query = targetSchema.getQueryType(); + const mutation = targetSchema.getMutationType(); + const subscription = targetSchema.getSubscriptionType(); + + const result: Mapping = {}; + if (query) { + result[query.name] = generateMappingFromObjectType(query, 'query'); + } + if (mutation) { + result[mutation.name] = generateMappingFromObjectType(mutation, 'mutation'); + } + if (subscription) { + result[subscription.name] = generateMappingFromObjectType( + subscription, + 'subscription', + ); + } + + return result; +} + +export function generateMappingFromObjectType( + type: GraphQLObjectType, + operation: Operation, +): { + [fieldName: string]: { + name: string; + operation: Operation; + }; +} { + const result = {}; + const fields = type.getFields(); + Object.keys(fields).forEach(fieldName => { + result[fieldName] = { + name: fieldName, + operation, + }; + }); + return result; +} + +function createProxyingResolver( + schema: GraphQLSchema, + operation: Operation, + fieldName: string, + transforms: Array, +): GraphQLFieldResolver { + return (parent, args, context, info) => delegateToSchema({ + schema, + operation, + fieldName, + args: {}, + context, + info, + transforms, + }); +} diff --git a/src/stitching/schemaRecreation.ts b/src/stitching/schemaRecreation.ts index f08f48a41c0..3ea08f8f665 100644 --- a/src/stitching/schemaRecreation.ts +++ b/src/stitching/schemaRecreation.ts @@ -1,28 +1,23 @@ import { GraphQLArgument, GraphQLArgumentConfig, - GraphQLBoolean, GraphQLEnumType, GraphQLField, GraphQLFieldConfig, GraphQLFieldConfigArgumentMap, GraphQLFieldConfigMap, GraphQLFieldMap, - GraphQLFloat, - GraphQLID, GraphQLInputField, GraphQLInputFieldConfig, GraphQLInputFieldConfigMap, GraphQLInputFieldMap, GraphQLInputObjectType, - GraphQLInt, GraphQLInterfaceType, GraphQLList, GraphQLNamedType, GraphQLNonNull, GraphQLObjectType, GraphQLScalarType, - GraphQLString, GraphQLType, GraphQLUnionType, Kind, @@ -30,6 +25,7 @@ import { getNamedType, isNamedType, } from 'graphql'; +import isSpecifiedScalarType from '../isSpecifiedScalarType'; import { ResolveType } from '../Interfaces'; import resolveFromParentTypename from './resolveFromParentTypename'; import defaultMergedResolver from './defaultMergedResolver'; @@ -37,6 +33,7 @@ import defaultMergedResolver from './defaultMergedResolver'; export function recreateType( type: GraphQLNamedType, resolveType: ResolveType, + keepResolvers: boolean, ): GraphQLNamedType { if (type instanceof GraphQLObjectType) { const fields = type.getFields(); @@ -46,7 +43,9 @@ export function recreateType( name: type.name, description: type.description, astNode: type.astNode, - fields: () => fieldMapToFieldConfigMap(fields, resolveType), + isTypeOf: keepResolvers ? type.isTypeOf : undefined, + fields: () => + fieldMapToFieldConfigMap(fields, resolveType, keepResolvers), interfaces: () => interfaces.map(iface => resolveType(iface)), }); } else if (type instanceof GraphQLInterfaceType) { @@ -56,9 +55,12 @@ export function recreateType( name: type.name, description: type.description, astNode: type.astNode, - fields: () => fieldMapToFieldConfigMap(fields, resolveType), - resolveType: (parent, context, info) => - resolveFromParentTypename(parent, info.schema), + fields: () => + fieldMapToFieldConfigMap(fields, resolveType, keepResolvers), + resolveType: keepResolvers + ? type.resolveType + : (parent, context, info) => + resolveFromParentTypename(parent, info.schema), }); } else if (type instanceof GraphQLUnionType) { return new GraphQLUnionType({ @@ -67,8 +69,10 @@ export function recreateType( astNode: type.astNode, types: () => type.getTypes().map(unionMember => resolveType(unionMember)), - resolveType: (parent, context, info) => - resolveFromParentTypename(parent, info.schema), + resolveType: keepResolvers + ? type.resolveType + : (parent, context, info) => + resolveFromParentTypename(parent, info.schema), }); } else if (type instanceof GraphQLInputObjectType) { return new GraphQLInputObjectType({ @@ -92,13 +96,7 @@ export function recreateType( values: newValues, }); } else if (type instanceof GraphQLScalarType) { - if ( - type === GraphQLID || - type === GraphQLString || - type === GraphQLFloat || - type === GraphQLBoolean || - type === GraphQLInt - ) { + if (isSpecifiedScalarType(type)) { return type; } else { return new GraphQLScalarType({ @@ -150,13 +148,18 @@ function parseLiteral(ast: ValueNode): any { export function fieldMapToFieldConfigMap( fields: GraphQLFieldMap, resolveType: ResolveType, + keepResolvers: boolean, ): GraphQLFieldConfigMap { const result: GraphQLFieldConfigMap = {}; Object.keys(fields).forEach(name => { const field = fields[name]; const type = resolveType(field.type); if (type !== null) { - result[name] = fieldToFieldConfig(fields[name], resolveType); + result[name] = fieldToFieldConfig( + fields[name], + resolveType, + keepResolvers, + ); } }); return result; @@ -189,47 +192,56 @@ export function createResolveType( return resolveType; } -function fieldToFieldConfig( +export function fieldToFieldConfig( field: GraphQLField, resolveType: ResolveType, + keepResolvers: boolean, ): GraphQLFieldConfig { return { type: resolveType(field.type), args: argsToFieldConfigArgumentMap(field.args, resolveType), - resolve: defaultMergedResolver, + resolve: keepResolvers ? field.resolve : defaultMergedResolver, + subscribe: keepResolvers ? field.subscribe : null, description: field.description, deprecationReason: field.deprecationReason, astNode: field.astNode, }; } -function argsToFieldConfigArgumentMap( +export function argsToFieldConfigArgumentMap( args: Array, resolveType: ResolveType, ): GraphQLFieldConfigArgumentMap { const result: GraphQLFieldConfigArgumentMap = {}; args.forEach(arg => { - const [name, def] = argumentToArgumentConfig(arg, resolveType); - result[name] = def; + const newArg = argumentToArgumentConfig(arg, resolveType); + if (newArg) { + result[newArg[0]] = newArg[1]; + } }); return result; } -function argumentToArgumentConfig( +export function argumentToArgumentConfig( argument: GraphQLArgument, resolveType: ResolveType, -): [string, GraphQLArgumentConfig] { - return [ - argument.name, - { - type: resolveType(argument.type), - defaultValue: argument.defaultValue, - description: argument.description, - }, - ]; +): [string, GraphQLArgumentConfig] | null { + const type = resolveType(argument.type); + if (type === null) { + return null; + } else { + return [ + argument.name, + { + type: type, + defaultValue: argument.defaultValue, + description: argument.description, + }, + ]; + } } -function inputFieldMapToFieldConfigMap( +export function inputFieldMapToFieldConfigMap( fields: GraphQLInputFieldMap, resolveType: ResolveType, ): GraphQLInputFieldConfigMap { @@ -244,7 +256,7 @@ function inputFieldMapToFieldConfigMap( return result; } -function inputFieldToFieldConfig( +export function inputFieldToFieldConfig( field: GraphQLInputField, resolveType: ResolveType, ): GraphQLInputFieldConfig { diff --git a/src/stitching/typeFromAST.ts b/src/stitching/typeFromAST.ts index 63458ab7a2d..ebeb30bea9d 100644 --- a/src/stitching/typeFromAST.ts +++ b/src/stitching/typeFromAST.ts @@ -23,60 +23,60 @@ import { UnionTypeDefinitionNode, valueFromAST, } from 'graphql'; -// -// TODO put back import once PR is merged -// https://github.com/graphql/graphql-js/pull/1165 -// import { getDescription } from 'graphql/utilities/buildASTSchema'; +import resolveFromParentType from './resolveFromParentTypename'; const backcompatOptions = { commentDescriptions: true }; -import resolveFromParentType from './resolveFromParentTypename'; -import TypeRegistry from './TypeRegistry'; +export type GetType = ( + name: string, + // this is a hack + type: 'object' | 'interface' | 'input', +) => GraphQLObjectType | GraphQLInputObjectType | GraphQLInterfaceType; export default function typeFromAST( - typeRegistry: TypeRegistry, node: DefinitionNode, + getType: GetType, ): GraphQLNamedType | null { switch (node.kind) { case Kind.OBJECT_TYPE_DEFINITION: - return makeObjectType(typeRegistry, node); + return makeObjectType(node, getType); case Kind.INTERFACE_TYPE_DEFINITION: - return makeInterfaceType(typeRegistry, node); + return makeInterfaceType(node, getType); case Kind.ENUM_TYPE_DEFINITION: - return makeEnumType(typeRegistry, node); + return makeEnumType(node, getType); case Kind.UNION_TYPE_DEFINITION: - return makeUnionType(typeRegistry, node); + return makeUnionType(node, getType); case Kind.SCALAR_TYPE_DEFINITION: - return makeScalarType(typeRegistry, node); + return makeScalarType(node, getType); case Kind.INPUT_OBJECT_TYPE_DEFINITION: - return makeInputObjectType(typeRegistry, node); + return makeInputObjectType(node, getType); default: return null; } } function makeObjectType( - typeRegistry: TypeRegistry, node: ObjectTypeDefinitionNode, + getType: GetType, ): GraphQLObjectType { return new GraphQLObjectType({ name: node.name.value, - fields: () => makeFields(typeRegistry, node.fields), + fields: () => makeFields(node.fields, getType), interfaces: () => node.interfaces.map( - iface => typeRegistry.getType(iface.name.value) as GraphQLInterfaceType, + iface => getType(iface.name.value, 'interface') as GraphQLInterfaceType, ), description: getDescription(node, backcompatOptions), }); } function makeInterfaceType( - typeRegistry: TypeRegistry, node: InterfaceTypeDefinitionNode, + getType: GetType, ): GraphQLInterfaceType { return new GraphQLInterfaceType({ name: node.name.value, - fields: () => makeFields(typeRegistry, node.fields), + fields: () => makeFields(node.fields, getType), description: getDescription(node, backcompatOptions), resolveType: (parent, context, info) => resolveFromParentType(parent, info.schema), @@ -84,8 +84,8 @@ function makeInterfaceType( } function makeEnumType( - typeRegistry: TypeRegistry, node: EnumTypeDefinitionNode, + getType: GetType, ): GraphQLEnumType { const values = {}; node.values.forEach(value => { @@ -101,14 +101,14 @@ function makeEnumType( } function makeUnionType( - typeRegistry: TypeRegistry, node: UnionTypeDefinitionNode, + getType: GetType, ): GraphQLUnionType { return new GraphQLUnionType({ name: node.name.value, types: () => node.types.map( - type => resolveType(typeRegistry, type) as GraphQLObjectType, + type => resolveType(type, getType, 'object') as GraphQLObjectType, ), description: getDescription(node, backcompatOptions), resolveType: (parent, context, info) => @@ -117,8 +117,8 @@ function makeUnionType( } function makeScalarType( - typeRegistry: TypeRegistry, node: ScalarTypeDefinitionNode, + getType: GetType, ): GraphQLScalarType { return new GraphQLScalarType({ name: node.name.value, @@ -134,38 +134,32 @@ function makeScalarType( } function makeInputObjectType( - typeRegistry: TypeRegistry, node: InputObjectTypeDefinitionNode, + getType: GetType, ): GraphQLInputObjectType { return new GraphQLInputObjectType({ name: node.name.value, - fields: () => makeValues(typeRegistry, node.fields), + fields: () => makeValues(node.fields, getType), description: getDescription(node, backcompatOptions), }); } -function makeFields( - typeRegistry: TypeRegistry, - nodes: Array, -) { +function makeFields(nodes: Array, getType: GetType) { const result = {}; nodes.forEach(node => { result[node.name.value] = { - type: resolveType(typeRegistry, node.type), - args: makeValues(typeRegistry, node.arguments), + type: resolveType(node.type, getType, 'object'), + args: makeValues(node.arguments, getType), description: getDescription(node, backcompatOptions), }; }); return result; } -function makeValues( - typeRegistry: TypeRegistry, - nodes: Array, -) { +function makeValues(nodes: Array, getType: GetType) { const result = {}; nodes.forEach(node => { - const type = resolveType(typeRegistry, node.type) as GraphQLInputType; + const type = resolveType(node.type, getType, 'input') as GraphQLInputType; result[node.name.value] = { type, defaultValue: valueFromAST(node.defaultValue, type), @@ -175,14 +169,18 @@ function makeValues( return result; } -function resolveType(typeRegistry: TypeRegistry, node: TypeNode): GraphQLType { +function resolveType( + node: TypeNode, + getType: GetType, + type: 'object' | 'interface' | 'input', +): GraphQLType { switch (node.kind) { case Kind.LIST_TYPE: - return new GraphQLList(resolveType(typeRegistry, node.type)); + return new GraphQLList(resolveType(node.type, getType, type)); case Kind.NON_NULL_TYPE: - return new GraphQLNonNull(resolveType(typeRegistry, node.type)); + return new GraphQLNonNull(resolveType(node.type, getType, type)); default: - return typeRegistry.getType(node.name.value); + return getType(node.name.value, type); } } diff --git a/src/test/testAlternateMergeSchemas.ts b/src/test/testAlternateMergeSchemas.ts new file mode 100644 index 00000000000..7e26181c656 --- /dev/null +++ b/src/test/testAlternateMergeSchemas.ts @@ -0,0 +1,237 @@ +/* tslint:disable:no-unused-expression */ + +import { expect } from 'chai'; +import { graphql, GraphQLSchema } from 'graphql'; +import mergeSchemas from '../stitching/mergeSchemas'; +import { + transformSchema, + FilterRootFields, + RenameTypes, + RenameRootFields, +} from '../transforms'; +import { propertySchema, bookingSchema } from './testingSchemas'; + +let linkSchema = ` + """ + A new type linking the Property type. + """ + type LinkType { + test: String + """ + The property. + """ + property: Properties_Property + } + + interface Node { + id: ID! + } + + extend type Bookings_Booking implements Node { + """ + The property of the booking. + """ + property: Properties_Property + } + + extend type Properties_Property implements Node { + """ + A list of bookings. + """ + bookings( + """ + The maximum number of bookings to retrieve. + """ + limit: Int + ): [Bookings_Booking] + } + + extend type Query { + linkTest: LinkType + node(id: ID!): Node + nodes: [Node] + } + + extend type Bookings_Customer implements Node +`; + +describe('merge schemas through transforms', () => { + let mergedSchema: GraphQLSchema; + + before(async () => { + // namespace and strip schemas + const transformedPropertySchema = transformSchema(propertySchema, [ + new FilterRootFields( + (operation: string, rootField: string) => + 'Query.properties' === `${operation}.${rootField}`, + ), + new RenameTypes((name: string) => `Properties_${name}`), + new RenameRootFields((name: string) => `Properties_${name}`), + ]); + const transformedBookingSchema = transformSchema(bookingSchema, [ + new FilterRootFields( + (operation: string, rootField: string) => + 'Query.bookings' === `${operation}.${rootField}`, + ), + new RenameTypes((name: string) => `Bookings_${name}`), + new RenameRootFields( + (operation: string, name: string) => `Bookings_${name}`, + ), + ]); + + mergedSchema = mergeSchemas({ + schemas: [ + transformedPropertySchema, + transformedBookingSchema, + linkSchema, + ], + resolvers: { + Query: { + // delegating directly, no subschemas or mergeInfo + node(parent, args, context, info) { + if (args.id.startsWith('p')) { + return info.mergeInfo.delegateToSchema({ + schema: propertySchema, + operation: 'query', + fieldName: 'propertyById', + args, + context, + info, + transforms: transformedPropertySchema.transforms, + }); + } else if (args.id.startsWith('b')) { + return info.mergeInfo.delegateToSchema({ + schema: bookingSchema, + operation: 'query', + fieldName: 'bookingById', + args, + context, + info, + transforms: transformedBookingSchema.transforms, + }); + } else if (args.id.startsWith('c')) { + return info.mergeInfo.delegateToSchema({ + schema: bookingSchema, + operation: 'query', + fieldName: 'customerById', + args, + context, + info, + transforms: transformedBookingSchema.transforms, + }); + } else { + throw new Error('invalid id'); + } + }, + }, + Properties_Property: { + bookings: { + fragment: 'fragment PropertyFragment on Property { id }', + resolve(parent, args, context, info) { + return info.mergeInfo.delegateToSchema({ + schema: bookingSchema, + operation: 'query', + fieldName: 'bookingsByPropertyId', + args: { + propertyId: parent.id, + limit: args.limit ? args.limit : null, + }, + context, + info, + transforms: transformedBookingSchema.transforms, + }); + }, + }, + }, + Bookings_Booking: { + property: { + fragment: 'fragment BookingFragment on Booking { propertyId }', + resolve(parent, args, context, info) { + return info.mergeInfo.delegateToSchema({ + schema: propertySchema, + operation: 'query', + fieldName: 'propertyById', + args: { + id: parent.propertyId, + }, + context, + info, + transforms: transformedPropertySchema.transforms, + }); + }, + }, + }, + }, + }); + }); + + // FIXME fragemnt replacements + it('node should work', async () => { + const result = await graphql( + mergedSchema, + ` + query($pid: ID!, $bid: ID!) { + property: node(id: $pid) { + __typename + ... on Properties_Property { + name + bookings { + startTime + endTime + } + } + } + booking: node(id: $bid) { + __typename + ... on Bookings_Booking { + startTime + endTime + property { + id + name + } + } + } + } + `, + {}, + {}, + { + pid: 'p1', + bid: 'b1', + }, + ); + + expect(result).to.deep.equal({ + data: { + booking: { + __typename: 'Bookings_Booking', + endTime: '2016-06-03', + property: { + id: 'p1', + name: 'Super great hotel', + }, + startTime: '2016-05-04', + }, + property: { + __typename: 'Properties_Property', + bookings: [ + { + endTime: '2016-06-03', + startTime: '2016-05-04', + }, + { + endTime: '2016-07-03', + startTime: '2016-06-04', + }, + { + endTime: '2016-09-03', + startTime: '2016-08-04', + }, + ], + name: 'Super great hotel', + }, + }, + }); + }); +}); diff --git a/src/test/testMergeSchemas.ts b/src/test/testMergeSchemas.ts index 9740072e715..4ae2d4171d2 100644 --- a/src/test/testMergeSchemas.ts +++ b/src/test/testMergeSchemas.ts @@ -4,8 +4,8 @@ import { expect } from 'chai'; import { graphql, GraphQLSchema, - GraphQLScalarType, GraphQLObjectType, + GraphQLScalarType, subscribe, parse, ExecutionResult, @@ -135,6 +135,14 @@ let linkSchema = ` id: ID! } + extend type Car implements Node { + fakeFieldToSatisfyOldGraphQLRemoveAfter12: String + } + + extend type Bike implements Node { + fakeFieldToSatisfyOldGraphQLRemoveAfter12: String + } + extend type Booking implements Node { """ The property of the booking. @@ -174,7 +182,6 @@ const loneExtend = ` } `; - let interfaceExtensionTest = ` # No-op for older versions since this feature does not yet exist extend type DownloadableProduct { @@ -265,6 +272,14 @@ if (process.env.GRAPHQL_VERSION === '^0.11') { id: ID! } + extend type Car implements Node { + fakeFieldToSatisfyOldGraphQL: String + } + + extend type Bike implements Node { + fakeFieldToSatisfyOldGraphQL: String + } + extend type Booking implements Node { # The property of the booking. property: Property @@ -291,6 +306,36 @@ if (process.env.GRAPHQL_VERSION === '^0.11') { `; } +// Miscellaneous typeDefs that exercise uncommon branches for the sake of +// code coverage. +const codeCoverageTypeDefs = ` + interface SyntaxNode { + type: String + } + + type Statement implements SyntaxNode { + type: String + } + + type Expression implements SyntaxNode { + type: String + } + + union ASTNode = Statement | Expression + + enum Direction { + NORTH + SOUTH + EAST + WEST + } + + input WalkingPlan { + steps: Int + direction: Direction + } +`; + testCombinations.forEach(async combination => { describe('merging ' + combination.name, () => { let mergedSchema: GraphQLSchema, @@ -314,12 +359,15 @@ testCombinations.forEach(async combination => { linkSchema, loneExtend, localSubscriptionSchema, + codeCoverageTypeDefs, ], resolvers: { Property: { bookings: { fragment: '... on Property { id }', resolve(parent, args, context, info) { + // Use the old mergeInfo.delegate API just this once, to make + // sure it continues to work. return info.mergeInfo.delegate( 'query', 'bookingsByPropertyId', @@ -337,60 +385,64 @@ testCombinations.forEach(async combination => { property: { fragment: 'fragment BookingFragment on Booking { propertyId }', resolve(parent, args, context, info) { - return info.mergeInfo.delegate( - 'query', - 'propertyById', - { + return info.mergeInfo.delegateToSchema({ + schema: propertySchema, + operation: 'query', + fieldName: 'propertyById', + args: { id: parent.propertyId, }, context, info, - ); + }); }, }, }, DownloadableProduct: { filesize() { return 1024; - } + }, }, LinkType: { property: { resolve(parent, args, context, info) { - return info.mergeInfo.delegate( - 'query', - 'propertyById', - { + return info.mergeInfo.delegateToSchema({ + schema: propertySchema, + operation: 'query', + fieldName: 'propertyById', + args: { id: 'p1', }, context, info, - ); + }); }, }, }, Query: { delegateInterfaceTest(parent, args, context, info) { - return info.mergeInfo.delegate( - 'query', - 'interfaceTest', - { + return info.mergeInfo.delegateToSchema({ + schema: propertySchema, + operation: 'query', + fieldName: 'interfaceTest', + args: { kind: 'ONE', }, context, info, - ); + }); }, delegateArgumentTest(parent, args, context, info) { - return info.mergeInfo.delegate( - 'query', - 'propertyById', - { + return info.mergeInfo.delegateToSchema({ + schema: propertySchema, + operation: 'query', + fieldName: 'propertyById', + args: { id: 'p1', }, context, info, - ); + }); }, linkTest() { return { @@ -402,49 +454,52 @@ testCombinations.forEach(async combination => { fragment: '... on Node { id }', resolve(parent, args, context, info) { if (args.id.startsWith('p')) { - return info.mergeInfo.delegate( - 'query', - 'propertyById', + return info.mergeInfo.delegateToSchema({ + schema: propertySchema, + operation: 'query', + fieldName: 'propertyById', args, context, info, - ); + }); } else if (args.id.startsWith('b')) { - return info.mergeInfo.delegate( - 'query', - 'bookingById', + return info.mergeInfo.delegateToSchema({ + schema: bookingSchema, + operation: 'query', + fieldName: 'bookingById', args, context, info, - ); + }); } else if (args.id.startsWith('c')) { - return info.mergeInfo.delegate( - 'query', - 'customerById', + return info.mergeInfo.delegateToSchema({ + schema: bookingSchema, + operation: 'query', + fieldName: 'customerById', args, context, info, - ); + }); } else { throw new Error('invalid id'); } }, }, async nodes(parent, args, context, info) { - const bookings = await info.mergeInfo.delegate( - 'query', - 'bookings', - {}, + const bookings = await info.mergeInfo.delegateToSchema({ + schema: bookingSchema, + operation: 'query', + fieldName: 'bookings', context, info, - ); - const properties = await info.mergeInfo.delegate( - 'query', - 'properties', - {}, + }); + const properties = await info.mergeInfo.delegateToSchema({ + schema: propertySchema, + operation: 'query', + fieldName: 'properties', context, info, - ); + }); return [...bookings, ...properties]; }, }, @@ -910,6 +965,130 @@ bookingById(id: "b1") { }); }); + it('unions implementing interface', async () => { + const query = ` + query { + test1: unionTest(output: "Interface") { + ... on TestInterface { + kind + testString + } + ... on TestImpl1 { + foo + } + ... on UnionImpl { + someField + } + } + + test2: unionTest(output: "OtherStuff") { + ... on TestInterface { + kind + testString + } + ... on TestImpl1 { + foo + } + ... on UnionImpl { + someField + } + } + } + `; + const mergedResult = await graphql(mergedSchema, query); + expect(mergedResult).to.deep.equal({ + data: { + test1: { + kind: 'ONE', + testString: 'test', + foo: 'foo', + }, + test2: { + someField: 'Bar', + }, + }, + }); + }); + + it('interfaces spread from top level functions', async () => { + const mergedResult = await graphql( + mergedSchema, + ` + query { + first: customerById(id: "c1") { + name + ... on Node { + id + } + } + + second: customerById(id: "c1") { + ...NodeFragment + } + } + + fragment NodeFragment on Node { + id + ... on Customer { + name + } + } + `, + ); + + expect(mergedResult).to.deep.equal({ + data: { + first: { + id: 'c1', + name: 'Exampler Customer', + }, + second: { + id: 'c1', + name: 'Exampler Customer', + }, + }, + }); + }); + + it('unions implementing an interface', async () => { + const mergedResult = await graphql( + mergedSchema, + ` + query { + customerById(id: "c1") { + ... on Person { + name + } + vehicle { + ... on Node { + __typename + id + } + } + secondVehicle: vehicle { + ...NodeFragment + } + } + } + + fragment NodeFragment on Node { + id + __typename + } + `, + ); + + expect(mergedResult).to.deep.equal({ + data: { + customerById: { + name: 'Exampler Customer', + vehicle: { __typename: 'Bike', id: 'v1' }, + secondVehicle: { __typename: 'Bike', id: 'v1' }, + }, + }, + }); + }); + it('input objects with default', async () => { const mergedResult = await graphql( mergedSchema, @@ -1017,7 +1196,7 @@ bookingById(id: "b1") { }); it('should merge resolvers when passed an array of resolver objects', async () => { - const Scalars = () => ({ + const Scalars = { TestScalar: new GraphQLScalarType({ name: 'TestScalar', description: undefined, @@ -1025,53 +1204,55 @@ bookingById(id: "b1") { parseValue: value => value, parseLiteral: () => null, }), - }); - const Enums = () => ({ + }; + const Enums = { NumericEnum: { TEST: 1, }, Color: { RED: '#EA3232', }, - }); + }; const PropertyResolvers: IResolvers = { Property: { bookings: { fragment: 'fragment PropertyFragment on Property { id }', resolve(parent, args, context, info) { - return info.mergeInfo.delegate( - 'query', - 'bookingsByPropertyId', - { + return info.mergeInfo.delegateToSchema({ + schema: bookingSchema, + operation: 'query', + fieldName: 'bookingsByPropertyId', + args: { propertyId: parent.id, limit: args.limit ? args.limit : null, }, context, info, - ); + }); }, }, }, }; - const LinkResolvers: (info: any) => IResolvers = info => ({ + const LinkResolvers: IResolvers = { Booking: { property: { fragment: 'fragment BookingFragment on Booking { propertyId }', - resolve(parent, args, context) { - return info.mergeInfo.delegate( - 'query', - 'propertyById', - { + resolve(parent, args, context, info) { + return info.mergeInfo.delegateToSchema({ + schema: propertySchema, + operation: 'query', + fieldName: 'propertyById', + args: { id: parent.propertyId, }, context, info, - ); + }); }, }, }, - }); - const Query1 = () => ({ + }; + const Query1 = { Query: { color() { return '#EA3232'; @@ -1080,30 +1261,32 @@ bookingById(id: "b1") { return 1; }, }, - }); - const Query2: (info: any) => IResolvers = () => ({ + }; + const Query2: IResolvers = { Query: { delegateInterfaceTest(parent, args, context, info) { - return info.mergeInfo.delegate( - 'query', - 'interfaceTest', - { + return info.mergeInfo.delegateToSchema({ + schema: propertySchema, + operation: 'query', + fieldName: 'interfaceTest', + args: { kind: 'ONE', }, context, info, - ); + }); }, delegateArgumentTest(parent, args, context, info) { - return info.mergeInfo.delegate( - 'query', - 'propertyById', - { + return info.mergeInfo.delegateToSchema({ + schema: propertySchema, + operation: 'query', + fieldName: 'propertyById', + args: { id: 'p1', }, context, info, - ); + }); }, linkTest() { return { @@ -1115,65 +1298,68 @@ bookingById(id: "b1") { fragment: 'fragment NodeFragment on Node { id }', resolve(parent, args, context, info) { if (args.id.startsWith('p')) { - return info.mergeInfo.delegate( - 'query', - 'propertyById', + return info.mergeInfo.delegateToSchema({ + schema: propertySchema, + operation: 'query', + fieldName: 'propertyById', args, context, info, - ); + }); } else if (args.id.startsWith('b')) { - return info.mergeInfo.delegate( - 'query', - 'bookingById', + return info.mergeInfo.delegateToSchema({ + schema: bookingSchema, + operation: 'query', + fieldName: 'bookingById', args, context, info, - ); + }); } else if (args.id.startsWith('c')) { - return info.mergeInfo.delegate( - 'query', - 'customerById', + return info.mergeInfo.delegateToSchema({ + schema: bookingSchema, + operation: 'query', + fieldName: 'customerById', args, context, info, - ); + }); } else { throw new Error('invalid id'); } }, }, }, - }); + }; - const AsyncQuery: (info: any) => IResolvers = info => ({ + const AsyncQuery: IResolvers = { Query: { - async nodes(parent, args, context) { - const bookings = await info.mergeInfo.delegate( - 'query', - 'bookings', - {}, + async nodes(parent, args, context, info) { + const bookings = await info.mergeInfo.delegateToSchema({ + schema: bookingSchema, + operation: 'query', + fieldName: 'bookings', context, info, - ); - const properties = await info.mergeInfo.delegate( - 'query', - 'properties', - {}, + }); + const properties = await info.mergeInfo.delegateToSchema({ + schema: propertySchema, + operation: 'query', + fieldName: 'properties', context, info, - ); + }); return [...bookings, ...properties]; }, }, - }); + }; const schema = mergeSchemas({ schemas: [ propertySchema, bookingSchema, productSchema, scalarTest, - enumTest, + enumSchema, linkSchema, loneExtend, localSubscriptionSchema, @@ -1469,6 +1655,63 @@ fragment BookingFragment on Booking { }); }); + it('containing fragment on outer type', async () => { + const mergedResult = await graphql( + mergedSchema, + ` + query { + propertyById(id: "p2") { + id + ... on Property { + name + ...BookingFragment1 + } + } + } + + fragment BookingFragment1 on Property { + bookings { + id + property { + id + name + } + } + ...BookingFragment2 + } + + fragment BookingFragment2 on Property { + bookings { + customer { + name + } + } + } + `, + ); + + expect(mergedResult).to.deep.equal({ + data: { + propertyById: { + id: 'p2', + name: 'Another great hotel', + bookings: [ + { + id: 'b4', + customer: { + name: 'Exampler Customer', + }, + property: { + id: 'p2', + name: 'Another great hotel', + }, + }, + ], + }, + }, + }); + }); + it('containing links and overlapping fragments on relation', async () => { const mergedResult = await graphql( mergedSchema, @@ -2090,7 +2333,6 @@ fragment BookingFragment on Booking { }); }); - // FIXME: __typename should be automatic describe('merge info defined interfaces', () => { it('inline fragments on existing types in subschema', async () => { const result = await graphql( @@ -2098,14 +2340,12 @@ fragment BookingFragment on Booking { ` query($pid: ID!, $bid: ID!) { property: node(id: $pid) { - __typename id ... on Property { name } } booking: node(id: $bid) { - __typename id ... on Booking { startTime @@ -2125,12 +2365,10 @@ fragment BookingFragment on Booking { expect(result).to.deep.equal({ data: { property: { - __typename: 'Property', id: 'p1', name: 'Super great hotel', }, booking: { - __typename: 'Booking', id: 'b1', startTime: '2016-05-04', endTime: '2016-06-03', @@ -2225,45 +2463,44 @@ fragment BookingFragment on Booking { }); }); - // KNOWN BUG - // it('fragments on interfaces in merged schema', async () => { - // const result = await graphql( - // mergedSchema, - // ` - // query($bid: ID!) { - // node(id: $bid) { - // ...NodeFragment - // } - // } - // - // fragment NodeFragment on Node { - // id - // ... on Property { - // name - // } - // ... on Booking { - // startTime - // endTime - // } - // } - // `, - // {}, - // {}, - // { - // bid: 'b1', - // }, - // ); - // - // expect(result).to.deep.equal({ - // data: { - // node: { - // id: 'b1', - // startTime: '2016-05-04', - // endTime: '2016-06-03', - // }, - // }, - // }); - // }); + it('fragments on interfaces in merged schema', async () => { + const result = await graphql( + mergedSchema, + ` + query($bid: ID!) { + node(id: $bid) { + ...NodeFragment + } + } + + fragment NodeFragment on Node { + id + ... on Property { + name + } + ... on Booking { + startTime + endTime + } + } + `, + {}, + {}, + { + bid: 'b1', + }, + ); + + expect(result).to.deep.equal({ + data: { + node: { + id: 'b1', + startTime: '2016-05-04', + endTime: '2016-06-03', + }, + }, + }); + }); it('multi-interface filter', async () => { const result = await graphql( @@ -2325,10 +2562,10 @@ fragment BookingFragment on Booking { { id: 'pd2', __typename: 'DownloadableProduct', - filesize: 1024 + filesize: 1024, }, - ] - } + ], + }, }); }); } @@ -2339,7 +2576,6 @@ fragment BookingFragment on Booking { ` query { nodes { - __typename id ... on Property { name @@ -2360,40 +2596,33 @@ fragment BookingFragment on Booking { id: 'b1', startTime: '2016-05-04', endTime: '2016-06-03', - __typename: 'Booking', }, { id: 'b2', startTime: '2016-06-04', endTime: '2016-07-03', - __typename: 'Booking', }, { id: 'b3', startTime: '2016-08-04', endTime: '2016-09-03', - __typename: 'Booking', }, { id: 'b4', startTime: '2016-10-04', endTime: '2016-10-03', - __typename: 'Booking', }, { id: 'p1', name: 'Super great hotel', - __typename: 'Property', }, { id: 'p2', name: 'Another great hotel', - __typename: 'Property', }, { id: 'p3', name: 'BedBugs - The Affordable Hostel', - __typename: 'Property', }, ], }, diff --git a/src/test/testTransforms.ts b/src/test/testTransforms.ts new file mode 100644 index 00000000000..dcd780e1e40 --- /dev/null +++ b/src/test/testTransforms.ts @@ -0,0 +1,471 @@ +/* tslint:disable:no-unused-expression */ + +import { expect } from 'chai'; +import { + GraphQLSchema, + GraphQLNamedType, + graphql, + Kind, + SelectionSetNode, +} from 'graphql'; +import { makeExecutableSchema } from '../schemaGenerator'; +import { propertySchema, bookingSchema } from './testingSchemas'; +import delegateToSchema from '../stitching/delegateToSchema'; +import { + transformSchema, + RenameTypes, + FilterTypes, + WrapQuery, + ExtractField, +} from '../transforms'; + +describe('transforms', () => { + describe('rename type', () => { + let schema: GraphQLSchema; + before(() => { + const transforms = [ + new RenameTypes( + (name: string) => + ({ + Property: 'House', + Location: 'Spots', + TestInterface: 'TestingInterface', + DateTime: 'Datum', + InputWithDefault: 'DefaultingInput', + TestInterfaceKind: 'TestingInterfaceKinds', + TestImpl1: 'TestImplementation1', + }[name]), + ), + ]; + schema = transformSchema(propertySchema, transforms); + }); + it('should work', async () => { + const result = await graphql( + schema, + ` + query($input: DefaultingInput!) { + interfaceTest(kind: ONE) { + ... on TestingInterface { + testString + } + } + propertyById(id: "p1") { + ... on House { + id + } + } + dateTimeTest + defaultInputTest(input: $input) + } + `, + {}, + {}, + { + input: { + test: 'bar', + }, + }, + ); + + expect(result).to.deep.equal({ + data: { + dateTimeTest: '1987-09-25T12:00:00', + defaultInputTest: 'bar', + interfaceTest: { + testString: 'test', + }, + propertyById: { + id: 'p1', + }, + }, + }); + }); + }); + + describe('namespace', () => { + let schema: GraphQLSchema; + before(() => { + const transforms = [ + new RenameTypes((name: string) => `Property_${name}`), + ]; + schema = transformSchema(propertySchema, transforms); + }); + it('should work', async () => { + const result = await graphql( + schema, + ` + query($input: Property_InputWithDefault!) { + interfaceTest(kind: ONE) { + ... on Property_TestInterface { + testString + } + } + propertyById(id: "p1") { + ... on Property_Property { + id + } + } + dateTimeTest + defaultInputTest(input: $input) + } + `, + {}, + {}, + { + input: { + test: 'bar', + }, + }, + ); + + expect(result).to.deep.equal({ + data: { + dateTimeTest: '1987-09-25T12:00:00', + defaultInputTest: 'bar', + interfaceTest: { + testString: 'test', + }, + propertyById: { + id: 'p1', + }, + }, + }); + }); + }); + + describe('filter type', () => { + let schema: GraphQLSchema; + before(() => { + const typeNames = ['ID', 'String', 'DateTime', 'Query', 'Booking']; + const transforms = [ + new FilterTypes( + (type: GraphQLNamedType) => typeNames.indexOf(type.name) >= 0, + ), + ]; + schema = transformSchema(bookingSchema, transforms); + }); + + it('should work normally', async () => { + const result = await graphql( + schema, + ` + query { + bookingById(id: "b1") { + id + propertyId + startTime + endTime + } + } + `, + ); + + expect(result).to.deep.equal({ + data: { + bookingById: { + endTime: '2016-06-03', + id: 'b1', + propertyId: 'p1', + startTime: '2016-05-04', + }, + }, + }); + }); + + it('should error on removed types', async () => { + const result = await graphql( + schema, + ` + query { + bookingById(id: "b1") { + id + propertyId + startTime + endTime + customer { + id + } + } + } + `, + ); + expect(result).to.deep.equal({ + errors: [ + { + locations: [ + { + column: 15, + line: 8, + }, + ], + message: 'Cannot query field "customer" on type "Booking".', + path: undefined, + }, + ], + }); + }); + }); + + describe('tree operations', () => { + let data: any; + let subSchema: GraphQLSchema; + let schema: GraphQLSchema; + before(() => { + data = { + u1: { + id: 'u1', + username: 'alice', + address: { + streetAddress: 'Windy Shore 21 A 7', + zip: '12345', + }, + }, + u2: { + id: 'u2', + username: 'bob', + address: { + streetAddress: 'Snowy Mountain 5 B 77', + zip: '54321', + }, + }, + }; + subSchema = makeExecutableSchema({ + typeDefs: ` + type User { + id: ID! + username: String + address: Address + } + + type Address { + streetAddress: String + zip: String + } + + input UserInput { + id: ID! + username: String + } + + input AddressInput { + id: ID! + streetAddress: String + zip: String + } + + type Query { + userById(id: ID!): User + } + + type Mutation { + setUser(input: UserInput!): User + setAddress(input: AddressInput!): Address + } + `, + resolvers: { + Query: { + userById(parent, { id }) { + return data[id]; + }, + }, + Mutation: { + setUser(parent, { input }) { + if (data[input.id]) { + return { + ...data[input.id], + ...input, + }; + } + }, + setAddress(parent, { input }) { + if (data[input.id]) { + return { + ...data[input.id].address, + ...input, + }; + } + }, + }, + }, + }); + schema = makeExecutableSchema({ + typeDefs: ` + type User { + id: ID! + username: String + address: Address + } + + type Address { + streetAddress: String + zip: String + } + + input UserInput { + id: ID! + username: String + streetAddress: String + zip: String + } + + type Query { + addressByUser(id: ID!): Address + } + + type Mutation { + setUserAndAddress(input: UserInput!): User + } + `, + resolvers: { + Query: { + addressByUser(parent, { id }, context, info) { + return delegateToSchema({ + schema: subSchema, + operation: 'query', + fieldName: 'userById', + args: { id }, + context, + info, + transforms: [ + // Wrap document takes a subtree as an AST node + new WrapQuery( + // path at which to apply wrapping and extracting + ['userById'], + (subtree: SelectionSetNode) => ({ + // we create a wrapping AST Field + kind: Kind.FIELD, + name: { + kind: Kind.NAME, + // that field is `address` + value: 'address', + }, + // Inside the field selection + selectionSet: subtree, + }), + // how to process the data result at path + result => result && result.address, + ), + ], + }); + }, + }, + Mutation: { + async setUserAndAddress(parent, { input }, context, info) { + const addressResult = await delegateToSchema({ + schema: subSchema, + operation: 'mutation', + fieldName: 'setAddress', + args: { + input: { + id: input.id, + streetAddress: input.streetAddress, + zip: input.zip, + }, + }, + context, + info, + transforms: [ + // ExtractField takes a path from which to extract the query + // for delegation and path to which to move it + new ExtractField({ + from: ['setAddress', 'address'], + to: ['setAddress'], + }), + ], + }); + const userResult = await delegateToSchema({ + schema: subSchema, + operation: 'mutation', + fieldName: 'setUser', + args: { + input: { + id: input.id, + username: input.username, + }, + }, + context, + info, + }); + return { + ...userResult, + address: addressResult, + }; + }, + }, + }, + }); + }); + + it('wrapping delegation', async () => { + const result = await graphql( + schema, + ` + query { + addressByUser(id: "u1") { + streetAddress + zip + } + } + `, + ); + + expect(result).to.deep.equal({ + data: { + addressByUser: { + streetAddress: 'Windy Shore 21 A 7', + zip: '12345', + }, + }, + }); + }); + + it('extracting delegation', async () => { + const result = await graphql( + schema, + ` + mutation($input: UserInput!) { + setUserAndAddress(input: $input) { + username + address { + zip + streetAddress + } + } + } + + # fragment UserFragment on User { + # address { + # zip + # ...AddressFragment + # } + # } + # + # fragment AddressFragment on Address { + # streetAddress + # } + `, + {}, + {}, + { + input: { + id: 'u2', + username: 'new-username', + streetAddress: 'New Address 555', + zip: '22222', + }, + }, + ); + expect(result).to.deep.equal({ + data: { + setUserAndAddress: { + username: 'new-username', + address: { + streetAddress: 'New Address 555', + zip: '22222', + }, + }, + }, + }); + }); + }); +}); diff --git a/src/test/testingSchemas.ts b/src/test/testingSchemas.ts index 0d8e3f366ed..2f4b260f19e 100644 --- a/src/test/testingSchemas.ts +++ b/src/test/testingSchemas.ts @@ -267,6 +267,12 @@ const propertyRootTypeDefs = ` bar: String } + type UnionImpl { + someField: String + } + + union TestUnion = TestImpl1 | UnionImpl + input InputWithDefault { test: String = "Foo" } @@ -278,6 +284,7 @@ const propertyRootTypeDefs = ` dateTimeTest: DateTime jsonTest(input: JSON): JSON interfaceTest(kind: TestInterfaceKind): TestInterface + unionTest(output: String): TestUnion errorTest: String errorTestNonNull: String! relay: Query! @@ -337,6 +344,20 @@ const propertyResolvers: IResolvers = { } }, + unionTest(root, { output }) { + if (output === 'Interface') { + return { + kind: 'ONE', + testString: 'test', + foo: 'foo', + }; + } else { + return { + someField: 'Bar', + }; + } + }, + errorTest() { throw new Error('Sample error!'); }, @@ -362,6 +383,16 @@ const propertyResolvers: IResolvers = { }, }, + TestUnion: { + __resolveType(obj) { + if (obj.kind === 'ONE') { + return 'TestImpl1'; + } else { + return 'UnionImpl'; + } + }, + }, + Property: { error() { throw new Error('Property.error error'); @@ -474,7 +505,7 @@ const bookingRootTypeDefs = ` bikeType: String } - type Car { + type Car { id: ID! licensePlate: String } @@ -730,7 +761,14 @@ export async function makeSchemaRemoteFromLink(schema: GraphQLSchema) { // ensure fetcher support exists from the 2.0 api async function makeExecutableSchemaFromFetcher(schema: GraphQLSchema) { const fetcher: Fetcher = ({ query, operationName, variables, context }) => { - return graphql(schema, print(query), null, context, variables, operationName); + return graphql( + schema, + print(query), + null, + context, + variables, + operationName, + ); }; const clientSchema = await introspectSchema(fetcher); diff --git a/src/test/tests.ts b/src/test/tests.ts index 6386e1ce72c..fb83fc64665 100755 --- a/src/test/tests.ts +++ b/src/test/tests.ts @@ -6,5 +6,7 @@ import './testMocking'; import './testResolution'; import './testMakeRemoteExecutableSchema'; import './testMergeSchemas'; +import './testTransforms'; +import './testAlternateMergeSchemas'; import './testErrors'; import './testDirectives'; diff --git a/src/transforms/AddArgumentsAsVariables.ts b/src/transforms/AddArgumentsAsVariables.ts new file mode 100644 index 00000000000..b2378ff8b64 --- /dev/null +++ b/src/transforms/AddArgumentsAsVariables.ts @@ -0,0 +1,199 @@ +import { + ArgumentNode, + DocumentNode, + FragmentDefinitionNode, + GraphQLArgument, + GraphQLInputType, + GraphQLList, + GraphQLField, + GraphQLNonNull, + GraphQLObjectType, + GraphQLSchema, + Kind, + OperationDefinitionNode, + SelectionNode, + TypeNode, + VariableDefinitionNode, +} from 'graphql'; +import { Request } from '../Interfaces'; +import { Transform } from './transforms'; + +export default class AddArgumentsAsVariablesTransform implements Transform { + private schema: GraphQLSchema; + private args: { [key: string]: any }; + + constructor(schema: GraphQLSchema, args: { [key: string]: any }) { + this.schema = schema; + this.args = args; + } + + public transformRequest(originalRequest: Request): Request { + const { document, newVariables } = addVariablesToRootField( + this.schema, + originalRequest.document, + this.args, + ); + const variables = { + ...originalRequest.variables, + ...newVariables, + }; + return { + document, + variables, + }; + } +} + +function addVariablesToRootField( + targetSchema: GraphQLSchema, + document: DocumentNode, + args: { [key: string]: any }, +): { + document: DocumentNode; + newVariables: { [key: string]: any }; +} { + const operations: Array< + OperationDefinitionNode + > = document.definitions.filter( + def => def.kind === Kind.OPERATION_DEFINITION, + ) as Array; + const fragments: Array = document.definitions.filter( + def => def.kind === Kind.FRAGMENT_DEFINITION, + ) as Array; + + const variableNames = {}; + + const newOperations = operations.map((operation: OperationDefinitionNode) => { + let existingVariables = operation.variableDefinitions.map( + (variableDefinition: VariableDefinitionNode) => + variableDefinition.variable.name.value, + ); + + let variableCounter = 0; + const variables = {}; + + const generateVariableName = (argName: string) => { + let varName; + do { + varName = `_v${variableCounter}_${argName}`; + variableCounter++; + } while (existingVariables.indexOf(varName) !== -1); + return varName; + }; + + let type: GraphQLObjectType; + if (operation.operation === 'subscription') { + type = targetSchema.getSubscriptionType(); + } else if (operation.operation === 'mutation') { + type = targetSchema.getMutationType(); + } else { + type = targetSchema.getQueryType(); + } + + const newSelectionSet: Array = []; + + operation.selectionSet.selections.forEach((selection: SelectionNode) => { + if (selection.kind === Kind.FIELD) { + let newArgs: { [name: string]: ArgumentNode } = {}; + selection.arguments.forEach((argument: ArgumentNode) => { + newArgs[argument.name.value] = argument; + }); + const name: string = selection.name.value; + const field: GraphQLField = type.getFields()[name]; + field.args.forEach((argument: GraphQLArgument) => { + if (argument.name in args) { + const variableName = generateVariableName(argument.name); + variableNames[argument.name] = variableName; + newArgs[argument.name] = { + kind: Kind.ARGUMENT, + name: { + kind: Kind.NAME, + value: argument.name, + }, + value: { + kind: Kind.VARIABLE, + name: { + kind: Kind.NAME, + value: variableName, + }, + }, + }; + existingVariables.push(variableName); + variables[variableName] = { + kind: Kind.VARIABLE_DEFINITION, + variable: { + kind: Kind.VARIABLE, + name: { + kind: Kind.NAME, + value: variableName, + }, + }, + type: typeToAst(argument.type), + }; + } + }); + + newSelectionSet.push({ + ...selection, + arguments: Object.keys(newArgs).map(argName => newArgs[argName]), + }); + } else { + newSelectionSet.push(selection); + } + }); + + return { + ...operation, + variableDefinitions: operation.variableDefinitions.concat( + Object.keys(variables).map(varName => variables[varName]), + ), + selectionSet: { + kind: Kind.SELECTION_SET, + selections: newSelectionSet, + }, + }; + }); + + const newVariables = {}; + Object.keys(variableNames).forEach(name => { + newVariables[variableNames[name]] = args[name]; + }); + + return { + document: { + ...document, + definitions: [...newOperations, ...fragments], + }, + newVariables, + }; +} + +function typeToAst(type: GraphQLInputType): TypeNode { + if (type instanceof GraphQLNonNull) { + const innerType = typeToAst(type.ofType); + if ( + innerType.kind === Kind.LIST_TYPE || + innerType.kind === Kind.NAMED_TYPE + ) { + return { + kind: Kind.NON_NULL_TYPE, + type: innerType, + }; + } else { + throw new Error('Incorrent inner non-null type'); + } + } else if (type instanceof GraphQLList) { + return { + kind: Kind.LIST_TYPE, + type: typeToAst(type.ofType), + }; + } else { + return { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: type.toString(), + }, + }; + } +} diff --git a/src/transforms/AddTypenameToAbstract.ts b/src/transforms/AddTypenameToAbstract.ts new file mode 100644 index 00000000000..ef790062219 --- /dev/null +++ b/src/transforms/AddTypenameToAbstract.ts @@ -0,0 +1,77 @@ +import { + DocumentNode, + FieldNode, + GraphQLInterfaceType, + GraphQLSchema, + GraphQLType, + GraphQLUnionType, + Kind, + SelectionSetNode, + TypeInfo, + visit, + visitWithTypeInfo, +} from 'graphql'; +import { Request } from '../Interfaces'; +import { Transform } from './transforms'; + +export default class AddTypenameToAbstract implements Transform { + private targetSchema: GraphQLSchema; + + constructor(targetSchema: GraphQLSchema) { + this.targetSchema = targetSchema; + } + + public transformRequest(originalRequest: Request): Request { + const document = addTypenameToAbstract( + this.targetSchema, + originalRequest.document, + ); + return { + ...originalRequest, + document, + }; + } +} + +function addTypenameToAbstract( + targetSchema: GraphQLSchema, + document: DocumentNode, +): DocumentNode { + const typeInfo = new TypeInfo(targetSchema); + return visit( + document, + visitWithTypeInfo(typeInfo, { + [Kind.SELECTION_SET]( + node: SelectionSetNode, + ): SelectionSetNode | null | undefined { + const parentType: GraphQLType = typeInfo.getParentType(); + let selections = node.selections; + if ( + parentType && + (parentType instanceof GraphQLInterfaceType || + parentType instanceof GraphQLUnionType) && + !selections.find( + _ => + (_ as FieldNode).kind === Kind.FIELD && + (_ as FieldNode).name.value === '__typename', + ) + ) { + selections = selections.concat({ + kind: Kind.FIELD, + name: { + kind: Kind.NAME, + value: '__typename', + }, + }); + } + + if (selections !== node.selections) { + return { + ...node, + selections, + }; + } + }, + }), + ); +} diff --git a/src/transforms/CheckResultAndHandleErrors.ts b/src/transforms/CheckResultAndHandleErrors.ts new file mode 100644 index 00000000000..73532674825 --- /dev/null +++ b/src/transforms/CheckResultAndHandleErrors.ts @@ -0,0 +1,17 @@ +import { GraphQLResolveInfo } from 'graphql'; +import { checkResultAndHandleErrors } from '../stitching/errors'; +import { Transform } from './transforms'; + +export default class CheckResultAndHandleErrors implements Transform { + private info: GraphQLResolveInfo; + private fieldName?: string; + + constructor(info: GraphQLResolveInfo, fieldName?: string) { + this.info = info; + this.fieldName = fieldName; + } + + public transformResult(result: any): any { + return checkResultAndHandleErrors(result, this.info, this.fieldName); + } +} diff --git a/src/transforms/ExpandAbstractTypes.ts b/src/transforms/ExpandAbstractTypes.ts new file mode 100644 index 00000000000..0038d0f8412 --- /dev/null +++ b/src/transforms/ExpandAbstractTypes.ts @@ -0,0 +1,229 @@ +import { + DocumentNode, + FragmentDefinitionNode, + GraphQLNamedType, + GraphQLSchema, + Kind, + OperationDefinitionNode, + SelectionNode, + SelectionSetNode, + TypeInfo, + getNamedType, + isAbstractType, + visit, + visitWithTypeInfo, +} from 'graphql'; +import implementsAbstractType from '../implementsAbstractType'; +import { Transform, Request } from '../Interfaces'; + +type TypeMapping = { [key: string]: Array }; + +export default class ExpandAbstractTypes implements Transform { + private targetSchema: GraphQLSchema; + private mapping: TypeMapping; + private reverseMapping: TypeMapping; + + constructor(transformedSchema: GraphQLSchema, targetSchema: GraphQLSchema) { + this.targetSchema = targetSchema; + this.mapping = extractPossibleTypes(transformedSchema, targetSchema); + this.reverseMapping = flipMapping(this.mapping); + } + + public transformRequest(originalRequest: Request): Request { + const document = expandAbstractTypes( + this.targetSchema, + this.mapping, + this.reverseMapping, + originalRequest.document, + ); + return { + ...originalRequest, + document, + }; + } +} + +function extractPossibleTypes( + transformedSchema: GraphQLSchema, + targetSchema: GraphQLSchema, +) { + const typeMap = transformedSchema.getTypeMap(); + const mapping: TypeMapping = {}; + Object.keys(typeMap).forEach(typeName => { + const type = typeMap[typeName]; + if (isAbstractType(type)) { + const targetType = targetSchema.getType(typeName); + if (!isAbstractType(targetType)) { + const implementations = transformedSchema.getPossibleTypes(type); + mapping[typeName] = implementations + .filter(impl => targetSchema.getType(impl.name)) + .map(impl => impl.name); + } + } + }); + return mapping; +} + +function flipMapping(mapping: TypeMapping): TypeMapping { + const result: TypeMapping = {}; + Object.keys(mapping).forEach(typeName => { + const toTypeNames = mapping[typeName]; + toTypeNames.forEach(toTypeName => { + if (!result[toTypeName]) { + result[toTypeName] = []; + } + result[toTypeName].push(typeName); + }); + }); + return result; +} + +function expandAbstractTypes( + targetSchema: GraphQLSchema, + mapping: TypeMapping, + reverseMapping: TypeMapping, + document: DocumentNode, +): DocumentNode { + const operations: Array< + OperationDefinitionNode + > = document.definitions.filter( + def => def.kind === Kind.OPERATION_DEFINITION, + ) as Array; + const fragments: Array = document.definitions.filter( + def => def.kind === Kind.FRAGMENT_DEFINITION, + ) as Array; + + const existingFragmentNames = fragments.map(fragment => fragment.name.value); + let fragmentCounter = 0; + const generateFragmentName = (typeName: string) => { + let fragmentName; + do { + fragmentName = `_${typeName}_Fragment${fragmentCounter}`; + fragmentCounter++; + } while (existingFragmentNames.indexOf(fragmentName) !== -1); + return fragmentName; + }; + + const newFragments: Array = []; + const fragmentReplacements: { + [fragmentName: string]: Array<{ fragmentName: string; typeName: string }>; + } = {}; + + fragments.forEach((fragment: FragmentDefinitionNode) => { + newFragments.push(fragment); + const possibleTypes = mapping[fragment.typeCondition.name.value]; + if (possibleTypes) { + fragmentReplacements[fragment.name.value] = []; + possibleTypes.forEach(possibleTypeName => { + const name = generateFragmentName(possibleTypeName); + existingFragmentNames.push(name); + const newFragment: FragmentDefinitionNode = { + kind: Kind.FRAGMENT_DEFINITION, + name: { + kind: Kind.NAME, + value: name, + }, + typeCondition: { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: possibleTypeName, + }, + }, + selectionSet: fragment.selectionSet, + }; + newFragments.push(newFragment); + + fragmentReplacements[fragment.name.value].push({ + fragmentName: name, + typeName: possibleTypeName, + }); + }); + } + }); + + const newDocument = { + ...document, + definitions: [...operations, ...newFragments], + }; + const typeInfo = new TypeInfo(targetSchema); + return visit( + newDocument, + visitWithTypeInfo(typeInfo, { + [Kind.SELECTION_SET](node: SelectionSetNode) { + const newSelections = [...node.selections]; + const parentType: GraphQLNamedType = getNamedType( + typeInfo.getParentType(), + ); + node.selections.forEach((selection: SelectionNode) => { + if (selection.kind === Kind.INLINE_FRAGMENT) { + const possibleTypes = mapping[selection.typeCondition.name.value]; + if (possibleTypes) { + possibleTypes.forEach(possibleType => { + if ( + implementsAbstractType( + targetSchema, + parentType, + targetSchema.getType(possibleType), + ) + ) { + newSelections.push({ + kind: Kind.INLINE_FRAGMENT, + typeCondition: { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: possibleType, + }, + }, + selectionSet: selection.selectionSet, + }); + } + }); + } + } else if (selection.kind === Kind.FRAGMENT_SPREAD) { + const fragmentName = selection.name.value; + const replacements = fragmentReplacements[fragmentName]; + if (replacements) { + replacements.forEach(replacement => { + const typeName = replacement.typeName; + if ( + implementsAbstractType( + targetSchema, + parentType, + targetSchema.getType(typeName), + ) + ) { + newSelections.push({ + kind: Kind.FRAGMENT_SPREAD, + name: { + kind: Kind.NAME, + value: replacement.fragmentName, + }, + }); + } + }); + } + } + }); + + if (parentType && reverseMapping[parentType.name]) { + newSelections.push({ + kind: Kind.FIELD, + name: { + kind: Kind.NAME, + value: '__typename', + }, + }); + } + + if (newSelections.length !== node.selections.length) { + return { + ...node, + selections: newSelections, + }; + } + }, + }), + ); +} diff --git a/src/transforms/ExtractField.ts b/src/transforms/ExtractField.ts new file mode 100644 index 00000000000..216d8f61e7c --- /dev/null +++ b/src/transforms/ExtractField.ts @@ -0,0 +1,55 @@ +import { visit, Kind, SelectionSetNode, BREAK, FieldNode } from 'graphql'; +import { Transform, Request } from '../Interfaces'; + +export default class ExtractField implements Transform { + private from: Array; + private to: Array; + + constructor({ from, to }: { from: Array; to: Array }) { + this.from = from; + this.to = to; + } + + public transformRequest(originalRequest: Request): Request { + let fromSelection: SelectionSetNode; + const ourPathFrom = JSON.stringify(this.from); + const ourPathTo = JSON.stringify(this.to); + let fieldPath: Array = []; + visit(originalRequest.document, { + [Kind.FIELD]: { + enter: (node: FieldNode) => { + fieldPath.push(node.name.value); + if (ourPathFrom === JSON.stringify(fieldPath)) { + fromSelection = node.selectionSet; + return BREAK; + } + }, + leave: (node: FieldNode) => { + fieldPath.pop(); + }, + }, + }); + + fieldPath = []; + const newDocument = visit(originalRequest.document, { + [Kind.FIELD]: { + enter: (node: FieldNode) => { + fieldPath.push(node.name.value); + if (ourPathTo === JSON.stringify(fieldPath) && fromSelection) { + return { + ...node, + selectionSet: fromSelection, + }; + } + }, + leave: (node: FieldNode) => { + fieldPath.pop(); + }, + }, + }); + return { + ...originalRequest, + document: newDocument, + }; + } +} diff --git a/src/transforms/FilterRootFields.ts b/src/transforms/FilterRootFields.ts new file mode 100644 index 00000000000..bc7a2c1979b --- /dev/null +++ b/src/transforms/FilterRootFields.ts @@ -0,0 +1,33 @@ +import { GraphQLField, GraphQLSchema } from 'graphql'; +import { Transform } from './transforms'; +import TransformRootFields from './TransformRootFields'; + +export type RootFilter = ( + operation: 'Query' | 'Mutation' | 'Subscription', + fieldName: string, + field: GraphQLField, +) => boolean; + +export default class FilterRootFields implements Transform { + private transformer: TransformRootFields; + + constructor(filter: RootFilter) { + this.transformer = new TransformRootFields( + ( + operation: 'Query' | 'Mutation' | 'Subscription', + fieldName: string, + field: GraphQLField, + ) => { + if (filter(operation, fieldName, field)) { + return undefined; + } else { + return null; + } + }, + ); + } + + public transformSchema(originalSchema: GraphQLSchema): GraphQLSchema { + return this.transformer.transformSchema(originalSchema); + } +} diff --git a/src/transforms/FilterToSchema.ts b/src/transforms/FilterToSchema.ts new file mode 100644 index 00000000000..b78fd6ba1d4 --- /dev/null +++ b/src/transforms/FilterToSchema.ts @@ -0,0 +1,281 @@ +import { + ArgumentNode, + DocumentNode, + FieldNode, + FragmentDefinitionNode, + FragmentSpreadNode, + GraphQLInterfaceType, + GraphQLList, + GraphQLNamedType, + GraphQLNonNull, + GraphQLObjectType, + GraphQLSchema, + GraphQLType, + GraphQLUnionType, + InlineFragmentNode, + Kind, + OperationDefinitionNode, + SelectionSetNode, + TypeNameMetaFieldDef, + VariableDefinitionNode, + VariableNode, + visit, +} from 'graphql'; +import { Request } from '../Interfaces'; +import implementsAbstractType from '../implementsAbstractType'; +import { Transform } from './transforms'; + +export default class FilterToSchema implements Transform { + private targetSchema: GraphQLSchema; + + constructor(targetSchema: GraphQLSchema) { + this.targetSchema = targetSchema; + } + + public transformRequest(originalRequest: Request): Request { + const document = filterDocumentToSchema( + this.targetSchema, + originalRequest.document, + ); + return { + ...originalRequest, + document, + }; + } +} + +function filterDocumentToSchema( + targetSchema: GraphQLSchema, + document: DocumentNode, +): DocumentNode { + const operations: Array< + OperationDefinitionNode + > = document.definitions.filter( + def => def.kind === Kind.OPERATION_DEFINITION, + ) as Array; + const fragments: Array = document.definitions.filter( + def => def.kind === Kind.FRAGMENT_DEFINITION, + ) as Array; + + let usedVariables: Array = []; + let usedFragments: Array = []; + const newOperations: Array = []; + let newFragments: Array = []; + + const validFragments: Array = fragments.filter( + (fragment: FragmentDefinitionNode) => { + const typeName = fragment.typeCondition.name.value; + return Boolean(targetSchema.getType(typeName)); + }, + ); + + const validFragmentsWithType: { [name: string]: GraphQLType } = {}; + validFragments.forEach((fragment: FragmentDefinitionNode) => { + const typeName = fragment.typeCondition.name.value; + const type = targetSchema.getType(typeName); + validFragmentsWithType[fragment.name.value] = type; + }); + + operations.forEach((operation: OperationDefinitionNode) => { + let type; + if (operation.operation === 'subscription') { + type = targetSchema.getSubscriptionType(); + } else if (operation.operation === 'mutation') { + type = targetSchema.getMutationType(); + } else { + type = targetSchema.getQueryType(); + } + const { + selectionSet, + usedFragments: operationUsedFragments, + usedVariables: operationUsedVariables, + } = filterSelectionSet( + targetSchema, + type, + validFragmentsWithType, + operation.selectionSet, + ); + + usedFragments = union(usedFragments, operationUsedFragments); + const fullUsedVariables = union(usedVariables, operationUsedVariables); + + const variableDefinitions = operation.variableDefinitions.filter( + (variable: VariableDefinitionNode) => + fullUsedVariables.indexOf(variable.variable.name.value) !== -1, + ); + + newOperations.push({ + kind: Kind.OPERATION_DEFINITION, + operation: operation.operation, + name: operation.name, + directives: operation.directives, + variableDefinitions, + selectionSet, + }); + }); + + while (usedFragments.length !== 0) { + const nextFragmentName = usedFragments.pop(); + const fragment = validFragments.find( + fr => fr.name.value === nextFragmentName, + ); + if (fragment) { + const name = nextFragmentName; + const typeName = fragment.typeCondition.name.value; + const type = targetSchema.getType(typeName); + const { + selectionSet, + usedFragments: fragmentUsedFragments, + usedVariables: fragmentUsedVariables, + } = filterSelectionSet( + targetSchema, + type, + validFragmentsWithType, + fragment.selectionSet, + ); + usedFragments = union(usedFragments, fragmentUsedFragments); + usedVariables = union(usedVariables, fragmentUsedVariables); + + newFragments.push({ + kind: Kind.FRAGMENT_DEFINITION, + name: { + kind: Kind.NAME, + value: name, + }, + typeCondition: fragment.typeCondition, + selectionSet, + }); + } + } + + return { + kind: Kind.DOCUMENT, + definitions: [...newOperations, ...newFragments], + }; +} + +function filterSelectionSet( + schema: GraphQLSchema, + type: GraphQLType, + validFragments: { [name: string]: GraphQLType }, + selectionSet: SelectionSetNode, +) { + const usedFragments: Array = []; + const usedVariables: Array = []; + const typeStack: Array = [type]; + + // Should be rewritten using visitWithSchema + const filteredSelectionSet = visit(selectionSet, { + [Kind.FIELD]: { + enter(node: FieldNode): null | undefined | FieldNode { + let parentType: GraphQLNamedType = resolveType( + typeStack[typeStack.length - 1], + ); + if ( + parentType instanceof GraphQLObjectType || + parentType instanceof GraphQLInterfaceType + ) { + const fields = parentType.getFields(); + const field = + node.name.value === '__typename' + ? TypeNameMetaFieldDef + : fields[node.name.value]; + if (!field) { + return null; + } else { + typeStack.push(field.type); + } + + const argNames = (field.args || []).map(arg => arg.name); + if (node.arguments) { + let args = node.arguments.filter((arg: ArgumentNode) => { + return argNames.indexOf(arg.name.value) !== -1; + }); + if (args.length !== node.arguments.length) { + return { + ...node, + arguments: args, + }; + } + } + } else if ( + parentType instanceof GraphQLUnionType && + node.name.value === '__typename' + ) { + typeStack.push(TypeNameMetaFieldDef.type); + } + }, + leave() { + typeStack.pop(); + }, + }, + [Kind.FRAGMENT_SPREAD](node: FragmentSpreadNode): null | undefined { + if (node.name.value in validFragments) { + const parentType: GraphQLNamedType = resolveType( + typeStack[typeStack.length - 1], + ); + const innerType = validFragments[node.name.value]; + if (!implementsAbstractType(schema, parentType, innerType)) { + return null; + } else { + usedFragments.push(node.name.value); + return; + } + } else { + return null; + } + }, + [Kind.INLINE_FRAGMENT]: { + enter(node: InlineFragmentNode): null | undefined { + if (node.typeCondition) { + const innerType = schema.getType(node.typeCondition.name.value); + const parentType: GraphQLNamedType = resolveType( + typeStack[typeStack.length - 1], + ); + if (implementsAbstractType(schema, parentType, innerType)) { + typeStack.push(innerType); + } else { + return null; + } + } + }, + leave(node: InlineFragmentNode) { + typeStack.pop(); + }, + }, + [Kind.VARIABLE](node: VariableNode) { + usedVariables.push(node.name.value); + }, + }); + + return { + selectionSet: filteredSelectionSet, + usedFragments, + usedVariables, + }; +} + +function resolveType(type: GraphQLType): GraphQLNamedType { + let lastType = type; + while ( + lastType instanceof GraphQLNonNull || + lastType instanceof GraphQLList + ) { + lastType = lastType.ofType; + } + return lastType; +} + +function union(...arrays: Array>): Array { + const cache: { [key: string]: boolean } = {}; + const result: Array = []; + arrays.forEach(array => { + array.forEach(item => { + if (!cache[item]) { + cache[item] = true; + result.push(item); + } + }); + }); + return result; +} diff --git a/src/transforms/FilterTypes.ts b/src/transforms/FilterTypes.ts new file mode 100644 index 00000000000..8d8196bd98d --- /dev/null +++ b/src/transforms/FilterTypes.ts @@ -0,0 +1,25 @@ +/* tslint:disable:no-unused-expression */ + +import { GraphQLSchema, GraphQLNamedType } from 'graphql'; +import { Transform } from '../transforms/transforms'; +import { visitSchema, VisitSchemaKind } from '../transforms/visitSchema'; + +export default class FilterTypes implements Transform { + private filter: (type: GraphQLNamedType) => boolean; + + constructor(filter: (type: GraphQLNamedType) => boolean) { + this.filter = filter; + } + + public transformSchema(schema: GraphQLSchema): GraphQLSchema { + return visitSchema(schema, { + [VisitSchemaKind.TYPE]: (type: GraphQLNamedType) => { + if (this.filter(type)) { + return undefined; + } else { + return null; + } + }, + }); + } +} diff --git a/src/transforms/RenameRootFields.ts b/src/transforms/RenameRootFields.ts new file mode 100644 index 00000000000..30fdbf4fe67 --- /dev/null +++ b/src/transforms/RenameRootFields.ts @@ -0,0 +1,39 @@ +import { GraphQLNamedType, GraphQLField, GraphQLSchema } from 'graphql'; +import { Transform } from './transforms'; +import { + createResolveType, + fieldToFieldConfig, +} from '../stitching/schemaRecreation'; +import TransformRootFields from './TransformRootFields'; + +export default class RenameRootFields implements Transform { + private transformer: TransformRootFields; + + constructor( + renamer: ( + operation: 'Query' | 'Mutation' | 'Subscription', + name: string, + field: GraphQLField, + ) => string, + ) { + const resolveType = createResolveType( + (name: string, type: GraphQLNamedType): GraphQLNamedType => type, + ); + this.transformer = new TransformRootFields( + ( + operation: 'Query' | 'Mutation' | 'Subscription', + fieldName: string, + field: GraphQLField, + ) => { + return { + name: renamer(operation, fieldName, field), + field: fieldToFieldConfig(field, resolveType, true), + }; + }, + ); + } + + public transformSchema(originalSchema: GraphQLSchema): GraphQLSchema { + return this.transformer.transformSchema(originalSchema); + } +} diff --git a/src/transforms/RenameTypes.ts b/src/transforms/RenameTypes.ts new file mode 100644 index 00000000000..8e7bd3b80e9 --- /dev/null +++ b/src/transforms/RenameTypes.ts @@ -0,0 +1,117 @@ +import { + visit, + GraphQLSchema, + NamedTypeNode, + Kind, + GraphQLNamedType, + GraphQLScalarType, +} from 'graphql'; +import isSpecifiedScalarType from '../isSpecifiedScalarType'; +import { Request, Result } from '../Interfaces'; +import { Transform } from '../transforms/transforms'; +import { visitSchema, VisitSchemaKind } from '../transforms/visitSchema'; + +export type RenameOptions = { + renameBuiltins: boolean; + renameScalars: boolean; +}; + +export default class RenameTypes implements Transform { + private renamer: (name: string) => string | undefined; + private reverseMap: { [key: string]: string }; + private renameBuiltins: boolean; + private renameScalars: boolean; + + constructor( + renamer: (name: string) => string | undefined, + options?: RenameOptions, + ) { + this.renamer = renamer; + this.reverseMap = {}; + const { renameBuiltins = false, renameScalars = true } = options || {}; + this.renameBuiltins = renameBuiltins; + this.renameScalars = renameScalars; + } + + public transformSchema(originalSchema: GraphQLSchema): GraphQLSchema { + return visitSchema(originalSchema, { + [VisitSchemaKind.TYPE]: (type: GraphQLNamedType) => { + if (isSpecifiedScalarType(type) && !this.renameBuiltins) { + return undefined; + } + if (type instanceof GraphQLScalarType && !this.renameScalars) { + return undefined; + } + const newName = this.renamer(type.name); + if (newName && newName !== type.name) { + this.reverseMap[newName] = type.name; + const newType = Object.assign(Object.create(type), type); + newType.name = newName; + return newType; + } + }, + + [VisitSchemaKind.ROOT_OBJECT](type: GraphQLNamedType) { + return undefined; + }, + }); + } + + public transformRequest(originalRequest: Request): Request { + const newDocument = visit(originalRequest.document, { + [Kind.NAMED_TYPE]: (node: NamedTypeNode) => { + const name = node.name.value; + if (name in this.reverseMap) { + return { + ...node, + name: { + kind: Kind.NAME, + value: this.reverseMap[name], + }, + }; + } + }, + }); + return { + document: newDocument, + variables: originalRequest.variables, + }; + } + + public transformResult(result: Result): Result { + if (result.data) { + const data = this.renameTypes(result.data, 'data'); + if (data !== result.data) { + return { ...result, data }; + } + } + + return result; + } + + private renameTypes(value: any, name: string) { + if (name === '__typename') { + return this.renamer(value); + } + + if (value && typeof value === 'object') { + const newObject = Object.create(Object.getPrototypeOf(value)); + let returnNewObject = false; + + Object.keys(value).forEach(key => { + const oldChild = value[key]; + const newChild = this.renameTypes(oldChild, key); + newObject[key] = newChild; + if (newChild !== oldChild) { + returnNewObject = true; + } + }); + + if (returnNewObject) { + return newObject; + } + } + + return value; + } +} diff --git a/src/transforms/ReplaceFieldWithFragment.ts b/src/transforms/ReplaceFieldWithFragment.ts new file mode 100644 index 00000000000..add06a3e98f --- /dev/null +++ b/src/transforms/ReplaceFieldWithFragment.ts @@ -0,0 +1,121 @@ +import { + DocumentNode, + GraphQLSchema, + GraphQLType, + InlineFragmentNode, + Kind, + SelectionSetNode, + TypeInfo, + OperationDefinitionNode, + parse, + visit, + visitWithTypeInfo, +} from 'graphql'; +import { Request } from '../Interfaces'; +import { Transform } from './transforms'; + +export default class ReplaceFieldWithFragment implements Transform { + private targetSchema: GraphQLSchema; + private mapping: FieldToFragmentMapping; + + constructor( + targetSchema: GraphQLSchema, + fragments: Array<{ + field: string; + fragment: string; + }>, + ) { + this.targetSchema = targetSchema; + this.mapping = {}; + for (const { field, fragment } of fragments) { + const parsedFragment = parseFragmentToInlineFragment(fragment); + const actualTypeName = parsedFragment.typeCondition.name.value; + this.mapping[actualTypeName] = fragments[actualTypeName] || {}; + this.mapping[actualTypeName][field] = parsedFragment; + } + } + + public transformRequest(originalRequest: Request): Request { + const document = replaceFieldsWithFragments( + this.targetSchema, + originalRequest.document, + this.mapping, + ); + return { + ...originalRequest, + document, + }; + } +} + +type FieldToFragmentMapping = { + [typeName: string]: { [fieldName: string]: InlineFragmentNode }; +}; + +function replaceFieldsWithFragments( + targetSchema: GraphQLSchema, + document: DocumentNode, + mapping: FieldToFragmentMapping, +): DocumentNode { + const typeInfo = new TypeInfo(targetSchema); + return visit( + document, + visitWithTypeInfo(typeInfo, { + [Kind.SELECTION_SET]( + node: SelectionSetNode, + ): SelectionSetNode | null | undefined { + const parentType: GraphQLType = typeInfo.getParentType(); + if (parentType) { + const parentTypeName = parentType.name; + let selections = node.selections; + + if (mapping[parentTypeName]) { + node.selections.forEach(selection => { + if (selection.kind === Kind.FIELD) { + const name = selection.name.value; + const fragment = mapping[parentTypeName][name]; + if (fragment) { + selections = selections.concat(fragment); + } + } + }); + } + + if (selections !== node.selections) { + return { + ...node, + selections, + }; + } + } + }, + }), + ); +} + +function parseFragmentToInlineFragment( + definitions: string, +): InlineFragmentNode { + if (definitions.trim().startsWith('fragment')) { + const document = parse(definitions); + for (const definition of document.definitions) { + if (definition.kind === Kind.FRAGMENT_DEFINITION) { + return { + kind: Kind.INLINE_FRAGMENT, + typeCondition: definition.typeCondition, + selectionSet: definition.selectionSet, + }; + } + } + } + + const query = parse(`{${definitions}}`) + .definitions[0] as OperationDefinitionNode; + for (const selection of query.selectionSet.selections) { + if (selection.kind === Kind.INLINE_FRAGMENT) { + return selection; + } + } + + throw new Error('Could not parse fragment'); +} diff --git a/src/transforms/TransformRootFields.ts b/src/transforms/TransformRootFields.ts new file mode 100644 index 00000000000..cc47ef3adb4 --- /dev/null +++ b/src/transforms/TransformRootFields.ts @@ -0,0 +1,107 @@ +import { + GraphQLObjectType, + GraphQLSchema, + GraphQLNamedType, + GraphQLField, + GraphQLFieldConfig, +} from 'graphql'; +import isEmptyObject from '../isEmptyObject'; +import { Transform } from './transforms'; +import { visitSchema, VisitSchemaKind } from './visitSchema'; +import { + createResolveType, + fieldToFieldConfig, +} from '../stitching/schemaRecreation'; + +export type RootTransformer = ( + operation: 'Query' | 'Mutation' | 'Subscription', + fieldName: string, + field: GraphQLField, +) => + | GraphQLFieldConfig + | { name: string; field: GraphQLFieldConfig } + | null + | undefined; + +export default class TransformRootFields implements Transform { + private transform: RootTransformer; + + constructor(transform: RootTransformer) { + this.transform = transform; + } + + public transformSchema(originalSchema: GraphQLSchema): GraphQLSchema { + return visitSchema(originalSchema, { + [VisitSchemaKind.QUERY]: (type: GraphQLObjectType) => { + return transformFields( + type, + (fieldName: string, field: GraphQLField) => + this.transform('Query', fieldName, field), + ); + }, + [VisitSchemaKind.MUTATION]: (type: GraphQLObjectType) => { + return transformFields( + type, + (fieldName: string, field: GraphQLField) => + this.transform('Mutation', fieldName, field), + ); + }, + [VisitSchemaKind.SUBSCRIPTION]: (type: GraphQLObjectType) => { + return transformFields( + type, + (fieldName: string, field: GraphQLField) => + this.transform('Subscription', fieldName, field), + ); + }, + }); + } +} + +function transformFields( + type: GraphQLObjectType, + transformer: ( + fieldName: string, + field: GraphQLField, + ) => + | GraphQLFieldConfig + | { name: string; field: GraphQLFieldConfig } + | null + | undefined, +): GraphQLObjectType { + const resolveType = createResolveType( + (name: string, originalType: GraphQLNamedType): GraphQLNamedType => + originalType, + ); + const fields = type.getFields(); + const newFields = {}; + Object.keys(fields).forEach(fieldName => { + const field = fields[fieldName]; + const newField = transformer(fieldName, field); + if (typeof newField === 'undefined') { + newFields[fieldName] = fieldToFieldConfig(field, resolveType, true); + } else if (newField !== null) { + if ( + (<{ name: string; field: GraphQLFieldConfig }>newField).name + ) { + newFields[ + (<{ name: string; field: GraphQLFieldConfig }>newField).name + ] = (<{ + name: string; + field: GraphQLFieldConfig; + }>newField).field; + } else { + newFields[fieldName] = newField; + } + } + }); + if (isEmptyObject(newFields)) { + return null; + } else { + return new GraphQLObjectType({ + name: type.name, + description: type.description, + astNode: type.astNode, + fields: newFields, + }); + } +} diff --git a/src/transforms/WrapQuery.ts b/src/transforms/WrapQuery.ts new file mode 100644 index 00000000000..2c640b13307 --- /dev/null +++ b/src/transforms/WrapQuery.ts @@ -0,0 +1,75 @@ +import { + FieldNode, + visit, + Kind, + SelectionNode, + SelectionSetNode, +} from 'graphql'; +import { Transform, Request, Result } from '../Interfaces'; + +export type QueryWrapper = (subtree: SelectionSetNode) => SelectionNode; + +export default class WrapQuery implements Transform { + private wrapper: QueryWrapper; + private extractor: (result: any) => any; + private path: Array; + + constructor( + path: Array, + wrapper: QueryWrapper, + extractor: (result: any) => any, + ) { + this.path = path; + this.wrapper = wrapper; + this.extractor = extractor; + } + + public transformRequest(originalRequest: Request): Request { + const document = originalRequest.document; + const fieldPath: Array = []; + const ourPath = JSON.stringify(this.path); + const newDocument = visit(document, { + [Kind.FIELD]: { + enter: (node: FieldNode) => { + fieldPath.push(node.name.value); + if (ourPath === JSON.stringify(fieldPath)) { + const selection = this.wrapper(node.selectionSet); + return { + ...node, + selectionSet: { + kind: Kind.SELECTION_SET, + selections: [selection], + }, + }; + } + }, + leave: (node: FieldNode) => { + fieldPath.pop(); + }, + }, + }); + return { + ...originalRequest, + document: newDocument, + }; + } + + public transformResult(originalResult: Result): Result { + let data = originalResult.data; + if (data) { + const path = [...this.path]; + while (path.length > 1) { + const next = path.unshift(); + if (data[next]) { + data = data[next]; + } + } + data[path[0]] = this.extractor(data[path[0]]); + } + + return { + data, + errors: originalResult.errors, + }; + } +} diff --git a/src/transforms/index.ts b/src/transforms/index.ts new file mode 100644 index 00000000000..db76efbc221 --- /dev/null +++ b/src/transforms/index.ts @@ -0,0 +1,22 @@ +import { Transform } from './transforms'; +export { Transform }; + +export { default as transformSchema } from './transformSchema'; + +export { default as AddArgumentsAsVariables } from './AddArgumentsAsVariables'; +export { + default as CheckResultAndHandleErrors, +} from './CheckResultAndHandleErrors'; +export { + default as ReplaceFieldWithFragment, +} from './ReplaceFieldWithFragment'; +export { default as AddTypenameToAbstract } from './AddTypenameToAbstract'; +export { default as FilterToSchema } from './FilterToSchema'; +export { default as RenameTypes } from './RenameTypes'; +export { default as FilterTypes } from './FilterTypes'; +export { default as TransformRootFields } from './TransformRootFields'; +export { default as RenameRootFields } from './RenameRootFields'; +export { default as FilterRootFields } from './FilterRootFields'; +export { default as ExpandAbstractTypes } from './ExpandAbstractTypes'; +export { default as ExtractField } from './ExtractField'; +export { default as WrapQuery } from './WrapQuery'; diff --git a/src/transforms/transformSchema.ts b/src/transforms/transformSchema.ts new file mode 100644 index 00000000000..e8dcdcb1e31 --- /dev/null +++ b/src/transforms/transformSchema.ts @@ -0,0 +1,32 @@ +import { GraphQLSchema } from 'graphql'; +import { addResolveFunctionsToSchema } from '../schemaGenerator'; + +import { visitSchema } from '../transforms/visitSchema'; +import { Transform, applySchemaTransforms } from '../transforms/transforms'; +import { + generateProxyingResolvers, + generateSimpleMapping, +} from '../stitching/resolvers'; + +export default function transformSchema( + targetSchema: GraphQLSchema, + transforms: Array, +): GraphQLSchema & { transforms: Array } { + let schema = visitSchema(targetSchema, {}, true); + const mapping = generateSimpleMapping(targetSchema); + const resolvers = generateProxyingResolvers( + targetSchema, + transforms, + mapping, + ); + addResolveFunctionsToSchema({ + schema, + resolvers, + resolverValidationOptions: { + allowResolversNotInSchema: true, + }, + }); + schema = applySchemaTransforms(schema, transforms); + (schema as any).transforms = transforms; + return schema as GraphQLSchema & { transforms: Array }; +} diff --git a/src/transforms/transforms.ts b/src/transforms/transforms.ts new file mode 100644 index 00000000000..7a84cb1bfe1 --- /dev/null +++ b/src/transforms/transforms.ts @@ -0,0 +1,55 @@ +import { GraphQLSchema } from 'graphql'; +import { Request, Result, Transform } from '../Interfaces'; + +export { Transform }; + +export function applySchemaTransforms( + originalSchema: GraphQLSchema, + transforms: Array, +): GraphQLSchema { + return transforms.reduce( + (schema: GraphQLSchema, transform: Transform) => + transform.transformSchema ? transform.transformSchema(schema) : schema, + originalSchema, + ); +} + +export function applyRequestTransforms( + originalRequest: Request, + transforms: Array, +): Request { + return transforms.reduce( + (request: Request, transform: Transform) => + transform.transformRequest + ? transform.transformRequest(request) + : request, + + originalRequest, + ); +} + +export function applyResultTransforms( + originalResult: any, + transforms: Array, +): any { + return transforms.reduce( + (result: any, transform: Transform) => + transform.transformResult ? transform.transformResult(result) : result, + originalResult, + ); +} + +export function composeTransforms(...transforms: Array): Transform { + const reverseTransforms = [...transforms].reverse(); + return { + transformSchema(originalSchema: GraphQLSchema): GraphQLSchema { + return applySchemaTransforms(originalSchema, transforms); + }, + transformRequest(originalRequest: Request): Request { + return applyRequestTransforms(originalRequest, reverseTransforms); + }, + transformResult(result: Result): Result { + return applyResultTransforms(result, reverseTransforms); + }, + }; +} diff --git a/src/transforms/visitSchema.ts b/src/transforms/visitSchema.ts new file mode 100644 index 00000000000..8d454fd25c2 --- /dev/null +++ b/src/transforms/visitSchema.ts @@ -0,0 +1,146 @@ +import { + GraphQLEnumType, + GraphQLInputObjectType, + GraphQLInterfaceType, + GraphQLObjectType, + GraphQLScalarType, + GraphQLSchema, + GraphQLType, + GraphQLUnionType, + GraphQLNamedType, + isNamedType, + getNamedType, +} from 'graphql'; +import { recreateType, createResolveType } from '../stitching/schemaRecreation'; + +export enum VisitSchemaKind { + TYPE = 'VisitSchemaKind.TYPE', + SCALAR_TYPE = 'VisitSchemaKind.SCALAR_TYPE', + ENUM_TYPE = 'VisitSchemaKind.ENUM_TYPE', + COMPOSITE_TYPE = 'VisitSchemaKind.COMPOSITE_TYPE', + OBJECT_TYPE = 'VisitSchemaKind.OBJECT_TYPE', + INPUT_OBJECT_TYPE = 'VisitSchemaKind.INPUT_OBJECT_TYPE', + ABSTRACT_TYPE = 'VisitSchemaKind.ABSTRACT_TYPE', + UNION_TYPE = 'VisitSchemaKind.UNION_TYPE', + INTERFACE_TYPE = 'VisitSchemaKind.INTERFACE_TYPE', + ROOT_OBJECT = 'VisitSchemaKind.ROOT_OBJECT', + QUERY = 'VisitSchemaKind.QUERY', + MUTATION = 'VisitSchemaKind.MUTATION', + SUBSCRIPTION = 'VisitSchemaKind.SUBSCRIPTION', +} +// I couldn't make keys to be forced to be enum values +export type SchemaVisitor = { [key: string]: TypeVisitor }; +export type TypeVisitor = ( + type: GraphQLType, + schema: GraphQLSchema, +) => GraphQLNamedType; + +export function visitSchema( + schema: GraphQLSchema, + visitor: SchemaVisitor, + stripResolvers?: boolean, +) { + const types = {}; + const resolveType = createResolveType(name => { + if (typeof types[name] === 'undefined') { + throw new Error(`Can't find type ${name}.`); + } + return types[name]; + }); + const queryType = schema.getQueryType(); + const mutationType = schema.getMutationType(); + const subscriptionType = schema.getSubscriptionType(); + const typeMap = schema.getTypeMap(); + Object.keys(typeMap).map((typeName: string) => { + const type = typeMap[typeName]; + if (isNamedType(type) && getNamedType(type).name.slice(0, 2) !== '__') { + const specifiers = getTypeSpecifiers(type, schema); + const typeVisitor = getVisitor(visitor, specifiers); + if (typeVisitor) { + const result: GraphQLNamedType | null | undefined = typeVisitor( + type, + schema, + ); + if (typeof result === 'undefined') { + types[typeName] = recreateType(type, resolveType, !stripResolvers); + } else if (result === null) { + types[typeName] = null; + } else { + types[typeName] = recreateType(result, resolveType, !stripResolvers); + } + } else { + types[typeName] = recreateType(type, resolveType, !stripResolvers); + } + } + }); + return new GraphQLSchema({ + query: queryType ? (types[queryType.name] as GraphQLObjectType) : null, + mutation: mutationType + ? (types[mutationType.name] as GraphQLObjectType) + : null, + subscription: subscriptionType + ? (types[subscriptionType.name] as GraphQLObjectType) + : null, + types: Object.keys(types).map(name => types[name]), + }); +} + +function getTypeSpecifiers( + type: GraphQLType, + schema: GraphQLSchema, +): Array { + const specifiers = [VisitSchemaKind.TYPE]; + if (type instanceof GraphQLObjectType) { + specifiers.unshift( + VisitSchemaKind.COMPOSITE_TYPE, + VisitSchemaKind.OBJECT_TYPE, + ); + const query = schema.getQueryType(); + const mutation = schema.getMutationType(); + const subscription = schema.getSubscriptionType(); + if (type === query) { + specifiers.push(VisitSchemaKind.ROOT_OBJECT, VisitSchemaKind.QUERY); + } else if (type === mutation) { + specifiers.push(VisitSchemaKind.ROOT_OBJECT, VisitSchemaKind.MUTATION); + } else if (type === subscription) { + specifiers.push( + VisitSchemaKind.ROOT_OBJECT, + VisitSchemaKind.SUBSCRIPTION, + ); + } + } else if (type instanceof GraphQLInputObjectType) { + specifiers.push(VisitSchemaKind.INPUT_OBJECT_TYPE); + } else if (type instanceof GraphQLInterfaceType) { + specifiers.push( + VisitSchemaKind.COMPOSITE_TYPE, + VisitSchemaKind.ABSTRACT_TYPE, + VisitSchemaKind.INTERFACE_TYPE, + ); + } else if (type instanceof GraphQLUnionType) { + specifiers.push( + VisitSchemaKind.COMPOSITE_TYPE, + VisitSchemaKind.ABSTRACT_TYPE, + VisitSchemaKind.UNION_TYPE, + ); + } else if (type instanceof GraphQLEnumType) { + specifiers.push(VisitSchemaKind.ENUM_TYPE); + } else if (type instanceof GraphQLScalarType) { + specifiers.push(VisitSchemaKind.SCALAR_TYPE); + } + + return specifiers; +} + +function getVisitor( + visitor: SchemaVisitor, + specifiers: Array, +): TypeVisitor | null { + let typeVisitor = null; + const stack = [...specifiers]; + while (!typeVisitor && stack.length > 0) { + const next = stack.pop(); + typeVisitor = visitor[next]; + } + + return typeVisitor; +}