July 7, 2022

Building a unified GraphQL API with Apollo Router

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 which recently became generally available.

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). Please note that pre-1.0 versions are not yet "semver stable" and new versions may introduce breaking changes. 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:

architecture

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 } }).

The source code of the project is available on GitHub and GitLab.

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 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 own 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.

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 apollo-router directory:

rover supergraph compose --config supergraph.yaml > supergraph.graphql

The command uses the following config:

Listing 1. supergraph.yaml
federation_version: 2

subgraphs:
  planets-service:
    routing_url: http://planets-service:8001
    schema:
      subgraph_url: http://localhost:8001
  satellites-service:
    routing_url: http://satellites-service:8002
    schema:
      subgraph_url: http://localhost:8002
  auth-service:
    routing_url: http://auth-service:8003
    schema:
      subgraph_url: http://localhost:8003
  • routing_url is the URL a gateway 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 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:8001 because at runtime, in the Docker environment, Apollo Router calls services by their names.

More information on configuring the router 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:

Listing 2. The router.yaml file
server:
  listen: 0.0.0.0:4000
  landing_page: false

headers:
  all:
    - propagate:
        named: role

plugins:
  demo.jwt_validation:
    secret_key: ${JWT_SECRET_KEY}

Here are set up:

  • configuration of the HTTP server: socket address and port to listen on and disabled landing page

  • header propagation: Apollo Router will include the role header to requests to all subgraphs (the role header is created by a custom Apollo Router plugin; its creation will be covered later)

  • configuration of a custom Apollo Router plugin

There are other configuration options, for example, a particular subgraph’s routing URL can be overridden with the override_subgraph_url option.

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:

In this project, I used the first option; the created plugin performs authorization: it checks whether JWT is specified in an incoming GraphQL request, validates it, extracts a user’s role, and passes the role in a header to downstream microservices. 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 an Apollo Router plugin; for production use of JWT-authorization and authentication, do more research.

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 npm and rustfmt. These tools are also included in a Dockerfile created to launch the binary in the Docker environment:

Listing 3. Specifying dependencies required to build custom Apollo Router binary
RUN rustup component add rustfmt
RUN apt-get update && apt-get install -y npm

Next, we need to specify a dependency; at the time of publishing this article, the latest versions of apollo-router are not available on crates.io, so I use a link to the repository and also specify a tag of the latest version:

Listing 4. Specifying apollo-router dependency
[dependencies]
...
apollo-router = { git = "https://github.com/apollographql/router", tag = "v0.14.0" }
...

Please note that pre-1.0 versions are not yet "semver stable" and new versions may introduce breaking changes.

An entry point of the application looks like this:

Listing 5. The entry point of the application
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:

Listing 6. Configuration of the plugin
#[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:

Listing 7. Definition of the plugin
#[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 RouterService, so it is needed to define the router_service function:

Listing 8. Definition of router_service function
#[async_trait::async_trait]
impl Plugin for JwtValidation {

    ...

    fn router_service(
        &self,
        service: BoxService<RouterRequest, RouterResponse, BoxError>,
    ) -> BoxService<RouterRequest, RouterResponse, BoxError> {
        let jwt_secret_key = self.secret_key.clone();
        ServiceBuilder::new()
            .checkpoint(move |mut request: RouterRequest| {
                let headers = request.originating_request.headers_mut();
                let maybe_auth_header_value = headers.get(AUTHORIZATION_HEADER_NAME);

                let jwt = match maybe_auth_header_value {
                    Some(auth_header_value) => {
                        let auth_header_value_str = auth_header_value.to_str()?;
                        get_jwt_from_header_value(auth_header_value_str).to_string()
                    }
                    None => return Ok(ControlFlow::Continue(request)),
                };

                match decode_jwt(&jwt, &jwt_secret_key) {
                    Ok(token_data) => {
                        let role = token_data.claims.role;
                        debug!("User role is: {}", &role);
                        request.context.insert(ROLE_CONTEXT_PARAM_NAME, role)?;

                        Ok(ControlFlow::Continue(request))
                    }
                    Err(e) => {
                        let response = RouterResponse::error_builder()
                            .error(apollo_router::graphql::Error {
                                message: format!("JWT is invalid: {}", e),
                                ..Default::default()
                            })
                            .status_code(StatusCode::BAD_REQUEST)
                            .context(request.context)
                            .build()?;

                        Ok(ControlFlow::Break(response))
                    }
                }
            })
            .map_request(|mut request: RouterRequest| {
                let maybe_user_role: Option<String> = request
                    .context
                    .get(ROLE_CONTEXT_PARAM_NAME)
                    .expect("This should not return an error");

                if let Some(user_role) = maybe_user_role {
                    request.originating_request.headers_mut().insert(
                        ROLE_HEADER_NAME,
                        user_role
                            .try_into()
                            .expect("Role should always be converted to HeaderValue"),
                    );
                }

                request
            })
            .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, two layers are used: checkpoint and map_request. The first 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 to use in a next layer. If a role is presented in the context, the map_request layer adds the role header to the request, and further this header can be used in downstream services to check whether a user has rights to access a particular GraphQL operation.

It would also be possible to add the role header in the checkpoint layer; it is done in the map_request layer for illustration of using a request’s context. The context could also be used to pass custom data between services in the router; in the considered example, a user’s role could be passed between RouterService and SubgraphService so that each SubgraphService responsible for a certain downstream service could decide whether to include the role to headers or to process the role in some other way.

To enable the Apollo Router to discover your plugin, you need to register the plugin:

Listing 9. Plugin registration
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; specifically, there is a more sophisticated example of JWT-based authentication.

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

    use cargo run inside the apollo-router directory

  • 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. Apollo Router service in Docker Compose file
      version: '3.9'
      services:
        ...
      
        apollo-router:
          image: kudryashovroman/graphql-rust:apollo-router
          container_name: apollo-router
          restart: always
          depends_on:
            - planets-service
            - satellites-service
            - auth-service
          environment:
            APOLLO_ROUTER_SUPERGRAPH_PATH: /apollo-router/schema/supergraph.graphql
            APOLLO_ROUTER_CONFIG_PATH: /apollo-router/config/router.yaml
            APOLLO_ROUTER_LOG: debug
            JWT_SECRET_KEY: $JWT_SECRET_KEY
          volumes:
            - ./apollo-router/supergraph.graphql:/apollo-router/schema/supergraph.graphql
            - ./apollo-router/router.yaml:/apollo-router/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 file
      version: '3.9'
      services:
        ...
      
        apollo-router:
          build:
            context: .
            dockerfile: ./apollo-router/Dockerfile

In all cases, the application takes configuration parameters from configuration files using dotenv crate, so you don’t need to explicitly specify them (otherwise, it would be something like cargo run — -s ./supergraph.graphql -c ./router.yaml (in case of running not in the Docker environment)). 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 (you can also find the source in the commit history of the project):

Listing 12. Launch of standard Apollo Router image
version: '3.9'
services:
  ...

  apollo-router:
    image: ghcr.io/apollographql/router:v0.10.0
    container_name: apollo-router
    restart: always
    depends_on:
      - planets-service
      - satellites-service
      - auth-service
    volumes:
      - ./apollo-router/supergraph.graphql:/dist/schema/supergraph.graphql
      - ./apollo-router/router.yaml:/dist/config/router.yaml
    command: [ "-c", "config/router.yaml", "-s", "schema/supergraph.graphql", "--log", "info" ]
    ports:
      - 4000:4000

  ...

In this case, it is also required that router.yaml does not contain the configuration of the custom plugin.

After the application start, you can execute GraphQL operations against localhost:4000.

Testing

Apollo Router doesn’t include an embedded GraphQL IDE, such as Playground; you can use a third-party IDE, such as Altair. Also, if you enable the landing_page option described in the Router configuration section you can navigate to http://localhost:4000 and click Query your server; after that, you will be redirected to https://studio.apollographql.com/sandbox/explorer.

Consolidated API looks like this:

altair

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:

Listing 13. Call of GraphQL mutation that requires authorization
mutation {
  createPlanet(
    planet: {
      name: "Pluto"
      type: DWARF_PLANET
      details: { meanRadius: "1188", mass: "1.303e22" }
    }
  ) {
    id
  }
}

The first case we consider is when the Authorization header contains an expired JWT:

altair expired signature

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:

altair invalid jwt

Both previous errors were raised in the decode_jwt function that uses the jsonwebtoken crate.

If the Authorization header is not specified at all, then the request passes through the router to planets-service, and the response is:

altair subgraph error

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:

altair planets service response

Finally, if a JWT was generated (using the signIn mutation) no earlier than an hour ago and everything else is fine, then the response looks like this:

altair success response

Possible alternatives

There are alternatives to Apollo Router, that is the tools that can consolidate multiple GraphQL APIs into one. Here is a list of the ones I know:

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!

© Roman Kudryashov 2019-2022

Powered by Hugo & Kiss.