How to write a REST API in Go with DI
This tutorial shows how to use sarulabs/di to create a rest api in go (golang). DI is a dependency injection framework for go programs. Its role is to handle the life cycle of the services in your application. If you want to learn more about dependency injection, you can check out my previous post: what is a dependency injection container and why use one.
This post is a good complement to the sarulabs/di documentation. It assumes that you are already familiar with go and the creation of a basic http server. The code of the api is available on github: sarulabs/di-example. Most of the code is explained here, but you still will need to go to the github repository if you want to see the whole thing.
API description
The role of the api is to manage a list a car. The cars are stored in mongodb. It simplifies things for this tutorial because it is schemaless and the database is automatically created the first time it is used. It would be easy to switch to postgres or another database.
The api implements the following basic CRUD operations:
Method | URL | Role | JSON Body example |
---|---|---|---|
GET | /cars | List the cars | |
POST | /cars | Insert a car | {"brand": "audi", "color": "black"} |
GET | /cars/{id} | Get a car | |
PUT | /cars/{id} | Update a car | {"brand": "audi", "color": "black"} |
DELETE | /cars/{id} | Delete a car |
The requests and responses bodies are encoded in json. The api handles the following error codes:
400 - Bad Request
: the parameters of the request are not valid404 - Not Found
: the car does not exist500 - Internal Error
: an unexpected error occurred (eg: the database connection failed)
The api can be tested with curl
or applications like postman. The github repository includes a docker-compose file that can be used to start the api without much effort.
Project structure
The project structure is as simple as this:
1
2
3
4
5
6
7
8
9
10
11
12
├─ app
│ ├─ handlers
│ ├─ middlewares
│ └─ models
│ ├─ garage
│ └─ helpers
│
├─ config
│ ├─ logging
│ └─ services
│
└─ main.go
The main.go
file is the entrypoint of the application. Its role is to create a web server that can handle the api routes.
The app/handler
and app/middlewares
directories are, as their names say, where the handlers and the middlewares of the application are defined. They represent the controller part of an MVC application, nothing more.
app/models/garage
contains the business logic. Put another way, it defines what is a car and how to manage them.
app/models/helpers
consists of functions that can assist the handlers. The ReadJSONBody
function can decode the body of an http request, and the JSONResponse
function can write a json response. The package also include two errors type: ErrValidation
and ErrNotFound
. They are used to facilitate the error handling in the http handlers.
In the config/logging
directory, a logger is defined as a global variable. The logger is a special object. That is because you need to have a logger as soon as possible in your application. And you also want to keep it until the application stops.
config/services
is where you can find the service definitions for the dependency injection container. They describe how the services are created, and how they should be closed.
Model
The model is where you can find the business logic of the application. In our case, the model should be able to handle CRUD operations for cars. In the models/garage
package, you can find the following elements:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
type Car struct {
ID string `json:"id" bson:"_id"`
Brand string `json:"brand" bson:"brand"`
Color string `json:"color" bson:"color"`
}
func ValidateCar(car *Car) error
type CarManagerInterface interface {
GetAll() ([]*Car, error)
Get(id string) (*Car, error)
Create(car *Car) (*Car, error)
Update(id string, car *Car) (*Car, error)
Delete(id string) error
}
type CarRepositoryInterface interface {
FindAll() ([]*Car, error)
FindByID(id string) (*Car, error)
Insert(car *Car) error
Update(car *Car) error
Delete(id string) error
IsNotFoundErr(err error) bool
IsAlreadyExistErr(err error) bool
}
Car
is the structure saved in the database. The structure is also used in the requests and responses. It represents a very simple car with only two fields, a brand and a color. The ValidateCar
function can check if the brand and the color are valid. If the combination is not allowed, it returns a validation error explaining what is wrong with the given car. It is used in the creation and the update of a car. You can find the code of the function in car.go.
There are two more structures in the model. A CarManager
and a CarRepository
. Their interfaces CarManagerInterface
and CarRepositoryInterface
do not exist in the application code but are given here to illustrate what they do.
The CarManager
is the structure used by the handlers to execute the CRUD operations. There is one method for each operation, or in other words, one for each http handler. The CarManager
needs a CarRepository
to execute the mongo queries.
Separating the database queries in a repository allows to easily list all the interactions with the database. In this situation, it is easy to replace the database. For example you can create another repository using postgres instead of mongo. It also gives you the opportunity to create a mock repository for your tests.
The CarManager
adds parameter validation, logging and some error management to the repository code. Is is defined in carManager.go.
The CarRepository
is just a wrapper around the mongo queries. The code is in carRepository.go and is really straightforward.
Services
The services package is where DI comes into play. You declare how to instantiate the structures from the model, as well as their dependencies. It allows the creation of a dependency injection container that can be used to retrieve the objects.
Actually, in our api, the handlers only need a CarManager
to handle the requests. But it should be created with its dependencies:
1
2
3
4
CarManager
├─ Logger
└─ CarRepository
└─ Mongo connection
The service definitions can be found in config/services/services.go. Let’s start with the CarManager
dependencies.
The logger is a global variable in the logging
package. It could be used directly in the CarManager
definition. But it still can be a good idea to create a service for the logger. It brings more homogeneity and allows the logger to be retrieved directly from the container.
The declaration is as follow:
1
2
3
4
5
6
7
8
9
10
var Services = []di.Def{
{
Name: "logger",
Scope: di.App,
Build: func(ctn di.Container) (interface{}, error) {
return logging.Logger, nil
},
},
// other services
}
The logger is in the App
scope. It means it is only created once for the whole application. The Build function is called the first time to retrieve the service. After that, the same object is returned when the service is requested again.
Now we need a mongo connection. What we want first, is a pool of connections. Then each http request will use that pool to retrieve its own connection.
So we will create two services. mongo-pool
in the App
scope, and mongo
in the Request
scope:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
{
Name: "mongo-pool",
Scope: di.App,
Build: func(ctn di.Container) (interface{}, error) {
// create a *mgo.Session
return mgo.DialWithTimeout(os.Getenv("MONGO_URL"), 5*time.Second)
},
Close: func(obj interface{}) error {
// close the *mgo.Session, it should be cast first
obj.(*mgo.Session).Close()
return nil
},
},
{
Name: "mongo",
Scope: di.Request,
Build: func(ctn di.Container) (interface{}, error) {
// get the pool of connections (*mgo.Session) from the container
// and retrieve a connection thanks to the Copy method
return ctn.Get("mongo-pool").(*mgo.Session).Copy(), nil
},
Close: func(obj interface{}) error {
// close the *mgo.Session, it should be cast first
obj.(*mgo.Session).Close()
return nil
},
},
The mongo
service is created in each request. It uses the mongo-pool
service to retrieve the connection. The mongo
service can use the mongo-pool
service in the Build function thanks to the Get
method of the Container.
Note that it is also important to close the mongo connection in both cases. This can be done using the Close
field of the definition. The Close function is called when the container is deleted. It happens at the end of each http request for the Request containers, and when the program stops for the App container.
Next is the CarRepository
. It depends on the mongo
service. As the mongo connection is in the Request
scope, the CarRepository
can not be in the App
scope. It should be in the Request
scope as well.
1
2
3
4
5
6
7
8
9
{
Name: "car-repository",
Scope: di.Request,
Build: func(ctn di.Container) (interface{}, error) {
return &garage.CarRepository{
Session: ctn.Get("mongo").(*mgo.Session),
}, nil
},
},
Finally, we can write the CarManager
definition. In the same way as the CarRepository
, the CarManager
should be in the Request
scope because of its dependencies.
1
2
3
4
5
6
7
8
9
10
{
Name: "car-manager",
Scope: di.Request,
Build: func(ctn di.Container) (interface{}, error) {
return &garage.CarManager{
Repo: ctn.Get("car-repository").(*garage.CarRepository),
Logger: ctn.Get("logger").(*zap.Logger),
}, nil
},
},
Based on these definitions, the dependency injection container can be created in the main.go
file.
Handlers
The role of an http handler is simple. It must parse the incoming request, retrieve and call the suitable service and write the formatted response. All the handlers are more or less the same and can be found in cars.go. For example the GetCarHandler
looks like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func GetCarHandler(w http.ResponseWriter, r *http.Request) {
id := mux.Vars(r)["carId"]
car, err := di.Get(r, "car-manager").(*garage.CarManager).Get(id)
if err == nil {
helpers.JSONResponse(w, 200, car)
return
}
switch e := err.(type) {
case *helpers.ErrNotFound:
helpers.JSONResponse(w, 404, map[string]interface{}{
"error": e.Error(),
})
default:
helpers.JSONResponse(w, 500, map[string]interface{}{
"error": "Internal Error",
})
}
}
mux.Vars
is just the way to retrieve the carId
parameter from the url with gorilla/mux, the routing library that has been used for this project.
The purpose of the end of this code snippet is to format and write the response depending on what happened in the CarManager
. The type switch allows to have different responses depending on the nature of the error.
The interesting part of the handler, is how the CarManager
is retrieved from the dependency injection container. It is done with di.Get(r, "car-manager")
. For this to work, the container should be included in the http.Request. You have to use a middleware to achieve that.
Middlewares
The api uses two middlewares. The first one is the PanicRecoveryMiddleware
. It is used to recover from the panic that could occur in the handlers, and log the errors. It is really important because di.Get(r, "car-manager")
can panic if the CarManager
can not be retrieved from the container. Its code can be found in middleware.go.
The second middleware allows di.Get(r, "car-manager").(*garage.CarManager)
to work by injecting the di.Container in the http.Request. The code is not in the middleware package because it is already included in the DI library with the di.HTTPMiddleware
function.
1
func HTTPMiddleware(h http.HandlerFunc, app Container, logFunc func(msg string)) http.HandlerFunc
For each http request. A sub-container of the given app
container is created. It is injected in the context.Context
of the http.Request so it can be retrieved with di.Get
. At the end of each request, the sub-container is deleted. The logFunc
function is used to log the errors that can occur during the deletion the sub-container.
Main
The main.go file is the entrypoint of the application.
First, it should ensure that the logger will write everything before the program ends:
1
defer logging.Logger.Sync()
Then the dependency injection container can be created:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// create a builder
builder, err := di.NewBuilder()
if err != nil {
logging.Logger.Fatal(err.Error())
}
// add the service definitions
err = builder.Add(services.Services...)
if err != nil {
logging.Logger.Fatal(err.Error())
}
// create the app container, delete it just before the program stops
app := builder.Build()
defer app.Delete()
The last interesting thing is this part:
1
2
3
4
5
6
7
8
m := func(h http.HandlerFunc) http.HandlerFunc {
return middlewares.PanicRecoveryMiddleware(
di.HTTPMiddleware(h, app, func(msg string) {
logging.Logger.Error(msg)
}),
logging.Logger,
)
}
The m
function combines the two middlewares. It can be use to apply the middlewares to the handlers.
The rest of the main file is just the configuration of the gorilla mux router and the creation of the web server.
Conclusion
Creating this small api was not hard at all. It would also be simple to extend it to handle more routes. Go is a good choice to design a rest api that has good performance, but also whose code is easy and fast to write.
Dependency injection would help make this project easier to maintain if it were to grow. Using the sarulabs/di framework allows you to separate the definition of the services from the business logic. The declarations happens in a single place, which is part of the application configuration. The services can be retrieved in the handlers by using the container stored in the request.
I hope this post has been helpful to you, and that you will consider Go and DI as an option to write your next rest api.