Beyond Apollo Federation - How to use Composite Schemas and Extensions to integrate non-GraphQL data sources

Benjamin RabierBenjamin Rabier

Beyond Apollo Federation - How to use Composite Schemas and Extensions to integrate non-GraphQL data sources

GraphQL Federation is extending its reach to non-GraphQL data sources, every major GraphQL Federation vendor offers a solution to integrate at least REST APIs without the need for an intermediate GraphQL subgraph: Grafbase Extensions, Apollo REST connectors and Cosmo gRPC plugins.

In this post we'll focus on the REST APIs case, they are two fundamental challenges for federating them:

  1. Two REST APIs never look alike. It's common to have a set of guidelines within a company, but REST endpoints from different companies have always small but numerous differences between them. This makes it hard to design a good generic solution for everyone and thus requires strong customization capabilities.
  2. One of the core promise of GraphQL Federation is the ability to seamlessly join between data sources, named subgraphs. One quickly faces the challenge of non only defining those joins and their requirements if at all possible, but also their performance with the N+1 problem. In short, the N+1 problem is a common challenge of GraphQL APIs, you load a first item which has a list of N sub-items. Do you gather all those N sub-items in a single request or in N requests? The latter obviously poses significant problems as your GraphQL query grows.

In this post we will show you how Grafbase Extensions and Composite Schemas work, how to create a simple example and finally we'll see how it compares to other solutions. All the examples can be found here which you can run locally.

To solve the first problem we have not only built a REST extension similar to Apollo REST connectors but a complete resolver framework. We also provide extensions for gRPC, subscriptions for Kafka, NATS, and Postgres which is similar to Hasura or PostGraphile. All of them and more can be installed from the Extensions Marketplace. In this section we'll go over the REST extension first and then show you how to create your own.

For simple cases, no need to reinvent the wheel. You can rely on the REST extension. Similar to Apollo REST connectors everything starts by defining a new GraphQL schema for a dedicated subgraph. First we import our directives and define the mapping from GraphQL to REST:

extend schema @link( url: "https://grafbase.com/extensions/rest/0.5.0", import: ["@restEndpoint", "@rest"]) @restEndpoint( name: "countries", baseURL: "https://restcountries.com/v3.1") type Country { name: String! } type Query { listAllCountries: [Country!]! @rest( endpoint: "countries" http: { GET: "/all?fields=name" } # jq logic to apply to the response selection: "[.[] | { name: .name.official }]" ) }

This subgraph can then be published like any GraphQL subgraph. Composition rules and any custom checks will be applied, you can be sure the federated supergraph will be consistent. However, the gateway doesn't download arbitrary WebAssembly modules, they must be explicitly specified in the TOML configuration:

[extensions.rest] version = "0.5.0"

Install it with the following command with the Grafbase CLI:

grafbase extension install

The Grafbase Gateway will ensure that a matching extension is installed for all the subgraph imports.

The REST extension provides an easy way to start and is enough for many use cases. But sometimes you may need to go further. Maybe you need to send or receive data in a different format than JSON? Or you need to rename every field from snake case to camelCase? The list goes on. Taking everything into account would make the REST extension as complex as the OpenAPI spec. Instead, we provide a framework for you to develop your own directives so that you can try to leverage your internal conventions.

Now let's see how a resolver extension actually looks like. First, bootstrap the project with the Grafbase CLI:

grafbase extension init --type resolver geo

This creates a Rust project using the Grafbase SDK. The extension.toml file defines the extension's metadata such as its name and type. We also a dummy integration test prepared that starts the Grafbase Gateway.

geo ├─ src │ └─ lib.rs ├─ tests │ └─ integration_tests.rs ├─ Cargo.toml ├─ Cargo.lock └─ extension.toml

The src/lib.rs containing the business logic. The extension must implement the ResolverExtension trait which has several methods:

use grafbase_sdk::{ ResolverExtension, types::{Configuration, Error, ResolvedField, Response, SubgraphHeaders, SubgraphSchema, Variables}, }; #[derive(ResolverExtension)] struct Geo { config: Config } // Configuration in the TOML for this extension #[derive(serde::Deserialize)] struct Config { #[serde(default)] key: Option<String> } impl ResolverExtension for Geo { fn new(subgraph_schemas: Vec<SubgraphSchema>, config: Configuration) -> Result<Self, Error> { let config: Config = config.deserialize()?; Ok(Self { config }) } fn resolve( &mut self, prepared: &[u8], headers: SubgraphHeaders, variables: Variables, ) -> Result<Response, Error> { // field which must be resolved. The prepared bytes can be customized to store anything you need in the operation cache. let field = ResolvedField::try_from(prepared)?; Ok(Response::null()) } }
  • new() is called when the extension is loaded with the configuration and the relevant subgraph GraphQL schemas.
  • prepare() has a default implementation so you don't need to worry about it, but it allows you to prepare and cache anything you may need as part of the operation cache. It'll only be called if the plan wasn't retrieved from the cache.
  • resolve() will contain your business logic, you can retrieve the field and directive arguments and the headers propagated through Header rules or with an authorization extension. You can also traverse the requested selection set to build a query like our postgres extension.
  • last but not least, resolve_subscription can be used to implement a subscription, used by our kafka and nats extensions.

We'll showcase a simple example later on. But first, let's make a detour to the composite schema spec.

The Composite Schemas specification is a new and improved specification for GraphQL federation built under the umbrella of the GraphQL Foundation. One of its important design decision is to embed all of the federation logic inside the schema explicitly. In contrast, Apollo federation relies on a hidden field _entities which requires dedicated subgraph support.

We will present the most important directives for joining data sources in the context of extensions and our changes.

Similar to Apollo entities are defined with a @key directive:

type User @key(fields: "id") { id: ID! name: String! birthDate: DateTime }

But there isn't any way to retrieve it yet though! We need to explicitly add a @lookup field:

type Query { user(id: ID!): User @lookup }

The key is automatically inferred from the arguments. In case of ambiguity you can define it explicitly with @is:

type Query { user(id: ID!): User @lookup @is(field: "id") }

The mapping can be quite complex, see FieldSelectionMap for more information.

Lastly, fields can also express requirements with @require:

type Query { age(birthDate: DateTime @require(field: "birthDate")): Int }

The supergraph will automatically inject birthDate argument behind the scenes. The API clients will see will be:

type Query { age: Int }

Both @lookup and @require directives make it possible to compose schemas without relying on any dedicated Subgraph support. Unfortunately, that wasn't enough for us. In particular for batching, the composite schemas spec relies on a new feature named variable batching to solve a problem of nested requirements. For example, if the age field was coming from a different subgraph and you wanted to retrieve it for multiple users, then you will obviously need to send the birth date for each of them. But you can't express it today in a GraphQL query like:

query { # single lookup user(id: $id) { age(birthDate: $birthDate) } # batch lookup doesn't work with the nested requirement. users(ids: $ids) { age(birthDate: $birthDate) } }

For GraphQL subgraphs the composite schema spec solves it with variable batching where we send a single graph query with a list of variables. So in this case something like:

{ "query": "query { users(ids: $ids) { age(birthDate: $birthDate) } }", "variables": [ {"id": "1", "birthDate": "2000-01-01"}, {"id": "2", "birthDate": "2000-01-01"} ] }

Data loaders would make this query efficient, like a proper batch lookup.

While this works, it's significantly more complex than having a lookup field that just returns a list. And nested requirements like those isn't something you can express with our extensions. So instead, we chose to enhance the composite schemas spec to make it as easy and simple as possible to declare non-GraphQL data sources with our extensions.

Today we made two changes to the composite schemas spec: A simpler batching mechanism and a new @derive directive.

Batching is automatically detected for @lookup when the output is a list:

type Query { # Retrieve users by their ID in batch users(ids: [ID!]!): [User!]! @lookup } type User @key(fields: "id") { id: ID! birthDate: DateTime! }

The key/entity match works in the same as Apollo Federation's _entities field, by position. If an entity doesn't exist, null should be returned. Later we may add more advanced matching mechanism.

It also works for @require, but is a bit more complex to follow:

type User { age(birthDates: [DateTime]! @require(field: "[birthDate]")): [Int]! }

The final GraphQL API will still be:

type Query { age: Int }

But the resolver resolving the age field will be called once with all the birth dates. We'll present a few examples just after.

Lastly, we have a @derive directive which makes joining through entities a lot easier. A typical REST endpoints would return ids rather than objects:

type Post { author_id: ID! reviewer_ids: [ID!] }

This doesn't allow retrieving the User entity, there is no author object to add fields to! Without @derive you would need to manually create those objects which can become more or less complex. As this is a very common pattern, we added @derive for it:

type Post { author_id: ID! author: User! @derive reviewer_ids: [ID!] reviewers: [User!]! @derive }

@derive will automatically create the user objects from the id fields. It relies on the @key directive and the field name to identify relevant fields. Similar to @lookup you can be explicit about the mapping an even hide the id fields:

type Post { author_id: ID! @inaccessible author: User! @derive @is(field: "{ id: author_id }") reviewer_ids: [ID!] @inaccessible reviewers: [User!]! @derive @is(field: "reviewer_ids[{ id: . }]") }

An example using all of those features on top of Zendesk Sales CRM API can be found here part of the complete example repo for this post.

Now let's make a simple example relying on public endpoint. We are going to use an open data endpoint from the French government that exposes data on municipalities, departments and regions. We'll focus on three endpoints:

  • /communes/{code}: Municipality by code.
  • /departements/{code}: Department by code.
  • /regions/{code}: Region by code.

All of them have a format and fields query argument which specifies how and what to retrieve. We could use the REST extension, but we would not be able to only retrieve exactly the fields we need. And we would repeat ourselves as the end points are fairly similar. A sensible GraphQL schema would be following:

type Query { commune(code: String): Commune departement(code: String): Departement region(code: String): Region } type Commune { code: String! region: Region! departement: Departement! nom: String! # name population: Int! } type Region { code: String! nom: String! # name chefLieu: String! # capital } type Departement { code: String! nom: String! # name region: Region! chefLieu: String! # capital }

Evidently the endpoints are very similar, so we can rely on it to simplify our extension:

#[derive(ResolverExtension)] struct Geo { url: Url, subgraph_schemas: Vec<SubgraphSchema>, } impl ResolverExtension for Geo { fn new(subgraph_schemas: Vec<SubgraphSchema>, _config: Configuration) -> Result<Self, Error> { // We store the subgraph schemas for later use. Ok(Self { url: "https://geo.api.gouv.fr/".parse().unwrap(), subgraph_schemas, }) } fn resolve(&mut self, prepared: &[u8], _headers: SubgraphHeaders, variables: Variables) -> Result<Response, Error> { // field which must be resolved. The prepared bytes can be customized to store anything you need in the operation cache. let field = ResolvedField::try_from(prepared)?; // We retrieve the subgraph schema for the current field. let schema = self .subgraph_schemas .iter() .find(|schema| schema.subgraph_name() == field.subgraph_name()) .unwrap(); // Extract the arguments for the current field. let FieldArguments { code } = field.arguments(&variables)?; let mut request = HttpRequest::get( self.url .join(&format!("{}s/{}", field.definition(schema).name(), code)) .unwrap(), ); request .url() .query_pairs_mut() .append_pair( "fields", // Only retrieve the fields requested by the client &field .selection_set() .fields() .map(|field| field.definition(schema).name()) .join(","), ) .append_pair("format", "json"); let response = http::execute(request)?; if response.status().is_success() { // Send the raw JSON bytes back to the gateway Ok(Response::json(response.into_bytes())) } else if response.status().as_u16() == 404 { Ok(Response::null()) } else { Err(Error::new("Geo API request failed")) } } } #[derive(serde::Deserialize)] struct FieldArguments { code: String, }

To make extension development as easy as possible, We also provide a test framework relying on the real gateway binary:

#[tokio::test] async fn test_example() { let gateway = TestGateway::builder() .subgraph( r#" extend schema @link(url: "<self>", import: ["@geo"]) type Query { commune(code: String): Commune @geo } type Commune { code: String codeRegion: String codeDepartement: String nom: String population: Int } "#, ) .build() .await .unwrap(); let response = gateway .query(r#"query { commune(code: "75101") { nom codeRegion codeDepartement } }"#) .send() .await; insta::assert_json_snapshot!(response, @r#" { "data": { "commune": { "nom": "Paris 1er Arrondissement", "codeRegion": "11", "codeDepartement": "75" } } } "#); }

With this extension, we can now define the following schema:

extend schema @link( url: "file:///var/lib/grafbase/extensions/geo/build" import: ["@geo"] ) type Query { commune(code: String): Commune @geo departement(code: String): Departement @geo region(code: String): Region @geo } type Commune { code: String! codeRegion: String! codeDepartement: String! nom: String! population: Int! } type Region { code: String! nom: String! chefLieu: String! } type Departement { code: String! nom: String! codeRegion: String! chefLieu: String! }

It's already super useful, but as you might have noticed, it's not exactly the same as what we initially wanted. We don't have any Commune.region field for example. This is where the composite schema specs comes in, we'll declare Region as an entity:

extend schema @link( url: "https://specs.grafbase.com/composite-schemas/v1" import: ["@lookup", "@key", "@is", "@derive"] ) type Query { commune(code: String): @geo Commune region(code: String): Region @geo @lookup } type Commune { codeRegion: String! region: Region! @derive @is(field: "{ code: codeRegion }") } type Region @key(fields: "code") { code: String! nom: String! chefLieu: String! } type Departement { codeRegion: String! region: Region! @derive @is(field: "{ code: codeRegion }") }

For the Department, we'll use @require as an alternative as we can reuse our resolver:

type Commune { codeDepartement: String! departement(code: String! @require(field: "codeDepartement")): Departement! @geo }

Now all put together, with the @inaccessible directive That lets us hide fields from the client, we have:

extend schema @link( url: "file:///var/lib/grafbase/extensions/geo/build" import: ["@geo"] ) @link( url: "https://specs.grafbase.com/composite-schemas/v1" import: ["@lookup", "@key", "@is", "@inaccessible", "@derive", "@require"] ) type Query { commune(code: String): Commune @geo departement(code: String): Departement @geo region(code: String): Region @geo @lookup } type Commune { code: String! codeRegion: String! @inaccessible region: Region! @derive @is(field: "{ code: codeRegion }") codeDepartement: String! @inaccessible departement(code: String! @require(field: "codeDepartement")): Departement! @geo nom: String! population: Int! } type Region @key(fields: "code") { code: String! nom: String! chefLieu: String! } type Departement { code: String! nom: String! codeRegion: String! @inaccessible region: Region! @derive @is(field: "{ code: codeRegion }") chefLieu: String! }

The complete example can be found here. A significantly more complex example relying on batching is also implemented for the Zendesk Sell CRM API in the same repository in subgraphs/zendesk

A first and important difference is that while we focus on resolvers here, Grafbase Extensions go way behind that with authentication, authorization, hooks, and more.

Apollo REST connectors are similar to the REST extension. In both cases, you define a subgraph, add directives and the gateway/router will do the appropriate requests. However, the batching and entities are baked in to the Apollo REST connectors directives. Whereas for Grafbase extensions, they are independent of the extension directives. Everything is specified through Composite Schemas directives. This is a result of a fundamental difference with extensions. We have built an extension framework, not just a single extension. This allowed us to provide extensions for Kafka, Postgres, NATS, and more. But also offers users the possibility to design a new one or modify an existing extension with the features and directives you need.

Another important difference, is that our extensions aren't tied to the gateway release cycle. You can upgrade your gateway without breaking any extensions, and iterate on extensions without changing the gateway.

Cosmo gRPC plugins are a very different take on integrating non-GraphQL data sources. A gRPC service protobuf is generated from a GraphQL schema that you define and the Cosmo Router will communicate through gRPC with your code.

For entities a batch lookup method is automatically generated. This provides more flexibility in terms of accessible data sources compared to Apollo and relies on more mature technologies than our WebAssembly extensions.

There are a few shortcomings today such as RPC methods are only generated for Query andMutation fields and entity lookups. That may sound fine at first glance, but more often than not you'll have nested data that you need to expose. For example, in the Zendesk example, while a LineItem has an id, it is not an entity. It can only be retrieved through the order id:

type Order { id: ID! createdAt: DateTime! deal_id: ID! @inaccessible deal: Deal! @derive lineItems(id: ID! @require(field: "id")): [LineItem!]! @rest( endpoint: "zendesk" http: { GET: "/orders/{{args.id}}/line_items" } selection: """ [.items[] | .data | { id, product_id, quantity }] """ ) }

But the most important difference, is that Cosmo gRPC plugins are dedicated to a single subgraph. So instead of writing a GraphQL server, you are writing a gRPC service with additional layers to convert it back to GraphQL. This has the consequence of tying your subgraphs to your router deployment as they're always deployed together at time of writing. If you have tens or hundreds of subgraphs, this will quickly become complex to deploy, observe and maintain.

On the other hand, with Grafbase Extensions, supergraph owners are responsible for providing the right extensions, but subgraph deployments are completely separate from the gateway. Multiple versions of the same extension can live side by side for migration purposes. Evidently extensions can also be a challenge to scale and observe, but our goal is to enable users to build extensions tailored to specific needs to accelerate adoption of GraphQL Federation.

Get Started

Start building your federated graph now.