GraphQL & gRPC
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against async-graphql 7.2.1, async-graphql-axum 7.2.1, tonic 0.14.6 and prost 0.14 with axum 0.8.9 on Rust 1.99 (edition 2024), checked on 2026-10-06. Bookshelf exposes its catalogue as a REST API; this page adds a GraphQL endpoint for the web front end and a gRPC service for internal consumers, all served by the same process and the same port. GraphQL itself — schema design, resolvers, pagination, security — is covered in the GraphQL Reference; this page covers only the Rust side.
The combined service
[dependencies]
axum = { version = "0.8.9", features = ["http2", "ws"] }
tokio = { version = "1.53", features = ["full"] }
async-graphql = { version = "7.2.1", features = ["dataloader"] }
async-graphql-axum = "7.2.1"
tonic = "0.14.6"
tonic-prost = "0.14.6"
prost = "0.14"
futures = "0.3"
tokio-stream = { version = "0.1", features = ["sync"] }
[build-dependencies]
tonic-prost-build = "0.14"
The gRPC contract is a protobuf file:
syntax = "proto3";
package bookshelf.catalog.v1;
service Catalog {
rpc GetBook (GetBookRequest) returns (Book);
}
message GetBookRequest {
string isbn = 1;
}
message Book {
string isbn = 1;
string title = 2;
uint32 year = 3;
}
tonic 0.14 split code generation out of tonic itself: messages are generated by prost through tonic-prost-build, and
the generated code depends on the small tonic-prost runtime crate. The build script runs protoc, which must be
installed (apt install protobuf-compiler), or supplied by the protoc-bin-vendored crate through the PROTOC
environment variable:
fn main() -> Result<(), Box<dyn std::error::Error>> {
tonic_prost_build::compile_protos("proto/catalog.proto")?; // generates bookshelf.catalog.v1.rs in OUT_DIR
Ok(())
}
Source: tonic-prost-build
The application serves GraphQL at /graphql (GraphiQL on GET, queries on POST), GraphQL subscriptions over
WebSocket at /graphql/ws, and the bookshelf.catalog.v1.Catalog gRPC service, from one Router:
use std::collections::HashMap;
use async_graphql::{
Context, EmptyMutation, Object, Schema, SimpleObject, Subscription,
dataloader::{DataLoader, Loader},
http::GraphiQLSource,
};
use async_graphql_axum::{GraphQL, GraphQLSubscription};
use axum::{
Router,
response::{Html, IntoResponse},
routing::get,
};
use futures::{Stream, StreamExt};
use tokio::sync::broadcast;
use tokio_stream::wrappers::BroadcastStream;
pub mod pb {
tonic::include_proto!("bookshelf.catalog.v1");
}
// ---------- GraphQL ----------
#[derive(SimpleObject, Clone)]
#[graphql(complex)]
struct Book {
isbn: String,
title: String,
author_id: u32,
}
#[derive(SimpleObject, Clone)]
struct Author {
id: u32,
name: String,
}
#[async_graphql::ComplexObject]
impl Book {
/// Resolved through a DataLoader: N books -> one batched author lookup.
async fn author(&self, ctx: &Context<'_>) -> async_graphql::Result<Option<Author>> {
Ok(ctx.data_unchecked::<DataLoader<AuthorLoader>>().load_one(self.author_id).await?)
}
}
struct AuthorLoader;
impl Loader<u32> for AuthorLoader {
type Value = Author;
type Error = std::sync::Arc<std::io::Error>;
async fn load(&self, ids: &[u32]) -> Result<HashMap<u32, Author>, Self::Error> {
println!("loading authors {ids:?} in one query");
Ok(ids.iter().map(|&id| (id, Author { id, name: format!("Author {id}") })).collect())
}
}
struct Query;
#[Object]
impl Query {
async fn books(&self) -> Vec<Book> {
vec![
Book { isbn: "9780441013593".into(), title: "Dune".into(), author_id: 1 },
Book { isbn: "9780441478125".into(), title: "The Left Hand of Darkness".into(), author_id: 2 },
Book { isbn: "9780593099322".into(), title: "Dune Messiah".into(), author_id: 1 },
]
}
}
struct Subscription;
#[Subscription]
impl Subscription {
/// New reviews, as they are posted.
async fn reviews(&self, ctx: &Context<'_>) -> impl Stream<Item = String> + use<> {
let rx = ctx.data_unchecked::<broadcast::Sender<String>>().subscribe();
BroadcastStream::new(rx).filter_map(|msg| async move { msg.ok() })
}
}
type BookshelfSchema = Schema<Query, EmptyMutation, Subscription>;
async fn graphiql() -> impl IntoResponse {
Html(GraphiQLSource::build().endpoint("/graphql").subscription_endpoint("/graphql/ws").finish())
}
// ---------- gRPC ----------
#[derive(Default)]
struct CatalogService;
#[tonic::async_trait]
impl pb::catalog_server::Catalog for CatalogService {
async fn get_book(&self, request: tonic::Request<pb::GetBookRequest>) -> Result<tonic::Response<pb::Book>, tonic::Status> {
let isbn = request.into_inner().isbn;
if isbn == "9780441013593" {
Ok(tonic::Response::new(pb::Book { isbn, title: "Dune".into(), year: 1965 }))
} else {
Err(tonic::Status::not_found(format!("no book {isbn}")))
}
}
}
#[tokio::main]
async fn main() {
let (reviews_tx, _) = broadcast::channel::<String>(64);
let schema: BookshelfSchema = Schema::build(Query, EmptyMutation, Subscription)
.data(DataLoader::new(AuthorLoader, tokio::spawn))
.data(reviews_tx.clone())
.finish();
let graphql = Router::new()
.route("/graphql", get(graphiql).post_service(GraphQL::new(schema.clone())))
.route_service("/graphql/ws", GraphQLSubscription::new(schema));
let grpc = tonic::service::Routes::new(pb::catalog_server::CatalogServer::new(CatalogService)).into_axum_router();
let app = graphql.merge(grpc);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
$ curl -s -H 'content-type: application/json' -d '{"query":"{ books { title author { name } } }"}' localhost:3000/graphql
{"data":{"books":[{"title":"Dune","author":{"name":"Author 1"}},{"title":"The Left Hand of Darkness","author":{"name":"Author 2"}},{"title":"Dune Messiah","author":{"name":"Author 1"}}]}}
# server log: one batched lookup for three books
loading authors [1, 2] in one query
$ cargo run --bin client # a tonic client calling Catalog/GetBook on the same port
Book { isbn: "9780441013593", title: "Dune", year: 1965 }
NotFound no book 1
GraphQL with async-graphql
-
Schema:
#[Object]on animplblock turns its async methods into fields;#[derive(SimpleObject)]exposes a struct’s fields directly, and#[graphql(complex)]with#[ComplexObject]adds computed fields such asauthor.Schema::build(Query, Mutation, Subscription)assembles the root types;.data(...)registers values that resolvers read withctx.data_unchecked::<T>()(or the checkedctx.data::<T>()?). -
Serving:
async_graphql_axum::GraphQLis a tower service forPOSTrequests (andGETwith a query string);GraphQLSubscriptionhandles thegraphql-transport-wsand legacygraphql-wsWebSocket protocols. Both are mounted withpost_service/route_service, so axum middleware (tracing, auth, body limits) applies as usual. -
N+1: a naive
authorresolver runs one database query per book. ADataLoadercollects the keys requested while a query executes and callsLoader::loadonce with all of them — the log above shows one batch for three books. The pattern is explained on Performance & N+1. -
Subscriptions return a
Stream; here each subscriber gets its own receiver of atokio::sync::broadcastchannel, the same fan-out used for WebSockets on WebSockets. Theuse<>bound (precise capturing, Rust 1.82) states that the stream borrows nothing fromctx. -
Security: limit query depth and complexity (
Schema::build(...).limit_depth(10).limit_complexity(500)), disable introspection in production if the schema is private, and authorise in resolvers or with guards; see Security & Demand Control.
gRPC with tonic on the same port
-
tonic generates a
catalog_server::Catalogtrait (implemented with#[tonic::async_trait], still used by tonic 0.14) and aCatalogServerwrapper that is a towerService. -
tonic::service::Routes::new(server).into_axum_router()turns one or more gRPC services into anaxum::Routerthat routes/bookshelf.catalog.v1.Catalog/*, ready tomergewith the REST and GraphQL routes. Because everything is a tower service, the sameTraceLayerand timeout layers wrap all three APIs (see Tower Service Model). -
gRPC requires HTTP/2. Enable axum’s
http2feature:axum::servethen accepts HTTP/2 with prior knowledge (h2c, as gRPC clients use over plain TCP) alongside HTTP/1.1 on the same port. Behind a TLS-terminating proxy, the proxy must speak HTTP/2 to the upstream too (see TLS & HTTPS). -
Errors are
tonic::Statusvalues with gRPC codes (not_found,invalid_argument, …), not HTTP status codes. -
For browsers, gRPC-Web needs the
tonic-weblayer.
When to choose which
| API style | Strengths | Costs |
|---|---|---|
REST + JSON |
cacheable, simple, every client and tool understands it; OpenAPI tooling |
many round trips for nested data; over- or under-fetching |
GraphQL |
clients ask for exactly the fields they need in one round trip; strong schema; subscriptions |
HTTP caching is harder; query cost must be limited; resolvers need batching |
gRPC |
compact binary encoding, streaming in both directions, generated clients in many languages, deadlines |
needs HTTP/2, not browser-native, less human-readable |
Bookshelf keeps REST as the public API, offers GraphQL to its own front end, and uses gRPC between internal services. The When to use GraphQL or REST page discusses the trade-off in depth.