Implementing a fully functional API Gateway in Rust: Part 1
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:
- Request Routing: Directs incoming requests to the appropriate service based on URL, method, headers, or other rules.
- Load Balancing: Distributes incoming requests to multiple instances of a service, ensuring no single instance gets overwhelmed.
- Authentication & Authorization: Verifies the identity of the requester (authentication) and determines whether they have the right permissions to access a particular resource (authorization).
- Rate Limiting: Restricts a client’s requests in a specified time window, protecting services from potential abuse or overloads.
- Request & Response Transformation: Modifies incoming requests or outgoing responses to adhere to the expected format or to add/remove specific information.
- Caching: Stores frequently-used responses to minimize redundant processing and accelerate response times.
- Circuit Breaking: Detects service failures and prevents the system from overloading those failing services by temporarily pausing requests.
- Logging & Monitoring: Keeps track of all incoming and outgoing requests, helping in monitoring, alerting, and debugging.
- Security: Provides features like SSL termination, CORS management, and protection against attacks such as SQL injection or DDoS attacks.
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:
- Simplification: Clients (like front-end apps) no longer need to make requests to multiple services individually. They communicate with the API Gateway, which handles the intricate details.
- Centralized Management: Aspects like authentication, logging, or rate limiting are managed centrally, preventing redundant configurations and streamlining the architecture.
- Flexibility: As services evolve, change, or scale, the API Gateway ensures clients remain unaffected. Service locations can change without the client ever knowing.
- Performance Enhancements: Features like caching result in faster response times, enhancing user experience.
- Robustness: By handling failures gracefully and preventing overloads with mechanisms like rate limiting and circuit breaking, the API Gateway ensures better system uptime.
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("http://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:
- Basic logging.
- Simple in-memory rate limiting.
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 http://{}", addr);
if let Err(e) = server.await {
eprintln!("server error: {}", e);
}
}
In this:
- We added a basic logging feature that prints incoming requests’ IP addresses.
- We introduced a simple rate limiter. If an IP address sends more than five requests, it will receive a “Too many requests” response until the rate limiter is reset (in this basic example, when the program restarts).
Adding a few more features
To finish up this first part of the tutorial, let’s add the following (still rudimentary) features:
- Request/Response Transformation: The example includes a simple transformation of appending a custom header to each request and adding a custom field to the JSON response.
- HTTPS Support: Using
hyper-tls
, our client can now forward requests to HTTPS services.
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:
- The secret key should be securely stored and rotated periodically.
- An authentication service should generate JWT tokens and might contain claims such as user identity, roles, or permissions.
- The token’s structure and claims would be documented, and client applications would be expected to provide valid tokens.
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:
- Generate a JWT token for authentication.
- Make requests to the API Gateway with and without the JWT token.
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("http://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:
- Generate a JWT token with hardcoded claims.
- Use the
hyper
library to make an HTTP request to our API Gateway's/service1
endpoint. - Attach the generated JWT token in the “Authorization” header.
- Print the response status and body.
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.
Check out some interesting hands-on Rust articles:
🌟 Developing a Fully Functional API Gateway in Rust — Discover how to set up a robust and scalable gateway that stands as the frontline for your microservices.
🌟 Implementing a Network Traffic Analyzer — Ever wondered about the data packets zooming through your network? Unravel their mysteries with this deep dive into network analysis.
🌟 Building an Application Container in Rust — Join us in creating a lightweight, performant, and secure container from scratch! Docker’s got nothing on this. 😉
🌟 Crafting a Secure Server-to-Server Handshake with Rust & OpenSSL — If you’ve been itching to give your servers a unique secret handshake that only they understand, you’ve come to the right place. Today, we’re venturing into the world of secure server-to-server handshakes, using the powerful combo of Rust and OpenSSL.
🌟Building a Function-as-a-Service (FaaS) in Rust: If you’ve been exploring cloud computing, you’ve likely come across FaaS platforms like AWS Lambda or Google Cloud Functions. In this article, we’ll be creating our own simple FaaS platform using Rust.
🌟 Rusting Up Your Own Self-Signed Certificate Generator — Let’s walk through the process of crafting your very own self-signed certificate generator, all using the versatile Rust programming language and the rust-openssl crate.Happy coding, and keep those Rust gears turning! 🦀
🌟 Implementing a Secret Vault in Rust: We’re about to dive into some cryptography, play around with key derivations, and even wrestle with user input, all to keep our most prized digital possessions under lock and key.
Happy coding, and keep those Rust gears turning!
Dive into the code, play around with it, and perhaps even contribute!
Stay tuned, and we’ll catch you in the next part of our Rust-powered journey.
Thanks for sticking around, and happy coding!
Read more articles about Rust in my Rust Programming Library!
Visit my Blog for more articles, news, and software engineering stuff!
Follow me on Medium, LinkedIn, and Twitter.
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