DeepThought.sh
Programming

Ultimate Guide to RESTful API Development in Go Part 1: Building an HTTP Server and Defining Your API

Master RESTful API development in Go by building a production-ready To-Do API from scratch using Gin framework, proper routing, and CRUD operations.

Aaron Mathis
22 min read
Ultimate Guide to RESTful API Development in Go Part 1: Building an HTTP Server and Defining Your API

Welcome to the first part of our series on building RESTful APIs in Go. By the end of this tutorial, you’ll have a working HTTP server that handles CRUD operations for a To-Do list application using Go’s built-in HTTP capabilities and the popular Gin framework.

Go’s standard library provides excellent HTTP server functionality, but frameworks like Gin make our lives easier by handling common patterns and providing useful middleware. We’ll start with the basics and gradually build up to a fully functional API that follows REST principles and production best practices.


What We’ll Build

Our To-Do API will support the following operations:

  • GET /todos - Retrieve all todos
  • GET /todos/:id - Retrieve a specific todo
  • POST /todos - Create a new todo
  • PUT /todos/:id - Update an existing todo
  • DELETE /todos/:id - Delete a todo

Each todo will have an ID, title, description, completion status, and timestamp fields. This gives us a realistic data model to work with while keeping the focus on API design patterns.


Prerequisites

Before we begin, ensure you have the following installed:

  • Go 1.21 or later: Download from golang.org
  • Git: For version control and dependency management
  • curl or Postman: For testing API endpoints
  • A text editor or IDE: VS Code with the Go extension is recommended

Verify your Go installation:

go version

Setting Up Your Project

Let’s start by creating a new Go module and organizing our project structure. This foundation will serve us well as the API grows in complexity throughout the series.

mkdir todo-api
cd todo-api
go mod init todo-api

Create the following directory structure to keep our code organized:

mkdir -p cmd/server internal/handlers internal/models internal/utils
touch cmd/server/main.go internal/handlers/todos.go \ 
    internal/models/todo.go internal/handlers/todos_test.go \
    internal/utils/response.go README.md .gitignore

This structure follows Go conventions and best practices:

  • cmd/: Contains application entry points and main packages
  • internal/: Holds packages that shouldn’t be imported by external projects
  • internal/handlers/: HTTP request handlers that contain our API logic
  • internal/models/: Data structures and business entities
  • internal/utils/: Shared utility functions and helpers
  • README.md: Project documentation
  • .gitignore: Git ignore file for Go projects

Let’s create a proper .gitignore file for our Go project:

cat > .gitignore << EOF
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib

# Test binary, built with \`go test -c\`
*.test

# Output of the go coverage tool
*.out

# Dependency directories (vendor/, if using go mod vendor)
vendor/

# Go workspace file
go.work

# IDE files
.vscode/
.idea/
*.swp
*.swo
*~

# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db

# Environment files
.env
.env.local
.env.*.local

# Log files
*.log
EOF

Installing Dependencies

For this tutorial, we’ll use the Gin framework, which provides excellent performance and a clean API for building HTTP services. Gin is widely adopted in production environments and offers features like middleware support, JSON binding, and efficient routing.

go get github.com/gin-gonic/gin

Gin automatically handles common tasks like parsing JSON requests, setting response headers, and routing HTTP methods to specific handlers. This lets us focus on business logic rather than boilerplate HTTP code.


Creating Your First HTTP Server

Let’s start with a basic HTTP server to ensure everything is working correctly. Open cmd/server/main.go and add the following code:

package main

import (
    "log"
    "net/http"

    "github.com/gin-gonic/gin"
)

func main() {
    // Create a Gin router with default middleware (logger and recovery)
    router := gin.Default()

    // Add a health check endpoint
    router.GET("/health", func(c *gin.Context) {
        c.JSON(http.StatusOK, gin.H{
            "status": "healthy",
            "message": "Todo API is running",
        })
    })

    // Start the server on port 8080
    log.Println("Starting server on :8080")
    if err := router.Run(":8080"); err != nil {
        log.Fatal("Failed to start server:", err)
    }
}

The gin.Default() function creates a router with built-in logging and panic recovery middleware. This is perfect for development and provides good defaults for production use. The gin.H type is a convenient shortcut for map[string]interface{}, making JSON responses more readable.

Test your server by running:

go run cmd/server/main.go

Visit http://localhost:8080/health in your browser or use curl to verify the server is working:

curl http://localhost:8080/health

You should see a JSON response confirming the server is healthy. This health check endpoint is a common pattern in production APIs for monitoring and load balancer configuration.

{"message":"Todo API is running","status":"healthy"}

Defining the Todo Model

Before implementing CRUD operations, we need to define our data structure. Open internal/models/todo.go and create the Todo model:

package models

import "time"

// Todo represents a single todo item
type Todo struct {
    ID          int       `json:"id"`
    Title       string    `json:"title" binding:"required"`
    Description string    `json:"description"`
    Completed   bool      `json:"completed"`
    CreatedAt   time.Time `json:"created_at"`
    UpdatedAt   time.Time `json:"updated_at"`
}

// TodoRequest represents the expected JSON structure for creating/updating todos
type TodoRequest struct {
    Title       string `json:"title" binding:"required"`
    Description string `json:"description"`
    Completed   bool   `json:"completed"`
}

The struct tags serve multiple purposes here. The json tags control how fields are serialized to JSON, while binding:"required" tells Gin to validate that required fields are present in incoming requests. This separation between Todo and TodoRequest allows us to control which fields clients can modify and ensures system-generated fields like timestamps aren’t overwritten.

Notice how we’re using proper Go naming conventions with exported fields (capitalized) since they need to be accessible to the JSON marshaler. The timestamp fields will help us track when todos are created and modified, which is valuable for debugging and auditing in production systems.


Creating Standardized Response Utilities

Before implementing our handlers, let’s create utility functions for consistent API responses. This establishes good patterns early and makes our API more professional. Create internal/utils/response.go:

package utils

import (
    "net/http"
    "github.com/gin-gonic/gin"
)

// APIResponse represents a standard API response structure
type APIResponse struct {
    Success bool        `json:"success"`
    Message string      `json:"message,omitempty"`
    Data    interface{} `json:"data,omitempty"`
    Error   string      `json:"error,omitempty"`
}

// SuccessResponse sends a successful response with data
func SuccessResponse(c *gin.Context, statusCode int, message string, data interface{}) {
    c.JSON(statusCode, APIResponse{
        Success: true,
        Message: message,
        Data:    data,
    })
}

// ErrorResponse sends an error response
func ErrorResponse(c *gin.Context, statusCode int, message string) {
    c.JSON(statusCode, APIResponse{
        Success: false,
        Error:   message,
    })
}

// ValidationErrorResponse sends a validation error response
func ValidationErrorResponse(c *gin.Context, err error) {
    c.JSON(http.StatusBadRequest, APIResponse{
        Success: false,
        Error:   "Validation failed: " + err.Error(),
    })
}

These utility functions ensure consistent response formats across our API, making it easier for clients to handle responses programmatically. The standardized structure includes success flags, messages, data payloads, and error information.

context.JSON in the Gin package is a method used to send a JSON response to the client. It sets the appropriate Content-Type header and serializes the provided Go data structure into JSON format, making it easy to return structured API responses with custom status codes and payloads.


Creating the Todo Handlers

Now let’s implement our CRUD operations. CRUD stands for Create, Read, Update, and Delete—the four fundamental operations for managing data in most applications. In the context of RESTful APIs, these operations map directly to HTTP methods:

  • Create: Adds new data (typically via POST)
  • Read: Retrieves existing data (GET)
  • Update: Modifies existing data (PUT or PATCH)
  • Delete: Removes data (DELETE)

Implementing CRUD ensures your API can fully manage its resources, providing a predictable and standardized interface for clients.

Create the handlers in internal/handlers/todos.go:

package handlers

import (
    "net/http"
    "strconv"
    "sync"
    "time"

    "todo-api/internal/models"
    "todo-api/internal/utils"

    "github.com/gin-gonic/gin"
)

// TodoHandler manages todo-related HTTP requests
type TodoHandler struct {
    todos  map[int]*models.Todo
    nextID int
    mutex  sync.RWMutex
}

// NewTodoHandler creates a new todo handler with sample data
func NewTodoHandler() *TodoHandler {
    handler := &TodoHandler{
        todos:  make(map[int]*models.Todo),
        nextID: 1,
    }
    
    // Add some sample todos for testing
    handler.addSampleData()
    return handler
}

func (h *TodoHandler) addSampleData() {
    sampleTodos := []*models.Todo{
        {
            ID:          1,
            Title:       "Learn Go",
            Description: "Study Go programming language fundamentals",
            Completed:   false,
            CreatedAt:   time.Now().Add(-24 * time.Hour),
            UpdatedAt:   time.Now().Add(-24 * time.Hour),
        },
        {
            ID:          2,
            Title:       "Build REST API",
            Description: "Create a RESTful API using Gin framework",
            Completed:   true,
            CreatedAt:   time.Now().Add(-12 * time.Hour),
            UpdatedAt:   time.Now().Add(-6 * time.Hour),
        },
    }

    for _, todo := range sampleTodos {
        h.todos[todo.ID] = todo
    }
    h.nextID = 3
}

This handler uses an in-memory map to store todos temporarily. The sync.RWMutex ensures thread safety when multiple goroutines access the map concurrently, which is essential since HTTP servers handle requests concurrently by default. In Part 2, we’ll replace this with proper database persistence.

The sample data helps us test the API immediately without manually creating todos first. In production, you’d typically have database seeds or migration scripts to populate initial data.


Implementing GET Operations

Let’s implement the read operations first, as they’re simpler and help us verify our data structure is working correctly add these functions in internal/handlers/todos.go:

// GetTodos returns all todos
func (h *TodoHandler) GetTodos(c *gin.Context) {
    h.mutex.RLock()
    defer h.mutex.RUnlock()

    todos := make([]*models.Todo, 0, len(h.todos))
    for _, todo := range h.todos {
        todos = append(todos, todo)
    }

    utils.SuccessResponse(c, http.StatusOK, "Todos retrieved successfully", gin.H{
        "todos": todos,
        "count": len(todos),
    })
}

// GetTodo returns a specific todo by ID
func (h *TodoHandler) GetTodo(c *gin.Context) {
    idParam := c.Param("id")
    id, err := strconv.Atoi(idParam)
    if err != nil {
        utils.ErrorResponse(c, http.StatusBadRequest, "Invalid todo ID format")
        return
    }

    h.mutex.RLock()
    todo, exists := h.todos[id]
    h.mutex.RUnlock()

    if !exists {
        utils.ErrorResponse(c, http.StatusNotFound, "Todo not found")
        return
    }

    utils.SuccessResponse(c, http.StatusOK, "Todo retrieved successfully", gin.H{
        "todo": todo,
    })
}

The GetTodos handler uses a read lock (RLock) to allow concurrent read access while preventing writes. Converting the map to a slice ensures consistent JSON output, as map iteration order is not guaranteed in Go.

Using defer with mutex.RLock() ensures that the read lock is always released, even if the function returns early due to an error or panic. This pattern helps prevent deadlocks and makes your code safer and easier to maintain, especially in concurrent environments.

For GetTodo, we extract the ID from the URL path using c.Param("id") and convert it to an integer. Proper error handling is crucial here - we return appropriate HTTP status codes (400 for bad requests, 404 for not found) along with descriptive error messages.


Implementing POST Operation

The POST operation creates new todos. This handler demonstrates JSON binding and validation:

// CreateTodo creates a new todo
func (h *TodoHandler) CreateTodo(c *gin.Context) {
    var req models.TodoRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{
            "error": "Invalid request format",
            "details": err.Error(),
        })
        return
    }

    h.mutex.Lock()
    defer h.mutex.Unlock()

    now := time.Now()
    todo := &models.Todo{
        ID:          h.nextID,
        Title:       req.Title,
        Description: req.Description,
        Completed:   req.Completed,
        CreatedAt:   now,
        UpdatedAt:   now,
    }

    h.todos[h.nextID] = todo
    h.nextID++

    c.JSON(http.StatusCreated, gin.H{
        "todo": todo,
        "message": "Todo created successfully",
    })
}

The ShouldBindJSON method automatically parses the request body and validates required fields based on our struct tags. This eliminates boilerplate validation code and provides consistent error messages. We use a full lock here since we’re modifying the map.

Notice how we set both CreatedAt and UpdatedAt to the current time for new todos. This pattern helps track data lifecycle in production systems where knowing when records were created and last modified is important for debugging and analytics.


Implementing PUT Operation

The PUT operation updates existing todos. RESTful conventions suggest PUT should be idempotent, meaning multiple identical requests should have the same effect.

Continue adding to internal/handlers/todos.go:

// UpdateTodo updates an existing todo
func (h *TodoHandler) UpdateTodo(c *gin.Context) {
    idParam := c.Param("id")
    id, err := strconv.Atoi(idParam)
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{
            "error": "Invalid todo ID",
        })
        return
    }

    var req models.TodoRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{
            "error": "Invalid request format",
            "details": err.Error(),
        })
        return
    }

    h.mutex.Lock()
    defer h.mutex.Unlock()

    todo, exists := h.todos[id]
    if !exists {
        c.JSON(http.StatusNotFound, gin.H{
            "error": "Todo not found",
        })
        return
    }

    // Update fields
    todo.Title = req.Title
    todo.Description = req.Description
    todo.Completed = req.Completed
    todo.UpdatedAt = time.Now()

    c.JSON(http.StatusOK, gin.H{
        "todo": todo,
        "message": "Todo updated successfully",
    })
}

This implementation updates all provided fields and refreshes the UpdatedAt timestamp. The handler first verifies the todo exists before attempting to update it, following the principle of failing fast with clear error messages.

In production APIs, you might want to implement PATCH for partial updates and PUT for complete resource replacement. This distinction becomes more important with complex data models where clients might only want to update specific fields.


Implementing DELETE Operation

The DELETE operation removes todos from our collection. Continue adding to internal/handlers/todos.go:

// DeleteTodo removes a todo
func (h *TodoHandler) DeleteTodo(c *gin.Context) {
    idParam := c.Param("id")
    id, err := strconv.Atoi(idParam)
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{
            "error": "Invalid todo ID",
        })
        return
    }

    h.mutex.Lock()
    defer h.mutex.Unlock()

    _, exists := h.todos[id]
    if !exists {
        c.JSON(http.StatusNotFound, gin.H{
            "error": "Todo not found",
        })
        return
    }

    delete(h.todos, id)

    c.JSON(http.StatusOK, gin.H{
        "message": "Todo deleted successfully",
    })
}

The DELETE operation is straightforward but includes the same careful error handling as our other endpoints. Some APIs return 204 No Content for successful deletions, but returning a confirmation message with 200 OK can be more helpful for API consumers during development and debugging.


Setting Up Routes

When setting up routes in Go, especially with frameworks like Gin, it’s best practice to organize endpoints using route groups and versioning (e.g., /api/v1). This approach keeps your API maintainable and scalable as it grows.

Group related routes together, apply middleware at the group level when needed, and use clear, resource-based paths that follow REST conventions.

Consistent naming and logical structure make your API easier to understand, test, and extend in the future.

Let’s wire up all our handlers by updating cmd/server/main.go:

package main

import (
    "log"
    "net/http"

    "todo-api/internal/handlers"

    "github.com/gin-gonic/gin"
)

func main() {
    // Create router and todo handler
    router := gin.Default()
    todoHandler := handlers.NewTodoHandler()

    // Health check endpoint
    router.GET("/health", func(c *gin.Context) {
        c.JSON(http.StatusOK, gin.H{
            "status": "healthy",
            "message": "Todo API is running",
        })
    })

    // Todo routes
    v1 := router.Group("/api/v1")
    {
        todos := v1.Group("/todos")
        {
            todos.GET("", todoHandler.GetTodos)
            todos.POST("", todoHandler.CreateTodo)
            todos.GET("/:id", todoHandler.GetTodo)
            todos.PUT("/:id", todoHandler.UpdateTodo)
            todos.DELETE("/:id", todoHandler.DeleteTodo)
        }
    }

    // Start server
    log.Println("Starting Todo API server on :8080")
    log.Println("API endpoints available at http://localhost:8080/api/v1/todos")
    if err := router.Run(":8080"); err != nil {
        log.Fatal("Failed to start server:", err)
    }
}

The route grouping with /api/v1 is a production best practice that allows for API versioning. When you need to make breaking changes, you can introduce /api/v2 while maintaining backward compatibility for existing clients.

Gin’s route groups also make it easy to add middleware to specific sets of routes. For example, you might add authentication middleware to all /api/v1 routes while keeping the health check endpoint public.

Testing Your API

With everything in place, let’s test our complete CRUD API. Start the server:

go run cmd/server/main.go

Test each endpoint using curl commands:

Get all todos:

curl http://localhost:8080/api/v1/todos

Get a specific todo:

curl http://localhost:8080/api/v1/todos/1

Create a new todo:

curl -X POST http://localhost:8080/api/v1/todos \
  -H "Content-Type: application/json" \
  -d '{"title":"Test API","description":"Testing our new API endpoint","completed":false}'

Update a todo:

curl -X PUT http://localhost:8080/api/v1/todos/1 \
  -H "Content-Type: application/json" \
  -d '{"title":"Learn Go Advanced","description":"Deep dive into Go concurrency","completed":true}'

Delete a todo:

curl -X DELETE http://localhost:8080/api/v1/todos/1

Each request should return appropriate JSON responses with status codes. If you encounter errors, check that the Content-Type header is set correctly for POST and PUT requests, as Gin expects proper JSON formatting.


Writing Unit Tests for Your API

Professional APIs require comprehensive testing. Let’s create unit tests for our handlers to ensure they work correctly and remain stable as we make changes. Create a test file internal/handlers/todos_test.go:

package handlers

import (
	"bytes"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"testing"

	"todo-api/internal/models"

	"github.com/gin-gonic/gin"
	"github.com/stretchr/testify/assert"
)

func TestMain(m *testing.M) {
	// Set Gin to test mode
	gin.SetMode(gin.TestMode)
	m.Run()
}

func setupTestRouter() (*gin.Engine, *TodoHandler) {
	router := gin.New()
	handler := NewTodoHandler()

	v1 := router.Group("/api/v1")
	todos := v1.Group("/todos")
	{
		todos.GET("", handler.GetTodos)
		todos.POST("", handler.CreateTodo)
		todos.GET("/:id", handler.GetTodo)
		todos.PUT("/:id", handler.UpdateTodo)
		todos.DELETE("/:id", handler.DeleteTodo)
	}

	return router, handler
}

func TestGetTodos(t *testing.T) {
	router, _ := setupTestRouter()

	req, _ := http.NewRequest("GET", "/api/v1/todos", nil)
	resp := httptest.NewRecorder()
	router.ServeHTTP(resp, req)

	assert.Equal(t, http.StatusOK, resp.Code)

	var response map[string]interface{}
	err := json.Unmarshal(resp.Body.Bytes(), &response)
	assert.NoError(t, err)
	assert.True(t, response["success"].(bool))
	assert.Contains(t, response, "data")
}

func TestCreateTodo(t *testing.T) {
	router, _ := setupTestRouter()

	todo := models.TodoRequest{
		Title:       "Test Todo",
		Description: "Test Description",
		Completed:   false,
	}

	jsonData, _ := json.Marshal(todo)
	req, _ := http.NewRequest("POST", "/api/v1/todos", bytes.NewBuffer(jsonData))
	req.Header.Set("Content-Type", "application/json")

	resp := httptest.NewRecorder()
	router.ServeHTTP(resp, req)

	assert.Equal(t, http.StatusCreated, resp.Code)

	var response map[string]interface{}
	err := json.Unmarshal(resp.Body.Bytes(), &response)
	assert.NoError(t, err)

	assert.Contains(t, response, "message")
	assert.Equal(t, "Todo created successfully", response["message"])
	assert.Contains(t, response, "todo")
}

func TestGetTodoByID(t *testing.T) {
	router, _ := setupTestRouter()

	// Test existing todo
	req, _ := http.NewRequest("GET", "/api/v1/todos/1", nil)
	resp := httptest.NewRecorder()
	router.ServeHTTP(resp, req)

	assert.Equal(t, http.StatusOK, resp.Code)

	// Test non-existing todo
	req, _ = http.NewRequest("GET", "/api/v1/todos/999", nil)
	resp = httptest.NewRecorder()
	router.ServeHTTP(resp, req)

	assert.Equal(t, http.StatusNotFound, resp.Code)
}

func TestUpdateTodo(t *testing.T) {
	router, _ := setupTestRouter()

	updateData := models.TodoRequest{
		Title:       "Updated Todo",
		Description: "Updated Description",
		Completed:   true,
	}

	jsonData, _ := json.Marshal(updateData)
	req, _ := http.NewRequest("PUT", "/api/v1/todos/1", bytes.NewBuffer(jsonData))
	req.Header.Set("Content-Type", "application/json")

	resp := httptest.NewRecorder()
	router.ServeHTTP(resp, req)

	assert.Equal(t, http.StatusOK, resp.Code)
}

func TestDeleteTodo(t *testing.T) {
	router, _ := setupTestRouter()

	req, _ := http.NewRequest("DELETE", "/api/v1/todos/1", nil)
	resp := httptest.NewRecorder()
	router.ServeHTTP(resp, req)

	assert.Equal(t, http.StatusOK, resp.Code)

	// Verify todo is deleted
	req, _ = http.NewRequest("GET", "/api/v1/todos/1", nil)
	resp = httptest.NewRecorder()
	router.ServeHTTP(resp, req)

	assert.Equal(t, http.StatusNotFound, resp.Code)
}

Install the testing dependency:

go get github.com/stretchr/testify/assert

Run your tests:

go test ./internal/handlers -v

These tests cover all CRUD operations and verify both success and error scenarios. They use httptest to simulate HTTP requests without starting an actual server, making tests fast and reliable.

You should get a response similiar to:

amathis@DeepThought:~/workspace/deepthought-code-examples/todo-api$ go test ./internal/handlers -v
=== RUN   TestGetTodos
--- PASS: TestGetTodos (0.00s)
=== RUN   TestCreateTodo
--- PASS: TestCreateTodo (0.00s)
=== RUN   TestGetTodoByID
--- PASS: TestGetTodoByID (0.00s)
=== RUN   TestUpdateTodo
--- PASS: TestUpdateTodo (0.00s)
=== RUN   TestDeleteTodo
--- PASS: TestDeleteTodo (0.00s)
PASS
ok  	todo-api/internal/handlers	0.007s

Input Validation and Error Handling

Let’s enhance our Todo model with better validation using struct tags and the validator package. The github.com/go-playground/validator/v10 package is the industry standard for validation in Go and is used by major companies like Uber, Netflix, and countless production APIs.

go get github.com/go-playground/validator/v10

Update your TodoRequest model in internal/models/todo.go to include both necessary imports and validation:

package models

import (
    "errors"
    "strings"
    "time"
    
    "github.com/go-playground/validator/v10"
)

// Use a single validator instance (recommended for performance)
var validate *validator.Validate

func init() {
    validate = validator.New()
    
    // Register custom validation functions
    validate.RegisterValidation("notblank", validateNotBlank)
}

// Todo represents a single todo item
type Todo struct {
    ID          int       `json:"id"`
    Title       string    `json:"title"`
    Description string    `json:"description"`
    Completed   bool      `json:"completed"`
    CreatedAt   time.Time `json:"created_at"`
    UpdatedAt   time.Time `json:"updated_at"`
}

// TodoRequest represents the expected JSON structure for creating/updating todos
type TodoRequest struct {
    Title       string `json:"title" binding:"required" validate:"required,notblank,min=1,max=255"`
    Description string `json:"description" binding:"omitempty" validate:"omitempty,max=1000"`
    Completed   bool   `json:"completed"`
}

// Validate performs validation on TodoRequest
func (tr *TodoRequest) Validate() error {
    // Use the validator package for struct validation
    if err := validate.Struct(tr); err != nil {
        // Convert validator errors to user-friendly messages
        return formatValidationError(err)
    }
    
    // Additional custom validation
    if len(strings.TrimSpace(tr.Title)) == 0 {
        return errors.New("title cannot be empty or only whitespace")
    }
    
    return nil
}

// Custom validation function for non-blank strings
func validateNotBlank(fl validator.FieldLevel) bool {
    return len(strings.TrimSpace(fl.Field().String())) > 0
}

// formatValidationError converts validator errors to user-friendly messages
func formatValidationError(err error) error {
    var validationErrors []string
    
    for _, err := range err.(validator.ValidationErrors) {
        switch err.Tag() {
        case "required":
            validationErrors = append(validationErrors, err.Field()+" is required")
        case "min":
            validationErrors = append(validationErrors, err.Field()+" must be at least "+err.Param()+" characters")
        case "max":
            validationErrors = append(validationErrors, err.Field()+" must be at most "+err.Param()+" characters")
        case "notblank":
            validationErrors = append(validationErrors, err.Field()+" cannot be blank")
        default:
            validationErrors = append(validationErrors, err.Field()+" is invalid")
        }
    }
    
    return errors.New(strings.Join(validationErrors, ", "))
}

// Sanitize cleans up the todo request data
func (tr *TodoRequest) Sanitize() {
    tr.Title = strings.TrimSpace(tr.Title)
    tr.Description = strings.TrimSpace(tr.Description)
}

Extend Validation to Handler

Update your CreateTodo handler in internal/handlers/todos.go to use enhanced validation:

// CreateTodo creates a new todo with comprehensive validation
func (h *TodoHandler) CreateTodo(c *gin.Context) {
    var req models.TodoRequest
    
    // Gin's ShouldBindJSON handles basic JSON parsing and binding validation
    if err := c.ShouldBindJSON(&req); err != nil {
        utils.ValidationErrorResponse(c, err)
        return
    }

    // Sanitize input data
    req.Sanitize()

    // Validation using validator/v10
    if err := req.Validate(); err != nil {
        utils.ErrorResponse(c, http.StatusBadRequest, err.Error())
        return
    }

    h.mutex.Lock()
    defer h.mutex.Unlock()

    now := time.Now()
    todo := &models.Todo{
        ID:          h.nextID,
        Title:       req.Title,      // Already sanitized
        Description: req.Description, // Already sanitized
        Completed:   req.Completed,
        CreatedAt:   now,
        UpdatedAt:   now,
    }

    h.todos[h.nextID] = todo
    h.nextID++

    utils.SuccessResponse(c, http.StatusCreated, "Todo created successfully", gin.H{
        "todo": todo,
    })
}

Also, update your unit test now that the response structure for CreateTodo nests the todo object inside a data field:

Change:

assert.Contains(t, response, "todo")

To:

assert.Contains(t, response["data"], "todo")

Production Validation Strategies

In production environments, validation typically happens at multiple layers:

1. API Gateway Level (Optional)

# Example Kong/AWS API Gateway validation
request_validation:
  max_body_size: 1MB
  required_headers: ["Content-Type", "Authorization"]

2. HTTP Framework Level (Gin binding)

// Gin handles basic type validation and required fields
type Request struct {
    Email string `binding:"required,email"`
}

3. Application Level (validator/v10)

// Business logic validation with custom rules
type UserProfile struct {
    Username string `validate:"required,min=3,max=20,alphanum"`
    Bio      string `validate:"max=500"`
    Age      int    `validate:"min=13,max=120"`
}

4. Database Level (Constraints)

-- Database constraints as final validation layer
CREATE TABLE users (
    email VARCHAR(255) NOT NULL UNIQUE CHECK (email ~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'),
    age INTEGER CHECK (age >= 13 AND age <= 120)
);

Advanced Production Validation Examples

Here are some more examples of how validation is structured:

// Example from a financial API
type TransactionRequest struct {
    Amount      decimal.Decimal `validate:"required,gt=0,lte=1000000"`
    FromAccount string         `validate:"required,len=10,numeric"`
    ToAccount   string         `validate:"required,len=10,numeric,nefield=FromAccount"`
    Reference   string         `validate:"required,min=1,max=100"`
    Currency    string         `validate:"required,len=3,oneof=USD EUR GBP JPY"`
}

// Example from a social media API
type PostRequest struct {
    Content     string   `validate:"required,min=1,max=280"`
    Tags        []string `validate:"max=10,dive,min=1,max=50,alphanum"`
    Visibility  string   `validate:"required,oneof=public private friends"`
    ScheduledAt *time.Time `validate:"omitempty,gtfield=CreatedAt"`
}

// Example from an e-commerce API
type ProductRequest struct {
    Name        string  `validate:"required,min=3,max=200"`
    Price       float64 `validate:"required,gt=0,lte=999999.99"`
    SKU         string  `validate:"required,min=3,max=50,alphanum"`
    Categories  []int   `validate:"required,min=1,max=5,dive,gt=0"`
    Weight      float64 `validate:"omitempty,gt=0,lte=1000"`
    Dimensions  *Dimensions `validate:"omitempty"`
}

type Dimensions struct {
    Length float64 `validate:"required,gt=0,lte=1000"`
    Width  float64 `validate:"required,gt=0,lte=1000"`
    Height float64 `validate:"required,gt=0,lte=1000"`
}

Understanding REST Principles

Our API follows several important REST principles that make it predictable and easy to use:

  • Resource-based URLs: We use /todos and /todos/:id to represent our todo resources. URLs represent “what” (the resource) rather than “how” (the action).
  • HTTP methods convey intent: GET for reading, POST for creating, PUT for updating, and DELETE for removing. This standard mapping means developers already know how to interact with your API.
  • Stateless requests: Each request contains all information needed to process it. We don’t rely on server-side sessions or stored state between requests.
  • JSON for data exchange: We consistently use JSON for both request and response bodies, making the API language-agnostic and easy to consume from any platform.
  • Meaningful HTTP status codes: We return appropriate status codes (200, 201, 400, 404) that clients can programmatically handle without parsing response bodies.

Production Considerations

While our current implementation works well for learning and development, production APIs require additional considerations:

  • Validation: Our basic validation catches required fields, but production APIs need more sophisticated validation for things like email formats, string lengths, and business rules.
  • Error handling: Consistent error response formats help API consumers handle errors gracefully. Consider standardizing on a format like JSON:API or Problem Details for HTTP APIs.
  • Logging: Production APIs need structured logging to track requests, errors, and performance metrics. Gin provides built-in logging middleware that can be customized for your needs.
  • Security: Authentication, authorization, and input sanitization are essential for production APIs. We’ll cover these topics in detail in Part 3 of this series.
  • Rate limiting: Protect your API from abuse by implementing rate limiting per client or API key.
  • Documentation: Auto-generated documentation from code annotations helps API consumers understand how to use your endpoints effectively.

What’s Next

In Part 1, we’ve built a solid foundation with a working RESTful API that handles CRUD operations for a todo list. Our API follows REST conventions, includes proper error handling, standardized responses, comprehensive testing, and production-ready patterns.

What you’ve accomplished:

  • Created a well-structured Go project following best practices
  • Built a complete HTTP server with Gin framework
  • Implemented full CRUD operations with proper REST endpoints
  • Added input validation and error handling
  • Created standardized API responses
  • Written comprehensive unit tests
  • Established proper project documentation

In the upcoming parts of this series, we’ll enhance our API with:

  • Part 2: Database persistence using PostgreSQL with Docker, connection pooling, and transaction management
  • Part 3: Authentication and authorization with JWT tokens, plus essential middleware for security, logging, rate limiting, and CORS
  • Part 4: Advanced error handling, input validation, and OpenAPI documentation generation
  • Part 5: Pagination, filtering, and comprehensive error handling patterns
  • Part 6: Production deployment strategies, containerization, monitoring, and CI/CD pipelines

Each part builds upon the foundation we’ve established here, maintaining the same quality standards and production-ready approach.

Further Learning

To deepen your understanding of Go and REST API development, explore these resources:

The foundation you’ve built in this tutorial prepares you well for exploring these advanced topics. Each concept builds upon the patterns we’ve established, making your journey into production-ready API development more straightforward and rewarding.

Aaron Mathis

Aaron Mathis

Systems administrator and software engineer specializing in cloud development, AI/ML, and modern web technologies. Passionate about building scalable solutions and sharing knowledge with the developer community.

Related Articles

Discover more insights on similar topics