Apollo Federation with local schemas

Problem:

We have an eco system of 12+ micro services using either .net or node and each exposing a restful API.

I built my first Apollo GraphQL solution in early 2019 and it works really well, the down side its huge and a pain to maintain as it basically is a collection of types, resolvers and requests to the different services. So we went with federation of the different services.

Now before I begin there is an excellent article here about using local schemas with Apollo Federation and Gateway.

This works but we needed a different approach that allowed us to slowly increment changes and separate out services. We also had the issue that a couple of the services could not be touched (this was a commercial decision).

Solution:

import { ApolloServer } from 'apollo-server-express';
import { ApolloGateway, RemoteGraphQLDataSource, LocalGraphQLDataSource } from '@apollo/gateway';
import { buildFederatedSchema } from '@apollo/federation';
import { DocumentNode } from 'graphql';
import depthLimit from 'graphql-depth-limit';
import userService from './services/user';
import organisationService from './services/organisation';

const remoteServices = {
  Goals: {
    url: 'https://api.mysite.com/goal/graphql',
  },
  Curriculum: {
    url: 'https://api-mysite.com/curriculum/graphql',
  },
};

const localServices = {
  ...userService,
  ...organisationService,
};

const services = ({
  ...localServices,
  ...remoteServices,
} as unknown) as {
  [index: string]: {
    url?: string;
    schema: DocumentNode;
  };
};

const DUMMY_SERVICE_URL = 'localService';

const TOKEN = process.env.KEY || ''; // This string is uses to access the remote services inside or micro service environment. It is used once when the service starts. All other queries and requests us the bearer token supplied by the client
 
    const gateway = new ApolloGateway({
  serviceList: Object.keys(services).map((name) => ({
    name,
    url: services[name].url || DUMMY_SERVICE_URL,
  })),
  __exposeQueryPlanExperimental: false,
  buildService({ name, url }) {
    if (url === DUMMY_SERVICE_URL) {
      return new LocalGraphQLDataSource(buildFederatedSchema(services[name].schema)); // The collection of local services
    } else {
      return new RemoteGraphQLDataSource({
        url,
        willSendRequest({ request, context }) {
          request.http?.headers.set('authorization', context.token || TOKEN); // This is the clients bearer token
        },
      });
    }
  },
});

// Just a regular Apollo Server. Its exported and used in the server/app file
export const gqlServer = new ApolloServer({
  gateway,
  subscriptions: false,
  validationRules: [depthLimit(8)],
  engine: false,
  context: ({ req }) => {
    const authorization = req.headers.authorization || '';
    return { authorization };
  },
  plugins: [
    {
      serverWillStart() {
        console.log('Server starting up!');
      },
    },
  ],
});

What the above allows us to do is create local schemas like this. You can always separate this into different files.

import { gql } from 'apollo-server-express';
import ConfigManager from '../../../lib/configSource/ConfigManager';
import axios from 'axios';
import { IAuthContext, makeHeaders, Error401, NotFound, errorHandling200 } from './utils'; // Common interfaces, a method to create a request header
import errors from '../../../lib/errors';

const BASE_URL = process.env.userProfileAPI;

interface User {
  id: string;
  firstName: string;
  lastName: string;
  displayName: string;
  lastLoggedIn: string;
  roles: string[];
  organizationId: string;
}

const typeDefs = gql`
  type User @key(fields: "id") {
    id: ID!
    email: String
    firstName: String
    lastName: String
    displayName: String
    lastLoggedIn: String
    roles: [String]
    organizationId: String
    organisation: Organisation
  }

  type NotFound {
    message: String
  }

  type Error401 {
    message: String
  }

  union UserResult = User | NotFound | Error401

  extend type Organisation @key(fields: "id") {
    id: ID! @external
    users: [UserResult]
  }

  extend type Query {
    User(id: ID!): UserResult
  }
`;

const resolvers = {
  Query: {
    User: async (
      parent: unknown,
      { id }: { id: string },
      ctx: IAuthContext,
    ): Promise<null | User | NotFound | Error401> => {
      try {
        const { data } = await getUser(id, ctx);
        return {
          __typename: 'User',
          ...data,
        };
      } catch (error) {
        return errorHandling200(error, error.response.status, `User with id "${id}" not found`);
      }
    },
  },
  User: {
    async __resolveReference(user: { id: string }, ctx: IAuthContext): Promise<User | NotFound | Error401> {
      try {
        const { data } = await getUser(user.id, ctx);
        return {
          __typename: 'User',
          ...data,
        };
      } catch (error) {
        return errorHandling200(error, error.response.status, `User with id "${user.id}" not found`);
      }
    },
    async organisation({ organizationId }: { organizationId: string }): Promise<unknown | null> {
      return { __typename: 'Organisation', id: organizationId };
    },
  },
  Organisation: {
    async users(parent: { id: string }, _: unknown, ctx: IAuthContext): Promise<User[] | NotFound | Error401> {
      try {
        const users = await getUserByOrganisation(parent.id, ctx);
        return users.map(user => ({
          __typename: "User",
          ...user
        }));
      } catch (error) {
        return errorHandling200(error, error.response.status, `User with id "${parent.id}" not found`);
      }
    },
  },
};

export default {
  user: {
    schema: {
      typeDefs,
      resolvers,
    },
  },
};