DEV Community

Ayoub Ali
Ayoub Ali

Posted on • Edited on

Build a Lightweight JSON Server in Go – No Dependencies, All Power

Let's walk through how to build a lightweight JSON server using only the Go standard library. This server will serve preloaded JSON data from either a local file or remote URL and support basic CRUD operations via HTTP methods like GET, POST, PUT, and DELETE. We’ll also add rate limiting and CORS support for robustness.


🚀 Overview

This server is designed to be minimal, fast, and flexible. It can be useful for:

  • Mocking APIs during frontend development
  • Serving static JSON data with dynamic access
  • Learning how to work with Go's net/http package
  • Implementing middleware patterns (like rate limiting and CORS)

📁 Project Structure

We're using just one Go file that does all the heavy lifting:

  • Loads JSON data at startup
  • Sets up an HTTP server
  • Handles routing
  • Implements middleware
  • Supports basic API functionality

Let’s dive into each part.


🧱 Data Model

First, we define our custom struct — representing the shape of our JSON data. In this example, it's a simple structure:

type JSONMODEL struct {
    Name     string `json:"name"`
    ID       int32  `json:"id"`
    PASSWORD string `json:"password"`
}
Enter fullscreen mode Exit fullscreen mode

💡 Replace this with your own data model based on your JSON schema. Tools like quickjson.io can help generate structs from raw JSON.

We then declare a global variable to hold our loaded data:

var jsonData []JSONMODEL
Enter fullscreen mode Exit fullscreen mode

🔁 Loading Data

The server supports loading data from two sources:

  1. A local file path
  2. A remote URL

We determine the source type by checking if it starts with "http":

func loadData(source string) ([]JSONMODEL, error) {
    var reader io.Reader

    if strings.HasPrefix(source, "http") {
        resp, err := http.Get(source)
        if err != nil {
            return nil, err
        }
        defer resp.Body.Close()
        reader = resp.Body
    } else {
        file, err := os.Open(filepath.Clean(source))
        if err != nil {
            return nil, err
        }
        defer file.Close()
        reader = file
    }

    var data []JSONMODEL
    if err := json.NewDecoder(reader).Decode(&data); err != nil {
        return nil, err
    }

    return data, nil
}
Enter fullscreen mode Exit fullscreen mode

📝 The SOURCE constant at the top of the code lets you easily switch between these modes:

const SOURCE = "LOCAL_PATH" // 👈 Change this to your local file or remote URL

🌐 Routing & Dynamic Access

We use Go’s http.HandleFunc to create endpoints. Here’s how we handle dynamic routes like /api/0, which returns the first item in the JSON array:

func getNestedValue(data []JSONMODEL, parts []string) interface{} {
    if len(parts) == 0 {
        return data
    }

    index := parseInt(parts[0])
    if index >= 0 && index < len(data) {
        return data[index]
    }

    return nil
}
Enter fullscreen mode Exit fullscreen mode

This function safely parses the route segment as an integer index and returns the corresponding object.


✏️ Update & Delete Operations

We support updating and deleting items by index. For example:

  • PUT /api/1 updates the second item
  • DELETE /api/1 removes the second item

Here’s how we implement update logic:

func setNestedValue(data []JSONMODEL, parts []string, value interface{}) ([]JSONMODEL, error) {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

And deletion:

func deleteNestedValue(data []JSONMODEL, parts []string) ([]JSONMODEL, error) {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

These functions modify the global jsonData slice in memory.


⚙️ Middleware: Rate Limiting & CORS

To protect our server from abuse and make it accessible across domains, we implement two middleware functions.

🚦 Rate Limiting

Limits clients to 5 requests per second:

const (
    rateLimitRequests = 5
    rateLimitWindow   = time.Second
)
Enter fullscreen mode Exit fullscreen mode

It uses a map to track IP addresses and their last request time:

var visits = make(map[string]time.Time)
Enter fullscreen mode Exit fullscreen mode

If a client exceeds the limit, they receive a 429 Too Many Requests response.

🔒 CORS Support

Allows cross-origin requests from any domain:

w.Header().Set("Access-Control-Allow-Origin", "*")
Enter fullscreen mode Exit fullscreen mode

Also handles preflight OPTIONS requests gracefully.


🖥️ Starting the Server

Our server runs on port 3000:

const port = "3000"
Enter fullscreen mode Exit fullscreen mode

We wrap the server start in a goroutine so we can handle graceful shutdowns when receiving OS signals:

quit := make(chan os.Signal, 1)
<-quit
log.Println("Shutting down server...")

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

if err := server.Shutdown(ctx); err != nil {
    log.Fatal("Server forced to shutdown:", err)
}
Enter fullscreen mode Exit fullscreen mode

📦 Final Thoughts

This small Go server demonstrates the power of the standard library. With no external dependencies, we've built:

  • A JSON loader
  • A RESTful API
  • Middleware for security and performance
  • Graceful shutdown handling

It's a great starting point for building mock services, internal tools, or even embedded servers in larger applications.


🧪 Try It Yourself

You can run this server locally with:

go run main.go
Enter fullscreen mode Exit fullscreen mode

Then visit:

http://localhost:3000/api/0
Enter fullscreen mode Exit fullscreen mode

Or send POST, PUT, or DELETE requests to manipulate the data.


Full Code

package main

import (
    "context"
    "encoding/json"
    "fmt"
    "io"
    "log"
    "net"
    "net/http"
    "os"
    "path/filepath"
    "strings"
    "sync"
    "time"
)

// 🔁 Hardcoded source: local file or remote URL
const SOURCE = "LOCAL_PATH" // 👈 Change this to your local file or remote URL

// 🧱 Your custom struct — replace this with what quickjson.io generates for your JSON
type JSONMODEL struct {
    Name     string `json:"name"`
    ID       int32  `json:"id"`
    PASSWORD string `json:"password"`
}

var jsonData []JSONMODEL // 👈 Adjust this type to match your data structure

// Rate limiting config
const (
    rateLimitRequests = 5
    rateLimitWindow   = time.Second
)

var (
    visits = make(map[string]time.Time)
    mu     sync.Mutex
)

// loadData loads JSON from local file or remote URL into typed struct
func loadData(source string) ([]JSONMODEL, error) {
    var reader io.Reader

    if strings.HasPrefix(source, "http") {
        resp, err := http.Get(source)
        if err != nil {
            return nil, err
        }
        defer resp.Body.Close()
        reader = resp.Body
    } else {
        file, err := os.Open(filepath.Clean(source))
        if err != nil {
            return nil, err
        }
        defer file.Close()
        reader = file
    }

    var data []JSONMODEL
    if err := json.NewDecoder(reader).Decode(&data); err != nil {
        return nil, err
    }

    return data, nil
}

// getNestedValue simulates dynamic access (for array index like /api/0)
func getNestedValue(data []JSONMODEL, parts []string) interface{} {
    if len(parts) == 0 {
        return data
    }

    index := parseInt(parts[0])
    if index >= 0 && index < len(data) {
        return data[index]
    }

    return nil
}

// setNestedValue allows updating an item by index
func setNestedValue(data []JSONMODEL, parts []string, value interface{}) ([]JSONMODEL, error) {
    if len(parts) == 0 {
        return data, fmt.Errorf("no index provided")
    }

    index := parseInt(parts[0])
    if index < 0 || index >= len(data) {
        return data, fmt.Errorf("index out of bounds")
    }

    if v, ok := value.(JSONMODEL); ok {
        data[index] = v
    } else {
        return data, fmt.Errorf("invalid type for update")
    }

    return data, nil
}

// deleteNestedValue removes an item by index
func deleteNestedValue(data []JSONMODEL, parts []string) ([]JSONMODEL, error) {
    if len(parts) == 0 {
        return data, fmt.Errorf("no index provided")
    }

    index := parseInt(parts[0])
    if index < 0 || index >= len(data) {
        return data, fmt.Errorf("index out of bounds")
    }

    return append(data[:index], data[index+1:]...), nil
}

// parseInt safely parses int from string
func parseInt(s string) int {
    n := 0
    for _, r := range s {
        n = n*10 + int(r-'0')
    }
    return n
}

// Standard lib-based rate limiter middleware
func rateLimit(next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        ip, _, _ := net.SplitHostPort(r.RemoteAddr)

        mu.Lock()
        defer mu.Unlock()

        lastSeen := visits[ip]
        now := time.Now()

        if !lastSeen.IsZero() && now.Sub(lastSeen) < rateLimitWindow {
            http.Error(w, `{"error": "rate limit exceeded"}`, http.StatusTooManyRequests)
            return
        }

        visits[ip] = now
        next(w, r)
    }
}

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

// handler for dynamic routes
func jsonHandler(data *[]JSONMODEL) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")

        path := strings.Trim(r.URL.Path, "/")
        parts := strings.Split(path, "/")

        switch r.Method {
        case http.MethodGet:
            value := getNestedValue(*data, parts)
            if value == nil {
                http.Error(w, `{"error": "not found"}`, http.StatusNotFound)
                return
            }
            json.NewEncoder(w).Encode(value)

        case http.MethodPost:
            fallthrough
        case http.MethodPut:
            var payload JSONMODEL
            if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
                http.Error(w, `{"error": "invalid request body"}`, http.StatusBadRequest)
                return
            }
            if len(parts) == 0 {
                http.Error(w, `{"error": "no index provided"}`, http.StatusBadRequest)
                return
            }
            updatedData, err := setNestedValue(*data, parts, payload)
            if err != nil {
                http.Error(w, `{"error": "update failed"}`, http.StatusInternalServerError)
                return
            }
            *data = updatedData
            json.NewEncoder(w).Encode(payload)

        case http.MethodDelete:
            if len(parts) == 0 {
                http.Error(w, `{"error": "nothing to delete"}`, http.StatusBadRequest)
                return
            }
            updatedData, err := deleteNestedValue(*data, parts)
            if err != nil {
                http.Error(w, `{"error": "delete failed"}`, http.StatusInternalServerError)
                return
            }
            *data = updatedData
            w.WriteHeader(http.StatusOK)
            json.NewEncoder(w).Encode(map[string]string{"status": "deleted"})

        default:
            http.Error(w, `{"error": "method not allowed"}`, http.StatusMethodNotAllowed)
        }
    }
}

func main() {
    const port = "3000"

    server := &http.Server{
        Addr:         ":" + port,
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 10 * time.Second,
        IdleTimeout:  120 * time.Second,
    }

    // Load JSON at startup
    log.Printf("Loading JSON from: %s", SOURCE)
    tempData, err := loadData(SOURCE)
    if err != nil {
        log.Fatalf("Failed to load JSON: %v", err)
    }
    jsonData = tempData

    // Root route
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(jsonData)
    })

    // API endpoints
    http.HandleFunc("/api/", enableCORS(rateLimit(func(w http.ResponseWriter, r *http.Request) {
        jsonHandler(&jsonData)(w, r)
    })))

    log.Printf("✅ JSON Server running at http://localhost:%s", port)

    // Start server in goroutine to allow graceful shutdown
    go func() {
        if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("Error starting server: %v", err)
        }
    }()

    // Wait for interrupt signal to gracefully shut down
    quit := make(chan os.Signal, 1)
    <-quit
    log.Println("Shutting down server...")

    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    if err := server.Shutdown(ctx); err != nil {
        log.Fatal("Server forced to shutdown:", err)
    }

    log.Println("Server exited")
}


Enter fullscreen mode Exit fullscreen mode

📚 Summary

Feature Description
Language Go (Golang)
Framework Standard Library (net/http)
Load Source Local file or Remote URL
Supported Methods GET, POST, PUT, DELETE
Middleware Rate Limiting, CORS
Graceful Shutdown Yes
Dependencies None

Top comments (0)