Overview
In this article, you will see a practical example of building a unified GraphQL API (supergraph) composed of multiple GraphQL APIs (subgraphs). This is achieved with Apollo Router whose first version was recently released.
The Apollo Router is written in Rust, which provides
better performance over Node.js-based
@apollo/gateway
library (usage of the latter was covered in my previous article). Apollo Router
supports any existing Apollo Federation architecture (v1 or v2). Apollo Router as well as several other Apollo tools
are licensed under the Elastic License v2 (ELv2).
After replacing Apollo Gateway with Apollo Router, the project architecture looks like this:
There are only two changes in the diagram compared to the previous version of the architecture: Apollo Router replaced Apollo Server and the project is no longer deployed to GCP.
In such an architecture, all subgraphs should support Apollo Federation, particularly by providing SDL endpoint (query { _service { sdl } }
).
In the article, I don’t consider enterprise features for Apollo Router.
Configuration
Unlike @apollo/gateway
, the Apollo Router is usually packaged as a static, standalone binary, so the task of launching this tool is reduced to the
configuration of the Apollo Router to customize its behavior, without the need to write custom
code. (Yet writing custom code is possible and will be described in the customization section.)
To make Apollo Router work the same way as the previous @apollo/gateway
-based implementation (but with improved performance), it was needed to create two
files: one — containing supergraph schema and the second — with the
router configuration.
Supergraph schema
As a best practice, a gateway does not compose its supergraph schema (the opposite was the case when using @apollo/gateway
). Instead, a separate process
composes the schema and provides it to the gateway. This helps improve reliability and reduce downtime when you make changes to a subgraph.
There are multiple ways to compose a supergraph schema from subgraph schemas:
The documentation says that the former approach is strongly recommended for production environments but for this demo project, the latter is enough.
Rover CLI needs the following information about each of our subgraphs to compose them:
-
the subgraph’s schema
-
the URL of the subgraph’s GraphQL endpoint (which must be accessible by Apollo Router)
To provide these details to Rover, we define a YAML configuration file:
supergraph.yaml
filefederation_version: =2.3.2
subgraphs:
planets-service:
routing_url: http://planets-service:8080
schema:
subgraph_url: http://localhost:8001
satellites-service:
routing_url: http://satellites-service:8080
schema:
subgraph_url: http://localhost:8002
auth-service:
routing_url: http://auth-service:8080
schema:
subgraph_url: http://localhost:8003
-
routing_url
is the URL the Apollo Router will use to send GraphQL operations to a subgraph at runtime -
schema.subgraph_url
is the URL that Rover will use to fetch the subgraph schema during the composition
The URLs may or may not be the same; in this case, the URLs differ. For example, planets-service.schema.subgraph_url
is http://localhost:8001
because I
have Rover CLI installed locally, and it fetches SDL endpoint ({ _service { sdl } }
) of planets-service
which is deployed in the Docker environment and
accessible from localhost
(by port publishing), but routing_url
is http://planets-service:8080
because at runtime, in the Docker environment, Apollo
Router calls services by their names.
Also, an exact federation_version
is specified accordingly to the docs.
Before using Rover CLI, all three Rust microservices in the project should be started. The simplest way to do it is by executing docker compose up --build
(the launch of the project was covered in detail in the previous post).
To generate a file containing supergraph schema, the following command is performed inside the gateway
directory:
rover supergraph compose --config ./supergraph.yaml --output supergraph.graphql
The output file generated by the command above will be used by Apollo Router.
More information on configuring Rover CLI is available in the documentation.
Note: Apollo Router doesn’t support subscriptions. Even though the output file contains a definition of one provided by planets-service
and that subscription
is discoverable in a GraphQL client, it cannot be executed.
Router configuration
The second file we need to start Apollo Router is the router.yaml
:
router.yaml
filesupergraph:
listen: 0.0.0.0:4000
introspection: true
sandbox:
enabled: true
homepage:
enabled: false
headers:
all:
request:
- remove:
named: .*
- insert:
name: "role"
from_context: "user_role"
plugins:
demo.jwt_validation:
secret_key: ${env.JWT_SECRET_KEY}
Here are set up:
-
configuration of the HTTP server: socket address and port to listen on
-
enabling introspection required for the sandbox to work
-
enabling sandbox (we will use this in the Testing section) and disabling homepage (you can only enable one of these)
-
header propagation: Apollo Router will remove all headers from an original GraphQL request and insert
role
header from context to requests to all subgraphs (inserting a user’s role to context is performed by a custom Apollo Router plugin; it will be shown later) -
configuration of a custom Apollo Router plugin
See the documentation for more configuration options.
The usage of supergraph schema and router configuration files will be shown in the launch section.
Customization. Implementation of a plugin
To implement functionality that is not included in the default Apollo Router distribution, you can write customizations. Apollo Router supports two types of customizations:
-
External co-processing (Enterprise feature)
Previously, the list also included Native Rust plugins option. Currently, the documentation says that creating Native Rust plugins is not a recommended customization option.
I decided to keep the plugin described below because its functionality requires access to some Rust crates. The plugin performs authorization: it checks whether JWT is specified in an incoming GraphQL request, validates it, extracts a user’s role, and puts it into the context. The implementation of the plugin made it possible to remove authorization code from the microservices which no longer need to work with JWTs and can obtain a user’s role from the header if it is passed by the plugin.
Please note that this example is a demonstration of how to create Native Rust plugin for Apollo Router; for production use of JWT-based authentication and authorization, do more research. Particularly, you can familiarize yourself with "JWT Authentication in the Apollo Router" (it is an enterprise feature).
Native Rust plugins require building a custom Apollo Router binary that includes your
plugin code. Before the development of the plugin, I needed to install protoc
/protobuf-compiler
. It is also included in a Dockerfile
created to launch
the binary in the Docker environment:
RUN apt-get update && apt-get install -y protobuf-compiler
Next, we need to specify a dependency:
apollo-router
dependency[dependencies]
...
apollo-router = "1.13.0"
...
An entry point of the application looks like this:
use anyhow::Result;
use dotenv::dotenv;
mod jwt_validation;
fn main() -> Result<()> {
dotenv().ok();
apollo_router::main()
}
The main code of the plugin is in the jwt_validation
module.
All plugins require an associated configuration; in this project, the configuration takes just one parameter:
#[derive(Deserialize, JsonSchema)]
struct JwtValidationConfig {
secret_key: String,
}
The secret_key
field will be populated with the value of the corresponding parameter defined in the router.yaml
configuration file which was shown earlier. This parameter specifies a
JWT secret that will be used to decode and verify JWT; it has the same value as in
auth-service
where JWTs are generated.
Next, we define the plugin itself and specify the configuration as an associated type:
#[derive(Debug)]
struct JwtValidation {
secret_key: String,
}
#[async_trait::async_trait]
impl Plugin for JwtValidation {
type Config = JwtValidationConfig;
async fn new(init: PluginInit<Self::Config>) -> Result<Self, BoxError> {
Ok(JwtValidation {
secret_key: init.config.secret_key,
})
}
...
}
All router plugins must implement the Plugin
trait. This trait defines lifecycle hooks that enable hooking into Apollo Router services and provides a
default implementation for each hook, so we can only define the hooks that are associated with Apollo Router services we need to customize. In this project,
according to the documentation, it is SupergraphService
, so it is needed to define the
supergraph_service
function:
supergraph_service
function#[async_trait::async_trait]
impl Plugin for JwtValidation {
...
fn supergraph_service(&self, service: supergraph::BoxService) -> supergraph::BoxService {
let jwt_secret_key = self.secret_key.clone();
fn failure_message(
context: Context,
message: String,
status: StatusCode,
) -> Result<ControlFlow<supergraph::Response, supergraph::Request>, BoxError> {
let response = supergraph::Response::error_builder()
.error(
graphql::Error::builder()
.message(message)
.extension_code("AUTH_ERROR")
.build(),
)
.status_code(status)
.context(context)
.build()?;
Ok(ControlFlow::Break(response))
}
let handler = move |request: supergraph::Request| {
let auth_header_value_result =
match request.supergraph_request.headers().get(AUTHORIZATION) {
Some(auth_header_value) => auth_header_value.to_str(),
// in this project, I decided to allow the passage of requests without the AUTHORIZATION header. then each subgraph performs authorization based on the ROLE header
// your case may be different. be careful
None => return Ok(ControlFlow::Continue(request)),
};
let auth_header_value_untrimmed = match auth_header_value_result {
Ok(auth_header_value) => auth_header_value,
Err(_not_a_string_error) => {
return failure_message(
request.context,
"'AUTHORIZATION' header is not convertible to a string".to_string(),
StatusCode::BAD_REQUEST,
)
}
};
let auth_header_value = auth_header_value_untrimmed.trim();
if !auth_header_value
.to_uppercase()
.as_str()
.starts_with("BEARER ")
{
return failure_message(
request.context,
format!("'{auth_header_value_untrimmed}' is not correctly formatted"),
StatusCode::BAD_REQUEST,
);
}
let auth_header_value_parts: Vec<&str> = auth_header_value.splitn(2, ' ').collect();
if auth_header_value_parts.len() != 2 {
return failure_message(
request.context,
format!("'{auth_header_value}' is not correctly formatted"),
StatusCode::BAD_REQUEST,
);
}
let jwt = auth_header_value_parts[1].trim_end();
match decode_jwt(&jwt, &jwt_secret_key) {
Ok(token_data) => {
let role = token_data.claims.role;
debug!("User role is: {}", &role);
if let Err(error) = request.context.insert(ROLE_CONTEXT_PARAM_NAME, role) {
return failure_message(
request.context,
format!("Failed to pass a user's role: {}", error),
StatusCode::INTERNAL_SERVER_ERROR,
);
}
Ok(ControlFlow::Continue(request))
}
Err(e) => {
let error_message = format!("JWT is invalid: {}", e);
return failure_message(
request.context,
error_message,
StatusCode::BAD_REQUEST,
);
}
}
};
ServiceBuilder::new()
.checkpoint(handler)
.service(service)
.boxed()
}
}
To define custom logic for a service hook, you can use ServiceBuilder
. ServiceBuilder
provides common building blocks that remove much of the complexity of
writing a plugin. These building blocks are called layers. In the given implementation of a plugin, I use checkpoint
layer. It performs a sync call to decide whether a request
should proceed, specifically, checks whether JWT is specified in the Authorization header and if yes, is it valid or not (based on a result of the decode_jwt
function); if the
JWT is valid, the function inserts a decoded role to a request’s context. Later, it will be used to insert role
header to a GraphQL request as was shown in
router.yaml
. Further, this header can be used in downstream services to check whether a user is
allowed to access a particular GraphQL operation.
To enable the Apollo Router to discover your plugin, you need to register the plugin:
register_plugin!("demo", "jwt_validation", JwtValidation);
The macro takes 3 arguments: a group name, a plugin name, and a struct implementing the Plugin
trait.
You can find more examples of the implementation of plugins in the Apollo Router repository.
Launch
The launch of the whole project with Docker Compose was described in the previous article. In this section, I’ll focus on how to launch a customized Apollo Router. It can be done in two ways:
-
binary
-
Docker image
In its turn, the container can be started in two ways:
-
base configuration (it uses a pre-created image that is built using GitHub Actions)
docker-compose -f docker-compose.yml up
Listing 10.gateway
service definition in Docker Compose fileversion: '3.9' services: ... gateway: image: kudryashovroman/graphql-rust:gateway container_name: gateway restart: always depends_on: - planets-service - satellites-service - auth-service environment: APOLLO_ROUTER_SUPERGRAPH_PATH: /gateway/schema/supergraph.graphql APOLLO_ROUTER_CONFIG_PATH: /gateway/config/router.yaml APOLLO_ROUTER_LOG: debug APOLLO_TELEMETRY_DISABLED: true JWT_SECRET_KEY: $JWT_SECRET_KEY volumes: - ./gateway/supergraph.graphql:/gateway/schema/supergraph.graphql - ./gateway/router.yaml:/gateway/config/router.yaml ports: - 4000:4000 ...
-
development configuration (the image will be built locally)
docker-compose up --build
Listing 11. Apollo Router service in Docker Compose fileversion: '3.9' services: ... gateway: build: context: . dockerfile: ./gateway/Dockerfile
-
Apollo Router will start with two configuration files which generation was described in the Configuration section.
Just in case, I’ll give an example of a launch standard (not customized) image:
version: '3.9'
services:
...
gateway:
image: ghcr.io/apollographql/router:v1.13.0
container_name: gateway
restart: always
depends_on:
- planets-service
- satellites-service
- auth-service
environment:
APOLLO_ROUTER_SUPERGRAPH_PATH: /gateway/schema/supergraph.graphql
APOLLO_ROUTER_CONFIG_PATH: /gateway/config/router.yaml
APOLLO_ROUTER_LOG: debug
APOLLO_TELEMETRY_DISABLED: true
volumes:
- ./gateway/supergraph.graphql:/gateway/schema/supergraph.graphql
- ./gateway/router.yaml:/gateway/config/router.yaml
ports:
- 4000:4000
...
In this case, it is also required that router.yaml
does not contain the configuration properties needed for the custom plugin.
After the application start, you can execute GraphQL operations against localhost:4000
.
Testing
For testing, you can use an embedded GraphQL IDE — Apollo Sandbox — or any other third-party IDE. To
open the sandbox, navigate to localhost:4000
in your browser.
Consolidated API looks like this:
When executing the above query, Apollo Router retrieves the data from planets-service
and satellites-service
subgraphs.
Next, I’ll try to check how the custom plugin works by calling the createPlanet
GraphQL mutation that requires authorization. The mutation is protected
by RoleGuard
which specifies that it can be called only by users
with the Admin role. The request I’ll use for testing is:
mutation {
createPlanet(
planet: {
name: "Pluto"
type: DWARF_PLANET
details: { meanRadius: "1188", mass: "1.303e22" }
}
) {
id
}
}
If the Authorization header is not specified at all, then the request passes through the router to planets-service
, and the response is:
Subgraph errors redacted
is the error message Apollo Router replaces errors in subgraphs with. This is the default behavior that prevents potential leaks
of information to a client. There is a plugin that makes it possible to
configure the router to propagate subgraph errors to a client.
If you’ll try to execute the request without the Authorization header against localhost:8001
where GraphQL API of planets-service
is located, you’ll see
the actual response from that service:
The next case we consider is when the Authorization header contains an expired JWT:
You can see that the plugin didn’t allow the request to pass to planets-service
because the JWT was generated quite a long time ago.
Now I’ll try to pass an invalid JWT by adding some symbol (for example, a letter) to the end of the token:
Finally, if a JWT was generated no earlier than an hour ago and everything else is fine, then the response looks like this:
To generate JWT, you can use the following example:
signIn
mutationmutation {
signIn(input: { username: "john_doe", password: "password" })
}
Possible alternatives
There are alternatives to Apollo Router, i.e. tools that can consolidate multiple GraphQL APIs into one. Here is a list of the ones I know:
-
graphql-go-tools (Go)
-
nautilus/gateway (Go)
-
GraphGate (Rust)
-
@apollo/gateway (JavaScript/Node.js)
Please let me know if there is a gateway not included in the list.
Conclusion
In this article, I showed how to configure, customize, and launch Apollo Router which allows combining multiple GraphQL APIs into a unified supergraph. Apollo
Router provides better performance and developer experience and is easier to manage than @apollo/gateway
. Also, worth noting is the lack of support for
subscriptions. Feel free to contact me if you have found any mistakes in the article or the source code. Thanks for reading!
Useful links
-
Apollo Router docs
-
Apollo Rover CLI docs
-
Apollo Federation docs
-
@apollo/gateway
to Apollo Router migration guide -
Examples of how to use and extend the Apollo Router
-
My previous article on how to build GraphQL microservices in Rust