Applied Go

Building REST APIs with Gin & MongoDB

Learn how to build production-ready REST APIs in Go using the Gin web framework and MongoDB for data persistence. This section covers HTTP methods, routing, middleware, validation, error handling, and real database operations with a complete example application.

What is REST HTTP Methods Project Setup Data Models Routing & Handlers Middleware Validation Error Handling Interview Questions

What is REST?

REST stands for Representational State Transfer. It's an architectural style for building distributed systems, particularly web APIs. REST APIs use HTTP requests to perform CRUD (Create, Read, Update, Delete) operations on resources.

Client
HTTP Request
Server
HTTP Response
Click a method to see how it maps to CRUD operations.

REST APIs are stateless — each request contains all the information needed to process it. They're resource-based — you interact with resources (like /todos or /users/123) using standard HTTP methods.

REST Principles

1. Resource-based: Everything is a resource identified by a URI (e.g., /todos, /todos/123).

2. HTTP methods: Use GET, POST, PUT, PATCH, DELETE for CRUD operations.

3. Statelessness: Each request is independent; the server doesn't store client state.

4. Uniform interface: Consistent patterns for requests and responses.

5. Self-descriptive messages: Requests include metadata (headers) and data (body).

HTTP Methods Explained

REST APIs use standard HTTP methods to perform operations. Each method has a specific purpose and semantic meaning.

GET
Safe
Idempotent
Body allowed
POST
Safe
Idempotent
Body required
PUT
Safe
Idempotent
Body required
PATCH
Safe
Idempotent
Body required
DELETE
Safe
Idempotent
Body allowed
Method Semantics
Click a method to learn about its semantics, safety, and idempotency.

Understanding these differences is crucial for building correct REST APIs:

Project Setup

Start by initializing a new Go module and installing the required dependencies. Gin is a lightweight web framework, and the MongoDB driver provides native Go support for database operations.

Terminal
$ mkdir todo-api && cd todo-api $ go mod init github.com/example/todo-api $ go get github.com/gin-gonic/gin $ go get go.mongodb.org/mongo-driver/mongo $ go get github.com/go-playground/validator/v10

These three packages form the foundation: Gin for HTTP routing and middleware, MongoDB driver for database access, and validator for input validation.

Data Models

Define your data structures using Go structs with struct tags for JSON serialization and validation. The tags tell Gin how to map JSON fields and validate incoming data.

models.go
package main

import (
	"time"
	"go.mongodb.org/mongo-driver/bson/primitive"
)

type Todo struct {
	ID        primitive.ObjectID `bson:"_id,omitempty" json:"id,omitempty"`
	Title     string             `bson:"title" json:"title" binding:"required,min=1,max=200"`
	Completed bool               `bson:"completed" json:"completed"`
	CreatedAt time.Time          `bson:"created_at" json:"created_at"`
	UpdatedAt time.Time          `bson:"updated_at" json:"updated_at"`
}

type CreateTodoRequest struct {
	Title string `json:"title" binding:"required,min=1,max=200"`
}

type UpdateTodoRequest struct {
	Title     string `json:"title" binding:"omitempty,min=1,max=200"`
	Completed bool   `json:"completed"`
}

The binding tags define validation rules: required means the field must be present, min=1 and max=200 enforce string length constraints. The bson tags map to MongoDB field names.

Routing & Handlers

Gin uses a simple router that maps HTTP methods and paths to handler functions. Each handler receives a context object that provides access to the request, response, and database connection.

handlers.go
package main

import (
	"net/http"
	"github.com/gin-gonic/gin"
	"go.mongodb.org/mongo-driver/bson"
	"go.mongodb.org/mongo-driver/bson/primitive"
	"context"
	"time"
)

func CreateTodo(c *gin.Context) {
	var req CreateTodoRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	todo := Todo{
		Title:     req.Title,
		Completed: false,
		CreatedAt: time.Now(),
		UpdatedAt: time.Now(),
	}

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	result, err := todosCollection.InsertOne(ctx, todo)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create todo"})
		return
	}

	todo.ID = result.InsertedID.(primitive.ObjectID)
	c.JSON(http.StatusCreated, todo)
}

func GetTodos(c *gin.Context) {
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	cursor, err := todosCollection.Find(ctx, bson.M{})
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch todos"})
		return
	}
	defer cursor.Close(ctx)

	var todos []Todo
	if err = cursor.All(ctx, &todos); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to parse todos"})
		return
	}

	c.JSON(http.StatusOK, todos)
}

func GetTodoByID(c *gin.Context) {
	id := c.Param("id")
	objID, err := primitive.ObjectIDFromHex(id)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid todo ID"})
		return
	}

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	todo := Todo{}
	err = todosCollection.FindOne(ctx, bson.M{"_id": objID}).Decode(&todo)
	if err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "Todo not found"})
		return
	}

	c.JSON(http.StatusOK, todo)
}

func UpdateTodo(c *gin.Context) {
	id := c.Param("id")
	objID, err := primitive.ObjectIDFromHex(id)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid todo ID"})
		return
	}

	var req UpdateTodoRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	update := bson.M{
		"$set": bson.M{
			"updated_at": time.Now(),
		},
	}
	if req.Title != "" {
		update["$set"].(bson.M)["title"] = req.Title
	}
	update["$set"].(bson.M)["completed"] = req.Completed

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	result, err := todosCollection.UpdateOne(ctx, bson.M{"_id": objID}, update)
	if err != nil || result.MatchedCount == 0 {
		c.JSON(http.StatusNotFound, gin.H{"error": "Todo not found"})
		return
	}

	c.JSON(http.StatusOK, gin.H{"message": "Todo updated"})
}

func DeleteTodo(c *gin.Context) {
	id := c.Param("id")
	objID, err := primitive.ObjectIDFromHex(id)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid todo ID"})
		return
	}

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	result, err := todosCollection.DeleteOne(ctx, bson.M{"_id": objID})
	if err != nil || result.DeletedCount == 0 {
		c.JSON(http.StatusNotFound, gin.H{"error": "Todo not found"})
		return
	}

	c.JSON(http.StatusOK, gin.H{"message": "Todo deleted"})
}

Each handler follows the same pattern: validate input with ShouldBindJSON, perform the database operation with a timeout context, and return appropriate HTTP status codes. The defer cancel() ensures the context is always cleaned up.

POST /todos
title"Buy milk"
201 Created
idObjectID
title"Buy milk"
completedfalse
Click to simulate a POST /todos request through Gin to MongoDB.

Middleware

Middleware functions run before or after handlers. Common uses include logging, authentication, CORS headers, and error recovery.

middleware.go
package main

import (
	"github.com/gin-gonic/gin"
	"log"
	"time"
)

func LoggingMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		start := time.Now()
		c.Next()
		duration := time.Since(start)
		log.Printf("%s %s %d %v", c.Request.Method, c.Request.URL.Path, c.Writer.Status(), duration)
	}
}

func CORSMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
		c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH")
		c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type")
		if c.Request.Method == "OPTIONS" {
			c.AbortWithStatus(204)
			return
		}
		c.Next()
	}
}

Validation

Gin integrates with the validator package to automatically validate struct fields based on tags. Custom validators can be registered for domain-specific rules.

main.go (setup)
package main

import (
	"github.com/gin-gonic/gin"
	"go.mongodb.org/mongo-driver/mongo"
	"go.mongodb.org/mongo-driver/mongo/options"
	"context"
	"log"
)

var todosCollection *mongo.Collection

func init() {
	client, err := mongo.Connect(context.Background(), options.Client().ApplyURI("mongodb://localhost:27017"))
	if err != nil {
		log.Fatal(err)
	}
	todosCollection = client.Database("todo_db").Collection("todos")
}

func main() {
	router := gin.Default()

	// Apply middleware
	router.Use(LoggingMiddleware())
	router.Use(CORSMiddleware())

	// Routes
	router.POST("/todos", CreateTodo)
	router.GET("/todos", GetTodos)
	router.GET("/todos/:id", GetTodoByID)
	router.PUT("/todos/:id", UpdateTodo)
	router.PATCH("/todos/:id", UpdateTodo)
	router.DELETE("/todos/:id", DeleteTodo)

	// Start server
	router.Run(":8080")
}

The complete route table shows all CRUD operations. Note that both PUT and PATCH use the same handler in this example — in a real app, you might separate them for stricter validation.

Error Handling

Click a scenario to see the HTTP status code and error body returned.

Proper error handling is critical for APIs. Always return appropriate HTTP status codes and meaningful error messages. Use context timeouts to prevent hanging requests.

Best practices for REST APIs

Use appropriate status codes: 200 for success, 201 for created, 400 for bad request, 404 for not found, 500 for server error.

Validate early: Check input before database operations.

Use timeouts: Always set context timeouts for database operations to prevent hanging.

Log everything: Use middleware to log requests, responses, and errors for debugging.

Be consistent: Use the same error format across all endpoints.

Interview Questions

Common interview questions about designing and building REST APIs in Go — organized by experience level. Click a tab to filter.

Q1. What is REST?

REST (Representational State Transfer) is an architectural style for networked APIs built around resources identified by URLs, manipulated through a small set of uniform verbs (GET, POST, PUT, PATCH, DELETE), and exchanged as representations such as JSON. It favors statelessness, a uniform interface, and layered, cacheable communication over the transport (almost always HTTP).

Q2. What does it mean for a REST API to be "stateless"?

Statelessness means each request contains all the information the server needs to process it — the server does not rely on data stored from a previous request (like server-side session state). This is why REST APIs authenticate every request independently, typically with a token or credential sent in a header rather than a cookie-backed session.

Q3. What is a "resource" in REST?

A resource is any named entity an API exposes — a user, an order, a document — identified by a URL such as /users/42. Collections of resources (/users) and individual resources (/users/42) are the nouns that HTTP methods act on as verbs.

Q4. What does GET do, and can it have a request body?

GET retrieves a representation of a resource without modifying server state. While HTTP technically permits a body on a GET request, net/http and most servers/clients ignore or discourage it, so query parameters (r.URL.Query()) are the conventional way to pass input.

Q5. What does POST do?

POST submits data to create a new resource or trigger a server-side action, typically under a collection URL like /users. The server usually responds with 201 Created and a Location header pointing to the new resource.

Q6. What's the difference between PUT and PATCH?

PUT replaces an entire resource with the representation supplied in the request body, while PATCH applies a partial update, modifying only the fields provided. PUT to a full URL like /users/42 is expected to fully overwrite that resource's state.

Q7. What does DELETE do?

DELETE removes the resource identified by the URL. A successful delete commonly returns 204 No Content with an empty body, or 200 OK if the response includes a representation of the deleted resource.

Q8. How do you define a route with net/http's ServeMux?

You create a mux with http.NewServeMux() and register patterns with mux.HandleFunc("GET /users/{id}", handler), then pass the mux as the Handler to http.ListenAndServe. Since Go 1.22, patterns can include an HTTP method prefix and {name} wildcards.

Q9. What is the difference between using http.HandleFunc and implementing http.Handler directly?

http.HandleFunc registers a plain function matching func(http.ResponseWriter, *http.Request), which Go adapts via the http.HandlerFunc type. Implementing http.Handler directly means defining a type with a ServeHTTP(w http.ResponseWriter, r *http.Request) method, which is useful when the handler needs to carry its own state, such as a database connection.

Continue to gRPC APIs →