Building REST APIs in Go

Building REST APIs in Go

REST (Representational State Transfer) APIs are the backbone of modern web services. Go makes it easy to build fast, scalable REST APIs using the standard library or frameworks like Gin.

Basic REST API Structure

A typical REST API follows these conventions:

  • Uses HTTP methods: GET, POST, PUT, DELETE
  • Returns JSON responses
  • Uses meaningful URLs
  • Returns appropriate HTTP status codes

Simple REST API with net/http

Let’s build a basic API for managing users:

package main

import (
    "encoding/json"
    "net/http"
    "strconv"
    "sync"
)

type User struct {
    ID   int    `json:"id"`
    Name string `json:"name"`
    Age  int    `json:"age"`
}

var (
    users  = make(map[int]User)
    nextID = 1
    mu     sync.RWMutex
)

func getUsers(w http.ResponseWriter, r *http.Request) {
    mu.RLock()
    defer mu.RUnlock()
    
    var userList []User
    for _, user := range users {
        userList = append(userList, user)
    }
    
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(userList)
}

func getUser(w http.ResponseWriter, r *http.Request) {
    idStr := r.URL.Path[len("/users/"):]
    id, err := strconv.Atoi(idStr)
    if err != nil {
        http.Error(w, "Invalid user ID", http.StatusBadRequest)
        return
    }
    
    mu.RLock()
    user, exists := users[id]
    mu.RUnlock()
    
    if !exists {
        http.Error(w, "User not found", http.StatusNotFound)
        return
    }
    
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(user)
}

func createUser(w http.ResponseWriter, r *http.Request) {
    var user User
    if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
        http.Error(w, "Invalid JSON", http.StatusBadRequest)
        return
    }
    
    mu.Lock()
    user.ID = nextID
    users[nextID] = user
    nextID++
    mu.Unlock()
    
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(user)
}

func updateUser(w http.ResponseWriter, r *http.Request) {
    idStr := r.URL.Path[len("/users/"):]
    id, err := strconv.Atoi(idStr)
    if err != nil {
        http.Error(w, "Invalid user ID", http.StatusBadRequest)
        return
    }
    
    var updatedUser User
    if err := json.NewDecoder(r.Body).Decode(&updatedUser); err != nil {
        http.Error(w, "Invalid JSON", http.StatusBadRequest)
        return
    }
    
    mu.Lock()
    if _, exists := users[id]; !exists {
        mu.Unlock()
        http.Error(w, "User not found", http.StatusNotFound)
        return
    }
    
    updatedUser.ID = id
    users[id] = updatedUser
    mu.Unlock()
    
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(updatedUser)
}

func deleteUser(w http.ResponseWriter, r *http.Request) {
    idStr := r.URL.Path[len("/users/"):]
    id, err := strconv.Atoi(idStr)
    if err != nil {
        http.Error(w, "Invalid user ID", http.StatusBadRequest)
        return
    }
    
    mu.Lock()
    if _, exists := users[id]; !exists {
        mu.Unlock()
        http.Error(w, "User not found", http.StatusNotFound)
        return
    }
    
    delete(users, id)
    mu.Unlock()
    
    w.WriteHeader(http.StatusNoContent)
}

func main() {
    // Routes
    http.HandleFunc("/users", func(w http.ResponseWriter, r *http.Request) {
        switch r.Method {
        case "GET":
            getUsers(w, r)
        case "POST":
            createUser(w, r)
        default:
            http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        }
    })
    
    http.HandleFunc("/users/", func(w http.ResponseWriter, r *http.Request) {
        switch r.Method {
        case "GET":
            getUser(w, r)
        case "PUT":
            updateUser(w, r)
        case "DELETE":
            deleteUser(w, r)
        default:
            http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        }
    })
    
    http.ListenAndServe(":8080", nil)
}

Using a Router

For better organization, use a router like gorilla/mux:

package main

import (
    "encoding/json"
    "net/http"
    
    "github.com/gorilla/mux"
)

func main() {
    r := mux.NewRouter()
    
    // Routes
    r.HandleFunc("/users", getUsers).Methods("GET")
    r.HandleFunc("/users", createUser).Methods("POST")
    r.HandleFunc("/users/{id}", getUser).Methods("GET")
    r.HandleFunc("/users/{id}", updateUser).Methods("PUT")
    r.HandleFunc("/users/{id}", deleteUser).Methods("DELETE")
    
    http.ListenAndServe(":8080", r)
}

// Handler functions (getUsers, createUser, etc.) remain the same
// but use mux.Vars(r)["id"] to get path parameters

Middleware

Add middleware for logging, CORS, authentication:

package main

import (
    "log"
    "net/http"
    "time"
)

// Logging middleware
func loggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        
        // Create a response writer wrapper to capture status code
        rw := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
        
        next.ServeHTTP(rw, r)
        
        log.Printf("%s %s %d %v", r.Method, r.URL.Path, rw.statusCode, time.Since(start))
    })
}

type responseWriter struct {
    http.ResponseWriter
    statusCode int
}

func (rw *responseWriter) WriteHeader(code int) {
    rw.statusCode = code
    rw.ResponseWriter.WriteHeader(code)
}

// CORS middleware
func corsMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Access-Control-Allow-Origin", "*")
        w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
        w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
        
        if r.Method == "OPTIONS" {
            w.WriteHeader(http.StatusOK)
            return
        }
        
        next.ServeHTTP(w, r)
    })
}

func main() {
    r := mux.NewRouter()
    
    // API routes
    api := r.PathPrefix("/api").Subrouter()
    api.Use(loggingMiddleware)
    api.Use(corsMiddleware)
    
    api.HandleFunc("/users", getUsers).Methods("GET")
    api.HandleFunc("/users", createUser).Methods("POST")
    
    http.ListenAndServe(":8080", r)
}

Input Validation

Validate incoming data:

package main

import (
    "errors"
    "strings"
)

type User struct {
    ID   int    `json:"id"`
    Name string `json:"name" validate:"required,min=2,max=50"`
    Age  int    `json:"age" validate:"required,min=0,max=150"`
}

func validateUser(user *User) error {
    if strings.TrimSpace(user.Name) == "" {
        return errors.New("name is required")
    }
    if len(user.Name) < 2 || len(user.Name) > 50 {
        return errors.New("name must be between 2 and 50 characters")
    }
    if user.Age < 0 || user.Age > 150 {
        return errors.New("age must be between 0 and 150")
    }
    return nil
}

func createUser(w http.ResponseWriter, r *http.Request) {
    var user User
    if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
        http.Error(w, "Invalid JSON", http.StatusBadRequest)
        return
    }
    
    if err := validateUser(&user); err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    
    // Create user...
}

Error Handling

Standardize error responses:

package main

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

type ErrorResponse struct {
    Error   string `json:"error"`
    Code    int    `json:"code,omitempty"`
    Message string `json:"message,omitempty"`
}

func sendError(w http.ResponseWriter, message string, statusCode int) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(statusCode)
    
    errorResp := ErrorResponse{
        Error: message,
        Code:  statusCode,
    }
    
    json.NewEncoder(w).Encode(errorResp)
}

func createUser(w http.ResponseWriter, r *http.Request) {
    var user User
    if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
        sendError(w, "Invalid JSON format", http.StatusBadRequest)
        return
    }
    
    if err := validateUser(&user); err != nil {
        sendError(w, err.Error(), http.StatusBadRequest)
        return
    }
    
    // Create user logic...
    
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(user)
}

Pagination

Implement pagination for list endpoints:

package main

type PaginatedResponse struct {
    Data       []User `json:"data"`
    Page       int    `json:"page"`
    PerPage    int    `json:"per_page"`
    Total      int    `json:"total"`
    TotalPages int    `json:"total_pages"`
}

func getUsers(w http.ResponseWriter, r *http.Request) {
    // Parse query parameters
    page := 1
    perPage := 10
    
    if p := r.URL.Query().Get("page"); p != "" {
        if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
            page = parsed
        }
    }
    
    if pp := r.URL.Query().Get("per_page"); pp != "" {
        if parsed, err := strconv.Atoi(pp); err == nil && parsed > 0 && parsed <= 100 {
            perPage = parsed
        }
    }
    
    // Get users with pagination
    mu.RLock()
    allUsers := make([]User, 0, len(users))
    for _, user := range users {
        allUsers = append(allUsers, user)
    }
    mu.RUnlock()
    
    total := len(allUsers)
    totalPages := (total + perPage - 1) / perPage
    
    start := (page - 1) * perPage
    end := start + perPage
    if end > total {
        end = total
    }
    
    var pageUsers []User
    if start < total {
        pageUsers = allUsers[start:end]
    }
    
    response := PaginatedResponse{
        Data:       pageUsers,
        Page:       page,
        PerPage:    perPage,
        Total:      total,
        TotalPages: totalPages,
    }
    
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(response)
}

Testing REST APIs

Test your API endpoints:

package main

import (
    "bytes"
    "net/http"
    "net/http/httptest"
    "testing"
)

func TestCreateUser(t *testing.T) {
    // Reset global state for test
    mu.Lock()
    users = make(map[int]User)
    nextID = 1
    mu.Unlock()
    
    // Create test data
    userData := `{"name": "Test User", "age": 25}`
    
    // Create request
    req := httptest.NewRequest("POST", "/users", bytes.NewBufferString(userData))
    req.Header.Set("Content-Type", "application/json")
    
    // Create response recorder
    w := httptest.NewRecorder()
    
    // Call handler
    createUser(w, req)
    
    // Check response
    if w.Code != http.StatusCreated {
        t.Errorf("Expected status 201, got %d", w.Code)
    }
    
    // Check if user was created
    mu.RLock()
    if len(users) != 1 {
        t.Errorf("Expected 1 user, got %d", len(users))
    }
    mu.RUnlock()
}

API Documentation

Document your API using OpenAPI/Swagger or simple comments:

// GET /users
// Returns a list of all users
// Query parameters:
//   - page: Page number (default: 1)
//   - per_page: Items per page (default: 10, max: 100)
func getUsers(w http.ResponseWriter, r *http.Request) {
    // Implementation...
}

// POST /users
// Creates a new user
// Request body: {"name": "string", "age": "number"}
// Returns: Created user with ID
func createUser(w http.ResponseWriter, r *http.Request) {
    // Implementation...
}

Best Practices

  1. Use consistent HTTP status codes: 200 for success, 201 for created, 400 for bad request, 404 for not found, 500 for server errors
  2. Validate input data: Always validate and sanitize user input
  3. Use JSON for data exchange: Standard format for APIs
  4. Implement proper error handling: Return meaningful error messages
  5. Add logging and monitoring: Track API usage and errors
  6. Version your API: Use URL versioning like /api/v1/users
  7. Use middleware: For cross-cutting concerns like authentication, logging
  8. Document your API: Make it easy for others to use
  9. Test thoroughly: Unit tests, integration tests, and end-to-end tests
  10. Consider rate limiting: Protect against abuse

Building REST APIs in Go is straightforward with the standard library, and you can enhance functionality with routers and middleware. For more complex applications, consider using frameworks like Gin or Echo.

For more on web servers, see our building web servers tutorial. If you’re building APIs with Gin, check out the Gin framework tutorial.

Last updated on