What is REST API?
An API, or application programming interface, is a set of rules that define how applications or devices can connect to and communicate with each other. A REST API is an API that conforms to the design principles of the REST, or representational state transfer architectural style. For this reason, REST APIs are sometimes referred to RESTful APIs.
Focus of this tutorial is to write a REST API with Go.
Movie Resource
We will be managing a Movie
resource with the current project. It is not an accurate representation of how you would model a movie resource in an actual system, just a mix of a few basic types and how to handle them in a REST API.
Field | Type |
---|---|
ID | UUID |
Title | String |
Director | String |
Director | String |
ReleaseDate | Time |
TicketPrice | float64 |
Project Setup
Create a folder for project, I named it as movies-api-with-go-chi-and-memory-store
but it usually will be the root of the GitHub repo so you can name it appropriately e.g. movies-api
.
Execute following command to initialise go.mod
on terminal
go mod init github.com/kashifsoofi/blog-code-samples/movies-api-with-go-chi-and-memory-store
Add a new file main.go
with following content to start with
package main
func main() {
println("Hello, World!")
}
Project Structure
I like to add sub-packages to group related functionality together. To that extent I will be adding 3 root-level folders and 1 sub-folder in the store
folder. Folder structure will be as follows (not showing files).
.
└── movies-api-with-go-chi-and-memory-store/
├── api - this will contain rest routes, handlers etc.
├── config - this will contain anything related to service configuration
└── store/ - this will contain store interface
I have only 2 resources, health
and movies
, however if you are serving more resources in a single rest service, feel free to add a folder per resource under api
. Same for the store
if you are adding multiple stores
e.g. Postgres
and a Redis
cache to check before hitting Postgres
then feel free to add specific folders for each store.
Configuration
Add a folder named config
and a file named config.go
. I like to keep all application configuration in a single place and will be using excellent envconfig
package to load the configuration, also setting some default values for options. This package allows us to load application configuration from Environment Variables, same thing can be done with standard Go packages but in my opinion this package provides nice abstraction without losing readability.
package config
import (
"time"
"github.com/kelseyhightower/envconfig"
)
const envPrefix = ""
type Configuration struct {
HTTPServer
}
type HTTPServer struct {
IdleTimeout time.Duration `envconfig:"HTTP_SERVER_IDLE_TIMEOUT" default:"60s"`
Port int `envconfig:"PORT" default:"8080"`
ReadTimeout time.Duration `envconfig:"HTTP_SERVER_READ_TIMEOUT" default:"1s"`
WriteTimeout time.Duration `envconfig:"HTTP_SERVER_WRITE_TIMEOUT" default:"2s"`
}
func Load() (Configuration, error) {
var cfg Configuration
err := envconfig.Process(envPrefix, &cfg)
if err != nil {
return cfg, err
}
return cfg, nil
}
This would result in an error that can be resolved by executing following on terminal.
go mod tidy
You can improve on it by converting Configuration
to an interface
and then adding a configuration to each of sub-packages e.g. api
, store
etc.
Configuration can be updated using environment variables e.g. executing following on terminal would start the server on port 5000 after we update main.go
to start the server.
PORT=5000 go run main.go
Movie Store Interface
Add a new folder named store
and a file named movie_store.go
. We will add an interface for our movie store and supporting structs.
package store
import (
"time"
"github.com/google/uuid"
)
type Movie struct {
ID uuid.UUID
Title string
Director string
ReleaseDate time.Time
TicketPrice float64
CreatedAt time.Time
UpdatedAt time.Time
}
type CreateMovieParams struct {
ID uuid.UUID
Title string
Director string
ReleaseDate time.Time
TicketPrice float64
}
type UpdateMovieParams struct {
Title string
Director string
ReleaseDate time.Time
TicketPrice float64
}
type Interface interface {
GetAll() ([]Movie, error)
GetByID(id uuid.UUID) (Movie, error)
Create(createMovieParams CreateMovieParams) error
Update(id uuid.UUID, updateMovieParams UpdateMovieParams) error
Delete(id uuid.UUID) error
}
Also add a file for custom application errors named errors.go
, these make clients of our store package agnostic of storage technology used, our storage implementation would translate any native errors to our business errors before returning to clients.
package store
import (
"fmt"
"github.com/google/uuid"
)
type DuplicateKeyError struct {
ID uuid.UUID
}
func (e *DuplicateKeyError) Error() string {
return fmt.Sprintf("duplicate movie id: %v", e.ID)
}
type RecordNotFoundError struct{}
func (e *RecordNotFoundError) Error() string {
return "record not found"
}
MemoryMoviesStore
Add a new file named memory_movies_store.go
in store
folder. Add a struct MemoryMoviesStore
with a map field to store movies in memory. Also add a RWMutex
field to avoid concurrent read/write access to movies field.
We will implement all methods defined for store.Interface
to add/remove movies to map field of the MemoryMoviesStore
struct. For reading we lock the collection for reading, read the result and release the lock using defer
. For writing we acquire a write lock instead of a read lock.
package store
import (
"sync"
"time"
"github.com/google/uuid"
)
type MemoryMoviesStore struct {
movies map[uuid.UUID]Movie
mu sync.RWMutex
}
func NewMemoryMoviesStore() *MemoryMoviesStore {
return &MemoryMoviesStore{
movies: map[uuid.UUID]Movie{},
}
}
func (s *MemoryMoviesStore) GetAll() ([]Movie, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var movies []Movie
for _, m := range s.movies {
movies = append(movies, m)
}
return movies, nil
}
func (s *MemoryMoviesStore) GetByID(id uuid.UUID) (Movie, error) {
s.mu.RLock()
defer s.mu.RUnlock()
m, ok := s.movies[id]
if !ok {
return Movie{}, &RecordNotFoundError{}
}
return m, nil
}
func (s *MemoryMoviesStore) Create(createMovieParams CreateMovieParams) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.movies[createMovieParams.ID]; ok {
return &DuplicateKeyError{ID: createMovieParams.ID}
}
movie := Movie{
ID: createMovieParams.ID,
Title: createMovieParams.Title,
Director: createMovieParams.Director,
ReleaseDate: createMovieParams.ReleaseDate,
TicketPrice: createMovieParams.TicketPrice,
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
s.movies[movie.ID] = movie
return nil
}
func (s *MemoryMoviesStore) Update(id uuid.UUID, updateMovieParams UpdateMovieParams) error {
s.mu.Lock()
defer s.mu.Unlock()
m, ok := s.movies[id]
if !ok {
return &RecordNotFoundError{}
}
m.Title = updateMovieParams.Title
m.Director = updateMovieParams.Director
m.ReleaseDate = updateMovieParams.ReleaseDate
m.TicketPrice = updateMovieParams.TicketPrice
m.UpdatedAt = time.Now().UTC()
s.movies[id] = m
return nil
}
func (s *MemoryMoviesStore) Delete(id uuid.UUID) error {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.movies, id)
return nil
}
REST Server
Add a new folder to add all REST api server related files. Let's start by adding server.go
file and add a struct to represent REST server. This struct would have an instance of configuration required to run server, routes and all the dependencies. Also add method to start the server.
For routes we would use excellent chi
router, that is a ligtweight, idomatic and composable router for building HTTP services.
In start method, we will construct an instance of Server
provided by standard net/http
package, providing chi mux
we setup in NewServer
method. We will then setup a method for graceful shutdown and call ListenAndServe
to start our REST server.
package api
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"github.com/kashifsoofi/blog-code-samples/movies-api-with-go-chi-and-memory-store/config"
"github.com/kashifsoofi/blog-code-samples/movies-api-with-go-chi-and-memory-store/store"
"github.com/go-chi/chi/v5"
)
type Server struct {
cfg config.HTTPServer
store store.Interface
router *chi.Mux
}
func NewServer(cfg config.HTTPServer, store store.Interface) *Server {
srv := &Server{
cfg: cfg,
store: store,
router: chi.NewRouter(),
}
srv.routes()
return srv
}
func (s *Server) Start(ctx context.Context) {
server := http.Server{
Addr: fmt.Sprintf(":%d", s.cfg.Port),
Handler: s.router,
IdleTimeout: s.cfg.IdleTimeout,
ReadTimeout: s.cfg.ReadTimeout,
WriteTimeout: s.cfg.WriteTimeout,
}
shutdownComplete := handleShutdown(func() {
if err := server.Shutdown(ctx); err != nil {
log.Printf("server.Shutdown failed: %v\n", err)
}
})
if err := server.ListenAndServe(); err == http.ErrServerClosed {
<-shutdownComplete
} else {
log.Printf("http.ListenAndServe failed: %v\n", err)
}
log.Println("Shutdown gracefully")
}
func handleShutdown(onShutdownSignal func()) <-chan struct{} {
shutdown := make(chan struct{})
go func() {
shutdownSignal := make(chan os.Signal, 1)
signal.Notify(shutdownSignal, os.Interrupt, syscall.SIGTERM)
<-shutdownSignal
onShutdownSignal()
close(shutdown)
}()
return shutdown
}
Custom API Errors
We would define any custom errors that are returned by our REST server in errors.go
file under api
folder. I have gone ahead and added all the errors I need to return from this service in the file. But practically we would start with the most common ones and then add any new when the need arise.
package api
import (
"net/http"
"github.com/go-chi/render"
)
type ErrResponse struct {
Err error `json:"-"` // low-level runtime error
HTTPStatusCode int `json:"-"` // http response status code
StatusText string `json:"status"` // user-level status message
AppCode int64 `json:"code,omitempty"` // application-specific error code
ErrorText string `json:"error,omitempty"` // application-level error message, for debugging
}
func (e *ErrResponse) Render(w http.ResponseWriter, r *http.Request) error {
render.Status(r, e.HTTPStatusCode)
return nil
}
var (
ErrNotFound = &ErrResponse{HTTPStatusCode: 404, StatusText: "Resource not found."}
ErrBadRequest = &ErrResponse{HTTPStatusCode: 400, StatusText: "Bad request"}
ErrInternalServerError = &ErrResponse{HTTPStatusCode: 500, StatusText: "Internal Server Error"}
)
func ErrConflict(err error) render.Renderer {
return &ErrResponse{
Err: err,
HTTPStatusCode: 409,
StatusText: "Duplicate Key",
ErrorText: err.Error(),
}
}
Routes
I like the keep all the routes served by a service in a single place and single file named routes.go
. Its easier to remember and eases the cognitive overload.
routes
method hangs off our Server
struct, defines all the endpoints on the router
field. I have defined a /health
endpoint that would return current health status of this service. Then added a subrouter group for movies. This can help us having middlewared applied only for /api/movies
routes e.g. authentication, request logging.
package api
import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
)
func (s *Server) routes() {
s.router.Use(render.SetContentType(render.ContentTypeJSON))
s.router.Get("/health", s.handleGetHealth)
s.router.Route("/api/movies", func(r chi.Router) {
r.Get("/", s.handleListMovies)
r.Post("/", s.handleCreateMovie)
r.Route("/{id}", func(r chi.Router) {
r.Get("/", s.handleGetMovie)
r.Put("/", s.handleUpdateMovie)
r.Delete("/", s.handleDeleteMovie)
})
})
}
Please note all handlers hang off the Server
struct, this helps to access required dependencies in each of the handlers. If there are multiple resources in a service, it might make sense to add separate structs
per resource containing only dependencies required by that resource.
Health Endpoint Handler
I have added a separate file for health
resource. It has a handler for a single endpoint, a struct that we would send as response and implementation of Renderer
interface for our response struct.
package api
import (
"net/http"
"github.com/go-chi/render"
)
type healthResponse struct {
OK bool `json:"ok"`
}
func (hr healthResponse) Render(w http.ResponseWriter, r *http.Request) error {
return nil
}
func (s *Server) handleGetHealth(w http.ResponseWriter, r *http.Request) {
health := healthResponse{OK: true}
render.Render(w, r, health)
}
Movies Endpoints Handlers
Get Movie By ID
Let's start by adding a struct we would use to return a Movie
to the caller of our REST service and also implement Renderer
interface so that we can use Render
method to return data.
type movieResponse struct {
ID uuid.UUID `json:"id"`
Title string `json:"title"`
Director string `json:"director"`
ReleaseDate time.Time `json:"release_date"`
TicketPrice float64 `json:"ticket_price"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func NewMovieResponse(m store.Movie) movieResponse {
return movieResponse{
ID: m.ID,
Title: m.Title,
Director: m.Director,
ReleaseDate: m.ReleaseDate,
TicketPrice: m.TicketPrice,
CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt,
}
}
func (hr movieResponse) Render(w http.ResponseWriter, r *http.Request) error {
return nil
}
I like to keep structs
closer to the method/package using those. It does lead to some code duplication e.g. in this case movieResponse
is quite similar to Movie
struct defind in store/movies_store.go
file but this allows rest package to be not completely dependent on store
package, we can have different tags e.g. db specific tags in store
struct but not in movieResponse
struct.
Now comes the handler, we receive a ResponseWriter
and a Request
, we extract id
parameter from path using URLParam
method, if parsing fails we rendera BadRequest
.
Then we proceed to get movie
from store
if a record is not found in store with given id
we render NotFound
, if the error returned is not the one we defined in our store package then we render an InternalServerError
, we can add more custom/known errors to store and translate to apprpriate HTTP Status Codes depending on the use case.
If everything works then we convert the store.Movie
to movieResponse
and render the result. Result would be returned to the caller as json
response body.
func (s *Server) handleGetMovie(w http.ResponseWriter, r *http.Request) {
idParam := chi.URLParam(r, "id")
id, err := uuid.Parse(idParam)
if err != nil {
render.Render(w, r, ErrBadRequest)
return
}
movie, err := s.store.GetByID(id)
if err != nil {
var rnfErr *store.RecordNotFoundError
if errors.As(err, &rnfErr) {
render.Render(w, r, ErrNotFound)
} else {
render.Render(w, r, ErrInternalServerError)
}
return
}
mr := NewMovieResponse(movie)
render.Render(w, r, mr)
}
Get All/List Movies
For response we would use the same movieResponse
struct we defined for Get By ID
, we would just add a new method to create an array/slice of Renderer
func (s *Server) handleListMovies(w http.ResponseWriter, r *http.Request) {
movies, err := s.store.GetAll()
if err != nil {
render.Render(w, r, ErrInternalServerError)
return
}
render.RenderList(w, r, NewMovieListResponse(movies))
}
And the handler method is quite simple, we call GetAll
, if error return InternalServerError
else return list of movies.
func (s *Server) handleListMovies() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
movies, err := s.store.GetAll()
if err != nil {
render.Render(w, r, ErrInternalServerError)
return
}
render.RenderList(w, r, NewMovieListResponse(movies))
}
}
Create Movie
Same as get, we would start by adding a new struct to receive parameters required to create a new movie. But instead of implmenting Renderer
we would implement Binder
interface, in Bind
method custom mapping can be done if required e.g. adding meta data or setting a CreatedBy
fields from JWT
token.
Please note we don't have CreatedAt
and UpdatedAt
in this struct.
type CreateMovieRequest struct {
ID string `json:"id"`
Title string `json:"title"`
Director string `json:"director"`
ReleaseDate time.Time `json:"release_date"`
TicketPrice float64 `json:"ticket_price"`
}
func (mr *createMovieRequest) Bind(r *http.Request) error {
return nil
}
In handler we bind request body to our struct, if Bind
is successful then convert it to CreateMovieParams
struct expected by store.Create
method and call Create
method to add movie to data store. If there is a duplicate key error we return 409 Conflict
for unknown errors we return 500 InternalServerError
and if all is successful we are returning 200 OK
.
func (s *Server) handleCreateMovie(w http.ResponseWriter, r *http.Request) {
data := &CreateMovieRequest{}
if err := render.Bind(r, data); err != nil {
render.Render(w, r, ErrBadRequest)
return
}
createMovieParams := store.CreateMovieParams{
ID: uuid.MustParse(data.ID),
Title: data.Title,
Director: data.Director,
ReleaseDate: data.ReleaseDate,
TicketPrice: data.TicketPrice,
}
err := s.store.Create(createMovieParams)
if err != nil {
var dupKeyErr *store.DuplicateKeyError
if errors.As(err, &dupKeyErr) {
render.Render(w, r, ErrConflict(err))
} else {
render.Render(w, r, ErrInternalServerError)
}
return
}
w.WriteHeader(200)
w.Write(nil)
}
Update Movie
Same as Create Movie
above, we introduced a new struct updateMovieRequest
to receive parameters required to update movie and implemeted Binder
interface for the struct.
type updateMovieRequest struct {
Title string `json:"title"`
Director string `json:"director"`
ReleaseDate time.Time `json:"release_date"`
TicketPrice float64 `json:"ticket_price"`
}
func (mr *updateMovieRequest) Bind(r *http.Request) error {
return nil
}
In hander we read the id
from path, then we bind the struct from request body. If no errors then we convert the request to store.UpdateMovieParams
and call Update
method of store to update movie. We return 200 OK
if upate is successful.
func (s *Server) handleUpdateMovie(w http.ResponseWriter, r *http.Request) {
idParam := chi.URLParam(r, "id")
id, err := uuid.Parse(idParam)
if err != nil {
render.Render(w, r, ErrBadRequest)
return
}
data := &updateMovieRequest{}
if err := render.Bind(r, data); err != nil {
render.Render(w, r, ErrBadRequest)
return
}
updateMovieParams := store.UpdateMovieParams{
Title: data.Title,
Director: data.Director,
ReleaseDate: data.ReleaseDate,
TicketPrice: data.TicketPrice,
}
err = s.store.Update(id, updateMovieParams)
if err != nil {
var rnfErr *store.RecordNotFoundError
if errors.As(err, &rnfErr) {
render.Render(w, r, ErrNotFound)
} else {
render.Render(w, r, ErrInternalServerError)
}
return
}
w.WriteHeader(200)
w.Write(nil)
}
Delete Movie
This probably is the simplest handler as it does not need any Renderer
or Binder
, we simply get id
from the path, and call Delete
method of store to delete the resource. If delete is successful we return 200 OK
.
func (s *Server) handleDeleteMovie(w http.ResponseWriter, r *http.Request) {
idParam := chi.URLParam(r, "id")
id, err := uuid.Parse(idParam)
if err != nil {
render.Render(w, r, ErrBadRequest)
return
}
err = s.store.Delete(id)
if err != nil {
var rnfErr *store.RecordNotFoundError
if errors.As(err, &rnfErr) {
render.Render(w, r, ErrNotFound)
} else {
render.Render(w, r, ErrInternalServerError)
}
return
}
w.WriteHeader(200)
w.Write(nil)
}
Start Server
Now everything is setup, its time to update main
method. Start by laoding the configuration, then create an instance of the MemoryMoviesStore
, here we can also instantiate any other dependencies our server is dependent on. Next step is to create an instance of api.Server
struct and call the Start
method to start the server. Server would start listening on the configured port and you can invoke endpoints using curl
or Postman
.
package main
import (
"context"
"log"
"github.com/kashifsoofi/blog-code-samples/movies-api-with-go-chi-and-memory-store/api"
"github.com/kashifsoofi/blog-code-samples/movies-api-with-go-chi-and-memory-store/config"
"github.com/kashifsoofi/blog-code-samples/movies-api-with-go-chi-and-memory-store/store"
)
func main() {
ctx := context.Background()
cfg, err := config.Load()
if err != nil {
log.Fatal(err)
}
store := store.NewMemoryMoviesStore()
server := api.NewServer(cfg.HTTPServer, store)
server.Start(ctx)
}
Testing
I am going to list steps to manually test the api endpoints, as we don't have Swagger UI
or any other UI to interact with this, Postman
can be used to test the endpoints as well.
- Start Server executing following
go run main.go
Execute following tests in order, remember to update the port if you are running on a different port than 8080.
NOTE: I have not added
created_at
andupdated_at
fields in responses below.
Tests
Get All returns empty list
Request
curl --request GET --url "http://localhost:8080/api/movies"
Expected Response
[]
Get By ID should return Not Found
Request
curl --request GET --url "http://localhost:8080/api/movies/1"
Expected Response
[]
Get By ID should return Not Found
Request
curl --request GET --url "http://localhost:8080/api/movies/1"
Expected Response
[]
Get By ID should return Not Found
Request
curl --request GET --url "http://localhost:8080/api/movies/1"
Expected Response
[]
Get By ID with invalid id
Request
curl --request GET --url "http://localhost:8080/api/movies/1"
Expected Response
{"status":"Bad request"}
Get by ID with non-existent record
Request
curl --request GET --url "http://localhost:8080/api/movies/98268a96-a6ac-444f-852a-c6472129aa22"
Expected Response
{"status":"Resource not found."}
Create Movie
Request
curl --request POST --data '{ "id": "98268a96-a6ac-444f-852a-c6472129aa22", "title": "Star Wars: Episode I – The Phantom Menace", "director": "George Lucas", "release_date": "1999-05-16T01:01:01.00Z", "ticket_price": 10.70 }' --url "http://localhost:8080/api/movies"
Expected Response
Create Movie with existing ID
Request
curl --request POST --data '{ "id": "98268a96-a6ac-444f-852a-c6472129aa22", "title": "Star Wars: Episode I – The Phantom Menace", "director": "George Lucas", "release_date": "1999-05-16T01:01:01.00Z", "ticket_price": 10.70 }' --url "http://localhost:8080/api/movies"
Expected Response
{"status":"Duplicate Key","error":"duplicate movie id: 98268a96-a6ac-444f-852a-c6472129aa22"}
Get ALL Movies
Request
curl --request GET --url "http://localhost:8080/api/movies"
Expected Response
[{"id":"98268a96-a6ac-444f-852a-c6472129aa22","title":"Star Wars: Episode I – The Phantom Menace","director":"George Lucas","release_date":"1999-05-16T01:01:01Z","ticket_price":10.7}]
Get Movie By ID
Request
curl --request GET --url "http://localhost:8080/api/movies/98268a96-a6ac-444f-852a-c6472129aa22"
Expected Response
{"id":"98268a96-a6ac-444f-852a-c6472129aa22","title":"Star Wars: Episode I – The Phantom Menace","director":"George Lucas","release_date":"1999-05-16T01:01:01Z","ticket_price":10.7}
Update Movie
Request
curl --request PUT --data '{ "title": "Star Wars: Episode I – The Phantom Menace", "director": "George Lucas", "release_date": "1999-05-16T01:01:01.00Z", "ticket_price": 20.70 }' --url "http://localhost:8080/api/movies/98268a96-a6ac-444f-852a-c6472129aa22"
Expected Response
Get Movie by ID - get updated record
Request
curl --request GET --url "http://localhost:8080/api/movies/98268a96-a6ac-444f-852a-c6472129aa22"
Expected Response
{"id":"98268a96-a6ac-444f-852a-c6472129aa22","title":"Star Wars: Episode I – The Phantom Menace","director":"George Lucas","release_date":"1999-05-16T01:01:01Z","ticket_price":20.7}
Delete Movie
Request
curl --request DELETE --url "http://localhost:8080/api/movies/98268a96-a6ac-444f-852a-c6472129aa22"
Expected Response
Get Movie By ID - deleted record
Request
curl --request GET --url "http://localhost:8080/api/movies/98268a96-a6ac-444f-852a-c6472129aa22"
Expected Response
{"status":"Resource not found."}
Source
Source code for the demo application is hosted on GitHub in blog-code-samples repository.
References
In no particular order
- What is a REST API?
- envconfig
- chi
- Special thanks to members of Gophers[gophers.slack.com] for review
- And many more
Top comments (0)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.