Go: مشروع متكامل: واجهة برمجة تطبيقات التجارة الإلكترونية…
آخر تحديث: 2026-08-26
في الجزء السابق، قمنا بإعداد إطار العمل لواجهة برمجة تطبيقات التجارة الإلكترونية. والهدف من هذا الجزء هو جعلها آمنة وسهلة الصيانة وقابلة للاختبار.
كشفت واجهة برمجة تطبيقات التجارة الإلكترونية الخاصة بـ«بوب» عن مشكلات خطيرة خلال عملية تدقيق أمني: عدم وجود مصادقة، وعدم وجود ضوابط وصول، وعدم وجود تقييد لعدد الطلبات. حان الوقت لتعزيز مسار البرمجيات الوسيطة.
1. ستتعلم
- تكامل البرمجيات الوسيطة لمصادقة JWT
- RBAC (التحكم في الوصول القائم على الأدوار)
- مسار عمل البرمجيات الوسيطة (التسجيل + الاستعادة + المصادقة + RBAC + تحديد معدل الاستخدام)
- ترحيل قاعدة بيانات SQL (golang-migrate)
- اختبار التكامل (httptest + قاعدة بيانات الاختبار)
- برمجيات وسيطة لترقيم الصفحات
2. القصة: تقرير التدقيق الأمني
(1) المشكلة: «لا تتطلب أي من واجهات برمجة التطبيقات (API) المصادقة، لذا يمكن لأي شخص حذف المنتجات.»
في اليوم الأول من إطلاق نظام MVP الخاص بـ«بوب»، أرسل فريق الأمن تقريرًا تدقيقيًّا:
"ثغرة أمنية خطيرة: لا تتطلب عملية الحذف DELETE /api/v1/products/1 أي مصادقة. يمكن لأي شخص حذف المنتجات. ثغرة أمنية متوسطة: لا يوجد تحديد لمعدل الطلبات؛ يمكن للمهاجم استخدام هجوم القوة الغاشمة لاختراق واجهة برمجة تطبيقات (API) تسجيل الدخول. التوصية: مصادقة JWT + أذونات RBAC + تحديد معدل الطلبات."
ألقى بوب نظرة على «الباب الخلفي» الذي تركته النسخة السابقة:
// 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) هدف الدرس: إكمال مسار البرمجيات الوسيطة
Request → Recovery → Logging → CORS → Auth → RBAC → RateLimit → Handler
خطط هذا الأسبوع:
- برنامج وسيط للمصادقة باستخدام JWT (يحل محل معرّف المستخدم المبرمج بشكل ثابت)
- التحكم في الأذونات وفقًا لنظام RBAC (أدوار المسؤولين/العملاء)
- ترحيل قاعدة بيانات SQL (إدارة مخطط الجداول مع التحكم في الإصدارات)
- اختبار التكامل (httptest + قاعدة بيانات مؤقتة)
- البرمجيات الوسيطة لترقيم الصفحات
3. التنفيذ الكامل
▶ مثال: 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 string `json:"role"`
Email string `json:"email"`
}
type TokenPair struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}
type jwtService struct {
secret []byte
accessTTL time.Duration
refreshTTL time.Duration
}
func NewJWTService(secret string) *jwtService {
return &jwtService{
secret: []byte(secret),
accessTTL: 15 * time.Minute,
refreshTTL: 7 * 24 * time.Hour,
}
}
func (s *jwtService) GenerateToken(claims Claims) (string, error) {
header := map[string]string{"alg": "HS256", "typ": "JWT"}
headerJSON, _ := json.Marshal(header)
payload := map[string]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 string) (*Claims, error) {
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 string `json:"role"`
Email string `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) string {
return strings.TrimRight(base64.URLEncoding.EncodeToString(data), "=")
}
func base64URLDecode(s string) ([]byte, error) {
switch len(s) % 4 {
case 2:
s += "=="
case 3:
s += "="
}
return base64.URLEncoding.DecodeString(s)
}
▶ مثال: مسار البرمجيات الوسيطة
// 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})
}
▶ مثال: ترحيل قاعدة بيانات SQL
// 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
}
▶ مثال: اختبار التكامل
⚙️ المتطلبات الأساسية: قم بتشغيل
go get github.com/mattn/go-sqlite3(يتطلب CGO؛ أو استخدمmodernc.org/sqliteكبديل للحصول على برنامج تشغيل مخصص للعبة «غو» فقط)
// internal/handler/integration_test.go
package handler
import (
"bytes"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"ecommerce/internal/auth"
"ecommerce/internal/middleware"
"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, شرح 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, status 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 middleware
return middleware.Chain(mux,
middleware.Recovery,
middleware.Logging,
middleware.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[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
data := result["data"].(map[string]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 middleware;
// 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) يمر الطلب عبر مسار البرمجيات الوسيطة
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
❓ أسئلة شائعة
jwtService.ValidateToken، وتقوم بإدراج Claims في سياق الطلب باستخدام context.WithValue. ثم تقوم المعالجات اللاحقة باسترداد معلومات المستخدم باستخدام r.Context().Value(middleware.UserClaimsKey).role في المطالبات موجودًا ضمن قائمة الأدوار المسموح بها. ويمكن أن تتضمن عمليات التنفيذ الأكثر تعقيدًا مصفوفة أذونات (حيث يكون لكل دور قائمة بالأذونات، ويقوم برنامج الوسيط بالتحقق من وجود أذونات محددة).golang-migrate/migrate — الأكثر استخدامًا، وتدعم قواعد بيانات متعددة ومصادر ترحيل متنوعة؛ (2) الترحيل اليدوي — النهج المستخدم في هذا الدرس، وهو مناسب للمشاريع الصغيرة؛ (3) pressly/goose — غنية بالميزات. بالنسبة للمشاريع الكبيرة، نوصي باستخدام golang-migrate.httptest.NewServer أو httptest.NewRecorder لمحاكاة طلبات HTTP، مع استخدام قاعدة بيانات مؤقتة (SQLite في وضع :memory:). مسار الاختبار: إعداد قاعدة البيانات → إدخال التبعيات → إنشاء معالج → إرسال طلب HTTP → التحقق من الاستجابة.page وper_page لحساب الإزاحة. ثم تضيف LIMIT ? OFFSET ? إلى الاستعلام في طبقة الخدمة. ويمكن للمعالج (Handler) إضافة رأس X-Total-Count أو معلومات تعريفية إلى الاستجابة. البرمجيات الوسيطة مسؤولة فقط عن تحليل المعلمات؛ وهي لا تُعدّل منطق الاستعلام.📖 ملخص
- برنامج وسيط JWT Auth: تحليل الرموز المميزة، وإدراج المطالبات
- RBAC: تقوم البرمجية الوسيطة RequireRole بالتحقق من الأدوار
- مسار البرامج الوسيطة: الاستعادة → التسجيل → CORS → المصادقة → RBAC → RateLimit
- عمليات ترحيل SQL: إدارة التغييرات في بنية الجداول مع التحكم في الإصدارات
- اختبار التكامل: httptest + قاعدة بيانات مؤقتة
- ترقيم الصفحات: تحليل المعلمة
query+LIMIT/OFFSET
📝 تمارين
-
أساسي (مستوى الصعوبة ⭐): قم بدمج البرمجيات الوسيطة الخاصة بالمصادقة (Auth) من هذا الدرس في واجهة برمجة تطبيقات التجارة الإلكترونية (API) من الدرس السابق. قم بإرجاع رمز JWT عند التسجيل، واشترط وجود رمز Bearer لجميع نقاط النهاية
/api/v1/orders/*. قم بإرجاع خطأ 401 للطلبات التي لا تحتوي على رمز. -
متقدم (صعوبة ⭐⭐): تنفيذ اختبار تكامل كامل لمسار البرمجيات الوسيطة. المتطلبات: (1) استخدام
httptest+ قاعدة بيانات SQLite مؤقتة؛ (2) اختبار مسار المصادقة (التسجيل → تسجيل الدخول → الحصول على الرمز المميز → الوصول إلى نقاط النهاية المحمية باستخدام الرمز المميز)؛ (3) اختبر سيناريوهات رفض الأذونات (محاولة «عميل» الوصول إلى نقطة نهاية «المسؤول»); (4) اختبر سيناريوهات تحديد معدل الطلبات (إرجاع خطأ 429 عند وجود عدد كبير من الطلبات خلال فترة قصيرة). -
التحدي (الصعوبة ⭐⭐⭐): تنفيذ ترحيل قاعدة البيانات باستخدام golang-migrate. المتطلبات: (1) تثبيت واجهة سطر الأوامر
golang-migrate/migrate؛ (2) كتابة 3 ملفات ترحيل (لإنشاء الجداولusersوproductsوorders)؛ (3) ترحيل ملف Go الثنائي (حزمةembed)؛ (4) التأكد من أنcmd/migrate/main.goيدعم الأمرينupوdown؛ (5) إجراء اختبار التكامل باستخدام مخطط قاعدة البيانات التي تم ترحيلها.