7
votes

First of I'm rather new at actually posting at Stack Overflow but I will of course try my best to have all the relevant information here AND share the solution once found because I can imagine more people might be having trouble with this.

So we've started with a system that has multiple small microservices as the backend and we found Apollo server that's able to retrieve schemas from the graphql endpoints and stitch them together so we can have one nice point of entry. We've got that working but apollo server has nothing really to help with the overal architecture. Thats when we found NestJS and because we use angular on the frontend and NestJS is so simular to it it seemed like a perfect fit.

The problem we're having though is that we can't seem to get the following functionality working: - I would like to have a module which contains a service that can be given a number of endpoints (uri's to microservices) - With the enpoints given the service should retrieve the graphQL schemas from these endpoints and make them into RemoteExecutableSchemas and then merge them. - After merging them and making 1 big schema with the (remote) link information so that graphQL knows where to fetch the data. - After this is happened we would like to add some stitching so that all relationships are present (but this is not where my problem lies)

I've been going through the official docs (https://docs.nestjs.com/graphql/quick-start) through the examples of them (https://github.com/nestjs/nest/tree/master/sample/12-graphql-apollo) and of course checked out the github project (https://github.com/nestjs/graphql) and been nosing in this repo to see what the code does on the background.

We've tried several things to fetch them on the fly but couldn't get the schemas into the GraphQLModule before it instantiated. Then we thought it might be acceptable to have the service retrieve a graphqlSchema from an endpoint and write it to a file using printSchema(schema) which actually works, but then I lose the link information effectively making it a local schema instead of a remote schema. Now we've come up with the following but are once again stuck.

lets start with a little snippet from my package.json so people know the versions :)

"dependencies": {
    "@nestjs/common": "^5.4.0",
    "@nestjs/core": "^5.4.0",
    "@nestjs/graphql": "^5.5.1",
    "apollo-link-http": "^1.5.9",
    "apollo-server-express": "^2.3.2",
    "graphql": "^14.1.1",
    "reflect-metadata": "^0.1.12",
    "rimraf": "^2.6.2",
    "rxjs": "^6.2.2",
    "typescript": "^3.0.1"
  },
  "devDependencies": {
    "@nestjs/testing": "^5.1.0",
    "@types/express": "^4.16.0",
    "@types/jest": "^23.3.1",
    "@types/node": "^10.7.1",
    "@types/supertest": "^2.0.5",
    "jest": "^23.5.0",
    "nodemon": "^1.18.3",
    "prettier": "^1.14.2",
    "supertest": "^3.1.0",
    "ts-jest": "^23.1.3",
    "ts-loader": "^4.4.2",
    "ts-node": "^7.0.1",
    "tsconfig-paths": "^3.5.0",
    "tslint": "5.11.0"
  },

So, at the moment I have a schema-handler module which looks like this:

@Module({
  imports: [GraphQLModule.forRootAsync({
    useClass: GqlConfigService
  })],
  controllers: [SchemaHandlerController],
  providers: [SchemaFetcherService, SchemaSticherService, GqlConfigService]
})
export class SchemaHandlerModule {
}

So here we import the GraphQLModule and let it use the gql-config service to handle giving it the GraphQLModuleOptions.

The gql-config service looks like this:

    @Injectable()
export class GqlConfigService implements GqlOptionsFactory {
  async createGqlOptions(): Promise<GqlModuleOptions> {
    try{
      const countrySchema = this.createCountrySchema();
      return {
        typeDefs: [countrySchema]
      };
    } catch(err) {
      console.log(err);
      return {};
    }
  }

So I'm async creating the GqlModuleOptions and await the result. createCountrySchema functions looks like this:

public async createCountrySchema() : GraphQLSchema{
    const uri = 'https://countries.trevorblades.com/Graphql';
    try {
      const link = new HttpLink({
        uri: uri,
        fetch
      });
      const remoteSchema = await introspectSchema(link);

      return makeRemoteExecutableSchema({
        schema: remoteSchema,
        link
      });
    } catch (err) {
      console.log('ERROR: exception when trying to connect to ' + uri + ' Error Message: ' + err);
    }
  };

For the sake of the POC I just got a simple public graphQL API as an endpoint. This function is returning a GraphQLSchema object which I would then like to add (in some way) to the GqlOptions and have it visible on the playground. We've also tried having createCountrySchema return a Promise and await it when calling the function in the createGqlOptions but that doesn't seem to make a difference.

The actuall error we're getting looks like this:

[Nest] 83   - 2/1/2019, 2:10:57 PM   [RoutesResolver] SchemaHandlerController {/schema-handler}: +1ms
apollo_1  | (node:83) UnhandledPromiseRejectionWarning: Syntax Error: Unexpected [
apollo_1  |
apollo_1  | GraphQL request (2:9)
apollo_1  | 1:
apollo_1  | 2:         [object Promise]
apollo_1  |            ^
apollo_1  | 3:
apollo_1  |
apollo_1  |     at syntaxError (/opt/node_modules/graphql/error/syntaxError.js:24:10)
apollo_1  |     at unexpected (/opt/node_modules/graphql/language/parser.js:1483:33)
apollo_1  |     at parseDefinition (/opt/node_modules/graphql/language/parser.js:155:9)
apollo_1  |     at many (/opt/node_modules/graphql/language/parser.js:1513:16)
apollo_1  |     at parseDocument (/opt/node_modules/graphql/language/parser.js:115:18)
apollo_1  |     at parse (/opt/node_modules/graphql/language/parser.js:50:10)
apollo_1  |     at parseDocument (/opt/node_modules/graphql-tag/src/index.js:129:16)
apollo_1  |     at Object.gql (/opt/node_modules/graphql-tag/src/index.js:170:10)
apollo_1  |     at GraphQLFactory.<anonymous> (/opt/node_modules/@nestjs/graphql/dist/graphql.factory.js:48:55)
apollo_1  |     at Generator.next (<anonymous>)
apollo_1  | (node:83) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). (rejection id: 2)
apollo_1  | (node:83) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.

I think I'm rather close with this approach but I'm not really sure. The error I'm getting states that all Promises must be handled with a try/catch so that we won't get an unhandled Promise and I believe I do that everywhere so I don't understand where this error is coming from...

If anyone has any pointers, solution or advice I would be very very happy. I've been strugling to get the functionality we want fitted in nestjs for more then a week now and have seen loads of examples, snippets and discussions on this but I cannot find an example that stitches remote schemas and handing them back to nestjs.

I would be very gratefull for any comments on this, with kind regards, Tjeerd

2
how did you call the createGqlOptions function?Nguyen Phong Thien
That would be within the SqlConfigService. The service is injected when the GraphQLModule is imported in the schema-handler module.Tjeerd

2 Answers

5
votes

I had solved schema stitching problem by using transform method.
Look src/graphql.config/graphql.config.service.ts

here my code
link for the test

import { Injectable } from '@nestjs/common';
import { GqlOptionsFactory, GqlModuleOptions } from '@nestjs/graphql';
import * as ws from 'ws';
import {
  makeRemoteExecutableSchema,
  mergeSchemas,
  introspectSchema
} from 'graphql-tools';
import { HttpLink } from 'apollo-link-http';
import nodeFetch from 'node-fetch';
import { split, from, NextLink, Observable, FetchResult, Operation } from 'apollo-link';
import { getMainDefinition } from 'apollo-utilities';
import { OperationTypeNode, buildSchema as buildSchemaGraphql, GraphQLSchema, printSchema } from 'graphql';
import { setContext } from 'apollo-link-context';
import { SubscriptionClient, ConnectionContext } from 'subscriptions-transport-ws';
import * as moment from 'moment';
import { extend } from 'lodash';

import { ConfigService } from '../config';

declare const module: any;
interface IDefinitionsParams {
  operation?: OperationTypeNode;
  kind: 'OperationDefinition' | 'FragmentDefinition';
}
interface IContext {
  graphqlContext: {
    subscriptionClient: SubscriptionClient,
  };
}

@Injectable()
export class GqlConfigService implements GqlOptionsFactory {

  private remoteLink: string = 'https://countries.trevorblades.com';

  constructor(
    private readonly config: ConfigService
  ) {}

  async createGqlOptions(): Promise<GqlModuleOptions> {
    const remoteExecutableSchema = await this.createRemoteSchema();

    return {
      autoSchemaFile: 'schema.gql',
      transformSchema: async (schema: GraphQLSchema) => {
        return mergeSchemas({
          schemas: [
            schema,
            remoteExecutableSchema
          ]
        });
      },
      debug: true,
      playground: {
        env: this.config.environment,
        endpoint: '/graphql',
        subscriptionEndpoint: '/subscriptions',
        settings: {
          'general.betaUpdates': false,
          'editor.theme': 'dark' as any,
          'editor.reuseHeaders': true,
          'tracing.hideTracingResponse': true,
          'editor.fontSize': 14,
          // tslint:disable-next-line:quotemark
          'editor.fontFamily': "'Source Code Pro', 'Consolas', 'Inconsolata', 'Droid Sans Mono', 'Monaco', monospace",
          'request.credentials': 'include',
        },
      },
      tracing: true,
      installSubscriptionHandlers: true,
      introspection: true,
      subscriptions: {
        path: '/subscriptions',
        keepAlive: 10000,
        onConnect: async (connectionParams, webSocket: any, context) => {
          const subscriptionClient = new SubscriptionClient(this.config.get('HASURA_WS_URI'), {
            connectionParams: {
              ...connectionParams,
              ...context.request.headers
            },
            reconnect: true,
            lazy: true,
          }, ws);

          return {
            subscriptionClient,
          };
        },
        async onDisconnect(webSocket, context: ConnectionContext) {
          const { subscriptionClient } = await context.initPromise;

          if (subscriptionClient) {
            subscriptionClient.close();
          }
        },
      },
      context(context) {
        const contextModified: any = {
          userRole: 'anonymous',
          currentUTCTime: moment().utc().format()
        };

        if (context && context.connection && context.connection.context) {
          contextModified.subscriptionClient = context.connection.context.subscriptionClient;
        }

        return contextModified;
      },
    };
  }

  private wsLink(operation: Operation, forward?: NextLink): Observable<FetchResult> | null {
    const context = operation.getContext();
    const { graphqlContext: { subscriptionClient } }: any = context;
    return subscriptionClient.request(operation);
  }

  private async createRemoteSchema(): Promise<GraphQLSchema> {

    const httpLink = new HttpLink({
      uri: this.remoteLink,
      fetch: nodeFetch as any,
    });

    const remoteIntrospectedSchema = await introspectSchema(httpLink);
    const remoteSchema = printSchema(remoteIntrospectedSchema);
    const link = split(
      ({ query }) => {
        const { kind, operation }: IDefinitionsParams = getMainDefinition(query);
        return kind === 'OperationDefinition' && operation === 'subscription';
      },
      this.wsLink,
      httpLink,
    );

    const contextLink = setContext((request, prevContext) => {
      extend(prevContext.headers, {
        'X-hasura-Role': prevContext.graphqlContext.userRole,
        'X-Hasura-Utc-Time': prevContext.graphqlContext.currentUTCTime,
      });
      return prevContext;
    });

    const buildedHasuraSchema = buildSchemaGraphql(remoteSchema);
    const remoteExecutableSchema = makeRemoteExecutableSchema({
      link: from([contextLink, link]),
      schema: buildedHasuraSchema,
    });

    return remoteExecutableSchema;
  }

}
1
votes

This is a simplification of the first answer - wherever the GraphQLModule.forRoot(async) is being invoked ( coded inside the appModule file or exported separately ), the below snippet of code should help

import { GraphQLModule } from "@nestjs/graphql";
import { CommonModule } from "@Common";
import { GraphQLSchema } from 'graphql';
import { ConfigInterface } from "@Common/config/ConfigService";
import {
 stitchSchemas
} from '@graphql-tools/stitch';
import { introspectSchema } from '@graphql-tools/wrap';
import { print } from 'graphql';
import { fetch } from 'cross-fetch';

export default GraphQLModule.forRootAsync({
  imports: [CommonModule],
  useFactory: async (configService: ConfigInterface) => {
    const remoteSchema = await createRemoteSchema('https://countries.trevorblades.com/graphql');
    return {
      playground: process.env.NODE_ENV !== "production",
      context: ({ req, res }) => ({ req, res }),
      installSubscriptionHandlers: true,
      autoSchemaFile: "schema.gql",
      transformSchema : async (schema: GraphQLSchema) => {
        return stitchSchemas({
          subschemas: [
            schema,
            remoteSchema
          ]
        });
      },
    };
  },
});

const createRemoteSchema = async (url : string) =>{
  const executor = async ({ document, variables }) => {
    const query = print(document);
    const fetchResult = await fetch(url, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ query, variables })
    });
    return fetchResult.json();
  };
  return {
    schema: await introspectSchema(executor),
    executor: executor
  };
}

Reference : https://www.graphql-tools.com/docs/stitch-combining-schemas/#stitching-remote-schemas