Keyboard shortcuts

Press ← or β†’ to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

gRPC-GraphQL Gateway

A high-performance Rust gateway that bridges gRPC services to GraphQL with full Apollo Federation v2 support.

Crates.io License: MIT

Transform your gRPC microservices into a unified GraphQL API with zero GraphQL code. This gateway dynamically generates GraphQL schemas from protobuf descriptors and routes requests to your gRPC backends via Tonic, providing a seamless bridge between gRPC and GraphQL ecosystems.

✨ Features

Core Capabilities

  • πŸš€ Dynamic Schema Generation - Automatic GraphQL schema from protobuf descriptors
  • ⚑ Full Operation Support - Queries, Mutations, and Subscriptions
  • πŸ”Œ WebSocket Subscriptions - Real-time data via GraphQL subscriptions (graphql-ws protocol)
  • πŸ“€ File Uploads - Multipart form data support for file uploads
  • 🎯 Type Safety - Leverages Rust’s type system for robust schema generation

Federation & Enterprise

  • 🌐 Apollo Federation v2 - Complete federation support with entity resolution
  • πŸ”„ Entity Resolution - Production-ready resolver with DataLoader batching
  • 🚫 No N+1 Queries - Built-in DataLoader prevents performance issues
  • πŸ”— All Federation Directives - @key, @external, @requires, @provides, @shareable
  • πŸ“Š Batch Operations - Efficient entity resolution with automatic batching

Developer Experience

  • πŸ› οΈ Code Generation - protoc-gen-graphql-template generates starter gateway code
  • πŸ”§ Middleware Support - Extensible middleware for auth, logging, and observability
  • πŸ“ Rich Examples - Complete working examples for all features
  • πŸ§ͺ Well Tested - Comprehensive test coverage

Production Ready

  • πŸ₯ Health Checks - /health and /ready endpoints for Kubernetes
  • πŸ“Š Prometheus Metrics - /metrics endpoint with request counts and latencies
  • πŸ”­ OpenTelemetry Tracing - Distributed tracing with GraphQL and gRPC spans
  • πŸ›‘οΈ DoS Protection - Query depth and complexity limiting
  • πŸ”’ Introspection Control - Disable schema introspection in production
  • πŸ” Query Whitelisting - Restrict to pre-approved queries (PCI-DSS compliant)
  • ⚑ Rate Limiting - Built-in rate limiting middleware
  • πŸ“¦ Automatic Persisted Queries - Reduce bandwidth with query hash caching
  • πŸ”Œ Circuit Breaker - Prevent cascading failures
  • πŸ—„οΈ Response Caching - In-memory LRU cache with TTL
  • πŸ“‹ Batch Queries - Execute multiple operations in one request
  • πŸ›‘ Graceful Shutdown - Clean shutdown with request draining
  • πŸ—œοΈ Response Compression - Automatic gzip/brotli compression
  • πŸ”€ Header Propagation - Forward HTTP headers to gRPC backends
  • 🧩 Multi-Descriptor Support - Combine multiple protobuf descriptors

Why gRPC-GraphQL Gateway?

If you have existing gRPC microservices and want to expose them via GraphQL without writing GraphQL resolvers manually, this gateway is for you. It:

  1. Reads your protobuf definitions - Including custom GraphQL annotations
  2. Generates a GraphQL schema automatically - Types, queries, mutations, subscriptions
  3. Routes requests to your gRPC backends - With full async/await support
  4. Supports federation - Build a unified supergraph from multiple services

Quick Example

use grpc_graphql_gateway::{Gateway, GrpcClient};

const DESCRIPTORS: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/descriptor.bin"));

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let gateway = Gateway::builder()
        .with_descriptor_set_bytes(DESCRIPTORS)
        .add_grpc_client(
            "greeter.Greeter",
            GrpcClient::builder("http://127.0.0.1:50051").connect_lazy()?,
        )
        .build()?;

    gateway.serve("0.0.0.0:8888").await?;
    Ok(())
}

That’s it! Your gateway is now running at:

  • GraphQL HTTP: http://localhost:8888/graphql
  • GraphQL WebSocket: ws://localhost:8888/graphql/ws

Getting Started

Ready to dive in? Start with the Installation guide.

Installation

Add to Cargo.toml

[dependencies]
grpc_graphql_gateway = "0.2"
tokio = { version = "1", features = ["full"] }
tonic = "0.12"

Optional Features

The gateway supports optional features that can be enabled in Cargo.toml:

[dependencies]
grpc_graphql_gateway = { version = "0.2", features = ["otlp"] }
FeatureDescription
otlpEnable OpenTelemetry Protocol export for distributed tracing

Prerequisites

Before using the gateway, ensure you have:

  1. Rust 1.70+ - The gateway uses modern Rust features
  2. Protobuf Compiler - protoc for generating descriptor files
  3. gRPC Services - Backend services to proxy requests to

Installing protoc

macOS

brew install protobuf

Ubuntu/Debian

sudo apt-get install protobuf-compiler

Windows

Download from the protobuf releases page.

Proto Annotations

To use the gateway, your .proto files need GraphQL annotations. Copy the graphql.proto file from the repository:

curl -o proto/graphql.proto https://raw.githubusercontent.com/Protocol-Lattice/grpc_graphql_gateway/main/proto/graphql.proto

This file defines the custom options like graphql.schema, graphql.field, and graphql.entity that the gateway uses to generate the GraphQL schema.

Next Steps

Once installed, proceed to the Quick Start guide to create your first gateway.

Quick Start

This guide will get you up and running with a basic gRPC-GraphQL gateway in minutes.

Basic Gateway

use grpc_graphql_gateway::{Gateway, GrpcClient};

const DESCRIPTORS: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/graphql_descriptor.bin"));

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let gateway = Gateway::builder()
        .with_descriptor_set_bytes(DESCRIPTORS)
        .add_grpc_client(
            "greeter.Greeter",
            GrpcClient::builder("http://127.0.0.1:50051").connect_lazy()?,
        )
        .build()?;

    gateway.serve("0.0.0.0:8888").await?;
    Ok(())
}

What This Does

  1. Loads protobuf descriptors - The binary descriptor file contains your service definitions
  2. Connects to gRPC backend - Lazily connects to your gRPC service
  3. Generates GraphQL schema - Automatically creates types, queries, and mutations
  4. Starts HTTP server - Serves GraphQL at /graphql

Endpoints

Once running, your gateway exposes:

EndpointDescription
http://localhost:8888/graphqlGraphQL HTTP endpoint (POST)
ws://localhost:8888/graphql/wsGraphQL WebSocket for subscriptions

Testing Your Gateway

Using curl

curl -X POST http://localhost:8888/graphql \
  -H "Content-Type: application/json" \
  -d '{"query": "{ sayHello(name: \"World\") { message } }"}'

Using GraphQL Playground

The gateway includes a built-in GraphQL Playground. Open your browser and navigate to:

http://localhost:8888/graphql

Example Proto File

Here’s a simple proto file that works with the gateway:

syntax = "proto3";

package greeter;

import "graphql.proto";

service Greeter {
  option (graphql.service) = {
    host: "localhost:50051"
    insecure: true
  };

  rpc SayHello(HelloRequest) returns (HelloReply) {
    option (graphql.schema) = {
      type: QUERY
      name: "sayHello"
    };
  }
}

message HelloRequest {
  string name = 1;
}

message HelloReply {
  string message = 1;
}

Next Steps

Generating Descriptors

The gateway reads protobuf descriptor files (.bin) to understand your service definitions. This page explains how to generate them.

Add a build.rs file to your project:

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let out_dir = std::env::var("OUT_DIR")?;
    
    tonic_build::configure()
        .build_server(false)
        .build_client(false)
        .file_descriptor_set_path(
            std::path::PathBuf::from(&out_dir).join("graphql_descriptor.bin")
        )
        .compile_protos(&["proto/your_service.proto"], &["proto"])?;
    
    Ok(())
}

Build Dependencies

Add to your Cargo.toml:

[build-dependencies]
tonic-build = "0.12"

Loading Descriptors

In your main code, load the generated descriptor:

const DESCRIPTORS: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/graphql_descriptor.bin"));

let gateway = Gateway::builder()
    .with_descriptor_set_bytes(DESCRIPTORS)
    .build()?;

Using protoc Directly

You can also generate descriptors using protoc directly:

protoc \
  --descriptor_set_out=descriptor.bin \
  --include_imports \
  --include_source_info \
  -I proto \
  proto/your_service.proto

Then load it from a file:

let gateway = Gateway::builder()
    .with_descriptor_set_file("descriptor.bin")?
    .build()?;

Multiple Proto Files

If you have multiple proto files, include them all:

tonic_build::configure()
    .file_descriptor_set_path(
        std::path::PathBuf::from(&out_dir).join("descriptor.bin")
    )
    .compile_protos(
        &[
            "proto/users.proto",
            "proto/products.proto",
            "proto/orders.proto",
        ],
        &["proto"]
    )?;

Multi-Descriptor Support

For microservice architectures where each team owns their proto files, you can combine multiple descriptor sets:

const USERS_DESCRIPTORS: &[u8] = include_bytes!("path/to/users.bin");
const PRODUCTS_DESCRIPTORS: &[u8] = include_bytes!("path/to/products.bin");

let gateway = Gateway::builder()
    .with_descriptor_set_bytes(USERS_DESCRIPTORS)
    .add_descriptor_set_bytes(PRODUCTS_DESCRIPTORS)
    .build()?;

See Multi-Descriptor Support for more details.

Required Proto Imports

Your proto files must import the GraphQL annotations:

import "graphql.proto";

Make sure graphql.proto is in your include path when compiling.

Troubleshooting

Missing graphql.schema extension

If you see this error:

missing graphql.schema extension

Ensure that:

  1. graphql.proto is included in your proto compilation
  2. You’re using --include_imports with protoc
  3. Your tonic-build includes all necessary proto files

Descriptor file not found

If the descriptor file isn’t found at runtime:

  1. Check that OUT_DIR is set correctly
  2. Verify the file was generated during build
  3. Use cargo clean && cargo build to regenerate

Queries, Mutations & Subscriptions

The gateway supports all three GraphQL operation types, automatically derived from your protobuf service definitions.

Annotating Proto Methods

Use the graphql.schema option to define how each RPC method maps to GraphQL:

service UserService {
  option (graphql.service) = {
    host: "localhost:50051"
    insecure: true
  };

  // Query - for fetching data
  rpc GetUser(GetUserRequest) returns (User) {
    option (graphql.schema) = {
      type: QUERY
      name: "user"
    };
  }

  // Mutation - for modifying data
  rpc CreateUser(CreateUserRequest) returns (User) {
    option (graphql.schema) = {
      type: MUTATION
      name: "createUser"
      request { name: "input" }
    };
  }

  // Subscription - for real-time data (server streaming)
  rpc WatchUser(WatchUserRequest) returns (stream User) {
    option (graphql.schema) = {
      type: SUBSCRIPTION
      name: "userUpdates"
    };
  }
}

Operation Type Mapping

Proto RPC TypeGraphQL TypeUse Case
UnaryQuery/MutationFetch or modify data
Server StreamingSubscriptionReal-time updates
Client StreamingNot supported-
BidirectionalNot supported-

Queries

Queries are used for fetching data:

query {
  user(id: "123") {
    id
    name
    email
  }
}

Query Example

Proto:

rpc GetUser(GetUserRequest) returns (User) {
  option (graphql.schema) = {
    type: QUERY
    name: "user"
  };
}

GraphQL:

query GetUser {
  user(id: "123") {
    id
    name
    email
  }
}

Mutations

Mutations are used for creating, updating, or deleting data:

mutation {
  createUser(input: { name: "Alice", email: "alice@example.com" }) {
    id
    name
  }
}

Using Input Types

The request option customizes how the request message is exposed:

rpc CreateUser(CreateUserRequest) returns (User) {
  option (graphql.schema) = {
    type: MUTATION
    name: "createUser"
    request { name: "input" }  // Wrap request fields under "input"
  };
}

This creates a GraphQL mutation with an input argument containing all fields from CreateUserRequest.

Subscriptions

Subscriptions provide real-time updates via WebSocket:

subscription {
  userUpdates(id: "123") {
    id
    name
    status
  }
}

WebSocket Protocol

The gateway supports the graphql-transport-ws protocol. Connect to:

ws://localhost:8888/graphql/ws

Subscription Example

Proto (server streaming RPC):

rpc WatchUser(WatchUserRequest) returns (stream User) {
  option (graphql.schema) = {
    type: SUBSCRIPTION
    name: "userUpdates"
  };
}

JavaScript Client:

import { createClient } from 'graphql-ws';

const client = createClient({
  url: 'ws://localhost:8888/graphql/ws',
});

client.subscribe(
  {
    query: 'subscription { userUpdates(id: "123") { id name status } }',
  },
  {
    next: (data) => console.log('Update:', data),
    error: (err) => console.error('Error:', err),
    complete: () => console.log('Complete'),
  }
);

Multiple Operations

You can run multiple operations in a single request using Batch Queries:

[
  {"query": "{ users { id name } }"},
  {"query": "{ products { upc price } }"}
]

File Uploads

The gateway automatically supports GraphQL file uploads via multipart requests, following the GraphQL multipart request specification.

Proto Definition

Map bytes fields to handle file uploads:

message UploadAvatarRequest {
  string user_id = 1;
  bytes avatar = 2;  // Maps to Upload scalar in GraphQL
}

message UploadAvatarResponse {
  string user_id = 1;
  int64 size = 2;
}

service UserService {
  rpc UploadAvatar(UploadAvatarRequest) returns (UploadAvatarResponse) {
    option (graphql.schema) = {
      type: MUTATION
      name: "uploadAvatar"
      request { name: "input" }
    };
  }
}

GraphQL Mutation

The generated GraphQL schema includes an Upload scalar:

mutation UploadAvatar($file: Upload!) {
  uploadAvatar(input: { userId: "123", avatar: $file }) {
    userId
    size
  }
}

Using curl

curl http://localhost:8888/graphql \
  --form 'operations={"query": "mutation($file: Upload!) { uploadAvatar(input:{userId:\"123\", avatar:$file}) { userId size } }", "variables": {"file": null}}' \
  --form 'map={"0": ["variables.file"]}' \
  --form '0=@avatar.png'

Request Format

  1. operations - JSON containing the query and variables
  2. map - Maps file indices to variable paths
  3. 0, 1, … - The actual file content

JavaScript Client

Using Apollo Client with apollo-upload-client:

import { createUploadLink } from 'apollo-upload-client';
import { ApolloClient, InMemoryCache } from '@apollo/client';

const client = new ApolloClient({
  link: createUploadLink({ uri: 'http://localhost:8888/graphql' }),
  cache: new InMemoryCache(),
});

// Upload mutation
const UPLOAD_AVATAR = gql`
  mutation UploadAvatar($file: Upload!) {
    uploadAvatar(input: { userId: "123", avatar: $file }) {
      userId
      size
    }
  }
`;

// Trigger upload
const file = document.querySelector('input[type="file"]').files[0];
client.mutate({
  mutation: UPLOAD_AVATAR,
  variables: { file },
});

Multiple Files

Upload multiple files by adding more entries to the map:

curl http://localhost:8888/graphql \
  --form 'operations={"query": "mutation($files: [Upload!]!) { uploadFiles(files: $files) { count } }", "variables": {"files": [null, null]}}' \
  --form 'map={"0": ["variables.files.0"], "1": ["variables.files.1"]}' \
  --form '0=@file1.pdf' \
  --form '1=@file2.pdf'

File Size Limits

By default, uploads are limited by your web server configuration. For large files, consider:

  1. Streaming uploads to avoid memory pressure
  2. Setting appropriate timeouts
  3. Using a CDN or object storage for very large files

Backend Handling

On the gRPC backend, the file is received as bytes. Example in Rust:

async fn upload_avatar(
    &self,
    request: Request<UploadAvatarRequest>,
) -> Result<Response<UploadAvatarResponse>, Status> {
    let req = request.into_inner();
    let file_data = req.avatar;  // Vec<u8>
    let size = file_data.len() as i64;
    
    // Save file, upload to S3, etc.
    
    Ok(Response::new(UploadAvatarResponse {
        user_id: req.user_id,
        size,
    }))
}

Field-Level Control

Use the graphql.field option to customize how individual fields are exposed in the GraphQL schema.

Basic Field Options

message User {
  string id = 1 [(graphql.field) = { required: true }];
  string email = 2 [(graphql.field) = { name: "emailAddress" }];
  string internal_id = 3 [(graphql.field) = { omit: true }];
  string password_hash = 4 [(graphql.field) = { omit: true }];
}

Available Options

OptionTypeDescription
namestringOverride the GraphQL field name
omitboolExclude this field from GraphQL schema
requiredboolMark field as non-nullable (!)
shareableboolFederation: field can be resolved by multiple subgraphs
externalboolFederation: field is defined in another subgraph
requiresstringFederation: fields needed from other subgraphs
providesstringFederation: fields this resolver provides

Renaming Fields

Use name to map protobuf field names to GraphQL conventions:

message User {
  string user_name = 1 [(graphql.field) = { name: "username" }];
  string email_address = 2 [(graphql.field) = { name: "email" }];
  int64 created_at_unix = 3 [(graphql.field) = { name: "createdAt" }];
}

Generated GraphQL:

type User {
  username: String!
  email: String!
  createdAt: Int!
}

Omitting Fields

Hide sensitive or internal fields:

message User {
  string id = 1;
  string name = 2;
  string password_hash = 3 [(graphql.field) = { omit: true }];
  string internal_notes = 4 [(graphql.field) = { omit: true }];
}

Generated GraphQL:

type User {
  id: String!
  name: String!
  # password_hash and internal_notes are not exposed
}

Required Fields

Mark fields as non-nullable in GraphQL:

message CreateUserInput {
  string name = 1 [(graphql.field) = { required: true }];
  string email = 2 [(graphql.field) = { required: true }];
  string bio = 3;  // Optional
}

Generated GraphQL:

input CreateUserInput {
  name: String!
  email: String!
  bio: String
}

Federation Directives

For Apollo Federation, use field-level directives:

message User {
  string id = 1 [(graphql.field) = { 
    required: true
    shareable: true 
  }];
  
  string email = 2 [(graphql.field) = { 
    external: true 
  }];
  
  repeated Review reviews = 3 [(graphql.field) = { 
    requires: "id" 
  }];
}

See Federation Directives for more details.

Combining Options

Options can be combined:

message Product {
  string upc = 1 [(graphql.field) = { 
    required: true
    name: "id"
    shareable: true 
  }];
}

Default Values

Protobuf fields have default values (empty string, 0, false). In GraphQL:

  • Fields with defaults may still be nullable
  • Use required: true to make them non-nullable
  • The gateway handles type conversion automatically

Multi-Descriptor Support

Combine multiple protobuf descriptor sets from different microservices into a unified GraphQL schema. This is essential for large microservice architectures where each team owns their proto files.

Overview

Instead of maintaining a single monolithic proto file, you can:

  1. Let each team generate their own descriptor file
  2. Combine them at gateway startup
  3. Serve a unified GraphQL API

Basic Usage

use grpc_graphql_gateway::{Gateway, GrpcClient};

// Load descriptor sets from different microservices
const USERS_DESCRIPTORS: &[u8] = include_bytes!("path/to/users.bin");
const PRODUCTS_DESCRIPTORS: &[u8] = include_bytes!("path/to/products.bin");
const ORDERS_DESCRIPTORS: &[u8] = include_bytes!("path/to/orders.bin");

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let gateway = Gateway::builder()
        // Primary descriptor set
        .with_descriptor_set_bytes(USERS_DESCRIPTORS)
        // Add additional services from other teams
        .add_descriptor_set_bytes(PRODUCTS_DESCRIPTORS)
        .add_descriptor_set_bytes(ORDERS_DESCRIPTORS)
        // Add clients for each service
        .add_grpc_client("users.UserService", 
            GrpcClient::builder("http://users:50051").connect_lazy()?)
        .add_grpc_client("products.ProductService", 
            GrpcClient::builder("http://products:50052").connect_lazy()?)
        .add_grpc_client("orders.OrderService", 
            GrpcClient::builder("http://orders:50053").connect_lazy()?)
        .build()?;

    gateway.serve("0.0.0.0:8888").await?;
    Ok(())
}

File-Based Loading

Load descriptors from files instead of embedding:

let gateway = Gateway::builder()
    .with_descriptor_set_file("path/to/users.bin")?
    .add_descriptor_set_file("path/to/products.bin")?
    .add_descriptor_set_file("path/to/orders.bin")?
    .build()?;

API Methods

MethodDescription
with_descriptor_set_bytes(bytes)Set primary descriptor (clears existing)
add_descriptor_set_bytes(bytes)Add additional descriptor
with_descriptor_set_file(path)Set primary descriptor from file
add_descriptor_set_file(path)Add additional descriptor from file
descriptor_count()Get number of configured descriptors

Use Cases

Microservice Architecture

Each team generates their own descriptor:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚   Users Team    β”‚     β”‚ Products Team   β”‚     β”‚  Orders Team    β”‚
β”‚                 β”‚     β”‚                 β”‚     β”‚                 β”‚
β”‚  users.proto    β”‚     β”‚ products.proto  β”‚     β”‚  orders.proto   β”‚
β”‚       ↓         β”‚     β”‚       ↓         β”‚     β”‚       ↓         β”‚
β”‚   users.bin     β”‚     β”‚  products.bin   β”‚     β”‚   orders.bin    β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜
         β”‚                       β”‚                       β”‚
         β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                 β”‚
                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                    β”‚    GraphQL Gateway      β”‚
                    β”‚                         β”‚
                    β”‚  Unified GraphQL Schema β”‚
                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Schema Stitching

Combine services at the gateway level:

# From users.bin
type Query {
  user(id: ID!): User
}

# From products.bin  
type Query {
  product(upc: String!): Product
}

# From orders.bin
type Query {
  order(id: ID!): Order
}

# Unified Schema (automatic)
type Query {
  user(id: ID!): User
  product(upc: String!): Product
  order(id: ID!): Order
}

Independent Deployments

Update individual service descriptors without restarting:

// Hot-reload could be implemented by watching descriptor files
let gateway = Gateway::builder()
    .with_descriptor_set_file("/config/users.bin")?
    .add_descriptor_set_file("/config/products.bin")?
    .build()?;

How It Works

  1. Primary descriptor is loaded with with_descriptor_set_bytes/file
  2. Additional descriptors are merged using add_descriptor_set_bytes/file
  3. Duplicate files are automatically skipped (same filename)
  4. Services and types from all descriptors are combined
  5. GraphQL schema is generated from the merged pool

Requirements

  • All descriptors must include graphql.proto with annotations
  • Service names should be unique across descriptors
  • Type names are namespaced by their proto package

Logging

The gateway logs merge information:

INFO Merged 3 descriptor sets into unified schema (5 services, 42 types)
DEBUG Merged descriptor set #2 (15234 bytes) into schema pool
DEBUG Merged descriptor set #3 (8921 bytes) into schema pool

Error Handling

Common errors and solutions:

ErrorCauseSolution
at least one descriptor set is requiredNo descriptors providedAdd at least one with with_descriptor_set_bytes
failed to merge descriptor set #NInvalid protobuf dataVerify the descriptor file is valid
missing graphql.schema extensionAnnotations not foundEnsure graphql.proto is included

Apollo Federation Overview

Build federated GraphQL architectures with multiple subgraphs. The gateway supports Apollo Federation v2, allowing you to compose a supergraph from multiple gRPC services.

What is Federation?

Apollo Federation is an architecture for building a distributed GraphQL API. Instead of a monolithic schema, you have:

  • Subgraphs: Individual GraphQL services that own part of the schema
  • Supergraph: The composed schema combining all subgraphs
  • Router: Distributes queries to appropriate subgraphs

Gateway as Subgraph

The gRPC-GraphQL Gateway can act as a federation subgraph:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚              Apollo Router / Gateway            β”‚
β”‚               (Supergraph Router)               β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                  β”‚                 β”‚
     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
     β”‚  gRPC-GraphQL     β”‚  β”‚  Traditional       β”‚
     β”‚  Gateway          β”‚  β”‚  GraphQL Service   β”‚
     β”‚  (Subgraph)       β”‚  β”‚  (Subgraph)        β”‚
     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                  β”‚
     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
     β”‚         gRPC Services         β”‚
     β”‚   Users β”‚ Products β”‚ Orders   β”‚
     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Enabling Federation

let gateway = Gateway::builder()
    .with_descriptor_set_bytes(DESCRIPTORS)
    .enable_federation()  // Enable federation features
    .add_grpc_client("users.UserService", user_client)
    .build()?;

Federation Features

When federation is enabled, the gateway:

  1. Adds _service query - Returns the SDL for schema composition
  2. Adds _entities query - Resolves entity references from other subgraphs
  3. Applies directives - @key, @shareable, @external, etc.

Schema Composition

Your proto files define entities with keys:

message User {
  option (graphql.entity) = {
    keys: "id"
    resolvable: true
  };
  
  string id = 1 [(graphql.field) = { required: true }];
  string name = 2;
  string email = 3;
}

This generates:

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

Running with Apollo Router

  1. Start your federation subgraphs
  2. Compose the supergraph schema
  3. Run Apollo Router

See Running with Apollo Router for detailed instructions.

Next Steps

Defining Entities

Entities are the building blocks of Apollo Federation. They’re types that can be resolved across multiple subgraphs using a unique key.

Basic Entity Definition

Use the graphql.entity option on your protobuf messages:

message User {
  option (graphql.entity) = {
    keys: "id"
    resolvable: true
  };
  
  string id = 1 [(graphql.field) = { required: true }];
  string name = 2;
  string email = 3 [(graphql.field) = { shareable: true }];
}

Entity Options

OptionTypeDescription
keysstringThe field(s) that uniquely identify this entity
resolvableboolWhether this subgraph can resolve the entity
extendboolWhether this extends an entity from another subgraph

Generated GraphQL

The above proto generates:

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

Composite Keys

Use space-separated fields for composite keys:

message Product {
  option (graphql.entity) = {
    keys: "sku region"
    resolvable: true
  };
  
  string sku = 1 [(graphql.field) = { required: true }];
  string region = 2 [(graphql.field) = { required: true }];
  string name = 3;
}

Generated:

type Product @key(fields: "sku region") {
  sku: ID!
  region: ID!
  name: String
}

Multiple Keys

Define multiple key sets by repeating the graphql.entity option or using multiple key definitions:

message User {
  option (graphql.entity) = {
    keys: "id"
    resolvable: true
  };
  
  string id = 1;
  string email = 2;  // Could also be a key
}

Resolvable vs Non-Resolvable

Resolvable Entities

When resolvable: true, this subgraph can fully resolve the entity:

message User {
  option (graphql.entity) = {
    keys: "id"
    resolvable: true  // Can resolve User by id
  };
  
  string id = 1;
  string name = 2;
  string email = 3;
}

Stub Entities

When resolvable: false, this subgraph only references the entity:

message User {
  option (graphql.entity) = {
    keys: "id"
    resolvable: false  // Cannot resolve, just references
  };
  
  string id = 1 [(graphql.field) = { external: true }];
}

Real-World Example

Users Service (owns User entity):

message User {
  option (graphql.entity) = {
    keys: "id"
    resolvable: true
  };
  
  string id = 1 [(graphql.field) = { required: true }];
  string name = 2 [(graphql.field) = { shareable: true }];
  string email = 3 [(graphql.field) = { shareable: true }];
}

Reviews Service (references User):

message Review {
  string id = 1;
  string body = 2;
  User author = 3;  // Reference to User from Users service
}

message User {
  option (graphql.entity) = {
    keys: "id"
    extend: true  // Extending User from another subgraph
  };
  
  string id = 1 [(graphql.field) = { external: true, required: true }];
  repeated Review reviews = 2 [(graphql.field) = { requires: "id" }];
}

Key Field Requirements

Key fields should be:

  1. Marked as required - Use required: true
  2. Non-null in responses - Always return a value
  3. Consistent across subgraphs - Same type everywhere

Entity Resolution

When Apollo Router receives a query that spans multiple subgraphs, it needs to resolve entity references. The gateway includes production-ready entity resolution with DataLoader batching.

How Entity Resolution Works

  1. Router sends _entities query with representations
  2. Gateway receives representations (e.g., { __typename: "User", id: "123" })
  3. Gateway calls your gRPC backend to resolve the entity
  4. Gateway returns the resolved entity data

Configuring Entity Resolution

use grpc_graphql_gateway::{
    Gateway, GrpcClient, EntityResolverMapping, GrpcEntityResolver
};
use std::sync::Arc;

// Configure entity resolver with DataLoader batching
let resolver = GrpcEntityResolver::builder(client_pool)
    .register_entity_resolver(
        "User",
        EntityResolverMapping {
            service_name: "UserService".to_string(),
            method_name: "GetUser".to_string(),
            key_field: "id".to_string(),
        }
    )
    .register_entity_resolver(
        "Product",
        EntityResolverMapping {
            service_name: "ProductService".to_string(),
            method_name: "GetProduct".to_string(),
            key_field: "upc".to_string(),
        }
    )
    .build();

let gateway = Gateway::builder()
    .with_descriptor_set_bytes(DESCRIPTORS)
    .enable_federation()
    .with_entity_resolver(Arc::new(resolver))
    .add_grpc_client("UserService", user_client)
    .add_grpc_client("ProductService", product_client)
    .build()?;

DataLoader Batching

The built-in GrpcEntityResolver uses DataLoader to batch entity requests:

Query requests:
  - User(id: "1")
  - User(id: "2")  
  - User(id: "3")

Without DataLoader: 3 gRPC calls
With DataLoader: 1 batched gRPC call

Benefits

  • βœ… No N+1 Queries - Concurrent requests are batched
  • βœ… Automatic Coalescing - Duplicate keys are deduplicated
  • βœ… Per-Request Caching - Same entity isn’t fetched twice per request

Custom Entity Resolver

Implement the EntityResolver trait for custom logic:

use grpc_graphql_gateway::federation::{EntityConfig, EntityResolver};
use async_graphql::{Value, indexmap::IndexMap, Name};
use async_trait::async_trait;

struct MyEntityResolver {
    // Your dependencies
}

#[async_trait]
impl EntityResolver for MyEntityResolver {
    async fn resolve_entity(
        &self,
        config: &EntityConfig,
        representation: &IndexMap<Name, Value>,
    ) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
        let typename = &config.type_name;
        
        match typename.as_str() {
            "User" => {
                let id = representation.get(&Name::new("id"))
                    .and_then(|v| v.as_str())
                    .ok_or("missing id")?;
                
                // Fetch from your backend
                let user = self.fetch_user(id).await?;
                
                Ok(Value::Object(indexmap! {
                    Name::new("id") => Value::String(user.id),
                    Name::new("name") => Value::String(user.name),
                    Name::new("email") => Value::String(user.email),
                }))
            }
            _ => Err(format!("Unknown entity type: {}", typename).into()),
        }
    }
}

EntityResolverMapping

Configure how each entity type maps to a gRPC method:

FieldDescription
service_nameThe gRPC service name
method_nameThe RPC method to call
key_fieldThe field in the request message that holds the key

Query Example

When Router sends:

query {
  _entities(representations: [
    { __typename: "User", id: "123" }
    { __typename: "User", id: "456" }
  ]) {
    ... on User {
      id
      name
      email
    }
  }
}

The gateway:

  1. Extracts the representations
  2. Groups by __typename
  3. Batches calls to the appropriate gRPC services
  4. Returns resolved entities

Error Handling

Entity resolution errors are returned per-entity:

{
  "data": {
    "_entities": [
      { "id": "123", "name": "Alice", "email": "alice@example.com" },
      null
    ]
  },
  "errors": [
    {
      "message": "User not found: 456",
      "path": ["_entities", 1]
    }
  ]
}

Performance Tips

  1. Use DataLoader - Always batch entity requests
  2. Implement bulk fetch - Have gRPC methods that fetch multiple entities
  3. Cache wisely - Consider caching frequently accessed entities
  4. Monitor - Track entity resolution latency with metrics

Extending Entities

Extend entities defined in other subgraphs to add fields that your service owns.

Basic Extension

Use extend: true to extend an entity from another subgraph:

// In Reviews service - extending User from Users service
message User {
  option (graphql.entity) = {
    extend: true
    keys: "id"
  };
  
  // Key field from the original entity
  string id = 1 [(graphql.field) = {
    external: true
    required: true
  }];
  
  // Fields this service adds
  repeated Review reviews = 2 [(graphql.field) = {
    requires: "id"
  }];
}

Generated Schema

The above generates federation-compatible schema:

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

Extension Pattern

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                         Supergraph                              β”‚
β”‚                                                                 β”‚
β”‚  type User @key(fields: "id") {                                 β”‚
β”‚    id: ID!           # From Users Service                       β”‚
β”‚    name: String      # From Users Service                       β”‚
β”‚    email: String     # From Users Service                       β”‚
β”‚    reviews: [Review] # From Reviews Service (extension)         β”‚
β”‚  }                                                              β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
         β–²                                    β–²
         β”‚                                    β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Users Service  β”‚              β”‚    Reviews Service       β”‚
β”‚                 β”‚              β”‚                          β”‚
β”‚  type User      β”‚              β”‚  type User @extends      β”‚
β”‚    id: ID!      β”‚              β”‚    id: ID! @external     β”‚
β”‚    name: String β”‚              β”‚    reviews: [Review]     β”‚
β”‚    email: Stringβ”‚              β”‚                          β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜              β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

External Fields

Mark fields owned by another subgraph as external:

message User {
  option (graphql.entity) = {
    extend: true
    keys: "id"
  };
  
  string id = 1 [(graphql.field) = { 
    external: true  // This field comes from another subgraph
    required: true 
  }];
  
  string name = 2 [(graphql.field) = { 
    external: true  // Also external
  }];
  
  // This service's contribution
  int32 review_count = 3;
}

Requires Directive

Use requires when you need data from external fields to resolve a local field:

message Product {
  option (graphql.entity) = {
    extend: true
    keys: "upc"
  };
  
  string upc = 1 [(graphql.field) = { external: true }];
  float price = 2 [(graphql.field) = { external: true }];
  float weight = 3 [(graphql.field) = { external: true }];
  
  // Needs price and weight to calculate
  float shipping_estimate = 4 [(graphql.field) = { 
    requires: "price weight" 
  }];
}

The federation router will fetch price and weight from the owning subgraph before calling your resolver for shipping_estimate.

Provides Directive

Use provides to indicate which nested fields your resolver provides:

message Review {
  string id = 1;
  string body = 2;
  
  // When resolving author, we also provide their username
  User author = 3 [(graphql.field) = {
    provides: "username"
  }];
}

Complete Example

Users Subgraph (owns User):

message User {
  option (graphql.entity) = {
    keys: "id"
    resolvable: true
  };
  
  string id = 1 [(graphql.field) = { required: true }];
  string name = 2 [(graphql.field) = { shareable: true }];
  string email = 3;
}

Reviews Subgraph (extends User):

message User {
  option (graphql.entity) = {
    extend: true
    keys: "id"
  };
  
  string id = 1 [(graphql.field) = { external: true, required: true }];
  repeated Review reviews = 2;
}

message Review {
  string id = 1;
  string body = 2;
  int32 rating = 3;
  User author = 4;
}

Composed Query:

query {
  user(id: "123") {
    id
    name          # Resolved by Users subgraph
    email         # Resolved by Users subgraph
    reviews {     # Resolved by Reviews subgraph (extension)
      id
      body
      rating
    }
  }
}

Federation Directives

The gateway supports all Apollo Federation v2 directives through proto annotations.

Directive Reference

DirectiveProto OptionPurpose
@keygraphql.entity.keysDefine entity key fields
@shareablegraphql.field.shareableField resolvable from multiple subgraphs
@externalgraphql.field.externalField defined in another subgraph
@requiresgraphql.field.requiresFields needed from other subgraphs
@providesgraphql.field.providesFields this resolver provides
@extendsgraphql.entity.extendExtending entity from another subgraph

@key

Defines how an entity is uniquely identified:

message User {
  option (graphql.entity) = {
    keys: "id"
  };
  string id = 1;
}

Generated:

type User @key(fields: "id") {
  id: ID!
}

Multiple Keys

message Product {
  option (graphql.entity) = {
    keys: "upc"  // Primary key
  };
  string upc = 1;
  string sku = 2;
}

Composite Keys

message Inventory {
  option (graphql.entity) = {
    keys: "warehouseId productId"
  };
  string warehouse_id = 1;
  string product_id = 2;
  int32 quantity = 3;
}

@shareable

Marks fields that can be resolved by multiple subgraphs:

message User {
  string id = 1;
  string name = 2 [(graphql.field) = { shareable: true }];
  string email = 3 [(graphql.field) = { shareable: true }];
}

Generated:

type User {
  id: ID!
  name: String @shareable
  email: String @shareable
}

When to Use

Use @shareable when:

  • Multiple subgraphs can resolve the same field
  • You want redundancy for a commonly accessed field
  • Different subgraphs have the same data source

@external

Marks fields defined in another subgraph that you need to reference:

message User {
  option (graphql.entity) = { extend: true, keys: "id" };
  
  string id = 1 [(graphql.field) = { external: true }];
  string name = 2 [(graphql.field) = { external: true }];
  repeated Review reviews = 3;  // Your field
}

Generated:

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

@requires

Declares that a field requires data from external fields:

message Product {
  option (graphql.entity) = { extend: true, keys: "upc" };
  
  string upc = 1 [(graphql.field) = { external: true }];
  float price = 2 [(graphql.field) = { external: true }];
  float weight = 3 [(graphql.field) = { external: true }];
  
  float shipping_cost = 4 [(graphql.field) = { 
    requires: "price weight" 
  }];
}

Generated:

type Product @extends @key(fields: "upc") {
  upc: ID! @external
  price: Float @external
  weight: Float @external
  shippingCost: Float @requires(fields: "price weight")
}

How It Works

  1. Router fetches price and weight from the owning subgraph
  2. Router sends those values to your subgraph
  3. Your resolver uses them to calculate shippingCost

@provides

Hints that a resolver provides additional fields on referenced entities:

message Review {
  string id = 1;
  string body = 2;
  
  User author = 3 [(graphql.field) = {
    provides: "name email"
  }];
}

Generated:

type Review {
  id: ID!
  body: String
  author: User @provides(fields: "name email")
}

When to Use

Use @provides when:

  • Your resolver already has the nested entity’s data
  • You want to avoid an extra subgraph hop
  • You’re denormalizing for performance

Complete Example

Products Subgraph:

message Product {
  option (graphql.entity) = {
    keys: "upc"
    resolvable: true
  };
  
  string upc = 1 [(graphql.field) = { required: true }];
  string name = 2 [(graphql.field) = { shareable: true }];
  float price = 3 [(graphql.field) = { shareable: true }];
}

Inventory Subgraph:

message Product {
  option (graphql.entity) = {
    extend: true
    keys: "upc"
  };
  
  string upc = 1 [(graphql.field) = { external: true }];
  float price = 2 [(graphql.field) = { external: true }];
  float weight = 3 [(graphql.field) = { external: true }];
  
  int32 stock = 4;
  bool in_stock = 5;
  float shipping_estimate = 6 [(graphql.field) = {
    requires: "price weight"
  }];
}

Running with Apollo Router

Compose your gRPC-GraphQL Gateway subgraphs with Apollo Router to create a federated supergraph.

Prerequisites

  • Apollo Router installed
  • Federation-enabled gateway subgraphs running

Step 1: Start Your Subgraphs

Start each gRPC-GraphQL Gateway as a federation subgraph:

Users Subgraph (port 8891):

let gateway = Gateway::builder()
    .with_descriptor_set_bytes(USERS_DESCRIPTORS)
    .enable_federation()
    .add_grpc_client("users.UserService", user_client)
    .build()?;

gateway.serve("0.0.0.0:8891").await?;

Products Subgraph (port 8892):

let gateway = Gateway::builder()
    .with_descriptor_set_bytes(PRODUCTS_DESCRIPTORS)
    .enable_federation()
    .add_grpc_client("products.ProductService", product_client)
    .build()?;

gateway.serve("0.0.0.0:8892").await?;

Step 2: Create Supergraph Configuration

Create supergraph.yaml:

federation_version: =2.3.2

subgraphs:
  users:
    routing_url: http://localhost:8891/graphql
    schema:
      subgraph_url: http://localhost:8891/graphql
  
  products:
    routing_url: http://localhost:8892/graphql
    schema:
      subgraph_url: http://localhost:8892/graphql

Step 3: Compose the Supergraph

Install Rover CLI:

curl -sSL https://rover.apollo.dev/nix/latest | sh

Compose the supergraph:

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

Step 4: Run Apollo Router

router --supergraph supergraph.graphql --dev

Or with configuration:

router \
  --supergraph supergraph.graphql \
  --config router.yaml

Router Configuration

Create router.yaml for production:

supergraph:
  listen: 0.0.0.0:4000
  introspection: true

cors:
  origins:
    - https://studio.apollographql.com

telemetry:
  exporters:
    tracing:
      otlp:
        enabled: true
        endpoint: http://jaeger:4317

health_check:
  listen: 0.0.0.0:8088
  enabled: true
  path: /health

Querying the Supergraph

Once running, query through the router:

query {
  user(id: "123") {
    id
    name
    email
    orders {      # From Orders subgraph
      id
      total
      products {  # From Products subgraph
        upc
        name
        price
      }
    }
  }
}

Docker Compose Example

version: '3.8'

services:
  router:
    image: ghcr.io/apollographql/router:v1.25.0
    ports:
      - "4000:4000"
    volumes:
      - ./supergraph.graphql:/supergraph.graphql
      - ./router.yaml:/router.yaml
    command: --supergraph /supergraph.graphql --config /router.yaml

  users-gateway:
    build: ./users-gateway
    ports:
      - "8891:8888"
    depends_on:
      - users-grpc

  products-gateway:
    build: ./products-gateway
    ports:
      - "8892:8888"
    depends_on:
      - products-grpc

  users-grpc:
    build: ./users-service
    ports:
      - "50051:50051"

  products-grpc:
    build: ./products-service
    ports:
      - "50052:50052"

Continuous Composition

For production environments, we recommend using Apollo GraphOS for managed federation and continuous delivery.

See the GraphOS & Schema Registry guide for detailed instructions on publishing subgraphs and setting up CI/CD pipelines.

Troubleshooting

Subgraph Schema Fetch Fails

Ensure the subgraph introspection is enabled and accessible:

curl http://localhost:8891/graphql \
  -H "Content-Type: application/json" \
  -d '{"query": "{ _service { sdl } }"}'

Entity Resolution Errors

Check that:

  1. Entity resolvers are configured for all entity types
  2. gRPC clients are connected
  3. Key fields match between subgraphs

Composition Errors

Run composition with verbose output:

rover supergraph compose --config supergraph.yaml --log debug

Apollo GraphOS & Schema Registry

Apollo GraphOS is a platform for building, managing, and scaling your supergraph. It provides a Schema Registry that acts as the source of truth for your supergraph’s schema, enabling Managed Federation.

Why use GraphOS?

  • Managed Federation: GraphOS handles supergraph composition for you.
  • Schema Checks: Validate changes against production traffic before deploying.
  • Explorer: A powerful IDE for your supergraph.
  • Metrics: Detailed usage statistics and performance monitoring.

Prerequisites

  1. An Apollo Studio account.
  2. The Rover CLI installed.
  3. A created Graph in Apollo Studio (of type β€œSupergraph”).

Publishing Subgraphs

Instead of composing the supergraph locally, you publish each subgraph’s schema to the GraphOS Registry. GraphOS then composes them into a supergraph schema.

1. Introspect Your Subgraph

First, start your grpc-graphql-gateway instance. Then, verify you can fetch the SDL:

# Example for the 'users' subgraph running on port 8891
rover subgraph introspect http://localhost:8891/graphql > users.graphql

2. Publish the Subgraph

Use rover to publish the schema to your graph variant (e.g., current or production).

# Replace MY_GRAPH with your Graph ID and 'users' with your subgraph name
rover subgraph publish MY_GRAPH@current \
  --name users \
  --schema ./users.graphql \
  --routing-url http://users-service:8891/graphql

Repeat this for all your subgraphs (e.g., products, reviews).

Automatic Composition

Once subgraphs are published, GraphOS automatically composes the supergraph schema.

You can view the status and build errors in the Build tab in Apollo Studio.

Fetching the Supergraph Schema

Your Apollo Router (or Gateway) needs the composed supergraph schema. With GraphOS, you have two options:

Configure Apollo Router to fetch the configuration directly from GraphOS. This allows for dynamic updates without restarting the router.

Set the APOLLO_KEY and APOLLO_GRAPH_REF environment variables:

export APOLLO_KEY=service:MY_GRAPH:your-api-key
export APOLLO_GRAPH_REF=MY_GRAPH@current

./router

Option B: CI/CD Fetch

Fetch the supergraph schema during your build process:

rover supergraph fetch MY_GRAPH@current > supergraph.graphql

./router --supergraph supergraph.graphql

Schema Checks

Before deploying a change, run a schema check to ensure it doesn’t break existing clients.

rover subgraph check MY_GRAPH@current \
  --name users \
  --schema ./users.graphql

GitHub Actions Example

Here is an example workflow to check and publish your schema:

name: Schema Registry

on:
  push:
    branches: [ main ]
  pull_request:

env:
  APOLLO_KEY: ${{ secrets.APOLLO_KEY }}
  APOLLO_VCS_COMMIT: ${{ github.event.pull_request.head.sha }}

jobs:
  schema-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Install Rover
        run: curl -sSL https://rover.apollo.dev/nix/latest | sh
      
      - name: Start Gateway (Background)
        run: cargo run --bin users-service &
      
      - name: Introspect Schema
        run: |
          sleep 10
          ~/.rover/bin/rover subgraph introspect http://localhost:8891/graphql > users.graphql

      - name: Check Schema
        if: github.event_name == 'pull_request'
        run: |
          ~/.rover/bin/rover subgraph check MY_GRAPH@current \
            --name users \
            --schema ./users.graphql

      - name: Publish Schema
        if: github.event_name == 'push'
        run: |
          ~/.rover/bin/rover subgraph publish MY_GRAPH@current \
            --name users \
            --schema ./users.graphql \
            --routing-url http://users-service/graphql

Authentication

The gateway provides a robust, built-in Enhanced Authentication Middleware designed for production use. It supports multiple authentication schemes, flexible token validation, and rich user context propagation.

Quick Start

use grpc_graphql_gateway::{
    Gateway, 
    EnhancedAuthMiddleware, 
    AuthConfig, 
    AuthClaims,
    TokenValidator,
    Result
};
use std::sync::Arc;
use async_trait::async_trait;

// 1. Define your token validator
struct MyJwtValidator;

#[async_trait]
impl TokenValidator for MyJwtValidator {
    async fn validate(&self, token: &str) -> Result<AuthClaims> {
        // Implement your JWT validation logic here
        // e.g., decode(token, &decoding_key, &validation)...
        
        Ok(AuthClaims {
            sub: Some("user_123".to_string()),
            roles: vec!["admin".to_string()],
            ..Default::default()
        })
    }
}

// 2. Configure and build the gateway
let auth_middleware = EnhancedAuthMiddleware::new(
    AuthConfig::required()
        .with_scheme(AuthScheme::Bearer),
    Arc::new(MyJwtValidator),
);

let gateway = Gateway::builder()
    .with_descriptor_set_bytes(DESCRIPTORS)
    .add_middleware(auth_middleware)
    .build()?;

Configuration

The AuthConfig builder allows you to customize how authentication is handled:

use grpc_graphql_gateway::{AuthConfig, AuthScheme};

let config = AuthConfig::required()
    // Allow multiple schemes
    .with_scheme(AuthScheme::Bearer)
    .with_scheme(AuthScheme::ApiKey)
    .with_api_key_header("x-service-token")
    
    // Public paths that don't need auth
    .skip_path("/health")
    .skip_path("/metrics")
    
    // Whether to require auth for introspection (default: true)
    .with_skip_introspection(false);

// Or create an optional config (allow unauthenticated requests)
let optional_config = AuthConfig::optional();

Supported Schemes

SchemeDescriptionHeader Example
AuthScheme::BearerStandard Bearer tokenAuthorization: Bearer <token>
AuthScheme::BasicBasic auth credentialsAuthorization: Basic <base64>
AuthScheme::ApiKeyCustom header API keyx-api-key: <key>
AuthScheme::CustomCustom prefixAuthorization: Custom <token>

Token Validation

You can implement the TokenValidator trait for reusable logic, or use a closure for simple cases.

Using a Closure

let auth = EnhancedAuthMiddleware::with_fn(
    AuthConfig::required(),
    |token| Box::pin(async move {
        if token == "secret-password" {
            Ok(AuthClaims {
                sub: Some("admin".to_string()),
                ..Default::default()
            })
        } else {
            Err(Error::Unauthorized("Invalid token".into()))
        }
    })
);

User Context (AuthClaims)

The middleware extracts user information into AuthClaims, which are available in the GraphQL context.

FieldTypeDescription
subOption<String>Subject (User ID)
rolesVec<String>User roles
issOption<String>Issuer
audOption<Vec<String>>Audience
expOption<i64>Expiration (Unix timestamp)
customHashMapCustom claims

Accessing Claims in Resolvers

In your custom resolvers or middleware, you can access these claims via the context:

async fn my_resolver(ctx: &Context) -> Result<String> {
    // Convenience methods
    let user_id = ctx.user_id();     // Option<String>
    let roles = ctx.user_roles();    // Vec<String>
    
    // Check authentication status
    if ctx.get("auth.authenticated") == Some(&serde_json::json!(true)) {
        // ...
    }
    
    // Access full claims
    if let Some(claims) = ctx.get_typed::<AuthClaims>("auth.claims") {
        println!("User: {:?}", claims.sub);
    }
}

Error Handling

  • Missing Token: If AuthConfig::required() is used, returns 401 Unauthorized immediately.
  • Invalid Token: Returns 401 Unauthorized with error details.
  • Expired Token: Automatically checks exp claim and returns 401 if expired.

To permit unauthenticated access (e.g. for public parts of the graph), use AuthConfig::optional(). The request will proceed, but ctx.user_id() will be None.

Authorization

Once a user is authenticated, Authorization determines what they are allowed to do. The gateway facilitates this by making user roles and claims available to your resolvers and downstream services.

Role-Based Access Control (RBAC)

The AuthClaims object includes a roles field (Vec<String>) which works out-of-the-box for RBAC.

Checking Roles in Logic

You can check roles programmatically within your custom resolvers or middleware:

async fn delete_user(ctx: &Context, id: String) -> Result<String> {
    let claims = ctx.get_typed::<AuthClaims>("auth.claims")
        .ok_or(Error::Unauthorized("No claims found".into()))?;
        
    if !claims.has_role("admin") {
        return Err(Error::Forbidden("Admins only".into()));
    }
    
    // Proceed with deletion...
}

Propagating Auth to Backends

The most common pattern in a gateway is to offload fine-grained authorization to the backend services. The gateway’s job is to securely propagate the identity.

Header Propagation

You can forward authentication headers directly to your gRPC services:

// Forward the 'Authorization' header automatically
let gateway = Gateway::builder()
    .with_header_propagation(HeaderPropagationConfig {
        forward_headers: vec!["authorization".to_string()],
        ..Default::default()
    })
    // ...

Metadata Propagation

Alternatively, you can extract claims and inject them as gRPC metadata (headers) for your backends. EnhancedAuthMiddleware does not do this automatically, but you can write a custom middleware to run after it:

struct AuthPropagationMiddleware;

#[async_trait]
impl Middleware for AuthPropagationMiddleware {
    async fn call(&self, ctx: &mut Context) -> Result<()> {
        if let Some(user_id) = ctx.user_id() {
            // Add to headers that will be sent to gRPC backend
            ctx.headers.insert("x-user-id", user_id.parse()?);
        }
        
        if let Some(roles) = ctx.get("auth.roles") {
             // Serialize roles to a header
             let roles_str = serde_json::to_string(roles)?;
             ctx.headers.insert("x-user-roles", roles_str.parse()?);
        }
        
        Ok(())
    }
}

Query Whitelisting

For strict control over what operations can be executed, see the Query Whitelisting feature. This acts as a coarse-grained authorization layer, preventing unauthorized query shapes entirely.

Field-Level Authorization

For advanced field-level authorization (e.g., hiding specific fields based on roles), you currently need to implement this logic in your custom resolvers or within the backend services themselves. The gateway ensures the necessary identity data is present for these decisions to be made.

Security Headers

The gateway automatically adds comprehensive security headers to all HTTP responses, providing defense-in-depth protection for production deployments.

Headers Applied

HTTP Strict Transport Security (HSTS)

Strict-Transport-Security: max-age=31536000; includeSubDomains

Forces browsers to only communicate over HTTPS for one year, including all subdomains. This prevents protocol downgrade attacks and cookie hijacking.

Content Security Policy (CSP)

Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'

Restricts resource loading to same-origin, preventing XSS attacks by blocking inline scripts and external script sources.

X-Content-Type-Options

X-Content-Type-Options: nosniff

Prevents browsers from MIME-sniffing responses, protecting against drive-by download attacks.

X-Frame-Options

X-Frame-Options: DENY

Prevents the page from being embedded in iframes, protecting against clickjacking attacks.

X-XSS-Protection

X-XSS-Protection: 1; mode=block

Enables browser’s built-in XSS filtering (for legacy browsers).

Referrer-Policy

Referrer-Policy: strict-origin-when-cross-origin

Controls referrer information sent with requests, limiting data leakage to third parties.

Cache-Control

Cache-Control: no-store, no-cache, must-revalidate

Prevents caching of sensitive GraphQL responses by browsers and proxies.

CORS Configuration

The gateway handles CORS preflight requests automatically:

OPTIONS Requests

Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization, X-Request-ID
Access-Control-Max-Age: 86400

Customizing CORS

For production deployments, you may want to restrict the Access-Control-Allow-Origin to specific domains. This can be configured in your gateway setup.

Security Test Verification

The gateway includes a comprehensive security test suite (test_security.sh) that verifies all security headers:

./test_security.sh

# Expected output:
[PASS] T1: X-Content-Type-Options: nosniff
[PASS] T2: X-Frame-Options: DENY
[PASS] T12: HSTS Enabled
[PASS] T13: No X-Powered-By Header
[PASS] T14: Server Header Hidden
[PASS] T15: TRACE Rejected (405)
[PASS] T16: OPTIONS/CORS Supported (204)

Best Practices

For Production

  1. Always use HTTPS - HSTS is automatically enabled
  2. Configure specific CORS origins - Replace * with your domain
  3. Review CSP rules - Adjust based on your frontend requirements
  4. Monitor security headers - Use tools like securityheaders.com

Additional Recommendations

  • Enable TLS 1.3 on your reverse proxy (nginx/Cloudflare)
  • Use certificate pinning for high-security applications
  • Implement rate limiting at the edge
  • Enable audit logging for security events

DoS Protection

Protect your gateway and gRPC backends from denial-of-service attacks with query depth and complexity limiting.

Query Depth Limiting

Prevent deeply nested queries that could overwhelm your backends:

let gateway = Gateway::builder()
    .with_descriptor_set_bytes(DESCRIPTORS)
    .with_query_depth_limit(10)  // Max 10 levels of nesting
    .build()?;

What It Prevents

# This would be blocked if depth exceeds limit
query {
  users {           # depth 1
    friends {       # depth 2
      friends {     # depth 3
        friends {   # depth 4
          friends { # depth 5 - blocked if limit < 5
            name
          }
        }
      }
    }
  }
}

Error Response

{
  "errors": [
    {
      "message": "Query is nested too deep",
      "extensions": {
        "code": "QUERY_TOO_DEEP"
      }
    }
  ]
}

Query Complexity Limiting

Limit the total β€œcost” of a query:

let gateway = Gateway::builder()
    .with_descriptor_set_bytes(DESCRIPTORS)
    .with_query_complexity_limit(100)  // Max complexity of 100
    .build()?;

How Complexity is Calculated

Each field adds to the complexity:

# Complexity = 4 (users + friends + name + email)
query {
  users {        # +1
    friends {    # +1
      name       # +1
      email      # +1
    }
  }
}

Error Response

{
  "errors": [
    {
      "message": "Query is too complex",
      "extensions": {
        "code": "QUERY_TOO_COMPLEX"
      }
    }
  ]
}
Use CaseDepth LimitComplexity Limit
Public API5-1050-100
Authenticated Users10-15100-500
Internal/Trusted15-25500-1000

Combining Limits

Use both limits together for comprehensive protection:

let gateway = Gateway::builder()
    .with_descriptor_set_bytes(DESCRIPTORS)
    .with_query_depth_limit(10)
    .with_query_complexity_limit(100)
    .build()?;

Environment-Based Configuration

Adjust limits based on environment:

let depth_limit = std::env::var("QUERY_DEPTH_LIMIT")
    .ok()
    .and_then(|s| s.parse().ok())
    .unwrap_or(10);

let complexity_limit = std::env::var("QUERY_COMPLEXITY_LIMIT")
    .ok()
    .and_then(|s| s.parse().ok())
    .unwrap_or(100);

let gateway = Gateway::builder()
    .with_query_depth_limit(depth_limit)
    .with_query_complexity_limit(complexity_limit)
    .build()?;

Query Whitelisting

Query Whitelisting (also known as Stored Operations or Persisted Operations) is a critical security feature that restricts which GraphQL queries can be executed. This is essential for public-facing GraphQL APIs and required for many compliance standards.

Why Query Whitelisting?

Security Benefits

  • Prevents Arbitrary Queries: Only pre-approved queries can be executed
  • Reduces Attack Surface: Prevents schema exploration and DoS attacks
  • Compliance: Required for PCI-DSS, HIPAA, SOC 2, and other standards
  • Performance: Known queries can be optimized and monitored
  • Audit Trail: Track exactly which queries are being used

Common Use Cases

  1. Public APIs: Prevent malicious actors from crafting expensive queries
  2. Mobile Applications: Apps typically have a fixed set of queries
  3. Third-Party Integrations: Control exactly what partners can query
  4. Compliance Requirements: Meet security standards for regulated industries

Configuration

Basic Setup

use grpc_graphql_gateway::{Gateway, QueryWhitelistConfig, WhitelistMode};
use std::collections::HashMap;

let mut allowed_queries = HashMap::new();
allowed_queries.insert(
    "getUserById".to_string(),
    "query getUserById($id: ID!) { user(id: $id) { id name } }".to_string()
);

let gateway = Gateway::builder()
    .with_query_whitelist(QueryWhitelistConfig {
        mode: WhitelistMode::Enforce,
        allowed_queries,
        allow_introspection: false,
    })
    .build()?;

Loading from JSON File

For production deployments, it’s recommended to load queries from a configuration file:

let config = QueryWhitelistConfig::from_json_file(
    "config/allowed_queries.json",
    WhitelistMode::Enforce
)?;

let gateway = Gateway::builder()
    .with_query_whitelist(config)
    .build()?;

Example JSON file (allowed_queries.json):

{
  "getUserById": "query getUserById($id: ID!) { user(id: $id) { id name email } }",
  "listProducts": "query { products { id name price } }",
  "createOrder": "mutation createOrder($input: OrderInput!) { createOrder(input: $input) { id } }"
}

Enforcement Modes

Enforce Mode (Production)

Rejects non-whitelisted queries with an error.

QueryWhitelistConfig {
    mode: WhitelistMode::Enforce,
    // ...
}

Error response:

{
  "errors": [{
    "message": "Query not in whitelist: Operation 'unknownQuery' (hash: 1234abcd...)",
    "extensions": {
      "code": "QUERY_NOT_WHITELISTED"
    }
  }]
}

Warn Mode (Staging)

Logs warnings but allows all queries. Useful for testing and identifying missing queries.

QueryWhitelistConfig {
    mode: WhitelistMode::Warn,
    // ...
}

Server log:

WARN grpc_graphql_gateway::query_whitelist: Query not in whitelist (allowed in Warn mode): Query hash: 0eb2d2f2e9111722

Disabled Mode (Development)

No whitelist checking. Same as not configuring a whitelist.

QueryWhitelistConfig::disabled()

Validation Methods

The whitelist supports two validation methods that can be used together:

1. Hash-Based Validation

Queries are validated by their SHA-256 hash. This is automatic and requires no client changes.

# This query's hash is calculated automatically
query { user(id: "123") { name } }

Query Normalization (v0.3.7+)

The gateway normalizes queries before hashing, so semantically equivalent queries produce the same hash. This means the following queries all match the same whitelist entry:

# Original
query { hello(name: "World") { message } }

# With extra whitespace
query   {   hello( name: "World" )   { message } }

# With comments stripped
query { # This is ignored
  hello(name: "World") { message }
}

# Multi-line format
query {
  hello(name: "World") {
    message
  }
}

Normalization rules:

  • Comments (# line comments and """ block comments) are removed
  • Whitespace is collapsed (multiple spaces β†’ single space)
  • Whitespace around punctuation ({, }, (, ), :, etc.) is removed
  • String literals are preserved exactly
  • Newlines are treated as whitespace

2. Operation ID Validation

Clients can explicitly reference queries by ID using GraphQL extensions:

Client request:

{
  "query": "query getUserById($id: ID!) { user(id: $id) { name } }",
  "variables": {"id": "123"},
  "extensions": {
    "operationId": "getUserById"
  }
}

The gateway validates the operationId against the whitelist.

Introspection Control

You can optionally allow introspection queries even in Enforce mode:

QueryWhitelistConfig {
    mode: WhitelistMode::Enforce,
    allowed_queries: queries,
    allow_introspection: true,  // Allow __schema and __type queries
}

This is useful for development and staging environments where developers need to explore the schema.

Runtime Management

The whitelist supports runtime modifications for dynamic use cases:

// Get whitelist reference
let whitelist = gateway.mux().query_whitelist().unwrap();

// Register new query at runtime
whitelist.register_query(
    "newQuery".to_string(),
    "query { newField }".to_string()
);

// Remove a query
whitelist.remove_query("oldQuery");

// Get statistics
let stats = whitelist.stats();
println!("Total allowed queries: {}", stats.total_queries);
println!("Mode: {:?}", stats.mode);

Best Practices

1. Use Enforce Mode in Production

Always use WhitelistMode::Enforce in production environments:

let mode = if std::env::var("ENV")? == "production" {
    WhitelistMode::Enforce
} else {
    WhitelistMode::Warn
};

2. Start with Warn Mode

When first implementing whitelisting:

  1. Deploy with Warn mode in staging
  2. Monitor logs to identify all queries
  3. Add missing queries to whitelist
  4. Switch to Enforce mode once complete

3. Version Control Your Whitelist

Store allowed_queries.json in version control alongside your application code.

4. Automated Query Extraction

For frontend applications, consider using tools to automatically extract queries from your codebase:

  • GraphQL Code Generator: Extract queries from React/Vue components
  • Apollo CLI: Generate persisted query manifests
  • Relay Compiler: Built-in persisted query support

5. CI/CD Integration

Validate the whitelist file in your CI pipeline:

# Validate JSON syntax
jq empty allowed_queries.json

# Run gateway with test queries
cargo test --test query_whitelist_validation

Working with APQ

Query Whitelisting and Automatic Persisted Queries (APQ) serve different purposes and work well together:

FeaturePurposeSecurity Level
APQBandwidth optimization (caches any query)Low
WhitelistSecurity (only allows pre-approved queries)High
BothBandwidth savings + SecurityMaximum

Example configuration with both:

Gateway::builder()
    // APQ for bandwidth optimization
    .with_persisted_queries(PersistedQueryConfig {
        cache_size: 1000,
        ttl: Some(Duration::from_secs(3600)),
    })
    // Whitelist for security
    .with_query_whitelist(QueryWhitelistConfig {
        mode: WhitelistMode::Enforce,
        allowed_queries: load_queries()?,
        allow_introspection: false,
    })
    .build()?

Migration Guide

Step 1: Inventory Queries

Use Warn mode to identify all queries currently in use:

.with_query_whitelist(QueryWhitelistConfig {
    mode: WhitelistMode::Warn,
    allowed_queries: HashMap::new(),
    allow_introspection: true,
})

Monitor logs for 1-2 weeks to capture all query variations.

Step 2: Build Whitelist

Extract unique query hashes from logs and build your whitelist file.

Step 3: Test in Staging

Deploy with the whitelist in Warn mode to staging:

# Monitor for any warnings
grep "Query not in whitelist" /var/log/gateway.log

Step 4: Production Deployment

Once confident, switch to Enforce mode:

.with_query_whitelist(QueryWhitelistConfig {
    mode: WhitelistMode::Enforce,
    allowed_queries: load_queries()?,
    allow_introspection: false,  // Disable in production
})

Troubleshooting

Query Rejected Despite Being in Whitelist

Problem: Query is in the whitelist but still gets rejected.

Solution: Ensure the query string exactly matches, including whitespace. Consider normalizing queries or using operation IDs.

Too Many Warnings in Warn Mode

Problem: Logs are flooded with warnings.

Solution: This is expected when first implementing. Collect all unique queries and add them to the whitelist.

Performance Impact

Problem: Concerned about validation overhead.

Solution: Hash calculation is fast (SHA-256). For 1000 RPS, overhead is <1ms. Consider caching if needed.

Example: Complete Production Setup

use grpc_graphql_gateway::{Gateway, QueryWhitelistConfig, WhitelistMode};
use std::path::Path;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Determine mode from environment
    let is_production = std::env::var("ENV")
        .map(|e| e == "production")
        .unwrap_or(false);
    
    // Load whitelist configuration
    let whitelist_config = if Path::new("config/allowed_queries.json").exists() {
        QueryWhitelistConfig::from_json_file(
            "config/allowed_queries.json",
            if is_production {
                WhitelistMode::Enforce
            } else {
                WhitelistMode::Warn
            }
        )?
    } else {
        QueryWhitelistConfig::disabled()
    };
    
    // Build gateway with production settings
    let gateway = Gateway::builder()
        .with_descriptor_set_bytes(DESCRIPTORS)
        .with_query_whitelist(whitelist_config)
        .with_response_cache(CacheConfig::default())
        .with_circuit_breaker(CircuitBreakerConfig::default())
        .with_compression(CompressionConfig::default())
        .build()?;
    
    gateway.serve("0.0.0.0:8888").await?;
    Ok(())
}

See Also

Middleware

The gateway supports an extensible middleware system for authentication, logging, rate limiting, and custom request processing.

Built-in Middleware

Rate Limiting

use grpc_graphql_gateway::{Gateway, RateLimitMiddleware};
use std::time::Duration;

let gateway = Gateway::builder()
    .with_descriptor_set_bytes(DESCRIPTORS)
    .add_middleware(RateLimitMiddleware::new(
        100,                          // Max requests
        Duration::from_secs(60),      // Per time window
    ))
    .build()?;

Custom Middleware

Implement the Middleware trait:

use grpc_graphql_gateway::middleware::{Middleware, Context};
use async_trait::async_trait;
use futures::future::BoxFuture;

struct AuthMiddleware {
    secret_key: String,
}

#[async_trait]
impl Middleware for AuthMiddleware {
    async fn call(
        &self,
        ctx: &mut Context,
        next: Box<dyn Fn(&mut Context) -> BoxFuture<'_, Result<()>>>,
    ) -> Result<()> {
        // Extract token from headers
        let token = ctx.headers()
            .get("authorization")
            .and_then(|v| v.to_str().ok())
            .ok_or_else(|| Error::Unauthorized)?;
        
        // Validate token
        let user = validate_jwt(token, &self.secret_key)?;
        
        // Add user info to context extensions
        ctx.extensions_mut().insert(user);
        
        // Continue to next middleware/handler
        next(ctx).await
    }
}

let gateway = Gateway::builder()
    .add_middleware(AuthMiddleware { secret_key: "secret".into() })
    .build()?;

Middleware Chain

Middlewares execute in order of registration:

Gateway::builder()
    .add_middleware(LoggingMiddleware)      // 1st: Log request
    .add_middleware(AuthMiddleware)         // 2nd: Authenticate
    .add_middleware(RateLimitMiddleware)    // 3rd: Rate limit
    .build()?

Context Object

The Context provides access to:

MethodDescription
headers()HTTP request headers
extensions()Shared data between middlewares
extensions_mut()Mutable access to extensions

Logging Middleware Example

struct LoggingMiddleware;

#[async_trait]
impl Middleware for LoggingMiddleware {
    async fn call(
        &self,
        ctx: &mut Context,
        next: Box<dyn Fn(&mut Context) -> BoxFuture<'_, Result<()>>>,
    ) -> Result<()> {
        let start = std::time::Instant::now();
        
        let result = next(ctx).await;
        
        tracing::info!(
            duration_ms = start.elapsed().as_millis(),
            success = result.is_ok(),
            "GraphQL request completed"
        );
        
        result
    }
}

Error Handling

Return errors from middleware to reject requests:

#[async_trait]
impl Middleware for AuthMiddleware {
    async fn call(
        &self,
        ctx: &mut Context,
        next: Box<dyn Fn(&mut Context) -> BoxFuture<'_, Result<()>>>,
    ) -> Result<()> {
        if !self.is_authorized(ctx) {
            return Err(Error::new("Unauthorized").extend_with(|_, e| {
                e.set("code", "UNAUTHORIZED");
            }));
        }
        
        next(ctx).await
    }
}

Error Handler

Set a global error handler for logging or transforming errors:

Gateway::builder()
    .with_error_handler(|errors| {
        for error in &errors {
            tracing::error!(
                message = %error.message,
                "GraphQL error"
            );
        }
    })
    .build()?

Health Checks

Enable Kubernetes-compatible health check endpoints for container orchestration.

Enabling Health Checks

let gateway = Gateway::builder()
    .with_descriptor_set_bytes(DESCRIPTORS)
    .enable_health_checks()
    .add_grpc_client("service", client)
    .build()?;

Endpoints

EndpointPurposeSuccess Response
GET /healthLiveness probe200 OK if server is running
GET /readyReadiness probe200 OK if gRPC clients configured

Response Format

{
  "status": "healthy",
  "components": {
    "grpc_clients": {
      "status": "healthy",
      "count": 3
    }
  }
}

Kubernetes Configuration

apiVersion: apps/v1
kind: Deployment
metadata:
  name: graphql-gateway
spec:
  template:
    spec:
      containers:
        - name: gateway
          image: your-gateway:latest
          ports:
            - containerPort: 8888
          livenessProbe:
            httpGet:
              path: /health
              port: 8888
            initialDelaySeconds: 5
            periodSeconds: 10
            failureThreshold: 3
          readinessProbe:
            httpGet:
              path: /ready
              port: 8888
            initialDelaySeconds: 5
            periodSeconds: 10
            failureThreshold: 3

Health States

StateDescription
healthyAll components working
degradedPartial functionality
unhealthyService unavailable

Custom Health Checks

The gateway automatically checks:

  • Server is running (liveness)
  • gRPC clients are configured (readiness)

For additional checks, consider using middleware or external health check services.

Load Balancer Integration

Health endpoints work with:

  • AWS ALB/NLB health checks
  • Google Cloud Load Balancer
  • Azure Load Balancer
  • HAProxy/Nginx health checks

Testing Health Endpoints

# Liveness check
curl http://localhost:8888/health
# {"status":"healthy"}

# Readiness check  
curl http://localhost:8888/ready
# {"status":"healthy","components":{"grpc_clients":{"status":"healthy","count":2}}}

Prometheus Metrics

Enable a /metrics endpoint exposing Prometheus-compatible metrics.

Enabling Metrics

let gateway = Gateway::builder()
    .with_descriptor_set_bytes(DESCRIPTORS)
    .enable_metrics()
    .build()?;

Available Metrics

MetricTypeLabelsDescription
graphql_requests_totalCounteroperation_typeTotal GraphQL requests
graphql_request_duration_secondsHistogramoperation_typeRequest latency
graphql_errors_totalCountererror_typeTotal GraphQL errors
grpc_backend_requests_totalCounterservice, methodgRPC backend calls
grpc_backend_duration_secondsHistogramservice, methodgRPC latency

Prometheus Scrape Configuration

scrape_configs:
  - job_name: 'graphql-gateway'
    static_configs:
      - targets: ['gateway:8888']
    metrics_path: '/metrics'
    scrape_interval: 15s

Example Metrics Output

# HELP graphql_requests_total Total number of GraphQL requests
# TYPE graphql_requests_total counter
graphql_requests_total{operation_type="query"} 1523
graphql_requests_total{operation_type="mutation"} 234
graphql_requests_total{operation_type="subscription"} 56

# HELP graphql_request_duration_seconds Request duration in seconds
# TYPE graphql_request_duration_seconds histogram
graphql_request_duration_seconds_bucket{operation_type="query",le="0.01"} 1200
graphql_request_duration_seconds_bucket{operation_type="query",le="0.05"} 1480
graphql_request_duration_seconds_bucket{operation_type="query",le="0.1"} 1510
graphql_request_duration_seconds_bucket{operation_type="query",le="+Inf"} 1523

# HELP grpc_backend_requests_total Total gRPC backend calls
# TYPE grpc_backend_requests_total counter
grpc_backend_requests_total{service="UserService",method="GetUser"} 892
grpc_backend_requests_total{service="ProductService",method="GetProduct"} 631

Grafana Dashboard

Create dashboards for:

  • Request rate and latency percentiles
  • Error rates by type
  • gRPC backend health
  • Operation type distribution

Example Queries

Request Rate:

rate(graphql_requests_total[5m])

P99 Latency:

histogram_quantile(0.99, rate(graphql_request_duration_seconds_bucket[5m]))

Error Rate:

rate(graphql_errors_total[5m]) / rate(graphql_requests_total[5m])

Programmatic Access

Use the metrics API directly:

use grpc_graphql_gateway::{GatewayMetrics, RequestTimer};

// Record custom metrics
let timer = GatewayMetrics::global().start_request_timer("query");
// ... process request
timer.observe_duration();

// Record gRPC calls
let grpc_timer = GatewayMetrics::global().start_grpc_timer("UserService", "GetUser");
// ... make gRPC call
grpc_timer.observe_duration();

OpenTelemetry Tracing

Enable distributed tracing for end-to-end visibility across your system.

Setting Up Tracing

use grpc_graphql_gateway::{Gateway, TracingConfig, init_tracer, shutdown_tracer};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Initialize the tracer
    let config = TracingConfig::new()
        .with_service_name("my-gateway")
        .with_sample_ratio(1.0);  // Sample all requests

    let _provider = init_tracer(&config);

    let gateway = Gateway::builder()
        .with_descriptor_set_bytes(DESCRIPTORS)
        .enable_tracing()
        .build()?;

    gateway.serve("0.0.0.0:8888").await?;

    // Shutdown on exit
    shutdown_tracer();
    Ok(())
}

Spans Created

SpanKindDescription
graphql.queryServerGraphQL query operation
graphql.mutationServerGraphQL mutation operation
grpc.callClientgRPC backend call

Span Attributes

GraphQL Spans

AttributeDescription
graphql.operation.nameThe operation name if provided
graphql.operation.typequery, mutation, or subscription
graphql.documentThe GraphQL query (truncated)

gRPC Spans

AttributeDescription
rpc.servicegRPC service name
rpc.methodgRPC method name
rpc.grpc.status_codegRPC status code

OTLP Export

Enable OTLP export by adding the feature:

[dependencies]
grpc_graphql_gateway = { version = "0.2", features = ["otlp"] }

Then configure the exporter:

use grpc_graphql_gateway::TracingConfig;

let config = TracingConfig::new()
    .with_service_name("my-gateway")
    .with_otlp_endpoint("http://jaeger:4317");

Jaeger Integration

Run Jaeger locally:

docker run -d --name jaeger \
  -p 4317:4317 \
  -p 16686:16686 \
  jaegertracing/jaeger:1.47

View traces at: http://localhost:16686

Sampling Configuration

Sample RatioDescription
1.0Sample all requests (dev)
0.1Sample 10% (staging)
0.01Sample 1% (production)
TracingConfig::new()
    .with_sample_ratio(0.1)  // 10% sampling

Context Propagation

The gateway automatically propagates trace context:

  • Incoming HTTP headers (traceparent, tracestate)
  • Outgoing gRPC metadata

Enable Header Propagation for distributed tracing headers.

Introspection Control

Disable GraphQL introspection in production to prevent schema discovery attacks.

Disabling Introspection

let gateway = Gateway::builder()
    .with_descriptor_set_bytes(DESCRIPTORS)
    .disable_introspection()
    .build()?;

What It Blocks

When introspection is disabled, these queries return errors:

# Blocked
{
  __schema {
    types {
      name
    }
  }
}

# Blocked
{
  __type(name: "User") {
    fields {
      name
    }
  }
}

Error Response

{
  "errors": [
    {
      "message": "Introspection is disabled",
      "extensions": {
        "code": "INTROSPECTION_DISABLED"
      }
    }
  ]
}

Environment-Based Toggle

Enable introspection only in development:

let is_production = std::env::var("ENV")
    .map(|e| e == "production")
    .unwrap_or(false);

let mut builder = Gateway::builder()
    .with_descriptor_set_bytes(DESCRIPTORS);

if is_production {
    builder = builder.disable_introspection();
}

let gateway = builder.build()?;

Security Benefits

Disabling introspection:

  • Prevents attackers from discovering your schema structure
  • Reduces attack surface for GraphQL-specific exploits
  • Hides internal type names and field descriptions

When to Disable

EnvironmentIntrospection
Developmentβœ… Enabled
Staging⚠️ Consider disabling
Production❌ Disabled

Alternative: Authorization

Instead of fully disabling, you can selectively allow introspection:

struct IntrospectionMiddleware {
    allowed_keys: HashSet<String>,
}

impl Middleware for IntrospectionMiddleware {
    async fn call(&self, ctx: &mut Context, next: ...) -> Result<()> {
        // Check if request is introspection
        if is_introspection_query(ctx) {
            let api_key = ctx.headers().get("x-api-key");
            if !self.allowed_keys.contains(api_key) {
                return Err(Error::new("Introspection not allowed"));
            }
        }
        next(ctx).await
    }
}

See Also

  • Query Whitelisting - For maximum security, combine introspection control with query whitelisting to restrict both schema discovery and query execution
  • DoS Protection - Query depth and complexity limits

REST API Connectors

The gateway supports REST API Connectors, enabling hybrid architectures where GraphQL fields can resolve data from both gRPC services and REST APIs. This is perfect for gradual migrations, integrating third-party APIs, or bridging legacy systems.

Quick Start

use grpc_graphql_gateway::{Gateway, RestConnector, RestEndpoint, HttpMethod};
use std::time::Duration;

let rest_connector = RestConnector::builder()
    .base_url("https://api.example.com")
    .timeout(Duration::from_secs(30))
    .default_header("Accept", "application/json")
    .add_endpoint(RestEndpoint::new("getUser", "/users/{id}")
        .method(HttpMethod::GET)
        .response_path("$.data")
        .description("Fetch a user by ID"))
    .add_endpoint(RestEndpoint::new("createUser", "/users")
        .method(HttpMethod::POST)
        .body_template(r#"{"name": "{name}", "email": "{email}"}"#))
    .build()?;

let gateway = Gateway::builder()
    .with_descriptor_set_bytes(DESCRIPTORS)
    .add_rest_connector("users_api", rest_connector)
    .add_grpc_client("UserService", grpc_client)
    .build()?;

GraphQL Schema Integration

REST endpoints are automatically exposed as GraphQL fields. The gateway generates:

  • Query fields for GET endpoints
  • Mutation fields for POST/PUT/PATCH/DELETE endpoints

Field names use the endpoint name directly (e.g., getUser, createPost).

Example GraphQL Queries

# Query a REST endpoint (GET /users/{id})
query {
  getUser(id: "123")
}

# Mutation to create via REST (POST /users)
mutation {
  createUser(name: "Alice", email: "alice@example.com")
}

Example Response

{
  "data": {
    "getUser": {
      "id": 123,
      "name": "Alice",
      "email": "alice@example.com"
    }
  }
}

REST responses are returned as the JSON scalar type, preserving the full structure from the API.

RestConnector

The RestConnector is the main entry point for REST API integration.

Builder Methods

MethodDescription
base_url(url)Required. Base URL for all endpoints
timeout(duration)Default timeout (default: 30s)
default_header(key, value)Add header to all requests
retry(config)Custom retry configuration
no_retry()Disable retries
log_bodies(true)Enable request/response body logging
with_cache(size)Enable LRU response cache for GET requests
interceptor(interceptor)Add request interceptor
transformer(transformer)Custom response transformer
add_endpoint(endpoint)Add a REST endpoint

RestEndpoint

Define individual REST endpoints with flexible configuration.

use grpc_graphql_gateway::{RestEndpoint, HttpMethod};

let endpoint = RestEndpoint::new("getUser", "/users/{id}")
    .method(HttpMethod::GET)
    .header("X-Custom-Header", "value")
    .query_param("include", "profile")
    .response_path("$.data.user")
    .timeout(Duration::from_secs(10))
    .description("Fetch a user by ID")
    .return_type("User");

Path Templates

Use {variable} placeholders in paths:

RestEndpoint::new("getOrder", "/users/{userId}/orders/{orderId}")

When called with { "userId": "123", "orderId": "456" }, resolves to:

/users/123/orders/456

Query Parameters

Add templated query parameters:

RestEndpoint::new("searchUsers", "/users")
    .query_param("q", "{query}")
    .query_param("limit", "{limit}")

Body Templates

For POST/PUT/PATCH, define request body templates:

RestEndpoint::new("createUser", "/users")
    .method(HttpMethod::POST)
    .body_template(r#"{
        "name": "{name}",
        "email": "{email}",
        "role": "{role}"
    }"#)

If no body template is provided, arguments are automatically serialized as JSON.

Response Extraction

Extract nested data from responses using JSONPath:

// API returns: { "status": "ok", "data": { "user": { "id": "123" } } }
RestEndpoint::new("getUser", "/users/{id}")
    .response_path("$.data.user")  // Returns just the user object

Supported JSONPath:

  • $.field - Access field
  • $.field.nested - Nested access
  • $.array[0] - Array index
  • $.array[0].field - Combined

Typed Responses

By default, REST endpoints return a JSON scalar blob. To enable field selection in GraphQL queries (e.g. { getUser { name email } }), you can define a response schema:

use grpc_graphql_gateway::{RestResponseSchema, RestResponseField};

RestEndpoint::new("getUser", "/users/{id}")
    .with_response_schema(RestResponseSchema::new("User")
        .field(RestResponseField::int("id"))
        .field(RestResponseField::string("name"))
        .field(RestResponseField::string("email"))
        // Define a nested object field
        .field(RestResponseField::object("address", "Address"))
    )

This registers a User type in the schema and allows clients to select only the fields they need.

Mutations vs Queries

Endpoints are automatically classified:

  • Queries: GET requests
  • Mutations: POST, PUT, PATCH, DELETE

Override explicitly:

// Force a POST to be a query (e.g., search endpoint)
RestEndpoint::new("searchUsers", "/users/search")
    .method(HttpMethod::POST)
    .as_query()

HTTP Methods

use grpc_graphql_gateway::HttpMethod;

HttpMethod::GET      // Read operations
HttpMethod::POST     // Create operations
HttpMethod::PUT      // Full update
HttpMethod::PATCH    // Partial update
HttpMethod::DELETE   // Delete operations

Authentication

Bearer Token

use grpc_graphql_gateway::{RestConnector, BearerAuthInterceptor};
use std::sync::Arc;

let connector = RestConnector::builder()
    .base_url("https://api.example.com")
    .interceptor(Arc::new(BearerAuthInterceptor::new("your-token")))
    .build()?;

The interceptor adds: Authorization: Bearer your-token

API Key

use grpc_graphql_gateway::{RestConnector, ApiKeyInterceptor};
use std::sync::Arc;

let connector = RestConnector::builder()
    .base_url("https://api.example.com")
    .interceptor(Arc::new(ApiKeyInterceptor::x_api_key("your-api-key")))
    .build()?;

The interceptor adds: X-API-Key: your-api-key

Custom Interceptor

Implement the RequestInterceptor trait for custom auth:

use grpc_graphql_gateway::{RequestInterceptor, RestRequest, Result};
use async_trait::async_trait;

struct CustomAuthInterceptor {
    // Your auth logic
}

#[async_trait]
impl RequestInterceptor for CustomAuthInterceptor {
    async fn intercept(&self, request: &mut RestRequest) -> Result<()> {
        // Add custom headers, modify URL, etc.
        request.headers.insert(
            "X-Custom-Auth".to_string(),
            "custom-value".to_string()
        );
        Ok(())
    }
}

Retry Configuration

Configure automatic retries with exponential backoff:

use grpc_graphql_gateway::{RestConnector, RetryConfig};
use std::time::Duration;

let connector = RestConnector::builder()
    .base_url("https://api.example.com")
    .retry(RetryConfig {
        max_retries: 3,
        initial_backoff: Duration::from_millis(100),
        max_backoff: Duration::from_secs(10),
        multiplier: 2.0,
        retry_statuses: vec![429, 500, 502, 503, 504],
    })
    .build()?;

Preset Configurations

// Disable retries
RetryConfig::disabled()

// Aggressive retries for critical endpoints
RetryConfig::aggressive()

Response Caching

Enable LRU caching for GET requests:

let connector = RestConnector::builder()
    .base_url("https://api.example.com")
    .with_cache(1000)  // Cache up to 1000 responses
    .build()?;

// Clear cache manually
connector.clear_cache().await;

Cache keys are based on endpoint name + arguments.

Multiple Connectors

Register multiple REST connectors for different services:

let users_api = RestConnector::builder()
    .base_url("https://users.example.com")
    .add_endpoint(RestEndpoint::new("getUser", "/users/{id}"))
    .build()?;

let products_api = RestConnector::builder()
    .base_url("https://products.example.com")
    .add_endpoint(RestEndpoint::new("getProduct", "/products/{id}"))
    .build()?;

let orders_api = RestConnector::builder()
    .base_url("https://orders.example.com")
    .add_endpoint(RestEndpoint::new("getOrder", "/orders/{id}"))
    .build()?;

let gateway = Gateway::builder()
    .add_rest_connector("users", users_api)
    .add_rest_connector("products", products_api)
    .add_rest_connector("orders", orders_api)
    .build()?;

Executing Endpoints

Execute endpoints programmatically:

use std::collections::HashMap;
use serde_json::json;

let mut args = HashMap::new();
args.insert("id".to_string(), json!("123"));

let result = connector.execute("getUser", args).await?;

Custom Response Transformer

Transform responses before returning to GraphQL:

use grpc_graphql_gateway::{ResponseTransformer, RestResponse, Result};
use async_trait::async_trait;
use serde_json::Value as JsonValue;
use std::sync::Arc;

struct SnakeToCamelTransformer;

#[async_trait]
impl ResponseTransformer for SnakeToCamelTransformer {
    async fn transform(&self, endpoint: &str, response: RestResponse) -> Result<JsonValue> {
        // Transform snake_case keys to camelCase
        Ok(transform_keys(response.body))
    }
}

let connector = RestConnector::builder()
    .base_url("https://api.example.com")
    .transformer(Arc::new(SnakeToCamelTransformer))
    .build()?;

Use Cases

ScenarioDescription
Hybrid ArchitectureMix gRPC and REST backends in one GraphQL API
Gradual MigrationMigrate from REST to gRPC incrementally
Third-Party APIsIntegrate external REST APIs (Stripe, Twilio, etc.)
Legacy SystemsBridge legacy REST services with modern infrastructure
Multi-ProtocolSupport teams using different backend technologies

Best Practices

  1. Set Appropriate Timeouts: Use shorter timeouts for internal services, longer for external APIs.

  2. Enable Retries for Idempotent Operations: GET, PUT, DELETE are typically safe to retry.

  3. Use Response Extraction: Extract only needed data with response_path to reduce payload size.

  4. Cache Read-Heavy Endpoints: Enable caching for frequently-accessed, rarely-changing data.

  5. Secure Credentials: Use environment variables for API keys and tokens, not hardcoded values.

  6. Log Bodies in Development Only: Enable log_bodies only in development to avoid leaking sensitive data.

See Also

OpenAPI Integration

The gateway can automatically generate REST connectors from OpenAPI (Swagger) specification files. This enables quick integration of REST APIs without manual endpoint configuration.

Supported Formats

FormatExtensionFeature Required
OpenAPI 3.0.x.jsonNone
OpenAPI 3.1.x.jsonNone
Swagger 2.0.jsonNone
YAML (any version).yaml, .ymlyaml

Quick Start

use grpc_graphql_gateway::{Gateway, OpenApiParser};

// Parse OpenAPI spec and create REST connector
let connector = OpenApiParser::from_file("petstore.yaml")?
    .with_base_url("https://api.petstore.io/v2")
    .build()?;

let gateway = Gateway::builder()
    .add_rest_connector("petstore", connector)
    .build()?;

Loading Options

From a File

// JSON file
let connector = OpenApiParser::from_file("api.json")?.build()?;

// YAML file (requires 'yaml' feature)
let connector = OpenApiParser::from_file("api.yaml")?.build()?;

From a URL

let connector = OpenApiParser::from_url("https://api.example.com/openapi.json")
    .await?
    .build()?;

From a String

let json_content = r#"{"openapi": "3.0.0", ...}"#;
let connector = OpenApiParser::from_string(json_content, false)?.build()?;

// For YAML content
let yaml_content = "openapi: '3.0.0'\n...";
let connector = OpenApiParser::from_string(yaml_content, true)?.build()?;

From JSON Value

let json_value: serde_json::Value = serde_json::from_str(content)?;
let connector = OpenApiParser::from_json(json_value)?.build()?;

Configuration Options

Base URL Override

Override the server URL from the spec:

let connector = OpenApiParser::from_file("api.json")?
    .with_base_url("https://api.staging.example.com")  // Use staging
    .build()?;

Timeout

Set a default timeout for all endpoints:

use std::time::Duration;

let connector = OpenApiParser::from_file("api.json")?
    .with_timeout(Duration::from_secs(60))
    .build()?;

Operation Prefix

Add a prefix to all operation names to avoid conflicts:

let connector = OpenApiParser::from_file("petstore.json")?
    .with_prefix("petstore_")  // listPets -> petstore_listPets
    .build()?;

Filtering Operations

By Tags

Only include operations with specific tags:

let connector = OpenApiParser::from_file("api.json")?
    .with_tags(vec!["pets".to_string(), "store".to_string()])
    .build()?;

Custom Filter

Use a predicate function for fine-grained control:

let connector = OpenApiParser::from_file("api.json")?
    .filter_operations(|operation_id, path| {
        // Only include non-deprecated v2 endpoints
        !operation_id.contains("deprecated") && path.starts_with("/api/v2")
    })
    .build()?;

What Gets Generated

The parser automatically generates:

Endpoints

Each path operation becomes a GraphQL field:

OpenAPIGraphQL
GET /petslistPets query
POST /petscreatePet mutation
GET /pets/{petId}getPet query
DELETE /pets/{petId}deletePet mutation

Arguments

  • Path parameters β†’ Required field arguments
  • Query parameters β†’ Optional field arguments
  • Request body β†’ Input arguments (auto-templated)

Response Types

Response schemas are converted to GraphQL types:

# OpenAPI
Pet:
  type: object
  properties:
    id:
      type: integer
    name:
      type: string
    tag:
      type: string

Becomes a GraphQL type with field selection.

Listing Operations

Before building, you can list all available operations:

let parser = OpenApiParser::from_file("api.json")?;

for op in parser.list_operations() {
    println!("{}: {} {} (tags: {:?})", 
        op.operation_id, 
        op.method, 
        op.path,
        op.tags
    );
    if let Some(summary) = op.summary {
        println!("  {}", summary);
    }
}

Accessing Spec Information

let parser = OpenApiParser::from_file("api.json")?;

let info = parser.info();
println!("API: {} v{}", info.title, info.version);
if let Some(desc) = &info.description {
    println!("Description: {}", desc);
}

YAML Support

To enable YAML parsing, add the yaml feature:

[dependencies]
grpc_graphql_gateway = { version = "0.3", features = ["yaml"] }

Example: Petstore Integration

use grpc_graphql_gateway::{Gateway, OpenApiParser};
use std::time::Duration;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // Parse the Petstore OpenAPI spec
    let petstore = OpenApiParser::from_url(
        "https://petstore3.swagger.io/api/v3/openapi.json"
    )
    .await?
    .with_base_url("https://petstore3.swagger.io/api/v3")
    .with_timeout(Duration::from_secs(30))
    .with_tags(vec!["pet".to_string()])  // Only pet operations
    .with_prefix("pet_")                  // Namespace operations
    .build()?;

    // Create the gateway
    let gateway = Gateway::builder()
        .with_descriptor_set_bytes(DESCRIPTORS)
        .add_grpc_client("service", grpc_client)
        .add_rest_connector("petstore", petstore)
        .serve("0.0.0.0:8888".to_string())
        .await?;

    Ok(())
}

Multiple REST APIs

Combine multiple OpenAPI specs:

// Payment API
let stripe = OpenApiParser::from_file("stripe-openapi.json")?
    .with_prefix("stripe_")
    .build()?;

// Email API
let sendgrid = OpenApiParser::from_file("sendgrid-openapi.json")?
    .with_prefix("email_")
    .build()?;

// User service (gRPC)
let gateway = Gateway::builder()
    .with_descriptor_set_bytes(USER_DESCRIPTORS)
    .add_grpc_client("users", users_client)
    .add_rest_connector("stripe", stripe)
    .add_rest_connector("sendgrid", sendgrid)
    .build()?;

Best Practices

  1. Use prefixes when combining multiple APIs to avoid naming conflicts

  2. Filter by tags to include only the operations you need

  3. Override base URLs for different environments (dev, staging, prod)

  4. Check available operations before building to understand what will be generated

  5. Enable YAML feature only if you need it (adds serde_yaml dependency)

Limitations

  • Authentication: OpenAPI security schemes are not automatically applied. Use request interceptors for auth.
  • Complex schemas: Very complex schemas (allOf, oneOf, anyOf) may be simplified.
  • Webhooks: OpenAPI 3.1 webhooks are not supported.
  • Callbacks: Async callbacks are not supported.

Response Caching

Dramatically improve performance with in-memory GraphQL response caching.

Enabling Caching

use grpc_graphql_gateway::{Gateway, CacheConfig};
use std::time::Duration;

let gateway = Gateway::builder()
    .with_descriptor_set_bytes(DESCRIPTORS)
    .with_response_cache(CacheConfig {
        max_size: 10_000,                              // Max cached responses
        default_ttl: Duration::from_secs(60),          // 1 minute TTL
        stale_while_revalidate: Some(Duration::from_secs(30)),
        invalidate_on_mutation: true,
    })
    .build()?;

Configuration Options

OptionTypeDescription
max_sizeusizeMaximum number of cached responses
default_ttlDurationTime before entries expire
stale_while_revalidateOption<Duration>Serve stale content while refreshing
invalidate_on_mutationboolClear cache on mutations
redis_urlOption<String>Redis connection URL for distributed caching
vary_headersVec<String>Headers to include in cache key (default: ["Authorization"])

Distributed Caching (Redis)

Use Redis for shared caching across multiple gateway instances:

let gateway = Gateway::builder()
    .with_response_cache(CacheConfig {
        redis_url: Some("redis://127.0.0.1:6379".to_string()),
        default_ttl: Duration::from_secs(60),
        ..Default::default()
    })
    .build()?;

Vary Headers

By default, the cache key includes the Authorization header to prevent leaking user data. You can configure which headers affect the cache key:

CacheConfig {
    // Cache per user and per tenant
    vary_headers: vec!["Authorization".to_string(), "X-Tenant-ID".to_string()],
    ..Default::default()
}

How It Works

  1. First Query: Cache miss β†’ Execute gRPC β†’ Cache response β†’ Return
  2. Second Query: Cache hit β†’ Return cached response immediately (<1ms)
  3. Mutation: Execute mutation β†’ Invalidate related cache entries
  4. Next Query: Cache miss (invalidated) β†’ Execute gRPC β†’ Cache β†’ Return

What Gets Cached

OperationCached?Triggers Invalidation?
Queryβœ… YesNo
Mutation❌ Noβœ… Yes
Subscription❌ NoNo

Cache Key Generation

The cache key is a SHA-256 hash of:

  • Normalized query string
  • Sorted variables JSON
  • Operation name (if provided)

Stale-While-Revalidate

Serve stale content immediately while refreshing in the background:

CacheConfig {
    default_ttl: Duration::from_secs(60),
    stale_while_revalidate: Some(Duration::from_secs(30)),
    ..Default::default()
}

Timeline:

  • 0-60s: Fresh content served
  • 60-90s: Stale content served, refresh triggered
  • 90s+: Cache miss, fresh fetch

Mutation Invalidation

When invalidate_on_mutation: true:

// This mutation invalidates cache
mutation { updateUser(id: "123", name: "Alice") { id name } }

// Subsequent queries fetch fresh data
query { user(id: "123") { id name } }

Testing with curl

# 1. First query - cache miss
curl -X POST http://localhost:8888/graphql \
  -H "Content-Type: application/json" \
  -d '{"query": "{ user(id: \"123\") { name } }"}'

# 2. Same query - cache hit (instant)
curl -X POST http://localhost:8888/graphql \
  -H "Content-Type: application/json" \
  -d '{"query": "{ user(id: \"123\") { name } }"}'

# 3. Mutation - invalidates cache
curl -X POST http://localhost:8888/graphql \
  -H "Content-Type: application/json" \
  -d '{"query": "mutation { updateUser(id: \"123\", name: \"Bob\") { name } }"}'

# 4. Query again - cache miss (fresh data)
curl -X POST http://localhost:8888/graphql \
  -H "Content-Type: application/json" \
  -d '{"query": "{ user(id: \"123\") { name } }"}'

Performance Impact

  • Cache hits: <1ms response time
  • 10-100x fewer gRPC backend calls
  • Significant reduction in backend load

Response Compression

Reduce bandwidth with automatic response compression.

Enabling Compression

use grpc_graphql_gateway::{Gateway, CompressionConfig, CompressionLevel};

let gateway = Gateway::builder()
    .with_descriptor_set_bytes(DESCRIPTORS)
    .with_compression(CompressionConfig {
        enabled: true,
        level: CompressionLevel::Default,
        min_size_bytes: 1024,  // Only compress responses > 1KB
        algorithms: vec!["br".into(), "gzip".into()],
    })
    .build()?;

Preset Configurations

// Fast compression for low latency
Gateway::builder().with_compression(CompressionConfig::fast())

// Best compression for bandwidth savings
Gateway::builder().with_compression(CompressionConfig::best())

// Default balanced configuration
Gateway::builder().with_compression(CompressionConfig::default())

// Disable compression
Gateway::builder().with_compression(CompressionConfig::disabled())

Supported Algorithms

AlgorithmAccept-EncodingCompression RatioSpeed
BrotlibrBestSlower
GzipgzipGoodFast
DeflatedeflateGoodFast
ZstdzstdExcellentFast

Algorithm Selection

The gateway selects the best algorithm based on client Accept-Encoding:

Accept-Encoding: br, gzip, deflate

Priority order matches your algorithms configuration.

Compression Levels

LevelDescriptionUse Case
FastMinimal compression, fastLow latency APIs
DefaultBalancedMost applications
BestMaximum compressionBandwidth-constrained

Configuration Options

OptionTypeDescription
enabledboolEnable/disable compression
levelCompressionLevelCompression speed vs ratio
min_size_bytesusizeSkip compression for small responses
algorithmsVec<String>Enabled algorithms in priority order

Testing Compression

# Request with brotli
curl -H "Accept-Encoding: br" \
  -X POST http://localhost:8888/graphql \
  -H "Content-Type: application/json" \
  -d '{"query": "{ users { id name email } }"}' \
  --compressed -v

# Check Content-Encoding header in response
< Content-Encoding: br

Performance Considerations

  • JSON responses typically compress 50-90%
  • Set min_size_bytes to skip small responses
  • Use CompressionLevel::Fast for latency-sensitive apps
  • Balance CPU cost vs. bandwidth savings

Request Collapsing

Request collapsing (also known as request deduplication) is a powerful optimization that reduces the number of gRPC backend calls by identifying and coalescing identical concurrent requests.

How It Works

When a GraphQL query contains multiple fields that call the same gRPC method with identical arguments, request collapsing ensures only one gRPC call is made:

query {
  user1: getUser(id: "1") { name }
  user2: getUser(id: "2") { name }
  user3: getUser(id: "1") { name }  # Duplicate of user1!
}

Without Request Collapsing: 3 gRPC calls are made.

With Request Collapsing: Only 2 gRPC calls are made (user1 and user3 share the same response).

The Leader-Follower Pattern

  1. Leader: The first request with a unique key executes the gRPC call
  2. Followers: Subsequent identical requests wait for the leader’s result
  3. Broadcast: When the leader completes, it broadcasts the result to all followers
  4. Cleanup: The in-flight entry is removed after broadcasting

Configuration

use grpc_graphql_gateway::{Gateway, RequestCollapsingConfig};
use std::time::Duration;

let gateway = Gateway::builder()
    .with_descriptor_set_bytes(DESCRIPTORS)
    .with_request_collapsing(RequestCollapsingConfig::default())
    .add_grpc_client("service", client)
    .build()?;

Configuration Options

OptionDefaultDescription
coalesce_window50msMaximum time to wait for in-flight requests
max_waiters100Maximum followers waiting for a single leader
enabledtrueEnable/disable collapsing
max_cache_size10000Maximum in-flight requests to track

Builder Pattern

let config = RequestCollapsingConfig::new()
    .coalesce_window(Duration::from_millis(100))  // Longer window
    .max_waiters(200)                              // More waiters allowed
    .max_cache_size(20000)                         // Larger cache
    .enabled(true);

Presets

Request collapsing comes with several presets for common scenarios:

Default (Balanced)

let config = RequestCollapsingConfig::default();
// coalesce_window: 50ms
// max_waiters: 100
// max_cache_size: 10000

Best for most workloads with a balance between latency and deduplication.

High Throughput

let config = RequestCollapsingConfig::high_throughput();
// coalesce_window: 100ms
// max_waiters: 500
// max_cache_size: 50000

Best for high-traffic scenarios where maximizing deduplication is more important than latency.

Low Latency

let config = RequestCollapsingConfig::low_latency();
// coalesce_window: 10ms
// max_waiters: 50
// max_cache_size: 5000

Best for latency-sensitive applications where quick responses are critical.

Disabled

let config = RequestCollapsingConfig::disabled();

Completely disables request collapsing.

Monitoring

You can monitor request collapsing effectiveness using the built-in statistics:

// Get the registry from ServeMux
if let Some(registry) = mux.request_collapsing() {
    let stats = registry.stats();
    println!("In-flight requests: {}", stats.in_flight_count);
    println!("Max cache size: {}", stats.max_cache_size);
    println!("Enabled: {}", stats.enabled);
}

Request Key Generation

Each request is identified by a SHA-256 hash of:

  1. Service name - The gRPC service identifier
  2. gRPC path - The method path (e.g., /greeter.Greeter/SayHello)
  3. Request bytes - The serialized protobuf message

This ensures that only truly identical requests are collapsed.

Relationship with Other Features

Response Caching

Request collapsing and response caching work together:

  • Request Collapsing: Deduplicates concurrent identical requests
  • Response Caching: Caches completed responses for future requests

The typical flow is:

  1. Check response cache β†’ cache hit? Return cached response
  2. Check in-flight requests β†’ follower? Wait for leader
  3. Execute gRPC call as leader
  4. Broadcast result to followers
  5. Cache response for future requests

Circuit Breaker

Request collapsing works seamlessly with the circuit breaker:

  • If the circuit is open, all collapsed requests fail fast together
  • The leader request respects circuit breaker state
  • Followers receive the same error as the leader

Best Practices

  1. Start with defaults: The default configuration works well for most use cases

  2. Monitor collapse ratio: Track how many requests are being deduplicated

    • Low ratio? Requests may be too unique, consider if collapsing adds value
    • High ratio? Great! You’re saving significant backend load
  3. Tune for your workload:

    • High read traffic? Use high_throughput() preset
    • Real-time requirements? Use low_latency() preset
  4. Consider request patterns:

    • GraphQL queries with aliases benefit most
    • Unique requests per field won’t see much benefit

Example: Full Configuration

use grpc_graphql_gateway::{Gateway, RequestCollapsingConfig, CacheConfig};
use std::time::Duration;

let gateway = Gateway::builder()
    .with_descriptor_set_bytes(DESCRIPTORS)
    // Enable response caching
    .with_response_cache(CacheConfig {
        max_size: 10_000,
        default_ttl: Duration::from_secs(60),
        stale_while_revalidate: Some(Duration::from_secs(30)),
        invalidate_on_mutation: true,
    })
    // Enable request collapsing
    .with_request_collapsing(
        RequestCollapsingConfig::new()
            .coalesce_window(Duration::from_millis(75))
            .max_waiters(150)
    )
    .add_grpc_client("service", client)
    .build()?;

Automatic Persisted Queries (APQ)

Reduce bandwidth by caching queries on the server and sending only hashes.

Enabling APQ

use grpc_graphql_gateway::{Gateway, PersistedQueryConfig};
use std::time::Duration;

let gateway = Gateway::builder()
    .with_descriptor_set_bytes(DESCRIPTORS)
    .with_persisted_queries(PersistedQueryConfig {
        cache_size: 1000,                        // Max cached queries
        ttl: Some(Duration::from_secs(3600)),    // 1 hour expiration
    })
    .build()?;

How APQ Works

  1. First request: Client sends hash only β†’ Gateway returns PERSISTED_QUERY_NOT_FOUND
  2. Retry: Client sends hash + full query β†’ Gateway caches and executes
  3. Subsequent requests: Client sends hash only β†’ Gateway uses cached query

Client Request Format

Hash only (after caching):

{
  "extensions": {
    "persistedQuery": {
      "version": 1,
      "sha256Hash": "ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38"
    }
  }
}

Hash + query (initial):

{
  "query": "{ user(id: \"123\") { id name } }",
  "extensions": {
    "persistedQuery": {
      "version": 1,
      "sha256Hash": "ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38"
    }
  }
}

Apollo Client Setup

import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries';
import { sha256 } from 'crypto-hash';
import { createHttpLink } from '@apollo/client';

const link = createPersistedQueryLink({ sha256 }).concat(
  createHttpLink({ uri: 'http://localhost:8888/graphql' })
);

Configuration Options

OptionTypeDefaultDescription
cache_sizeusize1000Max number of cached queries
ttlOption<Duration>NoneOptional expiration time

Benefits

  • βœ… 90%+ reduction in request payload size
  • βœ… Compatible with Apollo Client APQ
  • βœ… LRU eviction prevents unbounded memory growth
  • βœ… Optional TTL for cache expiration

Error Response

When hash is not found:

{
  "errors": [
    {
      "message": "PersistedQueryNotFound",
      "extensions": {
        "code": "PERSISTED_QUERY_NOT_FOUND"
      }
    }
  ]
}

Cache Statistics

Monitor APQ performance through logs and metrics.

Circuit Breaker

Protect your gateway from cascading failures when backend services are unhealthy.

Enabling Circuit Breaker

use grpc_graphql_gateway::{Gateway, CircuitBreakerConfig};
use std::time::Duration;

let gateway = Gateway::builder()
    .with_descriptor_set_bytes(DESCRIPTORS)
    .with_circuit_breaker(CircuitBreakerConfig {
        failure_threshold: 5,                      // Open after 5 failures
        recovery_timeout: Duration::from_secs(30), // Wait 30s before testing
        half_open_max_requests: 3,                 // Allow 3 test requests
    })
    .build()?;

Circuit States

   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”‚                                                 β”‚
   β–Ό                                                 β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”  failure_threshold  β”Œβ”€β”€β”€β”€β”€β”€β”  recovery   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚CLOSEDβ”‚ ─────────────────▢  β”‚ OPEN β”‚ ──────────▢ β”‚HALF-OPENβ”‚
β””β”€β”€β”€β”€β”€β”€β”˜     reached         β””β”€β”€β”€β”€β”€β”€β”˜   timeout   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
   β–²                                                 β”‚
   β”‚         success                                 β”‚
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
StateDescription
ClosedNormal operation, all requests flow through
OpenService unhealthy, requests fail fast
Half-OpenTesting recovery with limited requests

Configuration Options

OptionTypeDefaultDescription
failure_thresholdu325Consecutive failures to open circuit
recovery_timeoutDuration30sTime before testing recovery
half_open_max_requestsu323Test requests in half-open state

How It Works

  1. Closed: Requests flow normally, failures are counted
  2. Threshold reached: Circuit opens after N consecutive failures
  3. Open: Requests fail immediately with SERVICE_UNAVAILABLE
  4. Timeout: After recovery timeout, circuit enters half-open
  5. Half-Open: Limited requests test if service recovered
  6. Success: Circuit closes, normal operation resumes
  7. Failure: Circuit reopens, back to step 3

Error Response

When circuit is open:

{
  "errors": [
    {
      "message": "Service unavailable: circuit breaker is open",
      "extensions": {
        "code": "SERVICE_UNAVAILABLE",
        "service": "UserService"
      }
    }
  ]
}

Per-Service Circuits

Each gRPC service has its own circuit breaker:

  • UserService circuit open doesn’t affect ProductService
  • Failures are isolated to their respective services

Benefits

  • βœ… Prevents cascading failures
  • βœ… Fast-fail reduces latency when services are down
  • βœ… Automatic recovery testing
  • βœ… Per-service isolation

Monitoring

Track circuit breaker state through logs:

WARN Circuit breaker opened for UserService
INFO Circuit breaker half-open for UserService (testing recovery)
INFO Circuit breaker closed for UserService (service recovered)

Batch Queries

Execute multiple GraphQL operations in a single HTTP request.

Usage

Send an array of operations:

curl -X POST http://localhost:8888/graphql \
  -H "Content-Type: application/json" \
  -d '[
    {"query": "{ users { id name } }"},
    {"query": "{ products { upc price } }"},
    {"query": "mutation { createUser(input: {name: \"Alice\"}) { id } }"}
  ]'

Response Format

Returns an array of responses in the same order:

[
  {"data": {"users": [{"id": "1", "name": "Bob"}]}},
  {"data": {"products": [{"upc": "123", "price": 99}]}},
  {"data": {"createUser": {"id": "2"}}}
]

Benefits

  • Reduces HTTP overhead (one connection, one request)
  • Atomic execution perception
  • Ideal for initial page loads

Considerations

  • Operations execute concurrently (not sequentially)
  • Mutations don’t wait for previous queries
  • Total response size is sum of all responses

Error Handling

Errors are returned per-operation:

[
  {"data": {"users": [{"id": "1"}]}},
  {"errors": [{"message": "Product not found"}]},
  {"data": {"createUser": {"id": "2"}}}
]

Client Example

const batchQuery = async (queries) => {
  const response = await fetch('http://localhost:8888/graphql', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(queries),
  });
  return response.json();
};

const results = await batchQuery([
  { query: '{ users { id } }' },
  { query: '{ products { upc } }' },
]);

DataLoader

The gateway includes a built-in DataLoader implementation for batching entity resolution requests. This is essential for preventing the N+1 query problem in federated GraphQL architectures.

The N+1 Query Problem

Without DataLoader, resolving a list of entities results in one backend call per entity:

Query: users { friends { name } }

β†’ Fetch users (1 call)
β†’ For each user, fetch friends:
    - User 1's friends (call #2)
    - User 2's friends (call #3)
    - User 3's friends (call #4)
    ... (N more calls)

This is the N+1 problem: 1 initial query + N follow-up queries.

How DataLoader Solves This

DataLoader collects all entity resolution requests within a single execution frame and batches them together:

Query: users { friends { name } }

β†’ Fetch users (1 call)
β†’ Collect all friend IDs
β†’ Batch fetch all friends (1 call)

Total: 2 calls instead of N+1

EntityDataLoader

The EntityDataLoader is the main DataLoader implementation for entity resolution:

use grpc_graphql_gateway::{EntityDataLoader, EntityConfig};
use grpc_graphql_gateway::federation::EntityResolver;
use std::sync::Arc;
use std::collections::HashMap;

// Your entity resolver implementation
let resolver: Arc<dyn EntityResolver> = /* ... */;

// Entity configurations
let mut entity_configs: HashMap<String, EntityConfig> = HashMap::new();
entity_configs.insert("User".to_string(), user_config);
entity_configs.insert("Product".to_string(), product_config);

// Create the DataLoader
let loader = EntityDataLoader::new(resolver, entity_configs);

API Reference

EntityDataLoader::new

Creates a new DataLoader instance:

pub fn new(
    resolver: Arc<dyn EntityResolver>,
    entity_configs: HashMap<String, EntityConfig>,
) -> Self
  • resolver: The underlying entity resolver that performs the actual resolution
  • entity_configs: Map of entity type names to their configurations

EntityDataLoader::load

Load a single entity with automatic batching:

pub async fn load(
    &self,
    entity_type: &str,
    representation: IndexMap<Name, Value>,
) -> Result<Value>

Multiple concurrent calls to load() for the same entity type are automatically batched together.

EntityDataLoader::load_many

Load multiple entities in a batch:

pub async fn load_many(
    &self,
    entity_type: &str,
    representations: Vec<IndexMap<Name, Value>>,
) -> Result<Vec<Value>>

Explicitly batch multiple entity resolution requests.

Integration with Federation

When using Apollo Federation, the DataLoader is typically integrated through the entity resolution pipeline:

use grpc_graphql_gateway::{
    Gateway, GrpcEntityResolver, EntityDataLoader, EntityConfig
};
use std::sync::Arc;
use std::collections::HashMap;

// 1. Create the base entity resolver
let base_resolver = Arc::new(GrpcEntityResolver::default());

// 2. Configure entity types
let mut entity_configs: HashMap<String, EntityConfig> = HashMap::new();
entity_configs.insert(
    "User".to_string(),
    EntityConfig {
        type_name: "User".to_string(),
        keys: vec![vec!["id".to_string()]],
        extend: false,
        resolvable: true,
        descriptor: user_descriptor,
    },
);

// 3. Wrap with DataLoader
let loader = Arc::new(EntityDataLoader::new(
    base_resolver.clone(),
    entity_configs.clone(),
));

// 4. Build gateway with entity resolution
let gateway = Gateway::builder()
    .with_descriptor_set_bytes(DESCRIPTORS)
    .enable_federation()
    .with_entity_resolver(base_resolver)
    .add_grpc_client("UserService", user_client)
    .build()?;

Custom Entity Resolver with DataLoader

You can wrap a custom entity resolver with DataLoader:

use grpc_graphql_gateway::EntityDataLoader;
use grpc_graphql_gateway::federation::{EntityConfig, EntityResolver};
use async_graphql::{Value, indexmap::IndexMap, Name};
use async_trait::async_trait;
use std::sync::Arc;

struct DataLoaderResolver {
    loader: Arc<EntityDataLoader>,
}

impl DataLoaderResolver {
    pub fn new(
        base_resolver: Arc<dyn EntityResolver>,
        entity_configs: HashMap<String, EntityConfig>,
    ) -> Self {
        let loader = Arc::new(EntityDataLoader::new(
            base_resolver,
            entity_configs,
        ));
        Self { loader }
    }
}

#[async_trait]
impl EntityResolver for DataLoaderResolver {
    async fn resolve_entity(
        &self,
        config: &EntityConfig,
        representation: &IndexMap<Name, Value>,
    ) -> Result<Value> {
        // Single entity resolution goes through DataLoader
        self.loader.load(&config.type_name, representation.clone()).await
    }

    async fn batch_resolve_entities(
        &self,
        config: &EntityConfig,
        representations: Vec<IndexMap<Name, Value>>,
    ) -> Result<Vec<Value>> {
        // Batch resolution via DataLoader
        self.loader.load_many(&config.type_name, representations).await
    }
}

Key Features

Automatic Batching

Concurrent entity requests are automatically batched:

// These concurrent requests are batched into a single backend call
let (user1, user2, user3) = tokio::join!(
    loader.load("User", user1_repr),
    loader.load("User", user2_repr),
    loader.load("User", user3_repr),
);

Deduplication

Identical entity requests are deduplicated:

// Same user requested twice = only 1 backend call
let user1a = loader.load("User", user1_repr.clone());
let user1b = loader.load("User", user1_repr.clone());

let (result_a, result_b) = tokio::join!(user1a, user1b);
// result_a == result_b, and only 1 backend call was made

Normalized Cache Keys

Entity representations are normalized before caching, so field order doesn’t matter:

// These are treated as the same entity
let repr1 = indexmap! {
    Name::new("id") => Value::String("123".into()),
    Name::new("region") => Value::String("us".into()),
};

let repr2 = indexmap! {
    Name::new("region") => Value::String("us".into()),
    Name::new("id") => Value::String("123".into()),
};

// Only 1 backend call despite different field order

Per-Type Grouping

Entities are grouped by type for efficient batching:

// Mixed entity types are grouped appropriately
let (user, product, order) = tokio::join!(
    loader.load("User", user_repr),
    loader.load("Product", product_repr),
    loader.load("Order", order_repr),
);
// 3 batched backend calls (1 per entity type)

Performance Benefits

ScenarioWithout DataLoaderWith DataLoader
10 users with friends11 calls2 calls
100 products with reviews101 calls2 calls
N entities, M relationsN*M+1 callsM+1 calls

When to Use DataLoader

βœ… Always use DataLoader for:

  • Federated entity resolution
  • Nested field resolution that fetches related entities
  • Any resolver that may be called multiple times per query

❌ DataLoader may not be needed for:

  • Single root queries (no N+1 potential)
  • Mutations (typically single entity)
  • Subscriptions (streaming, not batched)

Example: Complete Federation Setup

Here’s a complete example demonstrating DataLoader with federation:

use grpc_graphql_gateway::{
    Gateway, EntityDataLoader, GrpcEntityResolver, EntityConfig,
    federation::EntityResolver,
};
use async_graphql::{Value, indexmap::IndexMap, Name};
use std::sync::Arc;
use std::collections::HashMap;

// Your store or data source
struct InMemoryStore {
    users: HashMap<String, User>,
    products: HashMap<String, Product>,
}

// Entity resolver that uses the DataLoader
struct StoreEntityResolver {
    store: Arc<InMemoryStore>,
    loader: Arc<EntityDataLoader>,
}

impl StoreEntityResolver {
    pub fn new(store: Arc<InMemoryStore>) -> Self {
        // Create base resolver
        let base = Arc::new(DirectStoreResolver { store: store.clone() });
        
        // Configure entities
        let mut configs = HashMap::new();
        configs.insert("User".to_string(), user_entity_config());
        configs.insert("Product".to_string(), product_entity_config());
        
        // Wrap with DataLoader
        let loader = Arc::new(EntityDataLoader::new(base, configs));
        
        Self { store, loader }
    }
}

#[async_trait::async_trait]
impl EntityResolver for StoreEntityResolver {
    async fn resolve_entity(
        &self,
        config: &EntityConfig,
        representation: &IndexMap<Name, Value>,
    ) -> grpc_graphql_gateway::Result<Value> {
        self.loader.load(&config.type_name, representation.clone()).await
    }

    async fn batch_resolve_entities(
        &self,
        config: &EntityConfig,
        representations: Vec<IndexMap<Name, Value>>,
    ) -> grpc_graphql_gateway::Result<Vec<Value>> {
        self.loader.load_many(&config.type_name, representations).await
    }
}

Best Practices

  1. Create DataLoader per request: For request-scoped caching, create a new DataLoader instance per GraphQL request.

  2. Share across resolvers: Pass the same DataLoader instance to all resolvers within a request.

  3. Configure appropriate batch sizes: The underlying resolver should handle batch sizes efficiently.

  4. Monitor batch efficiency: Track how many entities are batched together to identify optimization opportunities.

  5. Handle partial failures: The batch resolver should return results in the same order as the input, using null for failed items.

See Also

Production Security Checklist

The gateway is designed with a β€œZero Trust” security philosophy, minimizing the attack surface by default. However, a secure deployment requires coordination between the gateway’s internal features and your infrastructure.

Gateway Security Features (Built-in)

When correctly configured, the gateway provides Enterprise-Grade security covering the following layers:

1. Zero-Trust Access Layer

  • Query Whitelisting: With WhitelistMode::Enforce, the gateway rejects all arbitrary queries. This neutralizes 99% of GraphQL-specific attacks (introspection abuse, deep nesting, resource exhaustion) effectively treating GraphQL as a secured set of RPCs.
  • Introspection Disabled: Schema exploration is blocked in production.

2. Browser Security Layer

  • HSTS: Strict-Transport-Security enforces HTTPS usage.
  • CSP: Content-Security-Policy limits script sources using β€˜self’.
  • CORS: Strict Cross-Origin Resource Sharing controls.
  • XSS Protection: Headers to prevent cross-site scripting and sniffing.

3. Infrastructure Protection Layer

  • DoS Protection: Lock poisoning prevention (using parking_lot) and safe error handling (no stack leaks).
  • Rate Limiting: Token-bucket based limiting with burst control.
  • IP Protection: Strict IP header validation (preventing X-Forwarded-For spoofing).

Operational Responsibilities (Ops)

While the gateway code is secure, your deployment environment must handle the following external responsibilities:

βœ… TLS / SSL Termination

The gateway speaks plain HTTP. You must run it behind a reverse proxy (e.g., Nginx, Envoy, AWS ALB, Cloudflare) that handles:

  • HTTPS Termination: Manage certificates and TLS versions (TLS 1.2/1.3 recommended).
  • Force Redirects: Redirect all HTTP traffic to HTTPS.

βœ… Secrets Management

Never hardcode sensitive credentials. Use environment variables or a secrets manager (Vault, AWS Secrets Manager, Kubernetes Secrets) for:

  • REDIS_URL
  • API Keys
  • Database Credentials
  • Private Keys (if using JWT signing)

βœ… Authentication & Authorization

The gateway validates the presence of auth headers (via middleware), but your logic must define the validity:

  • JWT Verification: Ensure your EnhancedAuthMiddleware is configured with the correct public keys/secrets.
  • Role Limits: Verify that identified users have permission to execute specific operations.

βœ… Network Segmentation

  • Internal gRPC: The gRPC backend services should typically be isolated in a private network, accessible only by the gateway.
  • Redis Access: Restrict Redis access to only the gateway instances.

Verification

Before deploying to production, run the included comprehensive security suite:

# Run the 60+ point security audit script
./test_security.sh

A passing suite confirms that all built-in security layers are active and functioning correctly.

Graceful Shutdown

Enable production-ready server lifecycle management with graceful shutdown.

Enabling Graceful Shutdown

use grpc_graphql_gateway::{Gateway, ShutdownConfig};
use std::time::Duration;

let gateway = Gateway::builder()
    .with_descriptor_set_bytes(DESCRIPTORS)
    .with_graceful_shutdown(ShutdownConfig {
        timeout: Duration::from_secs(30),          // Wait up to 30s
        handle_signals: true,                       // Handle SIGTERM/SIGINT
        force_shutdown_delay: Duration::from_secs(5),
    })
    .build()?;

gateway.serve("0.0.0.0:8888").await?;

How It Works

  1. Signal Received: SIGTERM, SIGINT, or Ctrl+C is received
  2. Stop Accepting: Server stops accepting new connections
  3. Drain Requests: In-flight requests are allowed to complete
  4. Cleanup: Active subscriptions cancelled, resources released
  5. Exit: Server shuts down gracefully

Configuration Options

OptionTypeDefaultDescription
timeoutDuration30sMax wait for in-flight requests
handle_signalsbooltrueHandle OS signals automatically
force_shutdown_delayDuration5sWait before forcing shutdown

Custom Shutdown Signal

Trigger shutdown from your own logic:

use tokio::sync::oneshot;

let (tx, rx) = oneshot::channel::<()>();

// Trigger shutdown after some condition
tokio::spawn(async move {
    tokio::time::sleep(Duration::from_secs(60)).await;
    let _ = tx.send(());
});

Gateway::builder()
    .with_descriptor_set_bytes(DESCRIPTORS)
    .serve_with_shutdown("0.0.0.0:8888", async { let _ = rx.await; })
    .await?;

Kubernetes Integration

The gateway responds correctly to Kubernetes termination:

spec:
  terminationGracePeriodSeconds: 30
  containers:
    - name: gateway
      lifecycle:
        preStop:
          exec:
            command: ["sleep", "5"]

Benefits

  • βœ… No dropped requests during deployment
  • βœ… Automatic OS signal handling
  • βœ… Configurable drain timeout
  • βœ… Active subscription cleanup
  • βœ… Kubernetes-compatible

Header Propagation

Forward HTTP headers from GraphQL requests to gRPC backends for authentication and tracing.

Enabling Header Propagation

use grpc_graphql_gateway::{Gateway, HeaderPropagationConfig};

let gateway = Gateway::builder()
    .with_descriptor_set_bytes(DESCRIPTORS)
    .with_header_propagation(
        HeaderPropagationConfig::new()
            .propagate("authorization")
            .propagate("x-request-id")
            .propagate("x-tenant-id")
    )
    .build()?;

Common Headers Preset

Use the preset for common auth and tracing headers:

Gateway::builder()
    .with_header_propagation(HeaderPropagationConfig::common())
    .build()?;

Includes:

  • authorization - Bearer tokens
  • x-request-id, x-correlation-id - Request tracking
  • traceparent, tracestate - W3C Trace Context
  • x-b3-* - Zipkin B3 headers

Configuration Methods

MethodDescription
.propagate("header")Propagate exact header name
.propagate_with_prefix("x-custom-")Propagate headers with prefix
.propagate_all_headers()Propagate all headers (with exclusions)
.exclude("cookie")Exclude specific headers

Examples

Exact Match

HeaderPropagationConfig::new()
    .propagate("authorization")
    .propagate("x-api-key")

Prefix Match

HeaderPropagationConfig::new()
    .propagate_with_prefix("x-custom-")
    .propagate_with_prefix("x-tenant-")

All with Exclusions

HeaderPropagationConfig::new()
    .propagate_all_headers()
    .exclude("cookie")
    .exclude("host")

Security

Uses an allowlist approach - only explicitly configured headers are forwarded. This prevents accidental leakage of sensitive headers like Cookie or Host.

gRPC Backend

Headers become gRPC metadata:

// In your gRPC service
async fn get_user(&self, request: Request<GetUserRequest>) -> ... {
    let metadata = request.metadata();
    let auth = metadata.get("authorization")
        .map(|v| v.to_str().ok())
        .flatten();
    
    // Use auth for authorization
}

W3C Trace Context

For distributed tracing, propagate trace context headers:

HeaderPropagationConfig::new()
    .propagate("traceparent")
    .propagate("tracestate")
    .propagate("authorization")

Configuration Reference

Complete reference for all gateway configuration options.

GatewayBuilder Methods

Core Configuration

MethodDescription
.with_descriptor_set_bytes(bytes)Set primary proto descriptor
.add_descriptor_set_bytes(bytes)Add additional proto descriptor
.with_descriptor_set_file(path)Load primary descriptor from file
.add_descriptor_set_file(path)Load additional descriptor from file
.add_grpc_client(name, client)Register a gRPC backend client
.with_services(services)Restrict to specific services

Federation

MethodDescription
.enable_federation()Enable Apollo Federation v2
.with_entity_resolver(resolver)Custom entity resolver

Security

MethodDescription
.with_query_depth_limit(n)Max query nesting depth
.with_query_complexity_limit(n)Max query complexity
.disable_introspection()Block __schema queries

Middleware

MethodDescription
.add_middleware(middleware)Add custom middleware
.with_error_handler(handler)Custom error handler

Performance

MethodDescription
.with_response_cache(config)Enable response caching
.with_compression(config)Enable response compression
.with_persisted_queries(config)Enable APQ
.with_circuit_breaker(config)Enable circuit breaker

Production

MethodDescription
.enable_health_checks()Add /health and /ready endpoints
.enable_metrics()Add /metrics Prometheus endpoint
.enable_tracing()Enable OpenTelemetry tracing
.with_graceful_shutdown(config)Enable graceful shutdown
.with_header_propagation(config)Forward headers to gRPC

Environment Variables

Configure via environment variables:

# Query limits
QUERY_DEPTH_LIMIT=10
QUERY_COMPLEXITY_LIMIT=100

# Environment
ENV=production  # Affects introspection default

# Tracing
OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4317
OTEL_SERVICE_NAME=graphql-gateway

Configuration Structs

CacheConfig

CacheConfig {
    max_size: 10_000,
    default_ttl: Duration::from_secs(60),
    stale_while_revalidate: Some(Duration::from_secs(30)),
    invalidate_on_mutation: true,
    redis_url: Some("redis://127.0.0.1:6379".to_string()),
}

CompressionConfig

CompressionConfig {
    enabled: true,
    level: CompressionLevel::Default,
    min_size_bytes: 1024,
    algorithms: vec!["br".into(), "gzip".into()],
}

// Presets
CompressionConfig::fast()
CompressionConfig::best()
CompressionConfig::default()
CompressionConfig::disabled()

CircuitBreakerConfig

CircuitBreakerConfig {
    failure_threshold: 5,
    recovery_timeout: Duration::from_secs(30),
    half_open_max_requests: 3,
}

PersistedQueryConfig

PersistedQueryConfig {
    cache_size: 1000,
    ttl: Some(Duration::from_secs(3600)),
}

ShutdownConfig

ShutdownConfig {
    timeout: Duration::from_secs(30),
    handle_signals: true,
    force_shutdown_delay: Duration::from_secs(5),
}

HeaderPropagationConfig

HeaderPropagationConfig::new()
    .propagate("authorization")
    .propagate_with_prefix("x-custom-")
    .exclude("cookie")

// Preset
HeaderPropagationConfig::common()

TracingConfig

TracingConfig::new()
    .with_service_name("my-gateway")
    .with_sample_ratio(0.1)
    .with_otlp_endpoint("http://jaeger:4317")

API Documentation

The full Rust API documentation is available on docs.rs:

πŸ“š docs.rs/grpc_graphql_gateway

Main Types

Gateway

The main entry point for creating and running the gateway.

use grpc_graphql_gateway::Gateway;

let gateway = Gateway::builder()
    // ... configuration
    .build()?;

GatewayBuilder

Configuration builder with fluent API.

GrpcClient

Manages connections to gRPC backend services.

use grpc_graphql_gateway::GrpcClient;

// Lazy connection (connects on first request)
let client = GrpcClient::builder("http://localhost:50051")
    .connect_lazy()?;

// Immediate connection
let client = GrpcClient::new("http://localhost:50051").await?;

SchemaBuilder

Low-level builder for the dynamic GraphQL schema.

Module Reference

ModuleDescription
gatewayMain Gateway and GatewayBuilder
schemaSchema generation from protobuf
grpc_clientgRPC client management
federationApollo Federation support
middlewareRequest middleware
cacheResponse caching
compressionResponse compression
circuit_breakerCircuit breaker pattern
persisted_queriesAPQ support
healthHealth check endpoints
metricsPrometheus metrics
tracing_otelOpenTelemetry tracing
shutdownGraceful shutdown
headersHeader propagation

Re-exported Types

pub use gateway::{Gateway, GatewayBuilder};
pub use grpc_client::GrpcClient;
pub use schema::SchemaBuilder;
pub use cache::{CacheConfig, ResponseCache};
pub use compression::{CompressionConfig, CompressionLevel};
pub use circuit_breaker::{CircuitBreakerConfig, CircuitBreaker};
pub use persisted_queries::PersistedQueryConfig;
pub use shutdown::ShutdownConfig;
pub use headers::HeaderPropagationConfig;
pub use tracing_otel::TracingConfig;
pub use middleware::{Middleware, Context};
pub use federation::{EntityResolver, EntityResolverMapping, GrpcEntityResolver};

Error Types

use grpc_graphql_gateway::{Error, Result};

// Main error type
enum Error {
    Schema(String),
    Io(std::io::Error),
    Grpc(tonic::Status),
    // ...
}

Async Traits

When implementing custom resolvers or middleware, you’ll use:

use async_trait::async_trait;

#[async_trait]
impl Middleware for MyMiddleware {
    async fn call(&self, ctx: &mut Context, next: ...) -> Result<()> {
        // ...
    }
}

Changelog

All notable changes to this project are documented here.

For the full changelog, see the CHANGELOG.md file in the repository.

Recent Releases

[0.3.7] - 2025-12-16

Production Security Hardening

  • Comprehensive security headers: HSTS, CSP, X-XSS-Protection, Referrer-Policy
  • CORS preflight handling with proper OPTIONS response (204)
  • Cache-Control headers to prevent sensitive data caching
  • Query whitelist default to Enforce mode with introspection disabled
  • Improved query normalization for robust hash matching
  • Redis crate upgraded from 0.24 to 0.27
  • 31-test security assessment script (test_security.sh)

[0.3.6] - 2025-12-16

Security Fixes

  • Replaced std::sync::RwLock with parking_lot::RwLock to prevent DoS via lock poisoning
  • IP spoofing protection in middleware
  • SSRF protection in REST connectors
  • Security headers (X-Content-Type-Options, X-Frame-Options)

[0.3.5] - 2025-12-16

Redis Distributed Cache Backend

  • CacheConfig.redis_url - Configure Redis connection for distributed caching
  • Dual backend support: in-memory (single instance) or Redis (distributed)
  • Distributed cache invalidation across all gateway instances
  • Redis SETs for type and entity indexes (type:{TypeName}, entity:{EntityKey})
  • TTL synchronization with Redis SETEX
  • Automatic fallback to in-memory cache on connection failure
  • Ideal for Kubernetes deployments and horizontal scaling

[0.3.4] - 2025-12-14

OpenAPI to REST Connector

  • OpenApiParser - Parse OpenAPI 3.0/3.1 and Swagger 2.0 specs
  • Support for JSON and YAML formats
  • Automatic endpoint generation from paths and operations
  • Operation filtering by tags or custom predicates
  • Base URL override for different environments

[0.3.3] - 2025-12-14

Request Collapsing

  • RequestCollapsingConfig - Configure coalesce window, max waiters, and cache size
  • RequestCollapsingRegistry - Track in-flight requests for deduplication
  • Reduces gRPC calls by sharing responses for identical concurrent requests
  • Presets: default(), high_throughput(), low_latency(), disabled()
  • Metrics tracking: collapse ratio, leader/follower counts

[0.3.2] - 2025-12-14

Query Analytics Dashboard

  • Beautiful dark-themed analytics dashboard at /analytics
  • Most used queries, slowest queries, error patterns tracking
  • Field usage statistics and operation distribution
  • Cache hit rate monitoring and uptime tracking
  • Privacy-focused production mode (no query text storage)
  • JSON API at /analytics/api

[0.3.1] - 2025-12-14

Bug Fixes

  • Minor bug fixes and performance improvements
  • Updated dependencies

[0.3.0] - 2025-12-14

REST API Connectors

  • RestConnector - HTTP client with retry logic, caching, and interceptor support
  • RestEndpoint - Define REST endpoints with path templates and body templates
  • Typed responses with RestResponseSchema for GraphQL field selection
  • add_rest_connector() - New GatewayBuilder method
  • Built-in interceptors: BearerAuthInterceptor, ApiKeyInterceptor
  • JSONPath response extraction (e.g., $.data.users)
  • Ideal for hybrid gRPC/REST architectures and gradual migrations

[0.2.9] - 2025-12-14

Enhanced Middleware & Auth System

  • EnhancedAuthMiddleware - JWT support with claims extraction and context enrichment
  • AuthConfig - Required/optional modes with Bearer, Basic, ApiKey schemes
  • EnhancedLoggingMiddleware - Structured logging with sensitive data masking
  • LoggingConfig - Configurable log levels and slow request detection
  • Improved context with request_id, client_ip, and auth helpers
  • MiddlewareChain - Combine multiple middleware with builder pattern

[0.2.8] - 2025-12-13

Query Whitelisting (Stored Operations)

  • QueryWhitelistConfig - Configure allowed queries and enforcement mode
  • WhitelistMode - Enforce, Warn, or Disabled modes
  • Hash-based and ID-based query validation
  • Production security for PCI-DSS compliance
  • Compatible with APQ and GraphQL clients

[0.2.7] - 2025-12-12

Multi-Descriptor Support (Schema Stitching)

  • add_descriptor_set_bytes() - Add additional descriptor sets
  • add_descriptor_set_file() - Add descriptors from files
  • Seamless merging of services from multiple sources
  • Essential for microservice architectures

[0.2.6] - 2025-12-12

Header Propagation

  • HeaderPropagationConfig - Configure header forwarding
  • Allowlist approach for security
  • Support for distributed tracing headers

[0.2.5] - 2025-12-12

Response Compression

  • Brotli, Gzip, Deflate, Zstd support
  • Configurable compression levels
  • Minimum size threshold

[0.2.4] - 2025-12-12

Response Caching

  • LRU cache with TTL expiration
  • Stale-while-revalidate support
  • Mutation-triggered invalidation

[0.2.3] - 2025-12-11

Graceful Shutdown

  • Clean server shutdown
  • In-flight request draining
  • OS signal handling

[0.2.2] - 2025-12-11

Multiplex Subscriptions

  • Multiple subscriptions per WebSocket
  • graphql-transport-ws protocol support

[0.2.1] - 2025-12-11

Circuit Breaker

  • Per-service circuit breakers
  • Automatic recovery testing
  • Cascading failure prevention

[0.2.0] - 2025-12-11

Automatic Persisted Queries

  • SHA-256 query hashing
  • LRU cache with optional TTL
  • Apollo APQ protocol support

[0.1.x] - Earlier Releases

See the full changelog for earlier versions including:

  • Health checks and Prometheus metrics
  • OpenTelemetry tracing
  • Query depth and complexity limiting
  • Apollo Federation v2 support
  • File uploads
  • Middleware system

Contributing

We welcome contributions to gRPC-GraphQL Gateway!

Getting Started

  1. Fork the repository
  2. Clone your fork
  3. Create a feature branch
  4. Make your changes
  5. Submit a pull request

Development Setup

# Clone the repository
git clone https://github.com/Protocol-Lattice/grpc_graphql_gateway.git
cd grpc_graphql_gateway

# Build the project
cargo build

# Run tests
cargo test

# Run clippy
cargo clippy --all-targets

# Format code
cargo fmt

Running Examples

# Start the greeter example
cargo run --example greeter

# Start the federation example
cargo run --example federation

Project Structure

src/
β”œβ”€β”€ lib.rs              # Re-exports and module definitions
β”œβ”€β”€ gateway.rs          # Main Gateway and GatewayBuilder
β”œβ”€β”€ schema.rs           # GraphQL schema generation
β”œβ”€β”€ grpc_client.rs      # gRPC client management
β”œβ”€β”€ federation.rs       # Apollo Federation support
β”œβ”€β”€ middleware.rs       # Middleware trait and types
β”œβ”€β”€ cache.rs            # Response caching
β”œβ”€β”€ compression.rs      # Response compression
β”œβ”€β”€ circuit_breaker.rs  # Circuit breaker pattern
β”œβ”€β”€ persisted_queries.rs # APQ support
β”œβ”€β”€ health.rs           # Health check endpoints
β”œβ”€β”€ metrics.rs          # Prometheus metrics
β”œβ”€β”€ tracing_otel.rs     # OpenTelemetry tracing
β”œβ”€β”€ shutdown.rs         # Graceful shutdown
β”œβ”€β”€ headers.rs          # Header propagation
└── ...

Pull Request Guidelines

  • Follow Rust naming conventions
  • Add tests for new functionality
  • Update documentation as needed
  • Run cargo fmt before committing
  • Ensure cargo clippy passes
  • Update CHANGELOG.md for notable changes

Reporting Issues

Please include:

  • Rust version (rustc --version)
  • Gateway version
  • Minimal reproduction case
  • Expected vs actual behavior

License

By contributing, you agree that your contributions will be licensed under the MIT License.

Resources