Go: Integrated Project: E-commerce API (Part 2)
Last updated: 2026-08-26
In the previous part, we set up the إطار عمل for the e-commerce API. The goal of this part is to make it secure, maintainable, and testable.
Bob's e-commerce API revealed serious issues during a security audit: no authentication, no access controls, and no طلب throttling. It's time to shore up the وسيط pipeline.
1. You will learn
- JWT Auth Middleware Integration
- RBAC (Role-Based Access Control)
- Global وسيط pipeline (Logging + Recovery + Auth + RBAC + RateLimit)
- SQL Database Migration (golang-migrate)
- Integration Testing (httptest + test DB)
- Pagination وسيط
2. Story: Security Audit Report
(1) Pain point: "None of the APIs require authentication, so anyone can delete products."
On the first day Bob's MVP went live, the security team sent an audit report:
"Severe vulnerability: DELETE /api/v1/products/1 does not require authentication. Anyone can delete products. Moderate vulnerability: There is no طلب rate limiting; an attacker could brute-force the login API. Recommendation: JWT authentication + RBAC permissions + rate limiting."
Bob took a look at the backdoor left behind by the previous version:
// Bad code: no authentication, anyone can place an order
func (h *OrderHandler) CreateOrder(w http.ResponseWriter, r *http.Request) {
userID := 1 // Hardcoded! All orders go to user 1
// Business logic...
}
(2) Lesson Objective: Complete the middleware pipeline
Request → Recovery → Logging → CORS → Auth → RBAC → RateLimit → Handler
Plans for This Week:
- JWT Authentication Middleware (Replaces Hard-Coded userID)
- RBAC Permission Control (admin / customer roles)
- SQL Database Migration (Version-Controlled Table Schema Management)
- Integration Testing (httptest + Temporary Database)
- Pagination Middleware
3. Full Implementation
▶ Example: JWT Toolkit
// internal/auth/jwt.go
package auth
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"time"
)
type Claims struct {
UserID int `json:"user_id"`
Role سلسلة `json:"role"`
Email سلسلة `json:"email"`
}
type TokenPair struct {
AccessToken سلسلة `json:"access_token"`
RefreshToken سلسلة `json:"refresh_token"`
}
type jwtService struct {
secret []byte
accessTTL time.Duration
refreshTTL time.Duration
}
func NewJWTService(secret سلسلة) *jwtService {
return &jwtService{
secret: []byte(secret),
accessTTL: 15 * time.Minute,
refreshTTL: 7 * 24 * time.Hour,
}
}
func (s *jwtService) GenerateToken(claims Claims) (سلسلة, خطأ) {
header := map[سلسلة]سلسلة{"alg": "HS256", "typ": "JWT"}
headerJSON, _ := json.Marshal(header)
payload := map[سلسلة]interface{}{
"user_id": claims.UserID,
"role": claims.Role,
"email": claims.Email,
"exp": time.Now().Add(s.accessTTL).Unix(),
"iat": time.Now().Unix(),
}
payloadJSON, _ := json.Marshal(payload)
headerEnc := base64URLEncode(headerJSON)
payloadEnc := base64URLEncode(payloadJSON)
message := headerEnc + "." + payloadEnc
mac := hmac.New(sha256.New, s.secret)
mac.Write([]byte(message))
sig := base64URLEncode(mac.Sum(nil))
return message + "." + sig, nil
}
func (s *jwtService) ValidateToken(token سلسلة) (*Claims, خطأ) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("invalid token")
}
message := parts[0] + "." + parts[1]
mac := hmac.New(sha256.New, s.secret)
mac.Write([]byte(message))
expected := base64URLEncode(mac.Sum(nil))
if !hmac.Equal([]byte(parts[2]), []byte(expected)) {
return nil, fmt.Errorf("invalid signature")
}
payloadJSON, err := base64URLDecode(parts[1])
if err != nil {
return nil, err
}
var payload struct {
UserID int `json:"user_id"`
Role سلسلة `json:"role"`
Email سلسلة `json:"email"`
Exp float64 `json:"exp"`
}
if err := json.Unmarshal(payloadJSON, &payload); err != nil {
return nil, err
}
if time.Now().Unix() > int64(payload.Exp) {
return nil, fmt.Errorf("token expired")
}
return &Claims{
UserID: payload.UserID,
Role: payload.Role,
Email: payload.Email,
}, nil
}
func base64URLEncode(data []byte) سلسلة {
return strings.TrimRight(base64.URLEncoding.EncodeToString(data), "=")
}
func base64URLDecode(s سلسلة) ([]byte, خطأ) {
switch len(s) % 4 {
case 2:
s += "=="
case 3:
s += "="
}
return base64.URLEncoding.DecodeString(s)
}
▶ Example: Middleware Pipeline
// internal/middleware/middleware.go
package middleware
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"sync"
"time"
"ecommerce/internal/auth"
)
type contextKey string
const UserClaimsKey contextKey = "user_claims"
// ---------- Middleware type ----------
type Middleware func(http.Handler) http.Handler
func Chain(h http.Handler, mws ...Middleware) http.Handler {
for i := len(mws) - 1; i >= 0; i-- {
h = mws[i](h)
}
return h
}
// ---------- Recovery ----------
func Recovery(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
log.Printf("[PANIC] %v", err)
writeError(w, http.StatusInternalServerError, "internal error")
}
}()
next.ServeHTTP(w, r)
})
}
// ---------- Logging ----------
func Logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
log.Printf("[%s] %s %s", r.Method, r.URL.Path, r.RemoteAddr)
next.ServeHTTP(w, r)
log.Printf("[%s] %s → %v", r.Method, r.URL.Path, time.Since(start))
})
}
// ---------- CORS ----------
func CORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
// ---------- Auth (JWT) ----------
func Auth(jwtService *auth.JWTService) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if !strings.HasPrefix(authHeader, "Bearer ") {
writeError(w, http.StatusUnauthorized, "missing token")
return
}
token := authHeader[7:]
claims, err := jwtService.ValidateToken(token)
if err != nil {
writeError(w, http.StatusUnauthorized, err.Error())
return
}
ctx := context.WithValue(r.Context(), UserClaimsKey, claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// ---------- RBAC ----------
func RequireRole(roles ...string) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims, ok := r.Context().Value(UserClaimsKey).(*auth.Claims)
if !ok {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
for _, role := range roles {
if claims.Role == role {
next.ServeHTTP(w, r)
return
}
}
writeError(w, http.StatusForbidden, "insufficient permissions")
})
}
}
// ---------- Token bucket rate limiting ----------
type TokenBucket struct {
mu sync.Mutex
tokens float64
maxTokens float64
refillRate float64
lastRefill time.Time
}
type IPRateLimiter struct {
mu sync.RWMutex
buckets map[string]*TokenBucket
rate float64
burst int
}
var globalLimiter = NewIPRateLimiter(100, 200)
func NewIPRateLimiter(rate float64, burst int) *IPRateLimiter {
return &IPRateLimiter{
buckets: make(map[string]*TokenBucket),
rate: rate,
burst: burst,
}
}
func (rl *IPRateLimiter) getBucket(ip string) *TokenBucket {
rl.mu.Lock()
defer rl.mu.Unlock()
b, ok := rl.buckets[ip]
if !ok {
b = &TokenBucket{
tokens: float64(rl.burst),
maxTokens: float64(rl.burst),
refillRate: rl.rate,
lastRefill: time.Now(),
}
rl.buckets[ip] = b
}
return b
}
func (b *TokenBucket) allow() bool {
b.mu.Lock()
defer b.mu.Unlock()
now := time.Now()
elapsed := now.Sub(b.lastRefill).Seconds()
b.tokens = min(b.tokens+elapsed*b.refillRate, b.maxTokens)
b.lastRefill = now
if b.tokens >= 1 {
b.tokens--
return true
}
return false
}
func min(a, b float64) float64 {
if a < b {
return a
}
return b
}
func RateLimit(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := r.RemoteAddr
if !globalLimiter.getBucket(ip).allow() {
writeError(w, http.StatusTooManyRequests, "rate limit exceeded")
return
}
next.ServeHTTP(w, r)
})
}
// ---------- Pagination ----------
type Pagination struct {
Page int `json:"page"`
PerPage int `json:"per_page"`
Offset int `json:"-"`
}
func ParsePagination(r *http.Request) Pagination {
page := 1
perPage := 20
if p := r.URL.Query().Get("page"); p != "" {
if v, err := parseInt(p); err == nil && v > 0 {
page = v
}
}
if pp := r.URL.Query().Get("per_page"); pp != "" {
if v, err := parseInt(pp); err == nil && v > 0 && v <= 100 {
perPage = v
}
}
return Pagination{
Page: page,
PerPage: perPage,
Offset: (page - 1) * perPage,
}
}
func parseInt(s string) (int, error) {
var n int
for _, c := range s {
if c < '0' || c > '9' {
return 0, fmt.Errorf("not a number")
}
n = n*10 + int(c-'0')
}
return n, nil
}
// ---------- Utility ----------
func writeError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
▶ Example: SQL Database Migration
// internal/migration/migration.go
package migration
import (
"database/sql"
"fmt"
"log"
)
type Migration struct {
Version int
SQL string
}
var migrations = []Migration{
{
Version: 1,
SQL: `CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'customer',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
},
{
Version: 2,
SQL: `CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
price REAL NOT NULL,
stock INTEGER NOT NULL DEFAULT 0,
category_id INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (category_id) REFERENCES categories(id)
)`,
},
{
Version: 3,
SQL: `CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
quantity INTEGER NOT NULL,
total_price REAL NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (product_id) REFERENCES products(id)
)`,
},
{
Version: 4,
SQL: `CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
description TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
},
}
func RunMigrations(db *sql.DB) error {
// Create version table
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`)
if err != nil {
return fmt.Errorf("create migrations table: %w", err)
}
for _, m := range migrations {
// Check if already applied
var exists bool
err := db.QueryRow("SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = ?)", m.Version).Scan(&exists)
if err != nil {
return err
}
if exists {
continue
}
// Apply migration
if _, err := db.Exec(m.SQL); err != nil {
return fmt.Errorf("migration %d: %w", m.Version, err)
}
// Record version
if _, err := db.Exec("INSERT INTO schema_migrations (version) VALUES (?)", m.Version); err != nil {
return err
}
log.Printf("[Migration] Version %d applied", m.Version)
}
return nil
}
▶ Example: Integration Testing
⚙️ Prerequisite: Run
go get github.com/mattn/go-sqlite3(requires CGO; alternatively usemodernc.org/sqlitefor a pure Go driver)
// internal/handler/integration_test.go
package handler
import (
"bytes"
"قاعدة بيانات/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"ecommerce/internal/auth"
"ecommerce/internal/وسيط"
"ecommerce/internal/repository"
"ecommerce/internal/service"
_ "github.com/mattn/go-sqlite3"
)
func setupTestDB(t *testing.T) *sql.DB {
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatal(err)
}
// Initialize tables
_, err = db.Exec(`
CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT UNIQUE NOT NULL, password TEXT NOT NULL, name TEXT NOT NULL, role TEXT DEFAULT 'customer');
CREATE TABLE products (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, description TEXT, price REAL NOT NULL, stock INTEGER DEFAULT 0);
CREATE TABLE orders (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, product_id INTEGER NOT NULL, quantity INTEGER NOT NULL, total_price REAL NOT NULL, حالة TEXT DEFAULT 'pending');
`)
if err != nil {
t.Fatal(err)
}
return db
}
func setupTestApp(db *sql.DB) http.Handler {
userRepo := repository.NewUserRepository(db)
productRepo := repository.NewProductRepository(db)
orderRepo := repository.NewOrderRepository(db)
userSvc := service.NewUserService(userRepo)
productSvc := service.NewProductService(productRepo)
orderSvc := service.NewOrderService(orderRepo, productRepo, userRepo)
userHandler := NewUserHandler(userSvc)
productHandler := NewProductHandler(productSvc)
orderHandler := NewOrderHandler(orderSvc)
mux := http.NewServeMux()
userHandler.Register(mux)
productHandler.Register(mux)
orderHandler.Register(mux)
// Apply وسيط
return وسيط.Chain(mux,
وسيط.Recovery,
وسيط.Logging,
وسيط.CORS,
)
}
func TestRegisterAndLogin(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
app := setupTestApp(db)
// 1. Register
registerBody := `{"email":"test@example.com","password":"password123","name":"Test User"}`
req := httptest.NewRequest("POST", "/api/v1/register", bytes.NewBufferString(registerBody))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
app.ServeHTTP(resp, req)
if resp.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d", resp.Code)
}
// 2. Login
loginBody := `{"email":"test@example.com","password":"password123"}`
req = httptest.NewRequest("POST", "/api/v1/login", bytes.NewBufferString(loginBody))
req.Header.Set("Content-Type", "application/json")
resp = httptest.NewRecorder()
app.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.Code)
}
var result map[سلسلة]interface{}
json.NewDecoder(resp.Body).Decode(&result)
data := result["data"].(map[سلسلة]interface{})
if data["email"] != "test@example.com" {
t.Errorf("expected test@example.com, got %v", data["email"])
}
}
func TestCreateOrder_RequiresAuth(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
app := setupTestApp(db)
// Request without Token should return 401
orderBody := `{"product_id":1,"quantity":1}`
req := httptest.NewRequest("POST", "/api/v1/orders", bytes.NewBufferString(orderBody))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
// Note: The current version does not have Auth وسيط;
// it needs to be added in the integration test environment
_ = resp
_ = app
}
sequenceDiagram
participant Client
participant MW as Middleware Pipeline
participant AuthS as Auth Service
participant H as Handler
Client->>MW: POST /api/v1/orders (Bearer token)
MW->>MW: Recovery / Logging / CORS
MW->>AuthS: Auth middleware validates JWT
AuthS-->>MW: Claims {user_id, role}
MW->>MW: RBAC checks customer role
MW->>MW: RateLimit check
MW->>H: Passed! Inject user_id into Context
H->>H: Create order (using user_id)
H-->>MW: 201 Created
MW-->>Client: JSON response
(5) The طلب passes through the وسيط pipeline
Request
↓ Recovery (catch panic)
↓ Logging (log + duration)
↓ CORS (cross-origin)
↓ Auth (JWT verification → inject Claims)
↓ RBAC (check role)
↓ RateLimit (token bucket)
↓ Handler (business logic)
Response
❓ FAQ
jwtService.ValidateToken, and injects the Claims into the request context using context.WithValue. Subsequent handlers retrieve user information using r.Context().Value(middleware.UserClaimsKey).role field in the Claims is in the list of allowed roles. More complex implementations can incorporate a permission matrix (where each role has a list of permissions, and the middleware checks for specific permissions).golang-migrate/migrate—the most commonly used, supports multiple databases and migration sources; (2) Manual migration—the approach used in this lesson, suitable for small projects; (3) pressly/goose—rich in features. For large projects, we recommend golang-migrate.httptest.NewServer or httptest.NewRecorder to simulate HTTP requests, combined with a temporary database (SQLite in :memory: mode). Testing flow: set up the database → inject dependencies → create a handler → send an HTTP request → verify the response.page and per_page to calculate the offset. It then adds LIMIT ? OFFSET ? to the query at the Service layer. The Handler can add an X-Total-Count header or meta information to the response. The middleware is only responsible for parsing parameters; it does not modify the query logic.📖 Summary
- JWT Auth middleware: Parse tokens, inject claims
- RBAC: The RequireRole middleware checks roles
- Middleware pipeline: Recovery → Logging → CORS → Auth → RBAC → RateLimit
- SQL Migrations: Version-controlled management of table structure changes
- Integration testing: httptest + temporary database
- Pagination: Parsing the
queryParameter +LIMIT/OFFSET
📝 Exercises
-
Basic (Difficulty ⭐): Integrate the Auth middleware from this lesson into the e-commerce API from the previous lesson. Return a JWT token upon registration, and require a Bearer token for all
/api/v1/orders/*endpoints. Return a 401 error for requests without a token. -
Advanced (Difficulty ⭐⭐): Implement a complete integration test for the middleware pipeline. Requirements: (1) Use
httptest+ a temporary SQLite database; (2) Test the authentication flow (registration → login → obtain token → access protected endpoints using the token); (3) Test permission denial scenarios (a "customer" attempting to access an "admin" endpoint); (4) Test rate-limiting scenarios (a large number of requests within a short period returning a 429 error). -
Challenge (Difficulty ⭐⭐⭐): Implement database migration using golang-migrate. Requirements: (1) Install the
golang-migrate/migrateCLI; (2) Write 3 migration files (to create theusers,products, andorderstables); (3) Migrate the Go binary (embedpackage); (4) Ensurecmd/migrate/main.gosupports theupanddowncommands; (5) Perform integration testing using the migrated database schema.