This is a continuation of an earlier post REST API with Go, Chi and InMemory Store. In this tutorial I will extend the service to store data in a MongoDB, I will be using MongoDB Community Server Docker Image for this sample. I will use Docker to run MongoDB.
Setup Database Server
I will be using a docker-compose to run MongoDB in a docker container. This would allow us the add more services that our rest api is depenedent on e.g. redis server for distributed caching.
Let's start by adding a new file by right clicking on Solution name in Visual Studio and Add New File. I like to name file as docker-compose.dev-env.yml
, feel free to name it as you like. Add following content to add a database instance for movies rest api.
version: '3.7'
services:
movies.db:
image: mongodb/mongodb-community-server:6.0.5-ubuntu2204
environment:
- MONGODB_INITDB_ROOT_USERNAME=root
- MONGODB_INITDB_ROOT_PASSWORD=Password123
- MONGO_INITDB_DATABASE=Movies
volumes:
- moviesdbdata:/data/db
ports:
- "27017:27017"
volumes:
moviesdbdata:
Open a terminal at the root of the solution where docker-compose file is location and execute following command to start database server.
docker-compose -f docker-compose.dev-env.yml up -d
Database Migrations
We would not do any database/schema migrations for MongoDB as its a NoSQL database, here is an excellent discussion on Stackoverflow on this topic. We don't need any migration for this sample however if the need arise and there is no strong use case of a schema migration script I would prefer to opt the route of supporting multiple schemas conconcurrently and update when required.
MongoDB Movies Store
Add a new file named mongo_movies_store.go
under store
folder. Add a new struct MongoMoviesStore
containing databaseUrl
, a pointer to mongo.Client
and a pointer to the mongo.Collection
we will be working with. We will also add helper methods to connect
to database and initialise collection
field that we will use in each of the CRUD
methods, and a helper method to close
connection.
package store
import (
"context"
"time"
"github.com/google/uuid"
"github.com/kashifsoofi/blog-code-samples/movies-api-with-go-chi-and-mongodb/config"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
type MongoMoviesStore struct {
database config.Database
client *mongo.Client
collection *mongo.Collection
}
func NewMongoMoviesStore(config config.Database) *MongoMoviesStore {
return &MongoMoviesStore{
database: config,
}
}
func (s *MongoMoviesStore) connect(ctx context.Context) error {
serverAPI := options.ServerAPI(options.ServerAPIVersion1)
client, err := mongo.Connect(
ctx,
options.Client().ApplyURI(s.database.DatabaseURL).SetServerAPIOptions(serverAPI),
)
if err != nil {
return err
}
s.client = client
s.collection = s.client.Database(s.database.DatabaseName).Collection(s.database.MoviesCollectionName)
return nil
}
func (s *MongoMoviesStore) close(ctx context.Context) error {
return s.client.Disconnect(ctx)
}
Add db tag
Update Movie
struct in movies_store.go
file to add tag to mark ID
field as the ObjectID of Mongo. All other fields will be mapped as it is when saving and loading documents.
type Movie struct {
ID uuid.UUID `bson:"_id"`
...
}
Context
We did not make use of the Context
in the earlier sample movies-api-with-go-chi-and-memory-store
, now that we are connecting to an external storage and package we are going to use to run queries support methods accepting Context
we will update our store.Interface
to accept Context
and use that when running queries. store.Interface
will be updated as follows
type Interface interface {
GetAll(ctx context.Context) ([]Movie, error)
GetByID(ctx context.Context, id uuid.UUID) (Movie, error)
Create(ctx context.Context, createMovieParams CreateMovieParams) error
Update(ctx context.Context, id uuid.UUID, updateMovieParams UpdateMovieParams) error
Delete(ctx context.Context, id uuid.UUID) error
}
We will also need to update MemoryMoviesStore
methods to accept Context
to satisfy store.Interface
and update methods in movies_handler
to pass request context using r.Context()
when calling store
methods.
Create
We connect to database using connect
helper method, create a new instance of Movie
and execute InsertOne
method with newly created instance. We are handling an error
and return DuplicateKeyError
if returned error is a mongo DuplicateKeyError
. If insert is successful then we return nil
.
Create function looks like
func (s *MongoMoviesStore) Create(ctx context.Context, createMovieParams CreateMovieParams) error {
err := s.connect(ctx)
if err != nil {
return err
}
defer s.close(ctx)
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(),
}
if _, err := s.collection.InsertOne(ctx, movie); err != nil {
if mongo.IsDuplicateKeyError(err) {
return &DuplicateKeyError{ID: createMovieParams.ID}
}
return err
}
return nil
}
GetAll
We connect to database using connect
helper method, we call Find
method on our collection to get a cursor
with an empty filter to get all movie documents. We then use All
method on cursor to retrieve all documents into a slice.
func (s *MongoMoviesStore) GetAll(ctx context.Context) ([]Movie, error) {
err := s.connect(ctx)
if err != nil {
return nil, err
}
defer s.close(ctx)
cur, err := s.collection.Find(ctx, bson.D{})
if err != nil {
return nil, err
}
defer cur.Close(ctx)
var movies []Movie
if err := cur.All(ctx, &movies); err != nil {
return nil, err
}
return movies, nil
}
GetByID
We connect to database using connect
helper method, we call FindOne
method on our collection by passing the requested id and decode the result into a Movie
instance. We are checking if FindOne
retuns an ErrNoDocuments
and return our custom RecordNotFound
error to caller. If no error document loaded from the collection is returned.
func (s *MongoMoviesStore) GetByID(ctx context.Context, id uuid.UUID) (Movie, error) {
err := s.connect(ctx)
if err != nil {
return Movie{}, err
}
defer s.close(ctx)
var movie Movie
if err := s.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&movie); err != nil {
if err == mongo.ErrNoDocuments {
return Movie{}, &RecordNotFoundError{}
}
return Movie{}, err
}
return movie, nil
}
Update
We connect to database using connect
helper method, then prepare and update
set using the fields from updateMovieParams
and then call UpdateOne
method on our collection to update all the fields.
Here we are updating all the passed fields and not supporting partial updates, this means caller is responsible for correctly setting the fields to previous value if they don't want to change a specific field. This method can be enhanced to support partial updates.
func (s *MongoMoviesStore) Update(ctx context.Context, id uuid.UUID, updateMovieParams UpdateMovieParams) error {
err := s.connect(ctx)
if err != nil {
return err
}
defer s.close(ctx)
update := bson.M{
"$set": bson.M{
"Title": updateMovieParams.Title,
"Director": updateMovieParams.Director,
"ReleaseDate": updateMovieParams.ReleaseDate,
"TicketPrice": updateMovieParams.TicketPrice,
"UpdatedAt": time.Now().UTC(),
},
}
if _, err := s.collection.UpdateOne(ctx, bson.M{"_id": id}, update); err != nil {
return err
}
return nil
}
Delete
We connect to database using connect
helper method, then we call DeleteOne
method on our collection by passing the requested id to delete the record.
func (s *MongoMoviesStore) Delete(ctx context.Context, id uuid.UUID) error {
err := s.connect(ctx)
if err != nil {
return err
}
defer s.close(ctx)
if _, err := s.collection.DeleteOne(ctx, bson.M{"_id": id}); err != nil {
return err
}
return nil
}
Database Configuration
Add a new struct named Database
in config.go
and add that to Configuration
struct as well.
type Configuration struct {
HTTPServer
Database
}
...
type Database struct {
DatabaseURL string `envconfig:"DATABASE_URL" required:"true"`
DatabaseName string `envconfig:"DATABASE_NAME" default:"MoviesStore"`
MoviesCollectionName string `envconfig:"MOVIES_COLLECTION_NAME" default:"MoviesCollectionName"`
}
Dependency Injection
Update main.go
as follows to create a new instance of MongoMoviesStore
, I have opted to create instance of MongoMoviesStore
instead of MemoryMoviesStore
, solution can be enhanced to create either one of the dependency based on a configuration.
// store := store.NewMemoryMoviesStore()
store := store.NewMongoMoviesStore(cfg.Database)
Test
I am not adding any unit or integration tests for this tutorial, perhaps a following tutorial. But all the endpoints can be tested either using Postman for by following test plan from previous article.
You can start rest api with SQL Server running in docker by executing following
DATABASE_URL=mongodb://root:Password123@localhost:27017 go run main.go
Source
Source code for the demo application is hosted on GitHub in blog-code-samples repository.
References
In no particular order
Top comments (0)