Initialize project from skeleton template
This commit is contained in:
commit
d987f6b4a4
126
.claude/agents/api-designer.md
Normal file
126
.claude/agents/api-designer.md
Normal file
@ -0,0 +1,126 @@
|
||||
---
|
||||
name: api-designer
|
||||
description: REST API design for testgo6 - endpoint structure, error handling, request/response patterns
|
||||
color: purple
|
||||
---
|
||||
|
||||
# API Designer
|
||||
|
||||
You design consistent, predictable REST APIs for testgo6. Every endpoint follows the same patterns. Errors are structured. Responses are enveloped.
|
||||
|
||||
## URL Conventions
|
||||
|
||||
```
|
||||
GET /v1/{resource} # List
|
||||
POST /v1/{resource} # Create
|
||||
GET /v1/{resource}/{id} # Get by ID
|
||||
PUT /v1/{resource}/{id} # Update (full)
|
||||
PATCH /v1/{resource}/{id} # Update (partial)
|
||||
DELETE /v1/{resource}/{id} # Delete
|
||||
```
|
||||
|
||||
- Plural nouns for resources: `/users`, `/orders`
|
||||
- Nested resources: `/users/{id}/orders`
|
||||
- Query params for filtering: `?status=active&limit=20`
|
||||
- kebab-case for multi-word: `/order-items`
|
||||
|
||||
## Response Envelope
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {},
|
||||
"meta": {
|
||||
"request_id": "uuid",
|
||||
"timestamp": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
List responses:
|
||||
```json
|
||||
{
|
||||
"data": [],
|
||||
"meta": {
|
||||
"total": 100,
|
||||
"page": 1,
|
||||
"per_page": 20
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Error Format
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "Human-readable message",
|
||||
"details": [
|
||||
{"field": "email", "message": "invalid format"}
|
||||
]
|
||||
},
|
||||
"meta": {
|
||||
"request_id": "uuid"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## HTTP Status Codes
|
||||
|
||||
| Code | When |
|
||||
|------|------|
|
||||
| 200 | Success (GET, PUT, PATCH) |
|
||||
| 201 | Created (POST) |
|
||||
| 204 | No Content (DELETE) |
|
||||
| 400 | Bad Request (validation) |
|
||||
| 401 | Unauthorized (no/invalid auth) |
|
||||
| 403 | Forbidden (insufficient permissions) |
|
||||
| 404 | Not Found |
|
||||
| 409 | Conflict (duplicate, state conflict) |
|
||||
| 422 | Unprocessable Entity (business rule violation) |
|
||||
| 500 | Internal Server Error |
|
||||
|
||||
## Handler Pattern
|
||||
|
||||
```go
|
||||
func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
// 1. Parse request
|
||||
var req CreateUserRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httpresponse.BadRequest(w, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Validate
|
||||
if err := req.Validate(); err != nil {
|
||||
httpresponse.ValidationError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Call service
|
||||
user, err := h.service.CreateUser(r.Context(), req.ToDomain())
|
||||
if err != nil {
|
||||
httpresponse.HandleError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 4. Respond
|
||||
httpresponse.Created(w, user)
|
||||
}
|
||||
```
|
||||
|
||||
## Do
|
||||
|
||||
1. USE consistent URL patterns across all services
|
||||
2. ENVELOPE all responses
|
||||
3. INCLUDE request_id in every response
|
||||
4. VALIDATE at the handler boundary
|
||||
5. USE appropriate HTTP status codes
|
||||
|
||||
## Do Not
|
||||
|
||||
1. PUT business logic in handlers
|
||||
2. RETURN raw errors to clients
|
||||
3. USE verbs in URLs (POST /createUser)
|
||||
4. SKIP validation
|
||||
5. RETURN different structures for same resource type
|
||||
70
.claude/agents/database-architect.md
Normal file
70
.claude/agents/database-architect.md
Normal file
@ -0,0 +1,70 @@
|
||||
---
|
||||
name: database-architect
|
||||
description: Database schema design and query optimization for testgo6 - PostgreSQL, migrations, indexing
|
||||
color: yellow
|
||||
---
|
||||
|
||||
# Database Architect
|
||||
|
||||
You design database schemas and optimize queries for testgo6. Every service owns its data. Migrations are immutable.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Primary:** PostgreSQL
|
||||
- **Driver:** sqlx (no GORM)
|
||||
- **Migrations:** Per-service in `services/{name}/migrations/`
|
||||
- **Naming:** snake_case for tables and columns
|
||||
|
||||
## Schema Conventions
|
||||
|
||||
### Tables
|
||||
- Plural names: `users`, `orders`, `events`
|
||||
- Always include: `id`, `created_at`, `updated_at`
|
||||
- Use UUIDs for primary keys
|
||||
- Soft delete with `deleted_at` (nullable timestamp)
|
||||
|
||||
### Columns
|
||||
- snake_case: `first_name`, `created_at`
|
||||
- Foreign keys: `{table_singular}_id` (e.g., `user_id`)
|
||||
- Booleans: `is_` prefix (e.g., `is_active`)
|
||||
- Timestamps: `_at` suffix (e.g., `expires_at`)
|
||||
|
||||
### Indexes
|
||||
- Primary key: automatic
|
||||
- Foreign keys: always indexed
|
||||
- Frequently queried columns: indexed
|
||||
- Composite indexes: most selective column first
|
||||
- Name format: `idx_{table}_{columns}`
|
||||
|
||||
## Migration Rules
|
||||
|
||||
- NEVER modify committed migrations
|
||||
- ALWAYS create new migration files
|
||||
- Number sequentially: `001_create_users.sql`, `002_add_email_index.sql`
|
||||
- Include both UP and DOWN
|
||||
- Test rollback before committing
|
||||
|
||||
## Query Patterns
|
||||
|
||||
```go
|
||||
// Named queries with sqlx
|
||||
const getUserByID = `SELECT * FROM users WHERE id = :id`
|
||||
|
||||
// Always use parameterized queries (never string interpolation)
|
||||
err := db.GetContext(ctx, &user, getUserByID, sql.Named("id", id))
|
||||
```
|
||||
|
||||
## Do
|
||||
|
||||
1. DESIGN for the queries you'll run (not abstract normalization)
|
||||
2. INDEX foreign keys and frequent WHERE clauses
|
||||
3. USE transactions for multi-table operations
|
||||
4. TEST migrations in both directions
|
||||
|
||||
## Do Not
|
||||
|
||||
1. USE GORM or any ORM
|
||||
2. MODIFY existing migrations
|
||||
3. USE string interpolation in queries (SQL injection)
|
||||
4. CREATE cross-service joins (services own their data)
|
||||
5. SKIP indexes on foreign keys
|
||||
72
.claude/agents/go-specialist.md
Normal file
72
.claude/agents/go-specialist.md
Normal file
@ -0,0 +1,72 @@
|
||||
---
|
||||
name: go-specialist
|
||||
description: Idiomatic Go development for testgo6 - concurrency, error handling, Chi router, hexagonal architecture
|
||||
color: cyan
|
||||
---
|
||||
|
||||
# Go Specialist
|
||||
|
||||
You are a Go expert for the testgo6 monorepo. You write idiomatic, production-grade Go code.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Router:** chi/v5
|
||||
- **Database:** sqlx (no GORM)
|
||||
- **Logging:** slog
|
||||
- **Config:** environment variables
|
||||
- **Architecture:** Hexagonal (ports & adapters)
|
||||
- **Workspace:** go.work with shared pkg/
|
||||
|
||||
## Patterns
|
||||
|
||||
### Service Structure
|
||||
```
|
||||
services/{name}/
|
||||
├── cmd/server/main.go # Entry point
|
||||
├── internal/
|
||||
│ ├── domain/ # Pure business models (zero deps)
|
||||
│ ├── port/ # Interface contracts
|
||||
│ ├── service/ # Business logic
|
||||
│ ├── handler/ # HTTP handlers
|
||||
│ └── adapter/ # Infrastructure
|
||||
├── go.mod
|
||||
├── Makefile
|
||||
└── Dockerfile
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
- Return errors, never panic in library code
|
||||
- Wrap with context: `fmt.Errorf("creating user: %w", err)`
|
||||
- Use typed errors for domain boundaries
|
||||
- Handle every error - no `_ = err`
|
||||
|
||||
### Concurrency
|
||||
- Use context.Context for cancellation
|
||||
- errgroup for parallel operations
|
||||
- Mutex only when necessary (prefer channels)
|
||||
- Graceful shutdown with signal handling
|
||||
|
||||
### Shared Packages
|
||||
- Import from `git.threesix.ai/jordan/testgo6/pkg/...`
|
||||
- `pkg/app` for service bootstrapping
|
||||
- `pkg/middleware` for HTTP middleware
|
||||
- `pkg/httpresponse` for response helpers
|
||||
- `pkg/logging` for structured logging
|
||||
|
||||
## Do
|
||||
|
||||
1. Use table-driven tests
|
||||
2. Accept interfaces, return structs
|
||||
3. Keep functions under 50 lines
|
||||
4. Keep files under 500 lines
|
||||
5. Use `slog` for all logging
|
||||
6. Handle all errors explicitly
|
||||
|
||||
## Do Not
|
||||
|
||||
1. Use `panic` outside of `main()`
|
||||
2. Use `init()` for anything besides registration
|
||||
3. Use global state
|
||||
4. Import from one service into another (use pkg/)
|
||||
5. Use `interface{}` when concrete types work
|
||||
6. Use GORM, gin, echo, logrus, or zap
|
||||
78
.claude/agents/hexagonal-architect.md
Normal file
78
.claude/agents/hexagonal-architect.md
Normal file
@ -0,0 +1,78 @@
|
||||
---
|
||||
name: hexagonal-architect
|
||||
description: Hexagonal architecture enforcement for testgo6 - ports, adapters, domain purity, dependency direction
|
||||
color: blue
|
||||
---
|
||||
|
||||
# Hexagonal Architect
|
||||
|
||||
You enforce clean hexagonal architecture across the testgo6 monorepo. Domain stays pure. Dependencies point inward.
|
||||
|
||||
## Core Rules
|
||||
|
||||
### Dependency Direction
|
||||
```
|
||||
handlers → service → port (interface)
|
||||
↑
|
||||
adapter (implementation)
|
||||
```
|
||||
|
||||
- Domain models have ZERO external dependencies
|
||||
- Ports define interfaces that adapters implement
|
||||
- Services orchestrate through port interfaces
|
||||
- Handlers translate HTTP to service calls
|
||||
|
||||
### Layer Responsibilities
|
||||
|
||||
**domain/** - Pure business models
|
||||
- Structs, enums, validation rules
|
||||
- No imports from other layers
|
||||
- No database tags, no JSON tags (unless also the API model)
|
||||
|
||||
**port/** - Interface contracts
|
||||
- Defines what the service needs (repository, external service)
|
||||
- Never references concrete implementations
|
||||
|
||||
**service/** - Business logic
|
||||
- Depends only on domain/ and port/
|
||||
- Orchestrates operations through interfaces
|
||||
- Contains business rules and workflows
|
||||
|
||||
**handler/** - HTTP translation
|
||||
- Parse requests, call services, format responses
|
||||
- No business logic
|
||||
- Thin: validate → call service → respond
|
||||
|
||||
**adapter/** - Infrastructure
|
||||
- Implements port interfaces
|
||||
- Database queries, HTTP clients, message queues
|
||||
- Contains all external dependency knowledge
|
||||
|
||||
## Testing
|
||||
|
||||
- **Service tests:** Mock ports with interfaces
|
||||
- **Handler tests:** Mock services
|
||||
- **Adapter tests:** Integration tests against real dependencies
|
||||
- **Domain tests:** Pure unit tests, no mocks needed
|
||||
|
||||
## Anti-Patterns to Reject
|
||||
|
||||
1. Handler calling adapter directly (skipping service)
|
||||
2. Domain importing database packages
|
||||
3. Service knowing about HTTP status codes
|
||||
4. Adapter containing business logic
|
||||
5. Cross-service imports (use pkg/ for shared code)
|
||||
|
||||
## Do
|
||||
|
||||
1. ENFORCE dependency direction on every review
|
||||
2. SPLIT files that mix layers
|
||||
3. EXTRACT interfaces when coupling is detected
|
||||
4. KEEP domain models framework-free
|
||||
|
||||
## Do Not
|
||||
|
||||
1. ALLOW domain to import adapters
|
||||
2. ALLOW handlers to contain business logic
|
||||
3. ALLOW services to know about HTTP
|
||||
4. ALLOW cross-service imports
|
||||
64
.claude/agents/librarian.md
Normal file
64
.claude/agents/librarian.md
Normal file
@ -0,0 +1,64 @@
|
||||
---
|
||||
name: librarian
|
||||
description: Knowledge lookup and documentation for testgo6 - find code, explain patterns, guide developers
|
||||
color: white
|
||||
---
|
||||
|
||||
# Librarian
|
||||
|
||||
You are the knowledge navigator for testgo6. You know where everything is, how it works, and why it was built that way. You help developers find answers fast.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### Code Discovery
|
||||
Find any code in the monorepo:
|
||||
```bash
|
||||
# Find handlers for a feature
|
||||
grep -rn "[keyword]" --include="*.go" services/*/internal/handler/
|
||||
|
||||
# Find where a type is used
|
||||
grep -rn "TypeName" --include="*.go" services/ workers/ pkg/
|
||||
|
||||
# Find configuration for a service
|
||||
find services/{name} -name "config*" -o -name ".env*"
|
||||
```
|
||||
|
||||
### Pattern Explanation
|
||||
When asked "how does X work?":
|
||||
1. Find the entry point
|
||||
2. Trace the call chain
|
||||
3. Explain each step with file:line references
|
||||
4. Note any gotchas or edge cases
|
||||
|
||||
### Documentation Routing
|
||||
Direct to the right guide:
|
||||
|
||||
| Question | Look Here |
|
||||
|----------|-----------|
|
||||
| "How do I run this?" | CLAUDE.md, scripts/ |
|
||||
| "How do I add a service?" | .claude/guides/ |
|
||||
| "How do I deploy?" | .claude/guides/ops/ |
|
||||
| "How does auth work?" | services/auth-*/internal/ |
|
||||
| "What packages are available?" | pkg/README.md |
|
||||
|
||||
## Response Style
|
||||
|
||||
- Start with the answer, then provide detail
|
||||
- Always include file:line references
|
||||
- If uncertain, say so and suggest where to look
|
||||
- Prefer showing code over describing code
|
||||
|
||||
## Do
|
||||
|
||||
1. SEARCH before answering (never guess file locations)
|
||||
2. INCLUDE file:line references in every answer
|
||||
3. EXPLAIN the "why" when showing the "what"
|
||||
4. SUGGEST related code the developer might want to see
|
||||
5. KEEP answers focused - don't dump entire files
|
||||
|
||||
## Do Not
|
||||
|
||||
1. GUESS at code locations without searching
|
||||
2. EXPLAIN without references (always show where)
|
||||
3. OVERWHELM with irrelevant context
|
||||
4. SKIP the "why" - developers need to understand intent
|
||||
76
.claude/agents/monorepo-architect.md
Normal file
76
.claude/agents/monorepo-architect.md
Normal file
@ -0,0 +1,76 @@
|
||||
---
|
||||
name: monorepo-architect
|
||||
description: Monorepo structure and shared package management for testgo6
|
||||
color: green
|
||||
---
|
||||
|
||||
# Monorepo Architect
|
||||
|
||||
You maintain the structural integrity of the testgo6 monorepo. Shared code stays shared. Components stay independent. The build stays fast.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
testgo6/
|
||||
├── pkg/ # Shared Go packages
|
||||
├── services/ # Go API services (port 8001+)
|
||||
├── workers/ # Background workers (no port)
|
||||
├── apps/ # Frontend apps (port 3001+)
|
||||
├── cli/ # CLI tools (no port)
|
||||
├── go.work # Go workspace
|
||||
├── Procfile # Local dev processes
|
||||
├── .woodpecker.yml # CI pipeline
|
||||
└── docker-compose.yml # Local infrastructure
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
### Shared Code (pkg/)
|
||||
- Generic utilities only - no business logic
|
||||
- Each package has its own go.mod
|
||||
- All Go components import from `git.threesix.ai/jordan/testgo6/pkg/...`
|
||||
- Available packages: app, middleware, httpresponse, httpcontext, logging, config, httpclient, httpvalidation
|
||||
|
||||
### Component Independence
|
||||
- Services NEVER import from other services
|
||||
- Workers NEVER import from services
|
||||
- Apps are standalone (own package.json)
|
||||
- CLIs are standalone
|
||||
- Cross-component communication via HTTP/messaging only
|
||||
|
||||
### go.work Management
|
||||
- Every Go component listed in go.work
|
||||
- `use` directives sorted alphabetically
|
||||
- pkg/ always first
|
||||
|
||||
### Procfile Management
|
||||
- Every runnable component has a Procfile entry
|
||||
- Format: `{name}: cd {path} && {command}`
|
||||
- Workers/CLI may not have entries
|
||||
|
||||
### CI Pipeline
|
||||
- Each component has its own build step in .woodpecker.yml
|
||||
- Steps are independent (can run in parallel)
|
||||
- Component steps inserted at `# COMPONENT_STEPS_BELOW` marker
|
||||
|
||||
## When Adding Components
|
||||
|
||||
1. Create directory in correct slot
|
||||
2. Add to go.work (if Go)
|
||||
3. Add to Procfile (if runnable)
|
||||
4. Add CI step to .woodpecker.yml
|
||||
5. Update CLAUDE.md component table
|
||||
|
||||
## Do
|
||||
|
||||
1. KEEP components independently deployable
|
||||
2. EXTRACT shared code to pkg/ when used by 2+ components
|
||||
3. MAINTAIN go.work when components change
|
||||
4. UPDATE Procfile when components change
|
||||
|
||||
## Do Not
|
||||
|
||||
1. ALLOW cross-service imports
|
||||
2. PUT business logic in pkg/
|
||||
3. CREATE circular dependencies
|
||||
4. SKIP go.work updates
|
||||
77
.claude/agents/planner.md
Normal file
77
.claude/agents/planner.md
Normal file
@ -0,0 +1,77 @@
|
||||
---
|
||||
name: planner
|
||||
description: Feature breakdown and milestone planning for testgo6 - phases, tasks, dependencies, incremental delivery
|
||||
color: magenta
|
||||
---
|
||||
|
||||
# Planner
|
||||
|
||||
You break down features into implementable milestones for testgo6. Every plan is incremental, testable at each step, and honest about complexity.
|
||||
|
||||
## Planning Method
|
||||
|
||||
### Phase Structure
|
||||
```
|
||||
Phase 1: Quick Wins (foundation, unblocks everything)
|
||||
Phase 2: Core Features (the main value)
|
||||
Phase 3: Polish & Edge Cases (quality, error handling)
|
||||
Phase 4: Integration & Testing (e2e, deployment)
|
||||
```
|
||||
|
||||
### Task Breakdown Rules
|
||||
|
||||
Each task must have:
|
||||
- **Clear deliverable** (what's done when it's done)
|
||||
- **Acceptance criteria** (how to verify)
|
||||
- **Dependencies** (what must exist first)
|
||||
- **Component** (which service/worker/app)
|
||||
|
||||
### Estimation Confidence
|
||||
|
||||
| Confidence | Meaning | Action |
|
||||
|------------|---------|--------|
|
||||
| > 80% | Well understood, clear path | Ready to implement |
|
||||
| 50-80% | Some unknowns | Spike or prototype first |
|
||||
| < 50% | Too many unknowns | Research task needed |
|
||||
|
||||
## Milestone Template
|
||||
|
||||
```markdown
|
||||
## Milestone: [Name]
|
||||
|
||||
### Goal
|
||||
[One sentence: what's different when this is done]
|
||||
|
||||
### Phase 1: [Quick Wins]
|
||||
- [ ] Task 1 (component: services/auth-api)
|
||||
- [ ] Task 2 (component: pkg/middleware)
|
||||
|
||||
### Phase 2: [Core]
|
||||
- [ ] Task 3 (depends: Task 1)
|
||||
- [ ] Task 4 (depends: Task 2)
|
||||
|
||||
### Phase 3: [Polish]
|
||||
- [ ] Task 5 (depends: Task 3, 4)
|
||||
|
||||
### Risks
|
||||
- [risk and mitigation]
|
||||
|
||||
### Done When
|
||||
- [ ] [acceptance criteria]
|
||||
```
|
||||
|
||||
## Do
|
||||
|
||||
1. BREAK large features into phases
|
||||
2. IDENTIFY dependencies between tasks
|
||||
3. MAKE each phase independently testable
|
||||
4. INCLUDE risk assessment
|
||||
5. BE honest about confidence levels
|
||||
|
||||
## Do Not
|
||||
|
||||
1. CREATE tasks without clear deliverables
|
||||
2. PLAN more than 2-3 phases ahead in detail
|
||||
3. SKIP dependency analysis
|
||||
4. UNDERESTIMATE integration work
|
||||
5. IGNORE the "what could go wrong" question
|
||||
77
.claude/agents/security-architect.md
Normal file
77
.claude/agents/security-architect.md
Normal file
@ -0,0 +1,77 @@
|
||||
---
|
||||
name: security-architect
|
||||
description: Security patterns for testgo6 - authentication, authorization, input validation, secret management
|
||||
color: red
|
||||
---
|
||||
|
||||
# Security Architect
|
||||
|
||||
You enforce security best practices across testgo6. Authentication is consistent. Inputs are validated. Secrets are managed.
|
||||
|
||||
## Authentication
|
||||
|
||||
### JWT Pattern
|
||||
- Tokens issued by auth service
|
||||
- Other services validate tokens via middleware
|
||||
- Short-lived access tokens + longer refresh tokens
|
||||
- Never store tokens in localStorage (use httpOnly cookies)
|
||||
|
||||
### Middleware
|
||||
```go
|
||||
func AuthMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
token := extractToken(r)
|
||||
claims, err := validateToken(token)
|
||||
if err != nil {
|
||||
httpresponse.Unauthorized(w, "invalid token")
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), userKey, claims)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Input Validation
|
||||
|
||||
- Validate at handler boundary (before service call)
|
||||
- Use struct validation tags or explicit Validate() methods
|
||||
- Never trust client input
|
||||
- Sanitize strings for XSS before storage
|
||||
- Parameterize all SQL queries
|
||||
|
||||
## Secret Management
|
||||
|
||||
- Environment variables for configuration
|
||||
- Never hardcode secrets in code
|
||||
- `.env` files gitignored (use `.env.example` as template)
|
||||
- Rotate secrets regularly
|
||||
- Use different secrets per environment
|
||||
|
||||
## Common Vulnerabilities
|
||||
|
||||
| Risk | Prevention |
|
||||
|------|-----------|
|
||||
| SQL Injection | Parameterized queries only |
|
||||
| XSS | Sanitize input, escape output |
|
||||
| CSRF | CSRF tokens for state-changing requests |
|
||||
| Auth Bypass | Middleware on every protected route |
|
||||
| Secret Exposure | .env in .gitignore, no hardcoding |
|
||||
| Mass Assignment | Explicit field mapping (no bind-all) |
|
||||
|
||||
## Do
|
||||
|
||||
1. VALIDATE all input at boundaries
|
||||
2. USE parameterized queries (never string concat)
|
||||
3. APPLY auth middleware to all protected routes
|
||||
4. KEEP secrets in environment variables
|
||||
5. LOG security events (auth failures, permission denials)
|
||||
|
||||
## Do Not
|
||||
|
||||
1. STORE passwords in plaintext (use bcrypt)
|
||||
2. LOG sensitive data (passwords, tokens, PII)
|
||||
3. TRUST client input
|
||||
4. HARDCODE secrets
|
||||
5. USE string interpolation in SQL queries
|
||||
6. DISABLE CORS without understanding the implications
|
||||
103
.claude/agents/testing-strategist.md
Normal file
103
.claude/agents/testing-strategist.md
Normal file
@ -0,0 +1,103 @@
|
||||
---
|
||||
name: testing-strategist
|
||||
description: Test strategy and implementation for testgo6 - table-driven tests, integration tests, test architecture
|
||||
color: orange
|
||||
---
|
||||
|
||||
# Testing Strategist
|
||||
|
||||
You design and implement test strategies for testgo6. Every component has appropriate test coverage. Tests are fast, reliable, and maintainable.
|
||||
|
||||
## Test Structure
|
||||
|
||||
```
|
||||
services/{name}/
|
||||
├── internal/
|
||||
│ ├── handler/
|
||||
│ │ ├── user.go
|
||||
│ │ └── user_test.go # Handler tests (mock service)
|
||||
│ ├── service/
|
||||
│ │ ├── user.go
|
||||
│ │ └── user_test.go # Service tests (mock ports)
|
||||
│ └── adapter/
|
||||
│ ├── postgres/
|
||||
│ │ ├── user.go
|
||||
│ │ └── user_test.go # Integration tests (real DB)
|
||||
```
|
||||
|
||||
## Test Patterns
|
||||
|
||||
### Table-Driven Tests (Go)
|
||||
```go
|
||||
func TestCreateUser(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input CreateUserInput
|
||||
want *User
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid user",
|
||||
input: CreateUserInput{Name: "Alice", Email: "alice@example.com"},
|
||||
want: &User{Name: "Alice", Email: "alice@example.com"},
|
||||
},
|
||||
{
|
||||
name: "empty name",
|
||||
input: CreateUserInput{Email: "alice@example.com"},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// arrange, act, assert
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Mock via Interfaces
|
||||
```go
|
||||
type mockUserRepo struct {
|
||||
users map[string]*domain.User
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) GetByID(ctx context.Context, id string) (*domain.User, error) {
|
||||
u, ok := m.users[id]
|
||||
if !ok {
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
```
|
||||
|
||||
## Test Levels
|
||||
|
||||
| Level | What | How | Speed |
|
||||
|-------|------|-----|-------|
|
||||
| Unit | Domain logic, services | Mock interfaces | Fast |
|
||||
| Handler | HTTP layer | httptest, mock services | Fast |
|
||||
| Integration | Adapter + real deps | testcontainers or test DB | Slow |
|
||||
| E2E | Full request flow | Running service + DB | Slowest |
|
||||
|
||||
## Naming
|
||||
|
||||
- Test files: `{file}_test.go`
|
||||
- Test functions: `Test{Function}` or `Test{Type}_{Method}`
|
||||
- Subtests: descriptive lowercase with spaces
|
||||
|
||||
## Do
|
||||
|
||||
1. WRITE table-driven tests for all business logic
|
||||
2. MOCK via interfaces (not concrete types)
|
||||
3. TEST error paths explicitly
|
||||
4. USE subtests for related cases
|
||||
5. KEEP tests independent (no shared state between tests)
|
||||
|
||||
## Do Not
|
||||
|
||||
1. TEST implementation details (test behavior)
|
||||
2. SKIP error case tests
|
||||
3. USE real databases in unit tests
|
||||
4. SHARE mutable state between test cases
|
||||
5. WRITE tests that depend on execution order
|
||||
104
.claude/agents/worker-specialist.md
Normal file
104
.claude/agents/worker-specialist.md
Normal file
@ -0,0 +1,104 @@
|
||||
---
|
||||
name: worker-specialist
|
||||
description: Background worker patterns for testgo6 - job queues, tick-based processing, retry logic, graceful shutdown
|
||||
color: orange
|
||||
---
|
||||
|
||||
# Worker Specialist
|
||||
|
||||
You design and implement background workers for testgo6. Workers are reliable, observable, and gracefully handle failure.
|
||||
|
||||
## Worker Types
|
||||
|
||||
### Queue Consumer
|
||||
Processes jobs from a queue (PostgreSQL SKIP LOCKED, Redis, etc.):
|
||||
```go
|
||||
func (w *Worker) Run(ctx context.Context) error {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
job, err := w.queue.Dequeue(ctx)
|
||||
if err != nil {
|
||||
slog.Error("dequeue failed", "error", err)
|
||||
time.Sleep(w.backoff)
|
||||
continue
|
||||
}
|
||||
if job == nil {
|
||||
time.Sleep(w.pollInterval)
|
||||
continue
|
||||
}
|
||||
w.process(ctx, job)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Tick-Based Worker
|
||||
Runs on interval (cron-like):
|
||||
```go
|
||||
func (w *Worker) Run(ctx context.Context) error {
|
||||
ticker := time.NewTicker(w.interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
if err := w.tick(ctx); err != nil {
|
||||
slog.Error("tick failed", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Patterns
|
||||
|
||||
### Graceful Shutdown
|
||||
- Listen for SIGINT/SIGTERM
|
||||
- Stop accepting new work
|
||||
- Finish in-progress jobs (with timeout)
|
||||
- Close connections cleanly
|
||||
|
||||
### Retry Logic
|
||||
- Exponential backoff with jitter
|
||||
- Max retry count per job
|
||||
- Dead letter queue for permanently failed jobs
|
||||
- Log every retry with attempt count
|
||||
|
||||
### Observability
|
||||
- Log job start/end with duration
|
||||
- Track queue depth metrics
|
||||
- Alert on dead letter queue growth
|
||||
- Include job_id and worker_id in all logs
|
||||
|
||||
## Structure
|
||||
```
|
||||
workers/{name}/
|
||||
├── cmd/worker/main.go # Entry point, signal handling
|
||||
├── internal/
|
||||
│ ├── config/config.go # Worker configuration
|
||||
│ ├── processor/ # Job processing logic
|
||||
│ └── handler/ # Individual job type handlers
|
||||
├── go.mod
|
||||
├── Makefile
|
||||
└── Dockerfile
|
||||
```
|
||||
|
||||
## Do
|
||||
|
||||
1. ALWAYS handle context cancellation
|
||||
2. USE structured logging with job context
|
||||
3. IMPLEMENT graceful shutdown
|
||||
4. TEST with both success and failure cases
|
||||
5. MAKE workers idempotent (safe to retry)
|
||||
|
||||
## Do Not
|
||||
|
||||
1. PANIC on job failure (log and continue)
|
||||
2. PROCESS without timeout (use context.WithTimeout)
|
||||
3. IGNORE poison messages (dead letter after N retries)
|
||||
4. SKIP metrics (queue depth, processing time, error rate)
|
||||
5. SHARE state between job handlers without synchronization
|
||||
55
.claude/commands/audit-debt.md
Normal file
55
.claude/commands/audit-debt.md
Normal file
@ -0,0 +1,55 @@
|
||||
---
|
||||
description: Audit codebase for systemic tech debt - inconsistent patterns that should be unified
|
||||
argument-hint: <category, e.g., "error-handling", "logging", "api-calls", "auth", or "all">
|
||||
allowed-tools: Task, Read, Write, Edit, Glob, Grep, Bash
|
||||
---
|
||||
|
||||
Audit for systemic tech debt: $ARGUMENTS
|
||||
|
||||
## Instructions
|
||||
|
||||
Load the `systemic-debt-auditor` skill, then:
|
||||
|
||||
### If "all" or no category:
|
||||
|
||||
High-level scan:
|
||||
|
||||
```markdown
|
||||
| Category | Variations | Worst Issue | Priority |
|
||||
|----------|------------|-------------|----------|
|
||||
| Error Handling | 4 patterns | unwrap in prod | HIGH |
|
||||
| Logging | 3 patterns | println debug | MEDIUM |
|
||||
```
|
||||
|
||||
Then ask which to deep dive.
|
||||
|
||||
### If specific category:
|
||||
|
||||
1. **Survey** - Find all variations with grep
|
||||
2. **Categorize** - Document each pattern
|
||||
3. **Identify canonical** - Best existing pattern
|
||||
4. **Risk assess** - CRITICAL > HIGH > MEDIUM > LOW
|
||||
5. **Propose plan** - Incremental unification
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Error Handling (Go)
|
||||
```bash
|
||||
grep -rn "panic(" --include="*.go" | wc -l
|
||||
grep -rn "log.Fatal" --include="*.go" | wc -l
|
||||
grep -rn "if err != nil" --include="*.go" | wc -l
|
||||
```
|
||||
|
||||
### Logging
|
||||
```bash
|
||||
grep -rn "fmt.Print" --include="*.go" | wc -l
|
||||
grep -rn "slog\.\|log\." --include="*.go" | wc -l
|
||||
```
|
||||
|
||||
## Output Requirements
|
||||
|
||||
1. Patterns found with counts
|
||||
2. Canonical pattern recommendation
|
||||
3. Risk assessment table
|
||||
4. Unification plan (stop bleeding → fix critical → gradual cleanup)
|
||||
5. Enforcement mechanism
|
||||
60
.claude/commands/commit-all.md
Normal file
60
.claude/commands/commit-all.md
Normal file
@ -0,0 +1,60 @@
|
||||
---
|
||||
description: Check git status, verify .gitignore, stage everything safe, commit and push
|
||||
argument-hint: <commit message>
|
||||
allowed-tools: Bash, Read, Write, Edit, Glob, Grep
|
||||
---
|
||||
|
||||
Commit and push all changes with message: $ARGUMENTS
|
||||
|
||||
## Instructions
|
||||
|
||||
### Phase 1: Audit What's Changed
|
||||
|
||||
```bash
|
||||
git status
|
||||
git diff --stat
|
||||
git diff --cached --stat
|
||||
```
|
||||
|
||||
### Phase 2: Security Check
|
||||
|
||||
Scan for files that should NEVER be committed:
|
||||
|
||||
- `.env` files (except `.env.example`)
|
||||
- `*.pem`, `*.key`, `*.p12`, `*.pfx`
|
||||
- `credentials.json`, `service-account*.json`
|
||||
- `.envault/` directory
|
||||
|
||||
```bash
|
||||
git diff --cached --name-only | xargs grep -l -E "(api_key|apikey|secret|password|token)\s*[:=]\s*['\"][^'\"]+['\"]" 2>/dev/null || true
|
||||
```
|
||||
|
||||
### Phase 3: Verify .gitignore
|
||||
|
||||
Check that .gitignore covers secrets, dependencies, build artifacts.
|
||||
|
||||
### Phase 4: Stage and Commit
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git diff --cached --name-only | grep -E "\.(env|pem|key)$" && echo "WARNING: Sensitive files staged!" || true
|
||||
git commit -m "$ARGUMENTS"
|
||||
```
|
||||
|
||||
### Phase 5: If Commit Fails
|
||||
|
||||
If pre-commit hooks fail:
|
||||
1. Fix the issues
|
||||
2. Re-stage: `git add -A`
|
||||
3. Retry commit (max 3 times)
|
||||
|
||||
### Phase 6: Push
|
||||
|
||||
```bash
|
||||
git push origin HEAD
|
||||
```
|
||||
|
||||
## Safety Rules
|
||||
|
||||
**NEVER commit:** `.env` with real values, private keys, credentials, files > 50MB.
|
||||
**ALWAYS verify** .gitignore before staging.
|
||||
64
.claude/commands/fix-all.md
Normal file
64
.claude/commands/fix-all.md
Normal file
@ -0,0 +1,64 @@
|
||||
---
|
||||
description: Run code review and fix all issues from SUGGESTION to BLOCKER
|
||||
argument-hint: <"recent" | "staged" | "unstaged" | file path | git commit range>
|
||||
allowed-tools: Task, Read, Write, Edit, Glob, Grep, Bash
|
||||
---
|
||||
|
||||
Fix all issues in: $ARGUMENTS
|
||||
|
||||
## Instructions
|
||||
|
||||
### 1. Run Code Review
|
||||
|
||||
Perform a full code review using the `code-reviewer` skill.
|
||||
|
||||
### 2. Collect All Issues
|
||||
|
||||
Gather all issues by severity: BLOCKER > CRITICAL > WARNING > SUGGESTION.
|
||||
|
||||
### 3. Fix All Issues
|
||||
|
||||
Apply fixes in priority order. For each issue:
|
||||
1. Read the file at the specified location
|
||||
2. Apply the **proper fix** (not quick patches)
|
||||
3. If refactoring is warranted, do the full refactor
|
||||
4. Track what was fixed
|
||||
|
||||
### 4. Verify Fixes
|
||||
|
||||
```bash
|
||||
# Go
|
||||
go vet ./... 2>/dev/null || true
|
||||
go test ./... 2>/dev/null || true
|
||||
|
||||
# TypeScript
|
||||
npx tsc --noEmit 2>/dev/null || true
|
||||
npx eslint . 2>/dev/null || true
|
||||
```
|
||||
|
||||
### 5. Report
|
||||
|
||||
```markdown
|
||||
## Fix-All Report
|
||||
|
||||
### Issues Fixed
|
||||
| Severity | Count | Details |
|
||||
|----------|-------|---------|
|
||||
|
||||
### Files Modified
|
||||
- `file` - [what was fixed]
|
||||
|
||||
### Verification
|
||||
- Lint: PASS/FAIL
|
||||
- Tests: PASS/FAIL
|
||||
|
||||
### Remaining Issues
|
||||
[Any issues that could not be auto-fixed]
|
||||
```
|
||||
|
||||
## Critical Rules
|
||||
|
||||
- FIX ALL severities (BLOCKER through SUGGESTION)
|
||||
- Use PROPER FIXES only (no quick patches)
|
||||
- REFACTOR when structural problems exist
|
||||
- VERIFY after fixing (lint, test)
|
||||
59
.claude/commands/fix-quality.md
Normal file
59
.claude/commands/fix-quality.md
Normal file
@ -0,0 +1,59 @@
|
||||
---
|
||||
description: Fix failing quality gate checks - lint, test, build, format
|
||||
argument-hint: <optional: specific check to fix, e.g., "lint" or "test">
|
||||
allowed-tools: Task, Read, Write, Edit, Glob, Grep, Bash
|
||||
---
|
||||
|
||||
Fix quality gate failures: $ARGUMENTS
|
||||
|
||||
## Instructions
|
||||
|
||||
### 1. Run All Checks
|
||||
|
||||
```bash
|
||||
# Go
|
||||
gofmt -l .
|
||||
go vet ./...
|
||||
golangci-lint run ./... 2>/dev/null || true
|
||||
go test ./...
|
||||
|
||||
# Node (if applicable)
|
||||
pnpm lint 2>/dev/null || npm run lint 2>/dev/null || true
|
||||
pnpm typecheck 2>/dev/null || npm run typecheck 2>/dev/null || true
|
||||
```
|
||||
|
||||
### 2. Fix Each Category
|
||||
|
||||
**Order:** Format > Vet > Lint > Test > Build
|
||||
|
||||
| Category | Auto-fix | Manual fix |
|
||||
|----------|----------|------------|
|
||||
| Format | `gofmt -w`, `prettier --write` | N/A |
|
||||
| Vet | N/A | Read error, fix code |
|
||||
| Lint | `golangci-lint run --fix` | Read warning, fix code |
|
||||
| Test | N/A | Read failure, fix code |
|
||||
| Build | N/A | Read error, fix code |
|
||||
|
||||
### 3. Re-run Checks
|
||||
|
||||
After all fixes, re-run the full suite to confirm everything passes.
|
||||
|
||||
### 4. Report
|
||||
|
||||
```markdown
|
||||
## Quality Gate Report
|
||||
|
||||
| Check | Before | After |
|
||||
|-------|--------|-------|
|
||||
| gofmt | X issues | PASS |
|
||||
| go vet | X issues | PASS |
|
||||
| golangci-lint | X issues | PASS |
|
||||
| go test | X failures | PASS |
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- Fix ALL issues, not just the first one
|
||||
- Auto-fix where possible (gofmt, prettier)
|
||||
- Re-run checks after fixing to confirm
|
||||
- If a fix breaks something else, fix that too
|
||||
60
.claude/commands/investigate.md
Normal file
60
.claude/commands/investigate.md
Normal file
@ -0,0 +1,60 @@
|
||||
---
|
||||
description: Investigate how a pattern is implemented, analyze its effectiveness, and propose improvements
|
||||
argument-hint: <pattern to investigate, e.g., "error handling", "authentication", "logging">
|
||||
allowed-tools: Task, Read, Write, Edit, Glob, Grep, Bash
|
||||
---
|
||||
|
||||
Investigate this pattern: $ARGUMENTS
|
||||
|
||||
## Instructions
|
||||
|
||||
Load the `pattern-investigator` skill, then:
|
||||
|
||||
### 1. Define the Pattern
|
||||
|
||||
State explicitly what you're investigating and why.
|
||||
|
||||
### 2. Find All Instances
|
||||
|
||||
```bash
|
||||
# Search across the monorepo
|
||||
grep -rn "[pattern]" --include="*.go" services/ workers/ pkg/
|
||||
grep -rn "[pattern]" --include="*.ts" --include="*.tsx" apps/
|
||||
```
|
||||
|
||||
### 3. Categorize Variations
|
||||
|
||||
Group by approach. For each:
|
||||
- Where it's used (files, components)
|
||||
- Pros and cons
|
||||
- Consistency with rest of codebase
|
||||
|
||||
### 4. Analyze Effectiveness
|
||||
|
||||
- Does the dominant pattern work well?
|
||||
- Where does it break down?
|
||||
- What edge cases aren't handled?
|
||||
|
||||
### 5. Propose Improvements
|
||||
|
||||
Provide 2-4 concrete options:
|
||||
|
||||
```markdown
|
||||
### Option A: [Name]
|
||||
- What: [description]
|
||||
- Files affected: [count]
|
||||
- Risk: LOW/MEDIUM/HIGH
|
||||
- Effort: LOW/MEDIUM/HIGH
|
||||
```
|
||||
|
||||
### 6. Step Back
|
||||
|
||||
- Is the current pattern actually fine?
|
||||
- Will "improving" it create more churn than value?
|
||||
- Is there a reason the variations exist?
|
||||
|
||||
## Critical Rules
|
||||
|
||||
- ALWAYS search before opining
|
||||
- ALWAYS provide concrete options with tradeoffs
|
||||
- NEVER recommend changes without understanding why the current pattern exists
|
||||
79
.claude/commands/review-code.md
Normal file
79
.claude/commands/review-code.md
Normal file
@ -0,0 +1,79 @@
|
||||
---
|
||||
description: Review recent code changes for completeness, accuracy, tech debt, maintainability, extensibility, and DRY/CLEAN code
|
||||
argument-hint: <"recent" | "staged" | "unstaged" | file path | git commit range>
|
||||
allowed-tools: Task, Read, Write, Edit, Glob, Grep, Bash
|
||||
---
|
||||
|
||||
Review this code: $ARGUMENTS
|
||||
|
||||
## Instructions
|
||||
|
||||
Load the `code-reviewer` skill, then:
|
||||
|
||||
### 1. Identify What to Review
|
||||
|
||||
| Argument | What to Review |
|
||||
|----------|---------------|
|
||||
| `recent` | `git diff HEAD~1` (last commit) |
|
||||
| `staged` | `git diff --cached` (staged changes) |
|
||||
| `unstaged` | `git diff` (working directory) |
|
||||
| file path | Specific file(s) |
|
||||
| commit range | `git diff <range>` |
|
||||
|
||||
### 2. Review Each Dimension
|
||||
|
||||
| Dimension | Key Question |
|
||||
|-----------|--------------|
|
||||
| **Completeness** | Does it do everything it should? |
|
||||
| **Accuracy** | Is it correct? Edge cases? Errors? |
|
||||
| **Tech Debt** | Are we creating future problems? |
|
||||
| **Maintainability** | Can someone else understand this? |
|
||||
| **Extensibility** | Can this grow without rewrites? |
|
||||
| **DRY** | Is there duplicated logic? |
|
||||
| **CLEAN** | Clear, Logical, Efficient, Accurate, Neat? |
|
||||
|
||||
### 3. Categorize by Severity
|
||||
|
||||
| Severity | Meaning |
|
||||
|----------|---------|
|
||||
| **BLOCKER** | Cannot ship |
|
||||
| **CRITICAL** | Significant risk |
|
||||
| **WARNING** | Quality concern |
|
||||
| **SUGGESTION** | Improvement |
|
||||
| **PRAISE** | Good practice |
|
||||
|
||||
### 4. Provide Proper Fixes
|
||||
|
||||
For each issue:
|
||||
- Location (file:line)
|
||||
- What's wrong and why it matters
|
||||
- **Production-quality fix** (not a quick patch)
|
||||
|
||||
### 5. Summarize
|
||||
|
||||
- Overall recommendation: APPROVE / REQUEST_CHANGES
|
||||
- Count by severity
|
||||
- Key action items
|
||||
- What's done well
|
||||
|
||||
## Quick Checks
|
||||
|
||||
### Go
|
||||
```bash
|
||||
grep -n "panic(\|log.Fatal" [files] # Should use error returns
|
||||
grep -n "// TODO\|// FIXME" [files] # Tracked?
|
||||
```
|
||||
|
||||
### TypeScript
|
||||
```bash
|
||||
grep -n ": any\|as any" [files] # Should be typed
|
||||
grep -n "console.log" [files] # Debug left in?
|
||||
```
|
||||
|
||||
## Critical Rules
|
||||
|
||||
- ALWAYS provide production-quality fixes
|
||||
- ALWAYS categorize by severity
|
||||
- ALWAYS acknowledge good practices
|
||||
- NEVER block on formatting (formatters do that)
|
||||
- NEVER critique without alternative
|
||||
61
.claude/commands/thinkthrough.md
Normal file
61
.claude/commands/thinkthrough.md
Normal file
@ -0,0 +1,61 @@
|
||||
---
|
||||
description: Deep collaborative thinking about a problem - read code, consult experts, explore options, think together
|
||||
argument-hint: <problem to think through>
|
||||
allowed-tools: Task, Read, Write, Edit, Glob, Grep, Bash, AskUserQuestion
|
||||
---
|
||||
|
||||
Think through this problem deeply: $ARGUMENTS
|
||||
|
||||
## Instructions
|
||||
|
||||
Load the `ideate` skill, then:
|
||||
|
||||
### 1. Understand What's Being Asked
|
||||
|
||||
- **User's words:** [exact quote]
|
||||
- **Your interpretation:** [what you think they mean]
|
||||
- **Scope:** [what's in/out]
|
||||
|
||||
If interpretation differs, **ask**.
|
||||
|
||||
### 2. Gather Context
|
||||
|
||||
```bash
|
||||
grep -rn "[keywords]" --include="*.go" --include="*.ts"
|
||||
```
|
||||
|
||||
Read the specs, docs, adjacent systems.
|
||||
|
||||
### 3. Explore the Solution Space
|
||||
|
||||
Write out 3-4 options (always include "do nothing"):
|
||||
|
||||
```markdown
|
||||
### Option A: [Name]
|
||||
- Approach: [how]
|
||||
- Pros: [why good]
|
||||
- Cons: [why risky]
|
||||
- Assumption: [what must be true]
|
||||
```
|
||||
|
||||
### 4. Step Back
|
||||
|
||||
- **Assumptions:** What am I assuming that's unverified?
|
||||
- **Fresh eyes:** Would someone new agree?
|
||||
- **Skeptic:** What would a disagreer say?
|
||||
- **Missing:** Whose perspective am I ignoring?
|
||||
|
||||
### 5. Think Out Loud
|
||||
|
||||
Share: what you learned, what surprised you, the core tension, where you're leaning, questions for them.
|
||||
|
||||
### 6. Collaborate
|
||||
|
||||
Listen, adjust, drill deeper, iterate.
|
||||
|
||||
## Critical Rules
|
||||
|
||||
- READ code before forming opinions
|
||||
- INCLUDE "do nothing" as an option
|
||||
- SURFACE assumptions explicitly
|
||||
- INVITE dialogue, don't just deliver answers
|
||||
60
.claude/commands/trace-feature.md
Normal file
60
.claude/commands/trace-feature.md
Normal file
@ -0,0 +1,60 @@
|
||||
---
|
||||
description: Trace a feature end-to-end across the codebase - find all files, flows, DB tables, quality issues, and dead code
|
||||
argument-hint: <feature name or description>
|
||||
allowed-tools: Task, Read, Write, Edit, Glob, Grep, Bash
|
||||
---
|
||||
|
||||
Trace this feature: $ARGUMENTS
|
||||
|
||||
## Instructions
|
||||
|
||||
Load the `feature-tracer` skill, then:
|
||||
|
||||
### 1. Discover Entry Points
|
||||
|
||||
```bash
|
||||
# API handlers (Go)
|
||||
grep -rn "[keyword]" --include="*.go" services/*/internal/
|
||||
|
||||
# Frontend
|
||||
grep -rn "[keyword]" --include="*.tsx" --include="*.ts" apps/
|
||||
|
||||
# Workers
|
||||
grep -rn "[keyword]" --include="*.go" workers/*/internal/
|
||||
```
|
||||
|
||||
### 2. Trace Each Path
|
||||
|
||||
For each entry point:
|
||||
1. Read the file
|
||||
2. Follow each call (service → repository → external)
|
||||
3. Map DB tables touched
|
||||
4. Note external dependencies
|
||||
|
||||
### 3. Assess Quality
|
||||
|
||||
For each traced file:
|
||||
- Has tests? (`ls [file]_test.go` or `ls [file].test.ts`)
|
||||
- TODOs? (`grep -n "TODO\|FIXME" [file]`)
|
||||
- Dead code? (grep for function callers)
|
||||
- Error handling adequate?
|
||||
|
||||
### 4. Step Back
|
||||
|
||||
- [ ] Traced both read AND write paths?
|
||||
- [ ] Checked error/failure paths?
|
||||
- [ ] Verified dead code claims with grep counts?
|
||||
|
||||
### 5. Output
|
||||
|
||||
1. **Entry Points Table** - UI, API, Worker with file:line
|
||||
2. **Execution Flow** - Visual path diagram
|
||||
3. **Database Schema** - Tables touched
|
||||
4. **Quality Assessment** - Good / Bad / Ugly / Dead Code
|
||||
5. **Uncertainties** - What couldn't be traced
|
||||
|
||||
## Critical Rules
|
||||
|
||||
- NEVER mark code as "dead" without grep evidence showing 0 callers
|
||||
- ALWAYS include file:line references
|
||||
- ALWAYS note what you couldn't trace
|
||||
74
.claude/commands/verify.md
Normal file
74
.claude/commands/verify.md
Normal file
@ -0,0 +1,74 @@
|
||||
---
|
||||
description: Verify completed work meets acceptance criteria and quality standards
|
||||
argument-hint: <feature or task description to verify>
|
||||
allowed-tools: Task, Read, Write, Edit, Glob, Grep, Bash
|
||||
---
|
||||
|
||||
Verify this work: $ARGUMENTS
|
||||
|
||||
## Instructions
|
||||
|
||||
### 1. Understand What Was Done
|
||||
|
||||
Read recent commits and changed files:
|
||||
```bash
|
||||
git log --oneline -5
|
||||
git diff HEAD~1 --stat
|
||||
```
|
||||
|
||||
### 2. Check Acceptance Criteria
|
||||
|
||||
For each requirement:
|
||||
- [ ] Implemented correctly?
|
||||
- [ ] Tests pass?
|
||||
- [ ] Edge cases handled?
|
||||
- [ ] Error handling appropriate?
|
||||
|
||||
### 3. Run Quality Checks
|
||||
|
||||
```bash
|
||||
go vet ./...
|
||||
go test ./...
|
||||
golangci-lint run ./... 2>/dev/null || true
|
||||
```
|
||||
|
||||
### 4. Functional Verification
|
||||
|
||||
- Read the code path for the feature
|
||||
- Verify inputs are validated
|
||||
- Verify errors propagate correctly
|
||||
- Check that the happy path works logically
|
||||
|
||||
### 5. Integration Check
|
||||
|
||||
- Does it work with existing code?
|
||||
- Are there breaking changes?
|
||||
- Are dependencies updated?
|
||||
|
||||
### 6. Report
|
||||
|
||||
```markdown
|
||||
## Verification Report: [Feature]
|
||||
|
||||
### Criteria
|
||||
| Requirement | Status | Evidence |
|
||||
|-------------|--------|----------|
|
||||
| [requirement] | PASS/FAIL | [file:line or test name] |
|
||||
|
||||
### Quality
|
||||
- Tests: PASS/FAIL (N tests)
|
||||
- Lint: PASS/FAIL
|
||||
- Vet: PASS/FAIL
|
||||
|
||||
### Issues Found
|
||||
[Any problems discovered]
|
||||
|
||||
### Verdict: VERIFIED / NEEDS_WORK
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- VERIFY with evidence, not assumptions
|
||||
- RUN tests, don't just read them
|
||||
- CHECK edge cases explicitly
|
||||
- REPORT honestly - don't pass work that isn't ready
|
||||
37
.claude/guides/local/setup.md
Normal file
37
.claude/guides/local/setup.md
Normal file
@ -0,0 +1,37 @@
|
||||
# Local Development Setup
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Go 1.23+
|
||||
- Node.js 20+ (for frontend apps)
|
||||
- Docker and Docker Compose
|
||||
- Make
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. **Start infrastructure services:**
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
2. **Install dependencies:**
|
||||
```bash
|
||||
./scripts/install.sh
|
||||
```
|
||||
|
||||
3. **Start development:**
|
||||
```bash
|
||||
./scripts/dev.sh
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Copy `.env.example` files in each component to `.env` and configure as needed.
|
||||
|
||||
## Common Tasks
|
||||
|
||||
| Task | Command |
|
||||
|------|---------|
|
||||
| Run all tests | `./scripts/quality.sh` |
|
||||
| List components | `./scripts/discover.sh` |
|
||||
| Lint code | `golangci-lint run ./...` |
|
||||
24
.claude/guides/ops/deploying.md
Normal file
24
.claude/guides/ops/deploying.md
Normal file
@ -0,0 +1,24 @@
|
||||
# Deploying testgo6
|
||||
|
||||
## CI/CD Pipeline
|
||||
|
||||
Deployments are triggered automatically via Woodpecker CI when changes are pushed to `main`.
|
||||
|
||||
## Manual Deployment
|
||||
|
||||
For manual deployments:
|
||||
|
||||
```bash
|
||||
# Deploy all components
|
||||
curl -X POST $RDEV_API_URL/projects/testgo6/deploy \
|
||||
-H "X-API-Key: $RDEV_API_KEY"
|
||||
|
||||
# Deploy a single component
|
||||
curl -X POST $RDEV_API_URL/projects/testgo6/deploy \
|
||||
-H "X-API-Key: $RDEV_API_KEY" \
|
||||
-d '{"component": "services/auth-api"}'
|
||||
```
|
||||
|
||||
## Environment
|
||||
|
||||
Production environment variables are managed via Kubernetes secrets.
|
||||
14
.claude/settings.local.json
Normal file
14
.claude/settings.local.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(go:*)",
|
||||
"Bash(npm:*)",
|
||||
"Bash(make:*)",
|
||||
"Bash(docker:*)",
|
||||
"Bash(kubectl:*)",
|
||||
"Read",
|
||||
"Write",
|
||||
"Edit"
|
||||
]
|
||||
}
|
||||
}
|
||||
146
.claude/skills/code-review/SKILL.md
Normal file
146
.claude/skills/code-review/SKILL.md
Normal file
@ -0,0 +1,146 @@
|
||||
---
|
||||
name: code-reviewer
|
||||
description: Review code for completeness, accuracy, tech debt, maintainability, extensibility, and DRY/CLEAN principles. Use after writing code to catch issues before commit.
|
||||
---
|
||||
|
||||
# Code Reviewer
|
||||
|
||||
## Identity
|
||||
|
||||
You are a senior engineer who reviews code like you'll be maintaining it at 3am during an incident. You care about correctness, clarity, and sustainability - not cleverness.
|
||||
|
||||
## Principles
|
||||
|
||||
- **Correctness First**: Does it work? Edge cases handled?
|
||||
- **Clarity Over Cleverness**: Can someone else understand this in 6 months?
|
||||
- **Sustainability**: Are we creating debt or paying it down?
|
||||
- **Proper Fixes**: Suggest production-quality solutions, not quick patches
|
||||
- **Actionable Feedback**: Show how to fix it, don't just point at problems
|
||||
|
||||
## Review Dimensions
|
||||
|
||||
### 1. Completeness
|
||||
- Requirements met?
|
||||
- Edge cases handled?
|
||||
- Error paths covered?
|
||||
- Tests for new code?
|
||||
|
||||
### 2. Accuracy
|
||||
- Logic correct?
|
||||
- Types used correctly?
|
||||
- Race conditions?
|
||||
- Resources properly acquired/released?
|
||||
- Security (injection, auth bypass)?
|
||||
|
||||
### 3. Tech Debt
|
||||
- Copy-pasted code?
|
||||
- Hardcoded values?
|
||||
- Tight coupling?
|
||||
- "Temporary" solutions?
|
||||
|
||||
### 4. Maintainability
|
||||
- Clear naming?
|
||||
- Functions focused and < 50 lines?
|
||||
- Consistent with surrounding code?
|
||||
- Comments explain WHY not WHAT?
|
||||
|
||||
### 5. Extensibility
|
||||
- Appropriate abstraction for likely changes?
|
||||
- Well-defined boundaries?
|
||||
- YAGNI - not over-engineering?
|
||||
|
||||
### 6. DRY
|
||||
- Repeated code blocks?
|
||||
- Same logic in multiple handlers?
|
||||
- Constants in multiple files?
|
||||
|
||||
### 7. CLEAN
|
||||
- **C**lear: Intent obvious from reading
|
||||
- **L**ogical: Organized sensibly
|
||||
- **E**fficient: No unnecessary work
|
||||
- **A**ccurate: Does what it says
|
||||
- **N**eat: Formatted, no cruft
|
||||
|
||||
## Severity Levels
|
||||
|
||||
| Severity | Meaning | Action |
|
||||
|----------|---------|--------|
|
||||
| **BLOCKER** | Cannot ship | Must fix |
|
||||
| **CRITICAL** | Significant risk | Should fix |
|
||||
| **WARNING** | Quality concern | Fix soon |
|
||||
| **SUGGESTION** | Improvement | Consider |
|
||||
| **PRAISE** | Good practice | Acknowledge |
|
||||
|
||||
## Output Format
|
||||
|
||||
```markdown
|
||||
## Code Review: [Scope]
|
||||
|
||||
### Files
|
||||
- `file` - [what changed]
|
||||
|
||||
---
|
||||
|
||||
### BLOCKER: [Title]
|
||||
**File:** `file:line`
|
||||
**Issue:** [description]
|
||||
**Fix:**
|
||||
```code
|
||||
[production-quality fix]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### What's Good
|
||||
- [positives]
|
||||
|
||||
### Summary
|
||||
| Severity | Count |
|
||||
|----------|-------|
|
||||
| Blocker | N |
|
||||
| Critical | N |
|
||||
| Warning | N |
|
||||
| Suggestion | N |
|
||||
|
||||
**Recommendation:** APPROVE / REQUEST_CHANGES
|
||||
```
|
||||
|
||||
## Language Checks
|
||||
|
||||
### Go Red Flags
|
||||
```go
|
||||
panic() // Should use error returns
|
||||
log.Fatal() // Only in main()
|
||||
_ = err // Never ignore errors
|
||||
interface{} // Use concrete types or any
|
||||
```
|
||||
|
||||
### TypeScript Red Flags
|
||||
```typescript
|
||||
any // Should be typed
|
||||
// eslint-disable // Why?
|
||||
console.log // Debug left in?
|
||||
useEffect(()=>{}, []) // Missing deps?
|
||||
```
|
||||
|
||||
## What NOT to Review
|
||||
|
||||
- Formatting (formatters handle that)
|
||||
- Personal style preferences
|
||||
- Already-approved patterns
|
||||
- Generated or vendored code
|
||||
|
||||
## Do
|
||||
|
||||
1. Read the full change before commenting
|
||||
2. Understand intent before critiquing
|
||||
3. Provide concrete fixes
|
||||
4. Acknowledge what's done well
|
||||
5. Prioritize feedback
|
||||
|
||||
## Do Not
|
||||
|
||||
1. Critique without suggesting solutions
|
||||
2. Block on style preferences
|
||||
3. Nitpick trivial issues
|
||||
4. Default to minimal fixes when proper solutions exist
|
||||
99
.claude/skills/feature-tracer/SKILL.md
Normal file
99
.claude/skills/feature-tracer/SKILL.md
Normal file
@ -0,0 +1,99 @@
|
||||
---
|
||||
name: feature-tracer
|
||||
description: Trace a feature end-to-end across the codebase - find all services, workers, DB tables, and assess code quality.
|
||||
---
|
||||
|
||||
# Feature Tracer
|
||||
|
||||
## Identity
|
||||
|
||||
You are a systems detective who traces features across service boundaries. You follow data from entry point to storage and back, mapping every touchpoint.
|
||||
|
||||
## Principles
|
||||
|
||||
- **Evidence-Based**: Every claim backed by file:line references
|
||||
- **Complete Path**: Trace read AND write paths, success AND failure
|
||||
- **Quality Lens**: Assess test coverage, error handling, dead code at each stop
|
||||
- **Honest Uncertainty**: State clearly what you couldn't trace
|
||||
|
||||
## Protocol
|
||||
|
||||
### 1. Clarify the Feature
|
||||
- Feature name and what it does (1-2 sentences)
|
||||
- One feature or multiple? Split if needed.
|
||||
|
||||
### 2. Discover Entry Points
|
||||
|
||||
```bash
|
||||
# API handlers
|
||||
grep -rn "[keyword]" --include="*.go" services/*/internal/
|
||||
|
||||
# Frontend
|
||||
grep -rn "[keyword]" --include="*.tsx" --include="*.ts" apps/
|
||||
|
||||
# Workers
|
||||
grep -rn "[keyword]" --include="*.go" workers/*/internal/
|
||||
```
|
||||
|
||||
### 3. Trace Each Path
|
||||
|
||||
For each entry point:
|
||||
1. Read the file
|
||||
2. Find what it calls (service → repository → external)
|
||||
3. Follow each call chain
|
||||
4. Map DB tables touched
|
||||
5. Note external dependencies
|
||||
|
||||
### 4. Assess Quality
|
||||
|
||||
For each traced file:
|
||||
|
||||
| Check | How |
|
||||
|-------|-----|
|
||||
| Has tests? | `ls [file]_test.go` |
|
||||
| TODOs? | `grep -n "TODO\|FIXME" [file]` |
|
||||
| Dead code? | `grep -rn "[function]" . \| wc -l` |
|
||||
| Error handling? | `grep -n "if err" [file]` |
|
||||
|
||||
### 5. Step Back
|
||||
|
||||
Before finalizing:
|
||||
- [ ] Traced both read AND write paths?
|
||||
- [ ] Checked error/failure paths?
|
||||
- [ ] Verified dead code claims with grep counts?
|
||||
- [ ] Noted uncertainties?
|
||||
|
||||
## Output Format
|
||||
|
||||
```markdown
|
||||
## Feature Trace: [Name]
|
||||
|
||||
### Entry Points
|
||||
| Layer | File | Function | Line |
|
||||
|-------|------|----------|------|
|
||||
|
||||
### Execution Flow
|
||||
[entry] → [service] → [repository] → [DB]
|
||||
|
||||
### Database
|
||||
| Table | Operation | File |
|
||||
|-------|-----------|------|
|
||||
|
||||
### Quality
|
||||
| Category | Details |
|
||||
|----------|---------|
|
||||
| Good | [tested, well-structured] |
|
||||
| Bad | [missing tests, TODOs] |
|
||||
| Ugly | [debt, concerns] |
|
||||
| Dead | [unused code with evidence] |
|
||||
|
||||
### Uncertainties
|
||||
[What couldn't be traced and why]
|
||||
```
|
||||
|
||||
## Constraints
|
||||
|
||||
- NEVER mark code as "dead" without grep evidence
|
||||
- NEVER skip the step-back verification
|
||||
- ALWAYS include file:line references
|
||||
- ALWAYS note what you couldn't trace
|
||||
78
.claude/skills/ideate/SKILL.md
Normal file
78
.claude/skills/ideate/SKILL.md
Normal file
@ -0,0 +1,78 @@
|
||||
---
|
||||
name: ideate
|
||||
description: Deep collaborative thinking about a problem before committing to a solution. Explore the space, challenge assumptions, think together.
|
||||
---
|
||||
|
||||
# Ideate
|
||||
|
||||
## Identity
|
||||
|
||||
You are a thinking partner who helps explore problems deeply before jumping to solutions. You bring rigor, fresh perspectives, and honest challenge.
|
||||
|
||||
## Principles
|
||||
|
||||
- **Understand Before Solving**: Read code, gather context, then think
|
||||
- **Multiple Perspectives**: Consult different viewpoints
|
||||
- **Honest Challenge**: Surface assumptions, argue the opposite
|
||||
- **Collaborate, Don't Deliver**: This is dialogue, not a report
|
||||
|
||||
## Protocol
|
||||
|
||||
### 1. Understand the Problem
|
||||
|
||||
- **User's words:** [exact quote]
|
||||
- **Your interpretation:** [what you think they mean]
|
||||
- **Scope:** [what's in/out of bounds]
|
||||
|
||||
If your interpretation differs, **ask**.
|
||||
|
||||
### 2. Gather Context
|
||||
|
||||
Read actual code. Find:
|
||||
- What provides input?
|
||||
- What consumes output?
|
||||
- What's similar in the codebase?
|
||||
|
||||
### 3. Explore the Solution Space
|
||||
|
||||
Always include 3-4 options:
|
||||
|
||||
```markdown
|
||||
### Option A: [Name]
|
||||
- Approach: [how]
|
||||
- Pros: [why good]
|
||||
- Cons: [why risky]
|
||||
- Assumption: [what must be true]
|
||||
```
|
||||
|
||||
**Always include "do nothing" as an option.**
|
||||
|
||||
### 4. Step Back
|
||||
|
||||
Before presenting, challenge:
|
||||
- **Assumptions:** What am I assuming that's unverified?
|
||||
- **Fresh eyes:** Would someone new agree with my framing?
|
||||
- **Skeptic:** What would a disagreer say?
|
||||
- **Missing:** Whose perspective am I ignoring?
|
||||
- **Reversal:** Can I argue for the opposite?
|
||||
|
||||
### 5. Think Out Loud
|
||||
|
||||
Share:
|
||||
- What you learned
|
||||
- What surprised you
|
||||
- The core tension/tradeoff
|
||||
- Where you're leaning (starting point, not conclusion)
|
||||
- Questions for them
|
||||
|
||||
### 6. Iterate
|
||||
|
||||
Listen, adjust, drill deeper, repeat.
|
||||
|
||||
## Constraints
|
||||
|
||||
- NEVER form opinions without reading code first
|
||||
- NEVER skip "do nothing" option
|
||||
- NEVER present conclusions without showing reasoning
|
||||
- ALWAYS surface assumptions explicitly
|
||||
- ALWAYS leave room for user input
|
||||
263
.claude/skills/logging-standards/SKILL.md
Normal file
263
.claude/skills/logging-standards/SKILL.md
Normal file
@ -0,0 +1,263 @@
|
||||
---
|
||||
name: logging-standards
|
||||
description: Logging infrastructure standards for testgo6 - structured logging, trace propagation, error handling, frontend/backend consistency.
|
||||
---
|
||||
|
||||
# Logging Standards
|
||||
|
||||
## Identity
|
||||
|
||||
You enforce consistent, actionable logging across all services and apps in testgo6. Every log entry is structured, traceable, and tells the story of what happened.
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **Log once at the boundary** - handlers/workers log the result; internal functions return errors
|
||||
2. **Every log has context** - trace_id, request_id, service, and component on every line
|
||||
3. **Errors are actionable** - include what failed, why, and what to do about it
|
||||
4. **Structured always** - JSON in production, text in development; never fmt.Println
|
||||
5. **No sensitive data** - never log passwords, tokens, PII, or full request bodies
|
||||
|
||||
## Backend (Go + slog)
|
||||
|
||||
### Logger Creation
|
||||
|
||||
```go
|
||||
// Services get a logger from pkg/app - it's pre-configured
|
||||
app := app.New("auth-api", app.WithDefaultPort(8001))
|
||||
logger := app.Logger()
|
||||
|
||||
// Workers create their own context
|
||||
ctx = logging.WorkerContext(ctx, "email-sender")
|
||||
logger := logging.FromContext(ctx)
|
||||
```
|
||||
|
||||
### Context Propagation
|
||||
|
||||
The middleware stack automatically sets up context:
|
||||
|
||||
```
|
||||
RequestID() -> Tracing() -> RequestLogger() -> Recoverer()
|
||||
```
|
||||
|
||||
Every request gets:
|
||||
- `request_id` - unique per request (from X-Request-ID header or generated)
|
||||
- `trace_id` - unique per trace (from X-Trace-ID / X-Cloud-Trace-Context or generated)
|
||||
|
||||
Retrieve in handlers:
|
||||
```go
|
||||
logger := logging.FromContext(r.Context())
|
||||
logger.Info("user created", "user_id", user.ID)
|
||||
```
|
||||
|
||||
### Error Logging Pattern
|
||||
|
||||
```go
|
||||
// GOOD - log at handler boundary with context
|
||||
func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := h.service.Create(r.Context(), req)
|
||||
if err != nil {
|
||||
logging.FromContext(r.Context()).Error("create user failed",
|
||||
"error", err,
|
||||
"email", req.Email,
|
||||
)
|
||||
httpresponse.InternalError(w, r, "failed to create user")
|
||||
return
|
||||
}
|
||||
httpresponse.Created(w, r, user)
|
||||
}
|
||||
|
||||
// GOOD - service returns error, does not log
|
||||
func (s *Service) Create(ctx context.Context, input CreateInput) (*User, error) {
|
||||
user, err := s.repo.Insert(ctx, input)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert user: %w", err)
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// BAD - logging inside service AND returning error (double-logged)
|
||||
func (s *Service) Create(ctx context.Context, input CreateInput) (*User, error) {
|
||||
user, err := s.repo.Insert(ctx, input)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to insert", "error", err) // DON'T DO THIS
|
||||
return nil, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
```
|
||||
|
||||
### Log Levels
|
||||
|
||||
| Level | When |
|
||||
|-------|------|
|
||||
| **Error** | Something failed and needs attention (5xx, unrecoverable) |
|
||||
| **Warn** | Something unexpected but handled (4xx, retries, fallbacks) |
|
||||
| **Info** | Normal operations (request completed, job processed, startup) |
|
||||
| **Debug** | Diagnostic details (SQL queries, cache hits, retry attempts) |
|
||||
|
||||
### Service-to-Service
|
||||
|
||||
The `httpclient` package automatically propagates both `X-Request-ID` and `X-Trace-ID` headers on outgoing requests:
|
||||
|
||||
```go
|
||||
client := httpclient.New(httpclient.Config{Timeout: 5 * time.Second})
|
||||
resp, err := client.Do(req) // trace_id and request_id propagated automatically
|
||||
```
|
||||
|
||||
### Response Envelope
|
||||
|
||||
Every API response includes trace context in the meta field:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {},
|
||||
"meta": {
|
||||
"request_id": "abc-123",
|
||||
"trace_id": "def-456",
|
||||
"timestamp": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Frontend (TypeScript + @testgo6/logger)
|
||||
|
||||
### Setup
|
||||
|
||||
```typescript
|
||||
import { createLogger, installGlobalHandlers } from '@testgo6/logger';
|
||||
|
||||
export const logger = createLogger({
|
||||
level: import.meta.env.DEV ? 'debug' : 'info',
|
||||
service: 'dashboard',
|
||||
endpoint: '/api/logs', // optional: send logs to backend
|
||||
});
|
||||
|
||||
installGlobalHandlers(logger);
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```typescript
|
||||
// Simple logging
|
||||
logger.info('page loaded', { route: '/dashboard' });
|
||||
|
||||
// Error logging with Error objects
|
||||
try {
|
||||
await fetchData();
|
||||
} catch (err) {
|
||||
logger.error('fetch failed', err, { endpoint: '/api/data' });
|
||||
}
|
||||
|
||||
// Child logger with component context
|
||||
const authLogger = logger.withContext({ component: 'auth' });
|
||||
authLogger.info('login attempt', { method: 'oauth' });
|
||||
```
|
||||
|
||||
### Features
|
||||
|
||||
- **Batching**: Logs are buffered and sent in batches (default: 20 entries or 5s)
|
||||
- **Offline resilience**: Uses `navigator.sendBeacon` for reliable delivery during page unload
|
||||
- **Global handlers**: Captures uncaught exceptions and unhandled promise rejections
|
||||
- **Zero-crash**: Logging failures never break the app
|
||||
|
||||
## Workers & Cron Jobs
|
||||
|
||||
Workers don't have HTTP context. Use `WorkerContext` to generate trace IDs:
|
||||
|
||||
```go
|
||||
func (w *OrderProcessor) Handle(ctx context.Context, job queue.Job) error {
|
||||
ctx = logging.WorkerContext(ctx, "order-processor")
|
||||
logger := logging.FromContext(ctx)
|
||||
|
||||
logger.Info("processing order", "order_id", job.Payload.OrderID)
|
||||
|
||||
if err := w.process(ctx, job); err != nil {
|
||||
logger.Error("order processing failed",
|
||||
"error", err,
|
||||
"order_id", job.Payload.OrderID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Info("order processed", "order_id", job.Payload.OrderID)
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
## Error Wrapping Patterns
|
||||
|
||||
### Standard wrap (add context)
|
||||
```go
|
||||
return fmt.Errorf("insert user: %w", err)
|
||||
```
|
||||
|
||||
### Sentinel + detail wrap (Go 1.20+)
|
||||
When a handler needs to classify errors for HTTP status mapping, wrap both a sentinel and detail:
|
||||
```go
|
||||
// Service returns a matchable sentinel WITH the detail error
|
||||
return fmt.Errorf("%w: %w", domain.ErrInvalidCommand, err)
|
||||
|
||||
// Handler matches sentinel → 400, otherwise → 500
|
||||
if errors.Is(err, domain.ErrInvalidCommand) {
|
||||
httpresponse.BadRequest(w, r, err.Error())
|
||||
return
|
||||
}
|
||||
```
|
||||
Both errors are matchable via `errors.Is()`. This is NOT an anti-pattern.
|
||||
|
||||
### When to use which
|
||||
| Pattern | Use when |
|
||||
|---------|----------|
|
||||
| `fmt.Errorf("context: %w", err)` | Adding operation context |
|
||||
| `fmt.Errorf("%w: %w", sentinel, err)` | Handler needs to classify error type |
|
||||
| `fmt.Errorf("%w: detail string", sentinel)` | Sentinel + static detail (no inner error) |
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Don't | Do Instead |
|
||||
|-------|-----------|
|
||||
| `fmt.Println("error:", err)` | `logger.Error("description", "error", err)` |
|
||||
| `log.Fatal(err)` | `logger.Error(...)` + graceful shutdown |
|
||||
| Log in service AND handler | Log once at boundary, return errors |
|
||||
| `logger.Info("password=" + pw)` | Never log credentials or PII |
|
||||
| `logger.Error(err.Error())` | `logger.Error("what failed", "error", err)` |
|
||||
| Ignore returned errors | Wrap and return: `fmt.Errorf("context: %w", err)` |
|
||||
| `&http.Client{}` (no timeout) | `&http.Client{Timeout: 30 * time.Second}` |
|
||||
| `http.Get(url)` (default client) | Use `httpclient.Get(ctx, url)` from pkg/httpclient |
|
||||
|
||||
## HTTP Client Rules
|
||||
|
||||
Every `http.Client` must have an explicit `Timeout`. A bare `&http.Client{}` can hang indefinitely.
|
||||
|
||||
```go
|
||||
// GOOD - explicit timeout
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
// GOOD - use the shared httpclient package (has retries + trace propagation)
|
||||
client := httpclient.New(httpclient.Config{
|
||||
Timeout: 10 * time.Second,
|
||||
MaxRetries: 3,
|
||||
})
|
||||
resp, err := client.Do(req) // propagates trace_id and request_id
|
||||
|
||||
// BAD - no timeout, can hang forever
|
||||
client := &http.Client{}
|
||||
|
||||
// BAD - uses http.DefaultClient (no timeout)
|
||||
resp, err := http.Get(url)
|
||||
```
|
||||
|
||||
## Checklist
|
||||
|
||||
When reviewing logging in a PR:
|
||||
|
||||
- [ ] Every handler logs errors before returning error responses
|
||||
- [ ] Services return errors, don't log them
|
||||
- [ ] No sensitive data in log output
|
||||
- [ ] trace_id and request_id propagated on service-to-service calls
|
||||
- [ ] Workers use `logging.WorkerContext` for correlation
|
||||
- [ ] Frontend apps initialize logger and global handlers
|
||||
- [ ] Error logs include enough context to debug (IDs, operation name)
|
||||
- [ ] Log levels appropriate (not everything is Error)
|
||||
- [ ] All `http.Client` instances have explicit `Timeout` set
|
||||
- [ ] Service-to-service calls use `pkg/httpclient` (retries + trace propagation)
|
||||
76
.claude/skills/pattern-investigator/SKILL.md
Normal file
76
.claude/skills/pattern-investigator/SKILL.md
Normal file
@ -0,0 +1,76 @@
|
||||
---
|
||||
name: pattern-investigator
|
||||
description: Investigate how a pattern is implemented across the codebase, analyze its effectiveness, and propose improvements.
|
||||
---
|
||||
|
||||
# Pattern Investigator
|
||||
|
||||
## Identity
|
||||
|
||||
You are an implementation archaeologist who studies how patterns are actually used across a codebase, separating intentional design from accidental drift.
|
||||
|
||||
## Principles
|
||||
|
||||
- **Evidence Over Opinion**: Count instances, read implementations, then conclude
|
||||
- **Understand Before Judging**: Why do variations exist? Legacy wisdom?
|
||||
- **Practical Improvements**: Options with real tradeoffs, not idealistic rewrites
|
||||
- **Minimal Disruption**: Best improvement is often standardizing, not revolutionizing
|
||||
|
||||
## Protocol
|
||||
|
||||
### 1. Define the Pattern
|
||||
|
||||
State explicitly:
|
||||
- What pattern you're investigating
|
||||
- Why it matters
|
||||
- What "good" looks like
|
||||
|
||||
### 2. Survey All Instances
|
||||
|
||||
```bash
|
||||
# Find all implementations
|
||||
grep -rn "[pattern]" --include="*.go" services/ workers/ pkg/
|
||||
grep -rn "[pattern]" --include="*.ts" apps/
|
||||
|
||||
# Count variations
|
||||
grep -rn "[variant1]" --include="*.go" | wc -l
|
||||
grep -rn "[variant2]" --include="*.go" | wc -l
|
||||
```
|
||||
|
||||
### 3. Categorize Variations
|
||||
|
||||
For each approach found:
|
||||
- Where it's used (which components)
|
||||
- How many instances
|
||||
- Pros and cons
|
||||
- Is it intentional or accidental?
|
||||
|
||||
### 4. Identify the Canonical Pattern
|
||||
|
||||
Which existing variation is the best? Why?
|
||||
|
||||
### 5. Propose Improvements
|
||||
|
||||
Provide 2-4 options:
|
||||
|
||||
```markdown
|
||||
### Option A: Standardize on [pattern]
|
||||
- Files affected: N
|
||||
- Risk: LOW/MEDIUM/HIGH
|
||||
- Effort: LOW/MEDIUM/HIGH
|
||||
- Migration: [incremental/big-bang]
|
||||
```
|
||||
|
||||
### 6. Step Back
|
||||
|
||||
- Is the current state actually fine?
|
||||
- Will "improving" create more churn than value?
|
||||
- Is there a reason the variations exist that I'm missing?
|
||||
- Am I just adding a 6th pattern?
|
||||
|
||||
## Constraints
|
||||
|
||||
- ALWAYS search before forming opinions
|
||||
- ALWAYS provide evidence (counts, file references)
|
||||
- ALWAYS include "leave as-is" as an option
|
||||
- NEVER recommend changes without understanding why current patterns exist
|
||||
101
.claude/skills/systemic-debt-auditor/SKILL.md
Normal file
101
.claude/skills/systemic-debt-auditor/SKILL.md
Normal file
@ -0,0 +1,101 @@
|
||||
---
|
||||
name: systemic-debt-auditor
|
||||
description: Audit codebase for inconsistent patterns and systemic tech debt. Find where the same thing is done multiple ways and propose unification.
|
||||
---
|
||||
|
||||
# Systemic Debt Auditor
|
||||
|
||||
## Identity
|
||||
|
||||
You are a codebase health inspector who finds inconsistency - the same problem solved three different ways across services. You propose unification with realistic migration plans.
|
||||
|
||||
## Principles
|
||||
|
||||
- **Systemic, Not Spot**: Focus on patterns across the codebase, not individual bugs
|
||||
- **Evidence-Based**: Count everything, assume nothing
|
||||
- **Canonical Selection**: Pick the best EXISTING pattern, don't invent a new one
|
||||
- **Incremental Migration**: Stop the bleeding first, then gradually unify
|
||||
|
||||
## Protocol
|
||||
|
||||
### 1. Scope
|
||||
|
||||
Define what category to audit:
|
||||
- Error handling
|
||||
- Logging
|
||||
- HTTP clients / API calls
|
||||
- Authentication / authorization
|
||||
- Validation
|
||||
- Configuration
|
||||
- State management
|
||||
|
||||
### 2. Survey
|
||||
|
||||
Find all variations:
|
||||
|
||||
```bash
|
||||
# Error handling (Go)
|
||||
grep -rn "panic(" --include="*.go" | wc -l
|
||||
grep -rn "log.Fatal" --include="*.go" | wc -l
|
||||
grep -rn "if err != nil" --include="*.go" | wc -l
|
||||
|
||||
# Logging
|
||||
grep -rn "fmt.Print\|fmt.Fprint" --include="*.go" | wc -l
|
||||
grep -rn "slog\." --include="*.go" | wc -l
|
||||
grep -rn "log\." --include="*.go" | wc -l
|
||||
```
|
||||
|
||||
### 3. Categorize
|
||||
|
||||
| Pattern | Count | Quality | Where |
|
||||
|---------|-------|---------|-------|
|
||||
| [variation 1] | N | GOOD/POOR | services/auth-api |
|
||||
| [variation 2] | N | GOOD/POOR | workers/email |
|
||||
|
||||
### 4. Select Canonical
|
||||
|
||||
Pick the best existing pattern. Explain why.
|
||||
|
||||
### 5. Risk Assess
|
||||
|
||||
| Severity | Count | Example |
|
||||
|----------|-------|---------|
|
||||
| CRITICAL | N | [security/data risk] |
|
||||
| HIGH | N | [production issues likely] |
|
||||
| MEDIUM | N | [maintenance burden] |
|
||||
| LOW | N | [cosmetic] |
|
||||
|
||||
### 6. Propose Unification Plan
|
||||
|
||||
**Stop the Bleeding** (prevent new debt):
|
||||
- Lint rule / CI check
|
||||
- Add to CLAUDE.md constraints
|
||||
|
||||
**Fix Critical** (this week):
|
||||
- [specific files]
|
||||
|
||||
**Fix High** (this sprint):
|
||||
- [specific files]
|
||||
|
||||
**Gradual Cleanup** (ongoing):
|
||||
- Fix as files are touched
|
||||
|
||||
**Enforcement** (prevent drift):
|
||||
- Pre-commit hook
|
||||
- CI check
|
||||
- Code review checklist
|
||||
|
||||
### 7. Step Back
|
||||
|
||||
- Why do variations exist? Legacy wisdom?
|
||||
- Can we actually migrate? What's the risk?
|
||||
- Is this the priority? Worse debt elsewhere?
|
||||
- Will we just add a 6th pattern?
|
||||
|
||||
## Constraints
|
||||
|
||||
- ALWAYS count before concluding
|
||||
- ALWAYS pick an existing pattern as canonical (don't invent new)
|
||||
- ALWAYS include enforcement mechanism
|
||||
- NEVER recommend big-bang migrations for large codebases
|
||||
- NEVER skip the "why do variations exist" question
|
||||
56
.githooks/commit-msg
Normal file
56
.githooks/commit-msg
Normal file
@ -0,0 +1,56 @@
|
||||
#!/bin/bash
|
||||
# Commit message validation hook
|
||||
# Validates conventional commit format
|
||||
# Install: ./scripts/setup-hooks.sh
|
||||
|
||||
set -e
|
||||
|
||||
COMMIT_MSG_FILE="$1"
|
||||
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Conventional commit pattern:
|
||||
# type(scope): description
|
||||
# Types: feat, fix, docs, style, refactor, test, chore, perf, ci, build, revert
|
||||
PATTERN='^(feat|fix|docs|style|refactor|test|chore|perf|ci|build|revert)(\([a-z0-9-]+\))?: .{1,100}$'
|
||||
|
||||
# Also allow merge commits and WIP commits
|
||||
if echo "$COMMIT_MSG" | grep -qE "^(Merge|WIP|fixup!|squash!)"; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check first line of commit message
|
||||
FIRST_LINE=$(echo "$COMMIT_MSG" | head -n1)
|
||||
|
||||
if ! echo "$FIRST_LINE" | grep -qE "$PATTERN"; then
|
||||
echo -e "${RED}ERROR: Invalid commit message format${NC}"
|
||||
echo ""
|
||||
echo "Expected format: type(scope): description"
|
||||
echo ""
|
||||
echo "Valid types:"
|
||||
echo " feat - A new feature"
|
||||
echo " fix - A bug fix"
|
||||
echo " docs - Documentation changes"
|
||||
echo " style - Code style changes (formatting, etc.)"
|
||||
echo " refactor - Code refactoring"
|
||||
echo " test - Adding or updating tests"
|
||||
echo " chore - Maintenance tasks"
|
||||
echo " perf - Performance improvements"
|
||||
echo " ci - CI/CD changes"
|
||||
echo " build - Build system changes"
|
||||
echo " revert - Reverting changes"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " feat(auth): add JWT authentication"
|
||||
echo " fix(api): handle null response"
|
||||
echo " docs: update README"
|
||||
echo ""
|
||||
echo "Your message: $FIRST_LINE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}Commit message format is valid${NC}"
|
||||
exit 0
|
||||
135
.githooks/pre-commit
Normal file
135
.githooks/pre-commit
Normal file
@ -0,0 +1,135 @@
|
||||
#!/bin/bash
|
||||
# Pre-commit hook for monorepo quality checks
|
||||
# Install: ./scripts/setup-hooks.sh
|
||||
|
||||
set -e
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo "Running pre-commit checks..."
|
||||
|
||||
# Get staged files
|
||||
STAGED_GO_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.go$' || true)
|
||||
STAGED_TS_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(ts|tsx|js|jsx)$' || true)
|
||||
|
||||
ERRORS=0
|
||||
|
||||
# ============================================
|
||||
# 1. File Length Check (500 lines max)
|
||||
# ============================================
|
||||
echo "Checking file lengths..."
|
||||
for file in $STAGED_GO_FILES $STAGED_TS_FILES; do
|
||||
if [ -f "$file" ]; then
|
||||
LINE_COUNT=$(wc -l < "$file" | tr -d ' ')
|
||||
if [ "$LINE_COUNT" -gt 500 ]; then
|
||||
echo -e "${RED}ERROR: $file has $LINE_COUNT lines (max 500)${NC}"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# ============================================
|
||||
# 2. Go Checks (if Go files are staged)
|
||||
# ============================================
|
||||
if [ -n "$STAGED_GO_FILES" ]; then
|
||||
echo "Running Go checks..."
|
||||
|
||||
# gofmt
|
||||
echo " - gofmt..."
|
||||
GOFMT_OUTPUT=$(gofmt -l $STAGED_GO_FILES 2>&1 || true)
|
||||
if [ -n "$GOFMT_OUTPUT" ]; then
|
||||
echo -e "${YELLOW}Auto-fixing gofmt issues...${NC}"
|
||||
gofmt -w $STAGED_GO_FILES
|
||||
git add $STAGED_GO_FILES
|
||||
fi
|
||||
|
||||
# goimports (if available)
|
||||
if command -v goimports &> /dev/null; then
|
||||
echo " - goimports..."
|
||||
GOIMPORTS_OUTPUT=$(goimports -l $STAGED_GO_FILES 2>&1 || true)
|
||||
if [ -n "$GOIMPORTS_OUTPUT" ]; then
|
||||
echo -e "${YELLOW}Auto-fixing goimports issues...${NC}"
|
||||
goimports -w $STAGED_GO_FILES
|
||||
git add $STAGED_GO_FILES
|
||||
fi
|
||||
fi
|
||||
|
||||
# golangci-lint (if available)
|
||||
if command -v golangci-lint &> /dev/null; then
|
||||
echo " - golangci-lint..."
|
||||
# Get unique directories with Go files
|
||||
DIRS=$(echo "$STAGED_GO_FILES" | xargs -n1 dirname | sort -u)
|
||||
for dir in $DIRS; do
|
||||
if ! golangci-lint run "$dir/..." --fast 2>/dev/null; then
|
||||
echo -e "${RED}golangci-lint found issues in $dir${NC}"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# go vet
|
||||
echo " - go vet..."
|
||||
if ! go vet ./... 2>/dev/null; then
|
||||
echo -e "${RED}go vet found issues${NC}"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
fi
|
||||
|
||||
# ============================================
|
||||
# 3. TypeScript/JavaScript Checks
|
||||
# ============================================
|
||||
if [ -n "$STAGED_TS_FILES" ]; then
|
||||
echo "Running TypeScript checks..."
|
||||
|
||||
# Get component directories with TS files
|
||||
TS_DIRS=$(echo "$STAGED_TS_FILES" | xargs -n1 dirname | sort -u | grep -E '^apps/' | cut -d'/' -f1-2 | sort -u || true)
|
||||
|
||||
for dir in $TS_DIRS; do
|
||||
if [ -f "$dir/package.json" ]; then
|
||||
# Use subshell to automatically restore directory on exit
|
||||
(
|
||||
cd "$dir"
|
||||
|
||||
# Get files relative to this component directory
|
||||
COMPONENT_FILES=$(echo "$STAGED_TS_FILES" | grep "^$dir/" | xargs)
|
||||
|
||||
# prettier (if available)
|
||||
if [ -f "node_modules/.bin/prettier" ] || command -v prettier &> /dev/null; then
|
||||
echo " - prettier in $dir..."
|
||||
npx prettier --write $COMPONENT_FILES 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# eslint (if available)
|
||||
if [ -f "node_modules/.bin/eslint" ] || command -v eslint &> /dev/null; then
|
||||
echo " - eslint in $dir..."
|
||||
if ! npx eslint --fix $COMPONENT_FILES 2>/dev/null; then
|
||||
echo -e "${RED}eslint found issues in $dir${NC}"
|
||||
# Note: ERRORS can't propagate from subshell, so we exit with error
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
) || ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
# Re-add auto-fixed files
|
||||
for file in $STAGED_TS_FILES; do
|
||||
if [ -f "$file" ]; then
|
||||
git add "$file"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# ============================================
|
||||
# 4. Final Result
|
||||
# ============================================
|
||||
if [ $ERRORS -gt 0 ]; then
|
||||
echo -e "${RED}Pre-commit checks failed with $ERRORS error(s)${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}Pre-commit checks passed!${NC}"
|
||||
exit 0
|
||||
48
.gitignore
vendored
Normal file
48
.gitignore
vendored
Normal file
@ -0,0 +1,48 @@
|
||||
# Binaries
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
bin/
|
||||
dist/
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool
|
||||
*.out
|
||||
coverage.html
|
||||
|
||||
# Dependency directories
|
||||
vendor/
|
||||
|
||||
# Go workspace file (local only)
|
||||
go.work.sum
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
*.env
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
.npm/
|
||||
|
||||
# Shared packages
|
||||
packages/*/node_modules/
|
||||
packages/*/dist/
|
||||
|
||||
# Build artifacts
|
||||
build/
|
||||
.next/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
25
.golangci.yml
Normal file
25
.golangci.yml
Normal file
@ -0,0 +1,25 @@
|
||||
run:
|
||||
timeout: 5m
|
||||
modules-download-mode: readonly
|
||||
|
||||
linters:
|
||||
enable:
|
||||
- errcheck
|
||||
- gosimple
|
||||
- govet
|
||||
- ineffassign
|
||||
- staticcheck
|
||||
- unused
|
||||
- gofmt
|
||||
- goimports
|
||||
|
||||
linters-settings:
|
||||
gofmt:
|
||||
simplify: true
|
||||
goimports:
|
||||
local-prefixes: git.threesix.ai/jordan/testgo6
|
||||
|
||||
issues:
|
||||
exclude-use-default: false
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
21
.woodpecker.yml
Normal file
21
.woodpecker.yml
Normal file
@ -0,0 +1,21 @@
|
||||
# CI/CD Pipeline for testgo6
|
||||
# Components will add their build steps below the marker
|
||||
|
||||
clone:
|
||||
git:
|
||||
image: woodpeckerci/plugin-git
|
||||
settings:
|
||||
depth: 1
|
||||
|
||||
steps:
|
||||
# COMPONENT_STEPS_BELOW
|
||||
# Do not remove the marker above - component steps are inserted here
|
||||
|
||||
verify:
|
||||
image: bitnami/kubectl:latest
|
||||
commands:
|
||||
- echo "Pipeline complete for testgo6"
|
||||
- kubectl get deployments -n projects -l app=testgo6 --no-headers || true
|
||||
when:
|
||||
branch: main
|
||||
event: push
|
||||
49
CLAUDE.md
Normal file
49
CLAUDE.md
Normal file
@ -0,0 +1,49 @@
|
||||
# testgo6
|
||||
|
||||
Composable app E2E test
|
||||
|
||||
## Find Your Guide
|
||||
|
||||
| If you need to... | Read this |
|
||||
|-------------------|-----------|
|
||||
| **Set up local dev** | [local/setup.md](.claude/guides/local/setup.md) |
|
||||
| **Deploy** | [ops/deploying.md](.claude/guides/ops/deploying.md) |
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
# Start local dev
|
||||
./scripts/dev.sh
|
||||
|
||||
# Run quality checks
|
||||
./scripts/quality.sh
|
||||
|
||||
# List all components
|
||||
./scripts/discover.sh
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
testgo6/
|
||||
├── services/ # Go API services (port 8001+)
|
||||
├── workers/ # Background workers (no port)
|
||||
├── apps/ # Frontend applications (port 3001+)
|
||||
├── cli/ # CLI tools (no port)
|
||||
├── packages/ # Shared TypeScript packages (@testgo6/*)
|
||||
├── pkg/ # Shared Go packages (git.threesix.ai/jordan/testgo6/pkg/*)
|
||||
└── scripts/ # Development & CI scripts
|
||||
```
|
||||
|
||||
| Slot | Language | Port Range | Purpose |
|
||||
|------|----------|------------|---------|
|
||||
| services/ | Go | 8001+ | REST APIs, backend services |
|
||||
| workers/ | Go | none | Background jobs, queue consumers |
|
||||
| apps/ | TypeScript | 3001+ | React, Astro frontends |
|
||||
| cli/ | Go | none | CLI tools, scripts |
|
||||
| packages/ | TypeScript | none | Shared frontend packages |
|
||||
| pkg/ | Go | none | Shared backend packages |
|
||||
|
||||
## Components
|
||||
|
||||
<!-- Components will be listed here as they're added -->
|
||||
2
Procfile
Normal file
2
Procfile
Normal file
@ -0,0 +1,2 @@
|
||||
# Local development processes
|
||||
# Components will be added below as they're created
|
||||
55
README.md
Normal file
55
README.md
Normal file
@ -0,0 +1,55 @@
|
||||
# testgo6
|
||||
|
||||
Composable app E2E test
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
# Clone the repo
|
||||
git clone https://git.threesix.ai/jordan/testgo6.git
|
||||
cd testgo6
|
||||
|
||||
# Install dependencies
|
||||
./scripts/install.sh
|
||||
|
||||
# Start local development
|
||||
./scripts/dev.sh
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
testgo6/
|
||||
├── services/ # Go API services
|
||||
├── workers/ # Background workers
|
||||
├── apps/ # Frontend applications
|
||||
├── cli/ # CLI tools
|
||||
├── packages/ # Shared TypeScript packages
|
||||
├── pkg/ # Shared Go packages
|
||||
└── scripts/ # Development scripts
|
||||
```
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | Description |
|
||||
|--------|-------------|
|
||||
| `./scripts/dev.sh` | Start local development environment |
|
||||
| `./scripts/install.sh` | Install all dependencies |
|
||||
| `./scripts/quality.sh` | Run quality checks on all components |
|
||||
| `./scripts/discover.sh` | List all components in the monorepo |
|
||||
|
||||
## Adding Components
|
||||
|
||||
Components are added via the rdev API:
|
||||
|
||||
```bash
|
||||
# Add a Go service
|
||||
curl -X POST $RDEV_API_URL/projects/testgo6/components \
|
||||
-H "X-API-Key: $RDEV_API_KEY" \
|
||||
-d '{"type": "service", "name": "auth-api"}'
|
||||
|
||||
# Add a React app
|
||||
curl -X POST $RDEV_API_URL/projects/testgo6/components \
|
||||
-H "X-API-Key: $RDEV_API_KEY" \
|
||||
-d '{"type": "app", "name": "dashboard", "template": "app-react"}'
|
||||
```
|
||||
1
apps/.gitkeep
Normal file
1
apps/.gitkeep
Normal file
@ -0,0 +1 @@
|
||||
# Frontend applications go here
|
||||
1
cli/.gitkeep
Normal file
1
cli/.gitkeep
Normal file
@ -0,0 +1 @@
|
||||
# CLI tools go here
|
||||
24
docker-compose.yml
Normal file
24
docker-compose.yml
Normal file
@ -0,0 +1,24 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
environment:
|
||||
POSTGRES_USER: dev
|
||||
POSTGRES_PASSWORD: dev
|
||||
POSTGRES_DB: testgo6
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
4
go.work
Normal file
4
go.work
Normal file
@ -0,0 +1,4 @@
|
||||
go 1.23
|
||||
|
||||
use ./pkg
|
||||
// Component modules will be added below
|
||||
12
package.json
Normal file
12
package.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "testgo6",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"packageManager": "pnpm@9.15.0",
|
||||
"scripts": {
|
||||
"dev": "pnpm -r dev",
|
||||
"build": "pnpm -r build",
|
||||
"lint": "pnpm -r lint",
|
||||
"test": "pnpm -r test"
|
||||
}
|
||||
}
|
||||
0
packages/.gitkeep
Normal file
0
packages/.gitkeep
Normal file
15
packages/logger/package.json
Normal file
15
packages/logger/package.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@testgo6/logger",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.3"
|
||||
}
|
||||
}
|
||||
35
packages/logger/src/handlers.ts
Normal file
35
packages/logger/src/handlers.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import type { Logger } from './logger';
|
||||
|
||||
/**
|
||||
* Install global error handlers that route uncaught errors to the logger.
|
||||
*
|
||||
* Captures:
|
||||
* - window.onerror (uncaught exceptions)
|
||||
* - window.onunhandledrejection (unhandled promise rejections)
|
||||
*
|
||||
* Call once at app init. Returns a cleanup function.
|
||||
*/
|
||||
export function installGlobalHandlers(logger: Logger): () => void {
|
||||
const onError = (event: ErrorEvent) => {
|
||||
logger.error('Uncaught exception', event.error ?? new Error(event.message), {
|
||||
source: event.filename,
|
||||
line: event.lineno,
|
||||
col: event.colno,
|
||||
});
|
||||
};
|
||||
|
||||
const onRejection = (event: PromiseRejectionEvent) => {
|
||||
const err = event.reason instanceof Error
|
||||
? event.reason
|
||||
: new Error(String(event.reason));
|
||||
logger.error('Unhandled promise rejection', err);
|
||||
};
|
||||
|
||||
window.addEventListener('error', onError);
|
||||
window.addEventListener('unhandledrejection', onRejection);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('error', onError);
|
||||
window.removeEventListener('unhandledrejection', onRejection);
|
||||
};
|
||||
}
|
||||
3
packages/logger/src/index.ts
Normal file
3
packages/logger/src/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { createLogger, Logger } from './logger';
|
||||
export { installGlobalHandlers } from './handlers';
|
||||
export type { LogLevel, LogContext, LogEntry, LoggerConfig, LogTransport } from './types';
|
||||
170
packages/logger/src/logger.ts
Normal file
170
packages/logger/src/logger.ts
Normal file
@ -0,0 +1,170 @@
|
||||
import type { LogLevel, LogContext, LogEntry, LoggerConfig, LogTransport } from './types';
|
||||
|
||||
const LEVEL_PRIORITY: Record<LogLevel, number> = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3,
|
||||
};
|
||||
|
||||
/** Default transport: sends batched logs via sendBeacon or fetch. */
|
||||
class HttpTransport implements LogTransport {
|
||||
constructor(private endpoint: string) {}
|
||||
|
||||
send(entries: LogEntry[]): void {
|
||||
const payload = JSON.stringify(entries);
|
||||
|
||||
// sendBeacon is fire-and-forget, works during page unload
|
||||
if (typeof navigator !== 'undefined' && navigator.sendBeacon) {
|
||||
const blob = new Blob([payload], { type: 'application/json' });
|
||||
const sent = navigator.sendBeacon(this.endpoint, blob);
|
||||
if (sent) return;
|
||||
}
|
||||
|
||||
// Fallback to fetch (non-blocking)
|
||||
fetch(this.endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: payload,
|
||||
keepalive: true,
|
||||
}).catch(() => {
|
||||
// Silently drop - we don't want logging failures to break the app
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Console transport for development. */
|
||||
class ConsoleTransport implements LogTransport {
|
||||
send(entries: LogEntry[]): void {
|
||||
for (const entry of entries) {
|
||||
const method = entry.level === 'debug' ? 'log' : entry.level;
|
||||
const ctx = Object.keys(entry.context).length > 0 ? entry.context : undefined;
|
||||
if (entry.error) {
|
||||
console[method](`[${entry.level.toUpperCase()}] ${entry.message}`, entry.error, ctx);
|
||||
} else if (ctx) {
|
||||
console[method](`[${entry.level.toUpperCase()}] ${entry.message}`, ctx);
|
||||
} else {
|
||||
console[method](`[${entry.level.toUpperCase()}] ${entry.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class Logger {
|
||||
private buffer: LogEntry[] = [];
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
private transport: LogTransport;
|
||||
private minLevel: number;
|
||||
private baseContext: LogContext;
|
||||
private batchSize: number;
|
||||
private flushInterval: number;
|
||||
|
||||
constructor(config: LoggerConfig) {
|
||||
this.minLevel = LEVEL_PRIORITY[config.level];
|
||||
this.batchSize = config.batchSize ?? 20;
|
||||
this.flushInterval = config.flushInterval ?? 5000;
|
||||
this.baseContext = { service: config.service };
|
||||
|
||||
if (config.endpoint) {
|
||||
this.transport = new HttpTransport(config.endpoint);
|
||||
} else {
|
||||
this.transport = new ConsoleTransport();
|
||||
}
|
||||
|
||||
this.startFlushTimer();
|
||||
|
||||
// Flush on page unload
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
this.flush();
|
||||
}
|
||||
});
|
||||
window.addEventListener('pagehide', () => this.flush());
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a child logger with additional context fields. */
|
||||
withContext(ctx: LogContext): Logger {
|
||||
const child = Object.create(this) as Logger;
|
||||
child.baseContext = { ...this.baseContext, ...ctx };
|
||||
return child;
|
||||
}
|
||||
|
||||
debug(message: string, ctx?: LogContext): void {
|
||||
this.log('debug', message, ctx);
|
||||
}
|
||||
|
||||
info(message: string, ctx?: LogContext): void {
|
||||
this.log('info', message, ctx);
|
||||
}
|
||||
|
||||
warn(message: string, ctx?: LogContext): void {
|
||||
this.log('warn', message, ctx);
|
||||
}
|
||||
|
||||
error(message: string, error?: Error | unknown, ctx?: LogContext): void {
|
||||
const entry = this.createEntry('error', message, ctx);
|
||||
if (error instanceof Error) {
|
||||
entry.error = {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
};
|
||||
} else if (error !== undefined) {
|
||||
entry.error = {
|
||||
name: 'UnknownError',
|
||||
message: String(error),
|
||||
};
|
||||
}
|
||||
this.enqueue(entry);
|
||||
}
|
||||
|
||||
/** Force-flush the buffer immediately. */
|
||||
flush(): void {
|
||||
if (this.buffer.length === 0) return;
|
||||
const entries = this.buffer.splice(0);
|
||||
this.transport.send(entries);
|
||||
}
|
||||
|
||||
/** Stop the flush timer (call on teardown). */
|
||||
destroy(): void {
|
||||
this.flush();
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private log(level: LogLevel, message: string, ctx?: LogContext): void {
|
||||
if (LEVEL_PRIORITY[level] < this.minLevel) return;
|
||||
this.enqueue(this.createEntry(level, message, ctx));
|
||||
}
|
||||
|
||||
private createEntry(level: LogLevel, message: string, ctx?: LogContext): LogEntry {
|
||||
return {
|
||||
level,
|
||||
message,
|
||||
timestamp: new Date().toISOString(),
|
||||
context: { ...this.baseContext, ...ctx },
|
||||
};
|
||||
}
|
||||
|
||||
private enqueue(entry: LogEntry): void {
|
||||
this.buffer.push(entry);
|
||||
if (this.buffer.length >= this.batchSize) {
|
||||
this.flush();
|
||||
}
|
||||
}
|
||||
|
||||
private startFlushTimer(): void {
|
||||
if (this.flushInterval > 0 && typeof setInterval !== 'undefined') {
|
||||
this.timer = setInterval(() => this.flush(), this.flushInterval);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a logger instance. */
|
||||
export function createLogger(config: LoggerConfig): Logger {
|
||||
return new Logger(config);
|
||||
}
|
||||
40
packages/logger/src/types.ts
Normal file
40
packages/logger/src/types.ts
Normal file
@ -0,0 +1,40 @@
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||
|
||||
export interface LogContext {
|
||||
trace_id?: string;
|
||||
request_id?: string;
|
||||
user_id?: string;
|
||||
component?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
context: LogContext;
|
||||
error?: {
|
||||
name: string;
|
||||
message: string;
|
||||
stack?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LoggerConfig {
|
||||
/** Minimum log level to emit */
|
||||
level: LogLevel;
|
||||
/** Service/app name for log context */
|
||||
service: string;
|
||||
/** Endpoint to send logs to (POST). Omit for console-only. */
|
||||
endpoint?: string;
|
||||
/** Max entries to buffer before flushing (default: 20) */
|
||||
batchSize?: number;
|
||||
/** Max ms to wait before flushing (default: 5000) */
|
||||
flushInterval?: number;
|
||||
/** Install global error/rejection handlers (default: true) */
|
||||
captureGlobalErrors?: boolean;
|
||||
}
|
||||
|
||||
export interface LogTransport {
|
||||
send(entries: LogEntry[]): void;
|
||||
}
|
||||
14
packages/logger/tsconfig.json
Normal file
14
packages/logger/tsconfig.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
286
pkg/README.md
Normal file
286
pkg/README.md
Normal file
@ -0,0 +1,286 @@
|
||||
# Shared Packages
|
||||
|
||||
This directory contains shared Go packages used across all components in the monorepo.
|
||||
|
||||
## Package Overview
|
||||
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| `app` | Service bootstrapper with chi router, middleware, and graceful shutdown |
|
||||
| `config` | Viper-based configuration loading from environment variables |
|
||||
| `httpcontext` | Type-safe context key helpers for request-scoped data |
|
||||
| `httpclient` | Resilient HTTP client with automatic retries and exponential backoff |
|
||||
| `httpresponse` | Standard response envelope pattern for API responses |
|
||||
| `httpvalidation` | Struct validation wrapper around go-playground/validator |
|
||||
| `logging` | slog-based structured logging with context integration |
|
||||
| `middleware` | HTTP middleware: CORS, recovery, request ID, request logging |
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Creating a New Service
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.threesix.ai/jordan/testgo6/pkg/app"
|
||||
"git.threesix.ai/jordan/testgo6/pkg/httpresponse"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Create application with default middleware and health endpoints
|
||||
svc := app.New("my-service", app.WithDefaultPort(8080))
|
||||
|
||||
// Register routes
|
||||
svc.GET("/hello", func(w http.ResponseWriter, r *http.Request) {
|
||||
httpresponse.OK(w, r, map[string]string{"message": "Hello, World!"})
|
||||
})
|
||||
|
||||
// Start server (blocks until shutdown signal)
|
||||
svc.Run()
|
||||
}
|
||||
```
|
||||
|
||||
## Package Documentation
|
||||
|
||||
### pkg/app
|
||||
|
||||
Service bootstrapper that provides:
|
||||
- Chi router with standard middleware
|
||||
- Graceful shutdown handling
|
||||
- Health check endpoints (`/health`, `/ready`)
|
||||
|
||||
```go
|
||||
app := app.New("my-service",
|
||||
app.WithDefaultPort(8080),
|
||||
app.WithLogger(customLogger),
|
||||
)
|
||||
|
||||
// Register routes
|
||||
app.GET("/users/{id}", getUser)
|
||||
app.POST("/users", createUser)
|
||||
|
||||
// Group routes
|
||||
app.Route("/api/v1", func(r chi.Router) {
|
||||
r.Get("/users", listUsers)
|
||||
})
|
||||
|
||||
// Register shutdown hooks
|
||||
app.OnShutdown(func(ctx context.Context) error {
|
||||
return db.Close()
|
||||
})
|
||||
|
||||
app.Run()
|
||||
```
|
||||
|
||||
### pkg/config
|
||||
|
||||
Configuration loading from environment variables with Viper.
|
||||
|
||||
```go
|
||||
// Initialize configuration (once at startup)
|
||||
config.MustInit(config.Options{
|
||||
AppName: "my-service",
|
||||
DefaultPort: 8080,
|
||||
})
|
||||
|
||||
// Read typed configuration
|
||||
appCfg := config.ReadAppConfig() // APP_NAME, APP_ENVIRONMENT, APP_DEBUG
|
||||
serverCfg := config.ReadServerConfig() // SERVER_HOST, SERVER_PORT, timeouts
|
||||
dbCfg := config.ReadDatabaseConfig() // DATABASE_URL, pool settings
|
||||
|
||||
// Direct access
|
||||
dbURL := config.GetString("DATABASE_URL")
|
||||
debug := config.GetBool("APP_DEBUG")
|
||||
```
|
||||
|
||||
**Environment Variables:**
|
||||
- `APP_NAME` - Application name (default: service name)
|
||||
- `APP_ENVIRONMENT` - development, staging, production
|
||||
- `APP_DEBUG` - Enable debug mode
|
||||
- `SERVER_HOST` - Server bind host (default: 0.0.0.0)
|
||||
- `SERVER_PORT` - Server port (default: 8080)
|
||||
- `DATABASE_URL` - Database connection string
|
||||
- `LOG_LEVEL` - debug, info, warn, error
|
||||
- `LOG_FORMAT` - json, text, auto
|
||||
|
||||
### pkg/httpcontext
|
||||
|
||||
Type-safe context key helpers.
|
||||
|
||||
```go
|
||||
// Set values in middleware
|
||||
ctx := httpcontext.SetRequestID(r.Context(), requestID)
|
||||
ctx = httpcontext.SetUser(ctx, user)
|
||||
ctx = httpcontext.SetOrgID(ctx, orgID)
|
||||
|
||||
// Get values in handlers
|
||||
requestID, ok := httpcontext.GetRequestID(ctx)
|
||||
user, ok := httpcontext.GetUser(ctx)
|
||||
orgID, ok := httpcontext.GetOrgID(ctx)
|
||||
|
||||
// Panic if not found (use when middleware guarantees presence)
|
||||
user := httpcontext.MustGetUser(ctx)
|
||||
```
|
||||
|
||||
### pkg/httpclient
|
||||
|
||||
HTTP client with automatic retries.
|
||||
|
||||
```go
|
||||
// Create client
|
||||
client := httpclient.New(httpclient.Config{
|
||||
Timeout: 10 * time.Second,
|
||||
MaxRetries: 3,
|
||||
})
|
||||
|
||||
// Make requests
|
||||
resp, err := client.Do(req)
|
||||
|
||||
// Convenience methods
|
||||
resp, err := httpclient.Get(ctx, "https://api.example.com/users")
|
||||
resp, err := httpclient.JSONPost(ctx, url, bytes.NewReader(jsonData))
|
||||
```
|
||||
|
||||
Retries on:
|
||||
- HTTP 5xx server errors
|
||||
- HTTP 429 Too Many Requests
|
||||
- Connection errors (timeout, refused)
|
||||
|
||||
Does NOT retry on:
|
||||
- HTTP 4xx client errors (except 429)
|
||||
- Context cancellation
|
||||
|
||||
### pkg/httpresponse
|
||||
|
||||
Standard response envelope for API responses.
|
||||
|
||||
```go
|
||||
// Success responses
|
||||
httpresponse.OK(w, r, data) // 200 OK
|
||||
httpresponse.Created(w, r, data) // 201 Created
|
||||
httpresponse.NoContent(w) // 204 No Content
|
||||
|
||||
// Error responses
|
||||
httpresponse.BadRequest(w, r, "invalid input")
|
||||
httpresponse.Unauthorized(w, r, "authentication required")
|
||||
httpresponse.Forbidden(w, r, "insufficient permissions")
|
||||
httpresponse.NotFound(w, r, "user not found")
|
||||
httpresponse.InternalError(w, r, "something went wrong")
|
||||
|
||||
// Validation errors with details
|
||||
httpresponse.ValidationError(w, r, "validation failed", details)
|
||||
|
||||
// Decode request body
|
||||
var req CreateUserRequest
|
||||
if err := httpresponse.DecodeJSON(r, &req); err != nil {
|
||||
httpresponse.BadRequest(w, r, "invalid JSON")
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
**Response Format:**
|
||||
```json
|
||||
{
|
||||
"data": { ... },
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "validation failed",
|
||||
"details": [ ... ]
|
||||
},
|
||||
"meta": {
|
||||
"request_id": "abc-123",
|
||||
"timestamp": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### pkg/httpvalidation
|
||||
|
||||
Struct validation using go-playground/validator.
|
||||
|
||||
```go
|
||||
type CreateUserRequest struct {
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
Name string `json:"name" validate:"required,min=2,max=100"`
|
||||
Phone string `json:"phone" validate:"omitempty,phone"`
|
||||
}
|
||||
|
||||
// Validate struct
|
||||
if details := httpvalidation.ValidateStruct(req); len(details) > 0 {
|
||||
httpresponse.ValidationError(w, r, "validation failed", details)
|
||||
return
|
||||
}
|
||||
|
||||
// Custom validators available:
|
||||
// - uuid: Valid UUID
|
||||
// - uuid_or_empty: Valid UUID or empty string
|
||||
// - phone: E.164 phone number format
|
||||
// - slug: URL-safe slug (lowercase, numbers, hyphens)
|
||||
// - hex_color: Hex color code (#RGB, #RRGGBB, #RRGGBBAA)
|
||||
```
|
||||
|
||||
### pkg/logging
|
||||
|
||||
Structured logging with slog.
|
||||
|
||||
```go
|
||||
// Create logger
|
||||
logger := logging.New(logging.Config{
|
||||
Level: logging.LevelInfo,
|
||||
Format: logging.FormatJSON,
|
||||
Environment: "production",
|
||||
})
|
||||
|
||||
// Or use convenience constructors
|
||||
logger := logging.NewDevelopment() // text format, debug level
|
||||
logger := logging.NewProduction() // JSON format, info level
|
||||
|
||||
// Log messages
|
||||
logger.Info("user created", "user_id", userID)
|
||||
logger.Error("failed to connect", "error", err)
|
||||
|
||||
// Create derived loggers
|
||||
reqLogger := logger.With("request_id", requestID)
|
||||
svcLogger := logger.WithService("user-service")
|
||||
|
||||
// Get logger from context (set by middleware)
|
||||
logger := logging.FromContext(r.Context())
|
||||
```
|
||||
|
||||
### pkg/middleware
|
||||
|
||||
HTTP middleware for chi router.
|
||||
|
||||
```go
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Request ID generation/propagation
|
||||
r.Use(middleware.RequestID())
|
||||
|
||||
// Request logging
|
||||
r.Use(middleware.RequestLogger(logger))
|
||||
|
||||
// Panic recovery
|
||||
r.Use(middleware.Recoverer(logger))
|
||||
|
||||
// CORS
|
||||
r.Use(middleware.CORS(middleware.DefaultCORSConfig()))
|
||||
|
||||
// Production CORS
|
||||
r.Use(middleware.CORS(middleware.CORSConfig{
|
||||
AllowedOrigins: []string{"https://app.example.com"},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"},
|
||||
AllowCredentials: true,
|
||||
}))
|
||||
```
|
||||
|
||||
## Guidelines
|
||||
|
||||
- **Import Path**: Use `git.threesix.ai/jordan/testgo6/pkg/<package>` for imports
|
||||
- **Keep packages focused**: Each package should do one thing well
|
||||
- **No circular dependencies**: pkg packages should not import from services/workers
|
||||
- **Document public APIs**: All exported functions should have doc comments
|
||||
- **Write tests**: Cover exported functions with unit tests
|
||||
297
pkg/app/app.go
Normal file
297
pkg/app/app.go
Normal file
@ -0,0 +1,297 @@
|
||||
// Package app provides a service bootstrapper for HTTP services.
|
||||
//
|
||||
// App is the main application struct that provides infrastructure for HTTP services.
|
||||
// It manages configuration, logging, routing, and graceful shutdown.
|
||||
//
|
||||
// Example usage:
|
||||
//
|
||||
// func main() {
|
||||
// app := app.New("my-service", app.WithDefaultPort(8080))
|
||||
// app.GET("/users/{id}", handlers.GetUser)
|
||||
// app.POST("/users", handlers.CreateUser)
|
||||
// app.Run()
|
||||
// }
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.threesix.ai/jordan/testgo6/pkg/config"
|
||||
"git.threesix.ai/jordan/testgo6/pkg/httpresponse"
|
||||
"git.threesix.ai/jordan/testgo6/pkg/logging"
|
||||
"git.threesix.ai/jordan/testgo6/pkg/middleware"
|
||||
)
|
||||
|
||||
// Router is an alias for chi.Router, exposing it for handler mounting.
|
||||
type Router = chi.Router
|
||||
|
||||
// App is the main application struct that provides infrastructure for HTTP services.
|
||||
type App struct {
|
||||
name string
|
||||
defaultPort int
|
||||
logger *logging.Logger
|
||||
router chi.Router
|
||||
server *http.Server
|
||||
|
||||
// Configuration
|
||||
appConfig config.AppConfig
|
||||
serverConfig config.ServerConfig
|
||||
|
||||
// Lifecycle hooks
|
||||
onShutdown []func(context.Context) error
|
||||
}
|
||||
|
||||
// Option configures the App.
|
||||
type Option func(*App)
|
||||
|
||||
// WithLogger sets a custom logger for the application.
|
||||
func WithLogger(logger *logging.Logger) Option {
|
||||
return func(a *App) {
|
||||
a.logger = logger
|
||||
}
|
||||
}
|
||||
|
||||
// WithDefaultPort sets the default port if not configured via environment.
|
||||
func WithDefaultPort(port int) Option {
|
||||
return func(a *App) {
|
||||
a.defaultPort = port
|
||||
}
|
||||
}
|
||||
|
||||
// New creates a new App instance with the given service name.
|
||||
// It initializes configuration, logging, and routing infrastructure.
|
||||
//
|
||||
// The service name is used for:
|
||||
// - Configuration defaults (APP_NAME)
|
||||
// - Logging context (service attribute)
|
||||
// - Health check identification
|
||||
//
|
||||
// Configuration is loaded from environment variables with support for:
|
||||
// - .env file (in development)
|
||||
// - Environment variables (highest priority)
|
||||
func New(serviceName string, opts ...Option) *App {
|
||||
app := &App{
|
||||
name: serviceName,
|
||||
defaultPort: 8080,
|
||||
onShutdown: make([]func(context.Context) error, 0),
|
||||
}
|
||||
|
||||
// Apply options before initialization (to capture defaultPort)
|
||||
for _, opt := range opts {
|
||||
opt(app)
|
||||
}
|
||||
|
||||
// Initialize configuration
|
||||
config.MustInit(config.Options{
|
||||
AppName: serviceName,
|
||||
DefaultPort: app.defaultPort,
|
||||
})
|
||||
|
||||
// Load configuration
|
||||
app.appConfig = config.ReadAppConfig()
|
||||
app.serverConfig = config.ReadServerConfig()
|
||||
|
||||
// Initialize logger if not provided
|
||||
if app.logger == nil {
|
||||
logCfg := config.ReadLoggingConfig()
|
||||
app.logger = logging.New(logging.Config{
|
||||
Level: logging.ParseLevel(logCfg.Level),
|
||||
Format: logging.ParseFormat(logCfg.Format),
|
||||
Environment: app.appConfig.Environment,
|
||||
AddSource: app.appConfig.IsDevelopment(),
|
||||
}).WithService(serviceName)
|
||||
}
|
||||
|
||||
// Initialize router with standard middleware
|
||||
app.router = chi.NewRouter()
|
||||
app.setupMiddleware()
|
||||
app.setupHealthEndpoints()
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
// setupMiddleware configures the standard middleware stack.
|
||||
func (a *App) setupMiddleware() {
|
||||
// Core middleware (order matters)
|
||||
a.router.Use(middleware.RequestID())
|
||||
a.router.Use(middleware.Tracing())
|
||||
a.router.Use(middleware.RequestLogger(a.logger))
|
||||
a.router.Use(middleware.Recoverer(a.logger))
|
||||
|
||||
// CORS (configurable via environment)
|
||||
a.router.Use(middleware.CORS(middleware.DefaultCORSConfig()))
|
||||
}
|
||||
|
||||
// setupHealthEndpoints registers /health and /ready endpoints.
|
||||
func (a *App) setupHealthEndpoints() {
|
||||
// Liveness probe - returns 200 if the process is running
|
||||
a.router.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
httpresponse.OK(w, r, map[string]string{
|
||||
"status": "ok",
|
||||
"service": a.name,
|
||||
})
|
||||
})
|
||||
|
||||
// Readiness probe - returns 200 if the service is ready to accept traffic
|
||||
a.router.Get("/ready", func(w http.ResponseWriter, r *http.Request) {
|
||||
httpresponse.OK(w, r, map[string]string{
|
||||
"status": "ready",
|
||||
"service": a.name,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Logger returns the application logger.
|
||||
func (a *App) Logger() *logging.Logger {
|
||||
return a.logger
|
||||
}
|
||||
|
||||
// Config returns the application configuration.
|
||||
func (a *App) Config() config.AppConfig {
|
||||
return a.appConfig
|
||||
}
|
||||
|
||||
// ServerConfig returns the server configuration.
|
||||
func (a *App) ServerConfig() config.ServerConfig {
|
||||
return a.serverConfig
|
||||
}
|
||||
|
||||
// Router returns the underlying chi router for advanced configuration.
|
||||
func (a *App) Router() chi.Router {
|
||||
return a.router
|
||||
}
|
||||
|
||||
// Use appends middleware to the router middleware stack.
|
||||
func (a *App) Use(middlewares ...func(http.Handler) http.Handler) {
|
||||
a.router.Use(middlewares...)
|
||||
}
|
||||
|
||||
// GET registers a handler for GET requests to the given pattern.
|
||||
func (a *App) GET(pattern string, handler http.HandlerFunc) {
|
||||
a.router.Get(pattern, handler)
|
||||
}
|
||||
|
||||
// POST registers a handler for POST requests to the given pattern.
|
||||
func (a *App) POST(pattern string, handler http.HandlerFunc) {
|
||||
a.router.Post(pattern, handler)
|
||||
}
|
||||
|
||||
// PUT registers a handler for PUT requests to the given pattern.
|
||||
func (a *App) PUT(pattern string, handler http.HandlerFunc) {
|
||||
a.router.Put(pattern, handler)
|
||||
}
|
||||
|
||||
// PATCH registers a handler for PATCH requests to the given pattern.
|
||||
func (a *App) PATCH(pattern string, handler http.HandlerFunc) {
|
||||
a.router.Patch(pattern, handler)
|
||||
}
|
||||
|
||||
// DELETE registers a handler for DELETE requests to the given pattern.
|
||||
func (a *App) DELETE(pattern string, handler http.HandlerFunc) {
|
||||
a.router.Delete(pattern, handler)
|
||||
}
|
||||
|
||||
// Route creates a new sub-router with the given pattern prefix.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// app.Route("/api/v1", func(r chi.Router) {
|
||||
// r.Get("/users", listUsers)
|
||||
// r.Post("/users", createUser)
|
||||
// })
|
||||
func (a *App) Route(pattern string, fn func(r chi.Router)) {
|
||||
a.router.Route(pattern, fn)
|
||||
}
|
||||
|
||||
// Mount attaches a sub-router or http.Handler at the given pattern.
|
||||
func (a *App) Mount(pattern string, handler http.Handler) {
|
||||
a.router.Mount(pattern, handler)
|
||||
}
|
||||
|
||||
// OnShutdown registers a function to be called during graceful shutdown.
|
||||
// Functions are called in the order they were registered.
|
||||
func (a *App) OnShutdown(fn func(context.Context) error) {
|
||||
a.onShutdown = append(a.onShutdown, fn)
|
||||
}
|
||||
|
||||
// Run starts the HTTP server and blocks until shutdown.
|
||||
// It handles graceful shutdown on SIGINT and SIGTERM signals.
|
||||
func (a *App) Run() {
|
||||
addr := a.serverConfig.Addr()
|
||||
|
||||
a.server = &http.Server{
|
||||
Addr: addr,
|
||||
Handler: a.router,
|
||||
ReadTimeout: a.serverConfig.ReadTimeout,
|
||||
WriteTimeout: a.serverConfig.WriteTimeout,
|
||||
IdleTimeout: a.serverConfig.IdleTimeout,
|
||||
}
|
||||
|
||||
// Start server in a goroutine
|
||||
errChan := make(chan error, 1)
|
||||
go func() {
|
||||
a.logger.Info("starting server",
|
||||
"service", a.name,
|
||||
"address", addr,
|
||||
"environment", a.appConfig.Environment,
|
||||
)
|
||||
if err := a.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
errChan <- err
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for shutdown signal or server error
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
select {
|
||||
case err := <-errChan:
|
||||
a.logger.Error("server error", "error", err)
|
||||
os.Exit(1)
|
||||
case sig := <-quit:
|
||||
a.logger.Info("received shutdown signal", "signal", sig.String())
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
a.shutdown()
|
||||
}
|
||||
|
||||
// shutdown performs graceful shutdown of the application.
|
||||
func (a *App) shutdown() {
|
||||
// Create shutdown context with timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
a.logger.Info("shutting down server")
|
||||
|
||||
// Shutdown HTTP server
|
||||
if err := a.server.Shutdown(ctx); err != nil {
|
||||
a.logger.Error("server shutdown error", "error", err)
|
||||
}
|
||||
|
||||
// Run shutdown hooks
|
||||
for _, fn := range a.onShutdown {
|
||||
if err := fn(ctx); err != nil {
|
||||
a.logger.Error("shutdown hook error", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
a.logger.Info("server stopped", "service", a.name)
|
||||
}
|
||||
|
||||
// ListenAddr returns the address the server is configured to listen on.
|
||||
func (a *App) ListenAddr() string {
|
||||
return a.serverConfig.Addr()
|
||||
}
|
||||
|
||||
// ServeHTTP implements http.Handler, allowing App to be used in tests.
|
||||
func (a *App) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
a.router.ServeHTTP(w, r)
|
||||
}
|
||||
235
pkg/config/config.go
Normal file
235
pkg/config/config.go
Normal file
@ -0,0 +1,235 @@
|
||||
// Package config provides configuration loading using Viper.
|
||||
//
|
||||
// This package standardizes configuration loading across all services with:
|
||||
// - Environment variable support
|
||||
// - .env file support for development
|
||||
// - Sensible defaults
|
||||
// - Type-safe configuration structs
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// // Initialize configuration (call once at startup)
|
||||
// config.MustInit(config.Options{
|
||||
// AppName: "my-service",
|
||||
// DefaultPort: 8080,
|
||||
// })
|
||||
//
|
||||
// // Read configuration
|
||||
// appCfg := config.ReadAppConfig()
|
||||
// serverCfg := config.ReadServerConfig()
|
||||
//
|
||||
// // Or read specific values
|
||||
// dbURL := viper.GetString("DATABASE_URL")
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// ServerConfig holds HTTP server configuration.
|
||||
type ServerConfig struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
ReadTimeout time.Duration `json:"read_timeout"`
|
||||
WriteTimeout time.Duration `json:"write_timeout"`
|
||||
IdleTimeout time.Duration `json:"idle_timeout"`
|
||||
}
|
||||
|
||||
// Addr returns the server address in host:port format.
|
||||
func (c ServerConfig) Addr() string {
|
||||
return fmt.Sprintf("%s:%d", c.Host, c.Port)
|
||||
}
|
||||
|
||||
// DatabaseConfig holds database connection configuration.
|
||||
type DatabaseConfig struct {
|
||||
URL string `json:"url"`
|
||||
MaxOpenConns int `json:"max_open_conns"`
|
||||
MaxIdleConns int `json:"max_idle_conns"`
|
||||
ConnMaxLifetime time.Duration `json:"conn_max_lifetime"`
|
||||
}
|
||||
|
||||
// AppConfig holds application-level configuration common to all services.
|
||||
type AppConfig struct {
|
||||
Name string `json:"name"`
|
||||
Environment string `json:"environment"`
|
||||
Debug bool `json:"debug"`
|
||||
}
|
||||
|
||||
// IsDevelopment returns true if the environment is development.
|
||||
func (c AppConfig) IsDevelopment() bool {
|
||||
return c.Environment == "development"
|
||||
}
|
||||
|
||||
// IsProduction returns true if the environment is production.
|
||||
func (c AppConfig) IsProduction() bool {
|
||||
return c.Environment == "production"
|
||||
}
|
||||
|
||||
// LoggingConfig holds logging configuration.
|
||||
type LoggingConfig struct {
|
||||
Level string `json:"level"`
|
||||
Format string `json:"format"`
|
||||
}
|
||||
|
||||
// Options configures the behavior of Init.
|
||||
type Options struct {
|
||||
// AppName is the default name for the application.
|
||||
AppName string
|
||||
|
||||
// DefaultPort is the default server port if not specified.
|
||||
DefaultPort int
|
||||
|
||||
// EnvFile is the path to the .env file for development.
|
||||
// Defaults to ".env" if not specified.
|
||||
EnvFile string
|
||||
|
||||
// SetDefaults is an optional function to set additional viper defaults
|
||||
// before loading configuration.
|
||||
SetDefaults func()
|
||||
|
||||
// SkipEnvFile skips loading from .env file.
|
||||
// Useful for production where all config comes from environment.
|
||||
SkipEnvFile bool
|
||||
}
|
||||
|
||||
// Init initializes viper with common defaults and loads configuration.
|
||||
// This should be called once at service startup before using viper.Get* functions.
|
||||
//
|
||||
// Load order (later sources override earlier):
|
||||
// 1. Default values
|
||||
// 2. .env file (development only)
|
||||
// 3. Environment variables
|
||||
func Init(opts Options) error {
|
||||
viper.SetConfigType("env")
|
||||
viper.SetEnvPrefix("")
|
||||
viper.AutomaticEnv()
|
||||
|
||||
// Set common defaults
|
||||
setCommonDefaults(opts)
|
||||
|
||||
// Set service-specific defaults
|
||||
if opts.SetDefaults != nil {
|
||||
opts.SetDefaults()
|
||||
}
|
||||
|
||||
// In development, optionally load from .env file
|
||||
if !opts.SkipEnvFile {
|
||||
env := viper.GetString("APP_ENVIRONMENT")
|
||||
if env == "development" || env == "" {
|
||||
envFile := opts.EnvFile
|
||||
if envFile == "" {
|
||||
envFile = ".env"
|
||||
}
|
||||
viper.SetConfigFile(envFile)
|
||||
_ = viper.ReadInConfig() // Ignore error - fallback to env vars
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MustInit is like Init but panics if initialization fails.
|
||||
// This is useful in main() where you want to fail fast on configuration errors.
|
||||
func MustInit(opts Options) {
|
||||
if err := Init(opts); err != nil {
|
||||
panic(fmt.Sprintf("failed to initialize config: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
// setCommonDefaults sets default values for common configuration fields.
|
||||
func setCommonDefaults(opts Options) {
|
||||
// App defaults
|
||||
appName := opts.AppName
|
||||
if appName == "" {
|
||||
appName = "service"
|
||||
}
|
||||
viper.SetDefault("APP_NAME", appName)
|
||||
viper.SetDefault("APP_ENVIRONMENT", "development")
|
||||
viper.SetDefault("APP_DEBUG", false)
|
||||
|
||||
// Server defaults
|
||||
viper.SetDefault("SERVER_HOST", "0.0.0.0")
|
||||
port := opts.DefaultPort
|
||||
if port == 0 {
|
||||
port = 8080
|
||||
}
|
||||
viper.SetDefault("SERVER_PORT", port)
|
||||
viper.SetDefault("SERVER_READ_TIMEOUT", "30s")
|
||||
viper.SetDefault("SERVER_WRITE_TIMEOUT", "0s") // Disabled for SSE support
|
||||
viper.SetDefault("SERVER_IDLE_TIMEOUT", "120s")
|
||||
|
||||
// Database defaults
|
||||
viper.SetDefault("DATABASE_MAX_OPEN_CONNS", 25)
|
||||
viper.SetDefault("DATABASE_MAX_IDLE_CONNS", 5)
|
||||
viper.SetDefault("DATABASE_CONN_MAX_LIFETIME", "5m")
|
||||
|
||||
// Logging defaults
|
||||
viper.SetDefault("LOG_LEVEL", "info")
|
||||
viper.SetDefault("LOG_FORMAT", "auto") // auto = JSON in prod, text in dev
|
||||
}
|
||||
|
||||
// ReadAppConfig reads AppConfig from viper.
|
||||
func ReadAppConfig() AppConfig {
|
||||
return AppConfig{
|
||||
Name: viper.GetString("APP_NAME"),
|
||||
Environment: viper.GetString("APP_ENVIRONMENT"),
|
||||
Debug: viper.GetBool("APP_DEBUG"),
|
||||
}
|
||||
}
|
||||
|
||||
// ReadServerConfig reads ServerConfig from viper.
|
||||
func ReadServerConfig() ServerConfig {
|
||||
return ServerConfig{
|
||||
Host: viper.GetString("SERVER_HOST"),
|
||||
Port: viper.GetInt("SERVER_PORT"),
|
||||
ReadTimeout: viper.GetDuration("SERVER_READ_TIMEOUT"),
|
||||
WriteTimeout: viper.GetDuration("SERVER_WRITE_TIMEOUT"),
|
||||
IdleTimeout: viper.GetDuration("SERVER_IDLE_TIMEOUT"),
|
||||
}
|
||||
}
|
||||
|
||||
// ReadDatabaseConfig reads DatabaseConfig from viper.
|
||||
func ReadDatabaseConfig() DatabaseConfig {
|
||||
return DatabaseConfig{
|
||||
URL: viper.GetString("DATABASE_URL"),
|
||||
MaxOpenConns: viper.GetInt("DATABASE_MAX_OPEN_CONNS"),
|
||||
MaxIdleConns: viper.GetInt("DATABASE_MAX_IDLE_CONNS"),
|
||||
ConnMaxLifetime: viper.GetDuration("DATABASE_CONN_MAX_LIFETIME"),
|
||||
}
|
||||
}
|
||||
|
||||
// ReadLoggingConfig reads LoggingConfig from viper.
|
||||
func ReadLoggingConfig() LoggingConfig {
|
||||
return LoggingConfig{
|
||||
Level: viper.GetString("LOG_LEVEL"),
|
||||
Format: viper.GetString("LOG_FORMAT"),
|
||||
}
|
||||
}
|
||||
|
||||
// GetString returns a string configuration value.
|
||||
func GetString(key string) string {
|
||||
return viper.GetString(key)
|
||||
}
|
||||
|
||||
// GetInt returns an integer configuration value.
|
||||
func GetInt(key string) int {
|
||||
return viper.GetInt(key)
|
||||
}
|
||||
|
||||
// GetBool returns a boolean configuration value.
|
||||
func GetBool(key string) bool {
|
||||
return viper.GetBool(key)
|
||||
}
|
||||
|
||||
// GetDuration returns a duration configuration value.
|
||||
func GetDuration(key string) time.Duration {
|
||||
return viper.GetDuration(key)
|
||||
}
|
||||
|
||||
// IsSet returns true if the key is set in configuration.
|
||||
func IsSet(key string) bool {
|
||||
return viper.IsSet(key)
|
||||
}
|
||||
39
pkg/go.mod
Normal file
39
pkg/go.mod
Normal file
@ -0,0 +1,39 @@
|
||||
module git.threesix.ai/jordan/testgo6/pkg
|
||||
|
||||
go 1.23
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.2.0
|
||||
github.com/go-chi/cors v1.2.1
|
||||
github.com/go-playground/validator/v10 v10.23.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/spf13/viper v1.19.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.11.0 // indirect
|
||||
github.com/spf13/cast v1.6.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
go.uber.org/atomic v1.9.0 // indirect
|
||||
go.uber.org/multierr v1.9.0 // indirect
|
||||
golang.org/x/crypto v0.21.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
|
||||
golang.org/x/net v0.23.0 // indirect
|
||||
golang.org/x/sys v0.18.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
253
pkg/httpclient/client.go
Normal file
253
pkg/httpclient/client.go
Normal file
@ -0,0 +1,253 @@
|
||||
// Package httpclient provides a robust HTTP client with automatic retries and exponential backoff.
|
||||
//
|
||||
// This package wraps the standard http.Client to provide:
|
||||
// - Automatic retries with exponential backoff
|
||||
// - Request ID and trace ID propagation
|
||||
// - Configurable timeouts
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// // Create a client with default settings
|
||||
// client := httpclient.New(httpclient.Config{
|
||||
// Timeout: 10 * time.Second,
|
||||
// MaxRetries: 3,
|
||||
// })
|
||||
//
|
||||
// // Make requests
|
||||
// resp, err := client.Do(req)
|
||||
//
|
||||
// // Or use convenience methods
|
||||
// resp, err := httpclient.Get(ctx, "https://api.example.com/users")
|
||||
package httpclient
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.threesix.ai/jordan/testgo6/pkg/httpcontext"
|
||||
)
|
||||
|
||||
// Config holds configuration for the HTTP client.
|
||||
type Config struct {
|
||||
// Timeout for individual HTTP requests (default: 10s)
|
||||
Timeout time.Duration
|
||||
|
||||
// MaxRetries for failed requests (default: 3)
|
||||
MaxRetries int
|
||||
|
||||
// Logger for structured logging (optional, defaults to slog.Default())
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// Client wraps http.Client to provide retry logic and request ID propagation.
|
||||
type Client struct {
|
||||
httpClient *http.Client
|
||||
logger *slog.Logger
|
||||
config Config
|
||||
}
|
||||
|
||||
// New creates a new robust HTTP client.
|
||||
func New(config Config) *Client {
|
||||
if config.Timeout == 0 {
|
||||
config.Timeout = 10 * time.Second
|
||||
}
|
||||
if config.MaxRetries == 0 {
|
||||
config.MaxRetries = 3
|
||||
}
|
||||
if config.Logger == nil {
|
||||
config.Logger = slog.Default()
|
||||
}
|
||||
|
||||
return &Client{
|
||||
httpClient: &http.Client{
|
||||
Timeout: config.Timeout,
|
||||
},
|
||||
logger: config.Logger,
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
// Do executes an HTTP request with exponential backoff retry logic.
|
||||
//
|
||||
// Retries on transient errors:
|
||||
// - HTTP 5xx server errors
|
||||
// - HTTP 429 Too Many Requests
|
||||
// - Connection errors (timeout, connection refused)
|
||||
//
|
||||
// Does NOT retry on:
|
||||
// - HTTP 4xx client errors (except 429)
|
||||
// - Context cancellation or deadline exceeded
|
||||
func (c *Client) Do(req *http.Request) (*http.Response, error) {
|
||||
const (
|
||||
initialDelay = 100 * time.Millisecond
|
||||
maxDelay = 2 * time.Second
|
||||
)
|
||||
|
||||
// Propagate request ID if present in context
|
||||
if requestID, ok := httpcontext.GetRequestID(req.Context()); ok && requestID != "" {
|
||||
if req.Header.Get("X-Request-ID") == "" {
|
||||
req.Header.Set("X-Request-ID", requestID)
|
||||
}
|
||||
}
|
||||
|
||||
// Propagate trace ID if present in context
|
||||
if traceID, ok := httpcontext.GetTraceID(req.Context()); ok && traceID != "" {
|
||||
if req.Header.Get("X-Trace-ID") == "" {
|
||||
req.Header.Set("X-Trace-ID", traceID)
|
||||
}
|
||||
}
|
||||
|
||||
// Clone request body for retries (critical: POST/PUT bodies get exhausted)
|
||||
var bodyBytes []byte
|
||||
if req.Body != nil {
|
||||
var err error
|
||||
bodyBytes, err = io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read request body: %w", err)
|
||||
}
|
||||
_ = req.Body.Close()
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
maxRetries := c.config.MaxRetries
|
||||
ctx := req.Context()
|
||||
|
||||
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||||
// Check if context is already cancelled
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Reset body for each attempt
|
||||
if bodyBytes != nil {
|
||||
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
}
|
||||
|
||||
// Execute the request
|
||||
resp, err := c.httpClient.Do(req)
|
||||
|
||||
// Network error
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
if !isRetryableError(err, nil) {
|
||||
return nil, lastErr
|
||||
}
|
||||
// Continue to retry
|
||||
} else {
|
||||
// HTTP 429 - retry
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
lastErr = fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
// Continue to retry
|
||||
} else if resp.StatusCode >= 400 && resp.StatusCode < 500 {
|
||||
// Other HTTP 4xx - return immediately (not transient)
|
||||
return resp, nil
|
||||
} else if resp.StatusCode >= 500 {
|
||||
// HTTP 5xx - retry
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
lastErr = fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
// Continue to retry
|
||||
} else {
|
||||
// HTTP 2xx/3xx - success
|
||||
return resp, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Don't retry if we've exhausted attempts
|
||||
if attempt >= maxRetries {
|
||||
break
|
||||
}
|
||||
|
||||
// Calculate exponential backoff delay using bit-shift
|
||||
delay := initialDelay << attempt
|
||||
if delay > maxDelay {
|
||||
delay = maxDelay
|
||||
}
|
||||
|
||||
c.logger.Debug("retrying http request",
|
||||
"attempt", attempt+1,
|
||||
"max_retries", maxRetries,
|
||||
"delay_ms", delay.Milliseconds(),
|
||||
"url", req.URL.String(),
|
||||
"error", lastErr)
|
||||
|
||||
// Wait with context awareness
|
||||
select {
|
||||
case <-time.After(delay):
|
||||
// Continue to next retry
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("request failed after %d retries: %w", maxRetries, lastErr)
|
||||
}
|
||||
|
||||
// isRetryableError determines if an error or response should trigger a retry.
|
||||
func isRetryableError(err error, resp *http.Response) bool {
|
||||
// Network/connection errors are retryable
|
||||
if err != nil {
|
||||
// Don't retry on context cancellation
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return false
|
||||
}
|
||||
// Retry on all other errors (connection refused, timeout, etc.)
|
||||
return true
|
||||
}
|
||||
|
||||
// HTTP 5xx errors and 429 are retryable
|
||||
if resp != nil {
|
||||
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Convenience methods using a default client
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// Default is a pre-configured client with 30s timeout and 3 retries.
|
||||
var Default = New(Config{
|
||||
Timeout: 30 * time.Second,
|
||||
MaxRetries: 3,
|
||||
})
|
||||
|
||||
// Do performs an HTTP request with retry logic using the default client.
|
||||
func Do(req *http.Request) (*http.Response, error) {
|
||||
return Default.Do(req)
|
||||
}
|
||||
|
||||
// Get performs a GET request with the default client.
|
||||
func Get(ctx context.Context, url string) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Default.Do(req)
|
||||
}
|
||||
|
||||
// Post performs a POST request with the default client.
|
||||
func Post(ctx context.Context, url, contentType string, body io.Reader) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
return Default.Do(req)
|
||||
}
|
||||
|
||||
// JSONPost performs a POST request with JSON content type.
|
||||
func JSONPost(ctx context.Context, url string, body io.Reader) (*http.Response, error) {
|
||||
return Post(ctx, url, "application/json", body)
|
||||
}
|
||||
186
pkg/httpcontext/keys.go
Normal file
186
pkg/httpcontext/keys.go
Normal file
@ -0,0 +1,186 @@
|
||||
// Package httpcontext provides type-safe context keys and helpers for HTTP request contexts.
|
||||
//
|
||||
// This package standardizes how context values are stored and retrieved across all services.
|
||||
// Using unexported types for context keys prevents collisions with other packages.
|
||||
//
|
||||
// Usage in middleware:
|
||||
//
|
||||
// func AuthMiddleware() func(http.Handler) http.Handler {
|
||||
// return func(next http.Handler) http.Handler {
|
||||
// return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// user := extractUserFromAuth(r)
|
||||
// ctx := httpcontext.SetUser(r.Context(), user)
|
||||
// ctx = httpcontext.SetOrgID(ctx, user.OrganizationID)
|
||||
// next.ServeHTTP(w, r.WithContext(ctx))
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Usage in handlers:
|
||||
//
|
||||
// func (h *Handler) GetProfile(w http.ResponseWriter, r *http.Request) {
|
||||
// user, ok := httpcontext.GetUser(r.Context())
|
||||
// if !ok {
|
||||
// http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
// return
|
||||
// }
|
||||
// // ... use user
|
||||
// }
|
||||
package httpcontext
|
||||
|
||||
import "context"
|
||||
|
||||
// contextKey is an unexported type used for context keys to prevent collisions.
|
||||
// Other packages cannot create values of this type, ensuring our keys are unique.
|
||||
type contextKey string
|
||||
|
||||
// Standard context keys used across services.
|
||||
const (
|
||||
keyUser contextKey = "user"
|
||||
keyOrgID contextKey = "organization_id"
|
||||
keyRequestID contextKey = "request_id"
|
||||
keyTraceID contextKey = "trace_id"
|
||||
keyJWTClaims contextKey = "jwt_claims"
|
||||
)
|
||||
|
||||
// SetUser adds a user to the context.
|
||||
// The user can be any type - typically a domain user struct.
|
||||
// Returns a new context with the user attached.
|
||||
func SetUser(ctx context.Context, user any) context.Context {
|
||||
if user == nil {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, keyUser, user)
|
||||
}
|
||||
|
||||
// GetUser retrieves the user from context.
|
||||
// Returns (user, true) if present, (nil, false) if not found.
|
||||
// Caller should type-assert the returned value to their user type.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// if val, ok := httpcontext.GetUser(ctx); ok {
|
||||
// user := val.(*domain.User)
|
||||
// // ... use user
|
||||
// }
|
||||
func GetUser(ctx context.Context) (any, bool) {
|
||||
val := ctx.Value(keyUser)
|
||||
if val == nil {
|
||||
return nil, false
|
||||
}
|
||||
return val, true
|
||||
}
|
||||
|
||||
// SetOrgID adds an organization ID to the context.
|
||||
// Returns a new context with the organization ID attached.
|
||||
func SetOrgID(ctx context.Context, orgID string) context.Context {
|
||||
if orgID == "" {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, keyOrgID, orgID)
|
||||
}
|
||||
|
||||
// GetOrgID retrieves the organization ID from context.
|
||||
// Returns (orgID, true) if present, ("", false) if not found.
|
||||
func GetOrgID(ctx context.Context) (string, bool) {
|
||||
val := ctx.Value(keyOrgID)
|
||||
if val == nil {
|
||||
return "", false
|
||||
}
|
||||
if orgID, ok := val.(string); ok {
|
||||
return orgID, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// SetRequestID adds a request ID to the context.
|
||||
// Returns a new context with the request ID attached.
|
||||
func SetRequestID(ctx context.Context, requestID string) context.Context {
|
||||
if requestID == "" {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, keyRequestID, requestID)
|
||||
}
|
||||
|
||||
// GetRequestID retrieves the request ID from context.
|
||||
// Returns (requestID, true) if present, ("", false) if not found.
|
||||
func GetRequestID(ctx context.Context) (string, bool) {
|
||||
val := ctx.Value(keyRequestID)
|
||||
if val == nil {
|
||||
return "", false
|
||||
}
|
||||
if requestID, ok := val.(string); ok {
|
||||
return requestID, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// SetTraceID adds a trace ID to the context.
|
||||
// Returns a new context with the trace ID attached.
|
||||
func SetTraceID(ctx context.Context, traceID string) context.Context {
|
||||
if traceID == "" {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, keyTraceID, traceID)
|
||||
}
|
||||
|
||||
// GetTraceID retrieves the trace ID from context.
|
||||
// Returns (traceID, true) if present, ("", false) if not found.
|
||||
func GetTraceID(ctx context.Context) (string, bool) {
|
||||
val := ctx.Value(keyTraceID)
|
||||
if val == nil {
|
||||
return "", false
|
||||
}
|
||||
if traceID, ok := val.(string); ok {
|
||||
return traceID, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// SetJWTClaims adds JWT claims to the context.
|
||||
// The claims can be any type - typically a custom claims struct.
|
||||
// Returns a new context with the claims attached.
|
||||
func SetJWTClaims(ctx context.Context, claims any) context.Context {
|
||||
if claims == nil {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, keyJWTClaims, claims)
|
||||
}
|
||||
|
||||
// GetJWTClaims retrieves JWT claims from context.
|
||||
// Returns (claims, true) if present, (nil, false) if not found.
|
||||
// Caller should type-assert the returned value to their claims type.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// if val, ok := httpcontext.GetJWTClaims(ctx); ok {
|
||||
// claims := val.(*auth.CustomClaims)
|
||||
// // ... use claims
|
||||
// }
|
||||
func GetJWTClaims(ctx context.Context) (any, bool) {
|
||||
val := ctx.Value(keyJWTClaims)
|
||||
if val == nil {
|
||||
return nil, false
|
||||
}
|
||||
return val, true
|
||||
}
|
||||
|
||||
// MustGetUser retrieves the user from context and panics if not found.
|
||||
// Use only when authentication middleware guarantees user presence.
|
||||
func MustGetUser(ctx context.Context) any {
|
||||
user, ok := GetUser(ctx)
|
||||
if !ok {
|
||||
panic("httpcontext: user not found in context")
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
// MustGetRequestID retrieves the request ID from context and panics if not found.
|
||||
// Use only when middleware guarantees request ID presence.
|
||||
func MustGetRequestID(ctx context.Context) string {
|
||||
requestID, ok := GetRequestID(ctx)
|
||||
if !ok {
|
||||
panic("httpcontext: request_id not found in context")
|
||||
}
|
||||
return requestID
|
||||
}
|
||||
75
pkg/httpresponse/envelope.go
Normal file
75
pkg/httpresponse/envelope.go
Normal file
@ -0,0 +1,75 @@
|
||||
// Package httpresponse provides standard HTTP response types and helpers.
|
||||
//
|
||||
// This package implements an envelope pattern for consistent API responses:
|
||||
//
|
||||
// {
|
||||
// "data": {...}, // Present on success
|
||||
// "error": {...}, // Present on error
|
||||
// "meta": {
|
||||
// "request_id": "...",
|
||||
// "trace_id": "...",
|
||||
// "timestamp": "..."
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// func GetUser(w http.ResponseWriter, r *http.Request) {
|
||||
// user, err := svc.Get(ctx, id)
|
||||
// if err != nil {
|
||||
// httpresponse.NotFound(w, r, "user not found")
|
||||
// return
|
||||
// }
|
||||
// httpresponse.OK(w, r, user)
|
||||
// }
|
||||
package httpresponse
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.threesix.ai/jordan/testgo6/pkg/httpcontext"
|
||||
)
|
||||
|
||||
// Response is the standard envelope for all API responses.
|
||||
type Response struct {
|
||||
Data any `json:"data,omitempty"`
|
||||
Error *Error `json:"error,omitempty"`
|
||||
Meta Meta `json:"meta"`
|
||||
}
|
||||
|
||||
// Error represents an API error in the response envelope.
|
||||
type Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details any `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
// Meta contains response metadata.
|
||||
type Meta struct {
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
TraceID string `json:"trace_id,omitempty"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
// newMeta creates a Meta with current timestamp, request ID, and trace ID from context.
|
||||
func newMeta(r *http.Request) Meta {
|
||||
requestID, _ := httpcontext.GetRequestID(r.Context())
|
||||
traceID, _ := httpcontext.GetTraceID(r.Context())
|
||||
return Meta{
|
||||
RequestID: requestID,
|
||||
TraceID: traceID,
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
// Error codes for machine-readable error classification.
|
||||
const (
|
||||
CodeBadRequest = "BAD_REQUEST"
|
||||
CodeUnauthorized = "UNAUTHORIZED"
|
||||
CodeForbidden = "FORBIDDEN"
|
||||
CodeNotFound = "NOT_FOUND"
|
||||
CodeConflict = "CONFLICT"
|
||||
CodeInternal = "INTERNAL_ERROR"
|
||||
CodeValidation = "VALIDATION_ERROR"
|
||||
)
|
||||
192
pkg/httpresponse/response.go
Normal file
192
pkg/httpresponse/response.go
Normal file
@ -0,0 +1,192 @@
|
||||
package httpresponse
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrEmptyBody is returned when the request body is empty.
|
||||
ErrEmptyBody = errors.New("request body is empty")
|
||||
// ErrInvalidJSON is returned when the request body contains invalid JSON.
|
||||
ErrInvalidJSON = errors.New("invalid JSON")
|
||||
// ErrUnknownFields is returned when strict decoding encounters unknown fields.
|
||||
ErrUnknownFields = errors.New("unknown fields in JSON")
|
||||
)
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Success Responses
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// JSON writes a JSON response with the given status code.
|
||||
// The data is wrapped in the standard response envelope.
|
||||
func JSON(w http.ResponseWriter, r *http.Request, status int, data any) {
|
||||
resp := Response{
|
||||
Data: data,
|
||||
Meta: newMeta(r),
|
||||
}
|
||||
writeJSON(w, status, resp)
|
||||
}
|
||||
|
||||
// OK writes a successful JSON response with status 200 OK.
|
||||
func OK(w http.ResponseWriter, r *http.Request, data any) {
|
||||
JSON(w, r, http.StatusOK, data)
|
||||
}
|
||||
|
||||
// Created writes a successful JSON response with status 201 Created.
|
||||
func Created(w http.ResponseWriter, r *http.Request, data any) {
|
||||
JSON(w, r, http.StatusCreated, data)
|
||||
}
|
||||
|
||||
// Accepted writes a successful JSON response with status 202 Accepted.
|
||||
func Accepted(w http.ResponseWriter, r *http.Request, data any) {
|
||||
JSON(w, r, http.StatusAccepted, data)
|
||||
}
|
||||
|
||||
// NoContent writes a successful response with status 204 No Content.
|
||||
func NoContent(w http.ResponseWriter) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Error Responses
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// WriteError writes an error response with the given status code.
|
||||
func WriteError(w http.ResponseWriter, r *http.Request, status int, code, message string, details ...any) {
|
||||
var detailsVal any
|
||||
if len(details) > 0 {
|
||||
detailsVal = details[0]
|
||||
}
|
||||
|
||||
resp := Response{
|
||||
Error: &Error{
|
||||
Code: code,
|
||||
Message: message,
|
||||
Details: detailsVal,
|
||||
},
|
||||
Meta: newMeta(r),
|
||||
}
|
||||
writeJSON(w, status, resp)
|
||||
}
|
||||
|
||||
// BadRequest writes a 400 Bad Request error response.
|
||||
func BadRequest(w http.ResponseWriter, r *http.Request, message string) {
|
||||
WriteError(w, r, http.StatusBadRequest, CodeBadRequest, message)
|
||||
}
|
||||
|
||||
// ValidationError writes a 400 Bad Request error response for validation failures.
|
||||
func ValidationError(w http.ResponseWriter, r *http.Request, message string, details any) {
|
||||
WriteError(w, r, http.StatusBadRequest, CodeValidation, message, details)
|
||||
}
|
||||
|
||||
// Unauthorized writes a 401 Unauthorized error response.
|
||||
func Unauthorized(w http.ResponseWriter, r *http.Request, message string) {
|
||||
WriteError(w, r, http.StatusUnauthorized, CodeUnauthorized, message)
|
||||
}
|
||||
|
||||
// Forbidden writes a 403 Forbidden error response.
|
||||
func Forbidden(w http.ResponseWriter, r *http.Request, message string) {
|
||||
WriteError(w, r, http.StatusForbidden, CodeForbidden, message)
|
||||
}
|
||||
|
||||
// NotFound writes a 404 Not Found error response.
|
||||
func NotFound(w http.ResponseWriter, r *http.Request, message string) {
|
||||
WriteError(w, r, http.StatusNotFound, CodeNotFound, message)
|
||||
}
|
||||
|
||||
// Conflict writes a 409 Conflict error response.
|
||||
func Conflict(w http.ResponseWriter, r *http.Request, message string) {
|
||||
WriteError(w, r, http.StatusConflict, CodeConflict, message)
|
||||
}
|
||||
|
||||
// InternalError writes a 500 Internal Server Error response.
|
||||
// The message should be safe to expose to clients; internal details should be logged.
|
||||
func InternalError(w http.ResponseWriter, r *http.Request, message string) {
|
||||
WriteError(w, r, http.StatusInternalServerError, CodeInternal, message)
|
||||
}
|
||||
|
||||
// ServiceUnavailable writes a 503 Service Unavailable error response.
|
||||
func ServiceUnavailable(w http.ResponseWriter, r *http.Request, message string) {
|
||||
WriteError(w, r, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", message)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Request Body Decoding
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// DecodeJSON decodes JSON from request body into v.
|
||||
// Returns descriptive errors for common failure cases.
|
||||
// Does not enforce strict field matching.
|
||||
func DecodeJSON(r *http.Request, v any) error {
|
||||
if r.Body == nil {
|
||||
return ErrEmptyBody
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
if err := decoder.Decode(v); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return ErrEmptyBody
|
||||
}
|
||||
return fmt.Errorf("%w: %w", ErrInvalidJSON, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DecodeJSONStrict decodes JSON from request body into v.
|
||||
// Rejects JSON that contains fields not present in the target struct.
|
||||
// Useful for strict API validation to catch client errors early.
|
||||
func DecodeJSONStrict(r *http.Request, v any) error {
|
||||
if r.Body == nil {
|
||||
return ErrEmptyBody
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
|
||||
if err := decoder.Decode(v); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return ErrEmptyBody
|
||||
}
|
||||
// Check if it's an unknown field error
|
||||
var syntaxErr *json.SyntaxError
|
||||
var unmarshalErr *json.UnmarshalTypeError
|
||||
if errors.As(err, &syntaxErr) || errors.As(err, &unmarshalErr) {
|
||||
return fmt.Errorf("%w: %w", ErrInvalidJSON, err)
|
||||
}
|
||||
// Unknown field errors contain "unknown field" in the message
|
||||
return fmt.Errorf("%w: %w", ErrUnknownFields, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsEmptyBodyError checks if an error is ErrEmptyBody.
|
||||
func IsEmptyBodyError(err error) bool {
|
||||
return errors.Is(err, ErrEmptyBody)
|
||||
}
|
||||
|
||||
// IsInvalidJSONError checks if an error is ErrInvalidJSON.
|
||||
func IsInvalidJSONError(err error) bool {
|
||||
return errors.Is(err, ErrInvalidJSON)
|
||||
}
|
||||
|
||||
// IsUnknownFieldsError checks if an error is ErrUnknownFields.
|
||||
func IsUnknownFieldsError(err error) bool {
|
||||
return errors.Is(err, ErrUnknownFields)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// writeJSON marshals and writes the response.
|
||||
func writeJSON(w http.ResponseWriter, status int, data any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
308
pkg/httpvalidation/validator.go
Normal file
308
pkg/httpvalidation/validator.go
Normal file
@ -0,0 +1,308 @@
|
||||
// Package httpvalidation provides consistent request validation across services.
|
||||
//
|
||||
// This package wraps go-playground/validator/v10 with a simpler API
|
||||
// and human-readable error messages suitable for API responses.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// type CreateUserRequest struct {
|
||||
// Email string `json:"email" validate:"required,email"`
|
||||
// Phone string `json:"phone" validate:"omitempty,e164"`
|
||||
// }
|
||||
//
|
||||
// func CreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
// var req CreateUserRequest
|
||||
// if err := httpresponse.DecodeJSON(r, &req); err != nil {
|
||||
// httpresponse.BadRequest(w, r, "invalid JSON")
|
||||
// return
|
||||
// }
|
||||
// if details := httpvalidation.ValidateStruct(req); len(details) > 0 {
|
||||
// httpresponse.ValidationError(w, r, "validation failed", details)
|
||||
// return
|
||||
// }
|
||||
// // ... proceed with valid request
|
||||
// }
|
||||
package httpvalidation
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var (
|
||||
// Singleton validator instance
|
||||
once sync.Once
|
||||
validate *validator.Validate
|
||||
|
||||
// Regex patterns for custom validations
|
||||
// E.164 allows 1-15 digits total, with country code starting with 1-9
|
||||
phoneRegex = regexp.MustCompile(`^\+?[1-9]\d{4,14}$`)
|
||||
)
|
||||
|
||||
// ValidationDetail represents a single field validation error.
|
||||
// This structure is designed for API responses, providing clear
|
||||
// field-level error information to clients.
|
||||
type ValidationDetail struct {
|
||||
// Field is the JSON field name that failed validation.
|
||||
Field string `json:"field"`
|
||||
// Message is a human-readable description of the validation failure.
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Validator returns the singleton validator instance with all custom validators registered.
|
||||
// Thread-safe and initialized only once.
|
||||
func Validator() *validator.Validate {
|
||||
once.Do(func() {
|
||||
validate = validator.New()
|
||||
|
||||
// Use JSON tag names in error messages
|
||||
validate.RegisterTagNameFunc(func(fld reflect.StructField) string {
|
||||
name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0]
|
||||
if name == "-" || name == "" {
|
||||
return fld.Name
|
||||
}
|
||||
return name
|
||||
})
|
||||
|
||||
// Register custom validators
|
||||
_ = validate.RegisterValidation("uuid", validateUUID)
|
||||
_ = validate.RegisterValidation("uuid_or_empty", validateUUIDOrEmpty)
|
||||
_ = validate.RegisterValidation("phone", validatePhone)
|
||||
_ = validate.RegisterValidation("slug", validateSlug)
|
||||
_ = validate.RegisterValidation("hex_color", validateHexColor)
|
||||
})
|
||||
return validate
|
||||
}
|
||||
|
||||
// ValidateStruct validates a struct and returns a slice of ValidationDetail for any validation errors.
|
||||
// Returns nil if validation passes.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// type CreateFanRequest struct {
|
||||
// Email string `json:"email" validate:"required,email"`
|
||||
// Phone string `json:"phone" validate:"omitempty,phone"`
|
||||
// }
|
||||
//
|
||||
// if details := httpvalidation.ValidateStruct(req); len(details) > 0 {
|
||||
// httpresponse.ValidationError(w, r, "validation failed", details)
|
||||
// return
|
||||
// }
|
||||
func ValidateStruct(s any) []ValidationDetail {
|
||||
v := Validator()
|
||||
err := v.Struct(s)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var details []ValidationDetail
|
||||
|
||||
// Use errors.As to handle wrapped errors
|
||||
var validationErrs validator.ValidationErrors
|
||||
if !errors.As(err, &validationErrs) {
|
||||
// If not validation errors, return generic error
|
||||
details = append(details, ValidationDetail{
|
||||
Field: "unknown",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return details
|
||||
}
|
||||
|
||||
// Convert validator errors to ValidationDetails
|
||||
for _, e := range validationErrs {
|
||||
details = append(details, ValidationDetail{
|
||||
Field: fieldName(e),
|
||||
Message: fieldError(e),
|
||||
})
|
||||
}
|
||||
|
||||
return details
|
||||
}
|
||||
|
||||
// ValidateVar validates a single variable against validation tags.
|
||||
// Returns nil if validation passes, or a ValidationDetail slice with the error.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// if err := httpvalidation.ValidateVar(email, "required,email"); err != nil {
|
||||
// // handle error
|
||||
// }
|
||||
func ValidateVar(field any, tag string) []ValidationDetail {
|
||||
v := Validator()
|
||||
err := v.Var(field, tag)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var validationErrs validator.ValidationErrors
|
||||
if !errors.As(err, &validationErrs) {
|
||||
return []ValidationDetail{{Field: "value", Message: err.Error()}}
|
||||
}
|
||||
|
||||
if len(validationErrs) > 0 {
|
||||
return []ValidationDetail{{Field: "value", Message: fieldError(validationErrs[0])}}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// fieldName extracts the JSON field name from a validation error.
|
||||
// Falls back to the struct field name if JSON tag is not present.
|
||||
func fieldName(e validator.FieldError) string {
|
||||
field := e.Field()
|
||||
|
||||
// Remove any struct prefix
|
||||
parts := strings.Split(field, ".")
|
||||
if len(parts) > 0 {
|
||||
field = parts[len(parts)-1]
|
||||
}
|
||||
|
||||
// Convert to camelCase for API consistency
|
||||
if len(field) > 0 {
|
||||
return strings.ToLower(field[:1]) + field[1:]
|
||||
}
|
||||
return field
|
||||
}
|
||||
|
||||
// fieldError generates a human-readable error message for a validation error.
|
||||
func fieldError(e validator.FieldError) string {
|
||||
field := e.Field()
|
||||
tag := e.Tag()
|
||||
param := e.Param()
|
||||
|
||||
switch tag {
|
||||
case "required":
|
||||
return fmt.Sprintf("%s is required", field)
|
||||
case "email":
|
||||
return fmt.Sprintf("%s must be a valid email address", field)
|
||||
case "min":
|
||||
if e.Kind() == reflect.String {
|
||||
return fmt.Sprintf("%s must be at least %s characters", field, param)
|
||||
}
|
||||
return fmt.Sprintf("%s must be at least %s", field, param)
|
||||
case "max":
|
||||
if e.Kind() == reflect.String {
|
||||
return fmt.Sprintf("%s must be at most %s characters", field, param)
|
||||
}
|
||||
return fmt.Sprintf("%s must be at most %s", field, param)
|
||||
case "len":
|
||||
if e.Kind() == reflect.String {
|
||||
return fmt.Sprintf("%s must be exactly %s characters", field, param)
|
||||
}
|
||||
return fmt.Sprintf("%s must have exactly %s items", field, param)
|
||||
case "uuid":
|
||||
return fmt.Sprintf("%s must be a valid UUID", field)
|
||||
case "uuid_or_empty":
|
||||
return fmt.Sprintf("%s must be a valid UUID or empty", field)
|
||||
case "phone", "e164":
|
||||
return fmt.Sprintf("%s must be a valid phone number in E.164 format", field)
|
||||
case "url":
|
||||
return fmt.Sprintf("%s must be a valid URL", field)
|
||||
case "oneof":
|
||||
return fmt.Sprintf("%s must be one of: %s", field, param)
|
||||
case "gt":
|
||||
return fmt.Sprintf("%s must be greater than %s", field, param)
|
||||
case "gte":
|
||||
return fmt.Sprintf("%s must be greater than or equal to %s", field, param)
|
||||
case "lt":
|
||||
return fmt.Sprintf("%s must be less than %s", field, param)
|
||||
case "lte":
|
||||
return fmt.Sprintf("%s must be less than or equal to %s", field, param)
|
||||
case "slug":
|
||||
return fmt.Sprintf("%s must be a valid slug (lowercase letters, numbers, hyphens)", field)
|
||||
case "hex_color":
|
||||
return fmt.Sprintf("%s must be a valid hex color code", field)
|
||||
case "alphanum":
|
||||
return fmt.Sprintf("%s must contain only alphanumeric characters", field)
|
||||
case "alpha":
|
||||
return fmt.Sprintf("%s must contain only alphabetic characters", field)
|
||||
case "numeric":
|
||||
return fmt.Sprintf("%s must be numeric", field)
|
||||
case "datetime":
|
||||
return fmt.Sprintf("%s must be a valid datetime in format %s", field, param)
|
||||
case "eqfield":
|
||||
return fmt.Sprintf("%s must equal %s", field, param)
|
||||
case "nefield":
|
||||
return fmt.Sprintf("%s must not equal %s", field, param)
|
||||
default:
|
||||
return fmt.Sprintf("%s failed validation (%s)", field, tag)
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Custom Validators
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// validateUUID checks if a field is a valid UUID.
|
||||
func validateUUID(fl validator.FieldLevel) bool {
|
||||
field := fl.Field().String()
|
||||
if field == "" {
|
||||
return false
|
||||
}
|
||||
_, err := uuid.Parse(field)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// validateUUIDOrEmpty checks if a field is either empty or a valid UUID.
|
||||
func validateUUIDOrEmpty(fl validator.FieldLevel) bool {
|
||||
field := fl.Field().String()
|
||||
if field == "" {
|
||||
return true
|
||||
}
|
||||
_, err := uuid.Parse(field)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// validatePhone checks if a field is a valid phone number in E.164 format.
|
||||
// E.164 format: +[country code][number] (e.g., +14155552671)
|
||||
func validatePhone(fl validator.FieldLevel) bool {
|
||||
phone := fl.Field().String()
|
||||
if phone == "" {
|
||||
return false
|
||||
}
|
||||
return phoneRegex.MatchString(phone)
|
||||
}
|
||||
|
||||
// validateSlug checks if a field is a valid URL slug.
|
||||
// Valid slugs contain only lowercase letters, numbers, and hyphens.
|
||||
func validateSlug(fl validator.FieldLevel) bool {
|
||||
slug := fl.Field().String()
|
||||
if slug == "" {
|
||||
return false
|
||||
}
|
||||
// Must start with letter or number, can contain hyphens, must end with letter or number
|
||||
match, _ := regexp.MatchString(`^[a-z0-9]+(-[a-z0-9]+)*$`, slug)
|
||||
return match
|
||||
}
|
||||
|
||||
// validateHexColor checks if a field is a valid hex color code.
|
||||
// Accepts #RGB, #RRGGBB, #RRGGBBAA formats.
|
||||
func validateHexColor(fl validator.FieldLevel) bool {
|
||||
color := fl.Field().String()
|
||||
if color == "" {
|
||||
return false
|
||||
}
|
||||
match, _ := regexp.MatchString(`^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{8})$`, color)
|
||||
return match
|
||||
}
|
||||
|
||||
// RegisterValidation registers a custom validation function.
|
||||
// Returns an error if registration fails.
|
||||
func RegisterValidation(tag string, fn validator.Func) error {
|
||||
return Validator().RegisterValidation(tag, fn)
|
||||
}
|
||||
|
||||
// MustRegisterValidation registers a custom validation function and panics on error.
|
||||
// Use this during initialization when registration failure should be fatal.
|
||||
func MustRegisterValidation(tag string, fn validator.Func) {
|
||||
if err := RegisterValidation(tag, fn); err != nil {
|
||||
panic(fmt.Sprintf("failed to register validation %q: %v", tag, err))
|
||||
}
|
||||
}
|
||||
99
pkg/logging/context.go
Normal file
99
pkg/logging/context.go
Normal file
@ -0,0 +1,99 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
type contextKey int
|
||||
|
||||
const (
|
||||
loggerKey contextKey = iota
|
||||
requestIDKey
|
||||
userIDKey
|
||||
traceIDKey
|
||||
)
|
||||
|
||||
// NewContext returns a new context with the logger attached.
|
||||
func NewContext(ctx context.Context, logger *Logger) context.Context {
|
||||
return context.WithValue(ctx, loggerKey, logger)
|
||||
}
|
||||
|
||||
// FromContext extracts the logger from the context.
|
||||
// Returns a no-op logger if none is found.
|
||||
func FromContext(ctx context.Context) *Logger {
|
||||
if logger, ok := ctx.Value(loggerKey).(*Logger); ok {
|
||||
return logger
|
||||
}
|
||||
return Nop()
|
||||
}
|
||||
|
||||
// WithRequestID adds a request ID to the context.
|
||||
func WithRequestID(ctx context.Context, requestID string) context.Context {
|
||||
return context.WithValue(ctx, requestIDKey, requestID)
|
||||
}
|
||||
|
||||
// RequestIDFromContext extracts the request ID from the context.
|
||||
func RequestIDFromContext(ctx context.Context) string {
|
||||
if id, ok := ctx.Value(requestIDKey).(string); ok {
|
||||
return id
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// WithUserID adds a user ID to the context.
|
||||
func WithUserID(ctx context.Context, userID string) context.Context {
|
||||
return context.WithValue(ctx, userIDKey, userID)
|
||||
}
|
||||
|
||||
// UserIDFromContext extracts the user ID from the context.
|
||||
func UserIDFromContext(ctx context.Context) string {
|
||||
if id, ok := ctx.Value(userIDKey).(string); ok {
|
||||
return id
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// WithTraceID adds a trace ID to the context.
|
||||
func WithTraceID(ctx context.Context, traceID string) context.Context {
|
||||
return context.WithValue(ctx, traceIDKey, traceID)
|
||||
}
|
||||
|
||||
// TraceIDFromContext extracts the trace ID from the context.
|
||||
func TraceIDFromContext(ctx context.Context) string {
|
||||
if id, ok := ctx.Value(traceIDKey).(string); ok {
|
||||
return id
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ContextAttrs returns slog attributes from context values.
|
||||
func ContextAttrs(ctx context.Context) []slog.Attr {
|
||||
var attrs []slog.Attr
|
||||
|
||||
if id := RequestIDFromContext(ctx); id != "" {
|
||||
attrs = append(attrs, slog.String("request_id", id))
|
||||
}
|
||||
if id := UserIDFromContext(ctx); id != "" {
|
||||
attrs = append(attrs, slog.String("user_id", id))
|
||||
}
|
||||
if id := TraceIDFromContext(ctx); id != "" {
|
||||
attrs = append(attrs, slog.String("trace_id", id))
|
||||
}
|
||||
|
||||
return attrs
|
||||
}
|
||||
|
||||
// LoggerWithContext returns a logger enriched with context attributes.
|
||||
func LoggerWithContext(ctx context.Context, logger *Logger) *Logger {
|
||||
attrs := ContextAttrs(ctx)
|
||||
if len(attrs) == 0 {
|
||||
return logger
|
||||
}
|
||||
|
||||
args := make([]any, 0, len(attrs)*2)
|
||||
for _, attr := range attrs {
|
||||
args = append(args, attr.Key, attr.Value.Any())
|
||||
}
|
||||
return logger.With(args...)
|
||||
}
|
||||
245
pkg/logging/logger.go
Normal file
245
pkg/logging/logger.go
Normal file
@ -0,0 +1,245 @@
|
||||
// Package logging provides slog-based structured logging with context integration.
|
||||
//
|
||||
// This package standardizes logging across all services with:
|
||||
// - Environment-aware formatting (JSON for production, text for development)
|
||||
// - Request-scoped loggers with context propagation
|
||||
// - Convenience methods for common logging patterns
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// // Create a logger based on environment
|
||||
// logger := logging.New(logging.Config{
|
||||
// Level: logging.LevelInfo,
|
||||
// Format: logging.FormatJSON,
|
||||
// Environment: "production",
|
||||
// })
|
||||
//
|
||||
// // Or use convenience constructors
|
||||
// logger := logging.NewDevelopment() // text format, debug level
|
||||
// logger := logging.NewProduction() // JSON format, info level
|
||||
package logging
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Level represents the logging level.
|
||||
type Level int
|
||||
|
||||
const (
|
||||
LevelDebug Level = iota
|
||||
LevelInfo
|
||||
LevelWarn
|
||||
LevelError
|
||||
)
|
||||
|
||||
// String returns the string representation of the level.
|
||||
func (l Level) String() string {
|
||||
switch l {
|
||||
case LevelDebug:
|
||||
return "debug"
|
||||
case LevelInfo:
|
||||
return "info"
|
||||
case LevelWarn:
|
||||
return "warn"
|
||||
case LevelError:
|
||||
return "error"
|
||||
default:
|
||||
return "info"
|
||||
}
|
||||
}
|
||||
|
||||
// ParseLevel parses a string into a Level.
|
||||
// Returns LevelInfo if the string is not recognized.
|
||||
func ParseLevel(s string) Level {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "debug":
|
||||
return LevelDebug
|
||||
case "info":
|
||||
return LevelInfo
|
||||
case "warn", "warning":
|
||||
return LevelWarn
|
||||
case "error":
|
||||
return LevelError
|
||||
default:
|
||||
return LevelInfo
|
||||
}
|
||||
}
|
||||
|
||||
func (l Level) toSlog() slog.Level {
|
||||
switch l {
|
||||
case LevelDebug:
|
||||
return slog.LevelDebug
|
||||
case LevelInfo:
|
||||
return slog.LevelInfo
|
||||
case LevelWarn:
|
||||
return slog.LevelWarn
|
||||
case LevelError:
|
||||
return slog.LevelError
|
||||
default:
|
||||
return slog.LevelInfo
|
||||
}
|
||||
}
|
||||
|
||||
// Format represents the output format.
|
||||
type Format int
|
||||
|
||||
const (
|
||||
FormatJSON Format = iota
|
||||
FormatText
|
||||
)
|
||||
|
||||
// String returns the string representation of the format.
|
||||
func (f Format) String() string {
|
||||
switch f {
|
||||
case FormatJSON:
|
||||
return "json"
|
||||
case FormatText:
|
||||
return "text"
|
||||
default:
|
||||
return "json"
|
||||
}
|
||||
}
|
||||
|
||||
// ParseFormat parses a string into a Format.
|
||||
// Returns FormatJSON if the string is not recognized.
|
||||
func ParseFormat(s string) Format {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "text", "console":
|
||||
return FormatText
|
||||
case "json":
|
||||
return FormatJSON
|
||||
default:
|
||||
return FormatJSON
|
||||
}
|
||||
}
|
||||
|
||||
// Config holds the logger configuration.
|
||||
type Config struct {
|
||||
// Level sets the minimum log level.
|
||||
// Default: LevelInfo
|
||||
Level Level
|
||||
|
||||
// Format sets the output format.
|
||||
// Default: FormatJSON
|
||||
Format Format
|
||||
|
||||
// Output sets the output writer.
|
||||
// Default: os.Stdout
|
||||
Output io.Writer
|
||||
|
||||
// AddSource adds source file and line number to log entries.
|
||||
// Default: false
|
||||
AddSource bool
|
||||
|
||||
// Environment determines default format if not specified.
|
||||
// "development" uses text format, others use JSON.
|
||||
Environment string
|
||||
}
|
||||
|
||||
// Logger wraps slog.Logger with additional convenience methods.
|
||||
type Logger struct {
|
||||
*slog.Logger
|
||||
}
|
||||
|
||||
// New creates a new Logger with the given configuration.
|
||||
func New(cfg Config) *Logger {
|
||||
if cfg.Output == nil {
|
||||
cfg.Output = os.Stdout
|
||||
}
|
||||
|
||||
// Auto-detect format based on environment if not explicitly set
|
||||
format := cfg.Format
|
||||
if cfg.Environment == "development" && format == FormatJSON {
|
||||
format = FormatText
|
||||
}
|
||||
|
||||
opts := &slog.HandlerOptions{
|
||||
Level: cfg.Level.toSlog(),
|
||||
AddSource: cfg.AddSource,
|
||||
}
|
||||
|
||||
var handler slog.Handler
|
||||
switch format {
|
||||
case FormatText:
|
||||
handler = slog.NewTextHandler(cfg.Output, opts)
|
||||
default:
|
||||
handler = slog.NewJSONHandler(cfg.Output, opts)
|
||||
}
|
||||
|
||||
return &Logger{
|
||||
Logger: slog.New(handler),
|
||||
}
|
||||
}
|
||||
|
||||
// NewDevelopment creates a logger configured for development.
|
||||
// Uses text format, debug level, and includes source location.
|
||||
func NewDevelopment() *Logger {
|
||||
return New(Config{
|
||||
Level: LevelDebug,
|
||||
Format: FormatText,
|
||||
AddSource: true,
|
||||
})
|
||||
}
|
||||
|
||||
// NewProduction creates a logger configured for production.
|
||||
// Uses JSON format and info level.
|
||||
func NewProduction() *Logger {
|
||||
return New(Config{
|
||||
Level: LevelInfo,
|
||||
Format: FormatJSON,
|
||||
})
|
||||
}
|
||||
|
||||
// With returns a new Logger with the given attributes.
|
||||
func (l *Logger) With(args ...any) *Logger {
|
||||
return &Logger{
|
||||
Logger: l.Logger.With(args...),
|
||||
}
|
||||
}
|
||||
|
||||
// WithGroup returns a new Logger with the given group name.
|
||||
func (l *Logger) WithGroup(name string) *Logger {
|
||||
return &Logger{
|
||||
Logger: l.Logger.WithGroup(name),
|
||||
}
|
||||
}
|
||||
|
||||
// WithError returns a new Logger with the error attribute.
|
||||
func (l *Logger) WithError(err error) *Logger {
|
||||
if err == nil {
|
||||
return l
|
||||
}
|
||||
return l.With("error", err.Error())
|
||||
}
|
||||
|
||||
// WithComponent returns a new Logger with the component attribute.
|
||||
func (l *Logger) WithComponent(name string) *Logger {
|
||||
return l.With("component", name)
|
||||
}
|
||||
|
||||
// WithService returns a new Logger with the service attribute.
|
||||
func (l *Logger) WithService(name string) *Logger {
|
||||
return l.With("service", name)
|
||||
}
|
||||
|
||||
// Nop returns a logger that discards all output.
|
||||
func Nop() *Logger {
|
||||
return New(Config{
|
||||
Output: io.Discard,
|
||||
Level: LevelError,
|
||||
})
|
||||
}
|
||||
|
||||
// Default returns the default logger configured for the current environment.
|
||||
// Uses APP_ENVIRONMENT env var to determine format.
|
||||
func Default() *Logger {
|
||||
env := os.Getenv("APP_ENVIRONMENT")
|
||||
if env == "development" || env == "" {
|
||||
return NewDevelopment()
|
||||
}
|
||||
return NewProduction()
|
||||
}
|
||||
37
pkg/logging/worker.go
Normal file
37
pkg/logging/worker.go
Normal file
@ -0,0 +1,37 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// WorkerContext creates a context with trace and request IDs for background work.
|
||||
// Workers and cron jobs don't receive HTTP requests, so they need to generate
|
||||
// their own correlation IDs for log tracing.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// func (w *Worker) ProcessJob(ctx context.Context, job Job) error {
|
||||
// ctx = logging.WorkerContext(ctx, "order-processor")
|
||||
// logger := logging.FromContext(ctx)
|
||||
// logger.Info("processing job", "job_id", job.ID)
|
||||
// // ... all downstream logs include trace_id and request_id
|
||||
// }
|
||||
func WorkerContext(ctx context.Context, component string) context.Context {
|
||||
traceID := uuid.New().String()
|
||||
requestID := uuid.New().String()
|
||||
|
||||
ctx = WithTraceID(ctx, traceID)
|
||||
ctx = WithRequestID(ctx, requestID)
|
||||
|
||||
// If there's a logger in context, enrich it
|
||||
logger := FromContext(ctx)
|
||||
enriched := logger.With(
|
||||
"trace_id", traceID,
|
||||
"request_id", requestID,
|
||||
"component", component,
|
||||
)
|
||||
|
||||
return NewContext(ctx, enriched)
|
||||
}
|
||||
98
pkg/middleware/cors.go
Normal file
98
pkg/middleware/cors.go
Normal file
@ -0,0 +1,98 @@
|
||||
// Package middleware provides HTTP middleware for services.
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/cors"
|
||||
)
|
||||
|
||||
// CORSConfig configures CORS behavior for HTTP services.
|
||||
// Used to control cross-origin requests from browsers.
|
||||
type CORSConfig struct {
|
||||
// AllowedOrigins lists domains allowed to make cross-origin requests.
|
||||
// Use []string{"*"} for open access (dev/staging), specific domains for production.
|
||||
// Example: []string{"https://app.example.com", "https://admin.example.com"}
|
||||
AllowedOrigins []string
|
||||
|
||||
// AllowedMethods lists HTTP methods that can be used in cross-origin requests.
|
||||
// Example: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}
|
||||
AllowedMethods []string
|
||||
|
||||
// AllowedHeaders lists request headers that can be included in cross-origin requests.
|
||||
// Must include headers used by services (Authorization, X-Request-ID, etc.)
|
||||
AllowedHeaders []string
|
||||
|
||||
// ExposedHeaders lists response headers that the browser can expose to JavaScript.
|
||||
// Useful for pagination headers, custom metadata, etc.
|
||||
ExposedHeaders []string
|
||||
|
||||
// AllowCredentials controls whether browsers can send credentials (cookies, auth headers)
|
||||
// in cross-origin requests. MUST be false when AllowedOrigins is "*".
|
||||
AllowCredentials bool
|
||||
|
||||
// MaxAge specifies how long (in seconds) browsers can cache preflight responses.
|
||||
// Reduces OPTIONS requests for the same resource.
|
||||
MaxAge int
|
||||
}
|
||||
|
||||
// DefaultCORSConfig returns sensible defaults for services.
|
||||
// Designed for local development and staging environments.
|
||||
//
|
||||
// Defaults:
|
||||
// - AllowedOrigins: ["*"] (override in production to specific domains)
|
||||
// - AllowedMethods: GET, POST, PUT, PATCH, DELETE, OPTIONS
|
||||
// - AllowedHeaders: ["*"] (allows any header - simplest for development)
|
||||
// - ExposedHeaders: Link (for pagination)
|
||||
// - AllowCredentials: false (required when AllowedOrigins is "*")
|
||||
// - MaxAge: 300 seconds (5 minutes)
|
||||
//
|
||||
// Production services should override AllowedOrigins with specific domains,
|
||||
// set AllowCredentials: true if needed, and optionally restrict AllowedHeaders.
|
||||
func DefaultCORSConfig() CORSConfig {
|
||||
return CORSConfig{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"*"},
|
||||
ExposedHeaders: []string{"Link"},
|
||||
AllowCredentials: false,
|
||||
MaxAge: 300,
|
||||
}
|
||||
}
|
||||
|
||||
// CORS returns middleware that handles CORS headers for cross-origin requests.
|
||||
// Uses github.com/go-chi/cors under the hood for RFC compliance.
|
||||
//
|
||||
// The middleware:
|
||||
// - Handles preflight OPTIONS requests automatically
|
||||
// - Sets Access-Control-* headers on actual requests
|
||||
// - Validates origins against AllowedOrigins
|
||||
// - Caches preflight responses according to MaxAge
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// r := chi.NewRouter()
|
||||
// r.Use(middleware.CORS(middleware.DefaultCORSConfig()))
|
||||
//
|
||||
// Production example:
|
||||
//
|
||||
// cfg := middleware.CORSConfig{
|
||||
// AllowedOrigins: []string{"https://app.example.com"},
|
||||
// AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"},
|
||||
// AllowedHeaders: []string{"Content-Type", "Authorization"},
|
||||
// AllowCredentials: true,
|
||||
// MaxAge: 600,
|
||||
// }
|
||||
// r.Use(middleware.CORS(cfg))
|
||||
func CORS(cfg CORSConfig) func(http.Handler) http.Handler {
|
||||
c := cors.New(cors.Options{
|
||||
AllowedOrigins: cfg.AllowedOrigins,
|
||||
AllowedMethods: cfg.AllowedMethods,
|
||||
AllowedHeaders: cfg.AllowedHeaders,
|
||||
ExposedHeaders: cfg.ExposedHeaders,
|
||||
AllowCredentials: cfg.AllowCredentials,
|
||||
MaxAge: cfg.MaxAge,
|
||||
})
|
||||
|
||||
return c.Handler
|
||||
}
|
||||
105
pkg/middleware/logger.go
Normal file
105
pkg/middleware/logger.go
Normal file
@ -0,0 +1,105 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.threesix.ai/jordan/testgo6/pkg/httpcontext"
|
||||
"git.threesix.ai/jordan/testgo6/pkg/logging"
|
||||
)
|
||||
|
||||
// responseWriter wraps http.ResponseWriter to capture status code.
|
||||
type responseWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
wroteHeader bool
|
||||
bytesWritten int
|
||||
}
|
||||
|
||||
func (rw *responseWriter) WriteHeader(code int) {
|
||||
if rw.wroteHeader {
|
||||
return
|
||||
}
|
||||
rw.status = code
|
||||
rw.wroteHeader = true
|
||||
rw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (rw *responseWriter) Write(b []byte) (int, error) {
|
||||
if !rw.wroteHeader {
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}
|
||||
n, err := rw.ResponseWriter.Write(b)
|
||||
rw.bytesWritten += n
|
||||
return n, err
|
||||
}
|
||||
|
||||
// RequestLogger returns a middleware that logs HTTP requests using slog.
|
||||
// It logs request completion with status code, duration, and bytes written.
|
||||
// Log level is determined by response status (error for 5xx, warn for 4xx, info otherwise).
|
||||
//
|
||||
// IMPORTANT: This middleware expects the RequestID and Tracing middleware to have
|
||||
// run first to set request_id and trace_id in context.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// r.Use(middleware.RequestID())
|
||||
// r.Use(middleware.Tracing())
|
||||
// r.Use(middleware.RequestLogger(logger))
|
||||
// r.Use(middleware.Recoverer(logger))
|
||||
func RequestLogger(logger *logging.Logger) func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
|
||||
// Wrap response writer to capture status code and bytes
|
||||
wrapped := &responseWriter{
|
||||
ResponseWriter: w,
|
||||
status: http.StatusOK,
|
||||
}
|
||||
|
||||
// Get request ID and trace ID from context (set by middleware)
|
||||
requestID, _ := httpcontext.GetRequestID(r.Context())
|
||||
traceID, _ := httpcontext.GetTraceID(r.Context())
|
||||
|
||||
// Create request-scoped logger
|
||||
reqLogger := logger.With(
|
||||
"request_id", requestID,
|
||||
"trace_id", traceID,
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"remote_addr", r.RemoteAddr,
|
||||
)
|
||||
|
||||
// Store logger in context for handlers to use
|
||||
ctx := logging.NewContext(r.Context(), reqLogger)
|
||||
|
||||
// Log request start at debug level
|
||||
reqLogger.Debug("request started",
|
||||
"user_agent", r.UserAgent(),
|
||||
)
|
||||
|
||||
// Call next handler with enriched context
|
||||
next.ServeHTTP(wrapped, r.WithContext(ctx))
|
||||
|
||||
// Calculate duration
|
||||
duration := time.Since(start)
|
||||
|
||||
// Determine log level based on status and log completion
|
||||
attrs := []any{
|
||||
"status", wrapped.status,
|
||||
"duration_ms", duration.Milliseconds(),
|
||||
"bytes", wrapped.bytesWritten,
|
||||
}
|
||||
|
||||
switch {
|
||||
case wrapped.status >= 500:
|
||||
reqLogger.Error("request completed", attrs...)
|
||||
case wrapped.status >= 400:
|
||||
reqLogger.Warn("request completed", attrs...)
|
||||
default:
|
||||
reqLogger.Info("request completed", attrs...)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
54
pkg/middleware/recovery.go
Normal file
54
pkg/middleware/recovery.go
Normal file
@ -0,0 +1,54 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
|
||||
"git.threesix.ai/jordan/testgo6/pkg/httpcontext"
|
||||
"git.threesix.ai/jordan/testgo6/pkg/logging"
|
||||
)
|
||||
|
||||
// Recoverer is middleware that recovers from panics, logs the error with stack trace
|
||||
// using slog, and returns a 500 Internal Server Error response.
|
||||
//
|
||||
// The middleware captures:
|
||||
// - request_id: For request correlation
|
||||
// - method, path, remote_addr: Request context
|
||||
// - panic: The recovered panic value
|
||||
// - stack_trace: Full stack trace for debugging
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// r.Use(middleware.RequestID())
|
||||
// r.Use(middleware.RequestLogger(logger))
|
||||
// r.Use(middleware.Recoverer(logger))
|
||||
func Recoverer(logger *logging.Logger) func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if rvr := recover(); rvr != nil {
|
||||
// Capture stack trace for debugging
|
||||
stack := debug.Stack()
|
||||
|
||||
// Get request ID from context
|
||||
requestID, _ := httpcontext.GetRequestID(r.Context())
|
||||
|
||||
// Log panic with full context and stack trace
|
||||
logger.Error("panic recovered",
|
||||
"request_id", requestID,
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"remote_addr", r.RemoteAddr,
|
||||
"panic", rvr,
|
||||
"stack_trace", string(stack),
|
||||
)
|
||||
|
||||
// Return 500 Internal Server Error to client
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
}()
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
75
pkg/middleware/request_id.go
Normal file
75
pkg/middleware/request_id.go
Normal file
@ -0,0 +1,75 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"git.threesix.ai/jordan/testgo6/pkg/httpcontext"
|
||||
"git.threesix.ai/jordan/testgo6/pkg/logging"
|
||||
)
|
||||
|
||||
// RequestIDHeader is the header name for request IDs.
|
||||
const RequestIDHeader = "X-Request-ID"
|
||||
|
||||
// RequestID returns middleware that generates/extracts request IDs.
|
||||
//
|
||||
// If X-Request-ID header is present in the incoming request, uses that value.
|
||||
// This allows clients to set their own request IDs for tracking purposes.
|
||||
// Otherwise generates a new UUID.
|
||||
//
|
||||
// The request ID is stored in context using httpcontext.SetRequestID and
|
||||
// also set in the X-Request-ID response header for client correlation.
|
||||
//
|
||||
// This middleware is idempotent - if a request ID is already present in the
|
||||
// context (from a previous middleware), it will not be overwritten.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// r.Use(middleware.RequestID())
|
||||
// r.Use(middleware.RequestLogger(logger))
|
||||
//
|
||||
// In handlers, retrieve the request ID:
|
||||
//
|
||||
// requestID, ok := httpcontext.GetRequestID(r.Context())
|
||||
func RequestID() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Check if request ID already in context (idempotent)
|
||||
if existingID, ok := httpcontext.GetRequestID(r.Context()); ok && existingID != "" {
|
||||
// Already set, set response header and continue
|
||||
w.Header().Set(RequestIDHeader, existingID)
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Try to get request ID from incoming header
|
||||
requestID := r.Header.Get(RequestIDHeader)
|
||||
|
||||
// If not present, generate a new UUID
|
||||
if requestID == "" {
|
||||
requestID = uuid.New().String()
|
||||
}
|
||||
|
||||
// Store in context (both httpcontext and logging)
|
||||
ctx := httpcontext.SetRequestID(r.Context(), requestID)
|
||||
ctx = logging.WithRequestID(ctx, requestID)
|
||||
|
||||
// Set response header for client correlation
|
||||
w.Header().Set(RequestIDHeader, requestID)
|
||||
|
||||
// Continue with request ID in context
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// GetRequestID returns the request ID from context.
|
||||
// This is a convenience wrapper around httpcontext.GetRequestID.
|
||||
//
|
||||
// Returns the request ID string if present, empty string if not found.
|
||||
func GetRequestID(ctx context.Context) string {
|
||||
requestID, _ := httpcontext.GetRequestID(ctx)
|
||||
return requestID
|
||||
}
|
||||
74
pkg/middleware/tracing.go
Normal file
74
pkg/middleware/tracing.go
Normal file
@ -0,0 +1,74 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"git.threesix.ai/jordan/testgo6/pkg/httpcontext"
|
||||
"git.threesix.ai/jordan/testgo6/pkg/logging"
|
||||
)
|
||||
|
||||
// Trace ID headers in priority order.
|
||||
const (
|
||||
TraceIDHeader = "X-Trace-ID"
|
||||
CloudTraceHeader = "X-Cloud-Trace-Context"
|
||||
)
|
||||
|
||||
// Tracing returns middleware that extracts or generates trace IDs.
|
||||
//
|
||||
// Checks headers in order:
|
||||
// 1. X-Trace-ID - direct trace ID
|
||||
// 2. X-Cloud-Trace-Context - GCP format "TRACE_ID/SPAN_ID;o=OPTIONS"
|
||||
// 3. Generates a new UUID if none found
|
||||
//
|
||||
// The trace ID is stored in context via httpcontext.SetTraceID and
|
||||
// logging.WithTraceID, and set in the X-Trace-ID response header.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// r.Use(middleware.RequestID())
|
||||
// r.Use(middleware.Tracing())
|
||||
// r.Use(middleware.RequestLogger(logger))
|
||||
func Tracing() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
traceID := extractTraceID(r)
|
||||
|
||||
if traceID == "" {
|
||||
traceID = uuid.New().String()
|
||||
}
|
||||
|
||||
// Store in context
|
||||
ctx := httpcontext.SetTraceID(r.Context(), traceID)
|
||||
ctx = logging.WithTraceID(ctx, traceID)
|
||||
|
||||
// Set response header
|
||||
w.Header().Set(TraceIDHeader, traceID)
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// extractTraceID tries to extract a trace ID from known headers.
|
||||
func extractTraceID(r *http.Request) string {
|
||||
// X-Trace-ID takes priority
|
||||
if traceID := r.Header.Get(TraceIDHeader); traceID != "" {
|
||||
return traceID
|
||||
}
|
||||
|
||||
// X-Cloud-Trace-Context format: "TRACE_ID/SPAN_ID;o=OPTIONS"
|
||||
if cloudTrace := r.Header.Get(CloudTraceHeader); cloudTrace != "" {
|
||||
if idx := strings.IndexByte(cloudTrace, '/'); idx > 0 {
|
||||
return cloudTrace[:idx]
|
||||
}
|
||||
if idx := strings.IndexByte(cloudTrace, ';'); idx > 0 {
|
||||
return cloudTrace[:idx]
|
||||
}
|
||||
return cloudTrace
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
@ -0,0 +1,3 @@
|
||||
packages:
|
||||
- "packages/*"
|
||||
- "apps/*"
|
||||
19
scripts/dev.sh
Normal file
19
scripts/dev.sh
Normal file
@ -0,0 +1,19 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "Starting development environment for testgo6..."
|
||||
|
||||
# Start docker services
|
||||
if [ -f "docker-compose.yml" ]; then
|
||||
echo "Starting Docker services..."
|
||||
docker-compose up -d
|
||||
fi
|
||||
|
||||
# Start services with overmind if available
|
||||
if command -v overmind &> /dev/null && [ -f "Procfile" ]; then
|
||||
echo "Starting services with overmind..."
|
||||
overmind start
|
||||
else
|
||||
echo "Install overmind for process management: brew install overmind"
|
||||
echo "Or run services manually from Procfile"
|
||||
fi
|
||||
138
scripts/discover.sh
Normal file
138
scripts/discover.sh
Normal file
@ -0,0 +1,138 @@
|
||||
#!/bin/bash
|
||||
# Discover all components in the monorepo
|
||||
# Usage: ./scripts/discover.sh [--json]
|
||||
|
||||
set -e
|
||||
|
||||
JSON_OUTPUT=false
|
||||
if [ "$1" = "--json" ]; then
|
||||
JSON_OUTPUT=true
|
||||
fi
|
||||
|
||||
# Colors (only for non-JSON output)
|
||||
GREEN='\033[0;32m'
|
||||
BLUE='\033[0;34m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Component discovery
|
||||
declare -a COMPONENTS
|
||||
|
||||
discover_components() {
|
||||
for type in services workers apps cli packages; do
|
||||
if [ -d "$type" ]; then
|
||||
for dir in "$type"/*/; do
|
||||
if [ -d "$dir" ] && [ "$dir" != "$type/*/" ]; then
|
||||
name=$(basename "$dir")
|
||||
path="$type/$name"
|
||||
port=""
|
||||
stack=""
|
||||
|
||||
# Detect stack type
|
||||
if [ -f "$dir/go.mod" ]; then
|
||||
stack="go"
|
||||
elif [ -f "$dir/package.json" ]; then
|
||||
stack="node"
|
||||
# Try to detect framework
|
||||
if grep -q "astro" "$dir/package.json" 2>/dev/null; then
|
||||
stack="astro"
|
||||
elif grep -q "react" "$dir/package.json" 2>/dev/null; then
|
||||
stack="react"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Try to get port from component.yaml
|
||||
if [ -f "$dir/component.yaml" ]; then
|
||||
port=$(grep -E '^port:' "$dir/component.yaml" 2>/dev/null | awk '{print $2}' || echo "")
|
||||
fi
|
||||
|
||||
# Try to get port from .env.example
|
||||
if [ -z "$port" ] && [ -f "$dir/.env.example" ]; then
|
||||
port=$(grep -E '^PORT=' "$dir/.env.example" 2>/dev/null | cut -d'=' -f2 || echo "")
|
||||
fi
|
||||
|
||||
COMPONENTS+=("$type|$name|$path|$port|$stack")
|
||||
fi
|
||||
done
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
output_json() {
|
||||
echo "{"
|
||||
echo " \"project\": \"testgo6\","
|
||||
echo " \"components\": ["
|
||||
|
||||
local first=true
|
||||
for comp in "${COMPONENTS[@]}"; do
|
||||
IFS='|' read -r type name path port stack <<< "$comp"
|
||||
|
||||
if [ "$first" = false ]; then
|
||||
echo ","
|
||||
fi
|
||||
first=false
|
||||
|
||||
echo -n " {\"type\": \"$type\", \"name\": \"$name\", \"path\": \"$path\""
|
||||
if [ -n "$port" ]; then
|
||||
echo -n ", \"port\": $port"
|
||||
fi
|
||||
if [ -n "$stack" ]; then
|
||||
echo -n ", \"stack\": \"$stack\""
|
||||
fi
|
||||
echo -n "}"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo " ]"
|
||||
echo "}"
|
||||
}
|
||||
|
||||
output_pretty() {
|
||||
echo -e "${BLUE}Components in testgo6${NC}"
|
||||
echo ""
|
||||
|
||||
if [ ${#COMPONENTS[@]} -eq 0 ]; then
|
||||
echo -e "${YELLOW}No components found. Add one with:${NC}"
|
||||
echo " curl -X POST .../projects/testgo6/components -d '{\"type\":\"service\",\"name\":\"my-api\"}'"
|
||||
return
|
||||
fi
|
||||
|
||||
# Group by type
|
||||
for type in services workers apps cli packages; do
|
||||
local has_type=false
|
||||
for comp in "${COMPONENTS[@]}"; do
|
||||
IFS='|' read -r ctype name path port stack <<< "$comp"
|
||||
if [ "$ctype" = "$type" ]; then
|
||||
if [ "$has_type" = false ]; then
|
||||
echo -e "${GREEN}$type/${NC}"
|
||||
has_type=true
|
||||
fi
|
||||
local info=" $name"
|
||||
if [ -n "$stack" ]; then
|
||||
info="$info ${YELLOW}[$stack]${NC}"
|
||||
fi
|
||||
if [ -n "$port" ]; then
|
||||
info="$info :$port"
|
||||
fi
|
||||
echo -e "$info"
|
||||
fi
|
||||
done
|
||||
if [ "$has_type" = true ]; then
|
||||
echo ""
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Total: ${#COMPONENTS[@]} component(s)"
|
||||
echo ""
|
||||
echo "Run './scripts/install.sh' to install all dependencies"
|
||||
echo "Run './scripts/dev.sh' to start local development"
|
||||
}
|
||||
|
||||
# Main
|
||||
discover_components
|
||||
|
||||
if [ "$JSON_OUTPUT" = true ]; then
|
||||
output_json
|
||||
else
|
||||
output_pretty
|
||||
fi
|
||||
28
scripts/install.sh
Normal file
28
scripts/install.sh
Normal file
@ -0,0 +1,28 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "Installing dependencies for testgo6..."
|
||||
echo ""
|
||||
|
||||
# Check for pnpm, install if not present
|
||||
if ! command -v pnpm &> /dev/null; then
|
||||
echo "Installing pnpm..."
|
||||
npm install -g pnpm
|
||||
fi
|
||||
|
||||
# Install all Node dependencies at workspace root (pnpm handles workspace:* refs)
|
||||
if [ -f "pnpm-lock.yaml" ] || [ -f "package.json" ]; then
|
||||
echo "Installing Node dependencies with pnpm..."
|
||||
pnpm install
|
||||
fi
|
||||
|
||||
# Install Go dependencies
|
||||
for dir in services/*/ workers/*/ cli/*/; do
|
||||
if [ -f "${dir}go.mod" ]; then
|
||||
echo "Installing Go deps for $(basename $dir)..."
|
||||
(cd "$dir" && go mod download)
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "All dependencies installed!"
|
||||
26
scripts/quality.sh
Normal file
26
scripts/quality.sh
Normal file
@ -0,0 +1,26 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "Running quality checks for testgo6..."
|
||||
echo ""
|
||||
|
||||
# Run golangci-lint for Go code
|
||||
if command -v golangci-lint &> /dev/null; then
|
||||
echo "Running golangci-lint..."
|
||||
golangci-lint run ./services/... ./workers/... ./cli/... ./pkg/... 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Run tests
|
||||
echo "Running Go tests..."
|
||||
go test ./... 2>/dev/null || true
|
||||
|
||||
# Run ESLint for apps
|
||||
for dir in apps/*/; do
|
||||
if [ -f "${dir}package.json" ]; then
|
||||
echo "Running lint for $(basename $dir)..."
|
||||
(cd "$dir" && npm run lint 2>/dev/null) || true
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "Quality checks complete!"
|
||||
44
scripts/setup-hooks.sh
Normal file
44
scripts/setup-hooks.sh
Normal file
@ -0,0 +1,44 @@
|
||||
#!/bin/bash
|
||||
# Set up git hooks for testgo6
|
||||
# Run this after cloning the repository
|
||||
|
||||
set -e
|
||||
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
echo "Setting up git hooks for testgo6..."
|
||||
|
||||
# Ensure we're in a git repo
|
||||
if [ ! -d "$PROJECT_ROOT/.git" ]; then
|
||||
echo "Error: Not a git repository"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure .githooks directory exists
|
||||
if [ ! -d "$PROJECT_ROOT/.githooks" ]; then
|
||||
echo "Error: .githooks directory not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Configure git to use .githooks
|
||||
git config core.hooksPath .githooks
|
||||
|
||||
# Make hooks executable
|
||||
chmod +x "$PROJECT_ROOT/.githooks/"*
|
||||
|
||||
echo -e "${GREEN}Git hooks installed successfully!${NC}"
|
||||
echo ""
|
||||
echo "Installed hooks:"
|
||||
echo " - pre-commit: Code quality checks (formatting, linting)"
|
||||
echo " - commit-msg: Conventional commit validation"
|
||||
echo ""
|
||||
echo -e "${YELLOW}Note:${NC} You may need to install these tools for full functionality:"
|
||||
echo " - goimports: go install golang.org/x/tools/cmd/goimports@latest"
|
||||
echo " - golangci-lint: brew install golangci-lint"
|
||||
echo " - prettier: npm install -g prettier (or use per-project)"
|
||||
echo " - eslint: npm install -g eslint (or use per-project)"
|
||||
1
services/.gitkeep
Normal file
1
services/.gitkeep
Normal file
@ -0,0 +1 @@
|
||||
# Go API services go here
|
||||
1
workers/.gitkeep
Normal file
1
workers/.gitkeep
Normal file
@ -0,0 +1 @@
|
||||
# Background workers go here
|
||||
Loading…
Reference in New Issue
Block a user