Front-End Web & Mobile
Apollo GraphQL Federation with AWS AppSync
This article was written by Florian Chazal, Senior Specialist Solutions Architect, AWS
Update (August 2022): This blog post has been updated to comply with the new Apollo Federation spec v2.0. If you are migrating from v1 check the official documentation.
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:
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:
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;
};
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:
We do the same for the User
type and use its email
as primary key:
Finally, all types exposed as entities need to be added to a new _Entity
union.
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:
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:
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 :
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:
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 to link definitions within the document to external schemas with @link
. Thanks to this directive you can import various directives from the federation/v2.0
schema into your namespace such as directive to manage shared fields:
@shareable
indicating that an object type’s field is allowed to be resolved by multiple subgraphs (by default, each field can be resolved by only one subgraph).@inacessible
indicating that a field or type should be omitted from the gateway’s API schema, even if it’s also defined in other subgraphs.@override
indicating that a field is now resolved by this subgraph instead of another subgraph where it’s also defined.
or to reference external fields:
@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:
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 email
, totalProductsCreated
and name
since those fields are declared in the Products service subgraph and use email
as key
:
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:
- Deploy and expose the Products service GraphQL API in AWS AppSync
- Deploy the Users and Reviews service in Apollo Servers hosted in AWS Lambda and exposed by Amazon API Gateway
- Federate the three services schemas with an Apollo Gateway hosted in AWS Lambda and exposed by Amazon API Gateway
- 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:
Deploy the CDK app
To deploy the application, execute the following commands:
$ git clone https://github.com/aws-samples/federated-appsync-api-demo.git
$ 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.
You can try cross boundaries requests, querying for data owned by different services such as :
The result should be something similar to:
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.