Database: mongoDB
If you prefer NoSQL databases, mongoDB is definitely a great one that you don't want to miss.
Try mongoDB
- Install mongoDB on your machine and start it.
Note: Remember to create indexes on collections based on your need in a production project.
- Add mongo dependency:
go get -u go.mongodb.org/mongo-driver/mongo
- 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!