Account

How to build APIs in Go: create a statistical dashboard with Gin-Gonic

How to build APIs in Go: create a statistical dashboard with Gin-Gonic


Table of Contents

  1. Introduction
  2. Why Gin for Building APIs?
  3. Project Setup
  4. Building Our First Endpoint
  5. Understanding Routing and HTTP Methods
  6. Request Handling and Validation

Introduction

In this tutorial, we'll build a Statistical Reporting API using Gin, one of the fastest and most popular web frameworks for Go. Our application will serve various statistical reports with mock data, demonstrating real-world API development patterns.

What We'll Build:
- A RESTful API for statistical reports
- Endpoints for user statistics, sales data, and performance metrics
- Proper error handling and validation


Why Gin for Building APIs?

Gin-Gonic is a high-performance HTTP web framework written in Go. Here's why it's excellent for API development:

Performance

  • 40x faster than other Go frameworks in many benchmarks
  • Uses httprouter under the hood for efficient routing
  • Minimal memory allocation and overhead

Developer Experience

  • Clean, intuitive API
  • Excellent middleware support
  • Built-in validation and rendering
  • Great documentation and community

Production-Ready Features

  • Crash-free operation with panic recovery
  • JSON validation
  • Route grouping
  • Error management

Project Setup

Step 1: Initialize Your Go Module

First, create a new directory:

mkdir api

Note: Usually when we create a new Go project we need to run go mod init <name> to initialize the new module, but if we are executing the code in this lesson in the Bytestark Golang sandbox we don't need to do that as the main module is already pre-initialized. All you need to do is to add your project files and leverage existing mod file.

Step 3: Create Project Structure

mkdir -p api/handlers
mkdir -p api/models

Why this structure?
- main.go: Contains the main application entry point
- api/handlers: HTTP request handlers (controllers)
- api/models: Data structures and business logic


Building Our First Endpoint

Let's start with the simplest possible API - a health check endpoint.

Create main.go

The default working directory when using a Bytestark sandbox is /home/coder/learning.
If you are running this code in Go sandbox on Bytestark make sure your current directory is /home/coder/learning.
To validate this you can open a terminal and execute the command:

pwd

If the result is

`/home/coder/learning`

then we are good to proceed to the next step.

Create main.go:

package main

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

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

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

    // Start the server on port 8080
    router.Run(":8080")
}


Code Explanation

Let's break down each part:

1. router := gin.Default()

What it does:
- Creates a new Gin engine instance
- Includes default middleware: Logger and Recovery

Why we need it:
- Logger middleware logs all HTTP requests
- Recovery middleware catches panics and returns 500 errors instead of crashing

Alternative:

router := gin.New() // Creates router without any middleware


2. router.GET("/health", func(c *gin.Context) {...})

What it does:
- Registers a route that responds to GET requests at /health
- The anonymous function is the handler - it processes the request

Parameters:
- /health: The URL path
- func(c *gin.Context): Handler function receiving Gin's context

3. c *gin.Context

What it is:
- The most important type in Gin
- Contains everything about the current HTTP request and response
- Provides methods to read request data and write responses

Key methods:
- c.JSON(): Send JSON response
- c.Param(): Get URL parameters
- c.Query(): Get query string parameters
- c.Bind(): Parse request body

4. c.JSON(http.StatusOK, gin.H{...})

What it does:
- Sets response status code to 200 (OK)
- Serializes the data to JSON
- Sets Content-Type: application/json header
- Writes the response

gin.H:
- A shortcut for map[string]interface{}
- Makes it easy to create JSON responses

5. router.Run(":8080")

What it does:
- Starts the HTTP server
- Listens on port 8080
- Blocks until the server is stopped

Running the API

In your terminal run:

go run main.go

You should see in your terminal the following output:

[GIN-debug] GET    /health     --> main.main.func1 (3 handlers)
[GIN-debug] Listening and serving HTTP on :8080

What this means:
- Gin is in debug mode (shows routing information)
- One route registered: GET /health
- Server is listening on localhost:8080

Testing the Endpoint

When your development server starts it will start listening on the port 8080 and VSCode will display a message and a button, offering you a convenient way to open a new browser window with the URL where you can access your application.
Alternatively, you can open a new browser tab yourself and access the URL of your sandbox at /proxy/8080/health

You should see the following response

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

which indicates your API has started and is working correctly.

What Happens Behind the Scenes

  1. Request arrives at localhost:8080/health
  2. Gin's router matches the request to the /health route
  3. Logger middleware logs the request
  4. Handler function executes
  5. JSON response is serialized and sent
  6. Logger middleware logs the response status and time

Understanding Routing and HTTP Methods

Now let's expand our API with proper routing patterns.

HTTP Methods in REST

REST APIs use HTTP methods to indicate operations:
- GET: Retrieve data (read-only)
- POST: Create new resources
- PUT: Update entire resources
- PATCH: Partially update resources
- DELETE: Remove resources

Create models/stats.go

First, let's define our data structures in api/models/stats.go:

package models

import "time"

// UserStats represents user activity statistics
type UserStats struct {
    TotalUsers    int       `json:"total_users"`
    ActiveUsers   int       `json:"active_users"`
    NewUsers      int       `json:"new_users"`
    Timestamp     time.Time `json:"timestamp"`
}

// SalesReport represents sales data for a period
type SalesReport struct {
    Period        string  `json:"period"`
    TotalSales    float64 `json:"total_sales"`
    TotalOrders   int     `json:"total_orders"`
    AverageOrder  float64 `json:"average_order"`
    TopProduct    string  `json:"top_product"`
}

// PerformanceMetrics represents system performance data
type PerformanceMetrics struct {
    CPUUsage      float64 `json:"cpu_usage"`
    MemoryUsage   float64 `json:"memory_usage"`
    RequestsPerSec int    `json:"requests_per_sec"`
    AvgResponseTime float64 `json:"avg_response_time_ms"`
}


Code Explanation: Models

Struct Tags json:"field_name"

What it does:
- Controls how struct fields are serialized to JSON
- The field name in JSON will be "total_users", not "TotalUsers"

Why lowercase?
- JavaScript convention uses camelCase/snake_case
- Makes API responses consistent with frontend expectations

Example:

// Without tags:
{"TotalUsers": 100}

// With tags:
{"total_users": 100}


Create handlers/handlers.go

Now create api/handlers/handlers.go:

package handlers

import (
    "math/rand"
    "net/http"
    "learning/api/models"
    "time"

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

// StatsHandler holds dependencies for stats-related handlers
type StatsHandler struct {
    // In a real app, this would include database connections, etc.
}

// NewStatsHandler creates a new stats handler instance
func NewStatsHandler() *StatsHandler {
    return &StatsHandler{}
}

// GetUserStats returns current user statistics
func (h *StatsHandler) GetUserStats(c *gin.Context) {
    // Generate mock data
    stats := models.UserStats{
        TotalUsers:  rand.Intn(10000) + 5000,
        ActiveUsers: rand.Intn(3000) + 1000,
        NewUsers:    rand.Intn(500) + 100,
        Timestamp:   time.Now(),
    }

    c.JSON(http.StatusOK, gin.H{
        "success": true,
        "data":    stats,
    })
}


Code Explanation: Handlers

1. Handler Struct Pattern
type StatsHandler struct {}

Why use a struct?
- Allows dependency injection
- Easy to add database connections, services, etc.
- Makes testing easier (can mock dependencies)

Example with dependencies:

type StatsHandler struct {
    db     *sql.DB
    cache  *redis.Client
    logger *log.Logger
}


2. Constructor Function
func NewStatsHandler() *StatsHandler {
    return &StatsHandler{}
}


What it does:
- Creates and returns a new handler instance
- Common Go pattern for initialization

Why return a pointer?
- Efficient (doesn't copy the struct)
- Allows methods to modify handler state

3. Method Receiver
func (h *StatsHandler) GetUserStats(c *gin.Context) {...}


What (h *StatsHandler) means:
- This function is a method on StatsHandler
- h is the receiver (like this or self in other languages)
- Can access handler's fields via h.db, etc.

4. Response Wrapper Pattern

c.JSON(http.StatusOK, gin.H{
    "success": true,
    "data":    stats,
})


Why wrap responses?
- Consistent API response format
- Easy to add metadata (pagination, errors, etc.)
- Frontend knows what to expect

Response structure:

{
  "success": true,
  "data": {
    "total_users": 7543,
    "active_users": 2341,
    "new_users": 287,
    "timestamp": "2025-11-10T10:30:00Z"
  }
}


Update main.go with Routing

Update main.go:

package main

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

func main() {
    router := gin.Default()

    // Initialize handlers
    statsHandler := handlers.NewStatsHandler()

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

    // API v1 routes group
    v1 := router.Group("/api/v1")
    {
        // Stats endpoints
        stats := v1.Group("/stats")
        {
            stats.GET("/users", statsHandler.GetUserStats)
        }
    }

    router.Run(":8080")
}


Code Explanation: Route Groups

1. router.Group("/api/v1")

What it does:
- Creates a route group with the prefix /api/v1
- All routes added to this group will start with /api/v1

Why use groups?
- API versioning (/api/v1, /api/v2)
- Organize related endpoints
- Apply middleware to specific routes

2. Nested Groups
v1 := router.Group("/api/v1")
{
    stats := v1.Group("/stats")
    {
        stats.GET("/users", handler)
    }
}

Final URL: /api/v1/stats/users

Benefits:
- Clear hierarchical structure
- Easy to reorganize
- Self-documenting code

3. The Curly Braces {}

What they do:
- Create a code block (scope)
- Visual organization only
- Not required but improves readability

Running and Testing

Start the server: Note: If your server is currently running you need to stop it with Ctrl+C, rebuild the API and restart it using:

go run main.go

Test the new endpoint by accessing the new URL in your browser: /api/v1/stats/users

Expected response:

{
  "success": true,
  "data": {
    "total_users": 7543,
    "active_users": 2341,
    "new_users": 287,
    "timestamp": "2025-11-10T10:30:00.000Z"
  }
}


Request Handling and Validation

Now let's add endpoints that accept parameters and request bodies.

URL Parameters vs Query Parameters

URL Parameters (Path Parameters):
- Part of the URL path: /user/:id
- Example: /user/123
- Use for identifying specific resources

Query Parameters:
- After the ? in URL: /search?q=golang
- Example: /reports?period=monthly&year=2025
- Use for filtering, sorting, pagination

Add this code to handlers/handlers.go

// GetSalesReport returns sales data for a specific period
func (h *StatsHandler) GetSalesReport(c *gin.Context) {
    // Get query parameter
    period := c.DefaultQuery("period", "monthly")

    // Validate period
    validPeriods := map[string]bool{
        "daily":   true,
        "weekly":  true,
        "monthly": true,
        "yearly":  true,
    }

    if !validPeriods[period] {
        c.JSON(http.StatusBadRequest, gin.H{
            "success": false,
            "error":   "Invalid period. Use: daily, weekly, monthly, or yearly",
        })
        return
    }

    // Generate mock data based on period
    totalSales := rand.Float64() * 100000
    totalOrders := rand.Intn(1000) + 100

    report := models.SalesReport{
        Period:       period,
        TotalSales:   totalSales,
        TotalOrders:  totalOrders,
        AverageOrder: totalSales / float64(totalOrders),
        TopProduct:   "Product-" + string(rune(rand.Intn(26)+65)),
    }

    c.JSON(http.StatusOK, gin.H{
        "success": true,
        "data":    report,
    })
}

// GetPerformanceMetrics returns system performance data
func (h *StatsHandler) GetPerformanceMetrics(c *gin.Context) {
    metrics := models.PerformanceMetrics{
        CPUUsage:        rand.Float64() * 100,
        MemoryUsage:     rand.Float64() * 100,
        RequestsPerSec:  rand.Intn(1000) + 100,
        AvgResponseTime: rand.Float64() * 100,
    }

    c.JSON(http.StatusOK, gin.H{
        "success": true,
        "data":    metrics,
    })
}


Code Explanation: Query Parameters

1. c.DefaultQuery("period", "monthly")

What it does:
- Retrieves the "period" query parameter
- Returns "monthly" if parameter is missing

Examples:

/sales?period=weekly  → period = "weekly"
/sales                → period = "monthly" (default)

Alternative:

period := c.Query("period") // Returns empty string if missing


2. Validation Pattern
validPeriods := map[string]bool{
    "daily": true,
    "weekly": true,
}

if !validPeriods[period] {
    c.JSON(http.StatusBadRequest, ...)
    return
}


Why this pattern?
- Fast lookup using map
- Clear list of valid values
- Easy to maintain

Important: Always return after sending an error response!

3. Error Response Structure
c.JSON(http.StatusBadRequest, gin.H{
    "success": false,
    "error":   "Invalid period...",
})


HTTP Status Code Examples:
- 200 OK: Success
- 400 Bad Request: Client error (invalid input)
- 404 Not Found: Resource doesn't exist
- 500 Internal Server Error: Server error

Add URL Parameter Handling

Add to handlers/handlers.go:

// GetUserStatsByID returns stats for a specific user
func (h *StatsHandler) GetUserStatsByID(c *gin.Context) {
    // Get URL parameter
    userID := c.Param("id")

    // In a real app, you'd query the database
    // For now, we'll validate and return mock data
    if userID == "" {
        c.JSON(http.StatusBadRequest, gin.H{
            "success": false,
            "error":   "User ID is required",
        })
        return
    }

    // Mock user stats
    stats := gin.H{
        "user_id":        userID,
        "total_orders":   rand.Intn(100),
        "total_spent":    rand.Float64() * 1000,
        "account_age_days": rand.Intn(365),
        "last_login":     time.Now().Add(-time.Duration(rand.Intn(24)) * time.Hour),
    }

    c.JSON(http.StatusOK, gin.H{
        "success": true,
        "data":    stats,
    })
}


Code Explanation: URL Parameters

c.Param("id")

What it does:
- Extracts the :id parameter from the URL path
- Returns empty string if parameter doesn't exist

Route definition:

router.GET("/user/:id", handler)


URL examples:

/user/123     → id = "123"
/user/abc-456 → id = "abc-456"
/user/        → doesn't match route (404)


Multiple parameters:

router.GET("/user/:userID/orders/:orderID", handler)

// Access:
userID := c.Param("userID")
orderID := c.Param("orderID")


Update main.go with New Routes

Update main.go:

func main() {
    router := gin.Default()
    statsHandler := handlers.NewStatsHandler()

    router.GET("/health", func(c *gin.Context) {
        c.JSON(http.StatusOK, gin.H{
            "status": "healthy",
        })
    })

    v1 := router.Group("/api/v1")
    {
        stats := v1.Group("/stats")
        {
            stats.GET("/users", statsHandler.GetUserStats)
            stats.GET("/user/:id", statsHandler.GetUserStatsByID)
            stats.GET("/sales", statsHandler.GetSalesReport)
            stats.GET("/performance", statsHandler.GetPerformanceMetrics)
        }
    }

    router.Run(":8080")
}


Running and Testing Multiple Endpoints

Start the server:

go run main.go


Test 1: User stats (no parameters)

/api/v1/stats/users


Test 2: Specific user (URL parameter)

/api/v1/stats/user/12345


Expected response:

{
  "success": true,
  "data": {
    "user_id": "12345",
    "total_orders": 47,
    "total_spent": 532.89,
    "account_age_days": 180,
    "last_login": "2025-11-09T14:22:00Z"
  }
}


Test 3: Sales report (query parameter)

/api/v1/stats/sales?period=weekly"


Note: Use quotes when URL contains special characters like ?

Expected response:

{
  "success": true,
  "data": {
    "period": "weekly",
    "total_sales": 87543.21,
    "total_orders": 543,
    "average_order": 161.22,
    "top_product": "Product-F"
  }
}


Test 4: Invalid query parameter

/api/v1/stats/sales?period=invalid"

Expected response:

{
  "success": false,
  "error": "Invalid period. Use: daily, weekly, monthly, or yearly"
}


Test 5: Performance metrics

/api/v1/stats/performance

What We've learned

Congratulations! You've successfully built a functional RESTful API using Gin-Gonic. Let's recap what you've learned and implemented:

  1. Gin Fundamentals

    • Setting up a Gin router with gin.Default()
    • Understanding the Gin Context (*gin.Context)
    • Creating JSON responses with gin.H
    • Starting the HTTP server
  2. Routing Patterns

    • Defining routes with HTTP methods (GET, POST, etc.)
    • Creating route groups for better organization
    • Understanding URL structure and API versioning
    • Nested route groups for hierarchical endpoints
  3. Request Handling

    • Extracting URL parameters with c.Param()
    • Reading query parameters with c.Query() and c.DefaultQuery()
    • Validating input data
    • Sending appropriate error responses with HTTP status codes
  4. Data Modeling

    • Creating Go structs to represent data
    • Using struct tags for JSON serialization
    • Organizing models in a separate package
    • Generating mock data for testing
  5. Handler Organization

    • Using the handler struct pattern
    • Creating constructor functions
    • Method receivers for clean code organization
    • Consistent response wrapper patterns

You now have a Statistical Reporting API with these functional endpoints:

GET  /health                      - Health check
GET  /api/v1/stats/users          - All user statistics
GET  /api/v1/stats/user/:id       - Specific user statistics
GET  /api/v1/stats/sales          - Sales report (with period filter)
GET  /api/v1/stats/performance    - Performance metrics

Next Steps to expand your API

1. Add More Endpoints

2. Implement POST Endpoints

3. Add Pagination

4. Connect to a Real Database

5. Add Middleware


You need to be logged in to access the cloud lab and experiment with the code presented in this tutorial.

Log in