Implementing a fully functional API Gateway in Rust: Part 1
Luis Soares, M.Sc.
Lead Software Engineer | Blockchain & ZK Protocol Engineer | ?? Rust | C++ | Web3 | Solidity | Golang | Cryptography | Author
Welcome to the first part of our comprehensive series dedicated to constructing a fully functional API Gateway using the Rust programming language. If you’ve been hunting for a hands-on, step-by-step guide that demystifies the intricacies of building scalable and secure gateways in Rust, you’ve landed in the right place! ??
Over the span of this series, we will explore the foundational aspects of an API Gateway and delve into advanced features, ensuring that by the end, you will have a production-ready piece of infrastructure in your toolkit.
In today’s article, we’ll kick off our journey by introducing our gateway’s fundamental components and features, setting the stage for in-depth discussions and code explorations in the upcoming articles.
So, whether you’re a seasoned Rustacean or just venturing into the Rust ecosystem, strap in for an enlightening journey!
What is an API Gateway?
An API Gateway is a server that acts as an intermediary for requests from clients seeking resources from other services. Think of it as a kind of “middleman” or a “gatekeeper” that manages and directs incoming traffic, ensuring that requests are handled efficiently and securely.
Key Functions of an API Gateway:
Why is it Important?
With the rise of microservices architecture, where an application is broken into small, loosely coupled services, managing and coordinating requests becomes more complex. Here’s where the API Gateway shines:
Crates for the API Gateway Project
hyper
A fast and flexible HTTP library in Rust. It will be our primary tool for setting up the HTTP server, handling requests, and making client connections.
A Rust asynchronous runtime that provides the necessary tools to write reliable and fast asynchronous applications. This crate powers the asynchronous functionality, enabling non-blocking operations crucial for scalable services.
hyper-tls
Provides support for HTTPS to the hyper crate. Allows our gateway to make secure HTTPS client requests and potentially serve HTTPS requests.
A framework for serializing and deserializing Rust data structures efficiently and generically. serde_json provides JSON support. Used for parsing and creating JSON payloads, especially when transforming requests or responses.
jsonwebtoken
A library to use JSON Web Tokens (JWT) in Rust. Handles JWT-based authentication by validating provided JWT tokens.
arc-swap
Provides a way to safely and efficiently swap the content of an Arc (Atomic Reference Counted) pointer. Assists in managing shared state, like our rate limiter, across multiple threads without locking.
1. Setting Up
Firstly, add the necessary dependencies to your Cargo.toml:
[dependencies]
hyper = "0.14"
tokio = { version = "1", features = ["full"] }
serde = "1.0"
serde_json = "1.0"
2. Setting Up the Hyper Server
hyper is a fast, low-level HTTP library. Let's set it up:
use hyper::{Body, Request, Response, Server};
use hyper::service::{make_service_fn, service_fn};
async fn handle_request(req: Request<Body>) -> Result<Response<Body>, hyper::Error> {
// This is where our gateway logic will reside
Ok(Response::new(Body::from("Hello, World!")))
}
#[tokio::main]
async fn main() {
let make_svc = make_service_fn(|_conn| {
// Clone the handle for each connection.
let service = service_fn(handle_request);
async { Ok::<_, hyper::Error>(service) }
});
let addr = ([127, 0, 0, 1], 8080).into();
let server = Server::bind(&addr).serve(make_svc);
if let Err(e) = server.await {
eprintln!("server error: {}", e);
}
}
3. Routing
Next, we want to route the incoming requests to different services. We can create a simple match on the request’s path:
async fn handle_request(req: Request<Body>) -> Result<Response<Body>, hyper::Error> {
match req.uri().path() {
"/service1" => {
// Redirect to service 1
},
"/service2" => {
// Redirect to service 2
},
_ => Ok(Response::new(Body::from("Not Found"))),
}
}
4. Service Forwarding
To forward requests to downstream services, use the hyper client:
let client = hyper::Client::new();
let forwarded_req = Request::builder()
.method(req.method())
.uri("https://downstream_service_url")
.body(req.into_body())
.unwrap();
let resp = client.request(forwarded_req).await?;
Expanding our Basic API Gateway
Let’s expand our basic API Gateway by adding a couple more features:
Dependencies
Add the following dependencies to your Cargo.toml:
[dependencies]
hyper = "0.14"
tokio = { version = "1", features = ["full"] }
std::collections::HashMap
std::sync::Mutex
Enhance the API Gateway code:
use hyper::{Body, Request, Response, Server, StatusCode};
use hyper::service::{make_service_fn, service_fn};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::net::SocketAddr;
struct RateLimiter {
visitors: Arc<Mutex<HashMap<SocketAddr, u32>>>,
}
impl RateLimiter {
fn new() -> Self {
RateLimiter {
visitors: Arc::new(Mutex::new(HashMap::new())),
}
}
fn allow(&self, addr: SocketAddr) -> bool {
let mut visitors = self.visitors.lock().unwrap();
let counter = visitors.entry(addr).or_insert(0);
if *counter >= 5 { // Allow up to 5 requests
false
} else {
*counter += 1;
true
}
}
}
async fn service_handler(path: &str) -> Result<Response<Body>, hyper::Error> {
match path {
"/service1" => Ok(Response::new(Body::from("Hello from Service 1"))),
"/service2" => Ok(Response::new(Body::from("Hello from Service 2"))),
_ => {
let mut not_found = Response::default();
*not_found.status_mut() = StatusCode::NOT_FOUND;
Ok(not_found)
},
}
}
async fn handle_request(req: Request<Body>, rate_limiter: Arc<RateLimiter>) -> Result<Response<Body>, hyper::Error> {
let remote_addr = req.remote_addr().expect("Remote address should be available");
if !rate_limiter.allow(remote_addr) {
return Ok(Response::builder()
.status(StatusCode::TOO_MANY_REQUESTS)
.body(Body::from("Too many requests"))
.unwrap());
}
println!("Received request from {}:{}", remote_addr.ip(), remote_addr.port());
let response = service_handler(req.uri().path()).await;
response
}
#[tokio::main]
async fn main() {
let rate_limiter = Arc::new(RateLimiter::new());
let make_svc = make_service_fn(move |_conn| {
let rate_limiter = Arc::clone(&rate_limiter);
// Clone the handle for each connection.
let service = service_fn(move |req| handle_request(req, Arc::clone(&rate_limiter)));
async { Ok::<_, hyper::Error>(service) }
});
let addr = ([127, 0, 0, 1], 8080).into();
let server = Server::bind(&addr).serve(make_svc);
println!("API Gateway running on https://{}", addr);
if let Err(e) = server.await {
eprintln!("server error: {}", e);
}
}
In this:
领英推荐
Adding a few more features
To finish up this first part of the tutorial, let’s add the following (still rudimentary) features:
Dependencies
Add the following dependencies to your Cargo.toml:
jsonwebtoken = "7.2"
JWT-based Authentication
For simplicity, let’s use a hardcoded secret key for JWT token verification. In a real-world scenario, this would be securely stored and managed.
use jsonwebtoken::{decode, DecodingKey, Validation, Algorithm, errors::ErrorKind};
// ... [other imports]
const SECRET_KEY: &'static str = "secret_key"; // Please use a stronger secret in a real-world scenario
fn authenticate(token: &str) -> bool {
let validation = Validation {
iss: Some("my_issuer".to_string()),
algorithms: vec![Algorithm::HS256],
..Default::default()
};
match decode::<serde_json::Value>(&token, &DecodingKey::from_secret(SECRET_KEY.as_ref()), &validation) {
Ok(_data) => true,
Err(err) => {
match *err.kind() {
ErrorKind::InvalidToken => false, // token is invalid
_ => false
}
}
}
}
// ... [rest of the code]
async fn handle_request(req: Request<Body>, rate_limiter: Arc<RateLimiter>, client: Arc<hyper::Client<HttpsConnector<HttpConnector>>>) -> Result<Response<Body>, hyper::Error> {
// ... [Rate limiting and logging logic]
// Authentication
match req.headers().get("Authorization") {
Some(value) => {
let token_str = value.to_str().unwrap_or("");
if !authenticate(token_str) {
return Ok(Response::builder()
.status(StatusCode::UNAUTHORIZED)
.body(Body::from("Unauthorized"))
.unwrap());
}
}
None => {
return Ok(Response::builder()
.status(StatusCode::UNAUTHORIZED)
.body(Body::from("Unauthorized"))
.unwrap());
}
}
// Send the request to the service handler
service_handler(req, &client).await
}
// ... [rest of the code]
In the handle_request function, we check for the presence of the "Authorization" header and attempt to validate it using our authenticate function. If it's missing or invalid, we return a 401 Unauthorized status.
For this simple demonstration, we’re using a hardcoded SECRET_KEY and expecting tokens with an issuer claim of "my_issuer". In a real-world situation:
This approach provides a simple way to authenticate requests at the API Gateway level, but it can be extended with more complex authentication and authorization logic as needed.
Implementing a client for testing the API gateway
We’ll create a Rust-based client to test the API Gateway’s authentication and other features. This client will:
Dependencies
Add the following dependencies to your Cargo.toml:
jsonwebtoken = "7.2"
hyper = "0.14"
hyper-tls = "0.5"
tokio = { version = "1", features = ["full"] }
Rust-based Client Implementation:
use jsonwebtoken::{encode, Header, EncodingKey, Algorithm};
use hyper::{Client, Request};
use hyper_tls::HttpsConnector;
const SECRET_KEY: &'static str = "secret_key"; // Must match the secret in the API Gateway
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
sub: String,
iss: String,
}
#[tokio::main]
async fn main() {
let claims = Claims {
sub: "1234567890".to_string(),
iss: "my_issuer".to_string(),
};
let token = encode(&Header::default(), &claims, &EncodingKey::from_secret(SECRET_KEY.as_ref())).expect("Token generation failed");
let client = {
let https = HttpsConnector::new();
Client::builder().build::<_, hyper::Body>(https)
};
let request = Request::builder()
.method("GET")
.uri("https://127.0.0.1:8080/service1")
.header("Authorization", token)
.body(hyper::Body::empty())
.expect("Request builder failed.");
let response = client.request(request).await.expect("Request failed.");
println!("Response: {:?}", response.status());
let bytes = hyper::body::to_bytes(response.into_body()).await.expect("Failed to read response.");
let string = String::from_utf8_lossy(&bytes);
println!("Response Body: {}", string);
}
This client will:
When you run this client, it should successfully authenticate with the API Gateway and receive a response from the /service1 endpoint.
To test the authentication failure, you can comment out the line that sets the “Authorization” header or modify the JWT token’s content or signature.
Wrapping Up Today’s Journey
And there we have it, folks! We’ve embarked on an exciting adventure into API Gateways with Rust, laying down the groundwork with some essential features. While today’s implementation might seem basic, remember: every robust system starts with a strong foundation.
But don’t worry, this is just the beginning. Throughout this series, we’ll be delving deeper, expanding on these rudimentary features, and introducing more advanced functionalities to make our gateway truly production-ready.
Hungry for more and can’t wait for the next article?
Feel free to peek ahead by checking out our GitHub repository at https://github.com/luishsr/rust-api-gateway.
Subscribe to my Newsletter for more articles, news, and software engineering stuff!
Leave a comment, and drop me a message!
All the best,
Luis Soares
CTO | Tech Lead | Senior Software Engineer | Cloud Solutions Architect | Rust ?? | Golang | Java | ML AI & Statistics | Web3 & Blockchain
Made of Star stuff... Ad astra ???
2 周So instructive ?? Valuable resource for any serious #rust aspiring learner. I've also read the part 2 follow up and confirm my early impression. Keep posting!