Menu Close

CRUD REST API with gorilla mux

A common use case for Go applications is building REST API web services. In this article, we’re looking into how to implement a REST API with create, read, update, and delete (CRUD) operations for a User entity. For HTTP routing we’re going to use the library gorilla/mux.

>>> Next post of the series: REST API with SQL DB in Go

REST API Features

In this post we’re going to build a REST API with following HTTP Endpoints and Methods:

  • /users
    • GET returns a list of all users
    • POST creates a new user from the request body
  • /users/{id}
    • GET returns a single user entity
    • PUT updates a single user entity
    • DELETE deletes a single user entity

Why gorilla/mux?

Go has an HTTP router included in its standard library, but this router only provides minimal routing capabilities. For example, for routing different HTTP methods on the same path to different handler functions, we would have to define a handler functions with a switch case reading the method from a request object and call the appropriate function for that method. With gorilla/mux we get some additional routing options, it provides following routing capabilities we’re going to use throughout this post:

  • HTTP Method based routing
  • Routing for path parameters e. g. /users/{id}
  • Routing for query parameters e.g. /users?sort=firstname (used in a follow up post)

Another benefit of using gorilla/mux instead of other libraries like go-restful is, gorilla/mux works with the http.Handler interface defined in the standard library. So an HTTP handler function has always the following signature:

func(w http.ResponseWriter, r *http.Request)

Because of this approach, you can use gorilla/mux well alongside many other libraries that make use of this interface.

Implement the Listener and Router

Now that we’ve discussed why we’re using gorilla/mux let’s look into how to actually implement the user REST API.

We start with declaring the user struct and registering some routes, that currently link to functions, that are not yet implemented.

main.go

package main

import (
	"fmt"
	"net/http"

	"github.com/gorilla/mux"
)

type User struct {
	ID        string `json:"id"`
	Lastname  string `json:"lastname"`
	Firstname string `json:"firstname"`
	Age       int    `json:"age"`
	Email     string `json:"email"`
}

var users = []User{}
var idCounter int

func main() {
	r := mux.NewRouter()
	usersR := r.PathPrefix("/users").Subrouter()
	usersR.Path("").Methods(http.MethodGet).HandlerFunc(getAllUsers)
	usersR.Path("").Methods(http.MethodPost).HandlerFunc(createUser)
	usersR.Path("/{id}").Methods(http.MethodGet).HandlerFunc(getUserByID)
	usersR.Path("/{id}").Methods(http.MethodPut).HandlerFunc(updateUser)
	usersR.Path("/{id}").Methods(http.MethodDelete).HandlerFunc(deleteUser)

	fmt.Println("Start listening")
	fmt.Println(http.ListenAndServe(":8080", r))
}

func getAllUsers(w http.ResponseWriter, r *http.Request) {
	fmt.Println("Not implemented")
}

func getUserByID(w http.ResponseWriter, r *http.Request) {
	fmt.Println("Not implemented")
}

func updateUser(w http.ResponseWriter, r *http.Request) {
	fmt.Println("Not implemented")
}

func deleteUser(w http.ResponseWriter, r *http.Request) {
	fmt.Println("Not implemented")
}

func createUser(w http.ResponseWriter, r *http.Request) {
	fmt.Println("Not implemented")
}

Let’s walk through this code step by step.

At first, we define the user struct, which is straightforward.

type User struct {
	ID        string `json:"id"`
	Lastname  string `json:"lastname"`
	Firstname string `json:"firstname"`
	Age       int    `json:"age"`
	Email     string `json:"email"`
}

Notice we have to define the fields with uppercase names because otherwise, the json package wouldn’t be able to Unmarshal/Marshal these. In JSON it’s idiomatic to use LowerCamelCase for field naming, so we define the actual JSON field names with the struct tags.

For simplicity, we store the users in-memory for now, so we define a users slice.

var users = []User{}

In one of the upcoming blog posts, I’ll change this to use a PostgreSQL database instead.

Now we start with creating a new gorilla/mux router and sub router for routes prefixed with /users.

	r := mux.NewRouter()
	usersR := r.PathPrefix("/users").Subrouter()

The SubRouter allows us to register all further routes associated with the prefix /users without typing it out again.

Next, we register the routes described in REST API Features and the appropriate functions to handle those requests.

	usersR.Path("").Methods(http.MethodGet).HandlerFunc(getAllUsers)
	usersR.Path("").Methods(http.MethodPost).HandlerFunc(createUser)
	usersR.Path("/{id}").Methods(http.MethodGet).HandlerFunc(getUserByID)
	usersR.Path("/{id}").Methods(http.MethodPut).HandlerFunc(updateUser)
	usersR.Path("/{id}").Methods(http.MethodDelete).HandlerFunc(deleteUser)

The /{id} routes indicate to gorilla/mux that this is a path parameter, which will be variable. Later on, we’ll see how to access such parameters.

Lastly, we start listening for HTTP requests on port 8080 using the created router r. Now that we’ve set up all routes and the server, we can start with implementing the handler functions.

GET all users

The handler to get all users is straightforward. We only set the response content type to application/json and write the slice marshaled as JSON to the ResponseWriter. Since there’s no user provided input to this methods an error when encoding the JSON could only occure due to an internal error. In that case we respond with a http.StatusInternalServerError.

func getAllUsers(w http.ResponseWriter, r *http.Request) {
	w.Header().Add("Content-Type", "application/json")

	if err := json.NewEncoder(w).Encode(users); err != nil {
		fmt.Println(err)
		http.Error(w, "Error encoding response object", http.StatusInternalServerError)
	}
}

Let’s test it:

go run main.go

# in another terminal
curl localhost:8080/users
# Output: []

POST a user

The GET works for the empty slice of users. Let’s implement the POST method handler to create some users and add them to the list.

func createUser(w http.ResponseWriter, r *http.Request) {
	u := User{}

	if err := json.NewDecoder(r.Body).Decode(&u); err != nil {
		fmt.Println(err)
		http.Error(w, "Error decoidng response object", http.StatusBadRequest)
		return
	}

	users = append(users, u)

	response, err := json.Marshal(&u)
	if err != nil {
		fmt.Println(err)
		http.Error(w, "Error encoding response object", http.StatusInternalServerError)
		return
	}

	w.Header().Add("Content-Type", "application/json")
	w.WriteHeader(http.StatusCreated)
	w.Write(response)
}

We’ll go through this code step by step:

First, we create an empty User object and fill the values by decoding the request body. If the decoding fails, the API responds with an http.StatusBadRequest (403) because if the Decode function returns an err, there was something wrong with the request body.

	u := User{}

	if err := json.NewDecoder(r.Body).Decode(&u); err != nil {
		fmt.Println(err)
		http.Error(w, "Error decoidng response object", http.StatusBadRequest)
		return
	}

Then we append the User object to the users slice.

	users = append(users, u)

After that, the handler function writes the new user to the ResponseWriter together with the HTTP status 201 Created.

	response, err := json.Marshal(&u)
	if err != nil {
		fmt.Println(err)
		http.Error(w, "Error encoding response object", http.StatusInternalServerError)
		return
	}

	w.Header().Add("Content-Type", "application/json")
	w.WriteHeader(http.StatusCreated)
	w.Write(response)
}

We don’t ever expect the json.Marshal to fail except something is wrong with the server, so in case of an error, we return HTTP 500 Internal Server Error.

Great, let’s create some users.

curl -XPOST localhost:8080/users -H 'application/json' -d \
 '{"id":"1","lastname":"Pu","firstname":"Kak","age":18,"email":"k@te.st"}'
curl -XPOST localhost:8080/users -H 'application/json' -d \
 '{"id":"2","lastname":"Faehrlich","firstname":"Sergey","age":24,"email":"s@te.st"}'
curl -XPOST localhost:8080/users -H 'application/json' -d \
 '{"id":"3","lastname":"Gator","firstname":"Ali","age":30,"email":"a@te.st"}'

Now we expect the GET method implemented previously to return the following output:

curl localhost:8080/users
# Output:
# [{"id":"1","lastname":"Pu","firstname":"Kak","age":18,"email":"k@te.st"},{"id":"2","lastname":"Faehrlich","firstname":"Sergey","age":24,"email":"s@te.st"},{"id":"3","lastname":"Gator","firstname":"Ali","age":30,"email":"a@te.st"}]

GET a single user

Here’s the code to return a single user on the URL path /users/{id}:

func indexByID(users []User, id string) int {
	for i := 0; i < len(users); i++ {
		if users[i].ID == id {
			return i
		}
	}

	return -1
}

func getUserByID(w http.ResponseWriter, r *http.Request) {
	id := mux.Vars(r)["id"]
	index := indexByID(users, id)

	if index < 0 {
		http.Error(w, "User not found", http.StatusNotFound)
		return
	}

	w.Header().Add("Content-Type", "application/json")
	if err := json.NewEncoder(w).Encode(users[index]); err != nil {
		fmt.Println(err)
		http.Error(w, "Error encoding response object", http.StatusInternalServerError)
	}
}

First of all, we define a function that loops over the slice of users and returns the index of the first user with a matching ID field.

func indexByID(users []User, id string) int {
	for i := 0; i < len(users); i++ {
		if users[i].ID == id {
			return i
		}
	}

	return -1
}

We’re going to use this function for the delete and update handlers as well.

Then we implement the actual handler, we start with getting the path parameter by calling mux.Vars. Then we use the indexByID function to get the index of the first matching user in the slice.

	id := mux.Vars(r)["id"]
	index := indexByID(users, id)

If no user in the users slice matches the id, we return an HTTP 404 Not Found status.

	if index < 0 {
		http.Error(w, "User not found", http.StatusNotFound)
		return
	}

Otherwise, the handler writes the matching user to the ResponseWriter.

	w.Header().Add("Content-Type", "application/json")
	if err := json.NewEncoder(w).Encode(users[index]); err != nil {
		fmt.Println(err)
		http.Error(w, "Error encoding response object", http.StatusInternalServerError)
	}

PUT (update) a user

This is the updateUser implementation:

func updateUser(w http.ResponseWriter, r *http.Request) {
	id := mux.Vars(r)["id"]
	index := indexByID(users, id)
	if index < 0 {
		http.Error(w, "User not found", http.StatusNotFound)
		return
	}

	u := User{}
	if err := json.NewDecoder(r.Body).Decode(&u); err != nil {
		fmt.Println(err)
		http.Error(w, "Error decoidng response object", http.StatusBadRequest)
		return
	}

	users[index] = u

	w.Header().Add("Content-Type", "application/json")
	if err := json.NewEncoder(w).Encode(&u); err != nil {
		fmt.Println(err)
		http.Error(w, "Error encoding response object", http.StatusInternalServerError)
	}
}

Similar to the GET method for a single user, we try to find the user by the given id and return an HTTP 404 if no user matches.

	id := mux.Vars(r)["id"]
	index := indexByID(users, id)
	if index < 0 {
		http.Error(w, "User not found", http.StatusNotFound)
		return
	}

Then we decode the request body to set the field of a user object:

	u := User{}
	if err := json.NewDecoder(r.Body).Decode(&u); err != nil {
		fmt.Println(err)
		http.Error(w, "Error decoidng response object", http.StatusBadRequest)
		return
	}

We once again return an HTTP 400 Bad Request as we expect the decoding to fail if the request body is invalid.

Now we set the matching users in the users slice to the new user object and write it as JSON to the ResponseWriter.

	users[index] = u

	w.Header().Add("Content-Type", "application/json")
	if err := json.NewEncoder(w).Encode(&u); err != nil {
		fmt.Println(err)
		http.Error(w, "Error encoding response object", http.StatusInternalServerError)
	}
}

DELETE a user

After implementing the other handlers deleting a user is straightforward. We search for the user in the users slice, return an HTTP 404 if no user matches, otherwise we remove the user from the slice.

func deleteUser(w http.ResponseWriter, r *http.Request) {
	id := mux.Vars(r)["id"]
	index := indexByID(users, id)
	if index < 0 {
		http.Error(w, "User not found", http.StatusNotFound)
		return
	}

	users = append(users[:index], users[index+1:]...)
	w.WriteHeader(http.StatusOK)
}

Use the API

By now the full code file should look like this:

main.go

package main

import (
	"encoding/json"
	"fmt"
	"net/http"

	"github.com/gorilla/mux"
)

type User struct {
	ID        string `json:"id"`
	Lastname  string `json:"lastname"`
	Firstname string `json:"firstname"`
	Age       int    `json:"age"`
	Email     string `json:"email"`
}

var users = []User{}

func main() {
	r := mux.NewRouter()
	usersR := r.PathPrefix("/users").Subrouter()
	usersR.Path("").Methods(http.MethodGet).HandlerFunc(getAllUsers)
	usersR.Path("").Methods(http.MethodPost).HandlerFunc(createUser)
	usersR.Path("/{id}").Methods(http.MethodGet).HandlerFunc(getUserByID)
	usersR.Path("/{id}").Methods(http.MethodPut).HandlerFunc(updateUser)
	usersR.Path("/{id}").Methods(http.MethodDelete).HandlerFunc(deleteUser)

	fmt.Println("Start listening")
	fmt.Println(http.ListenAndServe(":8080", r))
}

func getAllUsers(w http.ResponseWriter, r *http.Request) {
	w.Header().Add("Content-Type", "application/json")

	if err := json.NewEncoder(w).Encode(users); err != nil {
		fmt.Println(err)
		http.Error(w, "Error encoding response object", http.StatusInternalServerError)
	}
}

func getUserByID(w http.ResponseWriter, r *http.Request) {
	id := mux.Vars(r)["id"]
	index := indexByID(users, id)

	if index < 0 {
		http.Error(w, "User not found", http.StatusNotFound)
		return
	}

	w.Header().Add("Content-Type", "application/json")
	if err := json.NewEncoder(w).Encode(users[index]); err != nil {
		fmt.Println(err)
		http.Error(w, "Error encoding response object", http.StatusInternalServerError)
	}
}

func updateUser(w http.ResponseWriter, r *http.Request) {
	id := mux.Vars(r)["id"]
	index := indexByID(users, id)
	if index < 0 {
		http.Error(w, "User not found", http.StatusNotFound)
		return
	}

	u := User{}
	if err := json.NewDecoder(r.Body).Decode(&u); err != nil {
		fmt.Println(err)
		http.Error(w, "Error decoidng response object", http.StatusBadRequest)
		return
	}

	users[index] = u

	w.Header().Add("Content-Type", "application/json")
	if err := json.NewEncoder(w).Encode(&u); err != nil {
		fmt.Println(err)
		http.Error(w, "Error encoding response object", http.StatusInternalServerError)
	}
}

func deleteUser(w http.ResponseWriter, r *http.Request) {
	id := mux.Vars(r)["id"]
	index := indexByID(users, id)
	if index < 0 {
		http.Error(w, "User not found", http.StatusNotFound)
		return
	}

	users = append(users[:index], users[index+1:]...)
	w.WriteHeader(http.StatusOK)
}

func createUser(w http.ResponseWriter, r *http.Request) {
	u := User{}

	if err := json.NewDecoder(r.Body).Decode(&u); err != nil {
		fmt.Println(err)
		http.Error(w, "Error decoidng response object", http.StatusBadRequest)
		return
	}

	users = append(users, u)

	response, err := json.Marshal(&u)
	if err != nil {
		fmt.Println(err)
		http.Error(w, "Error encoding response object", http.StatusInternalServerError)
		return
	}

	w.Header().Add("Content-Type", "application/json")
	w.WriteHeader(http.StatusCreated)
	w.Write(response)
}

func indexByID(users []User, id string) int {
	for i := 0; i < len(users); i++ {
		if users[i].ID == id {
			return i
		}
	}

	return -1
}

Now that we’ve implemented the CRUD REST API, we’ll once again test all the endpoints.

Run the programm:

go run main.go

Create some users using HTTP POST with the path /users:

curl -XPOST localhost:8080/users -H 'application/json' -d \
 '{"id":"1","lastname":"Pu","firstname":"Kak","age":18,"email":"k@te.st"}'
curl -XPOST localhost:8080/users -H 'application/json' -d \
 '{"id":"2","lastname":"Faehrlich","firstname":"Sergey","age":24,"email":"s@te.st"}'
curl -XPOST localhost:8080/users -H 'application/json' -d \
 '{"id":"3","lastname":"Gator","firstname":"Ali","age":30,"email":"a@te.st"}'

Get the full user list using HTTP GET with the path /users:

curl localhost:8080/users
# [{"id":"1","lastname":"Puh","firstname":"Kak","age":18,"email":"k@te.st"},{"id":"2","lastname":"Faehrlich","firstname":"Sergey","age":24,"email":"s@te.st"},{"id":"3","lastname":"Gator","firstname":"Ali","age":30,"email":"a@te.st"}]

Get a single user using HTTP GET with the path /users/{id}:

curl localhost:8080/users/2
# {"id":"2","lastname":"Faehrlich","firstname":"Sergey","age":24,"email":"s@te.st"}

Try to get a single user that doesn’t exist:

curl -v localhost:8080/users/4
# ...
# < HTTP/1.1 404 Not Found
# < Date: Thu, 09 Jul 2020 20:03:04 GMT
# < Content-Length: 0
# ...

Update a user using HTTP PUT with the path /users/{id}:

curl -XPUT localhost:8080/users/1 -H "Content-Type: application/json" -d \ '{"id":"4","lastname":"Puh","firstname":"Kak","age":18,"email":"k@te.st"}'
# {"id":"4","lastname":"Puh","firstname":"Kak","age":18,"email":"k@te.st"}

Delete the updated user using HTTP DELETE with the path /users/{id}:

curl -XDELETE localhost:8080/users/4

Get the updated user list:

curl localhost:8080/users
# [{"id":"2","lastname":"Faehrlich","firstname":"Sergey","age":24,"email":"s@te.st"},{"id":"3","lastname":"Gator","firstname":"Ali","age":30,"email":"a@te.st"}]

Conclusion

This article shows how to build a CRUD REST API using gorilla/mux. Of course, this program is far from production readiness. But you can use it as a basis to build more complex systems. In the upcoming blog post, I’m going to extend this API with common features like SQL database integration, validation, authorization, automated testing, and many more.

>>> Next post of the series: REST API with SQL DB in Go

Leave a Reply

Your email address will not be published.