Front-End Web & Mobile

Apollo GraphQL Federation with AWS AppSync

This article was written by Florian Chazal, Senior Specialist Solutions Architect, AWS

 
 

Apollo Federation is an architecture and specification used to build and connect multiple distributed backend GraphQL (micro)services, exposing a single endpoint and GraphQL schema to API clients and consumers. In this post we explain how to setup an Apollo Federation Gateway connected to GraphQL subgraphs powered by AWS AppSync, a fully managed GraphQL service, and self-managed Apollo Server subgraphs running on AWS Lambda functions.

First we describe how to make your AWS AppSync schema and resolvers compliant with the Apollo federation specification. We then setup and deploy additional Apollo Server subgraphs running on AWS Lambda using the AWS Cloud Development Kit (CDK), and show how a client could query the resulting federated schema.

Here is the diagram of the final solution:

AppSync as Apollo Federation subgraph

In order for the GraphQL schemas from services or subgraphs to be composed into a schema managed by an Apollo Federation gateway, a set of directives and queries need to be implemented in the federated GraphQL services themselves. This section describes how to configure a GraphQL schema and resolvers in AppSync so that it can be integrated into an Apollo federated environment.

Let’s take a simple GraphQL schema as an example. The following schema describes the data model of a serverless AppSync GraphQL API endpoint which is exposing a simple Product type and a User type that creates products.

Here is the version before being adapted to Apollo federation:


type Product {
  id: ID!
  sku: String
  package: String
  variation: ProductVariation
  dimensions: ProductDimension
  createdBy: User
}

type ProductDimension {
  size: String
  weight: Float
}

type ProductVariation {
  id: ID!
}

input ProductVariationInput {
  id: ID!
}

type User {
  email: ID!
  totalProductsCreated: Int
}

type Query {
  product(id: ID!): Product
}

Exposing the schema to the gateway

A GraphQL service is aggregated by an Apollo federation gateway by providing its subgraph schema definition. In order to do so, the Apollo federation spec imposes the implementation of a query called _service returning an extended GraphQL Schema Definition Language (SDL) response. We add a _service query and a _Service type to the AppSync managed schema as follows:

type Query {
 _service: _Service!
  product(id: ID!): Product
}

type _Service {
  sdl: String
}

The AppSync resolver attached to this query can simply be set to return the schema defined in the AppSync GraphQL API itself.

Here is a CDK code sample where the schema definition is passed to an AppSync API’s Lambda resolver through a SCHEMA environment variable:

import * as core from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as appsync from '@aws-cdk/aws-appsync-alpha';
import * as lambda from 'aws-cdk-lib/aws-lambda-nodejs';
import { Tracing } from 'aws-cdk-lib/aws-lambda';

import { join } from 'path';

export interface AppSyncBasedServiceProps {
  readonly serviceName: string;
}

export class AppSyncBasedService extends Construct {
  readonly graphQLApiEndpoint: string;
  readonly apiKey: string;

  constructor(scope: Construct, id: string, props: AppSyncBasedServiceProps) {
    super(scope, id);

    // AppSync subgraph API declaration 
    const api = new appsync.GraphqlApi(this, 'Api', {
      name: props.serviceName,
      schema: appsync.Schema.fromAsset(join(__dirname, `${props.serviceName}.graphql`)),
      authorizationConfig: {
        defaultAuthorization: {
          authorizationType: appsync.AuthorizationType.API_KEY,
          apiKeyConfig: {
            expires: core.Expiration.after(core.Duration.days(364)),
          },
        },
      },
      xrayEnabled: true,
    });

    // subgraph API lamda resolver declaration
    const lambdaResolver = new lambda.NodejsFunction(this, 'lambdaResolver', {
      entry: join(__dirname, `${props.serviceName}-resolver.ts`),
      environment: {
         SCHEMA: api.schema.definition.replace('__typename: String!', ''), // We need to remove __typename from the definition to stay compliant with expected Apollo SDL format
      },
      tracing: Tracing.ACTIVE,
    });
    
    ...

The schema definition can then be returned as is by the Lambda resolver under the sdl key as follows:

import { AppSyncResolverEvent } from 'aws-lambda';

// Handler resolving the entities from representations argument
export const handler = async (event: AppSyncResolverEvent<any>) => {
  console.log(`event ${JSON.stringify(event)}`);

  let result: any = [];
  switch (event.info.parentTypeName) {
    case 'Query':
      switch (event.info.fieldName) {
        case '_service':
          result = { sdl: process.env.SCHEMA };
          break;
          ....
      }
      break;
  }
  return result;
};

A complete example can be found in this GitHub repository.

Think entity

The power of federation is the capability for each service to only care about their own data types and fields and, at the same time, be able to reuse models from other services.

A type that can be extended and referenced by other services is called an Entity. In order to make a standard GraphQL type an entity, it needs to expose how it can be retrieved by other services. A new @key directive indicates a combination of fields that can be used to uniquely identify and fetch an object or interface.

In the initial example, exposing Product through its id, or sku and package would mean adding the combination of these fields to the schema in the @key directive as follows:

type Product 
  @key(fields: "id") 
  @key(fields: "sku package") {
...

We do the same for the User type and use its email as primary key:

type User 
  @key(fields: "email") {
...

Finally, all types exposed as entities need to be added to a new _Entity union.

union _Entity = User | Product

Extending an entity

Now let’s imagine a new independent service called Reviews is created and hosted in a separate GraphQL API to manage Product’s reviews with the following subgraph:

type Product @key(fields: "id") @extends {
    id: ID! @external
    reviews: [Review]
}

"""
This is an Entity, docs:https://www.apollographql.com/docs/federation/entities/
You will need to define a __resolveReference resolver for the type you define, docs: https://www.apollographql.com/docs/federation/entities/#resolving
"""
type Review @key(fields: "id") {
    id: ID!
    rating: Float
    content: String
}

The @extends directive is used for type extension by the Reviews subgraph service, adding an additional field reviews to the base Product type defined in the Products service hosted in AppSync. The external id field in the Product type is the unique product key or identifier that allows the Reviews service to uniquely identify a product without having to know all the details of the Product type resolved by the Products service.

The @external directive is used to mark a field as owned by another service. This allows the Review type to use fields from Product in the Products service while also knowing the types of the fields at runtime.

Resolving extended types

In the example above, the Reviews service needs to resolve an entire Product object if it wants to be able to satisfy a query like so:

query {
    reviews {
       content
       Product {
            sku,
            package,
           id
       }
    }
}

However, the Reviews service does not know about the sku and package fields in the Product type. Resolving the external type reference is done through the gateway which knows how to get information from the Products service in AppSync to satisfy the entire query. The Reviews service simply needs to define a resolver for the Product type that returns enough information to uniquely identify a Product object using the primary key from the external type:

{
  Review: {
    Product(review) {
      return { __typename: "Product", id: review.product.id };
    }
  }
}

This stub resolver is a “representation” of the Product type. Subgraphs use representations to reference entities from other subgraphs. A representation requires only an explicit __typename definition and values for the entity’s primary key fields.

The gateway creates an _entitities query with the list of representations that need resolving.

The representations input would look like :

[
    {
      "__typename": "Product",
      "id": "1"
    },
    {
      "__typename": "Product",
      "id": "2"
    }
]

Back to the Products service in AppSync, our next step is to define and create an _entities query capable to take _Any kind of field set:

type Query @extends {
  _entities(representations: [_Any!]!): [_Entity]!
  product(id: ID!): Product
}

input _Any {
    __typename: String!
    id: String
    sku: String
    package: String
    variation: ProductVariationInput
    email: String
}

The _Any input needs to have the __typename attribute as well as all the keys used on entities (id, sku, package and email).

The AppSync resolver attached to this _entities query simply returns the right object based on the given __typename  and keys (respectively Product and id in the following example).

{
  if (reference.get(__"typename") == "Product") {
      return Product.fetchById(reference.get("id"));
  }
}

Here is an example of a basic AppSync Lambda resolver implementing the necessary logic to handle expected _entities :

import { AppSyncResolverEvent } from 'aws-lambda';

// Hard coded products data 
const products = [
  {
    id: 'apollo-federation',
    sku: 'federation',
    package: '@apollo/federation',
    variation: { id: 'OSS' },
    dimensions: { size: 1, weight: 1 },
  },
  {
    id: 'apollo-studio',
    sku: 'studio',
    package: '',
    variation: { id: 'platform' },
  },
];

// Handler resolving the entities from representations argument
export const handler = async (event: AppSyncResolverEvent<any>) => {
  console.log(`event ${JSON.stringify(event)}`);

  let result: any = [];
  switch (event.info.parentTypeName) {
    case 'Product':
      console.log(`dealing with product and field ${event.info.fieldName}`);
      switch (event.info.fieldName) {
        case 'createdBy':
          result = { email: 'support@apollographql.com', name: 'Apollo Studio Support', totalProductsCreated: 1337 };
          break;
      }
      break;
    case 'Query':
      switch (event.info.fieldName) {
        ...
        case 'product':
          if (event.arguments.id) result = products.find((p) => p.id === event.arguments.id);
          if (event.arguments.sku && event.arguments.package)
            result = products.find((p) => p.sku === event.arguments.sku && p.package === event.arguments.package);
          if (event.arguments.sku && event.arguments.variation && event.arguments.variation.id)
            result = products.find(
              (p) => p.sku === event.arguments.sku && p.variation.id === event.arguments.variation.id
            );
          break;
        case '_entities':
          const { representations } = event.arguments;
          const entities: any[] = [];

          for (const representation of representations as [any]) {
            const filteredProduct = products.find((p: any) => {
              for (const key of Object.keys(representation)) {
                if (typeof representation[key] != 'object' && key != '__typename' && p[key] != representation[key]) {
                  return false;
                } else if (typeof representation[key] == 'object') {
                  for (const subkey of Object.keys(representation[key])) {
                    if (
                      typeof representation[key][subkey] != 'object' &&
                      p[key][subkey] != representation[key][subkey]
                    ) {
                      return false;
                    }
                  }
                }
              }
              return true;
            });

            entities.push({ ...filteredProduct, __typename: 'Product' });
          }
          result = entities;
          break;
      }
      break;
  }
  return result;
};

New Directives and Scalar

In addition to the aforementioned @key and @extends directives, the Apollo federation specification provides other directives:

  • @external enabling to specify an attribute that needs to be resolved by another service.
  • @provides which specify that, even if the property is owned by another service, it is also available in the current graph and does not need to be resolved by the service owning the type, object or field.
  • @requires specifying which field is required when resolving the resource.

To illustrate the usage of these directives, let’s split our current graph into two: one in charge of the Product type that we keep in AWS AppSync and a new one dedicated to the User type exposed by an Apollo Server. Here is the final Products service subgraph schema compatible with both  AppSync and the Apollo Federation specification:

schema {query: Query}

type Product @key(fields: "id") @key(fields: "sku package") @key(fields: "sku variation { id }") {
  id: ID!
  sku: String
  package: String
  variation: ProductVariation
  dimensions: ProductDimension
  createdBy: User @provides(fields: "totalProductsCreated")
}

type ProductDimension {
  size: String
  weight: Float
}

type _Service {
  sdl: String
}

type ProductVariation {
  id: ID!
}
input ProductVariationInput {
  id: ID!
}

type Query @extends {
  _service: _Service!
  _entities(representations: [_Any!]!): [_Entity]!
  product(id: ID!): Product
}

type User @key(fields: "email") @extends {
  email: ID! @external
  totalProductsCreated: Int @external
  name: String @external
  
}

union _Entity = User | Product

input _Any {
    __typename: String!
    id: String
    sku: String
    package: String
    variation: ProductVariationInput
}

Now the User type needs to be defined in a new service and it’s own subgraph. The schema above contains a stub of User (highlighted by the @extends directive) but still need to store its email (defined in the @key directive) and  totalProductsCreated (defined in the @provides directive), but name has to be resolved through the external User service (defined in the @external directive).

In the Users service subgraph schema we define at least the fields emailtotalProductsCreated and name since those fields are declared in the Products service subgraph and use email as key:

type User  @key(fields: "email") {
    email: ID!
    totalProductsCreated: Int
    name: String
    age: Int
  }

  type Query {
    users: [User]
  }

Federation

Now that a federation-ready subgraph schema is properly configured on AWS AppSync in the Products service, let’s test the end to end federation setup.

In this section we go through the following steps:

  1. Deploy and expose the Products service GraphQL API in AWS AppSync
  2. Deploy the Users and Reviews service in Apollo Servers hosted in AWS Lambda and exposed by Amazon API Gateway
  3. Federate the three services schemas with an Apollo Gateway hosted in AWS Lambda and exposed by Amazon API Gateway
  4. Test cross boundary queries using the Apollo Studio sandbox explorer (https://studio.apollographql.com/sandbox/explorer)

The entire environment is defined as a CDK app to simplify deployments but all Apollo based servers and gateway can be executed locally.

Prerequisites

In order to deploy the federated GraphQL backend services, you need:

  • an active AWS account
  • the AWS CDK
  • a git client
  • the NPM package manager

Deploy the CDK app

To deploy the application, execute the following commands:


$ git clone https://github.com/flochaz/federated-appsync-api-demo
$ cd federated-appsync-api-demo
$ npm run cdk deploy

The deployment will output all required endpoints:

✅ FederatedAppsyncApiDemoStack

Outputs:
FederatedAppsyncApiDemoStack.FederationGatewayEndpointC91B8CA6 = https://AAAAAA.execute-api.eu-west-1.amazonaws.com/prod/ (https://x4gut54tu9.execute-api.eu-west-1.amazonaws.com/prod/)
FederatedAppsyncApiDemoStack.ProductsServiceApiEndpoint496D7B0F = https://BBBBBBB.appsync-api.eu-west-1.amazonaws.com/graphql (https://rbet7v5hpvhrrnhpy3y6rvdzi4.appsync-api.eu-west-1.amazonaws.com/graphql)
FederatedAppsyncApiDemoStack.ReviewsServiceApiEndpoint5B523BB2 = https://CCCC.execute-api.eu-west-1.amazonaws.com/prod/ (https://j0o2bxgtdi.execute-api.eu-west-1.amazonaws.com/prod/)
FederatedAppsyncApiDemoStack.UsersServiceApiEndpoint06FFBE8B = https://DDDDD.execute-api.eu-west-1.amazonaws.com/prod/ (https://t8zp0otrp8.execute-api.eu-west-1.amazonaws.com/prod/)

Take note of the FederationGatewayApiEndpoint, we need it in the next section.

Client query

An easy way to test your setup is to go to https://studio.apollographql.com/sandbox/explorer and set the endpoint to your FederationGatewayApiEndpoint on the top left.

When you see a green dot in the explorer, the Client sends an IntrospectionQuery to the Apollo Gateway which itself calls all services endpoints that have a defined _service query. You can confirm by checking the logs of your different services in Amazon CloudWatch.

2021-10-20T12:57:17.799Z 700dc34e-086d-4577-84fd-b505b43b72e0 INFO event 

{
    "arguments": {},
...
    "info": {
        "fieldName": "*_service*",
        "selectionSetList": [
            "sdl"
        ],
        "selectionSetGraphQL": "{\n  *sdl*\n}",
        "parentTypeName": "Query",
        "variables": {}
    },
    "stash": {}
}

You can try cross boundaries requests, querying for data owned by different services such as :

query Query {
  product(id: "apollo-federation") {
    id
    package
    sku
    reviews {
      content
    }
  }
}

The result should be something similar to:

{
  "data": {
    "product": {
      "id": "apollo-federation",
      "package": "@apollo/federation",
      "sku": "federation",
      "reviews": [
        {
          "content": "awesome !!"
        },
        {
          "content": "awesome 2 !!"
        }
      ]
    }
  }
}

The flow of requests made by the gateway can be traced through Cloudwatch logs or Cloudwatch ServiceMap where you can retrieve the trace of your call captured by AWS X-Ray:

Conclusion

In this blog post we learned, using a concrete example, how to integrate an AWS AppSync API with an Apollo Federation gateway using Apollo Federation specification-compliant queries (such as _entities and _service) and directives (such as @extends and @key). We demonstrated as well how an AWS AppSync API can work both as an independent standalone service or by connecting to external Apollo GraphQL APIs behind a common federated schema.