JSON Handler

Exercise: JSON Handler

Difficulty - Beginner

Learning Objectives

  • Master JSON encoding and decoding in Go
  • Practice working with struct tags
  • Learn to handle nested JSON structures
  • Understand JSON marshaling patterns

Problem Statement

Create a program that works with a simple user management system using JSON. You'll handle JSON encoding/decoding, validation, and file persistence.

Implement these core functions:

  1. User struct with proper JSON tags
  2. SaveToJSON: Save users to a JSON file
  3. LoadFromJSON: Load users from a JSON file
  4. FindByID: Find a user by ID
  5. FilterByAge: Filter users by minimum age

Data Structures

 1package userstore
 2
 3import "time"
 4
 5type User struct {
 6    ID        int       `json:"id"`
 7    Username  string    `json:"username"`
 8    Email     string    `json:"email"`
 9    Age       int       `json:"age"`
10    Active    bool      `json:"active"`
11    CreatedAt time.Time `json:"created_at"`
12}
13
14type UserStore struct {
15    Users []User `json:"users"`
16}

Function Signatures

 1// SaveToJSON writes users to a JSON file
 2func SaveToJSON(filename string) error
 3
 4// LoadFromJSON reads users from a JSON file
 5func LoadFromJSON(filename string) error
 6
 7// AddUser adds a new user to the store
 8func AddUser(user User) error
 9
10// FindByID finds a user by their ID
11func FindByID(id int)
12
13// FilterByAge returns users with age >= minAge
14func FilterByAge(minAge int) []User
15
16// ToJSON converts the store to JSON string
17func ToJSON()
18
19// FromJSON populates the store from a JSON string
20func FromJSON(jsonStr string) error

Example Usage

 1package main
 2
 3import (
 4    "fmt"
 5    "log"
 6    "time"
 7    "userstore"
 8)
 9
10func main() {
11    store := &userstore.UserStore{}
12
13    // Add users
14    users := []userstore.User{
15        {
16            ID:        1,
17            Username:  "alice",
18            Email:     "alice@example.com",
19            Age:       28,
20            Active:    true,
21            CreatedAt: time.Now(),
22        },
23        {
24            ID:        2,
25            Username:  "bob",
26            Email:     "bob@example.com",
27            Age:       35,
28            Active:    true,
29            CreatedAt: time.Now(),
30        },
31        {
32            ID:        3,
33            Username:  "charlie",
34            Email:     "charlie@example.com",
35            Age:       17,
36            Active:    false,
37            CreatedAt: time.Now(),
38        },
39    }
40
41    for _, user := range users {
42        if err := store.AddUser(user); err != nil {
43            log.Fatal(err)
44        }
45    }
46
47    // Save to file
48    if err := store.SaveToJSON("users.json"); err != nil {
49        log.Fatal(err)
50    }
51    fmt.Println("Users saved to users.json")
52
53    // Load from file
54    newStore := &userstore.UserStore{}
55    if err := newStore.LoadFromJSON("users.json"); err != nil {
56        log.Fatal(err)
57    }
58    fmt.Printf("Loaded %d users\n", len(newStore.Users))
59
60    // Find by ID
61    user, err := newStore.FindByID(2)
62    if err != nil {
63        log.Fatal(err)
64    }
65    fmt.Printf("Found user: %s\n", user.Username)
66
67    // Filter by age
68    adults := newStore.FilterByAge(18)
69    fmt.Printf("Found %d adult users\n", len(adults))
70
71    // Convert to JSON string
72    jsonStr, err := store.ToJSON()
73    if err != nil {
74        log.Fatal(err)
75    }
76    fmt.Println("JSON:", jsonStr)
77}

Requirements

  1. Use proper JSON struct tags
  2. Handle duplicate IDs when adding users
  3. Return appropriate errors for not found cases
  4. Pretty-print JSON when saving to file
  5. Handle empty/non-existent files gracefully

Solution

Click to see the complete solution
  1package userstore
  2
  3import (
  4    "encoding/json"
  5    "fmt"
  6    "os"
  7    "time"
  8)
  9
 10type User struct {
 11    ID        int       `json:"id"`
 12    Username  string    `json:"username"`
 13    Email     string    `json:"email"`
 14    Age       int       `json:"age"`
 15    Active    bool      `json:"active"`
 16    CreatedAt time.Time `json:"created_at"`
 17}
 18
 19type UserStore struct {
 20    Users []User `json:"users"`
 21}
 22
 23// SaveToJSON writes users to a JSON file
 24func SaveToJSON(filename string) error {
 25    data, err := json.MarshalIndent(us, "", "  ")
 26    if err != nil {
 27        return fmt.Errorf("marshaling JSON: %w", err)
 28    }
 29
 30    if err := os.WriteFile(filename, data, 0644); err != nil {
 31        return fmt.Errorf("writing file: %w", err)
 32    }
 33
 34    return nil
 35}
 36
 37// LoadFromJSON reads users from a JSON file
 38func LoadFromJSON(filename string) error {
 39    data, err := os.ReadFile(filename)
 40    if err != nil {
 41        return fmt.Errorf("reading file: %w", err)
 42    }
 43
 44    if err := json.Unmarshal(data, us); err != nil {
 45        return fmt.Errorf("unmarshaling JSON: %w", err)
 46    }
 47
 48    return nil
 49}
 50
 51// AddUser adds a new user to the store
 52func AddUser(user User) error {
 53    // Check for duplicate ID
 54    for _, existingUser := range us.Users {
 55        if existingUser.ID == user.ID {
 56            return fmt.Errorf("user with ID %d already exists", user.ID)
 57        }
 58    }
 59
 60    us.Users = append(us.Users, user)
 61    return nil
 62}
 63
 64// FindByID finds a user by their ID
 65func FindByID(id int) {
 66    for i := range us.Users {
 67        if us.Users[i].ID == id {
 68            return &us.Users[i], nil
 69        }
 70    }
 71
 72    return nil, fmt.Errorf("user with ID %d not found", id)
 73}
 74
 75// FilterByAge returns users with age >= minAge
 76func FilterByAge(minAge int) []User {
 77    var filtered []User
 78
 79    for _, user := range us.Users {
 80        if user.Age >= minAge {
 81            filtered = append(filtered, user)
 82        }
 83    }
 84
 85    return filtered
 86}
 87
 88// ToJSON converts the store to a JSON string
 89func ToJSON() {
 90    data, err := json.MarshalIndent(us, "", "  ")
 91    if err != nil {
 92        return "", fmt.Errorf("marshaling JSON: %w", err)
 93    }
 94
 95    return string(data), nil
 96}
 97
 98// FromJSON populates the store from a JSON string
 99func FromJSON(jsonStr string) error {
100    if err := json.Unmarshal([]byte(jsonStr), us); err != nil {
101        return fmt.Errorf("unmarshaling JSON: %w", err)
102    }
103
104    return nil
105}

Explanation

JSON Struct Tags:

  • json:"field_name" specifies the JSON field name
  • Lowercase JSON fields follow JSON naming conventions
  • Tags are essential for proper marshaling/unmarshaling

SaveToJSON:

  • Uses json.MarshalIndent() for pretty-printed JSON
  • Two-space indentation for readability
  • Wraps errors with context

LoadFromJSON:

  • Reads entire file with os.ReadFile()
  • Uses json.Unmarshal() to populate struct
  • Returns clear error messages

AddUser:

  • Validates for duplicate IDs before adding
  • Returns error if duplicate exists
  • Simple O(n) duplicate check

FindByID:

  • Returns pointer to user to avoid copying
  • Returns error if user not found
  • Iterates through Users slice

FilterByAge:

  • Creates new slice with filtered users
  • Does not modify original store
  • Returns empty slice if no matches

JSON Struct Tag Options

 1type User struct {
 2    // Omit field if empty
 3    Email string `json:"email,omitempty"`
 4
 5    // Rename field
 6    Username string `json:"user_name"`
 7
 8    // Ignore field completely
 9    Password string `json:"-"`
10
11    // Use string encoding for numbers
12    Age int `json:"age,string"`
13}

Common JSON Patterns

Handling Unknown Fields:

 1type Config struct {
 2    Known string `json:"known"`
 3    Extra map[string]interface{} `json:"-"`
 4}
 5
 6func UnmarshalJSON(data []byte) error {
 7    type Alias Config
 8    aux := &struct {
 9        *Alias
10    }{
11        Alias:(c),
12    }
13
14    if err := json.Unmarshal(data, aux); err != nil {
15        return err
16    }
17
18    // Handle unknown fields in c.Extra
19    return nil
20}

Custom JSON Marshaling:

 1func MarshalJSON() {
 2    type Alias User
 3    return json.Marshal(&struct {
 4        *Alias
 5        CreatedAtFormatted string `json:"created_at_formatted"`
 6    }{
 7        Alias:             (u),
 8        CreatedAtFormatted: u.CreatedAt.Format(time.RFC3339),
 9    })
10}

Test Cases

 1package userstore
 2
 3import (
 4    "os"
 5    "testing"
 6    "time"
 7)
 8
 9func TestAddUser(t *testing.T) {
10    store := &UserStore{}
11
12    user := User{
13        ID:       1,
14        Username: "test",
15        Email:    "test@example.com",
16        Age:      25,
17        Active:   true,
18    }
19
20    // Should succeed
21    if err := store.AddUser(user); err != nil {
22        t.Fatalf("AddUser failed: %v", err)
23    }
24
25    // Should fail
26    if err := store.AddUser(user); err == nil {
27        t.Error("AddUser should fail for duplicate ID")
28    }
29}
30
31func TestSaveAndLoad(t *testing.T) {
32    filename := "test_users.json"
33    defer os.Remove(filename)
34
35    store := &UserStore{}
36    store.AddUser(User{
37        ID:        1,
38        Username:  "alice",
39        Email:     "alice@example.com",
40        Age:       28,
41        Active:    true,
42        CreatedAt: time.Now(),
43    })
44
45    // Save
46    if err := store.SaveToJSON(filename); err != nil {
47        t.Fatalf("SaveToJSON failed: %v", err)
48    }
49
50    // Load
51    newStore := &UserStore{}
52    if err := newStore.LoadFromJSON(filename); err != nil {
53        t.Fatalf("LoadFromJSON failed: %v", err)
54    }
55
56    if len(newStore.Users) != 1 {
57        t.Errorf("Expected 1 user, got %d", len(newStore.Users))
58    }
59}
60
61func TestFindByID(t *testing.T) {
62    store := &UserStore{}
63    store.AddUser(User{ID: 1, Username: "alice"})
64
65    // Should find
66    user, err := store.FindByID(1)
67    if err != nil {
68        t.Errorf("FindByID failed: %v", err)
69    }
70    if user.Username != "alice" {
71        t.Errorf("Expected alice, got %s", user.Username)
72    }
73
74    // Should not find
75    _, err = store.FindByID(999)
76    if err == nil {
77        t.Error("FindByID should fail for non-existent ID")
78    }
79}

Bonus Challenges

  1. Update/Delete Operations: Add UpdateUser and DeleteUser methods
  2. JSON Validation: Validate required fields before saving
  3. Streaming JSON: Handle large JSON files with streaming decoder
  4. Custom Time Format: Use custom time format in JSON
 1type User struct {
 2    CreatedAt CustomTime `json:"created_at"`
 3}
 4
 5type CustomTime struct {
 6    time.Time
 7}
 8
 9func MarshalJSON() {
10    return json.Marshal(ct.Format("2006-01-02"))
11}

Key Takeaways

  • JSON struct tags control marshaling/unmarshaling behavior
  • json.Marshal vs MarshalIndent - use Indent for readability
  • Handle errors from both marshaling and I/O operations
  • Use pointers when you need to modify structs
  • omitempty tag excludes zero-value fields from JSON

JSON is the most common data interchange format. Mastering Go's encoding/json package is essential for building modern Go applications that interact with APIs, config files, and data storage.