DEV Community

Boluwatife Adewusi
Boluwatife Adewusi

Posted on

Building Web Servers from First Principles (Part 6)

In Chapter 5, we learned to handle query parameters for GET requests. But real APIs need to handle POST requests with JSON data, create new resources, and provide proper JSON responses.

Today, we'll build a proper JSON API that can read request bodies, create new data, and respond with structured JSON - just like the APIs your frontend applications expect.

What We've Built So Far

From previous chapters:

  • ✅ Dynamic routing with URL parameters
  • ✅ Query parameter handling for GET requests
  • ✅ Basic JSON responses for data retrieval
  • ❌ POST request body handling
  • ❌ Creating new resources (CRUD operations)
  • ❌ Proper JSON API structure

The Problem: Frontend Apps Need Full CRUD

When building real applications, your frontend needs to:

// GET - Read data (we have this)
const user = await fetch('/users/123').then((r) => r.json());

// POST - Create data (we need this)
const newUser = await fetch('/users', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'John', email: 'john@example.com' }),
}).then((r) => r.json());
Enter fullscreen mode Exit fullscreen mode

Our current server can only handle the first case. Let's fix that.

Understanding Request Bodies

When your frontend sends a POST request with JSON data:

fetch('/users', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'Alice', email: 'alice@example.com' }),
});
Enter fullscreen mode Exit fullscreen mode

The server needs to:

  1. Read the request body (the JSON string)
  2. Parse the JSON into Go structs
  3. Process the data (validate, save, etc.)
  4. Return a proper response

Starting with a Simple Echo Handler

Let's begin by creating a handler that reads JSON from the request body and echoes it back. This will teach us the fundamentals of request body handling.

First, let's set up our data structure. We already have our data.go file with users, so let's start with a simple echo endpoint.

Update main.go:

package main

import (
    "encoding/json"
    "fmt"
    "io"
    "net/http"
)

func main() {
    server := NewServer(":3000")
    setupRoutes(server)

    server.Start()
}

func setupRoutes(s *Server) {
    s.Router.POST("/echo", echo)
}

type EchoPayload struct {
    Message string `json:"message"`
}

func echo(w http.ResponseWriter, r *http.Request) {
    // Read the request body
    body, err := io.ReadAll(r.Body)
    if err != nil {
        w.WriteHeader(http.StatusInternalServerError)
        w.Write([]byte("Error reading request body"))
        return
    }
    defer r.Body.Close()

    // Parse JSON into our struct
    var payload EchoPayload
    err = json.Unmarshal(body, &payload)
    if err != nil {
        w.WriteHeader(http.StatusBadRequest)
        w.Write([]byte("Invalid JSON"))
        return
    }

    // Echo back the message
    response := fmt.Sprintf("You said '%s'", payload.Message)
    w.Write([]byte(response))
}
Enter fullscreen mode Exit fullscreen mode

Let's test our echo handler:

go run .
Enter fullscreen mode Exit fullscreen mode

In another terminal, test with curl:

curl -X POST http://localhost:3000/echo \
  -H "Content-Type: application/json" \
  -d '{"message": "Hello from frontend!"}'

# Expected: You said 'Hello from frontend!'
Enter fullscreen mode Exit fullscreen mode

What's happening here?

  1. io.ReadAll(r.Body): Reads the entire request body into memory
  2. json.Unmarshal(body, &payload): Parses JSON string into our Go struct
  3. defer r.Body.Close(): Ensures the request body is properly closed
  4. Struct tags: json:"message" tells Go how to map JSON fields to struct fields

Creating a Reusable Body Reading Function

Reading request bodies will be common, so let's extract this into a reusable function:

func readBody(r *http.Request, payload any) error {
    body, err := io.ReadAll(r.Body)
    if err != nil {
        return err
    }
    defer r.Body.Close()

    err = json.Unmarshal(body, payload)
    if err != nil {
        return err
    }

    return nil
}
Enter fullscreen mode Exit fullscreen mode

Now let's simplify our echo handler:

func echo(w http.ResponseWriter, r *http.Request) {
    var payload EchoPayload

    err := readBody(r, &payload)
    if err != nil {
        w.WriteHeader(http.StatusInternalServerError)
        w.Write([]byte(err.Error()))
        return
    }

    w.Write([]byte(fmt.Sprintf("You said '%s'", payload.Message)))
}
Enter fullscreen mode Exit fullscreen mode

Much cleaner! The readBody function handles all the complexity of reading and parsing JSON.

Building a Real CRUD Endpoint: Creating Users

Now let's build something more realistic - a user creation endpoint. Add this to your routes:

func setupRoutes(s *Server) {
    s.Router.POST("/echo", echo)
    s.Router.GET("/users/:id", getUsers)    // We already have this from Chapter 4
    s.Router.POST("/users", createUser)     // NEW: Create users
}

type UserPayload struct {
    Name  string `json:"name"`
    Email string `json:"email"`
}

func createUser(w http.ResponseWriter, r *http.Request) {
    var payload UserPayload

    err := readBody(r, &payload)
    if err != nil {
        w.WriteHeader(http.StatusBadRequest)
        w.Write([]byte("Invalid JSON"))
        return
    }

    // Basic validation
    if payload.Name == "" || payload.Email == "" {
        w.WriteHeader(http.StatusBadRequest)
        w.Write([]byte("Name and email are required"))
        return
    }

    // Create new user (in our in-memory "database")
    newUser := User{
        Id:    len(users) + 1,  // Simple ID generation
        Name:  payload.Name,
        Email: payload.Email,
    }

    // Add to our users slice
    users = append(users, newUser)

    // Return success response
    w.WriteHeader(http.StatusCreated)
    w.Write([]byte("User created successfully!"))
}
Enter fullscreen mode Exit fullscreen mode

Also, don't forget to import the required strconv for the existing getUsers function:

import (
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "strconv"  // Add this
)
Enter fullscreen mode Exit fullscreen mode

Testing Our User Creation API

Let's test creating a new user:

# Create a new user
curl -X POST http://localhost:3000/users \
  -H "Content-Type: application/json" \
  -d '{"name": "Alice Smith", "email": "alice@example.com"}'

# Expected: User created successfully!
Enter fullscreen mode Exit fullscreen mode

Now let's verify the user was created by fetching it:

# Get the newly created user (should be ID 7, since we have 6 existing users)
curl http://localhost:3000/users/7

# Expected: {"data":{"id":7,"name":"Alice Smith","email":"alice@example.com"}}
Enter fullscreen mode Exit fullscreen mode

Let's test validation:

# Try creating user without required fields
curl -X POST http://localhost:3000/users \
  -H "Content-Type: application/json" \
  -d '{"name": ""}'

# Expected: Name and email are required (400 status)

# Try with invalid JSON
curl -X POST http://localhost:3000/users \
  -H "Content-Type: application/json" \
  -d 'invalid json'

# Expected: Invalid JSON (400 status)
Enter fullscreen mode Exit fullscreen mode

Understanding JSON API Best Practices

Let's improve our API to follow JSON API conventions:

1. Proper Content-Type Headers

func createUser(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json; charset=utf-8")
    // ... rest of function
}
Enter fullscreen mode Exit fullscreen mode

2. Structured JSON Responses

Instead of plain text responses, let's return JSON:

func createUser(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json; charset=utf-8")

    var payload UserPayload
    err := readBody(r, &payload)
    if err != nil {
        w.WriteHeader(http.StatusBadRequest)
        errorResponse := map[string]any{
            "error": "Invalid JSON",
            "message": err.Error(),
        }
        jsonBody, _ := json.Marshal(errorResponse)
        w.Write(jsonBody)
        return
    }

    // Validation
    if payload.Name == "" || payload.Email == "" {
        w.WriteHeader(http.StatusBadRequest)
        errorResponse := map[string]any{
            "error": "Validation failed",
            "message": "Name and email are required",
        }
        jsonBody, _ := json.Marshal(errorResponse)
        w.Write(jsonBody)
        return
    }

    // Create new user
    newUser := User{
        Id:    len(users) + 1,
        Name:  payload.Name,
        Email: payload.Email,
    }
    users = append(users, newUser)

    // Return structured success response
    w.WriteHeader(http.StatusCreated)
    successResponse := map[string]any{
        "message": "User created successfully",
        "data": newUser,
    }
    jsonBody, _ := json.Marshal(successResponse)
    w.Write(jsonBody)
}
Enter fullscreen mode Exit fullscreen mode

Testing Our Improved API

Let's test the improved version:

# Create a user (success case)
curl -X POST http://localhost:3000/users \
  -H "Content-Type: application/json" \
  -d '{"name": "Bob Johnson", "email": "bob@example.com"}'

# Expected: {"data":{"id":8,"name":"Bob Johnson","email":"bob@example.com"},"message":"User created successfully"}

# Test validation (error case)
curl -X POST http://localhost:3000/users \
  -H "Content-Type: application/json" \
  -d '{"name": ""}'

# Expected: {"error":"Validation failed","message":"Name and email are required"}
Enter fullscreen mode Exit fullscreen mode

Understanding HTTP Status Codes

Our API now uses proper HTTP status codes:

  • 200 OK: Successful GET requests
  • 201 Created: Successful POST requests that create resources
  • 400 Bad Request: Client error (invalid JSON, validation failures)
  • 404 Not Found: Resource doesn't exist
  • 500 Internal Server Error: Server error

This is what REST APIs should do - the status code tells the client what happened before they even look at the response body.

What We've Accomplished

We now have:

  • Request body reading (io.ReadAll, json.Unmarshal)
  • JSON parsing (into Go structs with tags)
  • Resource creation (POST endpoints that modify data)
  • Proper HTTP status codes (200, 201, 400, 404, 500)
  • Structured JSON responses (consistent error/success format)
  • Basic validation (required fields, data types)
  • In-memory persistence (data survives between requests)

Comparing to Real Frameworks

Our Approach:

func createUser(w http.ResponseWriter, r *http.Request) {
    var payload UserPayload
    err := readBody(r, &payload)
    // ... validation and creation
}
Enter fullscreen mode Exit fullscreen mode

Gin Framework:

func createUser(c *gin.Context) {
    var payload UserPayload
    if err := c.ShouldBindJSON(&payload); err != nil {
        c.JSON(400, gin.H{"error": err.Error()})
        return
    }
    // ... validation and creation
}
Enter fullscreen mode Exit fullscreen mode

The concepts are identical - frameworks just provide more convenience methods and features!

What's Next?

In Chapter 7, we'll add middleware to our server - the cross-cutting concerns that real applications need:

  • Request logging: See what requests are coming in
  • Response logging: Monitor what data is being sent back
  • Timing middleware: Track how long requests take
  • Panic recovery: Gracefully handle server errors

Middleware will make our server more production-ready and easier to debug.


Challenge: Try adding a /users GET endpoint (without the :id parameter) that returns all users. Then create a few users via POST and verify they appear in the list.

Bonus: Add a PUT /users/:id endpoint that can update an existing user's name and email.

Top comments (0)