This is a simple MongoDB CRUD example written in Golang. It also has a most basic pagination feature. Just bear in mind, some files need improvement. There are hard-coded pieces, duplications so on. I had to keep this post as short as possible. Feel free to refactor it.


Structure


├── cmd
│   └── blog
│   └── main.go
├── docker
│   ├── docker-compose.yaml
│   └── mongodb
│   └── init.sh
└── internal
├── comment
│   ├── controller.go
│   ├── request.go
│   └── response.go
└── pkg
├── domain
│   └── error.go
└── storage
├── comment_storer.go
└── mongodb
├── comment.go
└── database.go

Files


main.go


package main

import (
"context"
"log"
"net/http"
"time"

"github.com/you/mongo/internal/comment"
"github.com/you/mongo/internal/pkg/storage/mongodb"
"github.com/julienschmidt/httprouter"
)

func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

// Initiate MongoDB instance.
mng, err := mongodb.NewDatabase(ctx, mongodb.DatabaseConfig{
Auth: "SCRAM-SHA-256",
Host: "localhost",
Port: "27017",
User: "inanzzz",
Pass: "1234567",
Name: "blog",
})
if err != nil {
log.Fatalln(err)
}
defer mng.Client().Disconnect(ctx)

// Initiate storage.
comStrg := mongodb.CommentStorage{
Database: mng,
Timeout: time.Second * 5,
}

// Initiate API.
com := comment.Controller{
Storage: comStrg,
}

// Initiate HTTP router.
rtr := httprouter.New()
rtr.HandlerFunc("POST", "/api/v1/comments", com.Create)
rtr.HandlerFunc("GET", "/api/v1/comments/:uuid", com.Find)
rtr.HandlerFunc("DELETE", "/api/v1/comments/:uuid", com.Delete)
rtr.HandlerFunc("PATCH", "/api/v1/comments/:uuid", com.Update)
rtr.HandlerFunc("GET", "/api/v1/comments", com.List)

// Initiate HTTP server.
log.Fatalln((&http.Server{Addr: ":3000", Handler: rtr}).ListenAndServe())
}

docker-compose.yaml


version: "3.4"

services:

blog-mongodb:
container_name: "blog-mongodb"
image: "mongo:4.0"
ports:
- "27017:27017"
environment:
MONGO_INITDB_ROOT_USERNAME: "root"
MONGO_INITDB_ROOT_PASSWORD: "root"
MONGO_NAME: "blog"
MONGO_USER: "inanzzz"
MONGO_PASS: "1234567"
MONGO_AUTH: "SCRAM-SHA-256"
volumes:
- "./mongodb:/docker-entrypoint-initdb.d"

init.sh


#!/bin/sh

# Create a custom user with a password, role and auth mechanism. This user will
# be used by the application for MongoDB connection.
mongo \
-u ${MONGO_INITDB_ROOT_USERNAME} \
-p ${MONGO_INITDB_ROOT_PASSWORD} \
--authenticationDatabase admin ${MONGO_NAME} \
<<-EOJS
db.createUser({
user: "${MONGO_USER}",
pwd: "${MONGO_PASS}",
roles: [{
role: "readWrite",
db: "${MONGO_NAME}"
}],
mechanisms: ["${MONGO_AUTH}"],
})
EOJS

# Prepare database.
mongo \
-u ${MONGO_INITDB_ROOT_USERNAME} \
-p ${MONGO_INITDB_ROOT_PASSWORD} \
--authenticationDatabase admin ${MONGO_NAME} \
<<-EOJS
use ${MONGO_NAME}
db.createCollection("comments")
db.comments.createIndex({"uuid":1},{unique:true,name:"UQ_uuid"})
EOJS

controller.go


As you can see this file is doing a lot and there isn't really a proper request validation. You should refactor it as per your needs.


package comment

import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"

"github.com/you/mongo/internal/pkg/domain"
"github.com/you/mongo/internal/pkg/storage"
"github.com/google/uuid"
"github.com/julienschmidt/httprouter"
)

type Controller struct {
Storage storage.CommentStorer
}

// POST /api/v1/comments
func (c Controller) Create(w http.ResponseWriter, r *http.Request) {
var req Create
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}

id := uuid.New().String()

err := c.Storage.Insert(r.Context(), storage.Comment{
UUID: id,
Text: fmt.Sprintf("%s - %d", req.Text, time.Now().UTC().Nanosecond()),
CreatedAt: time.Now(),
})
if err != nil {
switch err {
case domain.ErrConflict:
w.WriteHeader(http.StatusConflict)
default:
w.WriteHeader(http.StatusInternalServerError)
}
return
}

w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(id))
}

// GET /api/v1/comments/:uuid
func (c Controller) Find(w http.ResponseWriter, r *http.Request) {
id := httprouter.ParamsFromContext(r.Context()).ByName("uuid")

com, err := c.Storage.Find(r.Context(), id)
if err != nil {
switch err {
case domain.ErrNotFound:
w.WriteHeader(http.StatusNotFound)
default:
w.WriteHeader(http.StatusInternalServerError)
}
return
}

res := Response{
UUID: com.UUID,
Text: com.Text,
CreatedAt: com.CreatedAt,
}

body, err := json.Marshal(res)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}

w.Header().Set("Content-Type", "application/json; charset=utf-8")
_, _ = w.Write(body)
}

// DELETE /api/v1/comments/:uuid
func (c Controller) Delete(w http.ResponseWriter, r *http.Request) {
id := httprouter.ParamsFromContext(r.Context()).ByName("uuid")

err := c.Storage.Delete(r.Context(), id)
if err != nil {
switch err {
case domain.ErrNotFound:
w.WriteHeader(http.StatusNotFound)
default:
w.WriteHeader(http.StatusInternalServerError)
}
return
}

w.WriteHeader(http.StatusNoContent)
}

// PATCH /api/v1/comments/:uuid
func (c Controller) Update(w http.ResponseWriter, r *http.Request) {
var req Update
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}

err := c.Storage.Update(r.Context(), storage.Comment{
UUID: httprouter.ParamsFromContext(r.Context()).ByName("uuid"),
Text: req.Text,
})
if err != nil {
switch err {
case domain.ErrNotFound:
w.WriteHeader(http.StatusNotFound)
default:
w.WriteHeader(http.StatusInternalServerError)
}
return
}

w.WriteHeader(http.StatusNoContent)
}

// GET /api/v1/comments?page=1&limit=10&sort=-created_at
func (c Controller) List(w http.ResponseWriter, r *http.Request) {
page, err := strconv.Atoi(r.URL.Query().Get("page"))
if err != nil || page < 1 {
page = 1
}
limit, err := strconv.Atoi(r.URL.Query().Get("limit"))
if err != nil || limit < 1 {
limit = 1
}

coms, err := c.Storage.List(r.Context(), page, limit)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}

res := make([]Response, len(coms))
for i, com := range coms {
res[i] = Response{
UUID: com.UUID,
Text: com.Text,
CreatedAt: com.CreatedAt,
}
}

body, err := json.Marshal(res)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}

w.Header().Set("Content-Type", "application/json; charset=utf-8")
_, _ = w.Write(body)
}

request.go


package comment

type Create struct {
Text string `json:"text"`
}

type Update struct {
Text string `json:"text"`
}

response.go


package comment

import "time"

type Response struct {
UUID string `json:"uuid"`
Text string `json:"text"`
CreatedAt time.Time `json:"created_at"`
}

error.go


package domain

import "errors"

var (
ErrInternal = errors.New("internal")
ErrNotFound = errors.New("not found")
ErrConflict = errors.New("conflict")
)

comment_storer.go


package storage

import (
"context"
"time"
)

type CommentStorer interface {
Insert(ctx context.Context, comment Comment) error
Find(ctx context.Context, uuid string) (Comment, error)
Delete(ctx context.Context, uuid string) error
Update(ctx context.Context, comment Comment) error
List(ctx context.Context, page, limit int) ([]*Comment, error)
}

type Comment struct {
UUID string `bson:"uuid"`
Text string `bson:"text"`
CreatedAt time.Time `bson:"created_at"`
}

comment.go


package mongodb

import (
"context"
"log"
"time"

"github.com/you/mongo/internal/pkg/domain"
"github.com/you/mongo/internal/pkg/storage"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)

var _ storage.CommentStorer = CommentStorage{}

type CommentStorage struct {
Database *mongo.Database
Timeout time.Duration
}

func (c CommentStorage) Insert(ctx context.Context, comment storage.Comment) error {
ctx, cancel := context.WithTimeout(ctx, c.Timeout)
defer cancel()

if _, err := c.Database.Collection("comments").InsertOne(ctx, comment); err != nil {
log.Println(err)

if er, ok := err.(mongo.WriteException); ok && er.WriteErrors[0].Code == 11000 {
return domain.ErrConflict
}

return domain.ErrInternal
}

return nil
}

func (c CommentStorage) Find(ctx context.Context, uuid string) (storage.Comment, error) {
ctx, cancel := context.WithTimeout(ctx, c.Timeout)
defer cancel()

var com storage.Comment

qry := bson.M{"uuid": uuid}

err := c.Database.Collection("comments").FindOne(ctx, qry).Decode(&com)
if err != nil {
log.Println(err)

if err == mongo.ErrNoDocuments {
return storage.Comment{}, domain.ErrNotFound
}

return storage.Comment{}, domain.ErrInternal
}

return com, nil
}

func (c CommentStorage) Delete(ctx context.Context, uuid string) error {
ctx, cancel := context.WithTimeout(ctx, c.Timeout)
defer cancel()

qry := bson.M{"uuid": uuid}

res, err := c.Database.Collection("comments").DeleteOne(ctx, qry)
if err != nil {
log.Println(err)

return domain.ErrInternal
}
if res.DeletedCount == 0 {
return domain.ErrNotFound
}

return nil
}

func (c CommentStorage) Update(ctx context.Context, comment storage.Comment) error {
ctx, cancel := context.WithTimeout(ctx, c.Timeout)
defer cancel()

qry := bson.M{"uuid": comment.UUID}
upd := bson.M{"$set": bson.M{"text": comment.Text}}

// If required, this replaces the whole record.
// res, err := c.Database.Collection("comments").ReplaceOne(ctx, qry, comment)
res, err := c.Database.Collection("comments").UpdateOne(ctx, qry, upd)
if err != nil {
log.Println(err)

return domain.ErrInternal
}
if res.MatchedCount == 0 {
return domain.ErrNotFound
}

return nil
}

func (c CommentStorage) List(ctx context.Context, page, limit int) ([]*storage.Comment, error) {
ctx, cancel := context.WithTimeout(ctx, c.Timeout)
defer cancel()

var skip int
if page > 1 {
skip = (page - 1) * limit
}

qry := bson.M{}
opt := options.FindOptions{}
opt.SetSkip(int64(skip))
opt.SetLimit(int64(limit))
opt.SetSort(bson.M{"created_at": -1}) // This should come from user!

cur, err := c.Database.Collection("comments").Find(ctx, qry, &opt)
if err != nil {
log.Println(err)

return nil, domain.ErrInternal
}

var comments []*storage.Comment

for cur.Next(context.Background()) {
comment := &storage.Comment{}

err := cur.Decode(comment)
if err != nil {
log.Println(err)

return nil, domain.ErrInternal
}

comments = append(comments, comment)
}
defer cur.Close(context.Background())

if err := cur.Err(); err != nil {
log.Println(err)

return nil, domain.ErrInternal
}

return comments, nil
}

database.go


package mongodb

import (
"context"
"fmt"

"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)

type DatabaseConfig struct {
Auth string
Host string
Port string
User string
Pass string
Name string
}

func NewDatabase(ctx context.Context, cnf DatabaseConfig) (*mongo.Database, error) {
mon, err := mongo.Connect(ctx, options.Client().
SetAuth(options.Credential{
AuthMechanism: cnf.Auth,
AuthSource: cnf.Name,
Username: cnf.User,
Password: cnf.Pass,
}).
ApplyURI(fmt.Sprintf("mongodb://%s:%s", cnf.Host, cnf.Port)),
)
if err != nil {
return nil, err
}

return mon.Database(cnf.Name), nil
}

Test


# Create
curl --request POST 'http://localhost:3000/api/v1/comments' \
--header 'Content-Type: application/json' \
--data-raw '{"text": "Hello"}'

# Update
curl --request PATCH 'http://localhost:3000/api/v1/comments/11206d6f-8a77-44bc-9442-2ef6c7f98507' \
--header 'Content-Type: application/json' \
--data-raw '{"text": "Hello - 1"}'

# Find
curl --request GET 'http://localhost:3000/api/v1/comments/11206d6f-8a77-44bc-9442-2ef6c7f98507'

# Delete
curl --request DELETE 'http://localhost:3000/api/v1/comments/11206d6f-8a77-44bc-9442-2ef6c7f98507'

# List
curl --request GET 'http://localhost:3000/api/v1/comments?page=1&limit=4'