REST API Design
REST API is the most widely used architectural style for communication between web services. Proper REST API design allows clients to interact with servers in a predictable way, improving maintainability and scalability. Go is well-suited for implementing REST API servers thanks to its concise syntax and powerful standard library.
REST Principles and URL Design
The core of REST (Representational State Transfer) is resource-centric design.
URL Design Principles
# Good URL design (use nouns, express hierarchical structure)
GET /v1/users # List users
POST /v1/users # Create user
GET /v1/users/{id} # Get specific user
PUT /v1/users/{id} # Full update of user
PATCH /v1/users/{id} # Partial update of user
DELETE /v1/users/{id} # Delete user
GET /v1/users/{id}/posts # List posts by user
GET /v1/users/{id}/posts/{pid} # Specific post by user
# Bad URL design (avoid using verbs)
GET /getUsers
POST /createUser
GET /getUserById?id=1
HTTP Method Semantics
| Method | Meaning | Idempotent | Safe |
|---|---|---|---|
| GET | Read | O | O |
| POST | Create | X | X |
| PUT | Full update | O | X |
| PATCH | Partial update | X | X |
| DELETE | Delete | O | X |
Idempotent: Making the same request multiple times produces the same result Safe: Does not change server state
Correct Usage of HTTP Status Codes
// Usage scenarios by status code
const (
// 2xx Success
StatusOK = 200 // GET, PUT, PATCH success
StatusCreated = 201 // Resource created successfully via POST
StatusNoContent = 204 // DELETE success (no response body)
// 4xx Client errors
StatusBadRequest = 400 // Malformed request
StatusUnauthorized = 401 // Authentication required
StatusForbidden = 403 // No permission (authenticated but not authorized)
StatusNotFound = 404 // Resource not found
StatusMethodNotAllowed = 405 // HTTP method not allowed
StatusConflict = 409 // Resource conflict (e.g., duplicate creation)
StatusUnprocessableEntity = 422 // Validation failed
// 5xx Server errors
StatusInternalServerError = 500 // Internal server error
StatusServiceUnavailable = 503 // Service temporarily unavailable
)
Standard Error Response Structure
Consistent error responses allow clients to handle errors in a predictable manner.
package main
import (
"encoding/json"
"net/http"
)
// APIError standard error response structure
type APIError struct {
Code string `json:"code"`
Message string `json:"message"`
Details []ErrorDetail `json:"details,omitempty"`
}
// ErrorDetail per-field validation error detail
type ErrorDetail struct {
Field string `json:"field"`
Message string `json:"message"`
}
// APIResponse standard response wrapper
type APIResponse[T any] struct {
Data T `json:"data,omitempty"`
Error *APIError `json:"error,omitempty"`
Meta *PageMeta `json:"meta,omitempty"`
}
// PageMeta pagination metadata
type PageMeta struct {
Total int64 `json:"total"`
Page int `json:"page"`
Limit int `json:"limit"`
HasNext bool `json:"has_next"`
}
// WriteJSON JSON response helper
func WriteJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
// WriteError error response helper
func WriteError(w http.ResponseWriter, status int, code, message string, details ...ErrorDetail) {
resp := APIResponse[any]{
Error: &APIError{
Code: code,
Message: message,
Details: details,
},
}
WriteJSON(w, status, resp)
}
URL Versioning
package main
import (
"fmt"
"net/http"
)
func main() {
mux := http.NewServeMux()
// v1 routes
v1 := http.NewServeMux()
v1.HandleFunc("GET /users", v1GetUsers)
v1.HandleFunc("POST /users", v1CreateUser)
// v2 routes (add features while maintaining backward compatibility)
v2 := http.NewServeMux()
v2.HandleFunc("GET /users", v2GetUsers) // changed response format
v2.HandleFunc("POST /users", v2CreateUser)
// Separate by version prefix
mux.Handle("/v1/", http.StripPrefix("/v1", v1))
mux.Handle("/v2/", http.StripPrefix("/v2", v2))
fmt.Println("Server started: http://localhost:8080")
http.ListenAndServe(":8080", mux)
}
func v1GetUsers(w http.ResponseWriter, r *http.Request) {
WriteJSON(w, http.StatusOK, map[string]any{
"users": []map[string]string{
{"id": "1", "name": "John Doe"},
},
})
}
func v2GetUsers(w http.ResponseWriter, r *http.Request) {
// v2 includes pagination meta
WriteJSON(w, http.StatusOK, APIResponse[any]{
Data: []map[string]string{
{"id": "1", "name": "John Doe"},
},
Meta: &PageMeta{Total: 1, Page: 1, Limit: 20, HasNext: false},
})
}
func v1CreateUser(w http.ResponseWriter, r *http.Request) {}
func v2CreateUser(w http.ResponseWriter, r *http.Request) {}
Pagination
Offset-Based Pagination
package main
import (
"net/http"
"strconv"
)
type PaginationParams struct {
Page int // starts from 1
Limit int
Offset int
}
// ParsePagination parse pagination from query parameters
func ParsePagination(r *http.Request) PaginationParams {
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if page < 1 {
page = 1
}
if limit < 1 || limit > 100 {
limit = 20 // default, max 100
}
return PaginationParams{
Page: page,
Limit: limit,
Offset: (page - 1) * limit,
}
}
// Usage example
func handleGetPosts(w http.ResponseWriter, r *http.Request) {
p := ParsePagination(r)
// DB query: SELECT * FROM posts LIMIT ? OFFSET ?
// posts, total := db.GetPosts(p.Limit, p.Offset)
posts := []map[string]any{} // fetched from DB in real code
total := int64(0)
WriteJSON(w, http.StatusOK, APIResponse[any]{
Data: posts,
Meta: &PageMeta{
Total: total,
Page: p.Page,
Limit: p.Limit,
HasNext: int64(p.Page*p.Limit) < total,
},
})
}
Cursor-Based Pagination
The offset approach has performance issues with large datasets. Cursor-based pagination is more efficient.
package main
import (
"encoding/base64"
"fmt"
"net/http"
)
type CursorParams struct {
Cursor string
Limit int
}
// EncodeCursor encode cursor to base64 (ID-based)
func EncodeCursor(id int64) string {
return base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%d", id)))
}
// DecodeCursor decode cursor
func DecodeCursor(cursor string) (int64, error) {
b, err := base64.StdEncoding.DecodeString(cursor)
if err != nil {
return 0, err
}
var id int64
_, err = fmt.Sscanf(string(b), "%d", &id)
return id, err
}
// cursor-based response structure
type CursorResponse[T any] struct {
Data T `json:"data"`
NextCursor string `json:"next_cursor,omitempty"`
HasNext bool `json:"has_next"`
}
func handleGetPostsCursor(w http.ResponseWriter, r *http.Request) {
cursor := r.URL.Query().Get("cursor")
var afterID int64
if cursor != "" {
id, err := DecodeCursor(cursor)
if err != nil {
WriteError(w, http.StatusBadRequest, "INVALID_CURSOR", "Invalid cursor")
return
}
afterID = id
}
// DB query: SELECT * FROM posts WHERE id > ? ORDER BY id LIMIT ?
_ = afterID
posts := []map[string]any{} // fetch from DB
hasNext := false
var nextCursor string
if hasNext {
// create next cursor from the last item's ID
// nextCursor = EncodeCursor(posts[len(posts)-1].ID)
}
WriteJSON(w, http.StatusOK, CursorResponse[any]{
Data: posts,
NextCursor: nextCursor,
HasNext: hasNext,
})
}
Filtering, Sorting, and Search
package main
import (
"net/http"
"strings"
)
type PostFilter struct {
AuthorID int64
Status string
Search string
SortBy string
SortDir string
}
// ParsePostFilter parse filter from query parameters
func ParsePostFilter(r *http.Request) PostFilter {
q := r.URL.Query()
sortDir := strings.ToUpper(q.Get("sort_dir"))
if sortDir != "ASC" && sortDir != "DESC" {
sortDir = "DESC"
}
sortBy := q.Get("sort_by")
// only accept allowed columns (prevent SQL injection)
allowedSortFields := map[string]bool{"created_at": true, "title": true, "views": true}
if !allowedSortFields[sortBy] {
sortBy = "created_at"
}
return PostFilter{
Status: q.Get("status"),
Search: q.Get("q"),
SortBy: sortBy,
SortDir: sortDir,
}
}
// Request example:
// GET /v1/posts?status=published&q=golang&sort_by=views&sort_dir=DESC&page=1&limit=10
func handleFilteredPosts(w http.ResponseWriter, r *http.Request) {
filter := ParsePostFilter(r)
p := ParsePagination(r)
_ = filter
_ = p
// Build and execute DB query
WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
Practical Example: Gin-Based Blog REST API
package main
import (
"net/http"
"strconv"
"sync"
"time"
"github.com/gin-gonic/gin"
)
// Post blog post model
type Post struct {
ID int64 `json:"id"`
Title string `json:"title" binding:"required,min=1,max=200"`
Content string `json:"content" binding:"required"`
AuthorID int64 `json:"author_id"`
Status string `json:"status"` // draft, published
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// CreatePostRequest create post request
type CreatePostRequest struct {
Title string `json:"title" binding:"required,min=1,max=200"`
Content string `json:"content" binding:"required"`
Status string `json:"status" binding:"omitempty,oneof=draft published"`
}
// UpdatePostRequest update post request
type UpdatePostRequest struct {
Title *string `json:"title" binding:"omitempty,min=1,max=200"`
Content *string `json:"content"`
Status *string `json:"status" binding:"omitempty,oneof=draft published"`
}
// PostStore in-memory store
type PostStore struct {
mu sync.RWMutex
posts map[int64]*Post
nextID int64
}
func NewPostStore() *PostStore {
return &PostStore{posts: make(map[int64]*Post), nextID: 1}
}
func (s *PostStore) Create(req CreatePostRequest) *Post {
s.mu.Lock()
defer s.mu.Unlock()
status := req.Status
if status == "" {
status = "draft"
}
post := &Post{
ID: s.nextID,
Title: req.Title,
Content: req.Content,
AuthorID: 1,
Status: status,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
s.posts[s.nextID] = post
s.nextID++
return post
}
func (s *PostStore) GetAll(page, limit int) ([]*Post, int64) {
s.mu.RLock()
defer s.mu.RUnlock()
all := make([]*Post, 0, len(s.posts))
for _, p := range s.posts {
all = append(all, p)
}
total := int64(len(all))
start := (page - 1) * limit
if start >= len(all) {
return []*Post{}, total
}
end := start + limit
if end > len(all) {
end = len(all)
}
return all[start:end], total
}
func (s *PostStore) GetByID(id int64) (*Post, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
p, ok := s.posts[id]
return p, ok
}
func (s *PostStore) Update(id int64, req UpdatePostRequest) (*Post, bool) {
s.mu.Lock()
defer s.mu.Unlock()
post, ok := s.posts[id]
if !ok {
return nil, false
}
if req.Title != nil {
post.Title = *req.Title
}
if req.Content != nil {
post.Content = *req.Content
}
if req.Status != nil {
post.Status = *req.Status
}
post.UpdatedAt = time.Now()
return post, true
}
func (s *PostStore) Delete(id int64) bool {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.posts[id]; !ok {
return false
}
delete(s.posts, id)
return true
}
// PostHandler collection of Gin handlers
type PostHandler struct {
store *PostStore
}
func (h *PostHandler) List(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
if page < 1 { page = 1 }
if limit < 1 || limit > 100 { limit = 20 }
posts, total := h.store.GetAll(page, limit)
c.JSON(http.StatusOK, gin.H{
"data": posts,
"meta": gin.H{
"total": total,
"page": page,
"limit": limit,
"has_next": int64(page*limit) < total,
},
})
}
func (h *PostHandler) Create(c *gin.Context) {
var req CreatePostRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{
"error": gin.H{"code": "VALIDATION_ERROR", "message": err.Error()},
})
return
}
post := h.store.Create(req)
c.JSON(http.StatusCreated, gin.H{"data": post})
}
func (h *PostHandler) Get(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": gin.H{"code": "INVALID_ID", "message": "Invalid ID"},
})
return
}
post, ok := h.store.GetByID(id)
if !ok {
c.JSON(http.StatusNotFound, gin.H{
"error": gin.H{"code": "NOT_FOUND", "message": "Post not found"},
})
return
}
c.JSON(http.StatusOK, gin.H{"data": post})
}
func (h *PostHandler) Update(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": gin.H{"code": "INVALID_ID", "message": "Invalid ID"},
})
return
}
var req UpdatePostRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{
"error": gin.H{"code": "VALIDATION_ERROR", "message": err.Error()},
})
return
}
post, ok := h.store.Update(id, req)
if !ok {
c.JSON(http.StatusNotFound, gin.H{
"error": gin.H{"code": "NOT_FOUND", "message": "Post not found"},
})
return
}
c.JSON(http.StatusOK, gin.H{"data": post})
}
func (h *PostHandler) Delete(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": gin.H{"code": "INVALID_ID", "message": "Invalid ID"},
})
return
}
if !h.store.Delete(id) {
c.JSON(http.StatusNotFound, gin.H{
"error": gin.H{"code": "NOT_FOUND", "message": "Post not found"},
})
return
}
c.Status(http.StatusNoContent)
}
func main() {
r := gin.Default()
handler := &PostHandler{store: NewPostStore()}
v1 := r.Group("/v1")
{
posts := v1.Group("/posts")
{
posts.GET("", handler.List)
posts.POST("", handler.Create)
posts.GET("/:id", handler.Get)
posts.PATCH("/:id", handler.Update)
posts.DELETE("/:id", handler.Delete)
}
}
// Health check
r.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
r.Run(":8080")
}
Content-Type Negotiation
package main
import (
"encoding/json"
"encoding/xml"
"net/http"
)
type User struct {
ID int `json:"id" xml:"id"`
Name string `json:"name" xml:"name"`
}
// Select response format based on client's Accept header
func handleUser(w http.ResponseWriter, r *http.Request) {
user := User{ID: 1, Name: "John Doe"}
accept := r.Header.Get("Accept")
switch accept {
case "application/xml":
w.Header().Set("Content-Type", "application/xml")
xml.NewEncoder(w).Encode(user)
default: // application/json or */*
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}
}
Pro Tips
1. Maintain consistent response structure: Use the same wrapper for both success and error responses
2. Use string error codes: "NOT_FOUND" is easier for clients to handle than numeric codes
3. Idempotency-Key: Use a unique key header to prevent duplicate POST requests
// Prevent duplicate requests with idempotency key
func handleCreateOrder(w http.ResponseWriter, r *http.Request) {
idempotencyKey := r.Header.Get("Idempotency-Key")
if idempotencyKey == "" {
WriteError(w, http.StatusBadRequest, "MISSING_IDEMPOTENCY_KEY",
"Idempotency-Key header is required")
return
}
// Check if the request has already been processed in cache
// if cached := cache.Get(idempotencyKey); cached != nil { ... }
}
4. Request ID propagation: Assign a unique ID to every request and include it in the response header
import "github.com/google/uuid"
func RequestIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := uuid.New().String()
w.Header().Set("X-Request-ID", id)
next.ServeHTTP(w, r)
})
}