Initialize project from skeleton template
This commit is contained in:
commit
e57cfe1f57
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 composed5 - endpoint structure, error handling, request/response patterns
|
||||||
|
color: purple
|
||||||
|
---
|
||||||
|
|
||||||
|
# API Designer
|
||||||
|
|
||||||
|
You design consistent, predictable REST APIs for composed5. 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 composed5 - PostgreSQL, migrations, indexing
|
||||||
|
color: yellow
|
||||||
|
---
|
||||||
|
|
||||||
|
# Database Architect
|
||||||
|
|
||||||
|
You design database schemas and optimize queries for composed5. 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 composed5 - concurrency, error handling, Chi router, hexagonal architecture
|
||||||
|
color: cyan
|
||||||
|
---
|
||||||
|
|
||||||
|
# Go Specialist
|
||||||
|
|
||||||
|
You are a Go expert for the composed5 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 `github.com/jordan/composed5/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 composed5 - ports, adapters, domain purity, dependency direction
|
||||||
|
color: blue
|
||||||
|
---
|
||||||
|
|
||||||
|
# Hexagonal Architect
|
||||||
|
|
||||||
|
You enforce clean hexagonal architecture across the composed5 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 composed5 - find code, explain patterns, guide developers
|
||||||
|
color: white
|
||||||
|
---
|
||||||
|
|
||||||
|
# Librarian
|
||||||
|
|
||||||
|
You are the knowledge navigator for composed5. 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 composed5
|
||||||
|
color: green
|
||||||
|
---
|
||||||
|
|
||||||
|
# Monorepo Architect
|
||||||
|
|
||||||
|
You maintain the structural integrity of the composed5 monorepo. Shared code stays shared. Components stay independent. The build stays fast.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
composed5/
|
||||||
|
├── 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 `github.com/jordan/composed5/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 composed5 - phases, tasks, dependencies, incremental delivery
|
||||||
|
color: magenta
|
||||||
|
---
|
||||||
|
|
||||||
|
# Planner
|
||||||
|
|
||||||
|
You break down features into implementable milestones for composed5. 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 composed5 - authentication, authorization, input validation, secret management
|
||||||
|
color: red
|
||||||
|
---
|
||||||
|
|
||||||
|
# Security Architect
|
||||||
|
|
||||||
|
You enforce security best practices across composed5. 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 composed5 - table-driven tests, integration tests, test architecture
|
||||||
|
color: orange
|
||||||
|
---
|
||||||
|
|
||||||
|
# Testing Strategist
|
||||||
|
|
||||||
|
You design and implement test strategies for composed5. 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 composed5 - job queues, tick-based processing, retry logic, graceful shutdown
|
||||||
|
color: orange
|
||||||
|
---
|
||||||
|
|
||||||
|
# Worker Specialist
|
||||||
|
|
||||||
|
You design and implement background workers for composed5. 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 composed5
|
||||||
|
|
||||||
|
## 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/composed5/deploy \
|
||||||
|
-H "X-API-Key: $RDEV_API_KEY"
|
||||||
|
|
||||||
|
# Deploy a single component
|
||||||
|
curl -X POST $RDEV_API_URL/projects/composed5/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 composed5 - structured logging, trace propagation, error handling, frontend/backend consistency.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Logging Standards
|
||||||
|
|
||||||
|
## Identity
|
||||||
|
|
||||||
|
You enforce consistent, actionable logging across all services and apps in composed5. 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 + @composed5/logger)
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createLogger, installGlobalHandlers } from '@composed5/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: github.com/jordan/composed5
|
||||||
|
|
||||||
|
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 composed5
|
||||||
|
# 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 composed5"
|
||||||
|
- kubectl get deployments -n projects -l app=composed5 --no-headers || true
|
||||||
|
when:
|
||||||
|
branch: main
|
||||||
|
event: push
|
||||||
49
CLAUDE.md
Normal file
49
CLAUDE.md
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
# composed5
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
```
|
||||||
|
composed5/
|
||||||
|
├── 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 (@composed5/*)
|
||||||
|
├── pkg/ # Shared Go packages (github.com/jordan/composed5/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 @@
|
|||||||
|
# composed5
|
||||||
|
|
||||||
|
Composable app E2E test
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repo
|
||||||
|
git clone https://git.threesix.ai/jordan/composed5.git
|
||||||
|
cd composed5
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
./scripts/install.sh
|
||||||
|
|
||||||
|
# Start local development
|
||||||
|
./scripts/dev.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
composed5/
|
||||||
|
├── 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/composed5/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/composed5/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: composed5
|
||||||
|
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": "composed5",
|
||||||
|
"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": "@composed5/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"
|
||||||
|
|
||||||
|
"github.com/jordan/composed5/pkg/app"
|
||||||
|
"github.com/jordan/composed5/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 `github.com/jordan/composed5/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"
|
||||||
|
|
||||||
|
"github.com/jordan/composed5/pkg/config"
|
||||||
|
"github.com/jordan/composed5/pkg/httpresponse"
|
||||||
|
"github.com/jordan/composed5/pkg/logging"
|
||||||
|
"github.com/jordan/composed5/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 github.com/jordan/composed5/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"
|
||||||
|
|
||||||
|
"github.com/jordan/composed5/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"
|
||||||
|
|
||||||
|
"github.com/jordan/composed5/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"
|
||||||
|
|
||||||
|
"github.com/jordan/composed5/pkg/httpcontext"
|
||||||
|
"github.com/jordan/composed5/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"
|
||||||
|
|
||||||
|
"github.com/jordan/composed5/pkg/httpcontext"
|
||||||
|
"github.com/jordan/composed5/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"
|
||||||
|
|
||||||
|
"github.com/jordan/composed5/pkg/httpcontext"
|
||||||
|
"github.com/jordan/composed5/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"
|
||||||
|
|
||||||
|
"github.com/jordan/composed5/pkg/httpcontext"
|
||||||
|
"github.com/jordan/composed5/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 composed5..."
|
||||||
|
|
||||||
|
# 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\": \"composed5\","
|
||||||
|
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 composed5${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [ ${#COMPONENTS[@]} -eq 0 ]; then
|
||||||
|
echo -e "${YELLOW}No components found. Add one with:${NC}"
|
||||||
|
echo " curl -X POST .../projects/composed5/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 composed5..."
|
||||||
|
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 composed5..."
|
||||||
|
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 composed5
|
||||||
|
# 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 composed5..."
|
||||||
|
|
||||||
|
# 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