Enhancing Your Go REST API: Adding Database, Auth, and Testing (Part 3)

Welcome to Part 3 of our “Mastering Go Programming” series! In this tutorial, we’ll transform our basic task manager API into a production-ready service by adding essential features like database integration, authentication, and comprehensive testing.

What We’ll Cover

In this comprehensive guide, you’ll learn how to:

Prerequisites

1. Setting Up PostgreSQL Integration

First, let’s add our database dependencies:

go get -u github.com/lib/pq
go get -u github.com/golang-migrate/migrate/v4

Creating Database Migrations

Create a migrations directory and add your first migration:

-- migrations/000001_create_tasks_table.up.sql
CREATE TABLE tasks (
    id UUID PRIMARY KEY,
    title VARCHAR(255) NOT NULL,
    description TEXT,
    status VARCHAR(50) NOT NULL,
    due_date TIMESTAMP WITH TIME ZONE,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    user_id UUID NOT NULL
);

CREATE INDEX idx_tasks_user_id ON tasks(user_id);

-- migrations/000001_create_tasks_table.down.sql
DROP TABLE IF EXISTS tasks;

Database Connection

Create internal/database/db.go:

package database

import (
    "database/sql"
    "fmt"
    _ "github.com/lib/pq"
)

type Config struct {
    Host     string
    Port     int
    User     string
    Password string
    DBName   string
    SSLMode  string
}

func NewConnection(config *Config) (*sql.DB, error) {
    connStr := fmt.Sprintf(
        "host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
        config.Host, config.Port, config.User, 
        config.Password, config.DBName, config.SSLMode,
    )
    
    db, err := sql.Open("postgres", connStr)
    if err != nil {
        return nil, err
    }

    err = db.Ping()
    if err != nil {
        return nil, err
    }

    return db, nil
}

Repository Pattern Implementation

Create internal/repository/task_repository.go:

package repository

import (
    "context"
    "database/sql"
    "time"
    "github.com/yourusername/task-manager/internal/models"
)

type TaskRepository struct {
    db *sql.DB
}

func NewTaskRepository(db *sql.DB) *TaskRepository {
    return &TaskRepository{db: db}
}

func (r *TaskRepository) CreateTask(ctx context.Context, task *models.Task) error {
    query := `
        INSERT INTO tasks (id, title, description, status, due_date, created_at, updated_at, user_id)
        VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
    `
    
    _, err := r.db.ExecContext(
        ctx,
        query,
        task.ID,
        task.Title,
        task.Description,
        task.Status,
        task.DueDate,
        task.CreatedAt,
        task.UpdatedAt,
        task.UserID,
    )
    
    return err
}

func (r *TaskRepository) GetTasksByUserID(ctx context.Context, userID string) ([]models.Task, error) {
    query := `
        SELECT id, title, description, status, due_date, created_at, updated_at, user_id
        FROM tasks
        WHERE user_id = $1
        ORDER BY created_at DESC
    `
    
    rows, err := r.db.QueryContext(ctx, query, userID)
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    var tasks []models.Task
    for rows.Next() {
        var task models.Task
        err := rows.Scan(
            &task.ID,
            &task.Title,
            &task.Description,
            &task.Status,
            &task.DueDate,
            &task.CreatedAt,
            &task.UpdatedAt,
            &task.UserID,
        )
        if err != nil {
            return nil, err
        }
        tasks = append(tasks, task)
    }
    
    return tasks, nil
}

2. Implementing JWT Authentication

Add the JWT dependency:

go get -u github.com/golang-jwt/jwt/v5

JWT Authentication Service

Create internal/auth/jwt.go:

package auth

import (
    "errors"
    "time"
    "github.com/golang-jwt/jwt/v5"
)

type JWTService struct {
    secretKey []byte
    expires   time.Duration
}

func NewJWTService(secretKey string, expires time.Duration) *JWTService {
    return &JWTService{
        secretKey: []byte(secretKey),
        expires:   expires,
    }
}

func (s *JWTService) GenerateToken(userID string) (string, error) {
    claims := jwt.MapClaims{
        "user_id": userID,
        "exp":     time.Now().Add(s.expires).Unix(),
        "iat":     time.Now().Unix(),
    }

    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    
    return token.SignedString(s.secretKey)
}

func (s *JWTService) ValidateToken(tokenString string) (string, error) {
    token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
        if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
            return nil, errors.New("unexpected signing method")
        }
        return s.secretKey, nil
    })

    if err != nil {
        return "", err
    }

    if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
        userID := claims["user_id"].(string)
        return userID, nil
    }

    return "", errors.New("invalid token")
}

Authentication Middleware

Create pkg/middleware/auth.go:

package middleware

import (
    "context"
    "net/http"
    "strings"
    "github.com/yourusername/task-manager/internal/auth"
)

func AuthMiddleware(jwtService *auth.JWTService) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            authHeader := r.Header.Get("Authorization")
            if authHeader == "" {
                http.Error(w, "Authorization header required", http.StatusUnauthorized)
                return
            }

            bearerToken := strings.Split(authHeader, " ")
            if len(bearerToken) != 2 || bearerToken[0] != "Bearer" {
                http.Error(w, "Invalid authorization header", http.StatusUnauthorized)
                return
            }

            userID, err := jwtService.ValidateToken(bearerToken[1])
            if err != nil {
                http.Error(w, "Invalid token", http.StatusUnauthorized)
                return
            }

            ctx := context.WithValue(r.Context(), "user_id", userID)
            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}

3. Adding Request Validation

Create internal/validator/task_validator.go:

package validator

import (
    "errors"
    "time"
    "github.com/yourusername/task-manager/internal/models"
)

type TaskValidator struct{}

func (v *TaskValidator) ValidateCreate(task *models.Task) error {
    if task.Title == "" {
        return errors.New("title is required")
    }

    if len(task.Title) < 3 {
        return errors.New("title must be at least 3 characters")
    }

    if task.DueDate.Before(time.Now()) {
        return errors.New("due date must be in the future")
    }

    return nil
}

4. Writing Tests

Unit Tests

Create internal/handlers/task_handlers_test.go:

package handlers

import (
    "bytes"
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"
    "github.com/yourusername/task-manager/internal/models"
    "github.com/stretchr/testify/assert"
)

func TestCreateTaskHandler(t *testing.T) {
    tests := []struct {
        name           string
        payload        map[string]interface{}
        expectedStatus int
    }{
        {
            name: "valid task",
            payload: map[string]interface{}{
                "title":       "Test Task",
                "description": "Test Description",
                "due_date":    time.Now().Add(24 * time.Hour),
            },
            expectedStatus: http.StatusCreated,
        },
        {
            name: "invalid task - missing title",
            payload: map[string]interface{}{
                "description": "Test Description",
            },
            expectedStatus: http.StatusBadRequest,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            payloadBytes, _ := json.Marshal(tt.payload)
            req := httptest.NewRequest(
                http.MethodPost,
                "/api/task",
                bytes.NewReader(payloadBytes),
            )
            
            rr := httptest.NewRecorder()
            handler := http.HandlerFunc(CreateTaskHandler)
            handler.ServeHTTP(rr, req)

            assert.Equal(t, tt.expectedStatus, rr.Code)
        })
    }
}

Integration Tests

Create tests/integration/api_test.go:

package integration

import (
    "context"
    "testing"
    "net/http"
    "github.com/stretchr/testify/suite"
    "github.com/yourusername/task-manager/internal/database"
)

type APITestSuite struct {
    suite.Suite
    db     *database.DB
    server *http.Server
    client *http.Client
}

func (s *APITestSuite) SetupSuite() {
    // Initialize test database
    config := &database.Config{
        Host:     "localhost",
        Port:     5432,
        User:     "test",
        Password: "test",
        DBName:   "taskmanager_test",
        SSLMode:  "disable",
    }
    
    db, err := database.NewConnection(config)
    s.Require().NoError(err)
    s.db = db

    // Start test server
    s.server = startTestServer()
    s.client = &http.Client{}
}

func (s *APITestSuite) TearDownSuite() {
    s.db.Close()
    s.server.Shutdown(context.Background())
}

func (s *APITestSuite) TestCreateAndGetTask() {
    // Create test JWT token
    token := createTestToken(s.T())

    // Test creating a task
    task := createTestTask(s.T(), s.client, token)
    s.Require().NotEmpty(task.ID)

    // Test getting the task
    tasks := getTestTasks(s.T(), s.client, token)
    s.Require().Len(tasks, 1)
    s.Equal(task.ID, tasks[0].ID)
}

func TestAPI(t *testing.T) {
    suite.Run(t, new(APITestSuite))
}

5. API Documentation with Swagger

Install swag:

go install github.com/swaggo/swag/cmd/swag@latest

Add Swagger annotations to your handlers:

// @title Task Manager API
// @version 1.0
// @description A task management REST API built with Go
// @host localhost:8080
// @BasePath /api

// CreateTaskHandler godoc
// @Summary Create a new task
// @Description Create a new task with the provided details
// @Tags tasks
// @Accept json
// @Produce json
// @Param task body models.Task true "Task details"
// @Success 201 {object} models.Task
// @Failure 400 {object} ErrorResponse
// @Failure 401 {object} ErrorResponse
// @Router /task [post]
func CreateTaskHandler(w http.ResponseWriter, r *http.Request) {
    // ... (existing implementation)
}

Generate Swagger documentation:

swag init -g cmd/api/main.go

6. Deployment Best Practices

Docker Configuration

Create Dockerfile:

FROM golang:1.21-alpine AS builder

WORKDIR /app
COPY . .
RUN go mod download
RUN CGO_ENABLED=0 GOOS=linux go build -o api ./cmd/api

FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/api .
COPY --from=builder /app/migrations ./migrations

EXPOSE 8080
CMD ["./api"]

Create docker-compose.yml:

version: '3.8'

services:
  api:
    build: .
    ports:
      - "8080:8080"
    environment:
      - DB_HOST=postgres
      - DB_PORT=5432
      - DB_USER=taskmanager
      - DB_PASSWORD=secret
      - DB_NAME=taskmanager
      - JWT_SECRET=your-secret-key
    depends_on:
      - postgres

  postgres:
    image: postgres:14-alpine
    environment:
      - POSTGRES_USER=taskmanager
      - POSTGRES_PASSWORD=secret
      - POSTGRES_DB=taskmanager
    volumes:
      - postgres_data:/var/lib/postgresql/data

volumes:
  postgres_data:

Next Steps

Try these challenges to further enhance your API:

  1. Add user management features:

    • User registration and login
    • Password reset functionality
    • Role-based access control
  2. Implement advanced task features:

    • Task assignments to multiple users
    • Task comments and attachments
    • Task prioritization
    • Recurring tasks
  3. Add monitoring and observability:

    • Prometheus metrics
    • Logging with ELK stack
    • Distributed tracing

Share Your Progress!

Have you implemented any of these features? Share your experience and code in the comments below. Don’t forget to star our GitHub repository for more Go programming tutorials!

Additional Resources

Last updated: January 11, 2025

Advertisement