With the release of Go 1.22, the net/http package now provides all you need for advanced routing in Golang when building Web APIs. However, understanding how to utilize the net/http package can be quite challenging, especially for advanced features like middleware, sub-routing, path parameters, HTTP methods, etc.

Therefore, in this article, we will build a basic CRUD API to cover almost 80% of what you need to know when building an API with the net/http package.

Here is the roadmap we will follow: First, we will set up a SQLite database with GORM. Then, we’ll create route handlers to process the HTTP requests and establish routes for these handlers.

Once completed, you can spin up a frontend app we created in a previous article to interact with the Go API. Don’t worry if you didn’t complete the article related to the frontend app; I will provide the GitHub link so you can clone and run it on your machine.

More practice:

Perform CRUD Operations with the nethttp Go Standard Library

Run the Go Project on Your Computer

To get the Go API project up and running on your machine, follow these simple steps:

  1. Download or clone the Golang project from its GitHub repository at https://github.com/wpcodevo/go-standard-lib-crud-app. Once you have the project, open it in your preferred IDE or text editor.
  2. Next, go to your terminal and execute the go run main.go command. This will install all the necessary packages, migrate the GORM schema to the SQLite database, and start the HTTP server.
  3. To test the API endpoints, you can use Postman to make HTTP requests to the Go server. First, import the Note App.postman_collection.json file available in the root directory, which contains predefined HTTP requests to make your life easier.
  4. Alternatively, you can follow the instructions below if you prefer to interact with the API using a front-end application.

Run the API with a Frontend App

If you’re looking for a more detailed guide on how to build the React.js CRUD app, you can check out the post titled “Build a React.js CRUD App using a RESTful API“. However, if you want to just run the frontend with the Go API without having to write any code, follow the steps below:

  1. First, ensure that you have Node.js and PNPM installed on your machine. If not, you can download and install them from the official websites.
  2. Next, obtain the source code for the React CRUD project by either cloning the repository or downloading it from https://github.com/wpcodevo/reactjs-crud-note-app, and then open it in your preferred IDE or text editor.
  3. In the console, navigate to the root directory of the project and execute the command pnpm install to install all of the necessary dependencies.
  4. Once the dependencies have been installed, run the command pnpm dev to start the Vite development server.
  5. To test the CRUD functionalities, open the app in your browser by navigating to http://localhost:3000/. Keep in mind that accessing the React app at http://127.0.0.1:3000 might result in a “site can’t be reached” error or CORS errors. Therefore, it’s best to use http://localhost:3000/.

Set up the Golang Project

As always, we need to start by generating a new Go project. To do this, navigate to the directory where you want to store the project’s source code and run the commands below. These commands will create a new directory and initialize it as a Golang project. Remember to replace the <username> placeholder with your GitHub username.


mkdir go-standard-lib-crud-app
go mod init github.com/wpcodevo/go-standard-lib-crud-app

Next, we need to install a couple of dependencies or packages we will need while building the API. Run the commands below to install them:


go get github.com/go-playground/validator/v10
go get gorm.io/driver/sqlite
go get -u gorm.io/gorm
go get github.com/google/uuid
go get github.com/rs/cors

  • validator – This package allows you to validate struct fields based on tags added to the struct fields.
  • sqlite – This package provides SQLite driver for the GORM ORM library in Go.
  • gorm – An ORM library for handling database operations in Go.
  • uuid – This package allows you to generate and manage UUIDs in Go.
  • cors – A package for handling CORS-related headers and requests in Go.

Set up GORM for Database Operations

With the dependencies installed, let’s start working on the database-related tasks, which include creating the database schemas, handling the validation logic, and connecting the Go project to the SQLite server.

Create the Database Schema

Let’s start by creating the GORM model called Note, which we will later use to generate its corresponding table and columns in the database using GORM. To do this, create a new file named model.go in the root directory and include the following code:

model.go


package main

import (
	"time"

	"github.com/go-playground/validator/v10"
	"github.com/google/uuid"
	"gorm.io/gorm"
)

type Note struct {
	ID        string    `gorm:"type:char(36);primary_key" json:"id,omitempty"`
	Title     string    `gorm:"type:varchar(255);uniqueIndex:idx_notes_title,LENGTH(255);not null" json:"title,omitempty"`
	Content   string    `gorm:"not null" json:"content,omitempty"`
	Category  string    `gorm:"varchar(100)" json:"category,omitempty"`
	Published bool      `gorm:"default:false;not null" json:"published"`
	CreatedAt time.Time `gorm:"not null;default:'1970-01-01 00:00:01'" json:"createdAt,omitempty"`
	UpdatedAt time.Time `gorm:"not null;default:'1970-01-01 00:00:01';ON UPDATE CURRENT_TIMESTAMP" json:"updatedAt,omitempty"`
}

func (note *Note) BeforeCreate(tx *gorm.DB) (err error) {
	note.ID = uuid.New().String()
	return nil
}

var validate = validator.New()

type ErrorResponse struct {
	Field string `json:"field"`
	Tag   string `json:"tag"`
	Value string `json:"value,omitempty"`
}

func ValidateStruct[T any](payload T) []*ErrorResponse {
	var errors []*ErrorResponse
	err := validate.Struct(payload)
	if err != nil {
		for _, err := range err.(validator.ValidationErrors) {
			var element ErrorResponse
			element.Field = err.StructNamespace()
			element.Tag = err.Tag()
			element.Value = err.Param()
			errors = append(errors, &element)
		}
	}
	return errors
}

type CreateNoteSchema struct {
	Title     string `json:"title" validate:"required"`
	Content   string `json:"content" validate:"required"`
	Category  string `json:"category,omitempty"`
	Published bool   `json:"published,omitempty"`
}

type UpdateNoteSchema struct {
	Title     string `json:"title,omitempty"`
	Content   string `json:"content,omitempty"`
	Category  string `json:"category,omitempty"`
	Published *bool  `json:"published,omitempty"`
}

In the above code, apart from the GORM model, we also have a function called ValidateStruct, which we will use alongside the CreateNoteSchema and UpdateNoteSchema structs to validate the incoming requests for the CREATE and UPDATE operations.

Connect to the Database Server

Now that we’ve defined the GORM model, let’s create a utility function to establish a connection pool with the SQLite database and automatically migrate the GORM model to it. To do this, create a file named initializer.go in the root directory and add the following code.

initializer.go


package main

import (
	"log"

	"gorm.io/driver/sqlite"
	"gorm.io/gorm"
	"gorm.io/gorm/logger"
)

var DB *gorm.DB

func ConnectDB() error {
	var err error

	DB, err = gorm.Open(sqlite.Open("gorm.db"), &gorm.Config{})
	if err != nil {
		return err
	}

	DB.Logger = logger.Default.LogMode(logger.Info)

	log.Println("Running Migrations")
	err = DB.AutoMigrate(&Note{})
	if err != nil {
		return err
	}

	log.Println("🚀 Connected Successfully to the Database")
	return nil
}

Later, we’ll invoke the ConnectDB function to create the connection pool and apply the migrations, but for now, let’s focus on the CRUD operations in the next section.

Perform CRUD Operations

With the database-related code now completed, let’s start implementing the CRUD operations. To do this, we will create route handlers, also known as controllers, that will use the CRUD functions provided by the GORM library to perform operations against the database. Start by creating a new file named handlers.go in the root directory and include the following code imports and package definition:


package main

import (
	"encoding/json"
	"net/http"
	"strconv"
	"strings"
	"time"

	"gorm.io/gorm"
)

Perform CREATE Operation

Let’s start with the CREATE operation. Here, we will create a function called CreateNoteHandler. Within it, we will validate the incoming request using the CreateNoteSchema struct along with the validator package to ensure that the user includes the right data in the request body.

Next, we will store the data in the database and return a copy in JSON format to the user. To do this, add the following code to the handlers.go file.

handlers.go


func CreateNoteHandler(w http.ResponseWriter, r *http.Request) {
	var payload CreateNoteSchema

	// Decode JSON request body into the payload struct
	if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
		w.WriteHeader(http.StatusBadRequest)
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(map[string]interface{}{
			"status":  "fail",
			"message": err.Error(),
		})
		return
	}

	// Validate payload struct
	errors := ValidateStruct(&payload)
	if errors != nil {
		w.WriteHeader(http.StatusBadRequest)
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(errors)
		return
	}

	now := time.Now()
	newNote := Note{
		Title:     payload.Title,
		Content:   payload.Content,
		Category:  payload.Category,
		Published: payload.Published,
		CreatedAt: now,
		UpdatedAt: now,
	}

	// Save new note to the database
	result := DB.Create(&newNote)
	if result.Error != nil {
		if strings.Contains(result.Error.Error(), "UNIQUE constraint failed") {
			w.Header().Set("Content-Type", "application/json")
			w.WriteHeader(http.StatusConflict)
			json.NewEncoder(w).Encode(map[string]interface{}{
				"status":  "fail",
				"message": "Title already exists, please use another title",
			})
			return
		}
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusBadGateway)
		json.NewEncoder(w).Encode(map[string]interface{}{
			"status":  "error",
			"message": result.Error.Error(),
		})
		return
	}

	// Return success response
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusCreated)
	json.NewEncoder(w).Encode(map[string]interface{}{
		"status": "success",
		"data": map[string]interface{}{
			"note": newNote,
		},
	})
}

Perform READ Operation

Let’s proceed to handle the READ operation of CRUD. We have two READ operations to handle: one for retrieving a paginated list of items and the other for retrieving an item by a specific ID.

To begin, let’s address the first READ operation, which involves fetching multiple items. Add the following code to the handlers.go file.

handlers.go


func FindNotes(w http.ResponseWriter, r *http.Request) {
	page := r.URL.Query().Get("page")
	limit := r.URL.Query().Get("limit")

	if page == "" {
		page = "1"
	}
	if limit == "" {
		limit = "10"
	}

	intPage, err := strconv.Atoi(page)
	if err != nil {
		http.Error(w, "Invalid page parameter", http.StatusBadRequest)
		return
	}
	intLimit, err := strconv.Atoi(limit)
	if err != nil {
		http.Error(w, "Invalid limit parameter", http.StatusBadRequest)
		return
	}
	offset := (intPage - 1) * intLimit

	var notes []Note
	results := DB.Limit(intLimit).Offset(offset).Find(&notes)
	if results.Error != nil {
		http.Error(w, results.Error.Error(), http.StatusBadGateway)
		return
	}

	// Return success response
	response := map[string]interface{}{
		"status":  "success",
		"results": len(notes),
		"notes":   notes,
	}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(response)
}

In the above code snippet, we first retrieve the page and limit query parameters from the request URL. Then, we use their values to calculate the offset and limit for paginating the results returned by the database.

Now, let’s proceed to retrieve an item by its ID. Navigate to the handlers.go file and include the code below:

handlers.go


func FindNoteById(w http.ResponseWriter, r *http.Request) {
	noteID := r.PathValue("noteId")

	var note Note
	result := DB.First(&note, "id = ?", noteID)
	if err := result.Error; err != nil {
		if err == gorm.ErrRecordNotFound {
			w.Header().Set("Content-Type", "application/json")
			w.WriteHeader(http.StatusNotFound)
			response := map[string]interface{}{
				"status":  "fail",
				"message": "No note with that ID exists",
			}
			json.NewEncoder(w).Encode(response)
			return
		}
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusBadGateway)
		response := map[string]interface{}{
			"status":  "fail",
			"message": err.Error(),
		}
		json.NewEncoder(w).Encode(response)
		return
	}

	response := map[string]interface{}{
		"status": "success",
		"data": map[string]interface{}{
			"note": note,
		},
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(response)
}

Within the FindNoteById route handler, we first extract the ID of the record to be retrieved from the request URL. Then, we proceed to make a request to the database to fetch the item by the provided ID. If a record is found, we return it in JSON format. Otherwise, a 404 Not Found error will be returned to the user.

Perform UPDATE Operation

Moving on, let’s now handle the UPDATE operation. This one is quite challenging since we need a way to partially update the columns in the database, which we can represent with a PATCH HTTP method.

This means we only update the fields provided in the request body in the database instead of updating the entire record. So, still inside the handlers.go file, add the following code to it.

handlers.go


func UpdateNote(w http.ResponseWriter, r *http.Request) {
	noteID := r.PathValue("noteId")

	var payload UpdateNoteSchema
	err := json.NewDecoder(r.Body).Decode(&payload)
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

	var note Note
	result := DB.First(&note, "id = ?", noteID)
	if err := result.Error; err != nil {
		if err == gorm.ErrRecordNotFound {
			errorResponse := map[string]interface{}{
				"status":  "fail",
				"message": "No note with that ID exists",
			}
			w.Header().Set("Content-Type", "application/json")
			w.WriteHeader(http.StatusNotFound)
			json.NewEncoder(w).Encode(errorResponse)
			return
		}
		http.Error(w, err.Error(), http.StatusBadGateway)
		return
	}

	updates := make(map[string]interface{})
	if payload.Title != "" {
		updates["title"] = payload.Title
	}
	if payload.Category != "" {
		updates["category"] = payload.Category
	}
	if payload.Content != "" {
		updates["content"] = payload.Content
	}
	if payload.Published != nil {
		updates["published"] = payload.Published
	}
	updates["updated_at"] = time.Now()

	DB.Model(&note).Updates(updates)

	response := map[string]interface{}{
		"status": "success",
		"data": map[string]interface{}{
			"note": note,
		},
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(response)
}

Quite a lot is happening in the above code. Here’s a breakdown of the UpdateNote function:

  1. First, it extracts the note ID from the request URL.
  2. Then, it decodes the JSON request body into an UpdateNoteSchema struct to get the updated note data.
  3. Next, it queries the database to find the note with the given ID.
  4. If the note is not found, it returns a 404 Not Found response.
  5. If there’s any other error during the database operation, it returns a 502 Bad Gateway response.
  6. Next, it constructs a map of updates based on the fields provided in the request body.
  7. Then, it updates the note in the database with the provided data.
  8. Finally, it constructs a success response containing the updated note data and sends it back to the client as JSON.

Perform DELETE Operation

Let’s now handle the DELETE operation of CRUD. This implementation is quite simple, as we need to find the record by its ID and delete it from the database. To do this, open the handlers.go file and include the following code:

handlers.go


func DeleteNote(w http.ResponseWriter, r *http.Request) {
	noteID := r.PathValue("noteId")

	result := DB.Delete(&Note{}, "id = ?", noteID)

	if result.RowsAffected == 0 {
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusNotFound)
		response := map[string]interface{}{
			"status":  "fail",
			"message": "No note with that ID exists",
		}
		json.NewEncoder(w).Encode(response)
		return
	} else if result.Error != nil {
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusBadGateway)
		response := map[string]interface{}{
			"status":  "error",
			"message": result.Error.Error(),
		}
		json.NewEncoder(w).Encode(response)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	response := map[string]interface{}{
		"status":  "success",
		"message": "Note deleted successfully",
	}
	json.NewEncoder(w).Encode(response)
}

Create Routes for HTTP Handlers

Finally, let’s wire everything together in the main file by creating routes for the HTTP route handlers, connecting to the database, setting up CORS, and starting the HTTP server. Create a file named main.go in the root directory and include the following code:

main.go


package main

import (
	"encoding/json"
	"log"
	"net/http"
	"time"

	"github.com/rs/cors"
)

func init() {
	// Initialize database
	err := ConnectDB()
	if err != nil {
		log.Fatalf("Failed to connect to the database: %v", err)
	}
}

func main() {
	// Create Router
	router := http.NewServeMux()

	router.HandleFunc("GET /api/healthchecker", HealthCheckHandler)
	router.HandleFunc("PATCH /api/notes/{noteId}", UpdateNote)
	router.HandleFunc("GET /api/notes/{noteId}", FindNoteById)
	router.HandleFunc("DELETE /api/notes/{noteId}", DeleteNote)
	router.HandleFunc("POST /api/notes/", CreateNoteHandler)
	router.HandleFunc("GET /api/notes/", FindNotes)

	// Custom CORS configuration
	corsConfig := cors.New(cors.Options{
		AllowedHeaders:   []string{"Origin", "Authorization", "Accept", "Content-Type"},
		AllowedOrigins:   []string{"http://localhost:3000"},
		AllowedMethods:   []string{"GET", "POST", "PATCH", "DELETE"},
		AllowCredentials: true,
	})

	// Wrap the router with the logRequests middleware
	loggedRouter := logRequests(router)

	// Create a new CORS handler
	corsHandler := corsConfig.Handler(loggedRouter)

	server := http.Server{
		Addr:    ":8000",
		Handler: corsHandler,
	}

	log.Println("Starting server on port :8000")
	if err := server.ListenAndServe(); err != nil {
		log.Fatalf("Server error: %v", err)
	}
}

type wrappedWriter struct {
	http.ResponseWriter
	statusCode int
}

func (w *wrappedWriter) WriteHeader(statusCode int) {
	w.ResponseWriter.WriteHeader(statusCode)
	w.statusCode = statusCode
}

func logRequests(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()

		wrapped := &wrappedWriter{
			ResponseWriter: w,
			statusCode:     http.StatusOK,
		}

		next.ServeHTTP(wrapped, r)

		elapsed := time.Since(start)
		log.Printf("Received request: %d %s %s %s", wrapped.statusCode, r.Method, r.URL.Path, elapsed)
	})
}

func HealthCheckHandler(w http.ResponseWriter, r *http.Request) {
	response := map[string]string{
		"status":  "success",
		"message": "Welcome to Go standard library",
	}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(response)
}

Conclusion

And that brings us to the end. In this article, you learned how to create a CRUD API using the net/http package in Go without using any third-party routing library. I hope you found this article helpful and enjoyable. If you have any questions or feedback, feel free to leave them in the comment section below.