Go SDK

Gin Integration

Complete production-ready Gin integration with Go 1.22+ patterns, structured logging, graceful shutdown, and modular architecture.

This example uses github.com/sentdm/sent-dm-go with modern Go patterns including service layers, middleware chains, and proper error handling.

Project Structure

sent-gin-service/
├── cmd/api/main.go              # Entry point
├── internal/
│   ├── config/config.go         # Viper configuration
│   ├── handlers/
│   │   ├── message_handler.go   # HTTP handlers
│   │   └── webhook_handler.go   # Webhook handlers
│   ├── middleware/              # Middleware chain
│   ├── service/                 # Business logic
│   └── models/                  # Request/response models
├── pkg/sentdm/client.go         # Sent client wrapper
├── go.mod
├── .env.example
├── Dockerfile
└── Makefile

Setup

go mod init sent-gin-service
go get github.com/gin-gonic/gin github.com/sentdm/sent-dm-go github.com/spf13/viper
go get github.com/swaggo/swag github.com/swaggo/gin-swagger golang.org/x/time/rate

Configuration

// internal/config/config.go
package config

import (
	"fmt"
	"time"
	"github.com/spf13/viper"
)

type Config struct {
	Server    ServerConfig    `mapstructure:"server"`
	Sent      SentConfig      `mapstructure:"sent"`
	Log       LogConfig       `mapstructure:"log"`
	RateLimit RateLimitConfig `mapstructure:"rate_limit"`
}

type ServerConfig struct {
	Port            string        `mapstructure:"port"`
	Host            string        `mapstructure:"host"`
	ReadTimeout     time.Duration `mapstructure:"read_timeout"`
	WriteTimeout    time.Duration `mapstructure:"write_timeout"`
	ShutdownTimeout time.Duration `mapstructure:"shutdown_timeout"`
}

type SentConfig struct {
	APIKey        string `mapstructure:"api_key"`
	WebhookSecret string `mapstructure:"webhook_secret"`
}

type LogConfig struct {
	Level  string `mapstructure:"level"`
	Format string `mapstructure:"format"`
}

type RateLimitConfig struct {
	RequestsPerSecond float64       `mapstructure:"requests_per_second"`
	BurstSize         int           `mapstructure:"burst_size"`
	TTL               time.Duration `mapstructure:"ttl"`
}

func Load() (*Config, error) {
	viper.SetDefault("server.port", "8080")
	viper.SetDefault("server.host", "0.0.0.0")
	viper.SetDefault("server.read_timeout", "30s")
	viper.SetDefault("server.write_timeout", "30s")
	viper.SetDefault("server.shutdown_timeout", "30s")
	viper.SetDefault("log.level", "info")
	viper.SetDefault("log.format", "json")
	viper.SetDefault("rate_limit.requests_per_second", 10)
	viper.SetDefault("rate_limit.burst_size", 20)
	viper.SetEnvPrefix("")
	viper.AutomaticEnv()
	viper.SetConfigFile(".env")
	_ = viper.ReadInConfig()

	var cfg Config
	if err := viper.Unmarshal(&cfg); err != nil {
		return nil, fmt.Errorf("failed to unmarshal config: %w", err)
	}
	return &cfg, nil
}

func (c *Config) Addr() string {
	return fmt.Sprintf("%s:%s", c.Server.Host, c.Server.Port)
}

Environment Variables

# .env.example
SERVER_PORT=8080
SERVER_HOST=0.0.0.0
SERVER_READ_TIMEOUT=30s
SERVER_WRITE_TIMEOUT=30s

SENT_API_KEY=your_api_key_here
SENT_DM_WEBHOOK_SECRET=your_webhook_secret_here

LOG_LEVEL=info
LOG_FORMAT=json

RATE_LIMIT_REQUESTS_PER_SECOND=10
RATE_LIMIT_BURST_SIZE=20

Models

// internal/models/message.go
package models

import (
	"time"
	"github.com/sentdm/sent-dm-go"
)

type SendMessageRequest struct {
	To           []string          `json:"to" binding:"required,min=1"`
	TemplateID   string            `json:"template_id" binding:"required,uuid"`
	TemplateName string            `json:"template_name" binding:"required,min=1,max=100"`
	Parameters   map[string]string `json:"parameters,omitempty"`
	Channels     []string          `json:"channels,omitempty" binding:"dive,oneof=sms whatsapp email"`
	TestMode     bool              `json:"test_mode"`
}

type SendMessageResponse struct {
	MessageID string    `json:"message_id"`
	Status    string    `json:"status"`
	Channel   string    `json:"channel,omitempty"`
	SentAt    time.Time `json:"sent_at"`
	TestMode  bool      `json:"test_mode"`
}

type BulkMessageRequest struct {
	Recipients []RecipientRequest `json:"recipients" binding:"required,min=1,max=100"`
	TemplateID string             `json:"template_id" binding:"required,uuid"`
}

type RecipientRequest struct {
	PhoneNumber string            `json:"phone_number" binding:"required"`
	Parameters  map[string]string `json:"parameters,omitempty"`
}

type ErrorResponse struct {
	Error      string            `json:"error"`
	Message    string            `json:"message"`
	StatusCode int               `json:"status_code"`
	Details    map[string]string `json:"details,omitempty"`
	RequestID  string            `json:"request_id,omitempty"`
}

func (r *SendMessageRequest) ToSentParams() sentdm.MessageSendParams {
	params := sentdm.MessageSendParams{
		To: r.To,
		Template: sentdm.MessageSendParamsTemplate{
			ID:         sentdm.String(r.TemplateID),
			Name:       sentdm.String(r.TemplateName),
			Parameters: r.Parameters,
		},
		TestMode: sentdm.Bool(r.TestMode),
	}
	if len(r.Channels) > 0 {
		params.Channels = sentdm.StringSlice(r.Channels)
	}
	return params
}
// internal/models/webhook.go
package models

import "time"

type WebhookEvent struct {
	Type      string           `json:"type" binding:"required"`
	ID        string           `json:"id" binding:"required,uuid"`
	Timestamp time.Time        `json:"timestamp"`
	Data      WebhookEventData `json:"data"`
}

type WebhookEventData struct {
	MessageID string `json:"message_id,omitempty"`
	Status    string `json:"status,omitempty"`
	Channel   string `json:"channel,omitempty"`
	Error     *struct {
		Code    string `json:"code"`
		Message string `json:"message"`
	} `json:"error,omitempty"`
}

type WebhookResponse struct {
	Received bool   `json:"received"`
	EventID  string `json:"event_id,omitempty"`
}

Client Wrapper

// pkg/sentdm/client.go
package sentdm

import (
	"context"
	"fmt"
	"log/slog"
	"github.com/sentdm/sent-dm-go"
	"github.com/sentdm/sent-dm-go/option"
)

type Client struct {
	*sentdm.Client
	logger *slog.Logger
}

func NewClient(apiKey string, logger *slog.Logger) (*Client, error) {
	if apiKey == "" {
		return nil, fmt.Errorf("API key is required")
	}
	client := sentdm.NewClient(option.WithAPIKey(apiKey))
	return &Client{Client: client, logger: logger}, nil
}

func (c *Client) SendMessage(ctx context.Context, params sentdm.MessageSendParams) (*sentdm.MessageSendResponse, error) {
	c.logger.Debug("sending message", slog.Any("to", params.To), slog.String("template_id", params.Template.ID.Value))
	response, err := c.Messages.Send(ctx, params)
	if err != nil {
		c.logger.Error("failed to send message", slog.String("error", err.Error()))
		return nil, fmt.Errorf("send message: %w", err)
	}
	c.logger.Info("message sent", slog.String("message_id", response.Data.Messages[0].ID))
	return response, nil
}

Service Layer

// internal/service/message_service.go
package service

import (
	"context"
	"fmt"
	"log/slog"
	"time"
	"sent-gin-service/internal/models"
	"sent-gin-service/pkg/sentdm"
)

type MessageService struct {
	client *sentdm.Client
	logger *slog.Logger
}

func NewMessageService(client *sentdm.Client, logger *slog.Logger) *MessageService {
	return &MessageService{client: client, logger: logger}
}

func (s *MessageService) SendMessage(ctx context.Context, req *models.SendMessageRequest) (*models.SendMessageResponse, error) {
	ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
	defer cancel()

	response, err := s.client.SendMessage(ctx, req.ToSentParams())
	if err != nil {
		return nil, fmt.Errorf("send message: %w", err)
	}

	message := response.Data.Messages[0]
	return &models.SendMessageResponse{
		MessageID: message.ID,
		Status:    message.Status,
		Channel:   message.Channel,
		SentAt:    time.Now(),
		TestMode:  req.TestMode,
	}, nil
}

func (s *MessageService) SendBulkMessages(ctx context.Context, req *models.BulkMessageRequest) ([]models.SendMessageResponse, []error) {
	var results []models.SendMessageResponse
	var errs []error

	for _, recipient := range req.Recipients {
		singleReq := &models.SendMessageRequest{
			To:         []string{recipient.PhoneNumber},
			TemplateID: req.TemplateID,
			Parameters: recipient.Parameters,
		}
		resp, err := s.SendMessage(ctx, singleReq)
		if err != nil {
			errs = append(errs, fmt.Errorf("failed to send to %s: %w", recipient.PhoneNumber, err))
			continue
		}
		results = append(results, *resp)
	}
	return results, errs
}
// internal/service/webhook_service.go
package service

import (
	"context"
	"encoding/json"
	"fmt"
	"log/slog"
	"time"
	"sent-gin-service/internal/models"
)

type WebhookService struct {
	logger *slog.Logger
}

func NewWebhookService(logger *slog.Logger) *WebhookService {
	return &WebhookService{logger: logger}
}

func (s *WebhookService) ProcessEvent(ctx context.Context, event *models.WebhookEvent) {
	go s.handleEvent(event)
}

func (s *WebhookService) handleEvent(event *models.WebhookEvent) {
	logger := s.logger.With(slog.String("event_id", event.ID), slog.String("event_type", event.Type))
	logger.Info("processing webhook event")

	switch event.Type {
	case "message.delivered":
		logger.Info("message delivered", slog.String("message_id", event.Data.MessageID))
	case "message.failed":
		if event.Data.Error != nil {
			logger.Error("message failed", slog.String("error_code", event.Data.Error.Code))
		}
	default:
		logger.Warn("unhandled webhook event type")
	}
}

func (s *WebhookService) ParseEvent(body []byte) (*models.WebhookEvent, error) {
	var event models.WebhookEvent
	if err := json.Unmarshal(body, &event); err != nil {
		return nil, fmt.Errorf("unmarshal event: %w", err)
	}
	if event.Timestamp.IsZero() {
		event.Timestamp = time.Now()
	}
	return &event, nil
}

Middleware

// internal/middleware/middleware.go
package middleware

import (
	"errors"
	"fmt"
	"log/slog"
	"net/http"
	"strings"
	"sync"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/go-playground/validator/v10"
	"github.com/google/uuid"
	"golang.org/x/time/rate"
	"sent-gin-service/internal/models"
)

// RequestLogger returns structured logging middleware
func RequestLogger(logger *slog.Logger) gin.HandlerFunc {
	return func(c *gin.Context) {
		start := time.Now()
		requestID := c.GetHeader("X-Request-ID")
		if requestID == "" {
			requestID = uuid.New().String()
		}
		c.Set("requestID", requestID)
		c.Header("X-Request-ID", requestID)

		logger := logger.With(
			slog.String("request_id", requestID),
			slog.String("method", c.Request.Method),
			slog.String("path", c.Request.URL.Path),
		)
		c.Set("logger", logger)
		c.Next()

		logger.Info("request completed",
			slog.Int("status", c.Writer.Status()),
			slog.Duration("duration", time.Since(start)),
		)
	}
}

// Recovery returns panic recovery middleware
func Recovery(logger *slog.Logger) gin.HandlerFunc {
	return func(c *gin.Context) {
		defer func() {
			if err := recover(); err != nil {
				logger.Error("panic recovered", slog.String("error", fmt.Sprintf("%v", err)))
				c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
					"error": "Internal Server Error",
				})
			}
		}()
		c.Next()
	}
}

// CORS returns CORS middleware
func CORS() 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, OPTIONS")
		c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Request-ID")
		if c.Request.Method == "OPTIONS" {
			c.AbortWithStatus(204)
			return
		}
		c.Next()
	}
}

// APIKeyAuth returns API key authentication middleware
func APIKeyAuth(validKey string) gin.HandlerFunc {
	return func(c *gin.Context) {
		key := c.GetHeader("Authorization")
		if strings.HasPrefix(key, "Bearer ") {
			key = strings.TrimPrefix(key, "Bearer ")
		}
		if key == "" || (validKey != "" && key != validKey) {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
			return
		}
		c.Next()
	}
}

// RateLimiter implements per-IP rate limiting
type RateLimiter struct {
	limiters map[string]*rate.Limiter
	mu       sync.RWMutex
	rate     rate.Limit
	burst    int
}

func NewRateLimiter(r rate.Limit, burst int, ttl time.Duration) *RateLimiter {
	rl := &RateLimiter{limiters: make(map[string]*rate.Limiter), rate: r, burst: burst}
	go rl.cleanup(ttl)
	return rl
}

func (rl *RateLimiter) getLimiter(ip string) *rate.Limiter {
	rl.mu.Lock()
	defer rl.mu.Unlock()
	if limiter, exists := rl.limiters[ip]; exists {
		return limiter
	}
	limiter := rate.NewLimiter(rl.rate, rl.burst)
	rl.limiters[ip] = limiter
	return limiter
}

func (rl *RateLimiter) cleanup(ttl time.Duration) {
	ticker := time.NewTicker(ttl)
	defer ticker.Stop()
	for range ticker.C {
		rl.mu.Lock()
		rl.limiters = make(map[string]*rate.Limiter)
		rl.mu.Unlock()
	}
}

func (rl *RateLimiter) RateLimit() gin.HandlerFunc {
	return func(c *gin.Context) {
		if !rl.getLimiter(c.ClientIP()).Allow() {
			c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "Rate limit exceeded"})
			return
		}
		c.Next()
	}
}

// ErrorHandler returns centralized error handling middleware
func ErrorHandler(logger *slog.Logger) gin.HandlerFunc {
	return func(c *gin.Context) {
		c.Next()
		if len(c.Errors) == 0 {
			return
		}
		err := c.Errors.Last()
		requestID, _ := c.Get("requestID")

		response := models.ErrorResponse{
			RequestID:  requestID.(string),
			StatusCode: http.StatusInternalServerError,
			Error:      "Internal Server Error",
			Message:    err.Err.Error(),
		}

		var validationErrors validator.ValidationErrors
		if errors.As(err.Err, &validationErrors) {
			response.StatusCode = http.StatusBadRequest
			response.Error = "Validation Error"
			response.Details = formatValidationErrors(validationErrors)
		} else if c.Writer.Status() >= 400 {
			response.StatusCode = c.Writer.Status()
			response.Error = http.StatusText(c.Writer.Status())
		}

		logger.Error("request error", slog.String("error", err.Err.Error()), slog.Int("status", response.StatusCode))
		c.JSON(response.StatusCode, response)
	}
}

func formatValidationErrors(errs validator.ValidationErrors) map[string]string {
	details := make(map[string]string)
	for _, err := range errs {
		switch err.Tag() {
		case "required":
			details[err.Field()] = "This field is required"
		case "uuid":
			details[err.Field()] = "Invalid UUID format"
		default:
			details[err.Field()] = "Invalid value"
		}
	}
	return details
}

Handlers

// internal/handlers/message_handler.go
package handlers

import (
	"net/http"
	"github.com/gin-gonic/gin"
	"sent-gin-service/internal/models"
	"sent-gin-service/internal/service"
)

type MessageHandler struct {
	service *service.MessageService
}

func NewMessageHandler(service *service.MessageService) *MessageHandler {
	return &MessageHandler{service: service}
}

func (h *MessageHandler) RegisterRoutes(router *gin.RouterGroup) {
	messages := router.Group("/messages")
	{
		messages.POST("/send", h.SendMessage)
		messages.POST("/bulk", h.SendBulk)
	}
}

// SendMessage godoc
// @Summary Send a message
// @Tags messages
// @Accept json
// @Produce json
// @Param request body models.SendMessageRequest true "Message request"
// @Success 200 {object} models.SendMessageResponse
// @Router /api/v1/messages/send [post]
func (h *MessageHandler) SendMessage(c *gin.Context) {
	var req models.SendMessageRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		_ = c.Error(err)
		return
	}

	response, err := h.service.SendMessage(c.Request.Context(), &req)
	if err != nil {
		_ = c.Error(err)
		c.Status(http.StatusInternalServerError)
		return
	}

	c.JSON(http.StatusOK, response)
}

// SendBulk godoc
// @Summary Send bulk messages
// @Tags messages
// @Accept json
// @Produce json
// @Param request body models.BulkMessageRequest true "Bulk message request"
// @Success 200 {object} object{results=[]models.SendMessageResponse}
// @Router /api/v1/messages/bulk [post]
func (h *MessageHandler) SendBulk(c *gin.Context) {
	var req models.BulkMessageRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		_ = c.Error(err)
		return
	}

	results, errs := h.service.SendBulkMessages(c.Request.Context(), &req)
	c.JSON(http.StatusOK, gin.H{"results": results, "errors": errs, "count": len(results)})
}
// internal/handlers/webhook_handler.go
package handlers

import (
	"io"
	"net/http"
	"os"
	"github.com/gin-gonic/gin"
	"sent-gin-service/internal/models"
	"sent-gin-service/internal/service"
)

type WebhookHandler struct {
	service       *service.WebhookService
	webhookSecret string
}

func NewWebhookHandler(service *service.WebhookService) *WebhookHandler {
	return &WebhookHandler{service: service, webhookSecret: os.Getenv("SENT_DM_WEBHOOK_SECRET")}
}

func (h *WebhookHandler) RegisterRoutes(router *gin.Engine) {
	router.POST("/webhooks/sent", h.HandleWebhook)
}

// HandleWebhook godoc
// @Summary Handle Sent webhooks
// @Tags webhooks
// @Accept json
// @Produce json
// @Success 200 {object} models.WebhookResponse
// @Router /webhooks/sent [post]
func (h *WebhookHandler) HandleWebhook(c *gin.Context) {
	signature := c.GetHeader("X-Webhook-Signature")
	if signature == "" {
		c.JSON(http.StatusUnauthorized, models.ErrorResponse{Error: "Unauthorized", Message: "Missing signature"})
		return
	}

	body, err := io.ReadAll(c.Request.Body)
	if err != nil {
		c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Bad Request", Message: "Failed to read body"})
		return
	}

	event, err := h.service.ParseEvent(body)
	if err != nil {
		c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Bad Request", Message: "Invalid JSON"})
		return
	}

	h.service.ProcessEvent(c.Request.Context(), event)
	c.JSON(http.StatusOK, models.WebhookResponse{Received: true, EventID: event.ID})
}

Main Application

// cmd/api/main.go
package main

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"github.com/gin-gonic/gin"
	swaggerFiles "github.com/swaggo/files"
	ginSwagger "github.com/swaggo/gin-swagger"

	"sent-gin-service/internal/config"
	"sent-gin-service/internal/handlers"
	"sent-gin-service/internal/middleware"
	"sent-gin-service/internal/service"
	"sent-gin-service/pkg/sentdm"
	_ "sent-gin-service/docs/swagger"
)

// @title Sent DM Gin Service API
// @version 1.0
// @description Production-ready Sent DM integration with Gin
// @host localhost:8080
// @BasePath /api/v1
// @securityDefinitions.apikey BearerAuth
// @in header
// @name Authorization
func main() {
	cfg, err := config.Load()
	if err != nil {
		fmt.Fprintf(os.Stderr, "Failed to load config: %v\n", err)
		os.Exit(1)
	}

	logger := setupLogger(cfg.Log)
	logger.Info("starting server", slog.String("addr", cfg.Addr()))

	if cfg.Log.Level == "debug" {
		gin.SetMode(gin.DebugMode)
	} else {
		gin.SetMode(gin.ReleaseMode)
	}

	sentClient, err := sentdm.NewClient(cfg.Sent.APIKey, logger)
	if err != nil {
		logger.Error("failed to create sent client", slog.String("error", err.Error()))
		os.Exit(1)
	}

	messageService := service.NewMessageService(sentClient, logger)
	webhookService := service.NewWebhookService(logger)
	messageHandler := handlers.NewMessageHandler(messageService)
	webhookHandler := handlers.NewWebhookHandler(webhookService)

	rateLimiter := middleware.NewRateLimiter(cfg.RateLimit.RequestsPerSecond, cfg.RateLimit.BurstSize, cfg.RateLimit.TTL)

	router := gin.New()
	router.Use(middleware.Recovery(logger))
	router.Use(middleware.RequestLogger(logger))
	router.Use(middleware.CORS())
	router.Use(middleware.ErrorHandler(logger))

	router.GET("/health", func(c *gin.Context) {
		c.JSON(http.StatusOK, gin.H{"status": "healthy", "timestamp": time.Now().UTC()})
	})
	router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))

	webhookHandler.RegisterRoutes(router)

	apiV1 := router.Group("/api/v1")
	apiV1.Use(middleware.APIKeyAuth(cfg.Sent.APIKey))
	apiV1.Use(rateLimiter.RateLimit())
	messageHandler.RegisterRoutes(apiV1)

	srv := &http.Server{
		Addr:         cfg.Addr(),
		Handler:      router,
		ReadTimeout:  cfg.Server.ReadTimeout,
		WriteTimeout: cfg.Server.WriteTimeout,
		IdleTimeout:  cfg.Server.IdleTimeout,
	}

	go func() {
		logger.Info("server listening", slog.String("addr", srv.Addr))
		if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			logger.Error("server failed to start", slog.String("error", err.Error()))
			os.Exit(1)
		}
	}()

	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
	<-quit

	logger.Info("shutting down server...")
	ctx, cancel := context.WithTimeout(context.Background(), cfg.Server.ShutdownTimeout)
	defer cancel()

	if err := srv.Shutdown(ctx); err != nil {
		logger.Error("server forced to shutdown", slog.String("error", err.Error()))
		os.Exit(1)
	}
	logger.Info("server exited gracefully")
}

func setupLogger(cfg config.LogConfig) *slog.Logger {
	var level slog.Level
	switch cfg.Level {
	case "debug":
		level = slog.LevelDebug
	case "warn":
		level = slog.LevelWarn
	case "error":
		level = slog.LevelError
	default:
		level = slog.LevelInfo
	}

	opts := &slog.HandlerOptions{Level: level}
	var handler slog.Handler
	if cfg.Format == "json" {
		handler = slog.NewJSONHandler(os.Stdout, opts)
	} else {
		handler = slog.NewTextHandler(os.Stdout, opts)
	}
	return slog.New(handler)
}

Testing

// internal/handlers/message_handler_test.go
package handlers

import (
	"bytes"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/gin-gonic/gin"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/mock"
	"sent-gin-service/internal/models"
)

type MockMessageService struct {
	mock.Mock
}

func (m *MockMessageService) SendMessage(ctx context.Context, req *models.SendMessageRequest) (*models.SendMessageResponse, error) {
	args := m.Called(ctx, req)
	if args.Get(0) == nil {
		return nil, args.Error(1)
	}
	return args.Get(0).(*models.SendMessageResponse), args.Error(1)
}

func (m *MockMessageService) SendBulkMessages(ctx context.Context, req *models.BulkMessageRequest) ([]models.SendMessageResponse, []error) {
	args := m.Called(ctx, req)
	return args.Get(0).([]models.SendMessageResponse), args.Get(1).([]error)
}

func TestMessageHandler_SendMessage(t *testing.T) {
	gin.SetMode(gin.TestMode)

	tests := []struct {
		name           string
		request        interface{}
		setupMock      func(*MockMessageService)
		expectedStatus int
	}{
		{
			name: "successful message send",
			request: models.SendMessageRequest{
				To: []string{"+1234567890"}, TemplateID: "7ba7b820-9dad-11d1-80b4-00c04fd430c8", TemplateName: "welcome",
			},
			setupMock: func(m *MockMessageService) {
				m.On("SendMessage", mock.Anything, mock.Anything).Return(&models.SendMessageResponse{MessageID: "msg_123", Status: "sent"}, nil)
			},
			expectedStatus: http.StatusOK,
		},
		{
			name:           "missing required fields",
			request:        map[string]string{},
			setupMock:      func(m *MockMessageService) {},
			expectedStatus: http.StatusBadRequest,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			mockService := new(MockMessageService)
			tt.setupMock(mockService)
			handler := NewMessageHandler(mockService)

			w := httptest.NewRecorder()
			c, _ := gin.CreateTestContext(w)
			body, _ := json.Marshal(tt.request)
			c.Request = httptest.NewRequest(http.MethodPost, "/messages/send", bytes.NewReader(body))
			c.Request.Header.Set("Content-Type", "application/json")

			handler.SendMessage(c)
			assert.Equal(t, tt.expectedStatus, w.Code)
			mockService.AssertExpectations(t)
		})
	}
}

Makefile

.PHONY: build run test swagger docker-build docker-run

APP_NAME := sent-gin-service
PORT := 8080

build:
	go build -o bin/$(APP_NAME) cmd/api/main.go

run:
	go run cmd/api/main.go

test:
	go test -v -race -cover ./...

swagger:
	swag init -g cmd/api/main.go -o docs/swagger

docker-build:
	docker build -t $(APP_NAME):latest .

docker-run:
	docker run -p $(PORT):8080 --env-file .env $(APP_NAME):latest

Dockerfile

# Build stage
FROM golang:1.23-alpine AS builder
WORKDIR /app
RUN apk add --no-cache git
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main cmd/api/main.go

# Final stage
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/main .
COPY --from=builder /app/docs ./docs
EXPOSE 8080
CMD ["./main"]

Environment Variables Reference

# Server Configuration
SERVER_PORT=8080
SERVER_HOST=0.0.0.0
SERVER_READ_TIMEOUT=30s
SERVER_WRITE_TIMEOUT=30s
SERVER_SHUTDOWN_TIMEOUT=30s

# Sent DM Configuration
SENT_API_KEY=your_api_key_here
SENT_DM_WEBHOOK_SECRET=your_webhook_secret_here

# Logging
LOG_LEVEL=info        # debug, info, warn, error
LOG_FORMAT=json       # json, text

# Rate Limiting
RATE_LIMIT_REQUESTS_PER_SECOND=10
RATE_LIMIT_BURST_SIZE=20
RATE_LIMIT_TTL=1h

Next Steps

On this page