#Part 2 - Routing & Path Matching
In the previous tutorial, we built a basic reverse proxy that forwards all requests to a single backend server. That’s a good start, but a real API gateway needs to route requests to different services based on the URL path.
For example, requests to /api/users might go to a user service on port 8081, while requests to /api/orders go to an order service on port 8082. This is the bread and butter of what an API gateway does.
By the end of this tutorial, we’ll have a routing system that matches URL path prefixes to different backend services.
Designing the Router
Before we jump into code, let’s think about what we need:
- A way to define routes — each route maps a path prefix to a backend URL
- A router that takes an incoming request and finds the matching route
- A way to handle requests that don’t match any route (404)
Let’s start by creating some new files. Our project is going to grow, so it’s time to organize things properly.
src/
├── main.rs
├── proxy.rs # The proxying logic from Part 1
└── router.rs # Our new routing layer
Defining a Route
Let’s create src/router.rs and define what a route looks like:
#[derive(Debug, Clone)]
pub struct Route {
pub path_prefix: String,
pub backend_url: String,
pub backend_host: String,
pub backend_port: u16,
}
impl Route {
pub fn new(path_prefix: &str, backend_url: &str) -> Self {
// Parse the backend URL to extract host and port
let url = backend_url.trim_start_matches("http://");
let parts: Vec<&str> = url.split(':').collect();
let host = parts[0].to_string();
let port: u16 = parts.get(1).unwrap_or(&"80").parse().unwrap_or(80);
Route {
path_prefix: path_prefix.to_string(),
backend_url: backend_url.to_string(),
backend_host: host,
backend_port: port,
}
}
/// Check if a given path matches this route's prefix
pub fn matches(&self, path: &str) -> bool {
path.starts_with(&self.path_prefix)
}
}
A few things to note here:
#[derive(Debug, Clone)] — This tells the Rust compiler to automatically generate implementations for printing (Debug) and copying (Clone) our struct. In Go, you get this for free with any struct. In Rust, you opt in explicitly — this keeps things transparent about what your types can do.
pub — This keyword makes items public, similar to capitalizing a function name in Go. Without pub, things are private to the module.
&str vs String — You’ll see both in Rust. Think of &str as a borrowed reference to text (like a Go string slice) and String as owned text that lives on the heap (like a Go string). The to_string() calls convert borrowed references into owned strings. We need owned strings here because our Route struct needs to own its data — it can’t borrow from function arguments that will go away.
Building the Router
Now let’s add the router itself. This will hold a collection of routes and find the right one for each request:
pub struct Router {
routes: Vec<Route>,
}
impl Router {
pub fn new() -> Self {
Router { routes: Vec::new() }
}
/// Add a new route to the router
pub fn add_route(&mut self, path_prefix: &str, backend_url: &str) {
let route = Route::new(path_prefix, backend_url);
self.routes.push(route);
println!(" Route added: {} -> {}", path_prefix, backend_url);
}
/// Find the route that matches the given path
/// Returns the longest matching prefix (most specific route wins)
pub fn find_route(&self, path: &str) -> Option<&Route> {
self.routes
.iter()
.filter(|route| route.matches(path))
.max_by_key(|route| route.path_prefix.len())
}
}
&mut self — The add_route method takes &mut self, which means it borrows the router mutably — it needs to modify the routes list. The find_route method takes &self (immutable borrow) because it only reads. Rust enforces this at compile time: you can have many readers OR one writer, but not both. This prevents data races.
Option<&Route> — This is Rust’s way of expressing “maybe a value, maybe nothing”. It’s similar to returning (route, bool) in Go or using nullable types. Option can be Some(value) or None. The compiler forces you to handle both cases, so you can never accidentally use a null pointer.
Longest prefix matching — We use max_by_key to find the most specific matching route. If you have routes for /api and /api/users, a request to /api/users/123 should match /api/users rather than /api. This is how production gateways like Nginx work.
Extracting the Proxy Logic
Let’s move our proxying logic from main.rs into its own module. Create src/proxy.rs:
use bytes::Bytes;
use http_body_util::{combinators::BoxBody, BodyExt, Full};
use hyper::{Request, Response};
use hyper_util::rt::TokioIo;
use crate::router::Route;
pub async fn forward_request(
req: Request<hyper::body::Incoming>,
route: &Route,
) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> {
let path = req.uri().path().to_string();
let method = req.method().clone();
println!("--> {} {} -> {}", method, path, route.backend_url);
let backend_stream = tokio::net::TcpStream::connect(
format!("{}:{}", route.backend_host, route.backend_port)
).await;
match backend_stream {
Ok(stream) => {
let io = TokioIo::new(stream);
let (mut sender, conn) = hyper::client::conn::http1::handshake(io).await?;
tokio::task::spawn(async move {
if let Err(err) = conn.await {
eprintln!("Connection error: {:?}", err);
}
});
// Strip the route prefix from the path if needed
let upstream_path = if path == route.path_prefix {
"/".to_string()
} else {
path[route.path_prefix.len()..].to_string()
};
let upstream_path = if upstream_path.is_empty() || !upstream_path.starts_with('/') {
format!("/{}", upstream_path)
} else {
upstream_path
};
let upstream_req = Request::builder()
.method(method)
.uri(&upstream_path)
.header("Host", &route.backend_host)
.body(req.into_body().boxed())
.unwrap();
let response = sender.send_request(upstream_req).await?;
Ok(response.map(|b| b.boxed()))
}
Err(e) => {
eprintln!("Failed to connect to backend {}: {}", route.backend_url, e);
let body = Full::new(Bytes::from("502 Bad Gateway"))
.map_err(|never| match never {})
.boxed();
Ok(Response::builder().status(502).body(body).unwrap())
}
}
}
The key change here is path stripping. When a request comes in for /api/users/123 and matches the /api/users route, we forward it to the backend as /123. The backend service doesn’t need to know about the gateway’s routing prefix — it just sees the path relative to itself.
Updating main.rs
Now let’s wire everything together in main.rs:
mod proxy;
mod router;
use bytes::Bytes;
use http_body_util::{combinators::BoxBody, BodyExt, Full};
use hyper::server::conn::http1;
use hyper::service::service_fn;
use hyper::{Request, Response};
use hyper_util::rt::TokioIo;
use router::Router;
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::net::TcpListener;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Configure our routes
let mut router = Router::new();
println!("Configuring routes...");
router.add_route("/api/users", "http://127.0.0.1:8081");
router.add_route("/api/orders", "http://127.0.0.1:8082");
router.add_route("/", "http://127.0.0.1:8080");
// Wrap in Arc so we can share across tasks
let router = Arc::new(router);
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
let listener = TcpListener::bind(addr).await?;
println!("Ferroway API Gateway listening on http://{}", addr);
loop {
let (stream, _) = listener.accept().await?;
let io = TokioIo::new(stream);
let router = router.clone();
tokio::task::spawn(async move {
if let Err(err) = http1::Builder::new()
.serve_connection(
io,
service_fn(move |req| {
let router = router.clone();
async move { handle_request(req, &router).await }
}),
)
.await
{
eprintln!("Error serving connection: {:?}", err);
}
});
}
}
async fn handle_request(
req: Request<hyper::body::Incoming>,
router: &Router,
) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> {
let path = req.uri().path().to_string();
match router.find_route(&path) {
Some(route) => proxy::forward_request(req, route).await,
None => {
let body = Full::new(Bytes::from("404 Not Found"))
.map_err(|never| match never {})
.boxed();
Ok(Response::builder().status(404).body(body).unwrap())
}
}
}
Arc — This stands for “Atomically Reference Counted”. We need it because our router is shared across multiple async tasks (one per connection). In Rust, you can’t just pass a reference to a value across thread boundaries — the compiler won’t let you, because it can’t guarantee the reference will outlive the thread. Arc wraps the router in a reference-counted pointer that can be safely shared. Think of it like a shared smart pointer. The .clone() calls are cheap — they just increment a counter, they don’t copy the router.
move |req| — The move keyword tells the closure to take ownership of the variables it captures (in this case, the Arc<Router> clone). Without move, the closure would try to borrow, which doesn’t work across async task boundaries.
Testing with Multiple Backends
Let’s update our example backend so we can run multiple instances. Update examples/backend.rs:
use bytes::Bytes;
use http_body_util::Full;
use hyper::server::conn::http1;
use hyper::service::service_fn;
use hyper::{Request, Response};
use hyper_util::rt::TokioIo;
use std::env;
use std::net::SocketAddr;
use tokio::net::TcpListener;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let port: u16 = env::args()
.nth(1)
.unwrap_or_else(|| "8080".to_string())
.parse()
.expect("Invalid port number");
let name = env::args()
.nth(2)
.unwrap_or_else(|| format!("backend-{}", port));
let addr = SocketAddr::from(([127, 0, 0, 1], port));
let listener = TcpListener::bind(addr).await?;
println!("{} listening on http://{}", name, addr);
loop {
let (stream, _) = listener.accept().await?;
let io = TokioIo::new(stream);
let name = name.clone();
tokio::task::spawn(async move {
if let Err(err) = http1::Builder::new()
.serve_connection(
io,
service_fn(move |req| {
let name = name.clone();
async move { handle(req, &name).await }
}),
)
.await
{
eprintln!("Error: {:?}", err);
}
});
}
}
async fn handle(
req: Request<hyper::body::Incoming>,
name: &str,
) -> Result<Response<Full<Bytes>>, hyper::Error> {
let body = format!(
"Response from: {}\nPath: {} {}\n",
name,
req.method(),
req.uri().path()
);
Ok(Response::new(Full::new(Bytes::from(body))))
}
Now open four terminal windows:
# Terminal 1 - Default backend
$ cargo run --example backend -- 8080 default-service
# Terminal 2 - User service
$ cargo run --example backend -- 8081 user-service
# Terminal 3 - Order service
$ cargo run --example backend -- 8082 order-service
# Terminal 4 - The gateway
$ cargo run
And test the routing:
$ curl http://localhost:3000/api/users/123
Response from: user-service
Path: GET /123
$ curl http://localhost:3000/api/orders/456
Response from: order-service
Path: GET /456
$ curl http://localhost:3000/health
Response from: default-service
Path: GET /health
Each request is being routed to the correct backend based on the path prefix. The /api/users prefix routes to port 8081, /api/orders to 8082, and everything else falls through to the default backend on 8080.
Adding Unit Tests
One of the great things about Rust is how easy it is to add tests. Let’s add some tests for our router. Add this to the bottom of src/router.rs:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_route_matches() {
let route = Route::new("/api/users", "http://127.0.0.1:8081");
assert!(route.matches("/api/users"));
assert!(route.matches("/api/users/123"));
assert!(!route.matches("/api/orders"));
assert!(!route.matches("/health"));
}
#[test]
fn test_router_finds_most_specific_route() {
let mut router = Router::new();
router.add_route("/api", "http://127.0.0.1:8080");
router.add_route("/api/users", "http://127.0.0.1:8081");
let route = router.find_route("/api/users/123").unwrap();
assert_eq!(route.backend_port, 8081);
}
#[test]
fn test_router_returns_none_for_no_match() {
let router = Router::new();
assert!(router.find_route("/anything").is_none());
}
#[test]
fn test_fallback_route() {
let mut router = Router::new();
router.add_route("/api/users", "http://127.0.0.1:8081");
router.add_route("/", "http://127.0.0.1:8080");
// Specific route
let route = router.find_route("/api/users/1").unwrap();
assert_eq!(route.backend_port, 8081);
// Fallback to /
let route = router.find_route("/health").unwrap();
assert_eq!(route.backend_port, 8080);
}
}
Run the tests:
$ cargo test
running 4 tests
test router::tests::test_route_matches ... ok
test router::tests::test_router_finds_most_specific_route ... ok
test router::tests::test_router_returns_none_for_no_match ... ok
test router::tests::test_fallback_route ... ok
#[cfg(test)] — This tells the compiler to only include this module when running tests. The test code won’t end up in your production binary.
Conclusion
Our API gateway can now route requests to different backend services based on URL path prefixes. We’ve introduced several important Rust concepts along the way — Arc for shared ownership across threads, the module system, and unit testing.
The routing is simple but effective. Production gateways often support more complex patterns (regex, wildcards, header-based routing), but prefix matching covers the vast majority of use cases.
Challenge - Add support for an
X-Forwarded-Forheader in the proxy. When the gateway forwards a request, it should add the client’s IP address to anX-Forwarded-Forheader so the backend service knows where the original request came from.
Next Part
In Part 3 - Middleware Pipeline, we’ll build a composable middleware system using Rust traits, then implement logging, CORS headers, and custom header middleware.
Continue Learning
Part 6 - Configuration & Authentication
In the final part of this series, we move our gateway configuration into a YAML file and implement JWT-based authentication middleware to protect our backend services.
Part 5 - Load Balancing & Health Checks
In this tutorial, we add load balancing to our API gateway with round-robin distribution and background health checks that automatically remove unhealthy backends from the pool.
Part 4 - Rate Limiting
In this tutorial, we implement a token bucket rate limiter as middleware for our API gateway, protecting backend services from abuse by tracking requests per client IP.
Part 3 - Middleware Pipeline
In this tutorial, we build a composable middleware system using Rust traits. We implement logging, CORS, and custom header injection middleware for our API gateway.