» Go: Build a REST API with Gin » 2. Development » 2.8 Database: MongoDB

Database: mongoDB

If you prefer NoSQL databases, mongoDB is definitely a great one that you don't want to miss.

Try mongoDB

  1. Install mongoDB on your machine and start it.

Note: Remember to create indexes on collections based on your need in a production project.

  1. Add mongo dependency:
go get -u go.mongodb.org/mongo-driver/mongo
  1. Update code.

Use mongoDB for CRUD operations

Add a new domain entity Review in domain/model/review.go:

package model

import "time"

// Review represents the review of a book
type Review struct {
	// Caution: bson tag, which is bound to mongodb, should not appear here in the domain entity.
	// It's a hack for tutorial brevity.
	ID        string    `json:"id,omitempty" bson:"_id,omitempty"`
	BookID    uint      `json:"book_id,omitempty"`
	Author    string    `json:"author,omitempty"`
	Title     string    `json:"title,omitempty"`
	Content   string    `json:"content,omitempty"`
	CreatedAt time.Time `json:"created_at,omitempty"`
	UpdatedAt time.Time `json:"updated_at,omitempty"`
}

Declare its business capabilities in domain/gateway/review_manager.go:

package gateway

import (
	"context"

	"literank.com/rest-books/domain/model"
)

// ReviewManager manages all book reviews
type ReviewManager interface {
	CreateReview(ctx context.Context, b *model.Review) (string, error)
	UpdateReview(ctx context.Context, id string, b *model.Review) error
	DeleteReview(ctx context.Context, id string) error
	GetReview(ctx context.Context, id string) (*model.Review, error)
	GetReviewsOfBook(ctx context.Context, bookID uint) ([]*model.Review, error)
}

Implement these methods in infrastructure/database/mongo.go:

/*
Package database does all db persistence implementations.
*/
package database

import (
	"context"
	"errors"
	"fmt"

	"go.mongodb.org/mongo-driver/bson"
	"go.mongodb.org/mongo-driver/bson/primitive"
	"go.mongodb.org/mongo-driver/mongo"
	"go.mongodb.org/mongo-driver/mongo/options"

	"literank.com/rest-books/domain/model"
)

const (
	collReview  = "reviews"
	idField     = "_id"
	bookIDField = "bookid"
)

type MongoPersistence struct {
	db   *mongo.Database
	coll *mongo.Collection
}

func NewMongoPersistence(mongoURI, dbName string) (*MongoPersistence, error) {
	client, err := mongo.Connect(context.Background(), options.Client().ApplyURI(mongoURI))
	if err != nil {
		return nil, err
	}
	db := client.Database(dbName)
	coll := db.Collection(collReview)
	return &MongoPersistence{db, coll}, nil
}

func (m *MongoPersistence) CreateReview(ctx context.Context, r *model.Review) (string, error) {
	result, err := m.coll.InsertOne(ctx, r)
	if err != nil {
		return "", err
	}
	insertedID, ok := result.InsertedID.(primitive.ObjectID)
	if !ok {
		return "", fmt.Errorf("failed to extract InsertedID from result: %v", result)
	}
	return insertedID.Hex(), nil
}

func (m *MongoPersistence) UpdateReview(ctx context.Context, id string, r *model.Review) error {
	objID, err := primitive.ObjectIDFromHex(id)
	if err != nil {
		return err
	}
	updateValues := bson.M{
		"title":     r.Title,
		"content":   r.Content,
		"updatedat": r.UpdatedAt,
	}
	result, err := m.coll.UpdateOne(ctx, bson.M{idField: objID}, bson.M{"$set": updateValues})
	if err != nil {
		return err
	}
	if result.ModifiedCount == 0 {
		return errors.New("review does not exist")
	}
	return nil
}

func (m *MongoPersistence) DeleteReview(ctx context.Context, id string) error {
	objID, err := primitive.ObjectIDFromHex(id)
	if err != nil {
		return err
	}
	result, err := m.coll.DeleteOne(ctx, bson.M{idField: objID})
	if err != nil {
		return err
	}
	if result.DeletedCount == 0 {
		return errors.New("review does not exist")
	}
	return nil
}

func (m *MongoPersistence) GetReview(ctx context.Context, id string) (*model.Review, error) {
	objID, err := primitive.ObjectIDFromHex(id)
	if err != nil {
		return nil, err
	}
	var review model.Review
	if err := m.coll.FindOne(ctx, bson.M{idField: objID}).Decode(&review); err != nil {
		return nil, err
	}
	return &review, nil
}

func (m *MongoPersistence) GetReviewsOfBook(ctx context.Context, bookID uint) ([]*model.Review, error) {
	cursor, err := m.coll.Find(ctx, bson.M{bookIDField: bookID})
	if err != nil {
		return nil, err
	}
	defer cursor.Close(ctx)

	reviews := make([]*model.Review, 0)
	if err := cursor.All(ctx, &reviews); err != nil {
		return nil, err
	}
	return reviews, nil
}

Add config items for mongodb in infrastructure/config/config.go:

@@ -13,8 +13,10 @@ type Config struct {
 }
 
 type DBConfig struct {
-       FileName string `json:"file_name" yaml:"file_name"`
-       DSN      string `json:"dsn" yaml:"dsn"`
+       FileName    string `json:"file_name" yaml:"file_name"`
+       DSN         string `json:"dsn" yaml:"dsn"`
+       MongoURI    string `json:"mongo_uri" yaml:"mongo_uri"`
+       MongoDBName string `json:"mongo_db_name" yaml:"mongo_db_name"`
 }
 
 type ApplicationConfig struct {

Add config values in config.yml:

@@ -3,3 +3,5 @@ app:
 db:
   file_name: "test.db"
   dsn: "test_user:test_pass@tcp(127.0.0.1:3306)/lr_book?charset=utf8mb4&parseTime=True&loc=Local"
+  mongo_uri: "mongodb://localhost:27017"
+  mongo_db_name: "lr_book"

Add review_operator in Application layer, application/executor/review_operator.go:

package executor

import (
	"context"
	"errors"
	"time"

	"literank.com/rest-books/application/dto"
	"literank.com/rest-books/domain/gateway"
	"literank.com/rest-books/domain/model"
)

type ReviewOperator struct {
	reviewManager gateway.ReviewManager
}

func NewReviewOperator(b gateway.ReviewManager) *ReviewOperator {
	return &ReviewOperator{reviewManager: b}
}

func (o *ReviewOperator) CreateReview(ctx context.Context, body *dto.ReviewBody) (*model.Review, error) {
	now := time.Now()
	b := &model.Review{
		BookID:    body.BookID,
		Author:    body.Author,
		Title:     body.Title,
		Content:   body.Content,
		CreatedAt: now,
		UpdatedAt: now,
	}
	id, err := o.reviewManager.CreateReview(ctx, b)
	if err != nil {
		return nil, err
	}
	b.ID = id
	return b, nil
}

func (o *ReviewOperator) GetReview(ctx context.Context, id string) (*model.Review, error) {
	return o.reviewManager.GetReview(ctx, id)
}

func (o *ReviewOperator) GetReviewsOfBook(ctx context.Context, bookID uint) ([]*model.Review, error) {
	return o.reviewManager.GetReviewsOfBook(ctx, bookID)
}

func (o *ReviewOperator) UpdateReview(ctx context.Context, id string, b *model.Review) (*model.Review, error) {
	if b.Title == "" || b.Content == "" {
		return nil, errors.New("required field cannot be empty")
	}
	b.UpdatedAt = time.Now()
	if err := o.reviewManager.UpdateReview(ctx, id, b); err != nil {
		return nil, err
	}
	return b, nil
}

func (o *ReviewOperator) DeleteReview(ctx context.Context, id string) error {
	return o.reviewManager.DeleteReview(ctx, id)
}

The ReviewBody struct is defined in application/dto/review.go:

/*
Package dto has all data transfer objects.
*/
package dto

type ReviewBody struct {
	BookID  uint   `json:"book_id"`
	Author  string `json:"author"`
	Title   string `json:"title"`
	Content string `json:"content"`
}

Tune application/wire_helper.go to wire in mongodb connections:

@@ -8,7 +8,8 @@ import (
 
 // WireHelper is the helper for dependency injection
 type WireHelper struct {
-       persistence *database.MySQLPersistence
+       sqlPersistence   *database.MySQLPersistence
+       noSQLPersistence *database.MongoPersistence
 }
 
 func NewWireHelper(c *config.Config) (*WireHelper, error) {
@@ -16,9 +17,17 @@ func NewWireHelper(c *config.Config) (*WireHelper, error) {
        if err != nil {
                return nil, err
        }
-       return &WireHelper{persistence: db}, nil
+       mdb, err := database.NewMongoPersistence(c.DB.MongoURI, c.DB.MongoDBName)
+       if err != nil {
+               return nil, err
+       }
+       return &WireHelper{sqlPersistence: db, noSQLPersistence: mdb}, nil
 }
 
 func (w *WireHelper) BookManager() gateway.BookManager {
-       return w.persistence
+       return w.sqlPersistence
+}
+
+func (w *WireHelper) ReviewManager() gateway.ReviewManager {
+       return w.noSQLPersistence
 }

Add review routes in adaptor/router.go:

@@ -7,18 +7,23 @@ import (
 
 	"github.com/gin-gonic/gin"
 	"literank.com/rest-books/application"
+	"literank.com/rest-books/application/dto"
 	"literank.com/rest-books/application/executor"
 	"literank.com/rest-books/domain/model"
 )
 
+const fieldID = "id"
+
 // RestHandler handles all restful requests
 type RestHandler struct {
-	bookOperator *executor.BookOperator
+	bookOperator   *executor.BookOperator
+	reviewOperator *executor.ReviewOperator
 }
 
 func MakeRouter(wireHelper *application.WireHelper) (*gin.Engine, error) {
 	rest := &RestHandler{
-		bookOperator: executor.NewBookOperator(wireHelper.BookManager()),
+		bookOperator:   executor.NewBookOperator(wireHelper.BookManager()),
+		reviewOperator: executor.NewReviewOperator(wireHelper.ReviewManager()),
 	}
 	// Create a new Gin router
 	r := gin.Default()
@@ -35,6 +40,11 @@ func MakeRouter(wireHelper *application.WireHelper) (*gin.Engine, error) {
 	r.POST("/books", rest.createBook)
 	r.PUT("/books/:id", rest.updateBook)
 	r.DELETE("/books/:id", rest.deleteBook)
+	r.GET("/books/:id/reviews", rest.getReviewsOfBook)
+	r.GET("/reviews/:id", rest.getReview)
+	r.POST("/reviews", rest.createReview)
+	r.PUT("/reviews/:id", rest.updateReview)
+	r.DELETE("/reviews/:id", rest.deleteReview)
 	return r, nil
 }
 
@@ -42,8 +52,8 @@ func MakeRouter(wireHelper *application.WireHelper) (*gin.Engine, error) {
 func (r *RestHandler) getBooks(c *gin.Context) {
 	books, err := r.bookOperator.GetBooks(c)
 	if err != nil {
-		fmt.Printf("Failed to get books: %v", err)
-		c.JSON(http.StatusNotFound, gin.H{"error": "Failed to get books"})
+		fmt.Printf("Failed to get books: %v\n", err)
+		c.JSON(http.StatusNotFound, gin.H{"error": "failed to get books"})
 		return
 	}
 	c.JSON(http.StatusOK, books)
@@ -51,15 +61,15 @@ func (r *RestHandler) getBooks(c *gin.Context) {
 
 // Get single book
 func (r *RestHandler) getBook(c *gin.Context) {
-	id, err := strconv.Atoi(c.Param("id"))
+	id, err := strconv.Atoi(c.Param(fieldID))
 	if err != nil {
-		c.JSON(http.StatusNotFound, gin.H{"error": "Invalid id"})
+		c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid id"})
 		return
 	}
 	book, err := r.bookOperator.GetBook(c, uint(id))
 	if err != nil {
-		fmt.Printf("Failed to get the book with %d: %v", id, err)
-		c.JSON(http.StatusNotFound, gin.H{"error": "Failed to get the book"})
+		fmt.Printf("Failed to get the book with %d: %v\n", id, err)
+		c.JSON(http.StatusNotFound, gin.H{"error": "failed to get the book"})
 		return
 	}
 	c.JSON(http.StatusOK, book)
@@ -75,8 +85,8 @@ func (r *RestHandler) createBook(c *gin.Context) {
 
 	book, err := r.bookOperator.CreateBook(c, &reqBody)
 	if err != nil {
-		fmt.Printf("Failed to create: %v", err)
-		c.JSON(http.StatusNotFound, gin.H{"error": "Failed to create"})
+		fmt.Printf("Failed to create: %v\n", err)
+		c.JSON(http.StatusNotFound, gin.H{"error": "failed to create"})
 		return
 	}
 	c.JSON(http.StatusCreated, book)
@@ -84,9 +94,9 @@ func (r *RestHandler) createBook(c *gin.Context) {
 
 // Update an existing book
 func (r *RestHandler) updateBook(c *gin.Context) {
-	id, err := strconv.Atoi(c.Param("id"))
+	id, err := strconv.Atoi(c.Param(fieldID))
 	if err != nil {
-		c.JSON(http.StatusNotFound, gin.H{"error": "Invalid id"})
+		c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
 		return
 	}
 
@@ -98,8 +108,8 @@ func (r *RestHandler) updateBook(c *gin.Context) {
 
 	book, err := r.bookOperator.UpdateBook(c, uint(id), &reqBody)
 	if err != nil {
-		fmt.Printf("Failed to update: %v", err)
-		c.JSON(http.StatusNotFound, gin.H{"error": "Failed to update"})
+		fmt.Printf("Failed to update: %v\n", err)
+		c.JSON(http.StatusNotFound, gin.H{"error": "failed to update"})
 		return
 	}
 	c.JSON(http.StatusOK, book)
@@ -107,15 +117,91 @@ func (r *RestHandler) updateBook(c *gin.Context) {
 
 // Delete an existing book
 func (r *RestHandler) deleteBook(c *gin.Context) {
-	id, err := strconv.Atoi(c.Param("id"))
+	id, err := strconv.Atoi(c.Param(fieldID))
 	if err != nil {
-		c.JSON(http.StatusNotFound, gin.H{"error": "Invalid id"})
+		c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
 		return
 	}
 
 	if err := r.bookOperator.DeleteBook(c, uint(id)); err != nil {
-		fmt.Printf("Failed to delete: %v", err)
-		c.JSON(http.StatusNotFound, gin.H{"error": "Failed to delete"})
+		fmt.Printf("Failed to delete: %v\n", err)
+		c.JSON(http.StatusNotFound, gin.H{"error": "failed to delete"})
+		return
+	}
+	c.JSON(http.StatusNoContent, nil)
+}
+
+// Get all book reviews
+func (r *RestHandler) getReviewsOfBook(c *gin.Context) {
+	bookID, err := strconv.Atoi(c.Param(fieldID))
+	if err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "invalid book id"})
+		return
+	}
+	books, err := r.reviewOperator.GetReviewsOfBook(c, uint(bookID))
+	if err != nil {
+		fmt.Printf("Failed to get reviews of book %d: %v\n", bookID, err)
+		c.JSON(http.StatusNotFound, gin.H{"error": "failed to get books"})
+		return
+	}
+	c.JSON(http.StatusOK, books)
+}
+
+// Get single review
+func (r *RestHandler) getReview(c *gin.Context) {
+	id := c.Param(fieldID)
+	review, err := r.reviewOperator.GetReview(c, id)
+	if err != nil {
+		fmt.Printf("Failed to get the review %s: %v\n", id, err)
+		c.JSON(http.StatusNotFound, gin.H{"error": "failed to get the review"})
+		return
+	}
+	c.JSON(http.StatusOK, review)
+}
+
+// Create a new review
+func (r *RestHandler) createReview(c *gin.Context) {
+	var reviewBody dto.ReviewBody
+	if err := c.ShouldBindJSON(&reviewBody); err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+		return
+	}
+
+	book, err := r.reviewOperator.CreateReview(c, &reviewBody)
+	if err != nil {
+		fmt.Printf("Failed to create: %v\n", err)
+		c.JSON(http.StatusNotFound, gin.H{"error": "failed to create the review"})
+		return
+	}
+	c.JSON(http.StatusCreated, book)
+}
+
+// Update an existing review
+func (r *RestHandler) updateReview(c *gin.Context) {
+	id := c.Param(fieldID)
+
+	var reqBody model.Review
+	if err := c.ShouldBindJSON(&reqBody); err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+		return
+	}
+
+	book, err := r.reviewOperator.UpdateReview(c, id, &reqBody)
+	if err != nil {
+		fmt.Printf("Failed to update: %v\n", err)
+		c.JSON(http.StatusNotFound, gin.H{"error": "failed to update the review"})
+		return
+	}
+	c.JSON(http.StatusOK, book)
+}
+
+// Delete an existing review
+func (r *RestHandler) deleteReview(c *gin.Context) {
+	id := c.Param(fieldID)
+
+	if err := r.reviewOperator.DeleteReview(c, id); err != nil {
+		fmt.Printf("Failed to delete: %v\n", err)
+		c.JSON(http.StatusNotFound, gin.H{"error": "failed to delete the review"})
 		return
 	}
 	c.JSON(http.StatusNoContent, nil)

All the changes are applied now. Let's try these endpoints with curl.

Try with curl

Create a new review:

curl -X POST \
  -H "Content-Type: application/json" \
  -d '{
        "book_id": 1,
        "author": "John Doe",
        "title": "Great Book",
        "content": "This is a great book!"
      }' \
  http://localhost:8080/reviews

It should respond with this:

{"id":"65db6ecb3a7e0ccbe4cb9a87","book_id":1,"author":"John Doe","title":"Great Book","content":"This is a great book!","created_at":"2024-02-26T00:46:03.027976+08:00","updated_at":"2024-02-26T00:46:03.027976+08:00"}

Fetch a single review by ID:

curl -X GET http://localhost:8080/reviews/65db6205d08ce884d9062c91

Result:

{
  "id": "65db6ecb3a7e0ccbe4cb9a87",
  "book_id": 1,
  "author": "John Doe",
  "title": "Great Book",
  "content": "This is a great book!",
  "created_at": "2024-02-25T16:46:03.027Z",
  "updated_at": "2024-02-25T16:46:03.027Z"
}

List all reviews of a book:

curl -X GET http://localhost:8080/books/1/reviews

Result list:

[
  {
    "id": "65db6ecb3a7e0ccbe4cb9a87",
    "book_id": 1,
    "author": "John Doe",
    "title": "Great Book",
    "content": "This is a great book!",
    "created_at": "2024-02-25T16:46:03.027Z",
    "updated_at": "2024-02-25T16:46:03.027Z"
  },
  {
    "id": "65db6f753a7e0ccbe4cb9a88",
    "book_id": 1,
    "author": "Robert Smith",
    "title": "Best book ever",
    "content": "This is the best!",
    "created_at": "2024-02-25T16:48:53.916Z",
    "updated_at": "2024-02-25T16:48:53.916Z"
  }
]

Update an existing review

curl -X PUT \
  -H "Content-Type: application/json" \
  -d '{
        "content": "I prefer Robert Smith new book",
        "title": "Not that good"
      }' \
  http://localhost:8080/reviews/65db6f753a7e0ccbe4cb9a88

Result:

{"title":"Not that good","content":"I prefer Robert Smith new book","created_at":"0001-01-01T00:00:00Z","updated_at":"2024-02-26T01:08:12.351487+08:00"}

Delete an existing review:

curl -X DELETE http://localhost:8080/reviews/65db6f753a7e0ccbe4cb9a88

It returns code 204 for a sucessful deletion.

Voila! Your api server is using mongoDB as well now!

PrevNext