DEV Community

Cover image for What a Go HTTP Handler Does (Without Frameworks)
Nichotieno
Nichotieno

Posted on

What a Go HTTP Handler Does (Without Frameworks)

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)
    })
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

Test with curl

curl -X POST http://localhost:8080/login \
  -H "Authorization: valid-token" \
  -H "Content-Type: application/json" \
  -d '{"email": "test@example.com"}'
Enter fullscreen mode Exit fullscreen mode

Expected output:

{
  "success": true,
  "email": "test@example.com"
}
Enter fullscreen mode Exit fullscreen mode

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),
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Test It

curl -X POST http://localhost:8080/login \
  -H "Authorization: valid-token" \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com"}'
Enter fullscreen mode Exit fullscreen mode

Output:

{"success": true, "email": "test@example.com"}
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
johneliud profile image
John Eliud Odhiambo

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.

Collapse
 
nichotieno profile image
Nichotieno

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, and validation 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.