Introduction
gRPC-GraphQL Gateway
A high-performance Rust gateway that bridges gRPC services to GraphQL with full Apollo Federation v2 support.
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-wsprotocol) - π€ 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-templategenerates 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 -
/healthand/readyendpoints for Kubernetes - π Prometheus Metrics -
/metricsendpoint 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:
- Reads your protobuf definitions - Including custom GraphQL annotations
- Generates a GraphQL schema automatically - Types, queries, mutations, subscriptions
- Routes requests to your gRPC backends - With full async/await support
- 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"] }
| Feature | Description |
|---|---|
otlp | Enable OpenTelemetry Protocol export for distributed tracing |
Prerequisites
Before using the gateway, ensure you have:
- Rust 1.70+ - The gateway uses modern Rust features
- Protobuf Compiler -
protocfor generating descriptor files - 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
- Loads protobuf descriptors - The binary descriptor file contains your service definitions
- Connects to gRPC backend - Lazily connects to your gRPC service
- Generates GraphQL schema - Automatically creates types, queries, and mutations
- Starts HTTP server - Serves GraphQL at
/graphql
Endpoints
Once running, your gateway exposes:
| Endpoint | Description |
|---|---|
http://localhost:8888/graphql | GraphQL HTTP endpoint (POST) |
ws://localhost:8888/graphql/ws | GraphQL 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
- Learn how to Generate Descriptors from your proto files
- Explore Queries, Mutations & Subscriptions
- Enable Apollo Federation for microservice architectures
Generating Descriptors
The gateway reads protobuf descriptor files (.bin) to understand your service definitions. This page explains how to generate them.
Using build.rs (Recommended)
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:
graphql.protois included in your proto compilation- Youβre using
--include_importswith protoc - Your tonic-build includes all necessary proto files
Descriptor file not found
If the descriptor file isnβt found at runtime:
- Check that
OUT_DIRis set correctly - Verify the file was generated during build
- Use
cargo clean && cargo buildto 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 Type | GraphQL Type | Use Case |
|---|---|---|
| Unary | Query/Mutation | Fetch or modify data |
| Server Streaming | Subscription | Real-time updates |
| Client Streaming | Not supported | - |
| Bidirectional | Not 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
- operations - JSON containing the query and variables
- map - Maps file indices to variable paths
- 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:
- Streaming uploads to avoid memory pressure
- Setting appropriate timeouts
- 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
| Option | Type | Description |
|---|---|---|
name | string | Override the GraphQL field name |
omit | bool | Exclude this field from GraphQL schema |
required | bool | Mark field as non-nullable (!) |
shareable | bool | Federation: field can be resolved by multiple subgraphs |
external | bool | Federation: field is defined in another subgraph |
requires | string | Federation: fields needed from other subgraphs |
provides | string | Federation: 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: trueto 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:
- Let each team generate their own descriptor file
- Combine them at gateway startup
- 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
| Method | Description |
|---|---|
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
- Primary descriptor is loaded with
with_descriptor_set_bytes/file - Additional descriptors are merged using
add_descriptor_set_bytes/file - Duplicate files are automatically skipped (same filename)
- Services and types from all descriptors are combined
- GraphQL schema is generated from the merged pool
Requirements
- All descriptors must include
graphql.protowith 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:
| Error | Cause | Solution |
|---|---|---|
at least one descriptor set is required | No descriptors provided | Add at least one with with_descriptor_set_bytes |
failed to merge descriptor set #N | Invalid protobuf data | Verify the descriptor file is valid |
missing graphql.schema extension | Annotations not found | Ensure 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:
- Adds
_servicequery - Returns the SDL for schema composition - Adds
_entitiesquery - Resolves entity references from other subgraphs - 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
- Start your federation subgraphs
- Compose the supergraph schema
- Run Apollo Router
See Running with Apollo Router for detailed instructions.
Next Steps
- Defining Entities - Mark types as federation entities
- Entity Resolution - Resolve entity references
- Federation Directives - Use
@shareable,@external, etc.
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
| Option | Type | Description |
|---|---|---|
keys | string | The field(s) that uniquely identify this entity |
resolvable | bool | Whether this subgraph can resolve the entity |
extend | bool | Whether 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:
- Marked as required - Use
required: true - Non-null in responses - Always return a value
- 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
- Router sends
_entitiesquery with representations - Gateway receives representations (e.g.,
{ __typename: "User", id: "123" }) - Gateway calls your gRPC backend to resolve the entity
- 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:
| Field | Description |
|---|---|
service_name | The gRPC service name |
method_name | The RPC method to call |
key_field | The 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:
- Extracts the representations
- Groups by
__typename - Batches calls to the appropriate gRPC services
- 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
- Use DataLoader - Always batch entity requests
- Implement bulk fetch - Have gRPC methods that fetch multiple entities
- Cache wisely - Consider caching frequently accessed entities
- 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
| Directive | Proto Option | Purpose |
|---|---|---|
@key | graphql.entity.keys | Define entity key fields |
@shareable | graphql.field.shareable | Field resolvable from multiple subgraphs |
@external | graphql.field.external | Field defined in another subgraph |
@requires | graphql.field.requires | Fields needed from other subgraphs |
@provides | graphql.field.provides | Fields this resolver provides |
@extends | graphql.entity.extend | Extending 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
- Router fetches
priceandweightfrom the owning subgraph - Router sends those values to your subgraph
- 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:
- Entity resolvers are configured for all entity types
- gRPC clients are connected
- 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
- An Apollo Studio account.
- The Rover CLI installed.
- 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:
Option A: Apollo Uplink (Recommended)
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
| Scheme | Description | Header Example |
|---|---|---|
AuthScheme::Bearer | Standard Bearer token | Authorization: Bearer <token> |
AuthScheme::Basic | Basic auth credentials | Authorization: Basic <base64> |
AuthScheme::ApiKey | Custom header API key | x-api-key: <key> |
AuthScheme::Custom | Custom prefix | Authorization: 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.
| Field | Type | Description |
|---|---|---|
sub | Option<String> | Subject (User ID) |
roles | Vec<String> | User roles |
iss | Option<String> | Issuer |
aud | Option<Vec<String>> | Audience |
exp | Option<i64> | Expiration (Unix timestamp) |
custom | HashMap | Custom 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
expclaim 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
- Always use HTTPS - HSTS is automatically enabled
- Configure specific CORS origins - Replace
*with your domain - Review CSP rules - Adjust based on your frontend requirements
- 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"
}
}
]
}
Recommended Values
| Use Case | Depth Limit | Complexity Limit |
|---|---|---|
| Public API | 5-10 | 50-100 |
| Authenticated Users | 10-15 | 100-500 |
| Internal/Trusted | 15-25 | 500-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()?;
Related Features
- Rate Limiting - Limit requests per time window
- Introspection Control - Disable schema discovery
- Circuit Breaker - Protect backend services
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
- Public APIs: Prevent malicious actors from crafting expensive queries
- Mobile Applications: Apps typically have a fixed set of queries
- Third-Party Integrations: Control exactly what partners can query
- 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:
- Deploy with
Warnmode in staging - Monitor logs to identify all queries
- Add missing queries to whitelist
- Switch to
Enforcemode 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:
| Feature | Purpose | Security Level |
|---|---|---|
| APQ | Bandwidth optimization (caches any query) | Low |
| Whitelist | Security (only allows pre-approved queries) | High |
| Both | Bandwidth savings + Security | Maximum |
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
- Introspection Control - Disabling schema introspection
- Automatic Persisted Queries - Bandwidth optimization
- DoS Protection - Query depth and complexity limits
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:
| Method | Description |
|---|---|
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
| Endpoint | Purpose | Success Response |
|---|---|---|
GET /health | Liveness probe | 200 OK if server is running |
GET /ready | Readiness probe | 200 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
| State | Description |
|---|---|
healthy | All components working |
degraded | Partial functionality |
unhealthy | Service 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
| Metric | Type | Labels | Description |
|---|---|---|---|
graphql_requests_total | Counter | operation_type | Total GraphQL requests |
graphql_request_duration_seconds | Histogram | operation_type | Request latency |
graphql_errors_total | Counter | error_type | Total GraphQL errors |
grpc_backend_requests_total | Counter | service, method | gRPC backend calls |
grpc_backend_duration_seconds | Histogram | service, method | gRPC 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
| Span | Kind | Description |
|---|---|---|
graphql.query | Server | GraphQL query operation |
graphql.mutation | Server | GraphQL mutation operation |
grpc.call | Client | gRPC backend call |
Span Attributes
GraphQL Spans
| Attribute | Description |
|---|---|
graphql.operation.name | The operation name if provided |
graphql.operation.type | query, mutation, or subscription |
graphql.document | The GraphQL query (truncated) |
gRPC Spans
| Attribute | Description |
|---|---|
rpc.service | gRPC service name |
rpc.method | gRPC method name |
rpc.grpc.status_code | gRPC 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 Ratio | Description |
|---|---|
1.0 | Sample all requests (dev) |
0.1 | Sample 10% (staging) |
0.01 | Sample 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
| Environment | Introspection |
|---|---|
| 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
| Method | Description |
|---|---|
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
| Scenario | Description |
|---|---|
| Hybrid Architecture | Mix gRPC and REST backends in one GraphQL API |
| Gradual Migration | Migrate from REST to gRPC incrementally |
| Third-Party APIs | Integrate external REST APIs (Stripe, Twilio, etc.) |
| Legacy Systems | Bridge legacy REST services with modern infrastructure |
| Multi-Protocol | Support teams using different backend technologies |
Best Practices
-
Set Appropriate Timeouts: Use shorter timeouts for internal services, longer for external APIs.
-
Enable Retries for Idempotent Operations: GET, PUT, DELETE are typically safe to retry.
-
Use Response Extraction: Extract only needed data with
response_pathto reduce payload size. -
Cache Read-Heavy Endpoints: Enable caching for frequently-accessed, rarely-changing data.
-
Secure Credentials: Use environment variables for API keys and tokens, not hardcoded values.
-
Log Bodies in Development Only: Enable
log_bodiesonly 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
| Format | Extension | Feature Required |
|---|---|---|
| OpenAPI 3.0.x | .json | None |
| OpenAPI 3.1.x | .json | None |
| Swagger 2.0 | .json | None |
| YAML (any version) | .yaml, .yml | yaml |
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:
| OpenAPI | GraphQL |
|---|---|
GET /pets | listPets query |
POST /pets | createPet 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
-
Use prefixes when combining multiple APIs to avoid naming conflicts
-
Filter by tags to include only the operations you need
-
Override base URLs for different environments (dev, staging, prod)
-
Check available operations before building to understand what will be generated
-
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
| Option | Type | Description |
|---|---|---|
max_size | usize | Maximum number of cached responses |
default_ttl | Duration | Time before entries expire |
stale_while_revalidate | Option<Duration> | Serve stale content while refreshing |
invalidate_on_mutation | bool | Clear cache on mutations |
redis_url | Option<String> | Redis connection URL for distributed caching |
vary_headers | Vec<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
- First Query: Cache miss β Execute gRPC β Cache response β Return
- Second Query: Cache hit β Return cached response immediately (<1ms)
- Mutation: Execute mutation β Invalidate related cache entries
- Next Query: Cache miss (invalidated) β Execute gRPC β Cache β Return
What Gets Cached
| Operation | Cached? | Triggers Invalidation? |
|---|---|---|
| Query | β Yes | No |
| Mutation | β No | β Yes |
| Subscription | β No | No |
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
| Algorithm | Accept-Encoding | Compression Ratio | Speed |
|---|---|---|---|
| Brotli | br | Best | Slower |
| Gzip | gzip | Good | Fast |
| Deflate | deflate | Good | Fast |
| Zstd | zstd | Excellent | Fast |
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
| Level | Description | Use Case |
|---|---|---|
Fast | Minimal compression, fast | Low latency APIs |
Default | Balanced | Most applications |
Best | Maximum compression | Bandwidth-constrained |
Configuration Options
| Option | Type | Description |
|---|---|---|
enabled | bool | Enable/disable compression |
level | CompressionLevel | Compression speed vs ratio |
min_size_bytes | usize | Skip compression for small responses |
algorithms | Vec<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_bytesto skip small responses - Use
CompressionLevel::Fastfor 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
- Leader: The first request with a unique key executes the gRPC call
- Followers: Subsequent identical requests wait for the leaderβs result
- Broadcast: When the leader completes, it broadcasts the result to all followers
- 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
| Option | Default | Description |
|---|---|---|
coalesce_window | 50ms | Maximum time to wait for in-flight requests |
max_waiters | 100 | Maximum followers waiting for a single leader |
enabled | true | Enable/disable collapsing |
max_cache_size | 10000 | Maximum 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:
- Service name - The gRPC service identifier
- gRPC path - The method path (e.g.,
/greeter.Greeter/SayHello) - 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:
- Check response cache β cache hit? Return cached response
- Check in-flight requests β follower? Wait for leader
- Execute gRPC call as leader
- Broadcast result to followers
- 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
-
Start with defaults: The default configuration works well for most use cases
-
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
-
Tune for your workload:
- High read traffic? Use
high_throughput()preset - Real-time requirements? Use
low_latency()preset
- High read traffic? Use
-
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
- First request: Client sends hash only β Gateway returns
PERSISTED_QUERY_NOT_FOUND - Retry: Client sends hash + full query β Gateway caches and executes
- 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
| Option | Type | Default | Description |
|---|---|---|---|
cache_size | usize | 1000 | Max number of cached queries |
ttl | Option<Duration> | None | Optional 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 β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
| State | Description |
|---|---|
| Closed | Normal operation, all requests flow through |
| Open | Service unhealthy, requests fail fast |
| Half-Open | Testing recovery with limited requests |
Configuration Options
| Option | Type | Default | Description |
|---|---|---|---|
failure_threshold | u32 | 5 | Consecutive failures to open circuit |
recovery_timeout | Duration | 30s | Time before testing recovery |
half_open_max_requests | u32 | 3 | Test requests in half-open state |
How It Works
- Closed: Requests flow normally, failures are counted
- Threshold reached: Circuit opens after N consecutive failures
- Open: Requests fail immediately with
SERVICE_UNAVAILABLE - Timeout: After recovery timeout, circuit enters half-open
- Half-Open: Limited requests test if service recovered
- Success: Circuit closes, normal operation resumes
- 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:
UserServicecircuit open doesnβt affectProductService- 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
| Scenario | Without DataLoader | With DataLoader |
|---|---|---|
| 10 users with friends | 11 calls | 2 calls |
| 100 products with reviews | 101 calls | 2 calls |
| N entities, M relations | N*M+1 calls | M+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
-
Create DataLoader per request: For request-scoped caching, create a new DataLoader instance per GraphQL request.
-
Share across resolvers: Pass the same DataLoader instance to all resolvers within a request.
-
Configure appropriate batch sizes: The underlying resolver should handle batch sizes efficiently.
-
Monitor batch efficiency: Track how many entities are batched together to identify optimization opportunities.
-
Handle partial failures: The batch resolver should return results in the same order as the input, using
nullfor failed items.
See Also
- Entity Resolution - Complete entity resolution guide
- Apollo Federation Overview - Federation concepts
- Response Caching - Additional caching strategies
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-Securityenforces HTTPS usage. - CSP:
Content-Security-Policylimits 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-Forspoofing).
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
EnhancedAuthMiddlewareis 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
- Signal Received: SIGTERM, SIGINT, or Ctrl+C is received
- Stop Accepting: Server stops accepting new connections
- Drain Requests: In-flight requests are allowed to complete
- Cleanup: Active subscriptions cancelled, resources released
- Exit: Server shuts down gracefully
Configuration Options
| Option | Type | Default | Description |
|---|---|---|---|
timeout | Duration | 30s | Max wait for in-flight requests |
handle_signals | bool | true | Handle OS signals automatically |
force_shutdown_delay | Duration | 5s | Wait 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 tokensx-request-id,x-correlation-id- Request trackingtraceparent,tracestate- W3C Trace Contextx-b3-*- Zipkin B3 headers
Configuration Methods
| Method | Description |
|---|---|
.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
| Method | Description |
|---|---|
.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
| Method | Description |
|---|---|
.enable_federation() | Enable Apollo Federation v2 |
.with_entity_resolver(resolver) | Custom entity resolver |
Security
| Method | Description |
|---|---|
.with_query_depth_limit(n) | Max query nesting depth |
.with_query_complexity_limit(n) | Max query complexity |
.disable_introspection() | Block __schema queries |
Middleware
| Method | Description |
|---|---|
.add_middleware(middleware) | Add custom middleware |
.with_error_handler(handler) | Custom error handler |
Performance
| Method | Description |
|---|---|
.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
| Method | Description |
|---|---|
.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
| Module | Description |
|---|---|
gateway | Main Gateway and GatewayBuilder |
schema | Schema generation from protobuf |
grpc_client | gRPC client management |
federation | Apollo Federation support |
middleware | Request middleware |
cache | Response caching |
compression | Response compression |
circuit_breaker | Circuit breaker pattern |
persisted_queries | APQ support |
health | Health check endpoints |
metrics | Prometheus metrics |
tracing_otel | OpenTelemetry tracing |
shutdown | Graceful shutdown |
headers | Header 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
Enforcemode 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::RwLockwithparking_lot::RwLockto 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 sizeRequestCollapsingRegistry- 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 supportRestEndpoint- Define REST endpoints with path templates and body templates- Typed responses with
RestResponseSchemafor GraphQL field selection add_rest_connector()- NewGatewayBuildermethod- 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 enrichmentAuthConfig- Required/optional modes with Bearer, Basic, ApiKey schemesEnhancedLoggingMiddleware- Structured logging with sensitive data maskingLoggingConfig- 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 modeWhitelistMode- 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 setsadd_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-wsprotocol 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
- Fork the repository
- Clone your fork
- Create a feature branch
- Make your changes
- 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 fmtbefore committing - Ensure
cargo clippypasses - 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.