NashTech Blog

🚀 Build Your First API with Go💻🌐

Table of Contents

📄 Why Go?

Go is a language designed by Google to build simple, fast, and reliable software. It’s the top choice for modern cloud and backend systems.

  • Blazing Fast. 🚀 Go is a compiled language, so your apps run at native speeds, often rivaling C and C++.
  • Built for Concurrency. 🧵 Go makes handling thousands of simultaneous tasks easy with goroutines and channels, its powerful concurrency tools.
  • Simple and Clean. ✍️ With a small, focused syntax, Go is easy to read and write. You spend less time debugging and more time building.
  • Powerful Ecosystem. 🐳 By learning Go, you gain a high-demand skill and join the community behind tools like Docker and Kubernetes.

📄 Go’s Building Blocks – Types, Control Flow & Data Structures

✍️ Variables & Constants

You can declare variables with the var keyword, but the short declaration operator := is much more common. Constants are declared with const and their values cannot be changed.

package main

import "fmt"

func main() {
	// Standard declaration
	var name string = "Alice"
	// Short declaration
	age := 25
	// Constant
	const pi = 3.14

	fmt.Println(name, age, pi)
}

🔢 Basic Types

Go has a set of basic data types. Importantly, all variables have a “zero value” if they are not explicitly initialized.

  • Numeric: int, float64, etc.
  • Text: string
  • Boolean: bool
  • Zero Values: 0 for numbers, "" for strings, and false for boolean.

🔄 Control Flow

  • If/Else: The condition in an if statement does not need to be wrapped in parentheses.
  • Switch: The switch statement automatically handles the break for you after each case. This makes your code cleaner and less error-prone.
  • For: The for loop is the only looping construct in Go. It can be used in many ways, from a simple counter to a while-like loop.
// If/Else
if age > 18 {
    fmt.Println("Adult")
} else {
    fmt.Println("Minor")
}

// Switch
switch day := 3; day {
case 1:
    fmt.Println("Monday")
case 2:
    fmt.Println("Tuesday")
default:
    fmt.Println("Another day")
}

// For Loop
for i := 0; i < 5; i++ {
    fmt.Println(i)
}

🏗️ Structs

A struct is a collection of named fields. It’s perfect for grouping related data, like a Task‘s ID, title, and status.

// Task is a struct. It represents a to-do item.
type Task struct {
    ID    int
    Title string
    Done  bool
}

func main() {
    // Create an instance of the Task struct
    task := Task{ID: 1, Title: "Learn Go", Done: false}
    // Access a field using dot notation
    fmt.Println(task.Title)
}

📚 Slices

A slice is a dynamic-sized view into an array. It’s the most common way to represent lists of data in Go. You can easily add elements to a slice using the built-in append function.

// Create a slice and initialize it with values
numbers := []int{1, 2, 3}

// Add a new element to the slice
numbers = append(numbers, 4)

fmt.Println(numbers) // Output: [1 2 3 4]

🗺️ Maps

A map is an unordered collection of key-value pairs. It’s perfect for looking up data quickly, like finding a user’s age by their name.

// Define a map from a string key to an int value.
users := map[string]int{
    "Alice": 25,
    "Bob":   30,
}

// Access a value using its key
fmt.Println(users["Alice"])

📄 Functions, Error Handling & Packages

This lesson is all about structuring your code. You’ll learn how to write reusable functions. You will handle errors gracefully using Go’s convention. Additionally, you’ll organize your code into logical packages for better maintainability.

📦 Functions

Functions in Go can return multiple values, a key feature used for returning both a result and an error.

// This function takes two integers and returns their sum
func add(a int, b int) int {
    return a + b
}

⚠️ Error Handling

Go’s approach to error handling is to explicitly return an error as the last return value of a function. The idiomatic way to check for an error is with a simple if statement.

package main

import (
    "errors"
    "fmt"
)

// The divide function returns a result and an error
func divide(a, b float64) (float64, error) {
    if b == 0 {
        // Return a zero value. Return a new error.
        return 0, errors.New("cannot divide by zero")
    }
    // Return the result. It provides a nil error. This means no error occurred.
    return a / b, nil
}

func main() {
    _, err := divide(10, 0)
    if err != nil {
        // Handle the error if it's not nil
        fmt.Println("Error:", err)
    }
}

📦 Packages

Packages are Go’s way of organizing and sharing code. A function or variable is exported (public) if its name starts with a capital letter.

task-manager/
├── main.go
└── utils/
    └── math.go

utils/math.go:

package utils // Package name matches the directory name

// Multiply is an exported function (capital M)
func Multiply(a, b int) int {
    return a * b
}

main.go:

package main

import (
    "fmt"
    // Import your local package using the module path
    "github.com/yourname/task-manager/utils"
)

func main() {
    // Call the exported function from the utils package
    fmt.Println(utils.Multiply(2, 3))
}

📄 Concurrency with Goroutines & Channels

🚀 Goroutines

  • What: Lightweight functions that run at the same time.
  • Why: To run tasks in parallel, making your app faster.
  • How: Add go before a function call.
package main

import (
    "fmt"
    "time"
)

func processTask(id int) {
    fmt.Println("Processing task", id)
    time.Sleep(time.Second) // Simulate work
    fmt.Println("Task", id, "finished")
}

func main() {
    go processTask(1)
    go processTask(2)

    // The main function must wait for the goroutines to finish
    time.Sleep(2 * time.Second) 
}

📡 Channels

  • What: Typed pipes for sending data between goroutines.
  • Why: To communicate safely and avoid common concurrency bugs.
  • How: Use make to create a channel and <- to send or receive data.
// Create a channel for integers
ch := make(chan int)

// Start a goroutine that sends a value to the channel
go func() {
    ch <- 100
}()

// Receive the value from the channel
value := <-ch
fmt.Println("Received:", value)

👷 Worker Pools

A worker pool is a common pattern that uses goroutines and channels to limit the number of concurrent tasks. This helps you manage system resources efficiently.

func worker(id int, jobs <-chan string, results chan<- string) {
    for job := range jobs {
        fmt.Println("Worker", id, "started job:", job)
        results <- "Job " + job + " done"
    }
}

func main() {
    jobs := make(chan string, 10)
    results := make(chan string, 10)

    // Start 3 workers
    for w := 1; w <= 3; w++ {
        go worker(w, jobs, results)
    }

    // Send 5 jobs
    for j := 1; j <= 5; j++ {
        jobs <- fmt.Sprintf("task-%d", j)
    }
    close(jobs) // Close the channel to signal no more jobs

    // Collect the results
    for i := 1; i <= 5; i++ {
        fmt.Println(<-results)
    }
}

📄 REST API with Gorilla Mux

Your Task Manager needs a way to communicate with other services. That’s where a REST API comes in. In this lesson, you’ll use Gorilla Mux. It’s a powerful and widely used router for Go. You’re going to build the foundational endpoints for your API.

⚙️ Setup Gorilla Mux

Install the library with the following command:

go get -u github.com/gorilla/mux

🛠️ Building Your API

Let’s create the core of your API. The mux.NewRouter() function creates a new router, and HandleFunc registers your handler functions for specific routes and methods.

package main

import (
    "fmt"
    "net/http"
    "github.com/gorilla/mux"
)

// The main function sets up the server and routes.
func main() {
    r := mux.NewRouter()
    r.HandleFunc("/tasks", getTasks).Methods("GET")
    r.HandleFunc("/tasks", createTask).Methods("POST")
    
    fmt.Println("Server running on :8080")
    http.ListenAndServe(":8080", r)
}

func getTasks(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("List of all tasks"))
}

func createTask(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("Task created successfully"))
}

Testing with curl:

  • curl -X GET http://localhost:8080/tasks
  • curl -X POST http://localhost:8080/tasks

📄 JSON Handling & File I/O

Your API needs to do more than just send text messages. It needs to handle data! In this lesson, you’ll learn how to work with JSON. It is the standard format for data exchange in APIs. You will also save your tasks to a JSON file so they persist between server restarts.

📝 JSON Struct Tags

Go uses struct tags to control how fields are converted to and from JSON.

type Task struct {
    ID    int    `json:"id"`
    Title string `json:"title"`
    Done  bool   `json:"done"`
}

💾 Reading & Writing JSON

The json package is built into Go. json.Marshal converts a Go struct to a JSON byte slice, and json.Unmarshal does the reverse. You’ll also use the os and io/ioutil packages to read from and write to your tasks.json file.

package main

import (
    "encoding/json"
    "os"
)

type Task struct {
    ID    int    `json:"id"`
    Title string `json:"title"`
}

func main() {
    // Write JSON to a file
    task := Task{ID: 1, Title: "Learn Go"}
    data, _ := json.Marshal(task)
    os.WriteFile("task.json", data, 0644)

    // Read JSON from a file
    fileData, _ := os.ReadFile("task.json")
    var decodedTask Task
    json.Unmarshal(fileData, &decodedTask)
    fmt.Println("Decoded task:", decodedTask.Title)
}

📄 Test-Driven Development (TDD)

Testing is crucial for production apps, and in Go, it’s built right in. In this lesson, you’ll adopt a Test-Driven Development (TDD) workflow. You’ll write tests first, watch them fail, then write the code to make them pass. This ensures your code is always robust and bug-free.

🔴 The Red Phase: Write the Failing Test

First, write a test for a function that doesn’t exist yet. Create task_test.go and add the test for a GetTaskByID function.

package main

import "testing"

func TestGetTaskByID(t *testing.T) {
    // Simulate a list of tasks
    tasks := []Task{{ID: 1, Title: "Task 1"}, {ID: 2, Title: "Task 2"}}
    
    // Test case for a valid task
    task, err := GetTaskByID(tasks, 2)
    if err != nil || task.ID != 2 {
        t.Errorf("Expected to find task 2, but got error or wrong task")
    }

    // Test case for a missing task
    _, err = GetTaskByID(tasks, 99)
    if err == nil {
        t.Errorf("Expected an error for a missing task, but got none")
    }
}

🟢 The Green Phase: Make it Pass

Now, write the function in your main.go file to make the test pass.

func GetTaskByID(tasks []Task, id int) (*Task, error) {
    for _, task := range tasks {
        if task.ID == id {
            return &task, nil
        }
    }
    return nil, errors.New("task not found")
}

🔨 Run the Test

Use go test to run your test and confirm it passes.

go test ./...

📄 Context – Timeouts & Cancellations

Go’s context package is a crucial tool for managing the lifecycle of API requests. It allows you to carry important signals, like a cancellation request or a timeout, across function boundaries. In this lesson, you’ll integrate context into your API handlers.

⏱️ Integrating Context

You’ll update your API handler to accept a context.Context and use it to manage a simulated long-running task.

package main

import (
    "context"
    "fmt"
    "net/http"
    "time"
)

func getTasksWithTimeout(w http.ResponseWriter, r *http.Request) {
    // Create a context with a 1-second timeout
    ctx, cancel := context.WithTimeout(r.Context(), time.Second)
    defer cancel()

    // Simulate a long-running database query
    select {
    case <-time.After(2 * time.Second): // This is the task taking too long
        w.WriteHeader(http.StatusOK)
        w.Write([]byte("Tasks fetched successfully"))
    case <-ctx.Done():
        // The context timed out, so we return an error
        w.WriteHeader(http.StatusRequestTimeout)
        fmt.Println("Request timed out:", ctx.Err())
        w.Write([]byte("Request timed out"))
    }
}

📄 Interfaces & Polymorphism

Go’s interfaces provide a powerful way to achieve polymorphism—the ability for different types to satisfy a common behavior. Unlike other languages, Go’s interfaces are satisfied implicitly, which makes them incredibly flexible. You’ll use interfaces to refactor your code and make it more extensible.

⚙️ The TaskStore Interface

Let’s define a TaskStore interface that represents the behavior of storing and retrieving tasks.

package main

import "fmt"

// TaskStore defines the contract for task storage
type TaskStore interface {
    GetTasks() ([]Task, error)
    SaveTask(task Task) error
}

// In-memory implementation of the interface
type InMemoryTaskStore struct {
    tasks []Task
}

func (s *InMemoryTaskStore) GetTasks() ([]Task, error) {
    return s.tasks, nil
}

func (s *InMemoryTaskStore) SaveTask(task Task) error {
    s.tasks = append(s.tasks, task)
    return nil
}

// A function that can use ANY TaskStore implementation
func listAllTasks(store TaskStore) {
    tasks, _ := store.GetTasks()
    fmt.Println("Listing tasks:", len(tasks))
}

📄 CLI with Cobra

Now that you have a functional API, it’s time to build a powerful command-line interface (CLI) to manage your tasks from the terminal. You’ll use Cobra, the library that powers tools like Docker and Kubernetes, to build a professional CLI.

⚙️ Setup Cobra

First, install the Cobra CLI generator and initialize a new project.

go install github.com/spf13/cobra-cli@latest
cobra-cli init task-cli
cd task-cli

🧑‍💻 Building Your list Command

Use cobra-cli add list to create the command file. Inside cmd/list.go, you’ll write code to make an HTTP request to your API and print the results.

package cmd

import (
    "fmt"
    "net/http"
    "io/ioutil"
    "github.com/spf13/cobra"
)

var listCmd = &cobra.Command{
    Use:   "list",
    Short: "List all tasks from the API",
    Run: func(cmd *cobra.Command, args []string) {
        resp, err := http.Get("http://localhost:8080/tasks")
        if err != nil {
            fmt.Println("Error fetching tasks:", err)
            return
        }
        defer resp.Body.Close()

        body, _ := ioutil.ReadAll(resp.Body)
        fmt.Println("Tasks:", string(body))
    },
}

func init() {
    rootCmd.AddCommand(listCmd)
}

Now you can run your new command from the terminal: go run main.go list.

📄 Configuration with Viper

In a real-world application, you need to manage settings like API ports, database connection strings, and other environment-specific variables. Hardcoding these values is a bad practice. Viper is a popular and flexible configuration library. It helps you manage settings from various sources. These sources include files, environment variables, and the command line.

⚙️ Setup Viper

First, install the library:

go get github.com/spf13/viper

🛠️ Using Viper

Let’s create a config.yml file to store our settings:

port: 8080
database:
  host: localhost
  port: 5432

Now, update your main.go file to read from this configuration. Viper makes it easy to set a config file name and path. It reads the file and retrieves values using simple dot notation.

package main

import (
    "fmt"
    "log"
    "github.com/spf13/viper"
)

func main() {
    // Set the file name and path
    viper.SetConfigName("config")
    viper.SetConfigType("yaml")
    viper.AddConfigPath(".")

    // Read the configuration file
    if err := viper.ReadInConfig(); err != nil {
        log.Fatalf("Error reading config file, %s", err)
    }

    // Get values from the config
    port := viper.GetInt("port")
    dbHost := viper.GetString("database.host")

    fmt.Printf("Server running on port %d, connecting to DB at %s\n", port, dbHost)
}

📄 Middleware

Middleware in Go is a powerful design pattern. It allows you to add reusable functionality to your API request handlers. You can do this without modifying the handlers themselves. Common uses include logging, authentication, and request validation.

🛠️ Building a Middleware

A middleware is a function that takes an http.Handler and returns another http.Handler. This “wrapping” pattern allows you to execute logic before or after the main handler

package main

import (
    "log"
    "net/http"
)

// LoggerMiddleware logs every incoming request
func LoggerMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        log.Printf("Received request: %s %s", r.Method, r.URL.Path)
        next.ServeHTTP(w, r)
    })
}

// In your main function, you would chain the middleware:
func main() {
    // ... setup router
    router.HandleFunc("/tasks", getTasks).Methods("GET")
    
    // Wrap your router with the middleware
    loggedRouter := LoggerMiddleware(router)

    http.ListenAndServe(":8080", loggedRouter)
}

📄 Logging with Zap

Replacing fmt.Println with a proper logging library is crucial for production. Zap is a fast and structured logging library created by Uber. It allows you to log with different severity levels. You can add structured data to your log messages. This is essential for monitoring and debugging.

⚙️ Setup Zap

go get go.uber.org/zap

🛠️ Using Zap

Let’s create a logger instance and use it in your code. The zap.NewProduction() logger is perfect for production environments as it outputs log messages in JSON format.

package main

import (
    "go.uber.org/zap"
)

func main() {
    // Create a new production-ready logger.
    logger, _ := zap.NewProduction()
    defer logger.Sync() // Flushes any buffered logs.

    // Log with a message and structured fields
    logger.Info("Starting server", zap.String("port", "8080"))

    // Log an error with a specific error code
    logger.Error("Something went wrong", zap.Int("code", 500))
}

📄 Advanced Error Handling

You’ve learned to handle errors by checking the err variable. But what if you want to add more context to an error? Go’s error handling can be made more powerful with error wrapping and custom error types. This provides better context for debugging and lets you handle specific error conditions.

🛠️ Wrapping Errors

The fmt.Errorf function with the %w verb is the standard way to wrap an error.

package main

import (
    "errors"
    "fmt"
)

var ErrFileNotFound = errors.New("file not found")

func readFile(path string) error {
    // Simulate a file not found error
    return fmt.Errorf("could not read file: %w", ErrFileNotFound)
}

func main() {
    err := readFile("data.txt")
    
    // Use errors.Is to check the underlying error
    if errors.Is(err, ErrFileNotFound) {
        fmt.Println("Caught the file not found error!")
    }
}

📄 Embedding & Composition

Go doesn’t have classical inheritance, but it provides a powerful choice: composition using struct embedding. This lets you reuse code and functionality by “embedding” a struct inside another. It’s a key part of Go’s design philosophy and allows for clean, flexible code.

🛠️ Using Struct Embedding

In this example, we embed a Logger struct into an APIServer struct. This gives APIServer all the fields and methods of the Logger without needing to define them again.

package main

import "fmt"

// A simple Logger struct
type Logger struct {
    LogLevel string
}

func (l *Logger) Log(message string) {
    fmt.Printf("[%s] %s\n", l.LogLevel, message)
}

// APIServer embeds the Logger struct
type APIServer struct {
    Address string
    Port    int
    Logger
}

func main() {
    server := APIServer{
        Address: "localhost",
        Port:    8080,
        Logger:  Logger{LogLevel: "INFO"},
    }
    server.Log("Starting server...")
}

📄 Database with GORM & PostgreSQL

Your API needs a real database. We’ll use GORM, the most popular Go ORM, to connect to PostgreSQL.

  • GORM: An Object-Relational Mapper (ORM) that lets you interact with the database using Go structs instead of raw SQL queries. This makes your code cleaner and easier to manage.
  • PostgreSQL: A powerful and widely-used open-source relational database. It’s reliable and has a rich feature set, making it an excellent choice for any serious application.

⚙️ Setup GORM & PostgreSQL

You’ll need the GORM library and its PostgreSQL driver.

go get gorm.io/gorm
go get gorm.io/driver/postgres

🛠️ Connecting to the Database

First, define your Task struct with GORM’s built-in gorm.Model to get standard fields like ID and timestamps. Then, connect to your database using the driver and migrate the schema.

package main

import (
    "gorm.io/gorm"
    "gorm.io/driver/postgres"
)

type Task struct {
    gorm.Model
    Title string `gorm:"type:text"`
    Done  bool
}

func main() {
    dsn := "host=localhost user=gorm password=gorm dbname=gorm port=5432 sslmode=disable"
    db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
    if err != nil {
        panic("failed to connect database")
    }

    // Migrate the schema
    db.AutoMigrate(&Task{})
    
    // Now you can do CRUD operations:
    // db.Create(&Task{Title: "Learn GORM"})
    // db.First(&task, 1)
}

📄 Docker & Docker Compose

Docker is a game-changer for deployment. It lets you package your application and all its dependencies into a single, isolated container. This ensures your application runs the same way on your machine, a testing server, or a production environment. Docker Compose simplifies managing multiple containers, for example, your Go API and your PostgreSQL database.

⚙️ Creating a Dockerfile

A Dockerfile is a script that tells Docker how to build your application image.

# Start from a Go base image
FROM golang:1.21-alpine

# Set the working directory
WORKDIR /app

# Copy go.mod and go.sum to cache dependencies
COPY go.mod go.sum ./
RUN go mod download

# Copy the rest of the source code
COPY . .

# Build the Go application
RUN go build -o /go-api

# Expose the port
EXPOSE 8080

# Command to run the application
CMD ["/go-api"]

🛠️ Using Docker Compose

You’ll create a docker-compose.yml file to define your services (the Go API and the PostgreSQL database). This single file lets you start both with one command.

version: '3.8'

services:
  app:
    build: .
    ports:
      - "8080:8080"
    depends_on:
      - db
  
  db:
    image: postgres:13
    ports:
      - "5432:5432"
    environment:
      POSTGRES_USER: gorm
      POSTGRES_PASSWORD: gorm
      POSTGRES_DB: gorm

Now, from your terminal, run docker compose up –build to start both services.

Picture of tiennguyennl

tiennguyennl

Leave a Comment

Your email address will not be published. Required fields are marked *

Suggested Article

Scroll to Top