Why
Writing microservices in Rust can result in small, fast services that use next to no memory. A lot of rust web frameworks have juniper bindings, but for a small graphql service, advanced routing, provided by a web framework might not be need, as in graphql routing is replaced by queries, so we can just use an http framework like hyper-rs directly.
Disclaimer: I haven't observed any latency improvements while using Hyper-rs directly, over using juniper with warp-rs, which is a web framework with more functionality.
How
We can start by installing the dependencies, this is easy if you have cargo add
tool. Then you can just invoke
cargo add tokio
cargo add hyper
cargo add juniper
cargo add lazy_static
cargo add lazy_json
You'll have to edit the cargo toml manually to enable the macros
feature for tokio.
At the end you Cargo.toml dependencies section should look like this.
[dependencies]
tokio = { version = "0.2.13", features = ["macros"]}
hyper = "0.13.2"
juniper = "0.14.2"
lazy_static = "1.4.0"
serde_json = "1.0.48"
Given the dependencies, you can create a simple server with hyper-rs. For simple things, routing can be easily done via Rust's pattern matching capabilities. We define 3 endpoints: the homepage, graphql playground and graphql endpoint itself.
use hyper::header::HeaderValue;
use hyper::service::{make_service_fn, service_fn};
use hyper::{Body, Method, Request, Response, Server, StatusCode};
async fn handle(req: Request<Body>) -> Result<Response<Body>, Infallible> {
let mut response = Response::new(Body::empty());
match (req.method(), req.uri().path()) {
(&Method::GET, "/") => {
*response.body_mut() = Body::from("Home");
}
(&Method::GET, "/playground") => {
*response.body_mut() = Body::from("GraphQL Playground");
}
(&Method::POST, "/graphql") => {
*response.body_mut() = Body::from("GraphQL Endpoint");
}
_ => *response.status_mut() = StatusCode::NOT_FOUND,
}
Ok(response)
}
#[tokio::main]
async fn main() {
let port = 3000;
let addr = SocketAddr::from(([0, 0, 0, 0], port));
let make_svc = make_service_fn(|_conn| async { Ok::<_, Infallible>(service_fn(handle)) });
let server = Server::bind(&addr).serve(make_svc);
println!("Listening on port: {}", port);
if let Err(e) = server.await {
eprintln!("server error: {}", e);
}
}
Next we can add a simple graphql root, using juniper-rs. We have a global state object that will be passed to the resolvers. It's a vec of users, and a query and a mutation.
#[derive(Clone)]
pub struct User {
id: Option<usize>,
username: String,
}
#[juniper::object]
#[graphql(description = "User")]
impl User {
#[graphql(description = "User id")]
fn id(&self) -> i32 {
self.id.unwrap_or(0) as i32
}
#[graphql(description = "User's selected name")]
fn username(&self) -> &str {
&self.username
}
}
#[derive(juniper::GraphQLInputObject)]
pub struct NewUser {
username: String,
}
impl NewUser {
fn to_internal(self) -> User {
User {
id: None,
username: self.username.to_owned(),
}
}
}
pub struct State {
users: RwLock<Vec<User>>,
}
pub struct QueryRoot;
pub struct MutationRoot;
pub type Schema = RootNode<'static, QueryRoot, MutationRoot>;
fn create_schema() -> Schema {
Schema::new(QueryRoot {}, MutationRoot {})
}
#[juniper::object(Context=State)]
impl QueryRoot {
#[graphql(description = "Get all Users")]
fn users(context: &State) -> Vec<User> {
let users = context.users.read().unwrap();
users.iter().cloned().collect()
}
}
#[juniper::object(Context=State)]
impl MutationRoot {
#[graphql(description = "Add new user")]
fn add_user(context: &State, user: NewUser) -> User {
let mut users = context.users.write().unwrap();
let mut user = user.to_internal();
user.id = Some((users.len() + 1) as usize);
users.push(user.clone());
user
}
}
Next we want to have some global state, we can do that by having a lazy_static block
// The schema and state are singletons, we want to initialize them once, globally
lazy_static! {
static ref SCHEMA: Schema = { create_schema() };
static ref STATE: State = {
State {
users: RwLock::new(Vec::new()),
}
};
}
And wire up the graphql playground and the endpoint in the hyper-rs handler.
async fn handle(req: Request<Body>) -> Result<Response<Body>, Infallible> {
let mut response = Response::new(Body::empty());
match (req.method(), req.uri().path()) {
(&Method::GET, "/") => {
*response.body_mut() = Body::from("Home");
}
(&Method::GET, "/playground") => {
*response.body_mut() =
Body::from(juniper::http::playground::playground_source("/graphql"));
(*response.headers_mut()).append(
"Content-Type",
HeaderValue::from_static("text/html;charset=utf-8"),
);
}
(&Method::POST, "/graphql") => {
let body = hyper::body::to_bytes(req)
.await
.expect("to be able to read the request body");
let query: juniper::http::GraphQLRequest =
serde_json::from_slice(&body).expect("to be able to deserialize the json request");
let graphql_response = query.execute(&SCHEMA, &STATE);
let status = if graphql_response.is_ok() {
StatusCode::OK
} else {
StatusCode::BAD_REQUEST
};
*response.status_mut() = status;
*response.body_mut() = Body::from(
serde_json::to_string(&graphql_response)
.expect("to be able to serialize the response"),
);
}
_ => *response.status_mut() = StatusCode::NOT_FOUND,
}
Ok(response)
}
You can find the whole program in the Annex at the bottom.
Conclusions
The resulting server is quite fast. Obviously, there are a lot of things missing, like authorization/authentication, tls, response compression, http/2. Still, you can hide this server behind a reverse proxy that implements all those. There will be a follow-up post on how to add these via Rust as well.
Annex
use hyper::header::HeaderValue;
use hyper::service::{make_service_fn, service_fn};
use hyper::{Body, Method, Request, Response, Server, StatusCode};
use juniper::RootNode;
use lazy_static::*;
use std::sync::RwLock;
use std::{convert::Infallible, net::SocketAddr};
#[derive(Clone)]
pub struct User {
id: Option<usize>,
username: String,
}
#[juniper::object]
#[graphql(description = "User")]
impl User {
#[graphql(description = "User id")]
fn id(&self) -> i32 {
self.id.unwrap_or(0) as i32
}
#[graphql(description = "User's selected name")]
fn username(&self) -> &str {
&self.username
}
}
#[derive(juniper::GraphQLInputObject)]
pub struct NewUser {
username: String,
}
impl NewUser {
fn to_internal(self) -> User {
User {
id: None,
username: self.username.to_owned(),
}
}
}
pub struct State {
users: RwLock<Vec<User>>,
}
pub struct QueryRoot;
pub struct MutationRoot;
pub type Schema = RootNode<'static, QueryRoot, MutationRoot>;
fn create_schema() -> Schema {
Schema::new(QueryRoot {}, MutationRoot {})
}
#[juniper::object(Context=State)]
impl QueryRoot {
#[graphql(description = "Get all Users")]
fn users(context: &State) -> Vec<User> {
let users = context.users.read().unwrap();
users.iter().cloned().collect()
}
}
#[juniper::object(Context=State)]
impl MutationRoot {
#[graphql(description = "Add new user")]
fn add_user(context: &State, user: NewUser) -> User {
let mut users = context.users.write().unwrap();
let mut user = user.to_internal();
user.id = Some((users.len() + 1) as usize);
users.push(user.clone());
user
}
}
// The schema is a singleton, we want to initialize it once, globally
lazy_static! {
static ref SCHEMA: Schema = { create_schema() };
static ref STATE: State = {
State {
users: RwLock::new(Vec::new()),
}
};
}
async fn handle(req: Request<Body>) -> Result<Response<Body>, Infallible> {
let mut response = Response::new(Body::empty());
match (req.method(), req.uri().path()) {
(&Method::GET, "/") => {
*response.body_mut() = Body::from("Home");
}
(&Method::GET, "/playground") => {
*response.body_mut() =
Body::from(juniper::http::playground::playground_source("/graphql"));
(*response.headers_mut()).append(
"Content-Type",
HeaderValue::from_static("text/html;charset=utf-8"),
);
}
(&Method::POST, "/graphql") => {
let body = hyper::body::to_bytes(req)
.await
.expect("to be able to read the request body");
let query: juniper::http::GraphQLRequest =
serde_json::from_slice(&body).expect("to be able to deserialize the json request");
let graphql_response = query.execute(&SCHEMA, &STATE);
let status = if graphql_response.is_ok() {
StatusCode::OK
} else {
StatusCode::BAD_REQUEST
};
*response.status_mut() = status;
*response.body_mut() = Body::from(
serde_json::to_string(&graphql_response)
.expect("to be able to serialize the response"),
);
}
_ => *response.status_mut() = StatusCode::NOT_FOUND,
}
Ok(response)
}
#[tokio::main]
async fn main() {
let port = 3000;
let addr = SocketAddr::from(([0, 0, 0, 0], port));
let make_svc = make_service_fn(
|_conn| async { Ok::<_, Infallible>(service_fn(handle)) });
let server = Server::bind(&addr).serve(make_svc);
println!("Listening on port: {}", port);
if let Err(e) = server.await {
eprintln!("server error: {}", e);
}
}