Working with Go’s net/http
package offers a lot of transparency. Even without any framework like Gin
, Echo
, or Fiber
, most of us follow a familiar flow when writing HTTP handlers.
This post breaks that flow down step by step — with code samples explained in detail — and ends with a full, runnable example.
The Standard Handler Flow (Explained)
Let’s walk through what a typical HTTP handler does in Go, and what each step looks like.
1. Authentication Middleware
A common first step is ensuring that only authenticated users can proceed. Middleware helps you intercept the request before it hits your actual handler.
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
// Simple auth check for example purposes
if token != "valid-token" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Pass control to the next handler
next.ServeHTTP(w, r)
})
}
What it does:
- Extracts an
Authorization
header. - If the token is incorrect or missing, it returns a
401 Unauthorized
. - Otherwise, it calls the actual handler.
2. Method Check
You need to ensure the HTTP method (e.g., POST
, GET
) matches what the handler expects.
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
Why this matters:
- Go’s
http.HandleFunc
doesn’t restrict HTTP methods by default. - You’ll avoid confusion or misuse by checking early.
3. Extract and Parse Request Data
For a POST
request, we often expect JSON in the request body.
type LoginRequest struct {
Email string `json:"email"`
}
var input LoginRequest
err := json.NewDecoder(r.Body).Decode(&input)
if err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
What's happening:
-
json.NewDecoder(r.Body).Decode(...)
reads and parses the request body into a Go struct. - If the JSON is malformed, we return a
400 Bad Request
.
4. Validate Input
Even if JSON is valid, you still need to make sure required fields are present.
if input.Email == "" {
http.Error(w, "Email is required", http.StatusBadRequest)
return
}
Simple manual validation — you could use a validator package for more complex rules, but this works fine for basic checks.
5. Business Logic / Database Simulation
You’ll usually fetch or store something in a database, or call an API. We’ll simulate that:
userExists := input.Email == "test@example.com"
In real code, you'd query a DB. For now, we simulate that a user exists if the email matches.
6. Prepare & Send a JSON Response
response := map[string]interface{}{
"success": true,
"email": input.Email,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(response)
What this does:
- Sets the response
Content-Type
to JSON. - Encodes the response map as JSON.
- Writes a
200 OK
status code.
Complete Working Example
Here’s how you tie everything together in a simple Go web server.
package main
import (
"encoding/json"
"log"
"net/http"
)
type LoginRequest struct {
Email string `json:"email"`
}
func loginHandler(w http.ResponseWriter, r *http.Request) {
// Step 1: Method check
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
// Step 2: Parse JSON body
var input LoginRequest
err := json.NewDecoder(r.Body).Decode(&input)
if err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
// Step 3: Validate input
if input.Email == "" {
http.Error(w, "Email is required", http.StatusBadRequest)
return
}
// Step 4: Simulate DB check
userExists := input.Email == "test@example.com"
if !userExists {
http.Error(w, "User not found", http.StatusNotFound)
return
}
// Step 5: Respond with success
response := map[string]interface{}{
"success": true,
"email": input.Email,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(response)
}
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if token != "valid-token" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
func main() {
// Wrap the handler in the middleware
http.Handle("/login", authMiddleware(http.HandlerFunc(loginHandler)))
log.Println("Server running on http://localhost:8080")
err := http.ListenAndServe(":8080", nil)
if err != nil {
log.Fatalf("Server failed: %v", err)
}
}
Test with curl
curl -X POST http://localhost:8080/login \
-H "Authorization: valid-token" \
-H "Content-Type: application/json" \
-d '{"email": "test@example.com"}'
Expected output:
{
"success": true,
"email": "test@example.com"
}
Final Thoughts
Writing handlers without a framework in Go:
- Keeps things simple and predictable.
- Helps you understand the full request lifecycle.
- Gives you full control over validation, logic, and response formatting.
As your project grows, you might reach for a framework—but starting raw will give you strong fundamentals and confidence when things break.
Over to You
Have you written raw HTTP handlers in Go? What patterns do you use? Do you wrap your own helper functions, or prefer using frameworks?
Let me know in the comments!
Bonus
Writing a Raw HTTP Handler in Rust Using Only the Standard Library
Let's now rebuild the Rust version to match the Go example even more closely — using only the Rust standard library, with no external crates like tiny_http
, serde
, or serde_json
.
This will give us a completely raw, minimal HTTP server in Rust, using std::net::TcpListener
and handling manual HTTP parsing and JSON formatting.
In this, we’ll:
- Listen for TCP connections
- Parse incoming HTTP requests manually
- Manually extract headers and body
- Implement basic routing and validation
- Construct and return a JSON response (as a string)
This is the closest Rust equivalent to Go’s net/http
— but fully from scratch.
Rust Code (Standard Library Only)
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
// A very basic JSON serializer, hand-crafted for this exact response format
fn json_response(success: bool, email: &str) -> String {
format!(
"{{\"success\": {}, \"email\": \"{}\"}}",
success,
email
)
}
// Handles a single connection
fn handle_connection(mut stream: TcpStream) {
let mut buffer = [0; 1024];
let bytes_read = stream.read(&mut buffer).unwrap();
let request = String::from_utf8_lossy(&buffer[..bytes_read]);
// Print the raw HTTP request for debugging
println!("--- Request ---\n{}\n--------------", request);
// Step 1: Simple path check
if !request.starts_with("POST /login") {
let response = "HTTP/1.1 404 NOT FOUND\r\n\r\nNot Found";
stream.write_all(response.as_bytes()).unwrap();
return;
}
// Step 2: Authorization header check
if !request.contains("Authorization: valid-token") {
let response = "HTTP/1.1 401 UNAUTHORIZED\r\n\r\nUnauthorized";
stream.write_all(response.as_bytes()).unwrap();
return;
}
// Step 3: Extract JSON body manually
let parts: Vec<&str> = request.split("\r\n\r\n").collect();
if parts.len() < 2 {
let response = "HTTP/1.1 400 BAD REQUEST\r\n\r\nMissing Body";
stream.write_all(response.as_bytes()).unwrap();
return;
}
let body = parts[1].trim();
if !body.starts_with("{") || !body.contains("email") {
let response = "HTTP/1.1 400 BAD REQUEST\r\n\r\nInvalid JSON";
stream.write_all(response.as_bytes()).unwrap();
return;
}
// Step 4: Very naive JSON parsing (no external crates)
// This assumes a body like: {"email":"test@example.com"}
let email = body
.split("\"email\"")
.nth(1)
.and_then(|s| s.split('"').nth(2))
.unwrap_or("")
.trim_matches([':', '"', ' '].as_ref());
if email.is_empty() {
let response = "HTTP/1.1 400 BAD REQUEST\r\n\r\nEmail is required";
stream.write_all(response.as_bytes()).unwrap();
return;
}
// Step 5: "Database" check
if email != "test@example.com" {
let response = "HTTP/1.1 404 NOT FOUND\r\n\r\nUser not found";
stream.write_all(response.as_bytes()).unwrap();
return;
}
// Step 6: Return JSON response
let body = json_response(true, email);
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
);
stream.write_all(response.as_bytes()).unwrap();
}
fn main() {
let listener = TcpListener::bind("127.0.0.1:8080").unwrap();
println!("Bare-metal Rust server running at http://localhost:8080");
for stream in listener.incoming() {
match stream {
Ok(stream) => handle_connection(stream),
Err(e) => eprintln!("Connection failed: {}", e),
}
}
}
Test It
curl -X POST http://localhost:8080/login \
-H "Authorization: valid-token" \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com"}'
Output:
{"success": true, "email": "test@example.com"}
How It Compares
Feature | Go | Rust (std only) |
---|---|---|
HTTP Parsing |
net/http does it all |
Manual from TcpStream
|
Routing | Built-in via ServeMux
|
Manually parsed |
JSON Parsing | encoding/json |
Manual string slicing |
Auth Middleware | Function wrapper | Header substring check |
Output JSON | Structured with encoder | Manual string building |
Takeaway
Rewriting this handler without any frameworks or libraries gives you a deep appreciation for everything they usually abstract away. You get:
- Full control over parsing and routing
- A solid grasp of HTTP protocol internals
- A crash course in string and byte manipulation
Once you're confident with this, stepping into hyper
, warp
, or actix-web
will feel like a luxury spa.
Top comments (2)
Separation of concerns remains a key principle in development. However, I find using a switch statement to handle HTTP methods within a single handler quite effective, especially in server-side rendered applications e.g handling GET to serve the HTML form and POST to process form submissions and apply business logic. This keeps related functionality together while still maintaining readability as well as adaptability.
Totally agree with you — grouping related GET and POST logic in one handler (like for a login form) can be super clean and effective, especially in server-rendered apps. That pattern keeps things tidy and readable when you're dealing with tightly coupled request types.
This post was more of a low-level deep dive — kind of a “look under the hood” at how much you have to do manually when you're not using any frameworks. So instead of using a switch, I tried to walk through each step of the request/response lifecycle just to show how things like
middleware
,JSON parsing
, andvalidation
work at the bare-metal level.But I really appreciate you pointing that out — great reminder that practical structure and separation can look different depending on context and goals.