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
This commit is contained in:
493
.kilo/agents/go-developer.md
Normal file
493
.kilo/agents/go-developer.md
Normal file
@@ -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.
|
||||
@@ -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:
|
||||
|
||||
283
.kilo/rules/go.md
Normal file
283
.kilo/rules/go.md
Normal file
@@ -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)
|
||||
```
|
||||
553
.kilo/skills/go-concurrency/SKILL.md
Normal file
553
.kilo/skills/go-concurrency/SKILL.md
Normal file
@@ -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
|
||||
}
|
||||
```
|
||||
628
.kilo/skills/go-db-patterns/SKILL.md
Normal file
628
.kilo/skills/go-db-patterns/SKILL.md
Normal file
@@ -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
|
||||
}
|
||||
```
|
||||
421
.kilo/skills/go-error-handling/SKILL.md
Normal file
421
.kilo/skills/go-error-handling/SKILL.md
Normal file
@@ -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)
|
||||
}
|
||||
```
|
||||
602
.kilo/skills/go-middleware/SKILL.md
Normal file
602
.kilo/skills/go-middleware/SKILL.md
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
```
|
||||
543
.kilo/skills/go-modules/SKILL.md
Normal file
543
.kilo/skills/go-modules/SKILL.md
Normal file
@@ -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 <module-path>
|
||||
|
||||
# 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 <package>
|
||||
|
||||
# Edit go.mod
|
||||
go mod edit -require=<package>@<version>
|
||||
go mod edit -droprequire=<package>
|
||||
|
||||
# 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/*
|
||||
```
|
||||
428
.kilo/skills/go-security/SKILL.md
Normal file
428
.kilo/skills/go-security/SKILL.md
Normal file
@@ -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
|
||||
546
.kilo/skills/go-testing/SKILL.md
Normal file
546
.kilo/skills/go-testing/SKILL.md
Normal file
@@ -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)
|
||||
```
|
||||
553
.kilo/skills/go-web-patterns/SKILL.md
Normal file
553
.kilo/skills/go-web-patterns/SKILL.md
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user