Implementing a fully functional API Gateway in Rust: Part 1

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:

  1. Request Routing: Directs incoming requests to the appropriate service based on URL, method, headers, or other rules.
  2. Load Balancing: Distributes incoming requests to multiple instances of a service, ensuring no single instance gets overwhelmed.
  3. Authentication & Authorization: Verifies the identity of the requester (authentication) and determines whether they have the right permissions to access a particular resource (authorization).
  4. Rate Limiting: Restricts a client’s requests in a specified time window, protecting services from potential abuse or overloads.
  5. Request & Response Transformation: Modifies incoming requests or outgoing responses to adhere to the expected format or to add/remove specific information.
  6. Caching: Stores frequently-used responses to minimize redundant processing and accelerate response times.
  7. Circuit Breaking: Detects service failures and prevents the system from overloading those failing services by temporarily pausing requests.
  8. Logging & Monitoring: Keeps track of all incoming and outgoing requests, helping in monitoring, alerting, and debugging.
  9. 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.

tokio

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.

serde & serde_json

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:

  • 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 https://{}", 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).


Download Now!


Adding a few more features

To finish up this first part of the tutorial, let’s add the following (still rudimentary) features:

  1. 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.
  2. 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:

  1. The secret key should be securely stored and rotated periodically.
  2. An authentication service should generate JWT tokens and might contain claims such as user identity, roles, or permissions.
  3. 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:

  1. Generate a JWT token for authentication.
  2. 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("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:

  1. Generate a JWT token with hardcoded claims.
  2. Use the hyper library to make an HTTP request to our API Gateway's /service1 endpoint.
  3. Attach the generated JWT token in the “Authorization” header.
  4. Print the response status and body.


Download Now!


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!


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

Menjanahary R. Rasolofoniaina

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!

回复

要查看或添加评论,请登录

社区洞察

其他会员也浏览了