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
└── MakefileSetup
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/rateConfiguration
// 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=20Models
// 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):latestDockerfile
# 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=1hNext Steps
- Learn about best practices for production deployments
- Set up webhooks for delivery status updates
- Explore the Go SDK reference for advanced features