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());
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' }),
});
The server needs to:
- Read the request body (the JSON string)
- Parse the JSON into Go structs
- Process the data (validate, save, etc.)
- 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))
}
Let's test our echo handler:
go run .
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!'
What's happening here?
-
io.ReadAll(r.Body)
: Reads the entire request body into memory -
json.Unmarshal(body, &payload)
: Parses JSON string into our Go struct -
defer r.Body.Close()
: Ensures the request body is properly closed -
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
}
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)))
}
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!"))
}
Also, don't forget to import the required strconv
for the existing getUsers
function:
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strconv" // Add this
)
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!
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"}}
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)
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
}
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)
}
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"}
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
}
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
}
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)