Simplifying Web Service Development: A Minimalist Approach
Finding the Sweet Spot
When building web services, it’s tempting to opt for a fully featured, heavyweight framework. However, this approach can lead to hidden complexity, degraded performance, and tedious debugging. On the other hand, building everything from scratch can be extremely time-consuming and prone to errors.
In my experience, the key to success lies in finding a balance between convenience and complexity. By using multiple, small libraries and building a minimal system, you can retain development speed while keeping complexity in check. This approach has worked well for me in the past, allowing me to tailor the system to the problem at hand.
Building a Rust Web Service without a Framework
In this tutorial, we’ll show you how to build a Rust web service without relying on a full-fledged framework. We’ll use hyper for our HTTP server and tokio as our async runtime. While these libraries aren’t the most lightweight options, they’re widely used and well-maintained.
Setup and Dependencies
To follow along, you’ll need a recent Rust installation (1.39+) and a tool to send HTTP requests, such as cURL. Create a new Rust project and add the following dependencies to your Cargo.toml
file:
[dependencies]
hyper = "0.14.4"
tokio = "1.20.0"
serde = "1.0.130"
serde_json = "1.0.64"
route-recognizer = "1.2.0"
bytes = "1.1.0"
async-trait = "0.1.52"
futures = "0.3.21"
Handler API
Let’s start by defining our handler API. We’ll create three handlers: a basic handler that returns a string, a handler that expects a JSON payload, and a handler that demonstrates path parameters.
struct Context {
// Add request state here
}
trait Handler {
fn handle(&self, ctx: &Context) -> String;
}
struct BasicHandler;
impl Handler for BasicHandler {
fn handle(&self, _ctx: &Context) -> String {
"Hello, World!".to_string()
}
}
// Define more handlers here...
Context and Handlers
To use request state within a handler, we need a way to pass it in. We’ll use a Context
object to encapsulate this information. Our handlers will take a Context
object as an argument and return a response.
// Define more handlers here...
struct JsonHandler;
impl Handler for JsonHandler {
fn handle(&self, ctx: &Context) -> String {
// Handle JSON payload here
"".to_string()
}
}
struct PathHandler;
impl Handler for PathHandler {
fn handle(&self, ctx: &Context) -> String {
// Handle path parameters here
"".to_string()
}
}
Routing
Next, we’ll build a routing mechanism to accommodate our handlers. We’ll define a Router
struct that holds a method map, which separates registered routes by HTTP method. We’ll use the route_recognizer crate to handle path parameters.
struct Router {
method_map: HashMap<string, hashmap<string,="" box<dyn="" handler="">>>,
}
impl Router {
fn new() -> Self {
Router {
method_map: HashMap::new(),
}
}
fn add_route(&mut self, method: &str, path: &str, handler: Box) {
// Add route to method map
}
fn route(&self, method: &str, path: &str, ctx: &Context) -> String {
// Route request to handler
"".to_string()
}
}</string,>
Putting it all Together
Finally, we’ll wire everything together. We’ll create a Router
, add some routes, and define what should happen for incoming requests. We’ll use the makeservicefn
function provided by hyper to define our service closure.
async fn handle_request(req: Request<Body>) -> Result<Response<Body>, Error> {
let ctx = Context { /* Initialize request state */ };
let router = Router::new();
router.add_route("GET", "/", Box::new(BasicHandler {}));
router.add_route("POST", "/json", Box::new(JsonHandler {}));
router.add_route("GET", "/path/:id", Box::new(PathHandler {}));
let response = router.route(req.method().as_str(), req.uri().path(), &ctx);
Ok(Response::new(Body::from(response)))
}
#[tokio::main]
async fn main() -> Result<(), Error> {
let make_service = make_service_fn(|_conn| async {
Ok::<_, Error>(service_fn(handle_request))
});
let addr = ([127, 0, 0, 1], 3000).into();
let server = Server::bind(&addr).serve(make_service);
println!("Server running on http://localhost:3000");
server.await?;
Ok(())
}
By using small, lightweight libraries and composing a minimal system, you can improve performance, maintainability, and code quality. This approach requires experimentation and courage, but it’s worth the effort.