From be4c586c8fb4b32f7a4dd329bd67d234d81054f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C2=A8NW=C2=A8?= <¨neroworld@mail.ru¨> Date: Sun, 5 Apr 2026 03:40:32 +0100 Subject: [PATCH] feat: add Go Lang development coverage (Milestone #49) - Add go-developer agent for Go backend development - Add 8 Go skills: web-patterns, middleware, db-patterns, error-handling, security, testing, concurrency, modules - Add go.md rules file - Update capability-index.yaml with Go capabilities - Complete backend coverage for both NodeJS and Go --- .kilo/agents/go-developer.md | 493 +++++++++++++++++++ .kilo/capability-index.yaml | 27 + .kilo/rules/go.md | 283 +++++++++++ .kilo/skills/go-concurrency/SKILL.md | 553 +++++++++++++++++++++ .kilo/skills/go-db-patterns/SKILL.md | 628 ++++++++++++++++++++++++ .kilo/skills/go-error-handling/SKILL.md | 421 ++++++++++++++++ .kilo/skills/go-middleware/SKILL.md | 602 +++++++++++++++++++++++ .kilo/skills/go-modules/SKILL.md | 543 ++++++++++++++++++++ .kilo/skills/go-security/SKILL.md | 428 ++++++++++++++++ .kilo/skills/go-testing/SKILL.md | 546 ++++++++++++++++++++ .kilo/skills/go-web-patterns/SKILL.md | 553 +++++++++++++++++++++ 11 files changed, 5077 insertions(+) create mode 100644 .kilo/agents/go-developer.md create mode 100644 .kilo/rules/go.md create mode 100644 .kilo/skills/go-concurrency/SKILL.md create mode 100644 .kilo/skills/go-db-patterns/SKILL.md create mode 100644 .kilo/skills/go-error-handling/SKILL.md create mode 100644 .kilo/skills/go-middleware/SKILL.md create mode 100644 .kilo/skills/go-modules/SKILL.md create mode 100644 .kilo/skills/go-security/SKILL.md create mode 100644 .kilo/skills/go-testing/SKILL.md create mode 100644 .kilo/skills/go-web-patterns/SKILL.md diff --git a/.kilo/agents/go-developer.md b/.kilo/agents/go-developer.md new file mode 100644 index 0000000..1dbcb59 --- /dev/null +++ b/.kilo/agents/go-developer.md @@ -0,0 +1,493 @@ +--- +description: Go backend specialist for Gin, Echo, APIs, and database integration +mode: subagent +model: ollama-cloud/deepseek-v3.2 +color: "#00ADD8" +permission: + read: allow + edit: allow + write: allow + bash: allow + glob: allow + grep: allow + task: + "*": deny +--- + +# Kilo Code: Go Developer + +## Role Definition + +You are **Go Developer** — the Go backend specialist. Your personality is pragmatic, concurrency-focused, and idiomatic Go. You build performant services, design clean APIs, and leverage Go's strengths for concurrent systems. + +## When to Use + +Invoke this mode when: +- Building Go web services with Gin/Echo +- Designing REST/gRPC APIs +- Implementing concurrent patterns (goroutines, channels) +- Database integration with GORM/sqlx +- Creating Go microservices +- Authentication and middleware in Go + +## Short Description + +Go backend specialist for Gin, Echo, APIs, and concurrent systems. + +## Behavior Guidelines + +1. **Idiomatic Go** — Follow Go conventions and idioms +2. **Error Handling** — Always handle errors explicitly, wrap with context +3. **Concurrency** — Use goroutines and channels safely, prevent leaks +4. **Context Propagation** — Always pass context as first parameter +5. **Interface Design** — Accept interfaces, return concrete types +6. **Zero Values** — Design for zero-value usability + +## Tech Stack + +| Layer | Technologies | +|-------|-------------| +| Runtime | Go 1.21+ | +| Framework | Gin, Echo, net/http | +| Database | PostgreSQL, MySQL, SQLite | +| ORM | GORM, sqlx | +| Auth | JWT, OAuth2 | +| Validation | go-playground/validator | +| Testing | testing, testify, mockery | + +## Output Format + +```markdown +## Go Implementation: [Feature] + +### API Endpoints Created +| Method | Path | Handler | Description | +|--------|------|---------|-------------| +| GET | /api/resource | ListResources | List resources | +| POST | /api/resource | CreateResource | Create resource | +| PUT | /api/resource/:id | UpdateResource | Update resource | +| DELETE | /api/resource/:id | DeleteResource | Delete resource | + +### Database Changes +- Table: `resources` +- Columns: id (UUID), name (VARCHAR), created_at (TIMESTAMP), updated_at (TIMESTAMP) +- Indexes: idx_resources_name + +### Files Created +- `internal/handlers/resource.go` - HTTP handlers +- `internal/services/resource.go` - Business logic +- `internal/repositories/resource.go` - Data access +- `internal/models/resource.go` - Data models +- `internal/middleware/auth.go` - Authentication middleware + +### Security +- ✅ Input validation (go-playground/validator) +- ✅ SQL injection protection (parameterized queries) +- ✅ Context timeout handling +- ✅ Rate limiting middleware + +--- +Status: implemented +@CodeSkeptic ready for review +``` + +## Project Structure + +```go +myapp/ +├── cmd/ +│ └── server/ +│ └── main.go // Application entrypoint +├── internal/ +│ ├── config/ +│ │ └── config.go // Configuration loading +│ ├── handlers/ +│ │ └── user.go // HTTP handlers +│ ├── services/ +│ │ └── user.go // Business logic +│ ├── repositories/ +│ │ └── user.go // Data access +│ ├── models/ +│ │ └── user.go // Data models +│ ├── middleware/ +│ │ └── auth.go // Middleware +│ └── app/ +│ └── app.go // Application setup +├── pkg/ +│ └── utils/ +│ └── response.go // Public utilities +├── api/ +│ └── openapi/ +│ └── openapi.yaml // API definition +├── go.mod +└── go.sum +``` + +## Handler Template + +```go +// internal/handlers/user.go +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/myorg/myapp/internal/models" + "github.com/myorg/myapp/internal/services" +) + +type UserHandler struct { + service services.UserService +} + +func NewUserHandler(service services.UserService) *UserHandler { + return &UserHandler{service: service} +} + +// List handles GET /api/users +func (h *UserHandler) List(c *gin.Context) { + users, err := h.service.List(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, users) +} + +// Create handles POST /api/users +func (h *UserHandler) Create(c *gin.Context) { + var req models.CreateUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + user, err := h.service.Create(c.Request.Context(), &req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, user) +} +``` + +## Service Template + +```go +// internal/services/user.go +package services + +import ( + "context" + "fmt" + + "github.com/myorg/myapp/internal/models" + "github.com/myorg/myapp/internal/repositories" +) + +type UserService interface { + GetByID(ctx context.Context, id string) (*models.User, error) + List(ctx context.Context) ([]models.User, error) + Create(ctx context.Context, req *models.CreateUserRequest) (*models.User, error) + Update(ctx context.Context, id string, req *models.UpdateUserRequest) (*models.User, error) + Delete(ctx context.Context, id string) error +} + +type userService struct { + repo repositories.UserRepository +} + +func NewUserService(repo repositories.UserRepository) UserService { + return &userService{repo: repo} +} + +func (s *userService) GetByID(ctx context.Context, id string) (*models.User, error) { + user, err := s.repo.FindByID(ctx, id) + if err != nil { + return nil, fmt.Errorf("get user: %w", err) + } + return user, nil +} + +func (s *userService) Create(ctx context.Context, req *models.CreateUserRequest) (*models.User, error) { + user := &models.User{ + Email: req.Email, + FirstName: req.FirstName, + LastName: req.LastName, + } + + if err := s.repo.Create(ctx, user); err != nil { + return nil, fmt.Errorf("create user: %w", err) + } + + return user, nil +} +``` + +## Repository Template + +```go +// internal/repositories/user.go +package repositories + +import ( + "context" + "errors" + "fmt" + + "gorm.io/gorm" + "github.com/myorg/myapp/internal/models" +) + +type UserRepository interface { + FindByID(ctx context.Context, id string) (*models.User, error) + FindByEmail(ctx context.Context, email string) (*models.User, error) + Create(ctx context.Context, user *models.User) error + Update(ctx context.Context, user *models.User) error + Delete(ctx context.Context, id string) error + List(ctx context.Context) ([]models.User, error) +} + +type gormUserRepository struct { + db *gorm.DB +} + +func NewUserRepository(db *gorm.DB) UserRepository { + return &gormUserRepository{db: db} +} + +func (r *gormUserRepository) FindByID(ctx context.Context, id string) (*models.User, error) { + var user models.User + if err := r.db.WithContext(ctx).First(&user, "id = ?", id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("find user: %w", err) + } + return &user, nil +} + +func (r *gormUserRepository) Create(ctx context.Context, user *models.User) error { + if err := r.db.WithContext(ctx).Create(user).Error; err != nil { + return fmt.Errorf("create user: %w", err) + } + return nil +} +``` + +## Model Template + +```go +// internal/models/user.go +package models + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type User struct { + ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primary_key" json:"id"` + Email string `gorm:"uniqueIndex;not null" json:"email"` + FirstName string `gorm:"size:100" json:"first_name"` + LastName string `gorm:"size:100" json:"last_name"` + Role string `gorm:"default:'user'" json:"role"` + Active bool `gorm:"default:true" json:"active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +func (User) TableName() string { + return "users" +} + +type CreateUserRequest struct { + Email string `json:"email" validate:"required,email"` + FirstName string `json:"first_name" validate:"required"` + LastName string `json:"last_name" validate:"required"` + Password string `json:"password" validate:"required,min=8"` +} + +type UpdateUserRequest struct { + FirstName string `json:"first_name,omitempty"` + LastName string `json:"last_name,omitempty"` +} +``` + +## Middleware Template + +```go +// internal/middleware/auth.go +package middleware + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" +) + +func Auth(jwtSecret string) gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": "missing authorization header", + }) + return + } + + tokenString := strings.TrimPrefix(authHeader, "Bearer ") + + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + return []byte(jwtSecret), nil + }) + + if err != nil || !token.Valid { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": "invalid token", + }) + return + } + + claims := token.Claims.(jwt.MapClaims) + c.Set("userID", claims["sub"]) + c.Next() + } +} +``` + +## Error Handling + +```go +// pkg/errors/errors.go +package errors + +import "errors" + +var ( + ErrNotFound = errors.New("not found") + ErrUnauthorized = errors.New("unauthorized") + ErrBadRequest = errors.New("bad request") + ErrInternal = errors.New("internal error") +) + +type AppError struct { + Code int + Message string + Err error +} + +func (e *AppError) Error() string { + return e.Message +} + +func (e *AppError) Unwrap() error { + return e.Err +} + +func NewNotFound(message string) *AppError { + return &AppError{Code: 404, Message: message, Err: ErrNotFound} +} + +func NewBadRequest(message string) *AppError { + return &AppError{Code: 400, Message: message, Err: ErrBadRequest} +} + +// internal/middleware/errors.go +func ErrorHandler() gin.HandlerFunc { + return func(c *gin.Context) { + c.Next() + + for _, err := range c.Errors { + var appErr *errors.AppError + if errors.As(err.Err, &appErr) { + c.AbortWithStatusJSON(appErr.Code, gin.H{ + "error": appErr.Message, + }) + return + } + + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{ + "error": "internal server error", + }) + return + } + } +} +``` + +## Prohibited Actions + +- DO NOT ignore errors — always handle or wrap +- DO NOT use panic in handlers +- DO NOT store contexts in structs +- DO NOT expose internal errors to clients +- DO NOT hardcode secrets or credentials +- DO NOT use global state for request data + +## Skills Reference + +This agent uses the following skills for comprehensive Go development: + +### Core Skills +| Skill | Purpose | +|-------|---------| +| `go-web-patterns` | Gin, Echo, net/http patterns | +| `go-middleware` | Authentication, CORS, rate limiting | +| `go-error-handling` | Error types, wrapping, handling | +| `go-security` | OWASP, validation, security headers | + +### Database +| Skill | Purpose | +|-------|---------| +| `go-db-patterns` | GORM, sqlx, migrations, transactions | + +### Concurrency +| Skill | Purpose | +|-------|---------| +| `go-concurrency` | Goroutines, channels, context, sync | + +### Testing & Quality +| Skill | Purpose | +|-------|---------| +| `go-testing` | Unit tests, table-driven, mocking | + +### Package Management +| Skill | Purpose | +|-------|---------| +| `go-modules` | go.mod, dependencies, versioning | + +### Rules +| File | Content | +|------|---------| +| `.kilo/rules/go.md` | Code style, error handling, best practices | + +## Handoff Protocol + +After implementation: +1. Run `go fmt ./...` and `go vet ./...` +2. Run `go test -race ./...` +3. Check for vulnerabilities: `govulncheck ./...` +4. Verify all handlers return proper status codes +5. Check context propagation throughout +6. Tag `@CodeSkeptic` for review + +## Gitea Commenting (MANDATORY) + +**You MUST post a comment to the Gitea issue after completing your work.** + +Post a comment with: +1. ✅ Success: What was done, files changed, duration +2. ❌ Error: What failed, why, and blocker +3. ❓ Question: Clarification needed with options + +Use the `post_comment` function from `.kilo/skills/gitea-commenting/SKILL.md`. + +**NO EXCEPTIONS** - Always comment to Gitea. \ No newline at end of file diff --git a/.kilo/capability-index.yaml b/.kilo/capability-index.yaml index 7b770c5..e123190 100644 --- a/.kilo/capability-index.yaml +++ b/.kilo/capability-index.yaml @@ -59,6 +59,27 @@ agents: model: ollama-cloud/qwen3-coder:480b mode: subagent + go-developer: + capabilities: + - go_api_development + - go_database_design + - go_concurrent_programming + - go_authentication + - go_microservices + receives: + - api_specifications + - database_requirements + - concurrent_requirements + produces: + - go_handlers + - go_database_schema + - go_api_documentation + - concurrent_solutions + forbidden: + - frontend_code + model: ollama-cloud/deepseek-v3.2 + mode: subagent + # Quality Assurance sdet-engineer: capabilities: @@ -484,6 +505,12 @@ capability_routing: memory_retrieval: memory-manager chain_of_thought: planner tree_of_thoughts: planner + # Go Development + go_api_development: go-developer + go_database_design: go-developer + go_concurrent_programming: go-developer + go_authentication: go-developer + go_microservices: go-developer # Parallelizable Tasks parallel_groups: diff --git a/.kilo/rules/go.md b/.kilo/rules/go.md new file mode 100644 index 0000000..b9fe752 --- /dev/null +++ b/.kilo/rules/go.md @@ -0,0 +1,283 @@ +# Go Rules + +Essential rules for Go development. + +## Code Style + +- Use `gofmt` for formatting +- Use `go vet` for static analysis +- Follow standard Go conventions +- Run `golangci-lint` before commit + +```go +// ✅ Good +package user + +import ( + "context" + "errors" + + "github.com/gin-gonic/gin" +) + +type Service struct { + repo Repository +} + +func NewService(repo Repository) *Service { + return &Service{repo: repo} +} + +// ❌ Bad +package user +import "context" +import "errors" +import "github.com/gin-gonic/gin" // Wrong import grouping +``` + +## Error Handling + +- Always handle errors +- Use `fmt.Errorf` with `%w` for wrapping +- Define custom error types +- Never panic in library code + +```go +// ✅ Good +func GetUser(id int64) (*User, error) { + user, err := repo.FindByID(id) + if err != nil { + return nil, fmt.Errorf("get user: %w", err) + } + return user, nil +} + +// ❌ Bad +func GetUser(id int64) *User { + user, _ := repo.FindByID(id) // Ignoring error + return user +} +``` + +## Context + +- Always pass `context.Context` as first parameter +- Use context for cancellation and timeouts +- Don't store context in structs + +```go +// ✅ Good +func (s *Service) GetByID(ctx context.Context, id int64) (*User, error) { + return s.repo.FindByID(ctx, id) +} + +// ❌ Bad +func (s *Service) GetByID(id int64) (*User, error) { + return s.repo.FindByID(context.Background(), id) +} +``` + +## Concurrency + +- Use `sync.WaitGroup` for goroutine coordination +- Use channels for communication, not shared memory +- Always close channels +- Use context for cancellation + +```go +// ✅ Good +func Process(items []int) error { + var wg sync.WaitGroup + errCh := make(chan error, 1) + + for _, item := range items { + wg.Add(1) + go func(i int) { + defer wg.Done() + if err := processItem(i); err != nil { + select { + case errCh <- err: + default: + } + } + }(item) + } + + go func() { + wg.Wait() + close(errCh) + }() + + return <-errCh +} +``` + +## Testing + +- Write tests for all exported functions +- Use table-driven tests +- Use `t.Parallel()` where appropriate +- Mock external dependencies + +```go +// ✅ Good: Table-driven test +func TestValidateEmail(t *testing.T) { + tests := []struct { + name string + email string + valid bool + }{ + {"valid", "test@example.com", true}, + {"invalid", "invalid", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ValidateEmail(tt.email) + if got != tt.valid { + t.Errorf("got %v, want %v", got, tt.valid) + } + }) + } +} +``` + +## Project Structure + +``` +myapp/ +├── cmd/ +│ └── server/ +│ └── main.go +├── internal/ +│ ├── config/ +│ ├── handlers/ +│ ├── services/ +│ ├── repositories/ +│ └── models/ +├── pkg/ +│ └── public/ +├── go.mod +└── go.sum +``` + +## Security + +- Validate all inputs +- Use parameterized queries +- Never store passwords in plain text +- Use environment variables for secrets +- Set security headers + +```go +// ✅ Good: Parameterized query +func GetUser(db *sql.DB, id string) (*User, error) { + query := "SELECT * FROM users WHERE id = ?" + return db.QueryRow(query, id) +} + +// ❌ Bad: SQL injection +func GetUser(db *sql.DB, id string) (*User, error) { + query := fmt.Sprintf("SELECT * FROM users WHERE id = %s", id) + return db.QueryRow(query) +} +``` + +## Dependencies + +- Use Go modules (`go.mod`) +- Run `go mod tidy` regularly +- Check for vulnerabilities: `govulncheck ./...` +- Don't overuse external dependencies + +```bash +# ✅ Good practices +go mod init myapp +go get github.com/gin-gonic/gin +go mod tidy +govulncheck ./... + +# Update dependencies +go get -u ./... +go mod tidy +``` + +## HTTP Handlers + +- Keep handlers thin +- Return proper HTTP status codes +- Use middleware for cross-cutting concerns +- Validate input before processing + +```go +// ✅ Good: Thin handler +func (h *Handler) GetUser(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + + user, err := h.service.GetByID(c.Request.Context(), id) + if err != nil { + handleErrorResponse(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{"user": user}) +} + +// ❌ Bad: Logic in handler +func GetUser(c *gin.Context) { + db := getDB() + var user User + db.First(&user, c.Param("id")) + c.JSON(200, user) +} +``` + +## Interface Design + +- Accept interfaces, return concrete types +- Keep interfaces small +- Use interfaces for testing + +```go +// ✅ Good +type Repository interface { + FindByID(ctx context.Context, id int64) (*User, error) + Create(ctx context.Context, user *User) error +} + +type UserService struct { + repo Repository +} + +// ❌ Bad: Too large interface +type Service interface { + GetUser(id int64) (*User, error) + CreateUser(user *User) error + UpdateUser(user *User) error + DeleteUser(id int64) error + // ...many more methods +} +``` + +## Logging + +- Use structured logging (zap, zerolog) +- Include context in logs +- Use appropriate log levels +- Don't log sensitive data + +```go +// ✅ Good: Structured logging +logger.Info("user login", + zap.String("user_id", userID), + zap.String("ip", ip), + zap.Time("timestamp", time.Now()), +) + +// ❌ Bad: Printf logging +log.Printf("user %s logged in from %s", userID, ip) +``` \ No newline at end of file diff --git a/.kilo/skills/go-concurrency/SKILL.md b/.kilo/skills/go-concurrency/SKILL.md new file mode 100644 index 0000000..e754798 --- /dev/null +++ b/.kilo/skills/go-concurrency/SKILL.md @@ -0,0 +1,553 @@ +# Go Concurrency Patterns + +Idiomatic concurrency patterns for Go applications. + +## Goroutines + +```go +// Basic goroutine +go func() { + fmt.Println("Running in goroutine") +}() + +// Anonymous function with parameters +for i := 0; i < 10; i++ { + go func(n int) { + fmt.Printf("Goroutine %d\n", n) + }(i) +} + +// Wait for goroutine completion +func processData() error { + var wg sync.WaitGroup + errChan := make(chan error, 1) + + for i := 0; i < 5; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + if err := processItem(n); err != nil { + select { + case errChan <- err: + default: + } + } + }(i) + } + + go func() { + wg.Wait() + close(errChan) + }() + + return <-errChan +} +``` + +## Channels + +```go +// Basic channel +ch := make(chan int) + +// Buffered channel +ch := make(chan int, 10) + +// Send and receive +go func() { + ch <- 42 // Send +}() +value := <-ch // Receive + +// Close channel +close(ch) + +// Range over channel +for value := range ch { + fmt.Println(value) +} + +// Select statement +select { +case msg := <-ch1: + fmt.Println("Received from ch1:", msg) +case msg := <-ch2: + fmt.Println("Received from ch2:", msg) +case <-time.After(time.Second): + fmt.Println("Timeout") +default: + fmt.Println("No message available") +} +``` + +## Worker Pool + +```go +package pool + +import "sync" + +type Task func() error + +type WorkerPool struct { + tasks chan Task + results chan error + workers int + wg sync.WaitGroup +} + +func NewWorkerPool(workers int) *WorkerPool { + return &WorkerPool{ + tasks: make(chan Task, 100), + results: make(chan error, 100), + workers: workers, + } +} + +func (p *WorkerPool) Start() { + for i := 0; i < p.workers; i++ { + p.wg.Add(1) + go p.worker() + } +} + +func (p *WorkerPool) worker() { + defer p.wg.Done() + for task := range p.tasks { + if err := task(); err != nil { + p.results <- err + } + } +} + +func (p *WorkerPool) Submit(task Task) { + p.tasks <- task +} + +func (p *WorkerPool) Stop() { + close(p.tasks) + p.wg.Wait() + close(p.results) +} + +func (p *WorkerPool) Results() <-chan error { + return p.results +} + +// Usage +func main() { + pool := NewWorkerPool(5) + pool.Start() + + for i := 0; i < 100; i++ { + i := i // Capture loop variable + pool.Submit(func() error { + return processItem(i) + }) + } + + go func() { + for err := range pool.Results() { + if err != nil { + log.Printf("Error: %v", err) + } + } + }() + + pool.Stop() +} +``` + +## Fan-Out / Fan-In + +```go +// Fan-out: Multiple workers process same channel +func FanOut(jobs <-chan int, workers int) []<-chan int { + results := make([]<-chan int, workers) + + for i := 0; i < workers; i++ { + results[i] = worker(jobs) + } + + return results +} + +func worker(jobs <-chan int) <-chan int { + results := make(chan int) + go func() { + defer close(results) + for job := range jobs { + results <- process(job) + } + }() + return results +} + +// Fan-in: Merge multiple channels +func FanIn(channels ...<-chan int) <-chan int { + out := make(chan int) + var wg sync.WaitGroup + + for _, ch := range channels { + wg.Add(1) + go func(c <-chan int) { + defer wg.Done() + for v := range c { + out <- v + } + }(ch) + } + + go func() { + wg.Wait() + close(out) + }() + + return out +} + +// Usage +func main() { + jobs := make(chan int, 100) + + // Fan-out + workers := FanOut(jobs, 5) + + // Fan-in + results := FanIn(workers...) + + go func() { + for result := range results { + fmt.Println(result) + } + }() + + // Send jobs + for i := 0; i < 100; i++ { + jobs <- i + } + close(jobs) +} +``` + +## Context + +```go +import "context" + +// Context with timeout +func ProcessWithTimeout(ctx context.Context) error { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + resultCh := make(chan error, 1) + go func() { + resultCh <- doWork() + }() + + select { + case err := <-resultCh: + return err + case <-ctx.Done(): + return ctx.Err() + } +} + +// Context with cancel +func ProcessWithCancel(parent context.Context) { + ctx, cancel := context.WithCancel(parent) + defer cancel() + + go func() { + // Some operation + if shouldCancel() { + cancel() + } + }() + + select { + case <-ctx.Done(): + fmt.Println("Context cancelled") + } +} + +// Context with values +type key string + +const ( + UserIDKey key = "userID" + RoleKey key = "role" +) + +func WithUser(ctx context.Context, userID, role string) context.Context { + ctx = context.WithValue(ctx, UserIDKey, userID) + ctx = context.WithValue(ctx, RoleKey, role) + return ctx +} + +func GetUser(ctx context.Context) (userID, role string) { + return ctx.Value(UserIDKey).(string), ctx.Value(RoleKey).(string) +} +``` + +## Pipeline + +```go +// Pipeline pattern +func Pipeline() { + // Stage 1: Generate numbers + nums := generate(1, 2, 3, 4, 5) + + // Stage 2: Square numbers + squared := square(nums) + + // Stage 3: Filter even numbers + evens := filter(squared, func(n int) bool { + return n%2 == 0 + }) + + // Consume + for n := range evens { + fmt.Println(n) + } +} + +func generate(nums ...int) <-chan int { + out := make(chan int) + go func() { + defer close(out) + for _, n := range nums { + out <- n + } + }() + return out +} + +func square(in <-chan int) <-chan int { + out := make(chan int) + go func() { + defer close(out) + for n := range in { + out <- n * n + } + }() + return out +} + +func filter(in <-chan int, predicate func(int) bool) <-chan int { + out := make(chan int) + go func() { + defer close(out) + for n := range in { + if predicate(n) { + out <- n + } + } + }() + return out +} +``` + +## Rate Limiting + +```go +import "golang.org/x/time/rate" + +// Token bucket rate limiter +func RateLimitMiddleware(r rate.Limit, b int) gin.HandlerFunc { + limiter := rate.NewLimiter(r, b) + + return func(c *gin.Context) { + if !limiter.Allow() { + c.JSON(http.StatusTooManyRequests, gin.H{ + "error": "rate limit exceeded", + }) + c.Abort() + return + } + c.Next() + } +} + +// Usage +router.Use(RateLimitMiddleware(100, 10)) // 100 requests/sec, burst 10 + +// Per-client rate limiting +type IPRateLimiter struct { + mu sync.Mutex + limiters map[string]*rate.Limiter + rate rate.Limit + burst int +} + +func NewIPRateLimiter(r rate.Limit, b int) *IPRateLimiter { + return &IPRateLimiter{ + limiters: make(map[string]*rate.Limiter), + rate: r, + burst: b, + } +} + +func (l *IPRateLimiter) GetLimiter(ip string) *rate.Limiter { + l.mu.Lock() + defer l.mu.Unlock() + + limiter, exists := l.limiters[ip] + if !exists { + limiter = rate.NewLimiter(l.rate, l.burst) + l.limiters[ip] = limiter + } + + return limiter +} + +func (l *IPRateLimiter) Middleware() gin.HandlerFunc { + return func(c *gin.Context) { + ip := c.ClientIP() + limiter := l.GetLimiter(ip) + + if !limiter.Allow() { + c.JSON(http.StatusTooManyRequests, gin.H{ + "error": "rate limit exceeded", + }) + c.Abort() + return + } + + c.Next() + } +} +``` + +## Graceful Shutdown + +```go +func main() { + server := &http.Server{Addr: ":8080"} + + // Start server in goroutine + go func() { + if err := server.ListenAndServe(); err != http.ErrServerClosed { + log.Fatalf("Server error: %v", err) + } + }() + + // Wait for interrupt signal + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + log.Println("Shutting down server...") + + // Create deadline + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Shutdown + if err := server.Shutdown(ctx); err != nil { + log.Fatal("Server forced to shutdown:", err) + } + + log.Println("Server exited") +} + +// Shutdown with cleanup +func shutdownWithCleanup(server *http.Server, cleanup func()) { + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + log.Println("Starting graceful shutdown...") + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Shutdown HTTP server + if err := server.Shutdown(ctx); err != nil { + log.Printf("HTTP server shutdown error: %v", err) + } + + // Run cleanup + cleanup() + + log.Println("Shutdown complete") +} +``` + +## Best Practices + +### Don't Do This + +```go +// ❌ Bad: No synchronization +var counter int +for i := 0; i < 1000; i++ { + go func() { + counter++ // Race condition! + }() +} + +// ❌ Bad: Goroutine leak +func process() { + ch := make(chan int) + go func() { + for { + ch <- getEvent() // Never exits! + } + }() + // ... function returns, goroutine leaks +} + +// ❌ Bad: Blocking main goroutine +func main() { + ch := make(chan int) + ch <- 1 // Blocks forever, no receiver +} +``` + +### Do This + +```go +// ✅ Good: Use mutex for shared state +var ( + counter int + mu sync.Mutex +) + +for i := 0; i < 1000; i++ { + go func() { + mu.Lock() + counter++ + mu.Unlock() + }() +} + +// ✅ Good: Use atomic for simple counters +var counter int64 +for i := 0; i < 1000; i++ { + go func() { + atomic.AddInt64(&counter, 1) + }() +} + +// ✅ Good: Proper goroutine termination +func process(ctx context.Context) { + ch := make(chan int) + go func() { + defer close(ch) + for { + select { + case <-ctx.Done(): + return + case ch <- getEvent(): + } + } + }() +} + +// ✅ Good: Buffered channel for non-blocking send +func main() { + ch := make(chan int, 1) + ch <- 1 // Doesn't block +} +``` \ No newline at end of file diff --git a/.kilo/skills/go-db-patterns/SKILL.md b/.kilo/skills/go-db-patterns/SKILL.md new file mode 100644 index 0000000..cff6ee0 --- /dev/null +++ b/.kilo/skills/go-db-patterns/SKILL.md @@ -0,0 +1,628 @@ +# Go Database Patterns Skill + +Comprehensive guide to database patterns in Go applications using GORM, sqlx, and database/sql. + +## Overview + +This skill covers database connection management, CRUD operations, transactions, migrations, and best practices for building robust data layers in Go applications. + +## Connection Management + +### Basic Connection (database/sql) + +```go +package database + +import ( + "database/sql" + "time" + + _ "github.com/lib/pq" // PostgreSQL driver +) + +// ✅ Good: Connection pool configuration +func NewDB(connectionString string) (*sql.DB, error) { + db, err := sql.Open("postgres", connectionString) + if err != nil { + return nil, fmt.Errorf("open database: %w", err) + } + + // Connection pool settings + db.SetMaxOpenConns(25) + db.SetMaxIdleConns(5) + db.SetConnMaxLifetime(5 * time.Minute) + db.SetConnMaxIdleTime(1 * time.Minute) + + // Verify connection + if err := db.Ping(); err != nil { + return nil, fmt.Errorf("ping database: %w", err) + } + + return db, nil +} + +// ❌ Bad: No connection pool settings +func NewDBBad(connectionString string) (*sql.DB, error) { + return sql.Open("postgres", connectionString) +} +``` + +### Transaction Management + +```go +// ✅ Good: Transaction pattern +func (s *Service) CreateUserWithProfile(ctx context.Context, user *User, profile *Profile) error { + return s.db.Transaction(ctx, func(tx *sql.Tx) error { + // Create user + userQuery := `INSERT INTO users (email, password) VALUES ($1, $2) RETURNING id` + if err := tx.QueryRowContext(ctx, userQuery, user.Email, user.Password).Scan(&user.ID); err != nil { + return fmt.Errorf("create user: %w", err) + } + + // Create profile with user ID + profileQuery := `INSERT INTO profiles (user_id, bio) VALUES ($1, $2)` + if _, err := tx.ExecContext(ctx, profileQuery, user.ID, profile.Bio); err != nil { + return fmt.Errorf("create profile: %w", err) + } + + return nil + }) +} + +// ✅ Good: Generic transaction helper +type Transactor struct { + db *sql.DB +} + +func (t *Transactor) Transaction(ctx context.Context, fn func(*sql.Tx) error) error { + tx, err := t.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin transaction: %w", err) + } + + if err := fn(tx); err != nil { + if rbErr := tx.Rollback(); rbErr != nil { + return fmt.Errorf("rollback error: %v, original error: %w", rbErr, err) + } + return err + } + + return tx.Commit() +} +``` + +## GORM Patterns + +### Model Definition + +```go +// ✅ Good: GORM model with proper conventions +type User struct { + ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primary_key"` + Email string `gorm:"uniqueIndex;not null"` + Password string `gorm:"not null"` + FirstName string `gorm:"size:100"` + LastName string `gorm:"size:100"` + Role string `gorm:"default:'user'"` + Active bool `gorm:"default:true"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` + + // Relationships + Profile *Profile `gorm:"foreignKey:UserID"` + Posts []Post `gorm:"foreignKey:AuthorID"` +} + +type Profile struct { + gorm.Model + UserID uuid.UUID `gorm:"not null;index"` + Bio string `gorm:"size:500"` + Avatar string `gorm:"size:255"` +} + +// ❌ Bad: Missing indexes and constraints +type UserBad struct { + gorm.Model // Uses uint ID instead of UUID + Email string // No unique constraint + Name string // No size limit +} +``` + +### GORM Repository Pattern + +```go +// ✅ Good: Repository interface +type UserRepository interface { + Create(ctx context.Context, user *User) error + GetByID(ctx context.Context, id uuid.UUID) (*User, error) + GetByEmail(ctx context.Context, email string) (*User, error) + Update(ctx context.Context, user *User) error + Delete(ctx context.Context, id uuid.UUID) error + List(ctx context.Context, params ListParams) ([]User, int64, error) +} + +// ✅ Good: GORM repository implementation +type GormUserRepository struct { + db *gorm.DB +} + +func NewUserRepository(db *gorm.DB) UserRepository { + return &GormUserRepository{db: db} +} + +func (r *GormUserRepository) Create(ctx context.Context, user *User) error { + if err := r.db.WithContext(ctx).Create(user).Error; err != nil { + return fmt.Errorf("create user: %w", err) + } + return nil +} + +func (r *GormUserRepository) GetByID(ctx context.Context, id uuid.UUID) (*User, error) { + var user User + if err := r.db.WithContext(ctx).First(&user, "id = ?", id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("get user: %w", err) + } + return &user, nil +} + +func (r *GormUserRepository) GetByEmail(ctx context.Context, email string) (*User, error) { + var user User + if err := r.db.WithContext(ctx).Where("email = ?", email).First(&user).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("get user by email: %w", err) + } + return &user, nil +} + +func (r *GormUserRepository) Update(ctx context.Context, user *User) error { + result := r.db.WithContext(ctx).Save(user) + if result.Error != nil { + return fmt.Errorf("update user: %w", result.Error) + } + if result.RowsAffected == 0 { + return ErrNotFound + } + return nil +} + +func (r *GormUserRepository) Delete(ctx context.Context, id uuid.UUID) error { + result := r.db.WithContext(ctx).Delete(&User{}, "id = ?", id) + if result.Error != nil { + return fmt.Errorf("delete user: %w", result.Error) + } + if result.RowsAffected == 0 { + return ErrNotFound + } + return nil +} + +func (r *GormUserRepository) List(ctx context.Context, params ListParams) ([]User, int64, error) { + var users []User + var total int64 + + query := r.db.WithContext(ctx).Model(&User{}) + + // Apply filters + if params.Search != "" { + search := "%" + params.Search + "%" + query = query.Where("email ILIKE ? OR first_name ILIKE ? OR last_name ILIKE ?", + search, search, search) + } + + if params.Role != "" { + query = query.Where("role = ?", params.Role) + } + + // Get total count + if err := query.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("count users: %w", err) + } + + // Apply pagination + offset := (params.Page - 1) * params.PageSize + if err := query.Offset(offset).Limit(params.PageSize).Find(&users).Error; err != nil { + return nil, 0, fmt.Errorf("list users: %w", err) + } + + return users, total, nil +} +``` + +### GORM Associations + +```go +// ✅ Good: Loading associations +func (r *GormUserRepository) GetWithProfile(ctx context.Context, id uuid.UUID) (*User, error) { + var user User + if err := r.db.WithContext(ctx). + Preload("Profile"). + First(&user, "id = ?", id).Error; err != nil { + return nil, fmt.Errorf("get user with profile: %w", err) + } + return &user, nil +} + +// ✅ Good: Creating with associations +func (s *Service) CreateUserWithProfile(ctx context.Context, req CreateUserRequest) error { + user := User{ + Email: req.Email, + Password: req.Password, + Profile: &Profile{ + Bio: req.Bio, + Avatar: req.Avatar, + }, + } + + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&user).Error; err != nil { + return fmt.Errorf("create user: %w", err) + } + return nil + }) +} + +// ✅ Good: Many-to-many with explicit join table +type Tag struct { + ID uint `gorm:"primaryKey"` + Name string `gorm:"uniqueIndex;size:50"` +} + +type Post struct { + ID uint `gorm:"primaryKey"` + Title string `gorm:"size:200"` + Content string + Tags []Tag `gorm:"many2many:post_tags;"` +} +``` + +## sqlx Patterns + +### Basic Queries + +```go +// ✅ Good: sqlx with struct scanning +type User struct { + ID uuid.UUID `db:"id"` + Email string `db:"email"` + Password string `db:"password"` + CreatedAt time.Time `db:"created_at"` +} + +func (r *sqlxRepo) GetByID(ctx context.Context, id uuid.UUID) (*User, error) { + const query = ` + SELECT id, email, password, created_at + FROM users + WHERE id = $1 + ` + + var user User + if err := r.db.GetContext(ctx, &user, query, id); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("get user: %w", err) + } + return &user, nil +} + +func (r *sqlxRepo) Create(ctx context.Context, user *User) error { + const query = ` + INSERT INTO users (id, email, password) + VALUES ($1, $2, $3) + RETURNING created_at + ` + + if err := r.db.QueryRowxContext(ctx, query, + user.ID, user.Email, user.Password, + ).Scan(&user.CreatedAt); err != nil { + return fmt.Errorf("create user: %w", err) + } + return nil +} +``` + +### Named Queries + +```go +// ✅ Good: Named parameters +func (r *sqlxRepo) Update(ctx context.Context, user *User) error { + const query = ` + UPDATE users + SET email = :email, password = :password + WHERE id = :id + ` + + result, err := r.db.NamedExecContext(ctx, query, user) + if err != nil { + return fmt.Errorf("update user: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("rows affected: %w", err) + } + if rows == 0 { + return ErrNotFound + } + return nil +} + +// ✅ Good: IN clause with sqlx +func (r *sqlxRepo) GetByIDs(ctx context.Context, ids []uuid.UUID) ([]User, error) { + const query = ` + SELECT id, email, password, created_at + FROM users + WHERE id IN (?) + ` + + query, args, err := sqlx.In(query, ids) + if err != nil { + return nil, fmt.Errorf("build IN query: %w", err) + } + + var users []User + if err := r.db.SelectContext(ctx, &users, r.db.Rebind(query), args...); err != nil { + return nil, fmt.Errorf("get users: %w", err) + } + return users, nil +} +``` + +## Migration Patterns + +### GORM Auto Migrate + +```go +// ✅ Good: Auto migration with version check +func Migrate(db *gorm.DB) error { + if err := db.AutoMigrate( + &User{}, + &Profile{}, + &Post{}, + &Tag{}, + ); err != nil { + return fmt.Errorf("auto migrate: %w", err) + } + + // Create indexes manually for complex constraints + if err := db.Exec(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_lower + ON users (LOWER(email)) + `).Error; err != nil { + return fmt.Errorf("create email index: %w", err) + } + + return nil +} +``` + +### Manual Migrations (golang-migrate) + +```go +// ✅ Good: Version-controlled migrations +// migrations/000001_create_users.up.sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, + role VARCHAR(50) DEFAULT 'user', + active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + deleted_at TIMESTAMP WITH TIME ZONE +); + +CREATE INDEX idx_users_email ON users (email) WHERE deleted_at IS NULL; + +// migrations/000001_create_users.down.sql +DROP TABLE IF EXISTS users; + +// Migration runner +func RunMigrations(db *sql.DB, migrationsDir string) error { + driver, err := postgres.WithInstance(db, &postgres.Config{}) + if err != nil { + return fmt.Errorf("create driver: %w", err) + } + + m, err := migrate.NewWithDatabaseInstance( + "file://"+migrationsDir, + "postgres", + driver, + ) + if err != nil { + return fmt.Errorf("create migrator: %w", err) + } + + if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { + return fmt.Errorf("migrate: %w", err) + } + + return nil +} +``` + +## Query Optimizations + +### Pagination + +```go +// ✅ Good: Cursor-based pagination for large datasets +type CursorParams struct { + Cursor uuid.UUID + PageSize int + Forward bool // true for next, false for previous +} + +func (r *GormUserRepository) ListCursor(ctx context.Context, params CursorParams) ([]User, error) { + var users []User + + query := r.db.WithContext(ctx).Model(&User{}) + + if params.Forward { + query = query.Where("id > ?", params.Cursor).Order("id ASC") + } else { + query = query.Where("id < ?", params.Cursor).Order("id DESC") + } + + if err := query.Limit(params.PageSize).Find(&users).Error; err != nil { + return nil, fmt.Errorf("list users: %w", err) + } + + return users, nil +} +``` + +### N+1 Prevention + +```go +// ✅ Good: Batch loading +func (r *PostRepository) GetPostsByUserID(ctx context.Context, userIDs []uuid.UUID) (map[uuid.UUID][]Post, error) { + var posts []Post + if err := r.db.WithContext(ctx). + Where("author_id IN ?", userIDs). + Find(&posts).Error; err != nil { + return nil, fmt.Errorf("get posts: %w", err) + } + + // Group by user ID + result := make(map[uuid.UUID][]Post) + for _, post := range posts { + result[post.AuthorID] = append(result[post.AuthorID], post) + } + return result, nil +} + +// ✅ Good: GORM Preload +func (r *UserRepository) GetUsersWithPosts(ctx context.Context, limit int) ([]User, error) { + var users []User + if err := r.db.WithContext(ctx). + Preload("Posts"). + Limit(limit). + Find(&users).Error; err != nil { + return nil, fmt.Errorf("get users with posts: %w", err) + } + return users, nil +} +``` + +## Testing + +### Mock Repository + +```go +// ✅ Good: Mock repository for testing +type MockUserRepository struct { + mock.Mock +} + +func (m *MockUserRepository) Create(ctx context.Context, user *User) error { + args := m.Called(ctx, user) + return args.Error(0) +} + +func (m *MockUserRepository) GetByID(ctx context.Context, id uuid.UUID) (*User, error) { + args := m.Called(ctx, id) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*User), args.Error(1) +} + +// Test usage +func TestUserService_Create(t *testing.T) { + mockRepo := new(MockUserRepository) + service := NewUserService(mockRepo) + + user := &User{Email: "test@example.com"} + + mockRepo.On("Create", mock.Anything, user).Return(nil) + + err := service.Create(context.Background(), user) + + assert.NoError(t, err) + mockRepo.AssertExpectations(t) +} +``` + +### Integration Tests with Testcontainers + +```go +// ✅ Good: PostgreSQL test container +func setupTestDB(t *testing.T) *gorm.DB { + ctx := context.Background() + + req := testcontainers.ContainerRequest{ + Image: "postgres:15", + ExposedPorts: []string{"5432/tcp"}, + Env: map[string]string{ + "POSTGRES_USER": "test", + "POSTGRES_PASSWORD": "test", + "POSTGRES_DB": "test", + }, + WaitingFor: wait.ForLog("database system is ready to accept connections"), + } + + container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + require.NoError(t, err) + + t.Cleanup(func() { + container.Terminate(ctx) + }) + + host, err := container.Host(ctx) + require.NoError(t, err) + + port, err := container.MappedPort(ctx, "5432") + require.NoError(t, err) + + dsn := fmt.Sprintf("host=%s port=%s user=test password=test dbname=test sslmode=disable", + host, port.Port()) + + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + + // Run migrations + err = db.AutoMigrate(&User{}, &Profile{}) + require.NoError(t, err) + + return db +} +``` + +## Best Practices + +```go +// ✅ Good: Context propagation +func (r *GormUserRepository) GetByID(ctx context.Context, id uuid.UUID) (*User, error) { + var user User + if err := r.db.WithContext(ctx).First(&user, "id = ?", id).Error; err != nil { + return nil, fmt.Errorf("get user: %w", err) + } + return &user, nil +} + +// ✅ Good: Soft delete awareness +func (r *GormUserRepository) HardDelete(ctx context.Context, id uuid.UUID) error { + result := r.db.WithContext(ctx).Unscoped().Delete(&User{}, "id = ?", id) + if result.Error != nil { + return fmt.Errorf("hard delete: %w", result.Error) + } + return nil +} + +// ❌ Bad: String concatenation (SQL injection vulnerable) +func BadQuery(db *sql.DB, userID string) (*User, error) { + query := fmt.Sprintf("SELECT * FROM users WHERE id = %s", userID) + // Never do this! + return nil, nil +} +``` \ No newline at end of file diff --git a/.kilo/skills/go-error-handling/SKILL.md b/.kilo/skills/go-error-handling/SKILL.md new file mode 100644 index 0000000..b61c8ae --- /dev/null +++ b/.kilo/skills/go-error-handling/SKILL.md @@ -0,0 +1,421 @@ +# Go Error Handling + +Comprehensive error handling patterns for Go applications. + +## Error Types + +```go +// internal/errors/errors.go +package errors + +import "fmt" + +type AppError struct { + Code int + Message string + Err error +} + +func (e *AppError) Error() string { + if e.Err != nil { + return fmt.Sprintf("%s: %v", e.Message, e.Err) + } + return e.Message +} + +func (e *AppError) Unwrap() error { + return e.Err +} + +// Specific errors +func NewNotFoundError(message string) *AppError { + return &AppError{Code: 404, Message: message} +} + +func NewBadRequestError(message string) *AppError { + return &AppError{Code: 400, Message: message} +} + +func NewUnauthorizedError(message string) *AppError { + return &AppError{Code: 401, Message: message} +} + +func NewForbiddenError(message string) *AppError { + return &AppError{Code: 403, Message: message} +} + +func NewConflictError(message string) *AppError { + return &AppError{Code: 409, Message: message} +} + +func NewInternalError(message string) *AppError { + return &AppError{Code: 500, Message: message} +} +``` + +## Error Wrapping + +```go +import "errors" +import "fmt" + +// Wrap errors with context +func GetUser(id int64) (*User, error) { + user, err := repo.FindByID(id) + if err != nil { + return nil, fmt.Errorf("failed to get user %d: %w", id, err) + } + return user, nil +} + +// Unwrap and check +func ProcessUser(id int64) error { + user, err := GetUser(id) + if err != nil { + var notFound *NotFoundError + if errors.As(err, ¬Found) { + // Handle not found + return nil // or handle differently + } + return err + } + // ... + return nil +} + +// Check specific error +func HandleError(err error) { + if errors.Is(err, sql.ErrNoRows) { + // Handle not found + } + if errors.Is(err, context.Canceled) { + // Handle cancellation + } +} +``` + +## Error Handling Middleware + +```go +// internal/middleware/error.go +package middleware + +import ( + "net/http" + "os" + + "github.com/gin-gonic/gin" + "myapp/internal/errors" +) + +func ErrorHandler() gin.HandlerFunc { + return func(c *gin.Context) { + c.Next() + + if len(c.Errors) > 0 { + err := c.Errors.Last().Err + + // Check for app errors + var appErr *errors.AppError + if errors.As(err, &appErr) { + c.JSON(appErr.Code, gin.H{ + "error": appErr.Message, + }) + return + } + + // Check for validation errors + var valErr *errors.ValidationError + if errors.As(err, &valErr) { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "validation failed", + "fields": valErr.Fields, + }) + return + } + + // Unknown error + if os.Getenv("ENV") == "development" { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "internal server error", + }) + } + } +} + +// Recovery middleware +func Recovery() gin.HandlerFunc { + return gin.CustomRecovery(func(c *gin.Context, recovered interface{}) { + // Log panic + log.Printf("Panic recovered: %v", recovered) + + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "internal server error", + }) + }) +} +``` + +## Validation Errors + +```go +// internal/errors/validation.go +package errors + +type ValidationError struct { + Fields map[string]string +} + +func (e *ValidationError) Error() string { + return "validation failed" +} + +func NewValidationError(fields map[string]string) *ValidationError { + return &ValidationError{Fields: fields} +} + +// Usage with validator +import "github.com/go-playground/validator/v10" + +func ValidateStruct(s interface{}) error { + validate := validator.New() + + if err := validate.Struct(s); err != nil { + fields := make(map[string]string) + for _, err := range err.(validator.ValidationErrors) { + fields[err.Field()] = err.Tag() + } + return NewValidationError(fields) + } + + return nil +} + +// Handler usage +func (h *UserHandler) Create(c *gin.Context) { + var req models.CreateUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "invalid request body", + }) + return + } + + if err := ValidateStruct(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": err.(*errors.ValidationError).Fields, + }) + return + } + + // ... +} +``` + +## Service Layer Errors + +```go +// internal/services/user.go +package services + +import ( + "context" + "errors" + + "myapp/internal/models" + "myapp/internal/repositories" +) + +func NewUserService(repo *repositories.UserRepository) *UserService { + return &UserService{repo: repo} +} + +type UserService struct { + repo *repositories.UserRepository +} + +func (s *UserService) Create(ctx context.Context, req *models.RegisterRequest) (*models.User, error) { + // Check if exists + existing, err := s.repo.FindByEmail(ctx, req.Email) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("check existing user: %w", err) + } + if existing != nil { + return nil, errors.NewConflictError("email already registered") + } + + // Hash password + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + return nil, fmt.Errorf("hash password: %w", err) + } + + // Create user + user := &models.User{ + Email: req.Email, + Password: string(hashedPassword), + Name: req.Name, + } + + if err := s.repo.Create(ctx, user); err != nil { + return nil, fmt.Errorf("create user: %w", err) + } + + return user, nil +} + +func (s *UserService) GetByID(ctx context.Context, id int64) (*models.User, error) { + user, err := s.repo.FindByID(ctx, id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, errors.NewNotFoundError("user not found") + } + return nil, fmt.Errorf("get user by id: %w", err) + } + return user, nil +} +``` + +## Repository Errors + +```go +// internal/repositories/user.go +package repositories + +import ( + "context" + "database/sql" + + "gorm.io/gorm" +) + +type UserRepository struct { + db *gorm.DB +} + +func NewUserRepository(db *gorm.DB) *UserRepository { + return &UserRepository{db: db} +} + +func (r *UserRepository) FindByID(ctx context.Context, id int64) (*models.User, error) { + var user models.User + err := r.db.WithContext(ctx).First(&user, id).Error + + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, sql.ErrNoRows + } + return nil, err + } + + return &user, nil +} + +// Handle unique constraint violations +func (r *UserRepository) Create(ctx context.Context, user *models.User) error { + err := r.db.WithContext(ctx).Create(user).Error + if err != nil { + // SQLite unique constraint + if strings.Contains(err.Error(), "UNIQUE constraint failed") { + return errors.NewConflictError("resource already exists") + } + // PostgreSQL unique constraint + if strings.Contains(err.Error(), "duplicate key") { + return errors.NewConflictError("resource already exists") + } + return err + } + return nil +} +``` + +## Error Logging + +```go +import "go.uber.org/zap" + +func LogError(logger *zap.Logger, err error, fields ...zap.Field) { + fields = append(fields, zap.Error(err)) + + var appErr *errors.AppError + if errors.As(err, &appErr) { + fields = append(fields, + zap.Int("status_code", appErr.Code), + zap.String("message", appErr.Message), + ) + } + + logger.Error("error occurred", fields...) +} + +// Usage +func (h *UserHandler) Get(c *gin.Context) { + user, err := h.service.GetByID(c.Request.Context(), id) + if err != nil { + LogError(h.logger, err, + zap.String("user_id", c.Param("id")), + zap.String("request_id", c.GetString("request_id")), + ) + // ... + } +} +``` + +## Best Practices + +### Don't Do This + +```go +// ❌ Bad: Ignore errors +user, _ := service.GetByID(id) + +// ❌ Bad: Panic on error +if err != nil { + panic(err) +} + +// ❌ Bad: Log and continue without handling +if err != nil { + log.Printf("error: %v", err) + // continue... +} + +// ❌ Bad: Wrap errors without context +return fmt.Errorf("%v", err) +``` + +### Do This + +```go +// ✅ Good: Handle errors properly +user, err := service.GetByID(id) +if err != nil { + return nil, fmt.Errorf("get user: %w", err) +} + +// ✅ Good: Return errors to caller +if err != nil { + return nil, err +} + +// ✅ Good: Use errors.Is and errors.As +if errors.Is(err, sql.ErrNoRows) { + return nil, errors.NewNotFoundError("not found") +} + +var appErr *errors.AppError +if errors.As(err, &appErr) { + return appErr +} + +// ✅ Good: Wrap with context +if err != nil { + return fmt.Errorf("failed to create user %s: %w", email, err) +} +``` \ No newline at end of file diff --git a/.kilo/skills/go-middleware/SKILL.md b/.kilo/skills/go-middleware/SKILL.md new file mode 100644 index 0000000..93c0bf1 --- /dev/null +++ b/.kilo/skills/go-middleware/SKILL.md @@ -0,0 +1,602 @@ +# Go Middleware Patterns Skill + +Comprehensive guide to middleware patterns in Go web applications using Gin, Echo, and net/http. + +## Overview + +Middleware in Go provides a powerful way to handle cross-cutting concerns like logging, authentication, rate limiting, and request processing. This skill covers best practices for designing, composing, and testing middleware. + +## Core Concepts + +### Middleware Function Signature + +```go +// Standard net/http middleware +type Middleware func(http.Handler) http.Handler + +// Gin middleware +type GinMiddleware func(gin.HandlerFunc) gin.HandlerFunc + +// Echo middleware +type EchoMiddleware func(echo.HandlerFunc) echo.HandlerFunc +``` + +### Middleware Chain Execution + +```go +// Middleware wraps handlers in reverse order +// Request: Log → Auth → RateLimit → Handler +// Response: Handler → RateLimit → Auth → Log + +func Chain(h http.Handler, middlewares ...Middleware) http.Handler { + for i := len(middlewares) - 1; i >= 0; i-- { + h = middlewares[i](h) + } + return h +} +``` + +## Standard Library (net/http) + +### Basic Middleware Pattern + +```go +// ✅ Good: Clean, composable middleware +func LoggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + // Wrap ResponseWriter to capture status + wrapped := &responseWriter{ResponseWriter: w, status: http.StatusOK} + + next.ServeHTTP(wrapped, r) + + log.Printf("%s %s %d %v", + r.Method, + r.URL.Path, + wrapped.status, + time.Since(start), + ) + }) +} + +// ✅ Good: ResponseWriter wrapper for status capture +type responseWriter struct { + http.ResponseWriter + status int +} + +func (rw *responseWriter) WriteHeader(code int) { + rw.status = code + rw.ResponseWriter.WriteHeader(code) +} +``` + +### Middleware Composition + +```go +// ✅ Good: Chainable middleware +func WithMiddleware(h http.Handler) http.Handler { + return LoggingMiddleware( + RecoveryMiddleware( + AuthMiddleware(h), + ), + ) +} + +// ✅ Better: Functional approach +func NewRouter() http.Handler { + mux := http.NewServeMux() + + // Public routes + mux.HandleFunc("/health", healthHandler) + mux.HandleFunc("/login", loginHandler) + + // Protected routes + protected := http.NewServeMux() + protected.HandleFunc("/api/users", usersHandler) + + // Apply middleware chain + handler := Chain(protected, + AuthMiddleware, + RateLimitMiddleware, + LoggingMiddleware, + ) + + return handler +} +``` + +## Gin Framework + +### Custom Middleware + +```go +// ✅ Good: Gin middleware with context values +func AuthMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + token := c.GetHeader("Authorization") + if token == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": "missing authorization header", + }) + return + } + + userID, err := validateToken(token) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": "invalid token", + }) + return + } + + // Store in context for downstream handlers + c.Set("userID", userID) + c.Next() + } +} + +// ✅ Good: Retrieving context values +func ProtectedHandler(c *gin.Context) { + userID, exists := c.Get("userID") + if !exists { + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) + return + } + + // Use userID... +} +``` + +### Request ID Middleware + +```go +// ✅ Good: Request ID for tracing +func RequestIDMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + requestID := c.GetHeader("X-Request-ID") + if requestID == "" { + requestID = uuid.New().String() + } + + c.Set("requestID", requestID) + c.Header("X-Request-ID", requestID) + c.Next() + } +} +``` + +### Recovery Middleware + +```go +// ✅ Good: Panic recovery +func RecoveryMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + defer func() { + if err := recover(); err != nil { + // Log stack trace + stack := debug.Stack() + log.Printf("panic recovered: %v\n%s", err, stack) + + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{ + "error": "internal server error", + }) + } + }() + c.Next() + } +} +``` + +### Rate Limiting Middleware + +```go +// ✅ Good: Token bucket rate limiter +func RateLimitMiddleware(rps int, burst int) gin.HandlerFunc { + limiter := rate.NewLimiter(rate.Limit(rps), burst) + + return func(c *gin.Context) { + if !limiter.Allow() { + c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{ + "error": "rate limit exceeded", + }) + return + } + c.Next() + } +} + +// ✅ Better: Per-client rate limiting +func PerClientRateLimitMiddleware() gin.HandlerFunc { + type client struct { + limiter *rate.Limiter + lastSeen time.Time + } + + var ( + mu sync.Mutex + clients = make(map[string]*client) + ) + + // Cleanup routine + go func() { + for range time.Tick(time.Minute) { + mu.Lock() + for ip, c := range clients { + if time.Since(c.lastSeen) > time.Minute { + delete(clients, ip) + } + } + mu.Unlock() + } + }() + + return func(c *gin.Context) { + ip := c.ClientIP() + + mu.Lock() + if _, exists := clients[ip]; !exists { + clients[ip] = &client{ + limiter: rate.NewLimiter(2, 5), + } + } + clients[ip].lastSeen = time.Now() + limiter := clients[ip].limiter + mu.Unlock() + + if !limiter.Allow() { + c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{ + "error": "rate limit exceeded", + }) + return + } + c.Next() + } +} +``` + +## Echo Framework + +### Echo Middleware Pattern + +```go +// ✅ Good: Echo middleware +func AuthMiddleware() echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + token := c.Request().Header.Get("Authorization") + if token == "" { + return echo.NewHTTPError(http.StatusUnauthorized, "missing token") + } + + claims, err := validateToken(token) + if err != nil { + return echo.NewHTTPError(http.StatusUnauthorized, "invalid token") + } + + c.Set("claims", claims) + return next(c) + } + } +} + +// Usage +e := echo.New() +e.Use(middleware.Logger()) +e.Use(middleware.Recover()) +e.Use(AuthMiddleware()) +``` + +### Custom Context in Echo + +```go +// ✅ Good: Custom context with typed getters +type Context struct { + echo.Context + user *User +} + +func (c *Context) User() *User { + return c.user +} + +func AuthMiddleware() echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + user, err := authenticate(c) + if err != nil { + return err + } + + cc := &Context{Context: c, user: user} + return next(cc) + } + } +} +``` + +## Cross-Cutting Concerns + +### Request Timeout Middleware + +```go +// ✅ Good: Context timeout +func TimeoutMiddleware(timeout time.Duration) gin.HandlerFunc { + return func(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), timeout) + defer cancel() + + c.Request = c.Request.WithContext(ctx) + + done := make(chan struct{}) + go func() { + defer close(done) + c.Next() + }() + + select { + case <-done: + // Handler completed + case <-ctx.Done(): + c.AbortWithStatusJSON(http.StatusGatewayTimeout, gin.H{ + "error": "request timeout", + }) + } + } +} +``` + +### CORS Middleware + +```go +// ✅ Good: CORS configuration +func CORSMiddleware(allowedOrigins []string) gin.HandlerFunc { + return func(c *gin.Context) { + origin := c.GetHeader("Origin") + + // Check if origin is allowed + allowed := false + for _, o := range allowedOrigins { + if o == "*" || o == origin { + allowed = true + break + } + } + + if allowed { + c.Header("Access-Control-Allow-Origin", origin) + c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization") + c.Header("Access-Control-Max-Age", "86400") + } + + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(http.StatusNoContent) + return + } + + c.Next() + } +} +``` + +### Request Validation Middleware + +```go +// ✅ Good: Request validation +func ValidateRequestMiddleware(req interface{}) gin.HandlerFunc { + return func(c *gin.Context) { + if err := c.ShouldBindJSON(req); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{ + "error": "invalid request body", + "details": err.Error(), + }) + return + } + + if err := validate.Struct(req); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{ + "error": "validation failed", + "details": formatValidationErrors(err), + }) + return + } + + c.Set("validatedRequest", req) + c.Next() + } +} +``` + +## Testing Middleware + +### Unit Testing + +```go +// ✅ Good: Middleware unit test +func TestAuthMiddleware(t *testing.T) { + tests := []struct { + name string + token string + wantStatus int + }{ + { + name: "missing token", + token: "", + wantStatus: http.StatusUnauthorized, + }, + { + name: "invalid token", + token: "invalid", + wantStatus: http.StatusUnauthorized, + }, + { + name: "valid token", + token: "valid-token", + wantStatus: http.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gin.SetMode(gin.TestMode) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + c.Request = httptest.NewRequest("GET", "/test", nil) + if tt.token != "" { + c.Request.Header.Set("Authorization", tt.token) + } + + // Run middleware + AuthMiddleware()(c) + + if !c.IsAborted() && tt.wantStatus == http.StatusOK { + // Continue to handler + } + + if tt.wantStatus != http.StatusOK { + assert.Equal(t, tt.wantStatus, w.Code) + } + }) + } +} +``` + +### Integration Testing + +```go +// ✅ Good: Integration test with middleware chain +func TestMiddlewareChain(t *testing.T) { + gin.SetMode(gin.TestMode) + + router := gin.New() + router.Use(RecoveryMiddleware()) + router.Use(AuthMiddleware()) + router.GET("/protected", func(c *gin.Context) { + userID, _ := c.Get("userID") + c.JSON(http.StatusOK, gin.H{"userID": userID}) + }) + + // Test with valid token + req := httptest.NewRequest("GET", "/protected", nil) + req.Header.Set("Authorization", "valid-token") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) +} +``` + +## Best Practices + +### ❌ Bad Patterns +```go +// ❌ Bad: Middleware with side effects +func BadMiddleware(c *gin.Context) { + c.Set("user", getUserFromDB(c)) // Database call in middleware + c.Next() +} + +// ❌ Bad: Panicking in middleware +func BadPanicMiddleware(c *gin.Context) { + token := c.GetHeader("Authorization") + if token == "" { + panic("no token") // Don't panic, handle gracefully + } + c.Next() +} + +// ❌ Bad: Blocking middleware +func BadBlockingMiddleware(c *gin.Context) { + time.Sleep(5 * time.Second) // Never block in middleware + c.Next() +} +``` + +### ✅ Good Patterns +```go +// ✅ Good: Middleware factory for configuration +func NewAuthMiddleware(config AuthConfig) gin.HandlerFunc { + return func(c *gin.Context) { + token := c.GetHeader("Authorization") + claims, err := config.ValidateToken(token) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": err.Error(), + }) + return + } + c.Set("claims", claims) + c.Next() + } +} + +// ✅ Good: Short-circuit with c.Abort() +func AuthMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + if !isAuthenticated(c) { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": "unauthorized", + }) + return // Important: return after Abort + } + c.Next() + } +} +``` + +## Common Patterns + +### Middleware with Dependencies + +```go +// ✅ Good: Dependency injection +type AuthMiddleware struct { + authService *auth.Service + logger *zap.Logger +} + +func NewAuthMiddleware(authService *auth.Service, logger *zap.Logger) *AuthMiddleware { + return &AuthMiddleware{ + authService: authService, + logger: logger, + } +} + +func (m *AuthMiddleware) Handle() gin.HandlerFunc { + return func(c *gin.Context) { + token := c.GetHeader("Authorization") + claims, err := m.authService.Validate(token) + if err != nil { + m.logger.Error("auth failed", zap.Error(err)) + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": "unauthorized", + }) + return + } + c.Set("claims", claims) + c.Next() + } +} +``` + +### Conditional Middleware + +```go +// ✅ Good: Skip middleware for certain routes +func SkipMiddleware(skipPaths map[string]bool) gin.HandlerFunc { + return func(c *gin.Context) { + if skipPaths[c.Request.URL.Path] { + c.Next() + return + } + + // Apply middleware logic + c.Next() + } +} +``` \ No newline at end of file diff --git a/.kilo/skills/go-modules/SKILL.md b/.kilo/skills/go-modules/SKILL.md new file mode 100644 index 0000000..2a4edad --- /dev/null +++ b/.kilo/skills/go-modules/SKILL.md @@ -0,0 +1,543 @@ +# Go Modules Management Skill + +Comprehensive guide to Go modules, dependency management, and project structure. + +## Overview + +Go modules are the official dependency management system for Go. This skill covers module initialization, dependency management, versioning, and best practices. + +## Module Basics + +### Creating a New Module + +```bash +# Initialize new module +go mod init github.com/myorg/myproject + +# Creates go.mod file +module github.com/myorg/myproject + +go 1.21 + +require ( + github.com/gin-gonic/gin v1.9.1 + gorm.io/gorm v1.25.5 +) +``` + +### Module File Structure + +``` +myproject/ +├── go.mod # Module definition and dependencies +├── go.sum # Dependency checksums +├── cmd/ # Main applications +│ ├── server/ +│ │ └── main.go +│ └── cli/ +│ └── main.go +├── internal/ # Private application code +│ ├── handlers/ +│ ├── services/ +│ └── models/ +├── pkg/ # Public library code +│ └── utils/ +└── api/ # API definitions + └── openapi/ +``` + +## go.mod File + +### Module Declaration + +```go +// go.mod +module github.com/myorg/myproject + +go 1.21 + +require ( + github.com/gin-gonic/gin v1.9.1 + gorm.io/gorm v1.25.5 +) + +require ( + // Indirect dependencies + github.com/go-playground/validator/v10 v10.15.0 // indirect + golang.org/x/crypto v0.13.0 // indirect +) + +replace github.com/old/package => github.com/new/package v1.0.0 + +exclude github.com/problematic/package v1.0.0 +``` + +### Version Syntax + +```bash +# Semantic versioning +require github.com/gin-gonic/gin v1.9.1 + +# Pseudo-versions (for unreleased commits) +require github.com/user/repo v0.0.0-20230815133123-1b2345678901 + +# Branch names (use commit hash instead) +require github.com/user/repo v1.2.3-alpha.1 + +# +incompatible for pre-module versions +require github.com/legacy/package v1.0.0+incompatible +``` + +## Dependency Management + +### Adding Dependencies + +```bash +# Add a new dependency +go get github.com/gin-gonic/gin@latest + +# Add specific version +go get github.com/gin-gonic/gin@v1.9.1 + +# Add all dependencies from imports +go mod tidy + +# Add dependency without installing +go get -d github.com/some/package +``` + +### Updating Dependencies + +```bash +# Update all dependencies +go get -u ./... + +# Update specific package +go get github.com/gin-gonic/gin@latest + +# Update to specific version +go get github.com/gin-gonic/gin@v1.10.0 + +# Update and tidy +go get -u && go mod tidy +``` + +### Removing Dependencies + +```bash +# Remove unused dependencies +go mod tidy + +# Remove specific dependency +go mod edit -droprequire github.com/gin-gonic/gin +go mod tidy +``` + +### Analyzing Dependencies + +```bash +# List all dependencies +go list -m all + +# List direct dependencies only +go list -m -json all | jq -r 'select(.Main == null and .Indirect == null) | .Path' + +# See dependency graph +go mod graph + +# See why a dependency is needed +go mod why github.com/gin-gonic/gin + +# Verify dependencies +go mod verify +``` + +## go.sum File + +### Understanding Checksums + +```go +// go.sum contains SHA-256 checksums +github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0Axc1x6JZY3sA3V8= +github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL6YRBqA0pA4dZ5FjP3QmJFdE3E7BEgO8Y0ePm7Y= + +// First line: checksum of module zip +// Second line: checksum of go.mod file +``` + +### Security Implications + +```bash +# Verify all modules against go.sum +go mod verify + +# Download dependencies to module cache +go mod download + +# Vendor dependencies (for air-gapped environments) +go mod vendor +``` + +## Versioning Best Practices + +### Semantic Versioning + +```bash +# ✅ Good: Use semantic versioning +v1.0.0 # Major.Minor.Patch +v1.0.1 # Patch: Bug fixes +v1.1.0 # Minor: New features, backward compatible +v2.0.0 # Major: Breaking changes + +# Pre-release versions +v1.0.0-alpha.1 +v1.0.0-beta.2 +v1.0.0-rc.1 +``` + +### Import Path Versioning (v2+) + +```go +// For v2 and above, include version in import path +// go.mod +module github.com/myorg/myproject/v2 + +go 1.21 + +// Import in other projects +import "github.com/myorg/myproject/v2" +``` + +### Pseudo-Versions + +```go +// When using unreleased commits +require github.com/user/repo v0.0.0-20230815133123-1b2345678901 + +// Format: vX.Y.Z-yyyymmddhhmmss-abcdefabcdef +// Where: +// - vX.Y.Z is base version tag +// - yyyymmddhhmmss is commit timestamp (UTC) +// - abcdefabcdef is first 12 chars of commit hash +``` + +## Workspaces + +### Multi-Module Workspaces + +```bash +# Initialize workspace +go work init ./modules/module1 ./modules/module2 + +# Creates go.work file +go 1.21 + +use ( + ./modules/module1 + ./modules/module2 +) + +# Add module to workspace +go work use ./modules/module3 + +# Sync workspace +go work sync +``` + +### Workspace File Structure + +``` +project/ +├── go.work +├── modules/ +│ ├── module1/ +│ │ ├── go.mod +│ │ └── main.go +│ └── module2/ +│ ├── go.mod +│ └── main.go +└── shared/ + └── utils/ + ├── go.mod + └── utils.go +``` + +## Dependency Security + +### Vulnerability Scanning + +```bash +# Check for known vulnerabilities +go install golang.org/x/vuln/cmd/govulncheck@latest +govulncheck ./... + +# Output example +Vulnerability #1: GO-2023-1234 + Vulnerable package: github.com/gin-gonic/gin + Fixed in: v1.9.2 + More info: https://pkg.go.dev/vuln/go-2023-1234 +``` + +### Security Best Practices + +```bash +# ✅ Good: Run vulnerability scans in CI +- name: Security Scan + run: govulncheck ./... + +# ✅ Good: Use specific versions +go get github.com/gin-gonic/gin@v1.9.1 + +# ❌ Bad: Using latest in production +go get github.com/gin-gonic/gin@latest # Can change unexpectedly + +# ✅ Good: Pin versions in go.mod +require github.com/gin-gonic/gin v1.9.1 + +# ✅ Good: Regular security updates +- name: Dependabot + uses: dependabot/fetch-metadata@v1 +``` + +## Project Structure Patterns + +### Standard Go Project Layout + +```go +// cmd/ - Main applications +// cmd/server/main.go +package main + +import "github.com/myorg/myproject/internal/server" + +func main() { + server.Run() +} + +// internal/ - Private code +// internal/user/service.go +package user + +type Service struct { + repo Repository +} + +func NewService(repo Repository) *Service { + return &Service{repo: repo} +} + +// pkg/ - Public code (reusable) +// pkg/utils/http.go +package utils + +func ParseCookie(cookie string) map[string]string { + // Implementation + return nil +} + +// api/ - API definitions +// api/openapi.yaml - OpenAPI spec +// api/protobuf/ - Protocol Buffer definitions +``` + +### Internal Package Pattern + +```go +// internal/ can only be imported by parent directories +// This enforces encapsulation + +// internal/database/postgres.go +package database + +type DB struct { + *sql.DB +} + +func New(connectionString string) (*DB, error) { + // ... +} + +// cmd/server/main.go +package main + +import "github.com/myorg/myproject/internal/database" + +func main() { + db, err := database.New(dsn) + // ... +} + +// ❌ Cannot import from external projects +// import "github.com/myorg/myproject/internal/database" // Error! +``` + +## Vendoring + +### Creating Vendor Directory + +```bash +# Create vendor directory +go mod vendor + +# Structure +vendor/ +├── modules.txt +├── github.com/ +│ └── gin-gonic/ +│ └── gin/ +│ ├── LICENSE +│ ├── context.go +│ └── ... +``` + +### Using Vendor Directory + +```bash +# Build using vendor +go build -mod=vendor ./cmd/server + +# Run tests using vendor +go test -mod=vendor ./... + +# Verify vendor matches go.mod +go mod verify +``` + +## Common Commands Reference + +```bash +# Module initialization +go mod init + +# Download dependencies +go mod download + +# Tidy dependencies +go mod tidy + +# Verify dependencies +go mod verify + +# Copy dependencies to vendor +go mod vendor + +# View dependency graph +go mod graph + +# Explain dependency need +go mod why + +# Edit go.mod +go mod edit -require=@ +go mod edit -droprequire= + +# List packages +go list -m all +go list -m -json all + +# Build info +go build -v ./... +go build -ldflags "-X main.version=v1.0.0" ./cmd/server +``` + +## Best Practices + +### ❌ Bad Patterns + +```bash +# ❌ Bad: Committing go.sum conflicts +git add go.sum # Without resolving conflicts +git commit -m "update" + +# ❌ Bad: Using latest in production +go get github.com/gin-gonic/gin@latest + +# ❌ Bad: Not running go mod tidy +# Leaves unused dependencies + +# ❌ Bad: Committing large vendor/ without .gitignore +git add vendor/ # Bloated repo + +# ❌ Bad: Using relative imports +import "../utils" // Error-prone +``` + +### ✅ Good Patterns + +```bash +# ✅ Good: Regular dependency audits +go mod tidy +go mod verify +govulncheck ./... + +# ✅ Good: Use specific versions in go.mod +require github.com/gin-gonic/gin v1.9.1 + +# ✅ Good: CI pipeline +- name: Lint + run: golangci-lint run + +- name: Test + run: go test -race -cover ./... + +- name: Security + run: govulncheck ./... + +- name: Build + run: go build -o bin/server ./cmd/server + +# ✅ Good: Module structure +module github.com/myorg/myproject + +go 1.21 + +require ( + github.com/gin-gonic/gin v1.9.1 + gorm.io/gorm v1.25.5 +) + +# ✅ Good: Use go.mod replace for local development +replace github.com/myorg/shared => ../shared +``` + +## Dependency Update Workflow + +```bash +# 1. Check for outdated dependencies +go list -u -m all + +# 2. Test current state +go test ./... + +# 3. Update dependencies +go get -u ./... +go mod tidy + +# 4. Run tests again +go test ./... + +# 5. Run security scan +govulncheck ./... + +# 6. Commit changes +git add go.mod go.sum +git commit -m "chore: update dependencies" +``` + +## Private Repositories + +```bash +# Set GOPRIVATE for private modules +go env -w GOPRIVATE=github.com/myorg/* + +# Configure git for private repos +git config --global url."git@github.com:".insteadOf "https://github.com/" + +# ~/.netrc for authentication +machine github.com +login myuser +password mytoken + +# GONOSUMDB for private modules +go env -w GONOSUMDB=github.com/myorg/* +``` \ No newline at end of file diff --git a/.kilo/skills/go-security/SKILL.md b/.kilo/skills/go-security/SKILL.md new file mode 100644 index 0000000..320e1b7 --- /dev/null +++ b/.kilo/skills/go-security/SKILL.md @@ -0,0 +1,428 @@ +# Go Security (OWASP) + +OWASP Top 10 security practices for Go applications. + +## OWASP Top 10 for Go + +### 1. Injection (A03:2021) + +```go +// ❌ Vulnerable: SQL Injection +func GetUser(db *sql.DB, id string) (*User, error) { + query := fmt.Sprintf("SELECT * FROM users WHERE id = %s", id) + row := db.QueryRow(query) + // ... +} + +// ✅ Secure: Parameterized queries +func GetUser(db *sql.DB, id string) (*User, error) { + query := "SELECT * FROM users WHERE id = ?" + row := db.QueryRow(query, id) + // ... +} + +// ✅ Secure: GORM +func GetUser(db *gorm.DB, id string) (*User, error) { + var user User + err := db.Where("id = ?", id).First(&user).Error + return &user, err +} +``` + +### 2. Broken Authentication (A07:2021) + +```go +// ✅ Password hashing with bcrypt +import "golang.org/x/crypto/bcrypt" + +func HashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + return string(bytes), err +} + +func CheckPasswordHash(password, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} + +// ✅ JWT authentication +import "github.com/golang-jwt/jwt/v5" + +type Claims struct { + UserID string `json:"user_id"` + Role string `json:"role"` + jwt.RegisteredClaims +} + +func GenerateToken(userID, role string) (string, error) { + claims := &Claims{ + UserID: userID, + Role: role, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), + IssuedAt: jwt.NewNumericDate(time.Now()), + Issuer: "myapp", + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(os.Getenv("JWT_SECRET"))) +} +``` + +### 3. Sensitive Data Exposure (A02:2021) + +```go +// ✅ Environment variables for secrets +import "github.com/kelseyhightower/envconfig" + +type Config struct { + DatabaseURL string `envconfig:"DATABASE_URL" required:"true"` + JWTSecret string `envconfig:"JWT_SECRET" required:"true"` + APIKey string `envconfig:"API_KEY" required:"true"` +} + +func LoadConfig() (*Config, error) { + var cfg Config + err := envconfig.Process("", &cfg) + return &cfg, err +} + +// ✅ Encrypt sensitive data +import "crypto/aes" +import "crypto/cipher" +import "crypto/rand" + +func Encrypt(plaintext, key []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return nil, err + } + + return gcm.Seal(nonce, nonce, plaintext, nil), nil +} + +// ✅ Never log sensitive data +func Login(db *gorm.DB, email, password string) (*User, error) { + var user User + if err := db.Where("email = ?", email).First(&user).Error; err != nil { + log.Printf("Login failed for email: %s", email) // Don't log password + return nil, errors.New("invalid credentials") + } + + if !CheckPasswordHash(password, user.Password) { + return nil, errors.New("invalid credentials") + } + + return &user, nil +} +``` + +### 4. XML External Entities (A05:2021) + +```go +// ✅ Secure XML parsing +import "encoding/xml" + +func ParseXML(data []byte) (User, error) { + var user User + decoder := xml.NewDecoder(bytes.NewReader(data)) + decoder.Strict = false // Prevent XXE + decoder.AutoClose = xml.HTMLAutoClose + + if err := decoder.Decode(&user); err != nil { + return User{}, err + } + + return user, nil +} +``` + +### 5. Broken Access Control (A01:2021) + +```go +// ✅ Role-based access control +func RequireRole(role string) gin.HandlerFunc { + return func(c *gin.Context) { + userRole, exists := c.Get("role") + if !exists { + c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"}) + c.Abort() + return + } + + if userRole != role { + c.JSON(http.StatusForbidden, gin.H{"error": "insufficient permissions"}) + c.Abort() + return + } + + c.Next() + } +} + +// Usage +admin := api.Group("/admin") +admin.Use(middleware.RequireRole("admin")) +{ + admin.GET("/users", handlers.ListUsers) +} + +// ✅ Resource ownership check +func CanAccessResource(userID string, resourceID string) bool { + // Check if user owns the resource + var resource Resource + if err := db.Where("id = ? AND user_id = ?", resourceID, userID).First(&resource).Error; err != nil { + return false + } + return true +} +``` + +### 6. Security Misconfiguration (A05:2021) + +```go +// ✅ Security headers middleware +func SecurityHeaders() gin.HandlerFunc { + return func(c *gin.Context) { + c.Header("X-Content-Type-Options", "nosniff") + c.Header("X-Frame-Options", "DENY") + c.Header("X-XSS-Protection", "1; mode=block") + c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains") + c.Header("Content-Security-Policy", "default-src 'self'") + c.Header("Referrer-Policy", "strict-origin-when-cross-origin") + c.Next() + } +} + +// ✅ Remove server header +func RemoveServerHeader() gin.HandlerFunc { + return func(c *gin.Context) { + c.Header("Server", "") + c.Next() + } +} + +// ✅ CORS configuration +func CORS() gin.HandlerFunc { + return cors.New(cors.Config{ + AllowOrigins: []string{"https://example.com"}, + AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowHeaders: []string{"Origin", "Content-Type", "Authorization"}, + ExposeHeaders: []string{"Content-Length"}, + AllowCredentials: true, + MaxAge: 12 * time.Hour, + }) +} +``` + +### 7. XSS (A03:2021) + +```go +// ✅ Input sanitization +import "github.com/microcosm-cc/bluemonday" + +func SanitizeInput(input string) string { + p := bluemonday.UGCPolicy() + return p.Sanitize(input) +} + +// ✅ Output encoding +import "html/template" + +func RenderTemplate(w http.ResponseWriter, data interface{}) { + tmpl := template.Must(template.New("safe").Parse(`{{.Content}}`)) + tmpl.Execute(w, data) // Auto-escapes HTML +} + +// ✅ Content Security Policy +func ContentSecurityPolicy() gin.HandlerFunc { + return func(c *gin.Context) { + c.Header("Content-Security-Policy", + "default-src 'self'; "+ + "script-src 'self'; "+ + "style-src 'self' 'unsafe-inline'; "+ + "img-src 'self' data:; "+ + "font-src 'self'; "+ + "connect-src 'self'; "+ + "frame-ancestors 'none'") + c.Next() + } +} +``` + +### 8. Insecure Deserialization (A08:2021) + +```go +// ❌ Vulnerable: Unmarshal untrusted JSON +func ParseUser(data []byte) (User, error) { + var user User + err := json.Unmarshal(data, &user) + return user, err +} + +// ✅ Secure: Validate after unmarshal +func ParseUser(data []byte) (User, error) { + var user User + if err := json.Unmarshal(data, &user); err != nil { + return User{}, err + } + + // Validate fields + if err := validateUser(&user); err != nil { + return User{}, err + } + + // Check for unexpected fields + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + return User{}, err + } + + expected := map[string]bool{ + "email": true, "name": true, "password": true, + } + + for key := range raw { + if !expected[key] { + return User{}, fmt.Errorf("unexpected field: %s", key) + } + } + + return user, nil +} +``` + +### 9. Known Vulnerabilities (A06:2021) + +```bash +# Check for vulnerabilities +go install golang.org/x/vuln/cmd/govulncheck@latest +govulncheck ./... + +# Update dependencies +go get -u ./... +go mod tidy + +# Check for outdated packages +go list -u -m -json all | jq 'select(.Update != null)' +``` + +```go +// ✅ Keep dependencies updated in go.mod +module myapp + +go 1.21 + +require ( + github.com/gin-gonic/gin v1.9.1 // Latest stable + golang.org/x/crypto v0.17.0 // Latest security patches + gorm.io/gorm v1.25.5 +) +``` + +### 10. Logging & Monitoring (A09:2021) + +```go +// ✅ Structured logging +import "go.uber.org/zap" + +func SetupLogger() *zap.Logger { + logger, _ := zap.NewProduction() + defer logger.Sync() + return logger +} + +// Log security events +func LogSecurityEvent(logger *zap.Logger, event string, details map[string]interface{}) { + logger.Info("security_event", + zap.String("event", event), + zap.Any("details", details), + zap.String("timestamp", time.Now().Format(time.RFC3339)), + ) +} + +// Audit logging middleware +func AuditLog(logger *zap.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + + c.Next() + + logger.Info("request", + zap.String("method", c.Request.Method), + zap.String("path", c.Request.URL.Path), + zap.Int("status", c.Writer.Status()), + zap.Duration("latency", time.Since(start)), + zap.String("user_id", getUserID(c)), + zap.String("ip", c.ClientIP()), + ) + } +} + +// Detect brute force +type LoginAttempt struct { + mu sync.RWMutex + store map[string]*Attempt +} + +type Attempt struct { + Count int + FirstTime time.Time + Blocked bool +} + +func (la *LoginAttempt) Check(email string) bool { + la.mu.Lock() + defer la.mu.Unlock() + + attempt, exists := la.store[email] + if !exists { + la.store[email] = &Attempt{Count: 1, FirstTime: time.Now()} + return true + } + + // Reset after 15 minutes + if time.Since(attempt.FirstTime) > 15*time.Minute { + la.store[email] = &Attempt{Count: 1, FirstTime: time.Now()} + return true + } + + // Block after 5 attempts + if attempt.Count >= 5 { + attempt.Blocked = true + return false + } + + attempt.Count++ + return true +} +``` + +## Security Checklist + +- [ ] Use parameterized queries (SQL injection prevention) +- [ ] Hash passwords with bcrypt (minimum cost 12) +- [ ] Use HTTPS in production +- [ ] Set security headers (helmet equivalent) +- [ ] Validate all input +- [ ] Rate limit public endpoints +- [ ] Use environment variables for secrets +- [ ] Implement proper error handling +- [ ] Log security events +- [ ] Run govulncheck regularly +- [ ] Keep dependencies updated +- [ ] Implement CORS properly +- [ ] Use Content Security Policy +- [ ] Implement proper session management +- [ ] Audit authentication flows \ No newline at end of file diff --git a/.kilo/skills/go-testing/SKILL.md b/.kilo/skills/go-testing/SKILL.md new file mode 100644 index 0000000..7d758e1 --- /dev/null +++ b/.kilo/skills/go-testing/SKILL.md @@ -0,0 +1,546 @@ +# Go Testing + +Comprehensive testing patterns for Go applications. + +## Setup + +```go +// No additional installation needed +// Go has built-in testing package + +// Directory structure +myapp/ +├── internal/ +│ └── services/ +│ ├── user.go +│ └── user_test.go # Tests go next to source +└── go.mod +``` + +## Unit Tests + +```go +// internal/services/user_test.go +package services + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +// Using testify for assertions +func TestUserService_Create(t *testing.T) { + tests := []struct { + name string + input *models.RegisterRequest + wantErr bool + }{ + { + name: "valid user", + input: &models.RegisterRequest{ + Email: "test@example.com", + Password: "password123", + Name: "Test User", + }, + wantErr: false, + }, + { + name: "duplicate email", + input: &models.RegisterRequest{ + Email: "existing@example.com", + Password: "password123", + Name: "Test User", + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Setup mock + mockRepo := NewMockUserRepository(t) + service := NewUserService(mockRepo) + + if tt.wantErr { + mockRepo.On("FindByEmail", mock.Anything, tt.input.Email). + Return(&models.User{Email: tt.input.Email}, nil) + } else { + mockRepo.On("FindByEmail", mock.Anything, tt.input.Email). + Return(nil, gorm.ErrRecordNotFound) + mockRepo.On("Create", mock.Anything, mock.Anything). + Return(nil) + } + + // Execute + _, err := service.Create(context.Background(), tt.input) + + // Assert + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} +``` + +## Table-Driven Tests + +```go +// Test with table-driven approach +func TestValidateEmail(t *testing.T) { + tests := []struct { + name string + email string + valid bool + }{ + {"valid email", "user@example.com", true}, + {"valid with subdomain", "user@mail.example.com", true}, + {"missing @", "userexample.com", false}, + {"missing domain", "user@", false}, + {"empty string", "", false}, + {"special chars", "user+test@example.com", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ValidateEmail(tt.email) + assert.Equal(t, tt.valid, got) + }) + } +} + +// Benchmark +func BenchmarkValidateEmail(b *testing.B) { + email := "user@example.com" + for i := 0; i < b.N; i++ { + ValidateEmail(email) + } +} + +// Parallel tests +func TestUser_Parallel(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + // ... + }{ + // test cases + } + + for _, tt := range tests { + tt := tt // Capture range variable + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + // test code + }) + } +} +``` + +## Mocking + +```go +// Using testify/mock +package mocks + +import ( + "github.com/stretchr/testify/mock" + "myapp/internal/models" +) + +type MockUserRepository struct { + mock.Mock +} + +func (m *MockUserRepository) FindByID(ctx context.Context, id int64) (*models.User, error) { + args := m.Called(ctx, id) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*models.User), args.Error(1) +} + +func (m *MockUserRepository) FindByEmail(ctx context.Context, email string) (*models.User, error) { + args := m.Called(ctx, email) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*models.User), args.Error(1) +} + +func (m *MockUserRepository) Create(ctx context.Context, user *models.User) error { + args := m.Called(ctx, user) + return args.Error(0) +} + +// Usage in test +func TestUserService_GetByID(t *testing.T) { + mockRepo := new(MockUserRepository) + service := NewUserService(mockRepo) + + mockUser := &models.User{ID: 1, Email: "test@example.com"} + mockRepo.On("FindByID", mock.Anything, int64(1)).Return(mockUser, nil) + + user, err := service.GetByID(context.Background(), 1) + + assert.NoError(t, err) + assert.Equal(t, mockUser, user) + mockRepo.AssertExpectations(t) +} + +// Using mockgen +//go:generate mockgen -source=user.go -destination=mocks/user.go -package=mocks +``` + +## Integration Tests + +```go +// tests/integration/user_test.go +package integration + +import ( + "context" + "database/sql" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +type UserTestSuite struct { + suite.Suite + db *sql.DB + router *gin.Engine +} + +func (s *UserTestSuite) SetupSuite() { + // Setup test database + db, err := sql.Open("sqlite3", ":memory:") + s.Require().NoError(err) + s.db = db + + // Run migrations + // ... + + // Setup router + s.router = setupTestRouter(db) +} + +func (s *UserTestSuite) TearDownSuite() { + s.db.Close() +} + +func (s *UserTestSuite) SetupTest() { + // Clear tables before each test + s.db.Exec("DELETE FROM users") +} + +func (s *UserTestSuite) TestCreateUser() { + body := `{"email":"test@example.com","password":"password123","name":"Test"}` + + req, _ := http.NewRequest("POST", "/api/v1/register", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + s.router.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusCreated, w.Code) + + var response map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &response) + + assert.NotNil(s.T(), response["user"]) +} + +func (s *UserTestSuite) TestGetUser() { + // Create user first + // ... + + req, _ := http.NewRequest("GET", "/api/v1/users/1", nil) + + w := httptest.NewRecorder() + s.router.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) +} + +func TestUserTestSuite(t *testing.T) { + suite.Run(t, new(UserTestSuite)) +} +``` + +## Test Helpers + +```go +// tests/helpers.go +package tests + +import ( + "context" + "database/sql" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +// SetupTestDB creates a mock database for testing +func SetupTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("Error creating mock: %v", err) + } + + gormDB, err := gorm.Open(mysql.New(mysql.Config{ + Conn: db, + SkipInitializeWithVersion: true, + }), &gorm.Config{}) + if err != nil { + t.Fatalf("Error opening gorm: %v", err) + } + + return gormDB, mock +} + +// Context with timeout +func Context(t *testing.T) context.Context { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + t.Cleanup(cancel) + return ctx +} + +// Assert error type +func AssertError(t *testing.T, err error, expected string) { + t.Helper() + assert.ErrorContains(t, err, expected) +} +``` + +## HTTP Handlers Testing + +```go +// internal/handlers/user_test.go +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +func TestGetUser(t *testing.T) { + // Setup + gin.SetMode(gin.TestMode) + + mockService := NewMockUserService(t) + handler := NewUserHandler(mockService) + + router := gin.New() + router.GET("/users/:id", handler.GetUser) + + // Mock service call + mockService.On("GetByID", mock.Anything, int64(1)). + Return(&models.User{ID: 1, Email: "test@example.com"}, nil) + + // Create request + req := httptest.NewRequest("GET", "/users/1", nil) + w := httptest.NewRecorder() + + // Execute + router.ServeHTTP(w, req) + + // Assert + assert.Equal(t, http.StatusOK, w.Code) + + var response map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &response) + + assert.NotNil(t, response["user"]) + mockService.AssertExpectations(t) +} + +func TestCreateUser_InvalidInput(t *testing.T) { + gin.SetMode(gin.TestMode) + + handler := NewUserHandler(nil) + + router := gin.New() + router.POST("/users", handler.CreateUser) + + body := `{"email":"invalid","password":"short"}` + req := httptest.NewRequest("POST", "/users", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} +``` + +## Coverage + +```bash +# Run tests with coverage +go test -cover ./... + +# Coverage by function +go test -covermode=atomic -coverprofile=coverage.out ./... +go tool cover -func=coverage.out + +# Generate HTML coverage report +go tool cover -html=coverage.out -o coverage.html + +# Enforce coverage threshold +#!/bin/bash +COVERAGE=$(go test -cover ./... | grep total | awk '{print $3}' | sed 's/%//') +if [ "$COVERAGE" -lt 80 ]; then + echo "Coverage $COVERAGE% is below threshold 80%" + exit 1 +fi +``` + +## Best Practices + +### Don't Do This + +```go +// ❌ Bad: No table-driven tests +func TestAdd(t *testing.T) { + result := Add(1, 2) + if result != 3 { + t.Error("expected 3") + } + + result = Add(2, 3) + if result != 5 { + t.Error("expected 5") + } +} + +// ❌ Bad: No cleanup +func TestDB(t *testing.T) { + db := setupDB() + // No teardown - leaves resources +} + +// ❌ Bad: Sleep in tests +func TestAsync(t *testing.T) { + go doSomething() + time.Sleep(1 * time.Second) // Flaky! + // ... +} +``` + +### Do This + +```go +// ✅ Good: Table-driven tests +func TestAdd(t *testing.T) { + tests := []struct { + name string + a, b int + want int + }{ + {"positive", 1, 2, 3}, + {"negative", -1, -2, -3}, + {"zero", 0, 0, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Add(tt.a, tt.b) + assert.Equal(t, tt.want, got) + }) + } +} + +// ✅ Good: Cleanup with t.Cleanup +func TestDB(t *testing.T) { + db := setupDB(t) + // t.Cleanup automatically called on test completion +} + +func setupDB(t *testing.T) *sql.DB { + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatal(err) + } + + t.Cleanup(func() { + db.Close() + }) + + return db +} + +// ✅ Good: Synchronization +func TestAsync(t *testing.T) { + done := make(chan struct{}) + + go func() { + doSomething() + close(done) + }() + + select { + case <-done: + // success + case <-time.After(5 * time.Second): + t.Fatal("timeout") + } +} +``` + +## Testify Assertions + +```go +import "github.com/stretchr/testify/assert" + +// Equality +assert.Equal(t, expected, actual) +assert.NotEqual(t, expected, actual) + +// True/False +assert.True(t, condition) +assert.False(t, condition) + +// Nil +assert.Nil(t, err) +assert.NotNil(t, obj) + +// Contains +assert.Contains(t, str, substr) +assert.ElementsMatch(t, expected, actual) // Unordered + +// Errors +assert.NoError(t, err) +assert.Error(t, err) +assert.ErrorIs(t, err, target) +assert.ErrorContains(t, err, "substring") + +// Type +assert.IsType(t, &User{}, obj) + +// Length +assert.Len(t, slice, 3) + +// Empty +assert.Empty(t, str) +assert.NotEmpty(t, str) +``` \ No newline at end of file diff --git a/.kilo/skills/go-web-patterns/SKILL.md b/.kilo/skills/go-web-patterns/SKILL.md new file mode 100644 index 0000000..935c908 --- /dev/null +++ b/.kilo/skills/go-web-patterns/SKILL.md @@ -0,0 +1,553 @@ +# Go Web Patterns (Gin) + +Production-ready patterns for building HTTP APIs with Gin framework. + +## Project Structure + +``` +myapp/ +├── cmd/ +│ └── server/ +│ └── main.go # Entry point +├── internal/ +│ ├── config/ +│ │ └── config.go # Configuration +│ ├── handlers/ # HTTP handlers +│ │ ├── user.go +│ │ └── auth.go +│ ├── middleware/ +│ │ ├── auth.go +│ │ ├── logging.go +│ │ └── recovery.go +│ ├── models/ # Data models +│ │ └── user.go +│ ├── services/ # Business logic +│ │ └── user.go +│ └── repositories/ # Data access +│ └── user.go +├── pkg/ # Public packages +│ └── utils/ +├── go.mod +├── go.sum +└── Dockerfile +``` + +## Main Entry Point + +```go +// cmd/server/main.go +package main + +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/gin-gonic/gin" + "myapp/internal/config" + "myapp/internal/handlers" + "myapp/internal/middleware" +) + +func main() { + // Load config + cfg := config.Load() + + // Setup router + router := setupRouter(cfg) + + // Create server + srv := &http.Server{ + Addr: ":" + cfg.Port, + Handler: router, + } + + // Start server in goroutine + go func() { + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("Failed to start server: %v", err) + } + }() + + log.Printf("Server running on port %s", cfg.Port) + + // Graceful shutdown + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + log.Println("Shutting down server...") + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := srv.Shutdown(ctx); err != nil { + log.Fatal("Server forced to shutdown:", err) + } + + log.Println("Server exited") +} + +func setupRouter(cfg *config.Config) *gin.Engine { + // Set mode + gin.SetMode(cfg.Mode) + + router := gin.New() + + // Middleware + router.Use(middleware.Logger()) + router.Use(middleware.Recovery()) + router.Use(middleware.CORS()) + router.Use(middleware.RateLimit()) + + // Health check + router.GET("/health", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "status": "ok", + "time": time.Now().Format(time.RFC3339), + }) + }) + + // API routes + api := router.Group("/api/v1") + { + // Public routes + api.POST("/register", handlers.Register) + api.POST("/login", handlers.Login) + + // Protected routes + protected := api.Group("") + protected.Use(middleware.Authenticate()) + { + protected.GET("/users/:id", handlers.GetUser) + protected.PUT("/users/:id", handlers.UpdateUser) + } + } + + return router +} +``` + +## Handler Pattern + +```go +// internal/handlers/user.go +package handlers + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "myapp/internal/models" + "myapp/internal/services" +) + +type UserHandler struct { + userService *services.UserService +} + +func NewUserHandler(userService *services.UserService) *UserHandler { + return &UserHandler{userService: userService} +} + +// Register godoc +// @Summary Register a new user +// @Description Create a new user account +// @Tags auth +// @Accept json +// @Produce json +// @Param user body models.RegisterRequest true "User registration data" +// @Success 201 {object} models.User +// @Failure 400 {object} gin.H +// @Router /register [post] +func (h *UserHandler) Register(c *gin.Context) { + var req models.RegisterRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + user, err := h.userService.Create(c.Request.Context(), &req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, gin.H{"user": user}) +} + +func (h *UserHandler) GetUser(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + + user, err := h.userService.GetByID(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{"user": user}) +} +``` + +## Service Layer + +```go +// internal/services/user.go +package services + +import ( + "context" + "errors" + + "golang.org/x/crypto/bcrypt" + "myapp/internal/models" + "myapp/internal/repositories" +) + +type UserService struct { + repo *repositories.UserRepository +} + +func NewUserService(repo *repositories.UserRepository) *UserService { + return &UserService{repo: repo} +} + +func (s *UserService) Create(ctx context.Context, req *models.RegisterRequest) (*models.User, error) { + // Check if user exists + existing, _ := s.repo.FindByEmail(ctx, req.Email) + if existing != nil { + return nil, errors.New("email already registered") + } + + // Hash password + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + + user := &models.User{ + Email: req.Email, + Password: string(hashedPassword), + Name: req.Name, + Role: "user", + } + + if err := s.repo.Create(ctx, user); err != nil { + return nil, err + } + + return user, nil +} + +func (s *UserService) GetByID(ctx context.Context, id int64) (*models.User, error) { + return s.repo.FindByID(ctx, id) +} +``` + +## Middleware Pattern + +```go +// internal/middleware/auth.go +package middleware + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" +) + +func Authenticate() gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header required"}) + c.Abort() + return + } + + parts := strings.Split(authHeader, " ") + if len(parts) != 2 || parts[0] != "Bearer" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid authorization header"}) + c.Abort() + return + } + + tokenString := parts[1] + + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + return []byte("secret"), nil + }) + + if err != nil || !token.Valid { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + c.Abort() + return + } + + claims := token.Claims.(jwt.MapClaims) + c.Set("userID", claims["sub"]) + c.Set("role", claims["role"]) + + c.Next() + } +} + +// internal/middleware/logging.go +package middleware + +import ( + "time" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +func Logger() gin.HandlerFunc { + logger, _ := zap.NewProduction() + + return func(c *gin.Context) { + start := time.Now() + path := c.Request.URL.Path + query := c.Request.URL.RawQuery + + c.Next() + + latency := time.Since(start) + + logger.Info("request", + zap.Int("status", c.Writer.Status()), + zap.String("method", c.Request.Method), + zap.String("path", path), + zap.String("query", query), + zap.String("ip", c.ClientIP()), + zap.Duration("latency", latency), + zap.String("user-agent", c.Request.UserAgent()), + ) + } +} +``` + +## Validation + +```go +// internal/validators/user.go +package validators + +import ( + "regexp" + "unicode" + + "github.com/go-playground/validator/v10" +) + +type RegisterRequest struct { + Email string `json:"email" validate:"required,email"` + Password string `json:"password" validate:"required,min=8,max=72,password"` + Name string `json:"name" validate:"required,min=2,max=100"` +} + +var passwordRegex = regexp.MustCompile(`^[a-zA-Z0-9!@#$%^&*()_+-=]+$`) + +func ValidatePassword(fl validator.FieldLevel) bool { + password := fl.Field().String() + + if len(password) < 8 || len(password) > 72 { + return false + } + + var hasUpper, hasLower, hasDigit bool + for _, char := range password { + switch { + case unicode.IsUpper(char): + hasUpper = true + case unicode.IsLower(char): + hasLower = true + case unicode.IsDigit(char): + hasDigit = true + } + } + + return hasUpper && hasLower && hasDigit +} + +func init() { + validate := validator.New() + validate.RegisterValidation("password", ValidatePassword) +} +``` + +## Error Handling + +```go +// internal/errors/errors.go +package errors + +import "net/http" + +type AppError struct { + Code int `json:"code"` + Message string `json:"message"` + Err error `json:"-"` +} + +func (e *AppError) Error() string { + return e.Message +} + +func NewNotFoundError(message string) *AppError { + return &AppError{Code: http.StatusNotFound, Message: message} +} + +func NewBadRequestError(message string) *AppError { + return &AppError{Code: http.StatusBadRequest, Message: message} +} + +func NewUnauthorizedError(message string) *AppError { + return &AppError{Code: http.StatusUnauthorized, Message: message} +} + +func NewInternalError(message string) *AppError { + return &AppError{Code: http.StatusInternalServerError, Message: message} +} + +// Middleware +func ErrorHandler() gin.HandlerFunc { + return func(c *gin.Context) { + c.Next() + + for _, err := range c.Errors { + if appErr, ok := err.Err.(*AppError); ok { + c.JSON(appErr.Code, gin.H{"error": appErr.Message}) + return + } + + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + return + } + } +} +``` + +## Dependency Injection + +```go +// cmd/server/main.go (updated) +package main + +import ( + "myapp/internal/config" + "myapp/internal/handlers" + "myapp/internal/repositories" + "myapp/internal/services" +) + +func main() { + cfg := config.Load() + + // Setup database + db := setupDB(cfg) + + // Dependency injection + userRepo := repositories.NewUserRepository(db) + userService := services.NewUserService(userRepo) + userHandler := handlers.NewUserHandler(userService) + + // Setup router + router := gin.New() + + api := router.Group("/api/v1") + { + api.POST("/register", userHandler.Register) + } + + router.Run(":8080") +} +``` + +## Best Practices + +### Don't Do This + +```go +// ❌ Bad: Logic in handler +func GetUser(c *gin.Context) { + id := c.Param("id") + var user User + db.Where("id = ?", id).First(&user) + c.JSON(200, user) +} + +// ❌ Bad: No error handling +func CreateUser(c *gin.Context) { + var user User + c.BindJSON(&user) + db.Create(&user) + c.JSON(201, user) +} + +// ❌ Bad: Global state +var db *gorm.DB + +func init() { + db = gorm.Open(...) +} +``` + +### Do This + +```go +// ✅ Good: Handler → Service → Repository pattern +func (h *UserHandler) GetUser(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + + user, err := h.userService.GetByID(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{"user": user}) +} + +// ✅ Good: Dependency injection +func NewApp(db *gorm.DB) *App { + userRepo := repositories.NewUserRepository(db) + userService := services.NewUserService(userRepo) + userHandler := handlers.NewUserHandler(userService) + + return &App{ + userHandler: userHandler, + } +} + +// ✅ Good: Context propagation +func (s *UserService) GetByID(ctx context.Context, id int64) (*models.User, error) { + var user models.User + if err := s.repo.WithContext(ctx).First(&user, id).Error; err != nil { + return nil, err + } + return &user, nil +} +``` + +## See Also + +- `go-error-handling` - Comprehensive error patterns +- `go-middleware` - Middleware patterns +- `go-security` - Security best practices +- `go-testing` - Testing patterns \ No newline at end of file