feat: complete template upgrade - chassis framework, UI library, auth, app-nextjs, OpenAPI, and cookbook

Weeks 1-7 of the template upgrade plan:
- pkg/api: typed HTTPError with sentinels, Wrap/WrapMiddleware, Bind, health probes, OpenAPI schema/param builders
- skeleton/packages: ui (design tokens, components), layout (DashboardShell), auth (AuthProvider, ProtectedRoute), api-client
- skeleton/pkg: httperror, app/handler, app/bind, app/health, auth (JWT/API key middleware)
- components/app-nextjs: Next.js 14 App Router template with dashboard, server actions, auth
- cookbooks/feature-development.md with test and validation scripts
- Handler tests for components, project management, and woodpecker webhook
- 3 rounds of code review fixes applied

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jordan 2026-02-02 00:46:51 -07:00
parent c280a92012
commit 62460bf098
94 changed files with 12592 additions and 128 deletions

View File

@ -91,22 +91,14 @@ func main() {
// Create adapters (dependency injection) // Create adapters (dependency injection)
namespace := envutil.GetEnv("K8S_NAMESPACE", "rdev") namespace := envutil.GetEnv("K8S_NAMESPACE", "rdev")
// Initialize K8s client for dynamic project discovery // Initialize K8s client (falls back gracefully if unavailable)
// Falls back gracefully if K8s is unavailable (e.g., local development)
k8sClient := kubernetes.NewClientOrNil(kubernetes.ClientConfig{ k8sClient := kubernetes.NewClientOrNil(kubernetes.ClientConfig{
Namespace: namespace, Namespace: namespace,
Kubeconfig: os.Getenv("KUBECONFIG"), Kubeconfig: os.Getenv("KUBECONFIG"),
}) })
if k8sClient != nil {
logger.Info("k8s client initialized, dynamic project discovery enabled")
} else {
logger.Warn("k8s client unavailable, using hardcoded fallback projects")
}
projectRepo := kubernetes.NewProjectRepositoryWithClient(namespace, k8sClient, logger) projectRepo := kubernetes.NewProjectRepositoryWithClient(namespace, k8sClient, logger)
k8sExecutor := kubernetes.NewExecutor(namespace) k8sExecutor := kubernetes.NewExecutor(namespace)
streamPub := memory.NewStreamPublisher() streamPub := memory.NewStreamPublisher()
if k8sClient != nil { if k8sClient != nil {
if err := projectRepo.StartWatching(context.Background()); err != nil { if err := projectRepo.StartWatching(context.Background()); err != nil {
logger.Warn("failed to start project watcher", "error", err) logger.Warn("failed to start project watcher", "error", err)
@ -139,15 +131,11 @@ func main() {
giteaClient, err = gitea.NewClient(infraCfg.GiteaURL, infraCfg.GiteaToken, infraCfg.GiteaDefaultOrg) giteaClient, err = gitea.NewClient(infraCfg.GiteaURL, infraCfg.GiteaToken, infraCfg.GiteaDefaultOrg)
if err != nil { if err != nil {
logger.Warn("failed to initialize gitea client", "error", err) logger.Warn("failed to initialize gitea client", "error", err)
} else {
logger.Info("gitea client initialized", "url", infraCfg.GiteaURL, "org", infraCfg.GiteaDefaultOrg)
} }
} }
var dnsClient *cloudflare.Client var dnsClient *cloudflare.Client
if infraCfg.CloudflareToken != "" && infraCfg.CloudflareZoneID != "" { if infraCfg.CloudflareToken != "" && infraCfg.CloudflareZoneID != "" {
dnsClient = cloudflare.NewClient(infraCfg.CloudflareToken, infraCfg.CloudflareZoneID, infraCfg.DefaultDomain) dnsClient = cloudflare.NewClient(infraCfg.CloudflareToken, infraCfg.CloudflareZoneID, infraCfg.DefaultDomain)
logger.Info("cloudflare DNS client initialized", "domain", infraCfg.DefaultDomain)
} }
var deployerAdapter *deployer.Deployer var deployerAdapter *deployer.Deployer
@ -159,30 +147,18 @@ func main() {
DefaultDomain: infraCfg.DefaultDomain, DefaultDomain: infraCfg.DefaultDomain,
DefaultReplicas: 1, DefaultReplicas: 1,
}) })
logger.Info("deployer initialized", "namespace", infraCfg.DeployNamespace)
} }
var woodpeckerClient *woodpecker.Client var woodpeckerClient *woodpecker.Client
if infraCfg.WoodpeckerURL != "" && infraCfg.WoodpeckerAPIToken != "" { if infraCfg.WoodpeckerURL != "" && infraCfg.WoodpeckerAPIToken != "" {
var err error var err error
woodpeckerClient, err = woodpecker.NewClient( woodpeckerClient, err = woodpecker.NewClient(infraCfg.WoodpeckerURL, infraCfg.WoodpeckerAPIToken, woodpecker.WithLogger(logger))
infraCfg.WoodpeckerURL,
infraCfg.WoodpeckerAPIToken,
woodpecker.WithLogger(logger),
)
if err != nil { if err != nil {
logger.Warn("failed to initialize woodpecker client", "error", err) logger.Warn("failed to initialize woodpecker client", "error", err)
} else {
logger.Info("woodpecker CI client initialized", "url", infraCfg.WoodpeckerURL)
} }
} }
// Initialize template provider (requires Gitea credentials for seeding repos)
var templateProvider *templates.Provider var templateProvider *templates.Provider
if infraCfg.GiteaToken != "" && infraCfg.GiteaURL != "" { if infraCfg.GiteaToken != "" && infraCfg.GiteaURL != "" {
// Pass URL and token directly - provider uses bulk file API for single-commit seeding
templateProvider = templates.NewProvider(infraCfg.GiteaURL, infraCfg.GiteaToken, logger) templateProvider = templates.NewProvider(infraCfg.GiteaURL, infraCfg.GiteaToken, logger)
logger.Info("template provider initialized")
} }
// Initialize database provisioner (optional - for project database isolation) // Initialize database provisioner (optional - for project database isolation)
@ -345,7 +321,8 @@ func main() {
) )
// Initialize project management handler // Initialize project management handler
projectMgmtHandler := handlers.NewProjectManagementHandler(projectInfraService, logger) projectMgmtHandler := handlers.NewProjectManagementHandler(projectInfraService, logger).
SetOperationService(operationService)
// Initialize component service and handler (for monorepo component management) // Initialize component service and handler (for monorepo component management)
var componentsHandler *handlers.ComponentsHandler var componentsHandler *handlers.ComponentsHandler
@ -362,7 +339,8 @@ func main() {
Logger: logger, Logger: logger,
}, },
) )
componentsHandler = handlers.NewComponentsHandler(componentService, logger) componentsHandler = handlers.NewComponentsHandler(componentService, logger).
SetOperationService(operationService)
logger.Info("component service initialized") logger.Info("component service initialized")
} }
@ -377,7 +355,7 @@ func main() {
ClusterIP: infraCfg.ClusterIP, ClusterIP: infraCfg.ClusterIP,
Logger: logger, Logger: logger,
}, },
) ).SetOperationService(operationService)
// Initialize credentials handler (superadmin only) // Initialize credentials handler (superadmin only)
credentialsHandler := handlers.NewCredentialsHandler(credentialStore) credentialsHandler := handlers.NewCredentialsHandler(credentialStore)
@ -393,9 +371,6 @@ func main() {
// Initialize operations handler (for debugging project failures) // Initialize operations handler (for debugging project failures)
operationsHandler := handlers.NewOperationsHandler(operationRepo) operationsHandler := handlers.NewOperationsHandler(operationRepo)
// Suppress unused variable warning - operationService will be wired to handlers in instrumentation phase
_ = operationService
// Override default health/ready endpoints with full dependency checks // Override default health/ready endpoints with full dependency checks
healthHandler := handlers.NewHealthHandler("rdev-api", database.DB, nil). healthHandler := handlers.NewHealthHandler("rdev-api", database.DB, nil).
WithAgentRegistry(agentRegistry) WithAgentRegistry(agentRegistry)
@ -518,5 +493,3 @@ func main() {
app.Run() app.Run()
} }
// Config, InfraConfig, loadConfig, loadInfraConfig are defined in config.go.

View File

@ -0,0 +1,772 @@
# Feature Development Cookbook
> Complete workflow for building features using rdev composable monorepo templates with chassis patterns, design system, and auth integration.
## Overview
This cookbook documents the end-to-end workflow for developing features in a composable monorepo project. It assumes you have:
- An rdev project with monorepo skeleton (created via `POST /project`)
- At least one API service component (`services/api`)
- A Next.js or React app component (`apps/dashboard`)
```
Feature Development Workflow
────────────────────────────
1. Planning → Define requirements, break into tasks
2. API Dev → Bind + Wrap patterns, OpenAPI annotations
3. Frontend Dev → Auth hooks, design system, API client
4. Testing → Handler tests, integration tests
5. Review → /review-code, quality checks
6. Deployment → Git push, Woodpecker CI, verify
```
---
## Prerequisites
### API Access
```bash
export RDEV_API_URL="https://rdev.masq-ops.orchard9.ai"
export RDEV_API_KEY="<your-api-key>"
```
### Project Structure
Your composable monorepo should have:
```
my-project/
├── pkg/ # Shared Go packages
│ ├── app/ # Service bootstrapper
│ │ ├── app.go # NewApp, Run, RegisterRoutes
│ │ ├── handler.go # Wrap pattern
│ │ ├── bind.go # Bind + BindAndValidate
│ │ └── health.go # Health probes
│ ├── httperror/ # Typed HTTP errors
│ ├── httpresponse/ # JSON response envelope
│ ├── httpvalidation/ # go-playground/validator
│ └── auth/ # JWT + API key validation
├── packages/ # Shared TS packages
│ ├── ui/ # Design system components
│ ├── layout/ # DashboardShell, Sidebar
│ ├── auth/ # AuthProvider, useAuth
│ ├── logger/ # Structured logging
│ └── api-client/ # Generated TypeScript client
├── services/ # Go backend services
│ └── api/
│ ├── cmd/server/main.go
│ └── internal/api/routes.go
└── apps/ # Frontend applications
└── dashboard/
└── src/app/ # Next.js App Router
```
---
## Step 1: Feature Planning
### Create Feature Branch
```bash
git checkout -b feature/user-profile
```
### Define Requirements
Example: User Profile Feature
- **Backend:** GET/PUT /api/users/me endpoints
- **Frontend:** Profile page with edit form
- **Validation:** Email format, name required
- **Auth:** Require authenticated user
### Break Into Tasks
```markdown
## Tasks
- [ ] Add User domain model with validation
- [ ] Implement GET /api/users/me handler
- [ ] Implement PUT /api/users/me handler
- [ ] Add OpenAPI annotations
- [ ] Create profile page component
- [ ] Add profile edit form
- [ ] Connect to API with generated client
- [ ] Write handler tests
- [ ] Run quality checks
```
---
## Step 2: API Development
### 2.1 Add Domain Model
Create the domain model in `services/api/internal/domain/`:
```go
// services/api/internal/domain/user.go
package domain
import "time"
// User represents an authenticated user's profile.
type User struct {
ID string `json:"id" db:"id"`
Email string `json:"email" db:"email"`
Name string `json:"name" db:"name"`
AvatarURL string `json:"avatar_url,omitempty" db:"avatar_url"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
```
### 2.2 Create Handler with Wrap Pattern
The `Wrap` pattern converts error-returning handlers to `http.HandlerFunc`:
```go
// services/api/internal/api/handlers/user.go
package handlers
import (
"net/http"
"my-project/pkg/app"
"my-project/pkg/auth"
"my-project/pkg/httperror"
"my-project/pkg/httpresponse"
"my-project/services/api/internal/service"
)
type UserHandler struct {
svc *service.UserService
logger *slog.Logger
}
func NewUserHandler(svc *service.UserService, logger *slog.Logger) *UserHandler {
return &UserHandler{svc: svc, logger: logger}
}
// GetMe returns the authenticated user's profile.
// Uses app.Wrap to convert error-returning handler to http.HandlerFunc.
func (h *UserHandler) GetMe() http.HandlerFunc {
return app.Wrap(func(w http.ResponseWriter, r *http.Request) error {
// Get user from auth context
user, ok := auth.GetUser(r.Context())
if !ok {
return httperror.Unauthorized("not authenticated")
}
// Fetch full profile
profile, err := h.svc.GetByID(r.Context(), user.ID)
if err != nil {
return httperror.WrapError(err, "failed to get profile")
}
httpresponse.JSON(w, http.StatusOK, profile)
return nil
})
}
// UpdateMe updates the authenticated user's profile.
// Uses app.BindAndValidate for request parsing and validation.
func (h *UserHandler) UpdateMe() http.HandlerFunc {
type request struct {
Name string `json:"name" validate:"required,min=1,max=100"`
AvatarURL string `json:"avatar_url" validate:"omitempty,url"`
}
return app.Wrap(func(w http.ResponseWriter, r *http.Request) error {
// Get user from auth context
user, ok := auth.GetUser(r.Context())
if !ok {
return httperror.Unauthorized("not authenticated")
}
// Bind and validate request body
var req request
if err := app.BindAndValidate(r, &req); err != nil {
return err // Already an HTTPError with validation details
}
// Update profile
profile, err := h.svc.Update(r.Context(), user.ID, req.Name, req.AvatarURL)
if err != nil {
return httperror.WrapError(err, "failed to update profile")
}
httpresponse.JSON(w, http.StatusOK, profile)
return nil
})
}
```
### 2.3 Register Routes
```go
// services/api/internal/api/routes.go
func (s *Server) RegisterRoutes() {
userHandler := handlers.NewUserHandler(s.userService, s.logger)
// Protected routes (require auth)
s.router.Group(func(r chi.Router) {
r.Use(auth.Middleware(s.authValidator))
r.Get("/api/users/me", userHandler.GetMe())
r.Put("/api/users/me", userHandler.UpdateMe())
})
}
```
### 2.4 Add OpenAPI Annotations
```go
// services/api/cmd/server/main.go
func buildOpenAPISpec() *api.OpenAPISpec {
spec := api.NewOpenAPISpec("My Project API", "1.0.0", "/api")
// Security schemes
spec.WithBearerSecurity("bearerAuth", "JWT Bearer token")
spec.WithGlobalSecurity("bearerAuth")
// User profile endpoints
spec.Op("GET", "/users/me", "Get current user profile",
api.OpResponses(
api.OpResponse(200, "User profile", api.Ref("User")),
api.OpStandardResponses()...,
),
)
spec.OpWithBody("PUT", "/users/me", "Update current user profile",
api.Object(map[string]*api.Schema{
"name": api.String().Description("Display name"),
"avatar_url": api.String().Format("uri").Description("Avatar URL"),
}, "name"),
api.OpResponses(
api.OpResponse(200, "Updated user profile", api.Ref("User")),
api.OpStandardResponses()...,
),
)
// Schema definitions
spec.Schema("User", api.Object(map[string]*api.Schema{
"id": api.UUID(),
"email": api.Email(),
"name": api.String(),
"avatar_url": api.String().Format("uri"),
"created_at": api.DateTime(),
"updated_at": api.DateTime(),
}))
return spec
}
```
---
## Step 3: Frontend Development
### 3.1 Generate TypeScript Client
After adding OpenAPI annotations, regenerate the client:
```bash
./scripts/generate-client.sh
```
This updates `packages/api-client/src/schema.d.ts` with typed endpoints.
### 3.2 Create Profile Page
```tsx
// apps/dashboard/src/app/(dashboard)/profile/page.tsx
import { ProfileForm } from '@/components/profile-form';
import { Card, CardContent, CardHeader, CardTitle } from '@my-project/ui';
export default function ProfilePage() {
return (
<div className="p-6">
<Card>
<CardHeader>
<CardTitle>Profile Settings</CardTitle>
</CardHeader>
<CardContent>
<ProfileForm />
</CardContent>
</Card>
</div>
);
}
```
### 3.3 Create Profile Form with Auth Hook
```tsx
// apps/dashboard/src/components/profile-form.tsx
'use client';
import { useState } from 'react';
import { useAuth } from '@my-project/auth';
import { Button, Input, Label } from '@my-project/ui';
import { updateProfile } from '@/actions/profile';
export function ProfileForm() {
const { user, refreshUser } = useAuth();
const [name, setName] = useState(user?.name ?? '');
const [avatarUrl, setAvatarUrl] = useState(user?.avatar_url ?? '');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
if (!user) {
return <div>Loading...</div>;
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setIsSubmitting(true);
setError(null);
try {
const result = await updateProfile({ name, avatar_url: avatarUrl });
if (result.success) {
await refreshUser();
} else {
setError(result.error ?? 'Failed to update profile');
}
} catch (err) {
setError('An unexpected error occurred');
} finally {
setIsSubmitting(false);
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={user.email}
disabled
className="bg-surface-50"
/>
</div>
<div className="space-y-2">
<Label htmlFor="name">Display Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter your name"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="avatar">Avatar URL</Label>
<Input
id="avatar"
type="url"
value={avatarUrl}
onChange={(e) => setAvatarUrl(e.target.value)}
placeholder="https://example.com/avatar.png"
/>
</div>
{error && (
<div className="text-sm text-error">{error}</div>
)}
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Saving...' : 'Save Changes'}
</Button>
</form>
);
}
```
### 3.4 Create Server Action
```typescript
// apps/dashboard/src/actions/profile.ts
'use server';
import { revalidatePath } from 'next/cache';
import { cookies } from 'next/headers';
interface UpdateProfileInput {
name: string;
avatar_url?: string;
}
interface UpdateProfileResult {
success: boolean;
error?: string;
}
export async function updateProfile(input: UpdateProfileInput): Promise<UpdateProfileResult> {
const token = cookies().get('auth_token')?.value;
if (!token) {
return { success: false, error: 'Not authenticated' };
}
const response = await fetch(`${process.env.API_URL}/api/users/me`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify(input),
});
if (!response.ok) {
const error = await response.json();
return {
success: false,
error: error.error?.message ?? 'Failed to update profile',
};
}
revalidatePath('/profile');
return { success: true };
}
```
### 3.5 Add Navigation Link
```tsx
// apps/dashboard/src/components/sidebar-nav.tsx
import { User, Settings, Home } from 'lucide-react';
const navItems = [
{ label: 'Dashboard', href: '/dashboard', icon: Home },
{ label: 'Profile', href: '/profile', icon: User },
{ label: 'Settings', href: '/settings', icon: Settings },
];
```
---
## Step 4: Testing
### 4.1 Write Handler Tests
```go
// services/api/internal/api/handlers/user_test.go
package handlers_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"my-project/pkg/auth"
"my-project/services/api/internal/api/handlers"
"my-project/services/api/internal/service"
)
func TestUserHandler_GetMe(t *testing.T) {
svc := service.NewUserService(/* mock deps */)
handler := handlers.NewUserHandler(svc, slog.Default())
t.Run("returns user profile", func(t *testing.T) {
// Create request with auth context
req := httptest.NewRequest("GET", "/api/users/me", nil)
req = req.WithContext(auth.SetUser(req.Context(), &auth.User{
ID: "user-123",
Email: "test@example.com",
}))
rr := httptest.NewRecorder()
handler.GetMe().ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var resp map[string]any
json.Unmarshal(rr.Body.Bytes(), &resp)
data := resp["data"].(map[string]any)
if data["id"] != "user-123" {
t.Errorf("expected user-123, got %v", data["id"])
}
})
t.Run("returns 401 without auth", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/users/me", nil)
rr := httptest.NewRecorder()
handler.GetMe().ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", rr.Code)
}
})
}
func TestUserHandler_UpdateMe(t *testing.T) {
svc := service.NewUserService(/* mock deps */)
handler := handlers.NewUserHandler(svc, slog.Default())
t.Run("updates profile", func(t *testing.T) {
body := `{"name": "New Name", "avatar_url": "https://example.com/avatar.png"}`
req := httptest.NewRequest("PUT", "/api/users/me", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(auth.SetUser(req.Context(), &auth.User{
ID: "user-123",
}))
rr := httptest.NewRecorder()
handler.UpdateMe().ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
})
t.Run("validates required name", func(t *testing.T) {
body := `{"name": ""}`
req := httptest.NewRequest("PUT", "/api/users/me", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(auth.SetUser(req.Context(), &auth.User{
ID: "user-123",
}))
rr := httptest.NewRecorder()
handler.UpdateMe().ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", rr.Code)
}
})
}
```
### 4.2 Run Tests
```bash
# Run all tests
./scripts/quality.sh
# Run specific service tests
go test ./services/api/...
# Run with coverage
go test -cover ./services/api/...
```
---
## Step 5: Review & Quality Checks
### 5.1 Run Code Review
Use the built-in review command:
```bash
# In Claude Code
/review-code
```
This checks for:
- Completeness and accuracy
- Tech debt indicators
- Maintainability issues
- Extensibility concerns
- DRY/CLEAN violations
### 5.2 Run Quality Checks
```bash
# Lint Go code
golangci-lint run ./...
# Lint frontend
cd apps/dashboard && npm run lint
# Type check
cd apps/dashboard && npm run typecheck
```
### 5.3 Verify OpenAPI Spec
```bash
# Start the service locally
cd services/api && make run
# Check the spec
curl http://localhost:8001/openapi.json | jq '.paths["/users/me"]'
# View docs
open http://localhost:8001/docs
```
---
## Step 6: Deployment
### 6.1 Commit Changes
```bash
# Stage changes
git add services/api/internal/api/handlers/user.go
git add services/api/internal/api/routes.go
git add apps/dashboard/src/app/\(dashboard\)/profile/
git add apps/dashboard/src/components/profile-form.tsx
git add apps/dashboard/src/actions/profile.ts
# Commit
git commit -m "feat: add user profile feature
- Add GET/PUT /api/users/me endpoints with Wrap pattern
- Add profile page with edit form
- Add server action for profile updates
- Add handler tests"
```
### 6.2 Push and Monitor CI
```bash
# Push feature branch
git push -u origin feature/user-profile
# Monitor pipeline
./cookbooks/scripts/feature-test.sh status my-project
```
### 6.3 Create Pull Request
```bash
gh pr create --title "feat: add user profile feature" --body "
## Summary
- Added user profile endpoints (GET/PUT /api/users/me)
- Added profile page with edit form
- Integrated with auth system
## Testing
- Handler tests added
- Manual testing complete
## Checklist
- [x] Handler tests pass
- [x] Quality checks pass
- [x] OpenAPI spec updated
"
```
### 6.4 Verify Deployment
After merge to main:
```bash
# Check deployment
curl https://my-project.threesix.ai/api/users/me \
-H "Authorization: Bearer $TOKEN" | jq .
# Open the app
open https://my-project.threesix.ai/profile
```
---
## Pattern Quick Reference
### Wrap Pattern
```go
app.Wrap(func(w http.ResponseWriter, r *http.Request) error {
// Return errors, they become proper HTTP responses
if err := doThing(); err != nil {
return httperror.WrapError(err, "failed to do thing")
}
httpresponse.JSON(w, http.StatusOK, result)
return nil
})
```
### Bind Pattern
```go
var req RequestType
if err := app.BindAndValidate(r, &req); err != nil {
return err // Validation errors become 400 with details
}
```
### HTTPError Sentinels
```go
httperror.BadRequest("invalid input")
httperror.NotFound("user not found")
httperror.Unauthorized("not authenticated")
httperror.Forbidden("access denied")
httperror.Conflict("already exists")
httperror.Internal("something went wrong")
httperror.Validation("validation failed")
```
### Auth Context
```go
user, ok := auth.GetUser(r.Context())
if !ok {
return httperror.Unauthorized("not authenticated")
}
```
### React Auth Hook
```tsx
const { user, isLoading, login, logout } = useAuth();
```
### Design System Components
```tsx
import { Button, Card, Input, Label, Badge } from '@my-project/ui';
import { DashboardShell, Sidebar, Header } from '@my-project/layout';
```
---
## E2E Test Script
Validate the complete feature development flow:
```bash
# Create test project with components
./cookbooks/scripts/feature-test.sh run test-feature
# Check status
./cookbooks/scripts/feature-test.sh status test-feature
# Cleanup
./cookbooks/scripts/feature-test.sh teardown test-feature
```
---
## Troubleshooting
### Handler returns 500 instead of proper error
Check that you're using `httperror.*` functions, not raw `errors.New()`.
### Validation errors not showing details
Ensure you're using `app.BindAndValidate()` which includes validation details.
### Auth middleware not applying
Verify route is inside the `r.Group()` with `auth.Middleware()`.
### Generated client out of sync
Run `./scripts/generate-client.sh` after OpenAPI changes.
### Frontend not seeing auth user
Check that `AuthProvider` wraps your app in `providers.tsx`.
---
## Related
- [Composable App Cookbook](./composable-app.md) - Creating projects with components
- [Landing Page Cookbook](./landing-page.md) - Simple single-component sites
- [Composable Monorepo Guide](../.claude/guides/services/composable-monorepo.md)
- [API Framework Guide](../.claude/guides/packages/api-framework.md)

417
cookbooks/scripts/feature-test.sh Executable file
View File

@ -0,0 +1,417 @@
#!/bin/bash
set -euo pipefail
# Feature Development E2E Test Script
# Tests the complete feature development workflow:
# 1. Create composable project with skeleton
# 2. Add service component
# 3. Add app-nextjs component
# 4. Verify chassis patterns are available
# 5. Verify design system packages exist
# 6. Test auth integration
# 7. Verify CI pipeline
#
# Usage: ./cookbooks/scripts/feature-test.sh <command> <project-name>
# Commands: run, status, verify-patterns, teardown
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/common.sh"
COMMAND="${1:-}"
PROJECT_NAME="${2:-}"
if [[ -z "$COMMAND" || -z "$PROJECT_NAME" ]]; then
echo "Usage: $0 <command> <project-name>"
echo "Commands:"
echo " run - Create project with full stack and verify patterns"
echo " status - Check project status"
echo " verify-patterns - Verify chassis and design system patterns exist"
echo " teardown - Delete the project"
exit 1
fi
# Verify chassis patterns in the created project
verify_chassis_patterns() {
local project_id="$1"
local git_owner
git_owner=$(get_git_owner)
print_header "Verifying Chassis Patterns"
# Check pkg/httperror exists
echo "Checking pkg/httperror..."
local httperror_check
httperror_check=$(curl -s "https://git.threesix.ai/api/v1/repos/$git_owner/$project_id/contents/pkg/httperror/error.go" \
-H "Authorization: token ${GITEA_TOKEN:-}" 2>/dev/null | jq -r '.name // "not found"')
if [[ "$httperror_check" == "error.go" ]]; then
print_success "pkg/httperror/error.go exists"
else
print_warning "pkg/httperror/error.go not found (may be named differently)"
fi
# Check pkg/app/handler.go (Wrap pattern)
echo "Checking pkg/app/handler.go (Wrap pattern)..."
local handler_check
handler_check=$(curl -s "https://git.threesix.ai/api/v1/repos/$git_owner/$project_id/contents/pkg/app/handler.go" \
-H "Authorization: token ${GITEA_TOKEN:-}" 2>/dev/null | jq -r '.name // "not found"')
if [[ "$handler_check" == "handler.go" ]]; then
print_success "pkg/app/handler.go exists (Wrap pattern)"
else
print_warning "pkg/app/handler.go not found"
fi
# Check pkg/app/bind.go
echo "Checking pkg/app/bind.go..."
local bind_check
bind_check=$(curl -s "https://git.threesix.ai/api/v1/repos/$git_owner/$project_id/contents/pkg/app/bind.go" \
-H "Authorization: token ${GITEA_TOKEN:-}" 2>/dev/null | jq -r '.name // "not found"')
if [[ "$bind_check" == "bind.go" ]]; then
print_success "pkg/app/bind.go exists (Bind pattern)"
else
print_warning "pkg/app/bind.go not found"
fi
# Check pkg/app/health.go
echo "Checking pkg/app/health.go..."
local health_check
health_check=$(curl -s "https://git.threesix.ai/api/v1/repos/$git_owner/$project_id/contents/pkg/app/health.go" \
-H "Authorization: token ${GITEA_TOKEN:-}" 2>/dev/null | jq -r '.name // "not found"')
if [[ "$health_check" == "health.go" ]]; then
print_success "pkg/app/health.go exists (Health probes)"
else
print_warning "pkg/app/health.go not found"
fi
# Check pkg/auth exists
echo "Checking pkg/auth..."
local auth_check
auth_check=$(curl -s "https://git.threesix.ai/api/v1/repos/$git_owner/$project_id/contents/pkg/auth" \
-H "Authorization: token ${GITEA_TOKEN:-}" 2>/dev/null | jq -r 'if type == "array" then "directory" else "not found" end')
if [[ "$auth_check" == "directory" ]]; then
print_success "pkg/auth/ directory exists"
else
print_warning "pkg/auth/ not found"
fi
}
# Verify design system packages
verify_design_system() {
local project_id="$1"
local git_owner
git_owner=$(get_git_owner)
print_header "Verifying Design System Packages"
# Check packages/ui exists
echo "Checking packages/ui..."
local ui_check
ui_check=$(curl -s "https://git.threesix.ai/api/v1/repos/$git_owner/$project_id/contents/packages/ui/package.json" \
-H "Authorization: token ${GITEA_TOKEN:-}" 2>/dev/null | jq -r '.name // "not found"')
if [[ "$ui_check" == "package.json" ]]; then
print_success "packages/ui exists"
else
print_warning "packages/ui not found"
fi
# Check packages/layout exists
echo "Checking packages/layout..."
local layout_check
layout_check=$(curl -s "https://git.threesix.ai/api/v1/repos/$git_owner/$project_id/contents/packages/layout/package.json" \
-H "Authorization: token ${GITEA_TOKEN:-}" 2>/dev/null | jq -r '.name // "not found"')
if [[ "$layout_check" == "package.json" ]]; then
print_success "packages/layout exists"
else
print_warning "packages/layout not found"
fi
# Check packages/auth exists
echo "Checking packages/auth..."
local auth_check
auth_check=$(curl -s "https://git.threesix.ai/api/v1/repos/$git_owner/$project_id/contents/packages/auth/package.json" \
-H "Authorization: token ${GITEA_TOKEN:-}" 2>/dev/null | jq -r '.name // "not found"')
if [[ "$auth_check" == "package.json" ]]; then
print_success "packages/auth exists"
else
print_warning "packages/auth not found"
fi
# Check packages/api-client exists
echo "Checking packages/api-client..."
local client_check
client_check=$(curl -s "https://git.threesix.ai/api/v1/repos/$git_owner/$project_id/contents/packages/api-client/package.json" \
-H "Authorization: token ${GITEA_TOKEN:-}" 2>/dev/null | jq -r '.name // "not found"')
if [[ "$client_check" == "package.json" ]]; then
print_success "packages/api-client exists"
else
print_warning "packages/api-client not found"
fi
}
# Verify service component uses chassis patterns
verify_service_patterns() {
local project_id="$1"
local git_owner
git_owner=$(get_git_owner)
print_header "Verifying Service Component"
# Check services/api exists
echo "Checking services/api..."
local svc_check
svc_check=$(curl -s "https://git.threesix.ai/api/v1/repos/$git_owner/$project_id/contents/services/api/cmd/server/main.go" \
-H "Authorization: token ${GITEA_TOKEN:-}" 2>/dev/null | jq -r '.name // "not found"')
if [[ "$svc_check" == "main.go" ]]; then
print_success "services/api component exists"
else
print_warning "services/api not found"
fi
# Check services/api/internal/api/routes.go exists
echo "Checking routes.go..."
local routes_check
routes_check=$(curl -s "https://git.threesix.ai/api/v1/repos/$git_owner/$project_id/contents/services/api/internal/api/routes.go" \
-H "Authorization: token ${GITEA_TOKEN:-}" 2>/dev/null | jq -r '.name // "not found"')
if [[ "$routes_check" == "routes.go" ]]; then
print_success "services/api/internal/api/routes.go exists"
else
print_warning "routes.go not found"
fi
}
# Verify app-nextjs component
verify_nextjs_app() {
local project_id="$1"
local git_owner
git_owner=$(get_git_owner)
print_header "Verifying Next.js App Component"
# Check apps/dashboard exists
echo "Checking apps/dashboard..."
local app_check
app_check=$(curl -s "https://git.threesix.ai/api/v1/repos/$git_owner/$project_id/contents/apps/dashboard/package.json" \
-H "Authorization: token ${GITEA_TOKEN:-}" 2>/dev/null | jq -r '.name // "not found"')
if [[ "$app_check" == "package.json" ]]; then
print_success "apps/dashboard exists"
else
print_warning "apps/dashboard not found"
fi
# Check App Router structure
echo "Checking App Router structure..."
local layout_check
layout_check=$(curl -s "https://git.threesix.ai/api/v1/repos/$git_owner/$project_id/contents/apps/dashboard/src/app/layout.tsx" \
-H "Authorization: token ${GITEA_TOKEN:-}" 2>/dev/null | jq -r '.name // "not found"')
if [[ "$layout_check" == "layout.tsx" ]]; then
print_success "App Router layout.tsx exists"
else
print_warning "App Router layout.tsx not found"
fi
# Check dashboard route group
echo "Checking (dashboard) route group..."
local dashboard_check
dashboard_check=$(curl -s "https://git.threesix.ai/api/v1/repos/$git_owner/$project_id/contents/apps/dashboard/src/app/(dashboard)/layout.tsx" \
-H "Authorization: token ${GITEA_TOKEN:-}" 2>/dev/null | jq -r '.name // "not found"')
if [[ "$dashboard_check" == "layout.tsx" ]]; then
print_success "(dashboard) route group exists"
else
print_warning "(dashboard) route group not found"
fi
}
# Add a component and verify
add_component() {
local comp_type="$1"
local comp_name="$2"
echo "Adding $comp_type component: $comp_name"
local payload
payload=$(jq -n \
--arg type "$comp_type" \
--arg name "$comp_name" \
'{type: $type, name: $name}')
local result
result=$(api_call POST "/projects/$PROJECT_NAME/components" "$payload")
local path
path=$(echo "$result" | jq -r '.data.path // .path // ""')
if [[ -z "$path" ]]; then
print_error "Failed to add component"
echo "$result" | jq '.'
return 1
fi
local port
port=$(echo "$result" | jq -r '.data.port // .port // "N/A"')
print_success "Added $comp_type/$comp_name at $path (port: $port)"
return 0
}
run_flow() {
print_header "Feature Development E2E Test"
echo "Project: $PROJECT_NAME"
# Step 1: Create project (skeleton)
print_header "Step 1: Creating project skeleton"
local create_payload
create_payload=$(jq -n \
--arg name "$PROJECT_NAME" \
--arg desc "Feature development E2E test" \
'{name: $name, description: $desc}')
local create_result
create_result=$(api_call POST "/project" "$create_payload")
echo "$create_result" | jq '.'
local domain
domain=$(echo "$create_result" | jq -r '.data.domain // .domain // ""')
if [[ -z "$domain" ]]; then
print_error "Failed to create project"
exit 1
fi
print_success "Project created with domain: $domain"
# Step 2: Add backend service
print_header "Step 2: Adding backend service"
if ! add_component "service" "api"; then
exit 1
fi
# Step 3: Add Next.js dashboard
print_header "Step 3: Adding Next.js dashboard"
if ! add_component "app-nextjs" "dashboard"; then
exit 1
fi
# Step 4: List components
print_header "Step 4: Verifying components"
local components
components=$(api_call GET "/projects/$PROJECT_NAME/components")
echo "$components" | jq '.data // .'
local comp_count
comp_count=$(echo "$components" | jq '.data.components | length // 0')
if [[ "$comp_count" -lt 2 ]]; then
print_warning "Expected 2 components, got $comp_count"
else
print_success "All components added successfully"
fi
# Step 5: Wait for initial commit to propagate
print_header "Step 5: Waiting for git to sync..."
sleep 10
# Step 6: Verify patterns
verify_chassis_patterns "$PROJECT_NAME"
verify_design_system "$PROJECT_NAME"
verify_service_patterns "$PROJECT_NAME"
verify_nextjs_app "$PROJECT_NAME"
# Step 7: Wait for CI pipeline
print_header "Step 7: Waiting for CI pipeline"
if ! wait_for_pipeline "$PROJECT_NAME"; then
print_warning "Pipeline may have issues, continuing to check site..."
fi
# Step 8: Wait for site
print_header "Step 8: Verifying site is accessible"
if ! wait_for_site "$domain" 30 5 "$PROJECT_NAME"; then
print_warning "Site not yet accessible (may still be deploying)"
fi
# Summary
print_header "E2E Test Results"
print_success "Project created: $PROJECT_NAME"
print_success "Components added: $comp_count"
echo ""
echo "Site URL: https://$domain"
echo "Git repo: https://git.threesix.ai/$(get_git_owner)/$PROJECT_NAME"
echo "CI: https://ci.threesix.ai/$(get_git_owner)/$PROJECT_NAME"
echo ""
echo "Next steps:"
echo " 1. Clone the repo and start developing features"
echo " 2. Use pkg/httperror, pkg/app for chassis patterns"
echo " 3. Use packages/ui, packages/layout for design system"
echo " 4. Use packages/auth for authentication"
echo " 5. Run ./scripts/generate-client.sh after OpenAPI changes"
}
check_status() {
print_header "Project Status: $PROJECT_NAME"
# Get project info
echo "Project:"
api_call GET "/project/$PROJECT_NAME" | jq '.data // .'
echo ""
# Get components
echo "Components:"
api_call GET "/projects/$PROJECT_NAME/components" | jq '.data // .'
echo ""
# Get latest pipelines
echo "Latest Pipelines:"
api_call GET "/projects/$PROJECT_NAME/pipelines" | jq '.data[:3] // .'
}
verify_patterns() {
print_header "Verifying Patterns: $PROJECT_NAME"
verify_chassis_patterns "$PROJECT_NAME"
verify_design_system "$PROJECT_NAME"
verify_service_patterns "$PROJECT_NAME"
verify_nextjs_app "$PROJECT_NAME"
print_header "Verification Complete"
}
teardown() {
print_header "Tearing down: $PROJECT_NAME"
local result
result=$(api_call DELETE "/project/$PROJECT_NAME")
echo "$result" | jq '.'
echo ""
print_success "Project deleted. Gitea repo preserved."
}
case "$COMMAND" in
run)
run_flow
;;
status)
check_status
;;
verify-patterns)
verify_patterns
;;
teardown)
teardown
;;
*)
echo "Unknown command: $COMMAND"
echo "Valid commands: run, status, verify-patterns, teardown"
exit 1
;;
esac

View File

@ -0,0 +1,395 @@
#!/bin/bash
set -euo pipefail
# Template Validation Script
# Validates all rdev templates by:
# 1. Creating a test project
# 2. Adding each component type
# 3. Verifying files exist and compile
# 4. Running CI pipeline
# 5. Cleaning up
#
# Usage: ./cookbooks/scripts/template-validation.sh <command>
# Commands: run, quick, cleanup
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/common.sh"
COMMAND="${1:-run}"
PROJECT_NAME="template-validation-$(date +%s)"
# Component types to test
COMPONENT_TYPES=(
"service"
"worker"
"app-astro"
"app-react"
"app-nextjs"
"cli"
)
print_banner() {
echo ""
echo -e "${BLUE}╔══════════════════════════════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ rdev Template Validation - Full Integration Test ║${NC}"
echo -e "${BLUE}╚══════════════════════════════════════════════════════════════════╝${NC}"
echo ""
}
validate_template_compilation() {
local project_id="$1"
local git_owner
git_owner=$(get_git_owner)
print_header "Validating Template Compilation"
local failures=0
# Check each key file exists
echo "Checking skeleton files..."
local files_to_check=(
"CLAUDE.md"
"README.md"
"go.work"
"pnpm-workspace.yaml"
".woodpecker.yml"
".golangci.yml"
"docker-compose.yml"
"pkg/README.md"
"scripts/dev.sh"
"scripts/discover.sh"
"scripts/quality.sh"
"scripts/install.sh"
)
for file in "${files_to_check[@]}"; do
local check
check=$(curl -s -o /dev/null -w "%{http_code}" \
"https://git.threesix.ai/api/v1/repos/$git_owner/$project_id/contents/$file" \
-H "Authorization: token ${GITEA_TOKEN:-}" 2>/dev/null)
if [[ "$check" == "200" ]]; then
print_success "$file"
else
print_error "$file (HTTP $check)"
((failures++))
fi
done
# Check pkg/ packages
echo ""
echo "Checking pkg/ packages..."
local pkg_dirs=(
"pkg/app"
"pkg/httperror"
"pkg/httpresponse"
"pkg/httpvalidation"
"pkg/middleware"
"pkg/auth"
)
for pkg_dir in "${pkg_dirs[@]}"; do
local check
check=$(curl -s "https://git.threesix.ai/api/v1/repos/$git_owner/$project_id/contents/$pkg_dir" \
-H "Authorization: token ${GITEA_TOKEN:-}" 2>/dev/null | jq -r 'if type == "array" then "ok" else "not found" end')
if [[ "$check" == "ok" ]]; then
print_success "$pkg_dir/"
else
print_warning "$pkg_dir/ (may be optional)"
fi
done
# Check packages/
echo ""
echo "Checking packages/..."
local packages=(
"packages/ui"
"packages/layout"
"packages/auth"
"packages/logger"
"packages/api-client"
)
for pkg in "${packages[@]}"; do
local check
check=$(curl -s "https://git.threesix.ai/api/v1/repos/$git_owner/$project_id/contents/$pkg/package.json" \
-H "Authorization: token ${GITEA_TOKEN:-}" 2>/dev/null | jq -r '.name // "not found"')
if [[ "$check" == "package.json" ]]; then
print_success "$pkg/"
else
print_warning "$pkg/ (may be optional)"
fi
done
echo ""
if [[ $failures -gt 0 ]]; then
print_error "Template compilation validation: $failures failures"
return 1
else
print_success "Template compilation validation: all required files present"
return 0
fi
}
validate_component() {
local project_id="$1"
local comp_type="$2"
local comp_name="$3"
local git_owner
git_owner=$(get_git_owner)
echo ""
echo "Validating $comp_type component..."
# Determine expected directory
local comp_dir
case "$comp_type" in
service)
comp_dir="services/$comp_name"
;;
worker)
comp_dir="workers/$comp_name"
;;
app-astro|app-react|app-nextjs)
comp_dir="apps/$comp_name"
;;
cli)
comp_dir="cli/$comp_name"
;;
esac
# Check component directory exists
local check
check=$(curl -s "https://git.threesix.ai/api/v1/repos/$git_owner/$project_id/contents/$comp_dir" \
-H "Authorization: token ${GITEA_TOKEN:-}" 2>/dev/null | jq -r 'if type == "array" then "ok" else "not found" end')
if [[ "$check" == "ok" ]]; then
print_success "$comp_type: $comp_dir/ exists"
else
print_error "$comp_type: $comp_dir/ not found"
return 1
fi
# Check component.yaml exists
local yaml_check
yaml_check=$(curl -s -o /dev/null -w "%{http_code}" \
"https://git.threesix.ai/api/v1/repos/$git_owner/$project_id/contents/$comp_dir/component.yaml" \
-H "Authorization: token ${GITEA_TOKEN:-}" 2>/dev/null)
if [[ "$yaml_check" == "200" ]]; then
print_success "$comp_type: component.yaml exists"
else
print_warning "$comp_type: component.yaml not found"
fi
# Check Dockerfile exists
local docker_check
docker_check=$(curl -s -o /dev/null -w "%{http_code}" \
"https://git.threesix.ai/api/v1/repos/$git_owner/$project_id/contents/$comp_dir/Dockerfile" \
-H "Authorization: token ${GITEA_TOKEN:-}" 2>/dev/null)
if [[ "$docker_check" == "200" ]]; then
print_success "$comp_type: Dockerfile exists"
else
print_warning "$comp_type: Dockerfile not found (may be optional)"
fi
return 0
}
add_component() {
local comp_type="$1"
local comp_name="$2"
echo "Adding $comp_type component: $comp_name"
local payload
payload=$(jq -n \
--arg type "$comp_type" \
--arg name "$comp_name" \
'{type: $type, name: $name}')
local result
result=$(api_call POST "/projects/$PROJECT_NAME/components" "$payload")
local path
path=$(echo "$result" | jq -r '.data.path // .path // ""')
if [[ -z "$path" ]]; then
print_error "Failed to add $comp_type component"
echo "$result" | jq '.'
return 1
fi
print_success "Added $comp_type/$comp_name at $path"
return 0
}
run_full_validation() {
print_banner
# Step 1: Create project
print_header "Step 1: Creating Test Project"
echo "Project name: $PROJECT_NAME"
local create_payload
create_payload=$(jq -n \
--arg name "$PROJECT_NAME" \
--arg desc "Template validation test project" \
'{name: $name, description: $desc}')
local create_result
create_result=$(api_call POST "/project" "$create_payload")
local domain
domain=$(echo "$create_result" | jq -r '.data.domain // .domain // ""')
if [[ -z "$domain" ]]; then
print_error "Failed to create project"
echo "$create_result" | jq '.'
exit 1
fi
print_success "Project created with domain: $domain"
# Step 2: Add all component types
print_header "Step 2: Adding Components"
local comp_names=()
for comp_type in "${COMPONENT_TYPES[@]}"; do
local comp_name
case "$comp_type" in
service)
comp_name="api"
;;
worker)
comp_name="jobs"
;;
app-astro)
comp_name="landing"
;;
app-react)
comp_name="web"
;;
app-nextjs)
comp_name="dashboard"
;;
cli)
comp_name="ctl"
;;
esac
if add_component "$comp_type" "$comp_name"; then
comp_names+=("$comp_type:$comp_name")
fi
done
# Wait for git to sync
print_header "Step 3: Waiting for Git Sync"
echo "Waiting 15 seconds for git to propagate..."
sleep 15
# Step 4: Validate skeleton files
print_header "Step 4: Validating Skeleton"
validate_template_compilation "$PROJECT_NAME" || true
# Step 5: Validate each component
print_header "Step 5: Validating Components"
for comp in "${comp_names[@]}"; do
local type="${comp%%:*}"
local name="${comp##*:}"
validate_component "$PROJECT_NAME" "$type" "$name" || true
done
# Step 6: Check CI pipeline
print_header "Step 6: Checking CI Pipeline"
if wait_for_pipeline "$PROJECT_NAME" 60 5; then
print_success "CI pipeline completed successfully"
else
print_warning "CI pipeline did not complete (may need investigation)"
fi
# Summary
print_header "Validation Summary"
echo "Project: $PROJECT_NAME"
echo "Domain: https://$domain"
echo "Git: https://git.threesix.ai/$(get_git_owner)/$PROJECT_NAME"
echo "CI: https://ci.threesix.ai/$(get_git_owner)/$PROJECT_NAME"
echo ""
echo "Components tested:"
for comp in "${comp_names[@]}"; do
echo " - $comp"
done
echo ""
print_warning "Remember to clean up: $0 cleanup $PROJECT_NAME"
}
run_quick_validation() {
print_banner
echo "Quick validation: checking template API responses only"
echo ""
# Check skeleton template info
print_header "Checking Skeleton Template"
local skeleton_info
skeleton_info=$(api_call GET "/templates/skeleton")
echo "$skeleton_info" | jq '.data // .'
# Check component templates
print_header "Checking Component Templates"
local comp_templates
comp_templates=$(api_call GET "/templates/components")
echo "$comp_templates" | jq '.data.components // .data // .'
# Verify each component type exists
print_header "Verifying Component Types"
for comp_type in "${COMPONENT_TYPES[@]}"; do
local info
info=$(api_call GET "/templates/components/$comp_type" 2>/dev/null || echo '{}')
local found
found=$(echo "$info" | jq -r '.data.type // ""')
if [[ "$found" == "$comp_type" ]]; then
print_success "$comp_type template available"
else
print_error "$comp_type template not found"
fi
done
}
cleanup() {
local project_to_delete="${2:-$PROJECT_NAME}"
print_header "Cleaning Up: $project_to_delete"
local result
result=$(api_call DELETE "/project/$project_to_delete")
echo "$result" | jq '.'
print_success "Project deleted. Gitea repo preserved."
}
case "$COMMAND" in
run)
run_full_validation
;;
quick)
run_quick_validation
;;
cleanup)
cleanup "$@"
;;
*)
echo "Usage: $0 <command>"
echo "Commands:"
echo " run - Full validation (creates project, adds all components, checks CI)"
echo " quick - Quick check (API responses only, no project creation)"
echo " cleanup - Delete test project (optionally specify project name)"
exit 1
;;
esac

View File

@ -0,0 +1,926 @@
# SDLC Orchestration System - Implementation Breakdown
**Spec:** `docs/specs/sdlc-orchestration-system.md`
**Goal:** Build an enterprise-grade SDLC system where every action is deterministic, API-observable, and artifact-producing.
## Executive Summary
This breakdown covers 8 weeks of implementation:
- **Weeks 1-2:** Foundation (SDLC CLI tool + git structure)
- **Weeks 3-4:** rdev API Surface (state, features, artifacts, classifier)
- **Weeks 5-6:** Command Integration (update skeleton commands to use SDLC)
- **Week 7:** Execution & Orchestration (Claude task execution via API)
- **Week 8:** Polish & Documentation (full workflow validation, docs)
---
## Week 1: SDLC CLI Foundation
**Goal:** Build the `sdlc` CLI tool with core state management commands.
### Deliverables
| File | Action | Description |
|------|--------|-------------|
| `cmd/sdlc/main.go` | CREATE | CLI entry point with cobra |
| `cmd/sdlc/cmd/root.go` | CREATE | Root command, config loading |
| `cmd/sdlc/cmd/init.go` | CREATE | `sdlc init` - create .sdlc/ structure |
| `cmd/sdlc/cmd/state.go` | CREATE | `sdlc state` - show current state |
| `cmd/sdlc/cmd/config.go` | CREATE | `sdlc config` - manage config |
| `internal/sdlc/state.go` | CREATE | State struct, Load/Save functions |
| `internal/sdlc/config.go` | CREATE | Config struct, validation |
| `internal/sdlc/paths.go` | CREATE | Path constants and helpers |
### Implementation Details
#### `cmd/sdlc/main.go`
```go
package main
import "github.com/orchard9/rdev/cmd/sdlc/cmd"
func main() {
cmd.Execute()
}
```
#### `internal/sdlc/state.go`
```go
type State struct {
Version int `yaml:"version"`
Project ProjectState `yaml:"project"`
ActiveWork ActiveWork `yaml:"active_work"`
Blocked []BlockedItem `yaml:"blocked"`
LastUpdated time.Time `yaml:"last_updated"`
LastAction string `yaml:"last_action"`
LastActor string `yaml:"last_actor"`
History []HistoryEntry `yaml:"history"`
}
func LoadState(root string) (*State, error)
func (s *State) Save(root string) error
func (s *State) RecordAction(action, feature, actor string)
```
### Commands Implemented
```bash
sdlc init # Create .sdlc/ structure with templates
sdlc state # Show current state
sdlc config set <key> <val> # Update config
sdlc config show # Display config
```
### Tests
- `cmd/sdlc/cmd/init_test.go` - Verify directory structure created
- `internal/sdlc/state_test.go` - State load/save round-trip
### Exit Criteria
- [ ] `sdlc init` creates valid `.sdlc/` structure
- [ ] `sdlc state` reads and displays state.yaml
- [ ] `sdlc config` reads/writes config.yaml
- [ ] Unit tests pass
---
## Week 2: Feature & Artifact Management
**Goal:** Complete SDLC CLI with feature lifecycle and artifact management.
### Deliverables
| File | Action | Description |
|------|--------|-------------|
| `cmd/sdlc/cmd/feature.go` | CREATE | `sdlc feature` subcommands |
| `cmd/sdlc/cmd/artifact.go` | CREATE | `sdlc artifact` subcommands |
| `cmd/sdlc/cmd/task.go` | CREATE | `sdlc task` subcommands |
| `cmd/sdlc/cmd/branch.go` | CREATE | `sdlc branch` subcommands |
| `internal/sdlc/feature.go` | CREATE | Feature struct, manifest I/O |
| `internal/sdlc/artifact.go` | CREATE | Artifact types, status tracking |
| `internal/sdlc/task.go` | CREATE | Task parsing from tasks.md |
| `internal/sdlc/manifest.go` | CREATE | Manifest YAML operations |
### Implementation Details
#### `internal/sdlc/feature.go`
```go
type Feature struct {
Slug string `yaml:"slug"`
Title string `yaml:"title"`
Created time.Time `yaml:"created"`
Branch string `yaml:"branch"`
RoadmapRef string `yaml:"roadmap_ref"`
Phase FeaturePhase `yaml:"phase"`
PhaseHistory []PhaseTransition `yaml:"phase_history"`
Artifacts map[ArtifactType]*Artifact `yaml:"artifacts"`
Blockers []string `yaml:"blockers"`
Dependencies Dependencies `yaml:"dependencies"`
}
type FeaturePhase string
const (
PhaseDraft FeaturePhase = "draft"
PhaseSpecified FeaturePhase = "specified"
PhasePlanned FeaturePhase = "planned"
PhaseReady FeaturePhase = "ready"
PhaseImplementation FeaturePhase = "implementation"
PhaseReview FeaturePhase = "review"
PhaseAudit FeaturePhase = "audit"
PhaseQA FeaturePhase = "qa"
PhaseMerge FeaturePhase = "merge"
PhaseReleased FeaturePhase = "released"
)
func (f *Feature) CanTransitionTo(phase FeaturePhase) error
func (f *Feature) Transition(phase FeaturePhase) error
```
### Commands Implemented
```bash
sdlc feature create <slug> # Create new feature
sdlc feature list # List all features
sdlc feature show <slug> # Show feature details
sdlc feature status <slug> # Show phase + progress
sdlc feature transition <slug> <phase> # Manual transition
sdlc feature block <slug> <reason> # Add blocker
sdlc feature unblock <slug> # Remove blocker
sdlc artifact create <feature> <type> # Create artifact file
sdlc artifact approve <feature> <type> # Mark approved
sdlc artifact reject <feature> <type> # Mark rejected
sdlc artifact status <feature> # Show all statuses
sdlc task list <feature> # List tasks
sdlc task start <feature> <id> # Mark in-progress
sdlc task complete <feature> <id> # Mark complete
sdlc task add <feature> <title> # Add new task
sdlc branch create <feature> # Create feature branch
sdlc branch status <feature> # Show branch state
```
### Tests
- `internal/sdlc/feature_test.go` - Phase transitions, validation
- `internal/sdlc/artifact_test.go` - Status tracking
- `cmd/sdlc/cmd/feature_test.go` - CLI integration
### Exit Criteria
- [ ] Can create feature and see it in list
- [ ] Can transition feature through phases
- [ ] Artifact status tracked in manifest
- [ ] Task status parsed from tasks.md markdown
- [ ] Branch creation via `git checkout -b`
---
## Week 3: Classifier Engine
**Goal:** Build the deterministic classifier that evaluates state and returns next action.
### Deliverables
| File | Action | Description |
|------|--------|-------------|
| `cmd/sdlc/cmd/next.go` | CREATE | `sdlc next` command |
| `internal/sdlc/classifier/classifier.go` | CREATE | Main classifier engine |
| `internal/sdlc/classifier/rules.go` | CREATE | Built-in classification rules |
| `internal/sdlc/classifier/types.go` | CREATE | Classification result types |
| `internal/sdlc/classifier/context.go` | CREATE | Context builder for commands |
### Implementation Details
#### `internal/sdlc/classifier/classifier.go`
```go
type Classifier struct {
rules []Rule
}
type Rule struct {
ID string
Priority int
Condition func(*EvalContext) bool
Action ActionType
Message string
NextCommand string
OutputPath string
TransitionTo FeaturePhase
}
type Classification struct {
Timestamp time.Time
Context ClassificationContext
Evaluation Evaluation
Decision Decision
CommandContext map[string]any
}
func (c *Classifier) Classify(state *State, feature *Feature) (*Classification, error)
```
#### Rule Evaluation
```go
// Rules evaluated in priority order, first match wins
var DefaultRules = []Rule{
// BLOCKERS (priority 1000)
{ID: "blocked-dependency", Priority: 1000, ...},
// DRAFT → SPECIFIED (priority 200)
{ID: "needs-spec", Priority: 200, ...},
{ID: "spec-needs-approval", Priority: 201, ...},
{ID: "spec-approved", Priority: 202, ...},
// SPECIFIED → PLANNED (priority 300)
{ID: "needs-design", Priority: 300, ...},
// ...
}
```
### Commands Implemented
```bash
sdlc next # Classify global state
sdlc next --for <feature> # Classify specific feature
sdlc next --execute # Classify and execute
sdlc next --json # Output as JSON for API
```
### Output Format
```
┌─────────────────────────────────────────────────────────────────┐
│ SDLC Classifier Result │
├─────────────────────────────────────────────────────────────────┤
│ Feature: auth │
│ Current Phase: implementation │
│ Tasks: 5/8 complete, 1 in-progress, 2 pending │
├─────────────────────────────────────────────────────────────────┤
│ NEXT ACTION: IMPLEMENT_TASK │
│ │
│ Command: /implement-task auth task-004 │
│ Output: .sdlc/features/auth/tasks.md (updated) │
└─────────────────────────────────────────────────────────────────┘
```
### Tests
- `internal/sdlc/classifier/classifier_test.go` - Rule matching
- `internal/sdlc/classifier/rules_test.go` - Each rule condition
- Integration test: Full feature lifecycle classification
### Exit Criteria
- [ ] Classifier returns correct action for each phase
- [ ] Blocked items identified with resolution path
- [ ] `--json` output matches API format
- [ ] `--execute` triggers appropriate command
---
## Week 4: rdev API Surface (Part 1)
**Goal:** Add SDLC API endpoints to rdev for state, features, and artifacts.
### Deliverables
| File | Action | Description |
|------|--------|-------------|
| `internal/domain/sdlc.go` | CREATE | SDLC domain types |
| `internal/port/sdlc.go` | CREATE | SDLC repository interface |
| `internal/service/sdlc_service.go` | CREATE | SDLC business logic |
| `internal/handlers/sdlc.go` | CREATE | HTTP handlers |
| `internal/handlers/sdlc_features.go` | CREATE | Feature endpoints |
| `internal/handlers/sdlc_artifacts.go` | CREATE | Artifact endpoints |
| `cmd/rdev-api/main.go` | MODIFY | Wire SDLC handlers |
### API Endpoints
```
GET /projects/{project}/sdlc/state
GET /projects/{project}/sdlc/features
POST /projects/{project}/sdlc/features
GET /projects/{project}/sdlc/features/{slug}
POST /projects/{project}/sdlc/features/{slug}/transition
GET /projects/{project}/sdlc/features/{slug}/artifacts/{type}
POST /projects/{project}/sdlc/features/{slug}/artifacts/{type}
POST /projects/{project}/sdlc/features/{slug}/artifacts/{type}/approve
POST /projects/{project}/sdlc/features/{slug}/artifacts/{type}/reject
```
### Implementation Details
#### `internal/service/sdlc_service.go`
```go
type SDLCService struct {
gitOps port.PodGitOperations
classifier *classifier.Classifier
logger *slog.Logger
}
func (s *SDLCService) GetState(ctx context.Context, project string) (*domain.SDLCState, error) {
// Read .sdlc/state.yaml from project pod
content, err := s.gitOps.ReadFile(ctx, project, ".sdlc/state.yaml")
// Parse and return
}
func (s *SDLCService) CreateFeature(ctx context.Context, project string, req CreateFeatureRequest) (*domain.Feature, error) {
// Create .sdlc/features/{slug}/ directory structure
// Write manifest.yaml
// Update state.yaml
// Commit changes
}
```
### Handler Pattern
```go
func (h *SDLCHandler) GetState(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
defer cancel()
projectID := chi.URLParam(r, "id")
state, err := h.sdlcService.GetState(ctx, projectID)
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
api.WriteNotFound(w, "SDLC not initialized for project")
return
}
h.logger.Error("get sdlc state", "project", projectID, "error", err)
api.WriteInternalError(w, "failed to get SDLC state")
return
}
api.WriteSuccess(w, state)
}
```
### Tests
- `internal/handlers/sdlc_test.go` - Handler tests
- `internal/service/sdlc_service_test.go` - Service tests
### Exit Criteria
- [ ] `GET /projects/{id}/sdlc/state` returns state
- [ ] `POST /projects/{id}/sdlc/features` creates feature
- [ ] Artifact CRUD works
- [ ] All endpoints require auth
---
## Week 5: rdev API Surface (Part 2) - Classifier & Execution
**Goal:** Add classifier, blocker resolution, and execution endpoints.
### Deliverables
| File | Action | Description |
|------|--------|-------------|
| `internal/handlers/sdlc_classifier.go` | CREATE | Classifier endpoints |
| `internal/handlers/sdlc_execution.go` | CREATE | Execution endpoints |
| `internal/handlers/sdlc_resolution.go` | CREATE | Blocker resolution |
| `internal/service/sdlc_executor.go` | CREATE | Command execution service |
### API Endpoints
```
GET /projects/{project}/sdlc/next
GET /projects/{project}/sdlc/next?feature={slug}
GET /projects/{project}/sdlc/blocked
POST /projects/{project}/sdlc/resolve
POST /projects/{project}/sdlc/execute
POST /projects/{project}/sdlc/commit
```
### Implementation Details
#### `/sdlc/next` Response
```json
{
"classification": {
"rule_id": "implement-next-task",
"action": "IMPLEMENT_TASK",
"feature": "auth",
"task_id": "task-004"
},
"instruction": {
"summary": "Implement JWT token validation",
"command": "/implement-task auth task-004",
"output_path": ".sdlc/features/auth/tasks.md",
"context": {
"task_spec": "...",
"patterns_required": ["error-handling"],
"related_files": ["internal/auth/jwt.go"]
}
},
"execute_url": "/projects/my-project/sdlc/execute",
"can_auto_execute": true
}
```
#### `/sdlc/execute` Flow
```go
func (s *SDLCService) Execute(ctx context.Context, project string, req ExecuteRequest) (*ExecuteResult, error) {
// 1. Validate action matches current classification
classification, err := s.Classify(ctx, project, req.Feature)
if classification.Decision.Action != req.Action {
return nil, domain.ErrInvalidAction
}
// 2. Execute the command via Claude
result, err := s.executor.ExecuteCommand(ctx, project, classification.Decision.Command)
// 3. Verify output exists
if !s.fileExists(ctx, project, classification.Decision.OutputPath) {
return nil, domain.ErrOutputMissing
}
// 4. Update state
s.state.RecordAction(req.Action, req.Feature, "api")
// 5. Auto-commit if requested
if req.AutoCommit {
s.gitOps.Commit(ctx, project, generateCommitMessage(req))
}
return &ExecuteResult{...}, nil
}
```
### Blocker Resolution Flow
```
GET /sdlc/blocked returns:
{
"blocked": [{
"type": "feature",
"slug": "auth",
"rule_id": "design-decision-needed",
"blocked_reason": "Design decision required",
"resolution": {
"action": "ANSWER_QUESTION",
"question": "Which auth provider?",
"options": ["jwt", "oauth", "session"],
"resolve_url": "/projects/my-project/sdlc/resolve"
}
}]
}
POST /sdlc/resolve with:
{
"feature": "auth",
"resolution_type": "ANSWER_QUESTION",
"question_id": "auth-provider",
"answer": "jwt",
"rationale": "Stateless, works well with microservices"
}
```
### Tests
- `internal/handlers/sdlc_classifier_test.go`
- `internal/handlers/sdlc_execution_test.go`
- Integration test: Full blocker resolution flow
### Exit Criteria
- [ ] `/sdlc/next` returns correct classification
- [ ] `/sdlc/blocked` shows all blocked items
- [ ] `/sdlc/resolve` updates artifacts and unblocks
- [ ] `/sdlc/execute` runs commands and verifies output
---
## Week 6: Skeleton Command Integration
**Goal:** Update skeleton template commands to use SDLC tool and produce deterministic outputs.
### Deliverables
| File | Action | Description |
|------|--------|-------------|
| `.../skeleton/.claude/commands/spec-feature.md` | CREATE | Create specification |
| `.../skeleton/.claude/commands/design-feature.md` | CREATE | Create design doc |
| `.../skeleton/.claude/commands/breakdown-feature.md` | CREATE | Create task breakdown |
| `.../skeleton/.claude/commands/create-qa-plan.md` | CREATE | Create QA plan |
| `.../skeleton/.claude/commands/implement-task.md` | CREATE | Implement single task |
| `.../skeleton/.claude/commands/review-feature.md` | CREATE | Review feature code |
| `.../skeleton/.claude/commands/audit-feature.md` | CREATE | Audit feature |
| `.../skeleton/.claude/commands/run-qa.md` | CREATE | Run QA verification |
| `.../skeleton/.claude/commands/next.md` | MODIFY | Integrate with classifier |
| `.../skeleton/.claude/commands/deliver.md` | CREATE | Full feature delivery |
### Command Pattern
Each command MUST:
1. Read context from SDLC structure
2. Produce a concrete artifact
3. Write artifact to the correct location
4. Update manifest/state via `sdlc` CLI
5. Return structured output
#### `/spec-feature` Template
```markdown
---
description: Create a feature specification document
argument-hint: <feature-slug>
allowed-tools: Bash, Read, Write, Edit, AskUserQuestion
---
Create specification for feature: $ARGUMENTS
## Instructions
### 1. Validate Feature Exists
```bash
sdlc feature show $ARGUMENTS
```
If not found, create it first with `sdlc feature create $ARGUMENTS`.
### 2. Gather Requirements
Use AskUserQuestion to understand:
- What problem does this solve?
- Who are the users?
- What are the acceptance criteria?
### 3. Write Specification
Write to `.sdlc/features/{slug}/spec.md` using template:
```markdown
---
feature: {slug}
version: 1
status: draft
---
# Feature: {Title}
## Overview
[2-3 sentence summary]
## Problem Statement
[What problem does this solve?]
## User Stories
- As a [user], I want to [action] so that [benefit]
## Acceptance Criteria
- [ ] AC1: ...
## Non-Functional Requirements
[Performance, security, availability]
## Out of Scope
[What this feature does NOT include]
## Dependencies
[Other features, patterns, infrastructure]
## Open Questions
[Must be empty before approval]
```
### 4. Update SDLC State
```bash
sdlc artifact create $ARGUMENTS spec
```
### 5. Output Summary
```markdown
## Created: spec.md
**Feature:** {slug}
**Path:** `.sdlc/features/{slug}/spec.md`
**Status:** draft
### Next Steps
Run `sdlc next --for {slug}` to see required action (approval needed).
```
```
#### `/implement-task` Template
```markdown
---
description: Implement a specific task from the task breakdown
argument-hint: <feature-slug> <task-id>
allowed-tools: Bash, Read, Write, Edit, Glob, Grep, Task
---
Implement task $ARGUMENTS
## Instructions
### 1. Load Task Context
```bash
sdlc task show {feature} {task-id}
```
### 2. Load Feature Context
Read:
- `.sdlc/features/{feature}/spec.md` - Requirements
- `.sdlc/features/{feature}/design.md` - Architecture
- `.sdlc/features/{feature}/tasks.md` - Full task list
### 3. Mark Task In-Progress
```bash
sdlc task start {feature} {task-id}
```
### 4. Implement
Follow the task specification. Ensure:
- Code follows required patterns
- Tests included
- No tech debt introduced
### 5. Update Task Status
```bash
sdlc task complete {feature} {task-id}
```
### 6. Output Summary
```markdown
## Completed: {task-id}
**Feature:** {feature}
**Task:** {task-title}
**Files Modified:**
- {list of files}
**Tests Added:**
- {list of tests}
### Task Progress
{completed}/{total} tasks complete
### Next Steps
Run `sdlc next --for {feature}` to continue.
```
```
### Exit Criteria
- [ ] Each command produces artifact at documented location
- [ ] Each command updates SDLC state
- [ ] Each command has structured output format
- [ ] `/next` integrates with `sdlc next` CLI
- [ ] `/deliver` orchestrates full delivery flow
---
## Week 7: Git Integration & Branch Management
**Goal:** Complete git operations for branch lifecycle and merge flow.
### Deliverables
| File | Action | Description |
|------|--------|-------------|
| `internal/handlers/sdlc_branches.go` | CREATE | Branch endpoints |
| `internal/handlers/sdlc_merge.go` | CREATE | Merge endpoint |
| `internal/service/sdlc_git.go` | CREATE | Git operations service |
| `cmd/sdlc/cmd/merge.go` | CREATE | `sdlc merge` command |
| `.../skeleton/.claude/commands/merge-feature.md` | CREATE | Merge command |
### API Endpoints
```
POST /projects/{project}/sdlc/branches
GET /projects/{project}/sdlc/branches/{branch}
POST /projects/{project}/sdlc/branches/{branch}/sync
POST /projects/{project}/sdlc/merge
```
### Implementation Details
#### Branch Creation
```go
func (s *SDLCService) CreateBranch(ctx context.Context, project, feature string) (*BranchInfo, error) {
// 1. Verify feature is in 'planned' phase
f, err := s.GetFeature(ctx, project, feature)
if f.Phase != PhasePlanned {
return nil, domain.ErrInvalidPhase
}
// 2. Create branch
branchName := fmt.Sprintf("feature/%s", feature)
err = s.gitOps.CreateBranch(ctx, project, branchName, "main")
// 3. Track branch in .sdlc/branches/{branch}.yaml
s.writeBranchManifest(ctx, project, branchName, feature)
// 4. Update feature manifest
f.Branch = branchName
s.SaveFeature(ctx, project, f)
// 5. Transition to 'ready'
s.TransitionFeature(ctx, project, feature, PhaseReady)
return &BranchInfo{Name: branchName, Feature: feature}, nil
}
```
#### Merge Flow
```go
func (s *SDLCService) Merge(ctx context.Context, project string, req MergeRequest) (*MergeResult, error) {
// 1. Verify feature completed all gates
f, err := s.GetFeature(ctx, project, req.Feature)
if f.Phase != PhaseMerge {
return nil, domain.ErrNotReadyToMerge
}
// Gates checklist
gates := []string{"review_passed", "audit_passed", "qa_passed"}
for _, gate := range gates {
if !f.GatePassed(gate) {
return nil, fmt.Errorf("gate not passed: %s", gate)
}
}
// 2. Merge branch
switch req.Strategy {
case "squash":
err = s.gitOps.SquashMerge(ctx, project, f.Branch, "main")
case "merge":
err = s.gitOps.Merge(ctx, project, f.Branch, "main")
}
// 3. Delete branch if requested
if req.DeleteBranch {
s.gitOps.DeleteBranch(ctx, project, f.Branch)
}
// 4. Transition to 'released'
s.TransitionFeature(ctx, project, req.Feature, PhaseReleased)
// 5. Archive feature
s.ArchiveFeature(ctx, project, req.Feature)
return &MergeResult{...}, nil
}
```
### CLI Commands
```bash
sdlc branch create <feature> # Create feature branch
sdlc branch status <feature> # Show branch state + checklist
sdlc branch sync <feature> # Rebase on main
sdlc merge <feature> # Merge feature to main
sdlc archive <feature> # Move to .sdlc/archives/
```
### Tests
- `internal/handlers/sdlc_branches_test.go`
- `internal/handlers/sdlc_merge_test.go`
- Integration: Full feature lifecycle with branch
### Exit Criteria
- [ ] Branch created for feature in 'planned' phase
- [ ] Branch status shows merge checklist
- [ ] Sync rebases on main
- [ ] Merge validates all gates
- [ ] Archive moves feature to archives/
---
## Week 8: Polish, Documentation & Validation
**Goal:** Complete documentation, validate full workflow, and polish UX.
### Deliverables
| File | Action | Description |
|------|--------|-------------|
| `docs/guides/sdlc/getting-started.md` | CREATE | Quick start guide |
| `docs/guides/sdlc/cli-reference.md` | CREATE | Full CLI docs |
| `docs/guides/sdlc/api-reference.md` | CREATE | API docs |
| `docs/guides/sdlc/command-catalog.md` | CREATE | Claude command docs |
| `.claude/guides/services/sdlc.md` | CREATE | Guide entry point |
| `CLAUDE.md` | MODIFY | Add SDLC guide link |
| `cookbooks/scripts/sdlc-test.sh` | CREATE | E2E validation script |
### Validation Scenarios
#### Scenario 1: Full Feature Delivery
```bash
# Initialize SDLC
sdlc init
# Create feature
sdlc feature create user-auth
# Run through full lifecycle via API
curl -X POST $API/projects/test/sdlc/features/user-auth/execute \
-d '{"action": "CREATE_SPEC"}'
# Keep running until complete
while true; do
NEXT=$(curl $API/projects/test/sdlc/next?feature=user-auth)
if [ "$NEXT.action" == "IDLE" ]; then break; fi
curl -X POST $API/projects/test/sdlc/execute -d "$NEXT"
done
```
#### Scenario 2: Blocker Resolution
```bash
# Get blocked items
BLOCKED=$(curl $API/projects/test/sdlc/blocked)
# Resolve design decision
curl -X POST $API/projects/test/sdlc/resolve \
-d '{"feature": "user-auth", "answer": "jwt"}'
# Verify unblocked
curl $API/projects/test/sdlc/next?feature=user-auth
```
#### Scenario 3: Claude Command Integration
```bash
# In Claude Code session
/spec-feature user-auth
# → Creates .sdlc/features/user-auth/spec.md
/next
# → Shows next action (await approval)
# User approves
sdlc artifact approve user-auth spec
/next
# → Shows CREATE_DESIGN action
/design-feature user-auth
# → Creates design.md
# Continue through full delivery...
```
### Documentation Structure
```
docs/guides/sdlc/
├── getting-started.md # 5-min quickstart
├── concepts.md # State machine, classifier, artifacts
├── cli-reference.md # Full sdlc CLI docs
├── api-reference.md # Full API docs with examples
├── command-catalog.md # All Claude commands
├── patterns.md # Pattern management
└── troubleshooting.md # Common issues
```
### Exit Criteria
- [ ] E2E test script runs full feature delivery
- [ ] All documentation complete
- [ ] CLAUDE.md updated with guide link
- [ ] `sdlc` CLI has `--help` for all commands
- [ ] API returns helpful error messages
- [ ] Blocker resolution flow works end-to-end
---
## Risk Mitigation
### Risk: Complex Git Operations
**Mitigation:** Use existing `PodGitOperations` adapter, add integration tests with real git repos.
### Risk: Classifier Rule Complexity
**Mitigation:** Start with feature lifecycle rules only, add pattern rules in follow-up.
### Risk: State Corruption
**Mitigation:** All state changes go through `sdlc` CLI which validates, add recovery command.
### Risk: Command Output Verification
**Mitigation:** Each command has explicit output path, executor verifies file exists.
---
## Dependencies
| Dependency | Required For | Status |
|------------|--------------|--------|
| `PodGitOperations` | Git operations in pods | ✓ Exists |
| `cobra` CLI framework | `sdlc` CLI | Add to go.mod |
| Project pod executor | Command execution | ✓ Exists |
| YAML parsing | State/manifest | ✓ `gopkg.in/yaml.v3` |
---
## Follow-Up Work (Post-MVP)
1. **Pattern Management** - Full pattern lifecycle (elevate, examples, violations)
2. **Roadmap Management** - Version roadmaps, milestones
3. **Audit System** - Codebase-wide audits
4. **Metrics Dashboard** - Cycle time, velocity tracking
5. **SSE Streaming** - Real-time execution status
6. **Rollback Support** - Phase rollback with cleanup
7. **Custom Rules** - Project-specific classifier rules
8. **CI Integration** - Auto-trigger SDLC actions from Woodpecker

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,258 @@
# Dashboard App Example
This example demonstrates a full-stack composable monorepo project using rdev's enhanced templates with:
- **Chassis patterns**: Wrap, Bind, HTTPError, Health probes
- **Design system**: packages/ui, packages/layout
- **Authentication**: pkg/auth, packages/auth
- **OpenAPI**: Auto-generated TypeScript client
- **Next.js 14**: App Router with server actions
## Creating This Example
```bash
# 1. Create the project
curl -X POST "$RDEV_API_URL/project" \
-H "X-API-Key: $RDEV_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "dashboard-app",
"description": "Example dashboard application"
}'
# 2. Add the API service
curl -X POST "$RDEV_API_URL/projects/dashboard-app/components" \
-H "X-API-Key: $RDEV_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"type": "service",
"name": "api"
}'
# 3. Add the Next.js dashboard
curl -X POST "$RDEV_API_URL/projects/dashboard-app/components" \
-H "X-API-Key: $RDEV_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"type": "app-nextjs",
"name": "dashboard"
}'
```
## Project Structure
After creation, the project looks like:
```
dashboard-app/
├── CLAUDE.md # AI routing with guides
├── README.md # Project docs
├── go.work # Go workspace
├── pnpm-workspace.yaml # pnpm monorepo config
├── .woodpecker.yml # CI pipeline
├── pkg/ # Shared Go packages
│ ├── app/ # Wrap, Bind, Health
│ │ ├── app.go # NewApp, Run
│ │ ├── handler.go # Wrap pattern
│ │ ├── bind.go # BindAndValidate
│ │ └── health.go # Health probes
│ ├── httperror/ # Typed HTTP errors
│ │ └── error.go # Sentinels, factory functions
│ ├── httpresponse/ # JSON envelope
│ ├── httpvalidation/ # Validator wrapper
│ ├── middleware/ # HTTP middleware
│ └── auth/ # JWT + API key auth
│ ├── auth.go # Interface, types
│ ├── context.go # GetUser, SetUser
│ ├── jwt.go # JWTValidator
│ ├── apikey.go # APIKeyValidator
│ └── middleware.go # Auth middleware
├── packages/ # Shared TypeScript packages
│ ├── ui/ # Design system
│ │ ├── src/styles/tokens.css # CSS custom properties
│ │ ├── src/utils/cn.ts # clsx + tailwind-merge
│ │ └── src/components/ # Button, Card, Input, etc.
│ ├── layout/ # Shell components
│ │ ├── src/DashboardShell.tsx # Main layout
│ │ ├── src/Sidebar.tsx # Navigation
│ │ └── src/Header.tsx # Top bar
│ ├── auth/ # React auth
│ │ ├── src/AuthProvider.tsx # Context provider
│ │ ├── src/useAuth.ts # Hook
│ │ └── src/ProtectedRoute.tsx # Route guard
│ ├── logger/ # Structured logging
│ └── api-client/ # Generated client
│ └── src/schema.d.ts # TypeScript types
├── services/ # Backend services
│ └── api/
│ ├── cmd/server/main.go # Entry point
│ ├── internal/
│ │ ├── api/routes.go # Chi routes with Wrap
│ │ ├── config/config.go # Configuration
│ │ └── domain/ # Business models
│ ├── migrations/ # SQL migrations
│ ├── Dockerfile # Multi-stage Go build
│ └── component.yaml # Port, dependencies
└── apps/ # Frontend applications
└── dashboard/
├── src/app/
│ ├── layout.tsx # Root layout
│ ├── globals.css # Design tokens import
│ ├── (dashboard)/ # Protected route group
│ │ ├── layout.tsx # DashboardShell
│ │ ├── page.tsx # Home
│ │ └── settings/page.tsx
│ └── login/page.tsx # Public login
├── src/components/
│ └── providers.tsx # AuthProvider wrapper
├── src/actions/ # Server actions
├── Dockerfile # Next.js standalone
└── component.yaml
```
## Development Workflow
### Local Development
```bash
# Start infrastructure (Postgres, Redis)
docker-compose up -d
# Install dependencies
./scripts/install.sh
# Start all services
./scripts/dev.sh
```
### Building a Feature
1. **Create feature branch**
```bash
git checkout -b feature/user-profile
```
2. **Add API endpoint** using Wrap + Bind:
```go
func (h *Handler) GetMe() http.HandlerFunc {
return app.Wrap(func(w http.ResponseWriter, r *http.Request) error {
user, ok := auth.GetUser(r.Context())
if !ok {
return httperror.Unauthorized("not authenticated")
}
profile, err := h.svc.GetByID(r.Context(), user.ID)
if err != nil {
return httperror.WrapError(err, "failed to get profile")
}
httpresponse.JSON(w, http.StatusOK, profile)
return nil
})
}
```
3. **Regenerate TypeScript client**:
```bash
./scripts/generate-client.sh
```
4. **Add frontend page** using design system:
```tsx
import { Card, CardHeader, CardTitle } from '@dashboard-app/ui';
import { useAuth } from '@dashboard-app/auth';
export default function ProfilePage() {
const { user } = useAuth();
return (
<Card>
<CardHeader>
<CardTitle>Profile: {user?.name}</CardTitle>
</CardHeader>
</Card>
);
}
```
5. **Run tests and quality checks**:
```bash
./scripts/quality.sh
```
6. **Commit and push**:
```bash
git add -A
git commit -m "feat: add user profile"
git push -u origin feature/user-profile
```
## Key Patterns
### Backend: Wrap Pattern
```go
// Error-returning handlers become http.HandlerFunc
app.Wrap(func(w http.ResponseWriter, r *http.Request) error {
// Return errors - they become proper HTTP responses
if err := doThing(); err != nil {
return httperror.Internal("something went wrong")
}
httpresponse.JSON(w, http.StatusOK, data)
return nil
})
```
### Backend: Bind Pattern
```go
// Decode + validate in one call
var req CreateItemRequest
if err := app.BindAndValidate(r, &req); err != nil {
return err // Validation errors become 400 with details
}
```
### Frontend: Auth Hook
```tsx
const { user, isLoading, login, logout } = useAuth();
if (isLoading) return <Spinner />;
if (!user) return <LoginRedirect />;
```
### Frontend: Design System
```tsx
import { Button, Card, Input, Badge } from '@dashboard-app/ui';
import { DashboardShell, Sidebar } from '@dashboard-app/layout';
```
## Deployment
CI/CD is automated via Woodpecker:
1. Push triggers build
2. Kaniko builds Docker images
3. Images pushed to registry
4. kubectl deploys to K8s
Access the deployed app:
```bash
open https://<slug>.threesix.ai
```
## Cleanup
```bash
curl -X DELETE "$RDEV_API_URL/project/dashboard-app" \
-H "X-API-Key: $RDEV_API_KEY"
```
## Related Documentation
- [Feature Development Cookbook](../../cookbooks/feature-development.md)
- [Composable App Cookbook](../../cookbooks/composable-app.md)
- [API Framework Guide](../../.claude/guides/packages/api-framework.md)

View File

@ -73,6 +73,13 @@ var availableComponentTemplates = []port.ComponentTemplateInfo{
DefaultPort: 5173, DefaultPort: 5173,
DestDir: "apps", DestDir: "apps",
}, },
{
Type: "app-nextjs",
Description: "Next.js 14 dashboard with App Router and design system",
Stack: "nextjs",
DefaultPort: 3000,
DestDir: "apps",
},
{ {
Type: "cli", Type: "cli",
Description: "Go CLI tool using Cobra", Description: "Go CLI tool using Cobra",

View File

@ -0,0 +1,6 @@
{
"extends": ["next/core-web-vitals"],
"rules": {
"@next/next/no-html-link-for-pages": "off"
}
}

View File

@ -0,0 +1,26 @@
# Woodpecker CI step for {{COMPONENT_NAME}} Next.js app
# Add this step to your .woodpecker.yml
build-{{COMPONENT_NAME}}:
image: woodpeckerci/plugin-kaniko
settings:
registry: registry.threesix.ai
repo: {{PROJECT_NAME}}/{{COMPONENT_NAME}}
tags:
- latest
- ${CI_COMMIT_SHA:0:8}
context: .
dockerfile: apps/{{COMPONENT_NAME}}/Dockerfile
cache: true
skip-tls-verify: true
when:
branch: main
event: push
deploy-{{COMPONENT_NAME}}:
image: bitnami/kubectl:latest
commands:
- kubectl set image deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} {{COMPONENT_NAME}}=registry.threesix.ai/{{PROJECT_NAME}}/{{COMPONENT_NAME}}:${CI_COMMIT_SHA:0:8} -n projects || echo "Deployment not found, skipping"
when:
branch: main
event: push

View File

@ -0,0 +1,58 @@
# Build stage - using pnpm for workspace dependency resolution
FROM node:20-alpine AS deps
# Install pnpm
RUN npm install -g pnpm
WORKDIR /workspace
# Copy workspace configuration files
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml* ./
# Copy shared packages (required for workspace:* dependencies)
COPY packages/ ./packages/
# Copy the app component
COPY apps/{{COMPONENT_NAME}}/ ./apps/{{COMPONENT_NAME}}/
# Install dependencies using pnpm (resolves workspace:* correctly)
RUN pnpm install --frozen-lockfile || pnpm install
# Build stage
FROM node:20-alpine AS builder
RUN npm install -g pnpm
WORKDIR /workspace
COPY --from=deps /workspace ./
# Build the Next.js app
WORKDIR /workspace/apps/{{COMPONENT_NAME}}
RUN pnpm build
# Production stage
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy built standalone output
COPY --from=builder /workspace/apps/{{COMPONENT_NAME}}/.next/standalone ./
COPY --from=builder /workspace/apps/{{COMPONENT_NAME}}/.next/static ./apps/{{COMPONENT_NAME}}/.next/static
COPY --from=builder /workspace/apps/{{COMPONENT_NAME}}/public ./apps/{{COMPONENT_NAME}}/public
USER nextjs
EXPOSE {{PORT}}
ENV PORT={{PORT}}
ENV HOSTNAME="0.0.0.0"
# Start the Next.js server
CMD ["node", "apps/{{COMPONENT_NAME}}/server.js"]

View File

@ -0,0 +1,6 @@
name: {{COMPONENT_NAME}}
type: app
port: {{PORT}}
path: apps/{{COMPONENT_NAME}}
stack: nextjs
dependencies: []

View File

@ -0,0 +1,24 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
// Enable React strict mode for better development experience
reactStrictMode: true,
// Output as standalone for Docker deployment
output: 'standalone',
// Enable transpilation of workspace packages
transpilePackages: [
'@{{PROJECT_NAME}}/ui',
'@{{PROJECT_NAME}}/layout',
'@{{PROJECT_NAME}}/auth',
'@{{PROJECT_NAME}}/logger',
],
// Environment variables available in the browser
env: {
NEXT_PUBLIC_APP_NAME: '{{COMPONENT_NAME}}',
},
};
export default nextConfig;

View File

@ -0,0 +1,34 @@
{
"name": "{{COMPONENT_NAME}}",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "next dev -p {{PORT}}",
"build": "next build",
"start": "next start -p {{PORT}}",
"lint": "next lint",
"format": "prettier --write src/"
},
"dependencies": {
"@{{PROJECT_NAME}}/auth": "workspace:*",
"@{{PROJECT_NAME}}/layout": "workspace:*",
"@{{PROJECT_NAME}}/logger": "workspace:*",
"@{{PROJECT_NAME}}/ui": "workspace:*",
"next": "^14.2.5",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/node": "^20.14.10",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.19",
"eslint": "^8.57.0",
"eslint-config-next": "^14.2.5",
"postcss": "^8.4.38",
"prettier": "^3.3.2",
"tailwindcss": "^3.4.4",
"typescript": "^5.5.3"
}
}

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="18" x="3" y="3" rx="2"/>
<path d="M9 9h6v6H9z"/>
</svg>

After

Width:  |  Height:  |  Size: 267 B

View File

@ -0,0 +1,85 @@
'use server';
import { revalidatePath } from 'next/cache';
/**
* Example server action for creating an item.
* Server actions run on the server and can be called from client components.
*
* @example
* // In a client component:
* import { createItem } from '@/actions/example';
*
* <form action={createItem}>
* <input name="name" />
* <button type="submit">Create</button>
* </form>
*/
export async function createItem(formData: FormData) {
const name = formData.get('name') as string;
if (!name || name.trim().length === 0) {
return { success: false, error: 'Name is required' };
}
// Example: Call your API service
// const response = await fetch(`${process.env.API_URL}/api/items`, {
// method: 'POST',
// headers: {
// 'Content-Type': 'application/json',
// 'Authorization': `Bearer ${getToken()}`,
// },
// body: JSON.stringify({ name }),
// });
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 500));
// Revalidate the page data
revalidatePath('/dashboard');
return { success: true, data: { id: crypto.randomUUID(), name } };
}
/**
* Example server action for deleting an item.
*/
export async function deleteItem(id: string) {
if (!id) {
return { success: false, error: 'ID is required' };
}
// Example: Call your API service
// await fetch(`${process.env.API_URL}/api/items/${id}`, {
// method: 'DELETE',
// });
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 300));
// Revalidate the page data
revalidatePath('/dashboard');
return { success: true };
}
/**
* Example server action for fetching data.
* This can be used for server-side data fetching in Server Components.
*/
export async function getItems() {
// Example: Fetch from your API
// const response = await fetch(`${process.env.API_URL}/api/items`, {
// next: { revalidate: 60 }, // Cache for 60 seconds
// });
// return response.json();
// Simulate API response
return {
items: [
{ id: '1', name: 'Item 1', createdAt: new Date().toISOString() },
{ id: '2', name: 'Item 2', createdAt: new Date().toISOString() },
],
total: 2,
};
}

View File

@ -0,0 +1,76 @@
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Button,
Badge,
} from '@{{PROJECT_NAME}}/ui';
export default function DashboardPage() {
return (
<div className="space-y-6">
{/* Welcome card */}
<Card>
<CardHeader>
<CardTitle>Welcome to {{COMPONENT_NAME}}</CardTitle>
<CardDescription>
This is your Next.js 14 dashboard, part of the{' '}
<code className="bg-[var(--surface-200)] px-1.5 py-0.5 rounded text-sm">
{{PROJECT_NAME}}
</code>{' '}
monorepo.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex gap-3">
<Button>Get Started</Button>
<Button variant="outline">View Docs</Button>
</div>
</CardContent>
</Card>
{/* Stats grid */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardHeader className="pb-2">
<CardDescription>Total Users</CardDescription>
<CardTitle className="text-3xl">1,234</CardTitle>
</CardHeader>
<CardContent>
<Badge variant="success">+12% from last month</Badge>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Active Sessions</CardDescription>
<CardTitle className="text-3xl">567</CardTitle>
</CardHeader>
<CardContent>
<Badge variant="info">Live</Badge>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>API Requests</CardDescription>
<CardTitle className="text-3xl">89.2k</CardTitle>
</CardHeader>
<CardContent>
<Badge variant="warning">High traffic</Badge>
</CardContent>
</Card>
</div>
{/* Edit hint */}
<p className="text-sm text-[var(--text-muted)]">
Edit this file at{' '}
<code className="bg-[var(--surface-200)] px-1.5 py-0.5 rounded">
apps/{{COMPONENT_NAME}}/src/app/(dashboard)/dashboard/page.tsx
</code>
</p>
</div>
);
}

View File

@ -0,0 +1,81 @@
'use client';
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Button,
Input,
Label,
Checkbox,
} from '@{{PROJECT_NAME}}/ui';
export default function SettingsPage() {
return (
<div className="space-y-6 max-w-2xl">
<Card>
<CardHeader>
<CardTitle>Profile Settings</CardTitle>
<CardDescription>
Manage your account settings and preferences.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Name field */}
<div className="space-y-2">
<Label htmlFor="name">Display Name</Label>
<Input
id="name"
placeholder="Your name"
defaultValue="John Doe"
/>
</div>
{/* Email field */}
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
defaultValue="john@example.com"
/>
</div>
{/* Notifications */}
<div className="space-y-3">
<Label>Notifications</Label>
<div className="flex items-center gap-2">
<Checkbox id="email-notifications" defaultChecked />
<Label htmlFor="email-notifications" className="font-normal">
Email notifications
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox id="push-notifications" />
<Label htmlFor="push-notifications" className="font-normal">
Push notifications
</Label>
</div>
</div>
<Button>Save Changes</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Danger Zone</CardTitle>
<CardDescription>
Irreversible account actions.
</CardDescription>
</CardHeader>
<CardContent>
<Button variant="destructive">Delete Account</Button>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,72 @@
'use client';
import { useMemo, useCallback } from 'react';
import { DashboardShell, Sidebar, Header, type NavItem } from '@{{PROJECT_NAME}}/layout';
import { Home, Users, Settings, BarChart3 } from '@{{PROJECT_NAME}}/ui';
import { useRouter, usePathname } from 'next/navigation';
const navItems: NavItem[] = [
{ label: 'Dashboard', href: '/dashboard', icon: Home },
{ label: 'Analytics', href: '/dashboard/analytics', icon: BarChart3 },
{ label: 'Users', href: '/dashboard/users', icon: Users },
{ label: 'Settings', href: '/dashboard/settings', icon: Settings },
];
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const router = useRouter();
const pathname = usePathname();
// Memoize active nav items based on current path
const itemsWithActive = useMemo(
() =>
navItems.map((item) => ({
...item,
active: pathname === item.href || pathname.startsWith(item.href + '/'),
})),
[pathname]
);
// Memoize navigation handler
const handleNavigate = useCallback(
(href: string) => router.push(href),
[router]
);
// Get current page title
const currentTitle = useMemo(
() => itemsWithActive.find((i) => i.active)?.label || 'Dashboard',
[itemsWithActive]
);
return (
<DashboardShell
sidebar={
<Sidebar
logo={
<span className="font-semibold text-lg">{{PROJECT_NAME}}</span>
}
items={itemsWithActive}
onNavigate={handleNavigate}
footer={
<div className="text-sm text-[var(--text-muted)]">
v0.0.1
</div>
}
/>
}
header={
<Header
title={currentTitle}
showSearch
searchPlaceholder="Search..."
/>
}
>
{children}
</DashboardShell>
);
}

View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@ -0,0 +1,23 @@
import type { Metadata } from 'next';
import { Providers } from '@/components/providers';
import '@{{PROJECT_NAME}}/ui/styles';
import './globals.css';
export const metadata: Metadata = {
title: '{{COMPONENT_NAME}}',
description: 'Dashboard application',
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className="dark">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}

View File

@ -0,0 +1,82 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Button,
Input,
Label,
} from '@{{PROJECT_NAME}}/ui';
import { useAuth } from '@{{PROJECT_NAME}}/auth';
export default function LoginPage() {
const router = useRouter();
const { login, isLoading, error } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await login({ email, password });
router.push('/dashboard');
} catch {
// Error is handled by useAuth
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-[var(--background)]">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Welcome back</CardTitle>
<CardDescription>
Sign in to your account to continue
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="p-3 rounded-md bg-[var(--error-bg)] border border-[var(--error-border)] text-sm text-[var(--error)]">
{error.message}
</div>
)}
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<Button type="submit" className="w-full" loading={isLoading}>
Sign in
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,6 @@
import { redirect } from 'next/navigation';
export default function Home() {
// Redirect to dashboard by default
redirect('/dashboard');
}

View File

@ -0,0 +1,15 @@
'use client';
import { AuthProvider } from '@{{PROJECT_NAME}}/auth';
export function Providers({ children }: { children: React.ReactNode }) {
return (
<AuthProvider
loginUrl="/api/auth/login"
logoutUrl="/api/auth/logout"
storage="localStorage"
>
{children}
</AuthProvider>
);
}

View File

@ -0,0 +1,2 @@
// Re-export cn from @{{PROJECT_NAME}}/ui for convenience
export { cn } from '@{{PROJECT_NAME}}/ui';

View File

@ -0,0 +1,16 @@
import type { Config } from 'tailwindcss';
const config: Config = {
content: [
'./src/**/*.{js,ts,jsx,tsx,mdx}',
// Include shared packages for Tailwind classes
'../../packages/ui/src/**/*.{js,ts,jsx,tsx}',
'../../packages/layout/src/**/*.{js,ts,jsx,tsx}',
],
theme: {
extend: {},
},
plugins: [],
};
export default config;

View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@ -12,8 +12,11 @@
}, },
"dependencies": { "dependencies": {
"@{{PROJECT_NAME}}/logger": "workspace:*", "@{{PROJECT_NAME}}/logger": "workspace:*",
"@{{PROJECT_NAME}}/ui": "workspace:*",
"@{{PROJECT_NAME}}/layout": "workspace:*",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1",
"react-router-dom": "^6.23.1"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.3.3", "@types/react": "^18.3.3",

View File

@ -1,45 +1,112 @@
import { DashboardShell, Sidebar, Header, type NavItem } from '@{{PROJECT_NAME}}/layout';
import {
Button,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Badge,
Home,
Users,
Settings,
BarChart3,
} from '@{{PROJECT_NAME}}/ui';
const navItems: NavItem[] = [
{ label: 'Dashboard', href: '/', icon: Home, active: true },
{ label: 'Analytics', href: '/analytics', icon: BarChart3 },
{ label: 'Users', href: '/users', icon: Users, badge: '12' },
{ label: 'Settings', href: '/settings', icon: Settings },
];
function App() { function App() {
return ( return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 to-slate-800"> <DashboardShell
<div className="container mx-auto px-4 py-16"> sidebar={
<div className="text-center"> <Sidebar
<h1 className="text-5xl font-bold text-white mb-6"> logo={
{{COMPONENT_NAME}} <span className="font-semibold text-lg">{{PROJECT_NAME}}</span>
</h1> }
<p className="text-xl text-slate-300 mb-8 max-w-2xl mx-auto"> items={navItems}
Welcome to your React app. This is part of the{' '} footer={
<code className="bg-slate-700 px-2 py-1 rounded">{{PROJECT_NAME}}</code>{' '} <div className="text-sm text-[var(--text-muted)]">
monorepo. v0.0.1
</p> </div>
<p className="text-slate-400 mb-8"> }
Edit this file at{' '} />
<code className="bg-slate-700 px-2 py-1 rounded"> }
apps/{{COMPONENT_NAME}}/src/App.tsx header={
</code> <Header
</p> title="Dashboard"
<div className="flex gap-4 justify-center"> showSearch
<a searchPlaceholder="Search..."
href="https://react.dev" />
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition" }
> >
React Docs <div className="space-y-6">
</a> {/* Welcome card */}
<a <Card>
href="https://vitejs.dev" <CardHeader>
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition" <CardTitle>Welcome to {{COMPONENT_NAME}}</CardTitle>
> <CardDescription>
Vite Docs This is part of the{' '}
</a> <code className="bg-[var(--surface-200)] px-1.5 py-0.5 rounded text-sm">
<a {{PROJECT_NAME}}
href="{{GIT_URL}}" </code>{' '}
className="px-6 py-3 bg-slate-700 text-white rounded-lg hover:bg-slate-600 transition" monorepo, using the shared UI library and layout components.
> </CardDescription>
View Source </CardHeader>
</a> <CardContent>
</div> <div className="flex gap-3">
<Button>Get Started</Button>
<Button variant="outline">Documentation</Button>
</div>
</CardContent>
</Card>
{/* Stats cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardHeader className="pb-2">
<CardDescription>Total Users</CardDescription>
<CardTitle className="text-3xl">1,234</CardTitle>
</CardHeader>
<CardContent>
<Badge variant="success">+12% from last month</Badge>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Active Sessions</CardDescription>
<CardTitle className="text-3xl">567</CardTitle>
</CardHeader>
<CardContent>
<Badge variant="info">Live</Badge>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>API Requests</CardDescription>
<CardTitle className="text-3xl">89.2k</CardTitle>
</CardHeader>
<CardContent>
<Badge variant="warning">High traffic</Badge>
</CardContent>
</Card>
</div> </div>
{/* Edit hint */}
<p className="text-sm text-[var(--text-muted)]">
Edit this file at{' '}
<code className="bg-[var(--surface-200)] px-1.5 py-0.5 rounded">
apps/{{COMPONENT_NAME}}/src/App.tsx
</code>
</p>
</div> </div>
</div> </DashboardShell>
); );
} }

View File

@ -1,17 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

View File

@ -0,0 +1,6 @@
/* Import design system tokens */
@import '@{{PROJECT_NAME}}/ui/styles';
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@ -0,0 +1,89 @@
package handlers
import (
"net/http"
"{{GO_MODULE}}/pkg/app"
"{{GO_MODULE}}/pkg/httperror"
"{{GO_MODULE}}/pkg/httpresponse"
"{{GO_MODULE}}/pkg/logging"
)
// Example demonstrates the Wrap pattern for error-returning handlers.
type Example struct {
logger *logging.Logger
}
// NewExample creates a new Example handler.
func NewExample(logger *logging.Logger) *Example {
return &Example{logger: logger}
}
// CreateRequest is the request body for creating an example.
type CreateRequest struct {
Name string `json:"name" validate:"required,min=1,max=100"`
Description string `json:"description" validate:"max=500"`
}
// CreateResponse is the response for creating an example.
type CreateResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
}
// Get returns an example by ID.
// Demonstrates returning HTTPErrors for common error cases.
func (h *Example) Get(w http.ResponseWriter, r *http.Request) error {
// Get ID from path parameter (using chi)
// id := chi.URLParam(r, "id")
// Example: resource not found
// if item == nil {
// return httperror.NotFoundf("example %s not found", id)
// }
// Example: forbidden access
// if !canAccess(user, item) {
// return httperror.Forbidden("access denied")
// }
// Success response
httpresponse.OK(w, r, map[string]any{
"id": "example-123",
"name": "Example Item",
"description": "This is an example item",
})
return nil
}
// Create creates a new example.
// Demonstrates using BindAndValidate for request parsing and validation.
func (h *Example) Create(w http.ResponseWriter, r *http.Request) error {
var req CreateRequest
// Bind and validate request body
// Returns HTTPError on failure, which Wrap will handle
if err := app.BindAndValidate(r, &req); err != nil {
return err
}
// Example: business logic error
// if exists(req.Name) {
// return httperror.Conflict("example with this name already exists")
// }
// Example: internal error (will be logged, generic message returned to client)
// if err := db.Create(item); err != nil {
// h.logger.Error("failed to create example", "error", err)
// return err // Generic errors become 500 Internal Error
// }
// Success response
httpresponse.Created(w, r, CreateResponse{
ID: "example-456",
Name: req.Name,
Description: req.Description,
})
return nil
}

View File

@ -12,10 +12,14 @@ func RegisterRoutes(application *app.App) {
// Initialize handlers // Initialize handlers
healthHandler := handlers.NewHealth(logger) healthHandler := handlers.NewHealth(logger)
exampleHandler := handlers.NewExample(logger)
// Register API routes // Register API routes
application.Route("/api/v1", func(r app.Router) { application.Route("/api/v1", func(r app.Router) {
r.Get("/health", healthHandler.Check) r.Get("/health", healthHandler.Check)
// Add more routes here
// Example routes using Wrap pattern for error-returning handlers
r.Get("/example", app.Wrap(exampleHandler.Get))
r.Post("/example", app.Wrap(exampleHandler.Create))
}) })
} }

View File

@ -0,0 +1,314 @@
# Feature Development Guide
This guide documents the end-to-end workflow for building features in your composable monorepo.
## Quick Start
```bash
# 1. Create feature branch
git checkout -b feature/my-feature
# 2. Implement API endpoint (use Wrap + Bind patterns)
# 3. Implement frontend (use design system + auth)
# 4. Write tests
# 5. Run quality checks
./scripts/quality.sh
# 6. Commit and push
git add -A && git commit -m "feat: my feature"
git push -u origin feature/my-feature
```
## Architecture Overview
```
{{PROJECT_NAME}}/
├── pkg/ # Shared Go packages (chassis)
│ ├── app/ # Wrap, Bind, Health patterns
│ ├── httperror/ # Typed HTTP errors
│ ├── httpresponse/ # JSON response envelope
│ └── auth/ # JWT + API key validation
├── packages/ # Shared TypeScript packages
│ ├── ui/ # Design system components
│ ├── layout/ # DashboardShell, Sidebar
│ ├── auth/ # AuthProvider, useAuth
│ └── api-client/ # Generated TypeScript client
├── services/ # Go backend services
└── apps/ # Frontend applications
```
## Backend Patterns
### Wrap Pattern
Convert error-returning handlers to `http.HandlerFunc`:
```go
import "{{PROJECT_NAME}}/pkg/app"
func (h *Handler) GetItem() http.HandlerFunc {
return app.Wrap(func(w http.ResponseWriter, r *http.Request) error {
// Return errors - they become proper HTTP responses
item, err := h.svc.Get(r.Context(), chi.URLParam(r, "id"))
if err != nil {
return httperror.NotFound("item not found")
}
httpresponse.JSON(w, http.StatusOK, item)
return nil
})
}
```
### Bind Pattern
Parse and validate request bodies in one call:
```go
import "{{PROJECT_NAME}}/pkg/app"
func (h *Handler) CreateItem() http.HandlerFunc {
type request struct {
Name string `json:"name" validate:"required,min=1,max=100"`
Type string `json:"type" validate:"required,oneof=a b c"`
}
return app.Wrap(func(w http.ResponseWriter, r *http.Request) error {
var req request
if err := app.BindAndValidate(r, &req); err != nil {
return err // Validation errors become 400 with details
}
// Use req.Name, req.Type...
})
}
```
### HTTPError Sentinels
Use typed errors that map to HTTP status codes:
```go
import "{{PROJECT_NAME}}/pkg/httperror"
httperror.BadRequest("invalid input") // 400
httperror.Unauthorized("not authenticated") // 401
httperror.Forbidden("access denied") // 403
httperror.NotFound("resource not found") // 404
httperror.Conflict("already exists") // 409
httperror.Internal("something went wrong") // 500
// With formatted messages
httperror.NotFoundf("user %s not found", userID)
// With details
httperror.WithDetails(
httperror.BadRequest("validation failed"),
map[string]string{"name": "required"},
)
```
### Auth Context
Access the authenticated user from request context:
```go
import "{{PROJECT_NAME}}/pkg/auth"
user, ok := auth.GetUser(r.Context())
if !ok {
return httperror.Unauthorized("not authenticated")
}
// user.ID, user.Email, user.Roles available
```
## Frontend Patterns
### Design System Components
Import from the shared UI package:
```tsx
import { Button, Card, Input, Label, Badge } from '@{{PROJECT_NAME}}/ui';
import { DashboardShell, Sidebar, Header } from '@{{PROJECT_NAME}}/layout';
```
### Auth Hook
Access auth state and actions:
```tsx
import { useAuth } from '@{{PROJECT_NAME}}/auth';
function MyComponent() {
const { user, isLoading, login, logout, refreshUser } = useAuth();
if (isLoading) return <div>Loading...</div>;
if (!user) return <LoginPrompt />;
return <div>Hello, {user.name}!</div>;
}
```
### Protected Routes
Wrap routes that require authentication:
```tsx
import { ProtectedRoute } from '@{{PROJECT_NAME}}/auth';
// In your router or layout
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
```
### Server Actions (Next.js)
Create server actions for form submissions:
```typescript
// src/actions/my-action.ts
'use server';
import { revalidatePath } from 'next/cache';
import { cookies } from 'next/headers';
export async function createItem(formData: FormData) {
const token = cookies().get('auth_token')?.value;
const response = await fetch(`${process.env.API_URL}/api/items`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
name: formData.get('name'),
}),
});
if (!response.ok) {
const error = await response.json();
return { success: false, error: error.message };
}
revalidatePath('/items');
return { success: true };
}
```
## API Client Generation
After adding OpenAPI annotations, regenerate the TypeScript client:
```bash
./scripts/generate-client.sh
```
This updates `packages/api-client/src/schema.d.ts` with typed endpoints.
## Testing
### Handler Tests
```go
func TestHandler_GetItem(t *testing.T) {
handler := NewHandler(mockService, slog.Default())
req := httptest.NewRequest("GET", "/api/items/123", nil)
req = req.WithContext(auth.SetUser(req.Context(), &auth.User{ID: "user-1"}))
rr := httptest.NewRecorder()
handler.GetItem().ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
}
```
### Running Tests
```bash
# All tests
./scripts/quality.sh
# Specific service
go test ./services/api/...
# With coverage
go test -cover ./services/api/...
```
## Code Review
Use the built-in review command:
```bash
/review-code
```
This checks for:
- Completeness and accuracy
- Tech debt indicators
- Maintainability issues
- DRY/CLEAN violations
## Deployment
### Commit Message Format
```
feat: add user profile feature
- Add GET/PUT /api/users/me endpoints
- Add profile page with edit form
- Add handler tests
```
### CI Pipeline
On push to any branch:
1. Build all components
2. Run tests
3. Build Docker images
On merge to main:
1. Build + test
2. Push to registry
3. Deploy to K8s
### Verifying Deployment
```bash
# Check pod status
kubectl get pods -n projects -l app={{PROJECT_NAME}}
# View logs
kubectl logs -n projects -l app={{PROJECT_NAME}} -f
# Test endpoint
curl https://{{DOMAIN}}/api/health
```
## Common Issues
### Handler returns 500 instead of proper error
Use `httperror.*` functions, not raw `errors.New()`.
### Validation errors missing details
Use `app.BindAndValidate()` instead of separate bind + validate.
### Auth middleware not working
Check route is inside the `r.Group()` with `auth.Middleware()`.
### Generated client out of sync
Run `./scripts/generate-client.sh` after OpenAPI changes.
### Frontend auth not working
Ensure `AuthProvider` wraps your app in `providers.tsx`.
## Related
- [Local Setup](./local/setup.md)
- [Deploying](./ops/deploying.md)

View File

@ -7,6 +7,7 @@
| If you need to... | Read this | | If you need to... | Read this |
|-------------------|-----------| |-------------------|-----------|
| **Set up local dev** | [local/setup.md](.claude/guides/local/setup.md) | | **Set up local dev** | [local/setup.md](.claude/guides/local/setup.md) |
| **Build a feature** | [feature-development.md](.claude/guides/feature-development.md) |
| **Deploy** | [ops/deploying.md](.claude/guides/ops/deploying.md) | | **Deploy** | [ops/deploying.md](.claude/guides/ops/deploying.md) |
## Quick Reference ## Quick Reference

View File

@ -0,0 +1,17 @@
{
"name": "@{{PROJECT_NAME}}/api-client",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"generate": "../scripts/generate-client.sh",
"typecheck": "tsc --noEmit",
"build": "tsc"
},
"devDependencies": {
"openapi-typescript": "^7.0.0",
"typescript": "^5.5.3"
}
}

View File

@ -0,0 +1,95 @@
/**
* API Client Configuration
*/
export interface ClientConfig {
baseUrl: string;
apiKey?: string;
bearerToken?: string;
headers?: Record<string, string>;
onError?: (error: Error) => void;
}
/**
* Create a typed API client
*
* @example
* const client = createClient({
* baseUrl: 'https://api.example.com',
* apiKey: 'your-api-key',
* });
*
* const users = await client.get('/users');
* const newUser = await client.post('/users', { name: 'John' });
*/
export function createClient(config: ClientConfig) {
const { baseUrl, apiKey, bearerToken, headers = {}, onError } = config;
async function request<T>(
method: string,
path: string,
options: {
body?: unknown;
params?: Record<string, string | number | boolean | undefined>;
headers?: Record<string, string>;
} = {}
): Promise<T> {
const url = new URL(path, baseUrl);
// Add query params
if (options.params) {
for (const [key, value] of Object.entries(options.params)) {
if (value !== undefined) {
url.searchParams.set(key, String(value));
}
}
}
// Build headers
const requestHeaders: Record<string, string> = {
'Content-Type': 'application/json',
...headers,
...options.headers,
};
if (apiKey) {
requestHeaders['X-API-Key'] = apiKey;
}
if (bearerToken) {
requestHeaders['Authorization'] = `Bearer ${bearerToken}`;
}
const response = await fetch(url.toString(), {
method,
headers: requestHeaders,
body: options.body ? JSON.stringify(options.body) : undefined,
});
if (!response.ok) {
const error = new Error(`API error: ${response.status}`);
if (onError) {
onError(error);
}
throw error;
}
// Handle no-content responses
if (response.status === 204) {
return undefined as T;
}
return response.json();
}
return {
get: <T>(path: string, params?: Record<string, string | number | boolean | undefined>) =>
request<T>('GET', path, { params }),
post: <T>(path: string, body?: unknown) =>
request<T>('POST', path, { body }),
put: <T>(path: string, body?: unknown) =>
request<T>('PUT', path, { body }),
patch: <T>(path: string, body?: unknown) =>
request<T>('PATCH', path, { body }),
delete: <T>(path: string) =>
request<T>('DELETE', path),
};
}

View File

@ -0,0 +1,3 @@
export * from './client';
// Note: schema.d.ts is generated by running `pnpm generate`
// export type { paths, components, operations } from './schema';

View File

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

View File

@ -0,0 +1,22 @@
{
"name": "@{{PROJECT_NAME}}/auth",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit",
"build": "tsc",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"peerDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"typescript": "^5.5.3"
}
}

View File

@ -0,0 +1,286 @@
import * as React from 'react';
import { createContext, useContext, useCallback, useMemo, useEffect, useState } from 'react';
import type { User, AuthState, LoginCredentials } from './types';
const TOKEN_STORAGE_KEY = 'auth_token';
const USER_STORAGE_KEY = 'auth_user';
/**
* Authentication context value.
*/
export interface AuthContextValue extends AuthState {
/** Log in with credentials */
login: (credentials: LoginCredentials) => Promise<void>;
/** Log in with a token directly */
loginWithToken: (token: string, user?: User) => void;
/** Log out the current user */
logout: () => void;
/** Get the current access token */
getToken: () => string | null;
/** Check if user has a specific role */
hasRole: (role: string) => boolean;
/** Check if user has a specific scope */
hasScope: (scope: string) => boolean;
}
const AuthContext = createContext<AuthContextValue | null>(null);
/**
* Auth provider configuration.
*/
export interface AuthProviderProps {
children: React.ReactNode;
/** API endpoint for login */
loginUrl?: string;
/** API endpoint for logout */
logoutUrl?: string;
/** API endpoint for fetching current user */
userUrl?: string;
/** Custom login handler */
onLogin?: (credentials: LoginCredentials) => Promise<{ token: string; user: User }>;
/** Custom logout handler */
onLogout?: () => Promise<void>;
/** Storage type for persisting auth state */
storage?: 'localStorage' | 'sessionStorage' | 'none';
}
/**
* AuthProvider manages authentication state and provides auth methods.
*
* @example
* // Basic usage
* <AuthProvider loginUrl="/api/auth/login">
* <App />
* </AuthProvider>
*
* @example
* // With custom handlers
* <AuthProvider
* onLogin={async (creds) => {
* const res = await myAuthService.login(creds);
* return { token: res.token, user: res.user };
* }}
* >
* <App />
* </AuthProvider>
*/
export function AuthProvider({
children,
loginUrl = '/api/auth/login',
logoutUrl = '/api/auth/logout',
userUrl = '/api/auth/me',
onLogin,
onLogout,
storage = 'localStorage',
}: AuthProviderProps) {
const [state, setState] = useState<AuthState>({
user: null,
isLoading: true,
isAuthenticated: false,
error: null,
});
// Get storage implementation
const getStorage = useCallback(() => {
if (storage === 'none') return null;
return storage === 'sessionStorage' ? sessionStorage : localStorage;
}, [storage]);
// Initialize auth state from storage
useEffect(() => {
const store = getStorage();
if (!store) {
setState((s) => ({ ...s, isLoading: false }));
return;
}
const token = store.getItem(TOKEN_STORAGE_KEY);
const userJson = store.getItem(USER_STORAGE_KEY);
if (token && userJson) {
try {
const user = JSON.parse(userJson) as User;
setState({
user,
isLoading: false,
isAuthenticated: true,
error: null,
});
} catch {
// Invalid stored data, clear it
store.removeItem(TOKEN_STORAGE_KEY);
store.removeItem(USER_STORAGE_KEY);
setState((s) => ({ ...s, isLoading: false }));
}
} else {
setState((s) => ({ ...s, isLoading: false }));
}
}, [getStorage]);
// Login with credentials
const login = useCallback(
async (credentials: LoginCredentials) => {
setState((s) => ({ ...s, isLoading: true, error: null }));
try {
let token: string;
let user: User;
if (onLogin) {
// Use custom login handler
const result = await onLogin(credentials);
token = result.token;
user = result.user;
} else {
// Use default API login
const response = await fetch(loginUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials),
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.message || 'Login failed');
}
const data = await response.json();
token = data.data?.token || data.token;
user = data.data?.user || data.user;
}
// Store token and user
const store = getStorage();
if (store) {
store.setItem(TOKEN_STORAGE_KEY, token);
store.setItem(USER_STORAGE_KEY, JSON.stringify(user));
}
setState({
user,
isLoading: false,
isAuthenticated: true,
error: null,
});
} catch (error) {
setState({
user: null,
isLoading: false,
isAuthenticated: false,
error: error instanceof Error ? error : new Error('Login failed'),
});
throw error;
}
},
[loginUrl, onLogin, getStorage]
);
// Login with token directly
const loginWithToken = useCallback(
(token: string, user?: User) => {
const store = getStorage();
if (store) {
store.setItem(TOKEN_STORAGE_KEY, token);
if (user) {
store.setItem(USER_STORAGE_KEY, JSON.stringify(user));
}
}
setState({
user: user || null,
isLoading: false,
isAuthenticated: true,
error: null,
});
},
[getStorage]
);
// Logout
const logout = useCallback(async () => {
try {
if (onLogout) {
await onLogout();
} else if (logoutUrl) {
await fetch(logoutUrl, { method: 'POST' }).catch(() => {});
}
} finally {
const store = getStorage();
if (store) {
store.removeItem(TOKEN_STORAGE_KEY);
store.removeItem(USER_STORAGE_KEY);
}
setState({
user: null,
isLoading: false,
isAuthenticated: false,
error: null,
});
}
}, [logoutUrl, onLogout, getStorage]);
// Get token
const getToken = useCallback(() => {
const store = getStorage();
return store ? store.getItem(TOKEN_STORAGE_KEY) : null;
}, [getStorage]);
// Role check
const hasRole = useCallback(
(role: string) => {
return state.user?.roles?.includes(role) ?? false;
},
[state.user]
);
// Scope check
const hasScope = useCallback(
(scope: string) => {
return state.user?.scopes?.includes(scope) ?? false;
},
[state.user]
);
const value = useMemo(
(): AuthContextValue => ({
...state,
login,
loginWithToken,
logout,
getToken,
hasRole,
hasScope,
}),
[state, login, loginWithToken, logout, getToken, hasRole, hasScope]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
/**
* Hook to access authentication state and methods.
*
* @example
* function Profile() {
* const { user, logout, isAuthenticated } = useAuth();
*
* if (!isAuthenticated) {
* return <LoginForm />;
* }
*
* return (
* <div>
* <p>Welcome, {user?.name}</p>
* <button onClick={logout}>Logout</button>
* </div>
* );
* }
*/
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

View File

@ -0,0 +1,131 @@
import * as React from 'react';
import { useAuth } from './AuthProvider';
export interface ProtectedRouteProps {
children: React.ReactNode;
/** Component to render while loading */
fallback?: React.ReactNode;
/** Component to render if not authenticated */
unauthorized?: React.ReactNode;
/** Required role(s) - user must have at least one */
roles?: string[];
/** Required scope(s) - user must have at least one */
scopes?: string[];
/** Redirect path for unauthorized access (alternative to unauthorized component) */
redirectTo?: string;
/** Custom redirect function (e.g., router.push). Falls back to window.location.href. */
onRedirect?: (path: string) => void;
}
/**
* ProtectedRoute guards routes that require authentication.
*
* @example
* // Basic protection
* <Route path="/dashboard" element={
* <ProtectedRoute>
* <Dashboard />
* </ProtectedRoute>
* } />
*
* @example
* // With role requirement
* <Route path="/admin" element={
* <ProtectedRoute roles={['admin']}>
* <AdminPanel />
* </ProtectedRoute>
* } />
*
* @example
* // With custom unauthorized view
* <ProtectedRoute
* unauthorized={<AccessDenied />}
* fallback={<LoadingSpinner />}
* >
* <SecureContent />
* </ProtectedRoute>
*/
export function ProtectedRoute({
children,
fallback = <DefaultLoading />,
unauthorized = <DefaultUnauthorized />,
roles,
scopes,
redirectTo,
onRedirect,
}: ProtectedRouteProps) {
const { isLoading, isAuthenticated, user } = useAuth();
// Show loading state
if (isLoading) {
return <>{fallback}</>;
}
// Not authenticated
if (!isAuthenticated) {
if (redirectTo) {
if (onRedirect) {
onRedirect(redirectTo);
} else {
window.location.href = redirectTo;
}
return null;
}
return <>{unauthorized}</>;
}
// Check role requirements
if (roles && roles.length > 0) {
const hasRequiredRole = roles.some((role) => user?.roles?.includes(role));
if (!hasRequiredRole) {
return <>{unauthorized}</>;
}
}
// Check scope requirements
if (scopes && scopes.length > 0) {
const hasRequiredScope = scopes.some((scope) => user?.scopes?.includes(scope));
if (!hasRequiredScope) {
return <>{unauthorized}</>;
}
}
return <>{children}</>;
}
// Default loading component
function DefaultLoading() {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
color: 'var(--text-muted)',
}}
>
Loading...
</div>
);
}
// Default unauthorized component
function DefaultUnauthorized() {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
gap: '1rem',
color: 'var(--text-primary)',
}}
>
<h1 style={{ fontSize: '1.5rem', fontWeight: 'bold' }}>Access Denied</h1>
<p style={{ color: 'var(--text-muted)' }}>You don't have permission to view this page.</p>
</div>
);
}

View File

@ -0,0 +1,3 @@
export { AuthProvider, useAuth, type AuthContextValue } from './AuthProvider';
export { ProtectedRoute } from './ProtectedRoute';
export type { User, AuthState, LoginCredentials } from './types';

View File

@ -0,0 +1,43 @@
/**
* Represents an authenticated user.
*/
export interface User {
id: string;
email?: string;
name?: string;
roles?: string[];
scopes?: string[];
metadata?: Record<string, unknown>;
}
/**
* Authentication state.
*/
export interface AuthState {
/** The authenticated user, or null if not authenticated */
user: User | null;
/** Whether authentication state is being loaded */
isLoading: boolean;
/** Whether the user is authenticated */
isAuthenticated: boolean;
/** Any authentication error */
error: Error | null;
}
/**
* Login credentials for username/password authentication.
*/
export interface LoginCredentials {
email: string;
password: string;
}
/**
* Token response from the authentication API.
*/
export interface TokenResponse {
access_token: string;
refresh_token?: string;
token_type: string;
expires_in: number;
}

View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

View File

@ -0,0 +1,26 @@
{
"name": "@{{PROJECT_NAME}}/layout",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit",
"build": "tsc",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"@{{PROJECT_NAME}}/ui": "workspace:*",
"lucide-react": "^0.395.0"
},
"peerDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"typescript": "^5.5.3"
}
}

View File

@ -0,0 +1,78 @@
import * as React from 'react';
import { cn } from '@{{PROJECT_NAME}}/ui';
export interface DashboardShellProps {
/** Sidebar element to render on the left */
sidebar?: React.ReactNode;
/** Header element to render at the top */
header?: React.ReactNode;
/** Main content */
children: React.ReactNode;
/** Width of the sidebar in pixels (default: 256) */
sidebarWidth?: number;
/** Height of the header in pixels (default: 64) */
headerHeight?: number;
/** Additional class names for the main content area */
className?: string;
}
/**
* DashboardShell provides a standard layout for dashboard applications
* with a fixed sidebar, header, and scrollable main content area.
*
* @example
* <DashboardShell
* sidebar={<AppSidebar />}
* header={<AppHeader />}
* >
* <MainContent />
* </DashboardShell>
*/
export function DashboardShell({
sidebar,
header,
children,
sidebarWidth = 256,
headerHeight = 64,
className,
}: DashboardShellProps) {
return (
<div className="min-h-screen bg-[var(--background)]">
{/* Sidebar */}
{sidebar && (
<aside
className="fixed inset-y-0 left-0 z-[var(--z-sticky)] flex flex-col border-r border-[var(--border)] bg-[var(--background-secondary)]"
style={{ width: sidebarWidth }}
>
{sidebar}
</aside>
)}
{/* Main area */}
<div
className="flex flex-col min-h-screen"
style={{ marginLeft: sidebar ? sidebarWidth : 0 }}
>
{/* Header */}
{header && (
<header
className="sticky top-0 z-[var(--z-sticky)] flex items-center border-b border-[var(--border)] bg-[var(--background)]/95 backdrop-blur supports-[backdrop-filter]:bg-[var(--background)]/60"
style={{ height: headerHeight }}
>
{header}
</header>
)}
{/* Main content */}
<main
className={cn(
'flex-1 overflow-auto p-6',
className
)}
>
{children}
</main>
</div>
</div>
);
}

View File

@ -0,0 +1,118 @@
import * as React from 'react';
import { cn, Button, Input, Search } from '@{{PROJECT_NAME}}/ui';
import { Bell, Menu } from 'lucide-react';
export interface HeaderProps {
/** Title to display in the header */
title?: string;
/** Breadcrumb or secondary navigation element */
breadcrumb?: React.ReactNode;
/** Whether to show the search input */
showSearch?: boolean;
/** Placeholder text for search input */
searchPlaceholder?: string;
/** Search input change handler */
onSearch?: (value: string) => void;
/** Custom actions to display on the right side */
actions?: React.ReactNode;
/** User menu or avatar element */
userMenu?: React.ReactNode;
/** Mobile menu toggle handler */
onMenuToggle?: () => void;
/** Whether to show the menu toggle button (for mobile) */
showMenuToggle?: boolean;
/** Additional class names */
className?: string;
}
/**
* Header provides the top navigation bar for dashboard applications.
* Supports title, breadcrumbs, search, notifications, and user menu.
*
* @example
* <Header
* title="Dashboard"
* showSearch
* onSearch={(value) => console.log(value)}
* userMenu={<UserDropdown />}
* />
*/
export function Header({
title,
breadcrumb,
showSearch = false,
searchPlaceholder = 'Search...',
onSearch,
actions,
userMenu,
onMenuToggle,
showMenuToggle = false,
className,
}: HeaderProps) {
const [searchValue, setSearchValue] = React.useState('');
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchValue(e.target.value);
onSearch?.(e.target.value);
};
return (
<div className={cn('flex items-center justify-between w-full px-6', className)}>
{/* Left section */}
<div className="flex items-center gap-4">
{showMenuToggle && (
<Button
variant="ghost"
size="icon"
onClick={onMenuToggle}
className="lg:hidden"
>
<Menu className="h-5 w-5" />
<span className="sr-only">Toggle menu</span>
</Button>
)}
{breadcrumb ? (
<div className="flex items-center">{breadcrumb}</div>
) : title ? (
<h1 className="text-lg font-semibold">{title}</h1>
) : null}
</div>
{/* Center section - Search */}
{showSearch && (
<div className="hidden md:flex flex-1 max-w-md mx-8">
<div className="relative w-full">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[var(--text-muted)]" />
<Input
type="search"
placeholder={searchPlaceholder}
value={searchValue}
onChange={handleSearchChange}
className="pl-9 bg-[var(--surface-50)] border-[var(--border-muted)]"
/>
</div>
</div>
)}
{/* Right section */}
<div className="flex items-center gap-2">
{actions}
{/* Notifications */}
<Button variant="ghost" size="icon" className="relative">
<Bell className="h-5 w-5" />
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-[var(--error)]" />
<span className="sr-only">Notifications</span>
</Button>
{/* User menu */}
{userMenu && (
<div className="ml-2 border-l border-[var(--border-muted)] pl-4">
{userMenu}
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,169 @@
import * as React from 'react';
import { cn } from '@{{PROJECT_NAME}}/ui';
import { ChevronRight, type LucideIcon } from 'lucide-react';
export interface NavItem {
/** Display label */
label: string;
/** URL to navigate to */
href: string;
/** Icon component from lucide-react */
icon?: LucideIcon;
/** Whether this item is currently active */
active?: boolean;
/** Badge text to display */
badge?: string;
/** Nested items for collapsible sections */
children?: NavItem[];
}
export interface SidebarProps {
/** Logo or brand element to display at the top */
logo?: React.ReactNode;
/** Navigation items */
items: NavItem[];
/** Footer element (e.g., user menu, settings) */
footer?: React.ReactNode;
/** Additional class names */
className?: string;
/** Click handler for navigation items */
onNavigate?: (href: string) => void;
}
/**
* Sidebar provides navigation for dashboard applications.
* Supports icons, nested items, badges, and active state highlighting.
*
* @example
* <Sidebar
* logo={<Logo />}
* items={[
* { label: 'Dashboard', href: '/', icon: Home, active: true },
* { label: 'Users', href: '/users', icon: Users },
* { label: 'Settings', href: '/settings', icon: Settings },
* ]}
* footer={<UserMenu />}
* />
*/
export function Sidebar({
logo,
items,
footer,
className,
onNavigate,
}: SidebarProps) {
const [expanded, setExpanded] = React.useState<Record<string, boolean>>({});
const toggleExpanded = (label: string) => {
setExpanded((prev) => ({ ...prev, [label]: !prev[label] }));
};
const handleClick = (item: NavItem, e: React.MouseEvent) => {
if (item.children) {
e.preventDefault();
toggleExpanded(item.label);
} else if (onNavigate) {
e.preventDefault();
onNavigate(item.href);
}
};
return (
<div className={cn('flex flex-col h-full', className)}>
{/* Logo area */}
{logo && (
<div className="flex h-16 items-center px-4 border-b border-[var(--border-muted)]">
{logo}
</div>
)}
{/* Navigation */}
<nav className="flex-1 overflow-y-auto py-4">
<ul className="space-y-1 px-2">
{items.map((item) => (
<NavItemComponent
key={item.href}
item={item}
isExpanded={expanded[item.label]}
onClick={handleClick}
onNavigate={onNavigate}
/>
))}
</ul>
</nav>
{/* Footer */}
{footer && (
<div className="border-t border-[var(--border-muted)] p-4">
{footer}
</div>
)}
</div>
);
}
interface NavItemComponentProps {
item: NavItem;
isExpanded?: boolean;
depth?: number;
onClick: (item: NavItem, e: React.MouseEvent) => void;
onNavigate?: (href: string) => void;
}
function NavItemComponent({
item,
isExpanded = false,
depth = 0,
onClick,
onNavigate,
}: NavItemComponentProps) {
const Icon = item.icon;
const hasChildren = item.children && item.children.length > 0;
return (
<li>
<a
href={item.href}
onClick={(e) => onClick(item, e)}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
item.active
? 'bg-[var(--surface-200)] text-[var(--text-primary)]'
: 'text-[var(--text-secondary)] hover:bg-[var(--surface-100)] hover:text-[var(--text-primary)]',
depth > 0 && 'ml-6'
)}
>
{Icon && <Icon className="h-4 w-4 shrink-0" />}
<span className="flex-1">{item.label}</span>
{item.badge && (
<span className="rounded-full bg-[var(--accent)] px-2 py-0.5 text-xs font-medium text-[var(--accent-foreground)]">
{item.badge}
</span>
)}
{hasChildren && (
<ChevronRight
className={cn(
'h-4 w-4 transition-transform',
isExpanded && 'rotate-90'
)}
/>
)}
</a>
{/* Nested items */}
{hasChildren && isExpanded && (
<ul className="mt-1 space-y-1">
{item.children!.map((child) => (
<NavItemComponent
key={child.href}
item={child}
depth={depth + 1}
onClick={onClick}
onNavigate={onNavigate}
/>
))}
</ul>
)}
</li>
);
}

View File

@ -0,0 +1,3 @@
export { DashboardShell, type DashboardShellProps } from './DashboardShell';
export { Sidebar, type SidebarProps, type NavItem } from './Sidebar';
export { Header, type HeaderProps } from './Header';

View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

View File

@ -0,0 +1,40 @@
{
"name": "@{{PROJECT_NAME}}/ui",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./styles": "./src/styles/tokens.css"
},
"scripts": {
"typecheck": "tsc --noEmit",
"build": "tsc",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slot": "^1.0.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lucide-react": "^0.395.0",
"tailwind-merge": "^2.3.0"
},
"peerDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"typescript": "^5.5.3"
}
}

View File

@ -0,0 +1,42 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '../utils/cn';
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--accent)] focus:ring-offset-2',
{
variants: {
variant: {
default:
'border-transparent bg-[var(--accent)] text-[var(--accent-foreground)]',
secondary:
'border-transparent bg-[var(--surface-200)] text-[var(--text-secondary)]',
outline:
'border-[var(--border)] text-[var(--text-secondary)]',
success:
'border-[var(--success-border)] bg-[var(--success-bg)] text-[var(--success)]',
warning:
'border-[var(--warning-border)] bg-[var(--warning-bg)] text-[var(--warning)]',
error:
'border-[var(--error-border)] bg-[var(--error-bg)] text-[var(--error)]',
info:
'border-[var(--info-border)] bg-[var(--info-bg)] text-[var(--info)]',
},
},
defaultVariants: {
variant: 'default',
},
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };

View File

@ -0,0 +1,63 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '../utils/cn';
import { Loader2 } from 'lucide-react';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--background)] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default:
'bg-[var(--accent)] text-[var(--accent-foreground)] hover:bg-[var(--accent-hover)]',
destructive:
'bg-[var(--error)] text-white hover:bg-[var(--error)]/90',
outline:
'border border-[var(--border)] bg-transparent hover:bg-[var(--surface-100)] hover:border-[var(--border-hover)]',
secondary:
'bg-[var(--surface-200)] text-[var(--text-primary)] hover:bg-[var(--surface-300)]',
ghost:
'hover:bg-[var(--surface-100)] hover:text-[var(--text-primary)]',
link: 'text-[var(--accent)] underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
loading?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, loading, children, disabled, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
disabled={disabled || loading}
{...props}
>
{loading && <Loader2 className="animate-spin" />}
{children}
</Comp>
);
}
);
Button.displayName = 'Button';
export { Button, buttonVariants };

View File

@ -0,0 +1,75 @@
import * as React from 'react';
import { cn } from '../utils/cn';
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'rounded-lg border border-[var(--border)] bg-[var(--surface-50)] text-[var(--text-primary)] shadow-sm',
className
)}
{...props}
/>
));
Card.displayName = 'Card';
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col space-y-1.5 p-6', className)}
{...props}
/>
));
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<
HTMLHeadingElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn('font-semibold leading-none tracking-tight', className)}
{...props}
/>
));
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-sm text-[var(--text-muted)]', className)}
{...props}
/>
));
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
));
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center p-6 pt-0', className)}
{...props}
/>
));
CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View File

@ -0,0 +1,30 @@
import * as React from 'react';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { Check } from 'lucide-react';
import { cn } from '../utils/cn';
export interface CheckboxProps
extends React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> {}
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
CheckboxProps
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
'peer h-4 w-4 shrink-0 rounded-sm border border-[var(--border)] shadow focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-[var(--accent)] data-[state=checked]:text-[var(--accent-foreground)] data-[state=checked]:border-[var(--accent)]',
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn('flex items-center justify-center text-current')}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };

View File

@ -0,0 +1,116 @@
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { cn } from '../utils/cn';
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-[var(--z-modal)] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-[var(--z-modal)] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-[var(--border)] bg-[var(--background)] p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-[var(--background)] transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-[var(--accent)] focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-[var(--surface-100)] data-[state=open]:text-[var(--text-muted)]">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-1.5 text-center sm:text-left',
className
)}
{...props}
/>
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className
)}
{...props}
/>
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
'text-lg font-semibold leading-none tracking-tight',
className
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-[var(--text-muted)]', className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View File

@ -0,0 +1,28 @@
import * as React from 'react';
import { cn } from '../utils/cn';
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
error?: boolean;
}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, error, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-9 w-full rounded-md border bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-[var(--text-muted)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] disabled:cursor-not-allowed disabled:opacity-50',
error
? 'border-[var(--error)] focus-visible:ring-[var(--error)]'
: 'border-[var(--border)] hover:border-[var(--border-hover)]',
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = 'Input';
export { Input };

View File

@ -0,0 +1,32 @@
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '../utils/cn';
const labelVariants = cva(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
);
export interface LabelProps
extends React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>,
VariantProps<typeof labelVariants> {
error?: boolean;
}
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
LabelProps
>(({ className, error, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(
labelVariants(),
error && 'text-[var(--error)]',
className
)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

View File

@ -0,0 +1,153 @@
import * as React from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '../utils/cn';
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-[var(--border)] bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-[var(--background)] placeholder:text-[var(--text-muted)] focus:outline-none focus:ring-2 focus:ring-[var(--accent)] disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
'flex cursor-default items-center justify-center py-1',
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
'flex cursor-default items-center justify-center py-1',
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-[var(--z-dropdown)] max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-[var(--border)] bg-[var(--background)] text-[var(--text-primary)] shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-semibold', className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-[var(--surface-100)] focus:text-[var(--text-primary)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-[var(--border)]', className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};

View File

@ -0,0 +1,119 @@
import * as React from 'react';
import { cn } from '../utils/cn';
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn('w-full caption-bottom text-sm', className)}
{...props}
/>
</div>
));
Table.displayName = 'Table';
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
));
TableHeader.displayName = 'TableHeader';
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn('[&_tr:last-child]:border-0', className)}
{...props}
/>
));
TableBody.displayName = 'TableBody';
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
'border-t bg-[var(--surface-100)] font-medium [&>tr]:last:border-b-0',
className
)}
{...props}
/>
));
TableFooter.displayName = 'TableFooter';
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
'border-b border-[var(--border)] transition-colors hover:bg-[var(--surface-50)] data-[state=selected]:bg-[var(--surface-100)]',
className
)}
{...props}
/>
));
TableRow.displayName = 'TableRow';
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
'h-10 px-4 text-left align-middle font-medium text-[var(--text-muted)] [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className
)}
{...props}
/>
));
TableHead.displayName = 'TableHead';
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
'p-4 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className
)}
{...props}
/>
));
TableCell.displayName = 'TableCell';
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn('mt-4 text-sm text-[var(--text-muted)]', className)}
{...props}
/>
));
TableCaption.displayName = 'TableCaption';
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};

View File

@ -0,0 +1,46 @@
// Core utilities
export { cn } from './utils/cn';
// Components
export { Button, type ButtonProps } from './components/Button';
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './components/Card';
export { Input, type InputProps } from './components/Input';
export { Label, type LabelProps } from './components/Label';
export { Badge, type BadgeProps } from './components/Badge';
export { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose } from './components/Dialog';
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from './components/Table';
export { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from './components/Select';
export { Checkbox, type CheckboxProps } from './components/Checkbox';
// Icons (re-export commonly used ones)
export {
AlertCircle,
ArrowLeft,
ArrowRight,
BarChart3,
Check,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
Copy,
Download,
Edit,
Eye,
EyeOff,
Filter,
Home,
Loader2,
Menu,
MoreHorizontal,
MoreVertical,
Plus,
RefreshCw,
Search,
Settings,
Trash2,
Upload,
User,
Users,
X,
} from 'lucide-react';

View File

@ -0,0 +1,174 @@
/**
* Design Tokens - Dark Theme (Platform-Admin style)
*
* Usage:
* Import this file in your app's main CSS:
* @import '@your-project/ui/styles';
*
* Or in your Tailwind config:
* import tokens from '@your-project/ui/styles/tokens.css';
*/
:root {
/* Background colors */
--background: #000000;
--background-secondary: #0a0a0a;
--foreground: #ffffff;
/* Surface colors (translucent white overlays) */
--surface-50: rgba(255, 255, 255, 0.02);
--surface-100: rgba(255, 255, 255, 0.05);
--surface-200: rgba(255, 255, 255, 0.08);
--surface-300: rgba(255, 255, 255, 0.12);
--surface-400: rgba(255, 255, 255, 0.16);
--surface-500: rgba(255, 255, 255, 0.24);
--surface-600: rgba(255, 255, 255, 0.32);
--surface-700: rgba(255, 255, 255, 0.48);
--surface-800: rgba(255, 255, 255, 0.64);
--surface-900: rgba(255, 255, 255, 0.80);
/* Border colors */
--border: rgba(255, 255, 255, 0.1);
--border-muted: rgba(255, 255, 255, 0.06);
--border-hover: rgba(255, 255, 255, 0.2);
--border-focus: rgba(59, 130, 246, 0.5);
/* Text colors */
--text-primary: rgba(255, 255, 255, 1);
--text-secondary: rgba(255, 255, 255, 0.7);
--text-muted: rgba(255, 255, 255, 0.5);
--text-disabled: rgba(255, 255, 255, 0.3);
/* Accent colors */
--accent: #3B82F6;
--accent-hover: #2563EB;
--accent-foreground: #ffffff;
/* Status colors */
--success: rgba(34, 197, 94, 0.8);
--success-bg: rgba(34, 197, 94, 0.15);
--success-border: rgba(34, 197, 94, 0.3);
--warning: rgba(234, 179, 8, 0.8);
--warning-bg: rgba(234, 179, 8, 0.15);
--warning-border: rgba(234, 179, 8, 0.3);
--error: rgba(239, 68, 68, 0.8);
--error-bg: rgba(239, 68, 68, 0.15);
--error-border: rgba(239, 68, 68, 0.3);
--info: rgba(59, 130, 246, 0.8);
--info-bg: rgba(59, 130, 246, 0.15);
--info-border: rgba(59, 130, 246, 0.3);
/* Typography */
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', Menlo, Monaco, monospace;
/* Font sizes */
--text-xs: 0.75rem; /* 12px */
--text-sm: 0.875rem; /* 14px */
--text-base: 1rem; /* 16px */
--text-lg: 1.125rem; /* 18px */
--text-xl: 1.25rem; /* 20px */
--text-2xl: 1.5rem; /* 24px */
--text-3xl: 1.875rem; /* 30px */
--text-4xl: 2.25rem; /* 36px */
/* Line heights */
--leading-tight: 1.25;
--leading-normal: 1.5;
--leading-relaxed: 1.75;
/* Spacing */
--space-1: 0.25rem; /* 4px */
--space-2: 0.5rem; /* 8px */
--space-3: 0.75rem; /* 12px */
--space-4: 1rem; /* 16px */
--space-5: 1.25rem; /* 20px */
--space-6: 1.5rem; /* 24px */
--space-8: 2rem; /* 32px */
--space-10: 2.5rem; /* 40px */
--space-12: 3rem; /* 48px */
--space-16: 4rem; /* 64px */
/* Border radius */
--radius-sm: 0.25rem; /* 4px */
--radius-md: 0.375rem; /* 6px */
--radius-lg: 0.5rem; /* 8px */
--radius-xl: 0.75rem; /* 12px */
--radius-2xl: 1rem; /* 16px */
--radius-full: 9999px;
/* Shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.4);
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.4);
/* Transitions */
--transition-fast: 100ms ease;
--transition-normal: 200ms ease;
--transition-slow: 300ms ease;
/* Z-index scale */
--z-dropdown: 50;
--z-sticky: 100;
--z-modal: 200;
--z-popover: 300;
--z-tooltip: 400;
--z-toast: 500;
}
/* Base styles */
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
font-family: var(--font-sans);
font-size: var(--text-base);
line-height: var(--leading-normal);
color: var(--text-primary);
background-color: var(--background);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code, pre, kbd, samp {
font-family: var(--font-mono);
}
/* Focus styles */
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* Scrollbar styling (for webkit browsers) */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--surface-50);
}
::-webkit-scrollbar-thumb {
background: var(--surface-300);
border-radius: var(--radius-full);
}
::-webkit-scrollbar-thumb:hover {
background: var(--surface-400);
}
/* Selection styling */
::selection {
background: var(--accent);
color: var(--accent-foreground);
}

View File

@ -0,0 +1,14 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
/**
* Utility for conditionally combining Tailwind CSS classes.
* Combines clsx for conditional classes and tailwind-merge for deduplication.
*
* @example
* cn('px-4 py-2', isActive && 'bg-blue-500', className)
* cn('text-sm text-gray-500', { 'font-bold': isBold })
*/
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}

View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

View File

@ -6,10 +6,11 @@ This directory contains shared Go packages used across all components in the mon
| Package | Description | | Package | Description |
|---------|-------------| |---------|-------------|
| `app` | Service bootstrapper with chi router, middleware, and graceful shutdown | | `app` | Service bootstrapper with Wrap pattern, Bind helpers, health probes |
| `config` | Viper-based configuration loading from environment variables | | `config` | Viper-based configuration loading from environment variables |
| `httpcontext` | Type-safe context key helpers for request-scoped data | | `httpcontext` | Type-safe context key helpers for request-scoped data |
| `httpclient` | Resilient HTTP client with automatic retries and exponential backoff | | `httpclient` | Resilient HTTP client with automatic retries and exponential backoff |
| `httperror` | Typed HTTP errors with sentinel error matching |
| `httpresponse` | Standard response envelope pattern for API responses | | `httpresponse` | Standard response envelope pattern for API responses |
| `httpvalidation` | Struct validation wrapper around go-playground/validator | | `httpvalidation` | Struct validation wrapper around go-playground/validator |
| `logging` | slog-based structured logging with context integration | | `logging` | slog-based structured logging with context integration |
@ -26,6 +27,7 @@ import (
"net/http" "net/http"
"{{GO_MODULE}}/pkg/app" "{{GO_MODULE}}/pkg/app"
"{{GO_MODULE}}/pkg/httperror"
"{{GO_MODULE}}/pkg/httpresponse" "{{GO_MODULE}}/pkg/httpresponse"
) )
@ -33,14 +35,36 @@ func main() {
// Create application with default middleware and health endpoints // Create application with default middleware and health endpoints
svc := app.New("my-service", app.WithDefaultPort(8080)) svc := app.New("my-service", app.WithDefaultPort(8080))
// Register routes // Register routes using Wrap pattern for error-returning handlers
svc.GET("/hello", func(w http.ResponseWriter, r *http.Request) { svc.GET("/hello", app.Wrap(getHello))
httpresponse.OK(w, r, map[string]string{"message": "Hello, World!"}) svc.POST("/users", app.Wrap(createUser))
})
// Start server (blocks until shutdown signal) // Start server (blocks until shutdown signal)
svc.Run() svc.Run()
} }
// HandlerFunc returns error - Wrap converts it to http.HandlerFunc
func getHello(w http.ResponseWriter, r *http.Request) error {
httpresponse.OK(w, r, map[string]string{"message": "Hello, World!"})
return nil
}
func createUser(w http.ResponseWriter, r *http.Request) error {
var req CreateUserRequest
// BindAndValidate decodes JSON and validates in one call
if err := app.BindAndValidate(r, &req); err != nil {
return err // HTTPError is returned to client
}
user, err := createUserInDB(req)
if err != nil {
// Domain errors map to HTTP errors
return httperror.Conflict("user already exists")
}
httpresponse.Created(w, r, user)
return nil
}
``` ```
## Package Documentation ## Package Documentation
@ -49,8 +73,10 @@ func main() {
Service bootstrapper that provides: Service bootstrapper that provides:
- Chi router with standard middleware - Chi router with standard middleware
- **Wrap pattern** for error-returning handlers
- **Bind helpers** for request parsing and validation
- **Health probes** with concurrent dependency checks
- Graceful shutdown handling - Graceful shutdown handling
- Health check endpoints (`/health`, `/ready`)
```go ```go
app := app.New("my-service", app := app.New("my-service",
@ -58,13 +84,13 @@ app := app.New("my-service",
app.WithLogger(customLogger), app.WithLogger(customLogger),
) )
// Register routes // Register routes using Wrap pattern
app.GET("/users/{id}", getUser) app.GET("/users/{id}", app.Wrap(getUser))
app.POST("/users", createUser) app.POST("/users", app.Wrap(createUser))
// Group routes // Group routes
app.Route("/api/v1", func(r chi.Router) { app.Route("/api/v1", func(r chi.Router) {
r.Get("/users", listUsers) r.Get("/users", app.Wrap(listUsers))
}) })
// Register shutdown hooks // Register shutdown hooks
@ -75,6 +101,46 @@ app.OnShutdown(func(ctx context.Context) error {
app.Run() app.Run()
``` ```
**Wrap Pattern:**
```go
// HandlerFunc returns error - Wrap converts to http.HandlerFunc
func getUser(w http.ResponseWriter, r *http.Request) error {
user, err := userSvc.Get(ctx, id)
if err != nil {
return httperror.NotFoundf("user %s not found", id)
}
httpresponse.OK(w, r, user)
return nil
}
```
**Bind Helpers:**
```go
// Bind - decode JSON only
if err := app.Bind(r, &req); err != nil {
return err
}
// BindAndValidate - decode + validate with struct tags
if err := app.BindAndValidate(r, &req); err != nil {
return err // Returns validation error with field details
}
```
**Health Probes:**
```go
// Custom health handler with dependency checks
healthHandler := app.NewHealthHandler(app.HealthConfig{
Service: "my-service",
Timeout: 5 * time.Second,
Checks: map[string]app.HealthChecker{
"database": app.PingChecker(db.PingContext),
"redis": app.PingChecker(redis.Ping),
},
})
r.Get("/health", healthHandler)
```
### pkg/config ### pkg/config
Configuration loading from environment variables with Viper. Configuration loading from environment variables with Viper.
@ -153,6 +219,58 @@ Does NOT retry on:
- HTTP 4xx client errors (except 429) - HTTP 4xx client errors (except 429)
- Context cancellation - Context cancellation
### pkg/httperror
Typed HTTP errors with sentinel error matching for idiomatic Go error handling.
```go
// Factory functions create typed errors
err := httperror.NotFound("user not found")
err := httperror.NotFoundf("user %s not found", id)
err := httperror.BadRequest("invalid input")
err := httperror.Unauthorized("authentication required")
err := httperror.Forbidden("access denied")
err := httperror.Conflict("resource already exists")
err := httperror.Internal("something went wrong")
err := httperror.Validation("validation failed")
// Check error types with errors.Is()
if errors.Is(err, httperror.ErrNotFound) {
// handle not found
}
if errors.Is(err, httperror.ErrUnauthorized) {
// handle unauthorized
}
// Add details to errors (field-level validation info)
err := httperror.WithDetails(httperror.Validation("validation failed"), []ValidationDetail{
{Field: "email", Message: "is required"},
{Field: "name", Message: "must be at least 2 characters"},
})
// Custom error codes for domain-specific errors
err := httperror.WithCode(httperror.Forbidden("access denied"), "KEY_REVOKED")
// Wrap underlying errors
err := httperror.WrapError(httperror.ErrInternal, dbError)
if underlyingErr := errors.Unwrap(err); underlyingErr != nil {
// access original error
}
// Extract HTTP info from errors
status := httperror.StatusCode(err) // e.g., 404
httpErr := httperror.AsHTTPError(err) // type assertion
```
**Sentinel Errors:**
- `ErrBadRequest` - 400 Bad Request
- `ErrUnauthorized` - 401 Unauthorized
- `ErrForbidden` - 403 Forbidden
- `ErrNotFound` - 404 Not Found
- `ErrConflict` - 409 Conflict
- `ErrInternal` - 500 Internal Server Error
- `ErrValidation` - 400 Validation Error
### pkg/httpresponse ### pkg/httpresponse
Standard response envelope for API responses. Standard response envelope for API responses.

View File

@ -0,0 +1,93 @@
package app
import (
"errors"
"net/http"
"{{GO_MODULE}}/pkg/httperror"
"{{GO_MODULE}}/pkg/httpresponse"
"{{GO_MODULE}}/pkg/httpvalidation"
)
// Bind decodes JSON from the request body into v.
// Returns an HTTPError on failure that can be returned directly from a HandlerFunc.
//
// Usage:
//
// func CreateUser(w http.ResponseWriter, r *http.Request) error {
// var req CreateUserRequest
// if err := app.Bind(r, &req); err != nil {
// return err // Returns typed HTTPError
// }
// // req is now decoded
// }
func Bind(r *http.Request, v any) error {
if err := httpresponse.DecodeJSON(r, v); err != nil {
if errors.Is(err, httpresponse.ErrEmptyBody) {
return httperror.BadRequest("request body is required")
}
return httperror.BadRequest("invalid request body")
}
return nil
}
// BindStrict decodes JSON from the request body with strict field checking.
// Unknown fields in the JSON will cause an error.
func BindStrict(r *http.Request, v any) error {
if err := httpresponse.DecodeJSONStrict(r, v); err != nil {
if errors.Is(err, httpresponse.ErrEmptyBody) {
return httperror.BadRequest("request body is required")
}
if errors.Is(err, httpresponse.ErrUnknownFields) {
return httperror.BadRequest("unknown fields in request body")
}
return httperror.BadRequest("invalid request body")
}
return nil
}
// BindAndValidate decodes JSON and validates using httpvalidation.
// Returns a validation error with field-level details if validation fails.
//
// Usage:
//
// type CreateUserRequest struct {
// Name string `json:"name" validate:"required,min=1,max=100"`
// Email string `json:"email" validate:"required,email"`
// }
//
// func CreateUser(w http.ResponseWriter, r *http.Request) error {
// var req CreateUserRequest
// if err := app.BindAndValidate(r, &req); err != nil {
// return err
// }
// // req is decoded and validated
// }
func BindAndValidate(r *http.Request, v any) error {
// Decode JSON
if err := Bind(r, v); err != nil {
return err
}
// Validate struct
if details := httpvalidation.ValidateStruct(v); len(details) > 0 {
return httperror.WithDetails(httperror.Validation("validation failed"), details)
}
return nil
}
// BindAndValidateStrict decodes JSON with strict field checking and validates.
func BindAndValidateStrict(r *http.Request, v any) error {
// Decode JSON strictly
if err := BindStrict(r, v); err != nil {
return err
}
// Validate struct
if details := httpvalidation.ValidateStruct(v); len(details) > 0 {
return httperror.WithDetails(httperror.Validation("validation failed"), details)
}
return nil
}

View File

@ -0,0 +1,127 @@
package app
import (
"errors"
"net/http"
"{{GO_MODULE}}/pkg/httperror"
"{{GO_MODULE}}/pkg/httpresponse"
"{{GO_MODULE}}/pkg/logging"
)
// HandlerFunc is a handler function that returns an error.
// Use with Wrap() to automatically handle error responses.
//
// Example:
//
// func GetUser(w http.ResponseWriter, r *http.Request) error {
// user, err := svc.Get(ctx, id)
// if err != nil {
// return httperror.NotFoundf("user %s not found", id)
// }
// httpresponse.OK(w, r, user)
// return nil
// }
type HandlerFunc func(w http.ResponseWriter, r *http.Request) error
// Wrap converts a HandlerFunc to http.HandlerFunc, automatically handling
// error responses. HTTPErrors are written with their status code and details;
// other errors are logged and returned as 500 Internal Server Error.
//
// Example:
//
// r.Get("/users/{id}", app.Wrap(handlers.GetUser))
//
// func (h *Handlers) GetUser(w http.ResponseWriter, r *http.Request) error {
// id := chi.URLParam(r, "id")
// user, err := h.userSvc.Get(r.Context(), id)
// if err != nil {
// if errors.Is(err, ErrUserNotFound) {
// return httperror.NotFoundf("user %s not found", id)
// }
// return err // Will be logged and returned as 500
// }
// httpresponse.OK(w, r, user)
// return nil
// }
func Wrap(h HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := h(w, r); err != nil {
writeErrorFromErr(w, r, err, nil)
}
}
}
// WrapWithLogger is like Wrap but accepts a logger for internal error logging.
// Use this when you want to log internal errors that aren't HTTPErrors.
//
// Example:
//
// r.Get("/users/{id}", app.WrapWithLogger(handlers.GetUser, logger))
func WrapWithLogger(h HandlerFunc, logger *logging.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := h(w, r); err != nil {
writeErrorFromErr(w, r, err, logger)
}
}
}
// writeErrorFromErr writes an error response based on the error type.
// HTTPErrors are written with their status code; other errors become 500.
func writeErrorFromErr(w http.ResponseWriter, r *http.Request, err error, logger *logging.Logger) {
var httpErr *httperror.HTTPError
if errors.As(err, &httpErr) {
// Write the HTTPError directly
httpresponse.WriteError(w, r, httpErr.Status, httpErr.Code, httpErr.Message, httpErr.Details)
return
}
// For non-HTTP errors, log and return generic 500
if logger != nil {
logger.Error("internal error",
"error", err,
"method", r.Method,
"path", r.URL.Path,
)
}
httpresponse.InternalError(w, r, "internal error")
}
// -----------------------------------------------------------------------------
// Middleware Helpers
// -----------------------------------------------------------------------------
// MiddlewareFunc is a middleware that returns an error.
// Use with WrapMiddleware() to automatically handle error responses.
type MiddlewareFunc func(next http.Handler) func(w http.ResponseWriter, r *http.Request) error
// WrapMiddleware converts a MiddlewareFunc to a standard http middleware.
//
// Example:
//
// func AuthMiddleware(next http.Handler) func(w http.ResponseWriter, r *http.Request) error {
// return func(w http.ResponseWriter, r *http.Request) error {
// token := r.Header.Get("Authorization")
// if token == "" {
// return httperror.Unauthorized("missing authorization header")
// }
// user, err := validateToken(token)
// if err != nil {
// return httperror.Unauthorized("invalid token")
// }
// ctx := context.WithValue(r.Context(), userKey, user)
// next.ServeHTTP(w, r.WithContext(ctx))
// return nil
// }
// }
//
// r.Use(app.WrapMiddleware(AuthMiddleware))
func WrapMiddleware(m MiddlewareFunc) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := m(next)(w, r); err != nil {
writeErrorFromErr(w, r, err, nil)
}
})
}
}

View File

@ -0,0 +1,217 @@
package app
import (
"context"
"net/http"
"sync"
"time"
"{{GO_MODULE}}/pkg/httperror"
"{{GO_MODULE}}/pkg/httpresponse"
)
// HealthChecker is a function that checks the health of a dependency.
// Returns nil if healthy, an error describing the issue otherwise.
type HealthChecker func(ctx context.Context) error
// HealthCheckResult represents the result of a single health check.
type HealthCheckResult struct {
Name string `json:"name"`
Status string `json:"status"` // "healthy" or "unhealthy"
Latency string `json:"latency,omitempty"`
Error string `json:"error,omitempty"`
}
// HealthResponse is the response structure for health endpoints.
type HealthResponse struct {
Status string `json:"status"` // "healthy" or "unhealthy"
Service string `json:"service"`
Checks []HealthCheckResult `json:"checks,omitempty"`
Duration string `json:"duration,omitempty"`
}
// HealthConfig configures health check behavior.
type HealthConfig struct {
// Service name for identification
Service string
// Timeout for individual health checks (default: 5s)
Timeout time.Duration
// Checks is a map of check names to checker functions
Checks map[string]HealthChecker
}
// NewHealthHandler creates an HTTP handler that runs health checks concurrently.
// Returns 200 if all checks pass, 503 if any check fails.
//
// Example:
//
// healthHandler := app.NewHealthHandler(app.HealthConfig{
// Service: "my-service",
// Timeout: 5 * time.Second,
// Checks: map[string]app.HealthChecker{
// "database": func(ctx context.Context) error {
// return db.PingContext(ctx)
// },
// "redis": func(ctx context.Context) error {
// return redis.Ping(ctx).Err()
// },
// },
// })
//
// r.Get("/health", healthHandler)
func NewHealthHandler(cfg HealthConfig) http.HandlerFunc {
if cfg.Timeout == 0 {
cfg.Timeout = 5 * time.Second
}
return func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// If no checks configured, return simple healthy response
if len(cfg.Checks) == 0 {
httpresponse.OK(w, r, HealthResponse{
Status: "healthy",
Service: cfg.Service,
})
return
}
// Create context with timeout
ctx, cancel := context.WithTimeout(r.Context(), cfg.Timeout)
defer cancel()
// Run checks concurrently
results := make([]HealthCheckResult, 0, len(cfg.Checks))
var mu sync.Mutex
var wg sync.WaitGroup
for name, checker := range cfg.Checks {
wg.Add(1)
go func(name string, checker HealthChecker) {
defer wg.Done()
checkStart := time.Now()
err := checker(ctx)
latency := time.Since(checkStart)
result := HealthCheckResult{
Name: name,
Status: "healthy",
Latency: latency.Round(time.Millisecond).String(),
}
if err != nil {
result.Status = "unhealthy"
result.Error = err.Error()
}
mu.Lock()
results = append(results, result)
mu.Unlock()
}(name, checker)
}
wg.Wait()
// Determine overall status
status := "healthy"
httpStatus := http.StatusOK
for _, result := range results {
if result.Status == "unhealthy" {
status = "unhealthy"
httpStatus = http.StatusServiceUnavailable
break
}
}
resp := HealthResponse{
Status: status,
Service: cfg.Service,
Checks: results,
Duration: time.Since(start).Round(time.Millisecond).String(),
}
httpresponse.JSON(w, r, httpStatus, resp)
}
}
// NewLivenessHandler creates a simple liveness probe handler.
// Always returns 200 OK if the process is running.
// Use for Kubernetes liveness probes.
//
// Example:
//
// r.Get("/health/live", app.NewLivenessHandler("my-service"))
func NewLivenessHandler(service string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
httpresponse.OK(w, r, map[string]string{
"status": "ok",
"service": service,
})
}
}
// NewReadinessHandler creates a readiness probe handler with dependency checks.
// Returns 200 if all checks pass, 503 if any check fails.
// Use for Kubernetes readiness probes.
//
// Example:
//
// r.Get("/health/ready", app.NewReadinessHandler(app.HealthConfig{
// Service: "my-service",
// Checks: map[string]app.HealthChecker{
// "database": dbHealthCheck,
// },
// }))
func NewReadinessHandler(cfg HealthConfig) http.HandlerFunc {
return NewHealthHandler(cfg)
}
// -----------------------------------------------------------------------------
// Common Health Checkers
// -----------------------------------------------------------------------------
// PingChecker creates a health checker from a Ping function.
// Many database clients have a Ping or PingContext method.
//
// Example:
//
// checks := map[string]app.HealthChecker{
// "postgres": app.PingChecker(db.PingContext),
// }
func PingChecker(pingFn func(context.Context) error) HealthChecker {
return pingFn
}
// HTTPChecker creates a health checker that makes an HTTP GET request.
// Returns error if status is not 2xx.
//
// Example:
//
// checks := map[string]app.HealthChecker{
// "external-api": app.HTTPChecker("https://api.example.com/health"),
// }
func HTTPChecker(url string) HealthChecker {
return func(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return httperror.Internalf("unhealthy: status %d", resp.StatusCode)
}
return nil
}
}

View File

@ -0,0 +1,90 @@
package auth
import (
"context"
"errors"
)
var (
// ErrInvalidAPIKey is returned when the API key is invalid.
ErrInvalidAPIKey = errors.New("invalid API key")
)
// APIKeyLookup is a function that looks up a user by API key.
// Returns nil user and no error if the key is not found.
type APIKeyLookup func(ctx context.Context, key string) (*User, error)
// APIKeyValidator validates API keys using a lookup function.
type APIKeyValidator struct {
lookup APIKeyLookup
}
// NewAPIKeyValidator creates a new API key validator.
//
// Example with database lookup:
//
// validator := auth.NewAPIKeyValidator(func(ctx context.Context, key string) (*auth.User, error) {
// apiKey, err := db.GetAPIKeyByHash(ctx, hashKey(key))
// if err != nil {
// return nil, err
// }
// if apiKey == nil {
// return nil, nil // Key not found
// }
// return &auth.User{
// ID: apiKey.UserID,
// Scopes: apiKey.Scopes,
// }, nil
// })
func NewAPIKeyValidator(lookup APIKeyLookup) *APIKeyValidator {
return &APIKeyValidator{lookup: lookup}
}
// Validate validates an API key and returns the associated user.
func (v *APIKeyValidator) Validate(ctx context.Context, key string) (*User, error) {
if key == "" {
return nil, ErrInvalidAPIKey
}
user, err := v.lookup(ctx, key)
if err != nil {
return nil, err
}
if user == nil {
return nil, ErrInvalidAPIKey
}
return user, nil
}
// StaticAPIKeyValidator validates against a static set of API keys.
// Useful for simple use cases or testing.
type StaticAPIKeyValidator struct {
keys map[string]*User
}
// NewStaticAPIKeyValidator creates a validator with static API keys.
//
// Example:
//
// validator := auth.NewStaticAPIKeyValidator(map[string]*auth.User{
// "sk-test-123": {ID: "user-1", Scopes: []string{"read", "write"}},
// "sk-test-456": {ID: "user-2", Scopes: []string{"read"}},
// })
func NewStaticAPIKeyValidator(keys map[string]*User) *StaticAPIKeyValidator {
return &StaticAPIKeyValidator{keys: keys}
}
// Validate validates an API key against the static key map.
func (v *StaticAPIKeyValidator) Validate(ctx context.Context, key string) (*User, error) {
if key == "" {
return nil, ErrInvalidAPIKey
}
user, ok := v.keys[key]
if !ok {
return nil, ErrInvalidAPIKey
}
return user, nil
}

View File

@ -0,0 +1,91 @@
// Package auth provides authentication utilities for HTTP services.
//
// This package supports multiple authentication methods:
// - API Key authentication (X-API-Key header)
// - JWT Bearer token authentication
//
// Usage:
//
// // Create a validator
// validator := auth.NewJWTValidator(auth.JWTConfig{
// Secret: []byte("your-secret"),
// })
//
// // Use as middleware
// r.Use(auth.Middleware(validator))
//
// // Access user in handler
// user := auth.GetUser(r.Context())
package auth
import (
"context"
)
// User represents an authenticated user/principal.
type User struct {
// ID is the unique identifier for the user
ID string `json:"id"`
// Email is the user's email address (optional)
Email string `json:"email,omitempty"`
// Roles are the user's assigned roles
Roles []string `json:"roles,omitempty"`
// Scopes are the permitted scopes/permissions
Scopes []string `json:"scopes,omitempty"`
// Metadata contains additional user data
Metadata map[string]any `json:"metadata,omitempty"`
}
// HasRole checks if the user has a specific role.
func (u *User) HasRole(role string) bool {
for _, r := range u.Roles {
if r == role {
return true
}
}
return false
}
// HasAnyRole checks if the user has any of the specified roles.
func (u *User) HasAnyRole(roles ...string) bool {
for _, role := range roles {
if u.HasRole(role) {
return true
}
}
return false
}
// HasScope checks if the user has a specific scope.
func (u *User) HasScope(scope string) bool {
for _, s := range u.Scopes {
if s == scope {
return true
}
}
return false
}
// HasAnyScope checks if the user has any of the specified scopes.
func (u *User) HasAnyScope(scopes ...string) bool {
for _, scope := range scopes {
if u.HasScope(scope) {
return true
}
}
return false
}
// Validator validates authentication credentials and returns a User.
type Validator interface {
// Validate validates the provided token/key and returns a User.
// Returns an error if validation fails.
Validate(ctx context.Context, token string) (*User, error)
}
// TokenExtractor extracts an authentication token from a request.
type TokenExtractor interface {
// Extract extracts a token from the context (usually the request).
// Returns empty string if no token is found.
Extract(ctx context.Context) string
}

View File

@ -0,0 +1,51 @@
package auth
import (
"context"
)
// contextKey is a private type for context keys to prevent collisions.
type contextKey int
const (
userKey contextKey = iota
tokenKey
)
// SetUser stores a user in the context.
func SetUser(ctx context.Context, user *User) context.Context {
return context.WithValue(ctx, userKey, user)
}
// GetUser retrieves the user from the context.
// Returns nil if no user is present.
func GetUser(ctx context.Context) *User {
user, _ := ctx.Value(userKey).(*User)
return user
}
// MustGetUser retrieves the user from the context.
// Panics if no user is present.
func MustGetUser(ctx context.Context) *User {
user := GetUser(ctx)
if user == nil {
panic("auth: user not found in context")
}
return user
}
// IsAuthenticated returns true if a user is present in the context.
func IsAuthenticated(ctx context.Context) bool {
return GetUser(ctx) != nil
}
// SetToken stores the raw token in the context.
func SetToken(ctx context.Context, token string) context.Context {
return context.WithValue(ctx, tokenKey, token)
}
// GetToken retrieves the raw token from the context.
func GetToken(ctx context.Context) string {
token, _ := ctx.Value(tokenKey).(string)
return token
}

View File

@ -0,0 +1,179 @@
package auth
import (
"context"
"errors"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
)
var (
// ErrInvalidToken is returned when the token is malformed or signature is invalid.
ErrInvalidToken = errors.New("invalid token")
// ErrExpiredToken is returned when the token has expired.
ErrExpiredToken = errors.New("token expired")
// ErrInvalidClaims is returned when the token claims are invalid.
ErrInvalidClaims = errors.New("invalid claims")
)
// JWTConfig configures the JWT validator.
type JWTConfig struct {
// Secret is the HMAC secret key for HS256/HS384/HS512
Secret []byte
// PublicKey is the RSA/ECDSA public key for RS*/ES* algorithms (optional)
PublicKey any
// Issuer is the expected issuer claim (optional)
Issuer string
// Audience is the expected audience claim (optional)
Audience string
}
// JWTClaims extends jwt.RegisteredClaims with custom fields.
type JWTClaims struct {
jwt.RegisteredClaims
// UserID is the user identifier
UserID string `json:"uid,omitempty"`
// Email is the user's email
Email string `json:"email,omitempty"`
// Roles are the user's roles
Roles []string `json:"roles,omitempty"`
// Scopes are the permitted scopes
Scopes []string `json:"scopes,omitempty"`
}
// JWTValidator validates JWT tokens.
type JWTValidator struct {
secret []byte
publicKey any
issuer string
audience string
}
// NewJWTValidator creates a new JWT validator.
func NewJWTValidator(cfg JWTConfig) *JWTValidator {
return &JWTValidator{
secret: cfg.Secret,
publicKey: cfg.PublicKey,
issuer: cfg.Issuer,
audience: cfg.Audience,
}
}
// Validate validates a JWT token and returns the user.
func (v *JWTValidator) Validate(ctx context.Context, tokenString string) (*User, error) {
// Parse and validate the token
token, err := jwt.ParseWithClaims(tokenString, &JWTClaims{}, func(token *jwt.Token) (any, error) {
// Check signing method
switch token.Method.(type) {
case *jwt.SigningMethodHMAC:
if v.secret == nil {
return nil, fmt.Errorf("HMAC secret not configured")
}
return v.secret, nil
case *jwt.SigningMethodRSA, *jwt.SigningMethodECDSA:
if v.publicKey == nil {
return nil, fmt.Errorf("public key not configured")
}
return v.publicKey, nil
default:
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
})
if err != nil {
if errors.Is(err, jwt.ErrTokenExpired) {
return nil, ErrExpiredToken
}
return nil, fmt.Errorf("%w: %v", ErrInvalidToken, err)
}
claims, ok := token.Claims.(*JWTClaims)
if !ok || !token.Valid {
return nil, ErrInvalidClaims
}
// Validate issuer if configured
if v.issuer != "" && claims.Issuer != v.issuer {
return nil, fmt.Errorf("%w: invalid issuer", ErrInvalidClaims)
}
// Validate audience if configured
if v.audience != "" {
found := false
for _, aud := range claims.Audience {
if aud == v.audience {
found = true
break
}
}
if !found {
return nil, fmt.Errorf("%w: invalid audience", ErrInvalidClaims)
}
}
// Build user from claims
user := &User{
ID: claims.UserID,
Email: claims.Email,
Roles: claims.Roles,
Scopes: claims.Scopes,
}
// Fallback to subject if no user ID
if user.ID == "" {
user.ID = claims.Subject
}
return user, nil
}
// -----------------------------------------------------------------------------
// Token Generation (for testing and admin tools)
// -----------------------------------------------------------------------------
// GenerateToken creates a new JWT token for the given user.
// expiresIn specifies the token lifetime.
func GenerateToken(secret []byte, user *User, expiresIn time.Duration) (string, error) {
now := time.Now()
claims := JWTClaims{
RegisteredClaims: jwt.RegisteredClaims{
Subject: user.ID,
ExpiresAt: jwt.NewNumericDate(now.Add(expiresIn)),
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
},
UserID: user.ID,
Email: user.Email,
Roles: user.Roles,
Scopes: user.Scopes,
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(secret)
}
// GenerateTokenWithIssuer creates a new JWT token with issuer and audience claims.
func GenerateTokenWithIssuer(secret []byte, user *User, expiresIn time.Duration, issuer, audience string) (string, error) {
now := time.Now()
claims := JWTClaims{
RegisteredClaims: jwt.RegisteredClaims{
Subject: user.ID,
Issuer: issuer,
Audience: []string{audience},
ExpiresAt: jwt.NewNumericDate(now.Add(expiresIn)),
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
},
UserID: user.ID,
Email: user.Email,
Roles: user.Roles,
Scopes: user.Scopes,
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(secret)
}

View File

@ -0,0 +1,233 @@
package auth
import (
"context"
"net/http"
"strings"
"{{GO_MODULE}}/pkg/httperror"
"{{GO_MODULE}}/pkg/httpresponse"
)
// MiddlewareConfig configures the authentication middleware.
type MiddlewareConfig struct {
// Validator is the token/key validator to use
Validator Validator
// TokenExtractor extracts the token from the request (optional)
// Default: BearerTokenExtractor or APIKeyExtractor
TokenExtractor func(*http.Request) string
// Optional returns 401 only when a token is provided but invalid.
// If no token is provided, the request continues without authentication.
Optional bool
// SkipPaths are paths that skip authentication entirely
SkipPaths []string
}
// Middleware creates an authentication middleware.
//
// Example:
//
// r.Use(auth.Middleware(auth.MiddlewareConfig{
// Validator: jwtValidator,
// }))
//
// // Or with optional auth (passes through if no token)
// r.Use(auth.Middleware(auth.MiddlewareConfig{
// Validator: jwtValidator,
// Optional: true,
// }))
func Middleware(cfg MiddlewareConfig) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check if path should be skipped
for _, path := range cfg.SkipPaths {
if strings.HasPrefix(r.URL.Path, path) {
next.ServeHTTP(w, r)
return
}
}
// Extract token
var token string
if cfg.TokenExtractor != nil {
token = cfg.TokenExtractor(r)
} else {
// Try Bearer token first, then API key
token = ExtractBearerToken(r)
if token == "" {
token = ExtractAPIKey(r)
}
}
// No token provided
if token == "" {
if cfg.Optional {
next.ServeHTTP(w, r)
return
}
httpresponse.Unauthorized(w, r, "authentication required")
return
}
// Validate token
user, err := cfg.Validator.Validate(r.Context(), token)
if err != nil {
httpresponse.Unauthorized(w, r, "invalid credentials")
return
}
// Store user and token in context
ctx := SetUser(r.Context(), user)
ctx = SetToken(ctx, token)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// RequireAuth middleware requires authentication.
// Use after auth.Middleware to ensure a user is present.
func RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !IsAuthenticated(r.Context()) {
httpresponse.Unauthorized(w, r, "authentication required")
return
}
next.ServeHTTP(w, r)
})
}
// RequireRole middleware requires the user to have a specific role.
func RequireRole(role string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := GetUser(r.Context())
if user == nil {
httpresponse.Unauthorized(w, r, "authentication required")
return
}
if !user.HasRole(role) {
httpresponse.Forbidden(w, r, "insufficient permissions")
return
}
next.ServeHTTP(w, r)
})
}
}
// RequireAnyRole middleware requires the user to have any of the specified roles.
func RequireAnyRole(roles ...string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := GetUser(r.Context())
if user == nil {
httpresponse.Unauthorized(w, r, "authentication required")
return
}
if !user.HasAnyRole(roles...) {
httpresponse.Forbidden(w, r, "insufficient permissions")
return
}
next.ServeHTTP(w, r)
})
}
}
// RequireScope middleware requires the user to have a specific scope.
func RequireScope(scope string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := GetUser(r.Context())
if user == nil {
httpresponse.Unauthorized(w, r, "authentication required")
return
}
if !user.HasScope(scope) {
httpresponse.Forbidden(w, r, "insufficient scope")
return
}
next.ServeHTTP(w, r)
})
}
}
// -----------------------------------------------------------------------------
// Token Extractors
// -----------------------------------------------------------------------------
// ExtractBearerToken extracts a Bearer token from the Authorization header.
func ExtractBearerToken(r *http.Request) string {
auth := r.Header.Get("Authorization")
if auth == "" {
return ""
}
parts := strings.SplitN(auth, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") {
return ""
}
return parts[1]
}
// ExtractAPIKey extracts an API key from the X-API-Key header.
func ExtractAPIKey(r *http.Request) string {
return r.Header.Get("X-API-Key")
}
// ExtractFromQuery extracts a token from a query parameter.
func ExtractFromQuery(paramName string) func(*http.Request) string {
return func(r *http.Request) string {
return r.URL.Query().Get(paramName)
}
}
// ExtractFromCookie extracts a token from a cookie.
func ExtractFromCookie(cookieName string) func(*http.Request) string {
return func(r *http.Request) string {
cookie, err := r.Cookie(cookieName)
if err != nil {
return ""
}
return cookie.Value
}
}
// -----------------------------------------------------------------------------
// Error-returning middleware helpers (for use with app.Wrap)
// -----------------------------------------------------------------------------
// RequireAuthErr returns an error if the user is not authenticated.
// Use with app.Wrap pattern.
func RequireAuthErr(ctx context.Context) error {
if !IsAuthenticated(ctx) {
return httperror.Unauthorized("authentication required")
}
return nil
}
// RequireRoleErr returns an error if the user doesn't have the role.
// Use with app.Wrap pattern.
func RequireRoleErr(ctx context.Context, role string) error {
user := GetUser(ctx)
if user == nil {
return httperror.Unauthorized("authentication required")
}
if !user.HasRole(role) {
return httperror.Forbidden("insufficient permissions")
}
return nil
}
// RequireScopeErr returns an error if the user doesn't have the scope.
// Use with app.Wrap pattern.
func RequireScopeErr(ctx context.Context, scope string) error {
user := GetUser(ctx)
if user == nil {
return httperror.Unauthorized("authentication required")
}
if !user.HasScope(scope) {
return httperror.Forbidden("insufficient scope")
}
return nil
}

View File

@ -0,0 +1,331 @@
// Package httperror provides typed HTTP errors with sentinel error matching.
//
// NOTE: This file mirrors github.com/orchard9/rdev/pkg/api/error.go
// When updating patterns here, consider updating the rdev source as well.
//
// HTTPError implements the error interface and provides errors.Is() matching
// for idiomatic Go error handling in HTTP handlers.
//
// Usage:
//
// func GetUser(w http.ResponseWriter, r *http.Request) error {
// user, err := svc.Get(ctx, id)
// if err != nil {
// if errors.Is(err, ErrUserNotFound) {
// return httperror.NotFoundf("user %s not found", id)
// }
// return err
// }
// httpresponse.OK(w, r, user)
// return nil
// }
//
// // In error handling middleware, check error types:
// if errors.Is(err, httperror.ErrNotFound) {
// // handle not found
// }
package httperror
import (
"errors"
"fmt"
"net/http"
)
// HTTPError is a typed error that maps to an HTTP response.
// It implements the error interface and provides sentinel error matching via errors.Is().
type HTTPError struct {
Status int // HTTP status code
Code string // Machine-readable error code
Message string // Human-readable message
Details any // Optional additional details
cause error // Underlying error for wrapping
}
// Error implements the error interface.
func (e *HTTPError) Error() string {
if e.cause != nil {
return fmt.Sprintf("%s: %v", e.Message, e.cause)
}
return e.Message
}
// Unwrap returns the underlying error for errors.Unwrap().
func (e *HTTPError) Unwrap() error {
return e.cause
}
// Is implements errors.Is() matching for sentinel errors.
// Matches if both errors are HTTPErrors with the same status code.
func (e *HTTPError) Is(target error) bool {
var t *HTTPError
if errors.As(target, &t) {
return e.Status == t.Status
}
return false
}
// WithCause returns a copy of the error with the given underlying cause.
func (e *HTTPError) WithCause(cause error) *HTTPError {
return &HTTPError{
Status: e.Status,
Code: e.Code,
Message: e.Message,
Details: e.Details,
cause: cause,
}
}
// -----------------------------------------------------------------------------
// Sentinel Errors
// -----------------------------------------------------------------------------
// Sentinel errors for errors.Is() matching.
// Use these with errors.Is() to check error types:
//
// if errors.Is(err, httperror.ErrNotFound) {
// // handle not found
// }
var (
ErrBadRequest = &HTTPError{Status: http.StatusBadRequest, Code: "BAD_REQUEST", Message: "bad request"}
ErrUnauthorized = &HTTPError{Status: http.StatusUnauthorized, Code: "UNAUTHORIZED", Message: "unauthorized"}
ErrForbidden = &HTTPError{Status: http.StatusForbidden, Code: "FORBIDDEN", Message: "forbidden"}
ErrNotFound = &HTTPError{Status: http.StatusNotFound, Code: "NOT_FOUND", Message: "not found"}
ErrConflict = &HTTPError{Status: http.StatusConflict, Code: "CONFLICT", Message: "conflict"}
ErrUnprocessableEntity = &HTTPError{Status: http.StatusUnprocessableEntity, Code: "UNPROCESSABLE_ENTITY", Message: "unprocessable entity"}
ErrTooManyRequests = &HTTPError{Status: http.StatusTooManyRequests, Code: "TOO_MANY_REQUESTS", Message: "too many requests"}
ErrInternal = &HTTPError{Status: http.StatusInternalServerError, Code: "INTERNAL_ERROR", Message: "internal error"}
ErrServiceUnavailable = &HTTPError{Status: http.StatusServiceUnavailable, Code: "SERVICE_UNAVAILABLE", Message: "service unavailable"}
ErrValidation = &HTTPError{Status: http.StatusBadRequest, Code: "VALIDATION_ERROR", Message: "validation failed"}
)
// -----------------------------------------------------------------------------
// Factory Functions
// -----------------------------------------------------------------------------
// BadRequest creates a 400 Bad Request error.
func BadRequest(msg string) error {
return &HTTPError{
Status: http.StatusBadRequest,
Code: "BAD_REQUEST",
Message: msg,
}
}
// BadRequestf creates a 400 Bad Request error with formatted message.
func BadRequestf(format string, args ...any) error {
return BadRequest(fmt.Sprintf(format, args...))
}
// Unauthorized creates a 401 Unauthorized error.
func Unauthorized(msg string) error {
return &HTTPError{
Status: http.StatusUnauthorized,
Code: "UNAUTHORIZED",
Message: msg,
}
}
// Unauthorizedf creates a 401 Unauthorized error with formatted message.
func Unauthorizedf(format string, args ...any) error {
return Unauthorized(fmt.Sprintf(format, args...))
}
// Forbidden creates a 403 Forbidden error.
func Forbidden(msg string) error {
return &HTTPError{
Status: http.StatusForbidden,
Code: "FORBIDDEN",
Message: msg,
}
}
// Forbiddenf creates a 403 Forbidden error with formatted message.
func Forbiddenf(format string, args ...any) error {
return Forbidden(fmt.Sprintf(format, args...))
}
// NotFound creates a 404 Not Found error.
func NotFound(msg string) error {
return &HTTPError{
Status: http.StatusNotFound,
Code: "NOT_FOUND",
Message: msg,
}
}
// NotFoundf creates a 404 Not Found error with formatted message.
func NotFoundf(format string, args ...any) error {
return NotFound(fmt.Sprintf(format, args...))
}
// Conflict creates a 409 Conflict error.
func Conflict(msg string) error {
return &HTTPError{
Status: http.StatusConflict,
Code: "CONFLICT",
Message: msg,
}
}
// Conflictf creates a 409 Conflict error with formatted message.
func Conflictf(format string, args ...any) error {
return Conflict(fmt.Sprintf(format, args...))
}
// Internal creates a 500 Internal Server Error.
func Internal(msg string) error {
return &HTTPError{
Status: http.StatusInternalServerError,
Code: "INTERNAL_ERROR",
Message: msg,
}
}
// Internalf creates a 500 Internal Server Error with formatted message.
func Internalf(format string, args ...any) error {
return Internal(fmt.Sprintf(format, args...))
}
// Validation creates a 400 validation error.
func Validation(msg string) error {
return &HTTPError{
Status: http.StatusBadRequest,
Code: "VALIDATION_ERROR",
Message: msg,
}
}
// Validationf creates a 400 validation error with formatted message.
func Validationf(format string, args ...any) error {
return Validation(fmt.Sprintf(format, args...))
}
// UnprocessableEntity creates a 422 Unprocessable Entity error.
// Use for validation failures when the request is syntactically correct
// but semantically invalid.
func UnprocessableEntity(msg string) error {
return &HTTPError{
Status: http.StatusUnprocessableEntity,
Code: "UNPROCESSABLE_ENTITY",
Message: msg,
}
}
// UnprocessableEntityf creates a 422 Unprocessable Entity error with formatted message.
func UnprocessableEntityf(format string, args ...any) error {
return UnprocessableEntity(fmt.Sprintf(format, args...))
}
// TooManyRequests creates a 429 Too Many Requests error.
func TooManyRequests(msg string) error {
return &HTTPError{
Status: http.StatusTooManyRequests,
Code: "TOO_MANY_REQUESTS",
Message: msg,
}
}
// TooManyRequestsf creates a 429 Too Many Requests error with formatted message.
func TooManyRequestsf(format string, args ...any) error {
return TooManyRequests(fmt.Sprintf(format, args...))
}
// ServiceUnavailable creates a 503 Service Unavailable error.
func ServiceUnavailable(msg string) error {
return &HTTPError{
Status: http.StatusServiceUnavailable,
Code: "SERVICE_UNAVAILABLE",
Message: msg,
}
}
// ServiceUnavailablef creates a 503 Service Unavailable error with formatted message.
func ServiceUnavailablef(format string, args ...any) error {
return ServiceUnavailable(fmt.Sprintf(format, args...))
}
// -----------------------------------------------------------------------------
// Error Wrapping
// -----------------------------------------------------------------------------
// WithDetails returns an error with additional details attached.
// If the error is not an HTTPError, it wraps it as an internal error.
func WithDetails(err error, details any) error {
var httpErr *HTTPError
if errors.As(err, &httpErr) {
return &HTTPError{
Status: httpErr.Status,
Code: httpErr.Code,
Message: httpErr.Message,
Details: details,
cause: httpErr.cause,
}
}
// Not an HTTPError, wrap as internal error with details
return &HTTPError{
Status: http.StatusInternalServerError,
Code: "INTERNAL_ERROR",
Message: err.Error(),
Details: details,
}
}
// WithCode returns an error with a custom error code.
// Useful for domain-specific error codes like "KEY_REVOKED".
func WithCode(err error, code string) error {
var httpErr *HTTPError
if errors.As(err, &httpErr) {
return &HTTPError{
Status: httpErr.Status,
Code: code,
Message: httpErr.Message,
Details: httpErr.Details,
cause: httpErr.cause,
}
}
return &HTTPError{
Status: http.StatusInternalServerError,
Code: code,
Message: err.Error(),
}
}
// WrapError wraps an underlying error with an HTTPError.
// The underlying error is accessible via errors.Unwrap().
func WrapError(httpErr *HTTPError, cause error) error {
return httpErr.WithCause(cause)
}
// -----------------------------------------------------------------------------
// Error Checking
// -----------------------------------------------------------------------------
// IsHTTPError checks if an error is an HTTPError.
func IsHTTPError(err error) bool {
var httpErr *HTTPError
return errors.As(err, &httpErr)
}
// AsHTTPError extracts an HTTPError from an error chain.
// Returns nil if the error is not an HTTPError.
func AsHTTPError(err error) *HTTPError {
var httpErr *HTTPError
if errors.As(err, &httpErr) {
return httpErr
}
return nil
}
// StatusCode returns the HTTP status code for an error.
// Returns 200 for nil errors (success case).
// Returns 500 for non-HTTPErrors.
func StatusCode(err error) int {
if err == nil {
return http.StatusOK
}
if httpErr := AsHTTPError(err); httpErr != nil {
return httpErr.Status
}
return http.StatusInternalServerError
}

View File

@ -0,0 +1,131 @@
#!/bin/bash
# Generate TypeScript API client from OpenAPI spec
#
# Usage:
# ./scripts/generate-client.sh [SPEC_URL]
#
# Environment:
# SPEC_URL - OpenAPI spec URL (default: http://localhost:8080/openapi.json)
set -e
SPEC_URL="${1:-${SPEC_URL:-http://localhost:8080/openapi.json}}"
OUTPUT_DIR="packages/api-client/src"
echo "Generating TypeScript client from: $SPEC_URL"
# Ensure output directory exists
mkdir -p "$OUTPUT_DIR"
# Generate TypeScript types from OpenAPI spec
npx openapi-typescript "$SPEC_URL" -o "$OUTPUT_DIR/schema.d.ts"
echo "Generated: $OUTPUT_DIR/schema.d.ts"
# Create client wrapper if it doesn't exist
if [ ! -f "$OUTPUT_DIR/client.ts" ]; then
cat > "$OUTPUT_DIR/client.ts" << 'EOF'
import type { paths } from './schema';
export type { paths };
/**
* API Client Configuration
*/
export interface ClientConfig {
baseUrl: string;
apiKey?: string;
bearerToken?: string;
headers?: Record<string, string>;
onError?: (error: Error) => void;
}
/**
* Create a typed API client
*/
export function createClient(config: ClientConfig) {
const { baseUrl, apiKey, bearerToken, headers = {}, onError } = config;
async function request<T>(
method: string,
path: string,
options: {
body?: unknown;
params?: Record<string, string | number | boolean | undefined>;
headers?: Record<string, string>;
} = {}
): Promise<T> {
const url = new URL(path, baseUrl);
// Add query params
if (options.params) {
for (const [key, value] of Object.entries(options.params)) {
if (value !== undefined) {
url.searchParams.set(key, String(value));
}
}
}
// Build headers
const requestHeaders: Record<string, string> = {
'Content-Type': 'application/json',
...headers,
...options.headers,
};
if (apiKey) {
requestHeaders['X-API-Key'] = apiKey;
}
if (bearerToken) {
requestHeaders['Authorization'] = `Bearer ${bearerToken}`;
}
const response = await fetch(url.toString(), {
method,
headers: requestHeaders,
body: options.body ? JSON.stringify(options.body) : undefined,
});
if (!response.ok) {
const error = new Error(`API error: ${response.status}`);
if (onError) {
onError(error);
}
throw error;
}
// Handle no-content responses
if (response.status === 204) {
return undefined as T;
}
return response.json();
}
return {
get: <T>(path: string, params?: Record<string, string | number | boolean | undefined>) =>
request<T>('GET', path, { params }),
post: <T>(path: string, body?: unknown) =>
request<T>('POST', path, { body }),
put: <T>(path: string, body?: unknown) =>
request<T>('PUT', path, { body }),
patch: <T>(path: string, body?: unknown) =>
request<T>('PATCH', path, { body }),
delete: <T>(path: string) =>
request<T>('DELETE', path),
};
}
EOF
echo "Created: $OUTPUT_DIR/client.ts"
fi
# Create index if it doesn't exist
if [ ! -f "$OUTPUT_DIR/index.ts" ]; then
cat > "$OUTPUT_DIR/index.ts" << 'EOF'
export * from './client';
export type { paths, components, operations } from './schema';
EOF
echo "Created: $OUTPUT_DIR/index.ts"
fi
echo "Done! Client generated in: $OUTPUT_DIR"

View File

@ -10,14 +10,16 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port" "github.com/orchard9/rdev/internal/port"
"github.com/orchard9/rdev/internal/service"
"github.com/orchard9/rdev/internal/validate" "github.com/orchard9/rdev/internal/validate"
"github.com/orchard9/rdev/pkg/api" "github.com/orchard9/rdev/pkg/api"
) )
// ComponentsHandler handles component management endpoints. // ComponentsHandler handles component management endpoints.
type ComponentsHandler struct { type ComponentsHandler struct {
service port.ComponentService service port.ComponentService
logger *slog.Logger operationService *service.OperationService
logger *slog.Logger
} }
// NewComponentsHandler creates a new components handler. // NewComponentsHandler creates a new components handler.
@ -28,6 +30,14 @@ func NewComponentsHandler(service port.ComponentService, logger *slog.Logger) *C
return &ComponentsHandler{service: service, logger: logger} return &ComponentsHandler{service: service, logger: logger}
} }
// SetOperationService sets the operation tracking service.
func (h *ComponentsHandler) SetOperationService(svc *service.OperationService) *ComponentsHandler {
if svc != nil {
h.operationService = svc
}
return h
}
// Mount registers the component routes. // Mount registers the component routes.
func (h *ComponentsHandler) Mount(r api.Router) { func (h *ComponentsHandler) Mount(r api.Router) {
r.Route("/projects/{id}/components", func(r chi.Router) { r.Route("/projects/{id}/components", func(r chi.Router) {
@ -88,6 +98,15 @@ func (h *ComponentsHandler) Add(w http.ResponseWriter, r *http.Request) {
return return
} }
// Start operation tracking
var operationID string
if h.operationService != nil {
operationID, _ = h.operationService.StartOperation(ctx, projectID,
domain.OperationTypeComponentAdd,
map[string]any{"type": req.Type, "name": req.Name, "template": req.Template, "port": req.Port},
r.Header.Get("X-Request-ID"))
}
component, err := h.service.AddComponent(ctx, projectID, port.AddComponentRequest{ component, err := h.service.AddComponent(ctx, projectID, port.AddComponentRequest{
Type: req.Type, Type: req.Type,
Name: req.Name, Name: req.Name,
@ -95,6 +114,11 @@ func (h *ComponentsHandler) Add(w http.ResponseWriter, r *http.Request) {
Port: req.Port, Port: req.Port,
}) })
if err != nil { if err != nil {
if h.operationService != nil && operationID != "" {
if opErr := h.operationService.FailOperation(ctx, operationID, err.Error(), ""); opErr != nil {
h.logger.Error("failed to record operation failure", "error", opErr, "operation_id", operationID)
}
}
// Map domain errors to HTTP responses // Map domain errors to HTTP responses
switch { switch {
case errors.Is(err, domain.ErrInvalidComponentType): case errors.Is(err, domain.ErrInvalidComponentType):
@ -112,14 +136,31 @@ func (h *ComponentsHandler) Add(w http.ResponseWriter, r *http.Request) {
return return
} }
api.WriteCreated(w, r, ComponentResponse{ if h.operationService != nil && operationID != "" {
Type: string(component.Type), if opErr := h.operationService.CompleteOperation(ctx, operationID, map[string]any{
Name: component.Name, "path": component.Path,
Path: component.Path, "port": component.Port,
Port: component.Port, }); opErr != nil {
Template: component.Template, h.logger.Error("failed to record operation completion", "error", opErr, "operation_id", operationID)
Dependencies: component.Dependencies, }
}) }
deps := component.Dependencies
if deps == nil {
deps = []string{}
}
resp := map[string]any{
"type": string(component.Type),
"name": component.Name,
"path": component.Path,
"port": component.Port,
"template": component.Template,
"dependencies": deps,
}
if operationID != "" {
resp["operation_id"] = operationID
}
api.WriteCreated(w, r, resp)
} }
// List lists all components in a project's monorepo. // List lists all components in a project's monorepo.

View File

@ -0,0 +1,158 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
"github.com/orchard9/rdev/internal/service"
)
func TestComponentsHandler_SetOperationService(t *testing.T) {
h := NewComponentsHandler(nil, nil)
t.Run("sets non-nil service", func(t *testing.T) {
repo := newMockOperationRepo()
opSvc := service.NewOperationService(repo, slog.Default())
result := h.SetOperationService(opSvc)
if h.operationService != opSvc {
t.Error("expected operation service to be set")
}
if result != h {
t.Error("expected fluent return of handler")
}
})
t.Run("ignores nil service", func(t *testing.T) {
repo := newMockOperationRepo()
opSvc := service.NewOperationService(repo, slog.Default())
h.SetOperationService(opSvc)
h.SetOperationService(nil)
if h.operationService != opSvc {
t.Error("nil should not clear operation service")
}
})
}
func TestComponentsHandler_AddTracksOperation(t *testing.T) {
opRepo := newMockOperationRepo()
opSvc := service.NewOperationService(opRepo, slog.Default())
mock := &mockComponentService{
addComponent: func(ctx context.Context, projectID string, req port.AddComponentRequest) (*domain.Component, error) {
return &domain.Component{
Type: domain.ComponentTypeService,
Name: "auth-api",
Path: "services/auth-api",
Port: 8001,
Template: "service",
Dependencies: []string{},
}, nil
},
}
handler := NewComponentsHandler(mock, slog.Default())
handler.SetOperationService(opSvc)
body, _ := json.Marshal(AddComponentRequest{Type: "service", Name: "auth-api"})
req := httptest.NewRequest(http.MethodPost, "/projects/my-project/components", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Request-ID", "req-456")
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", "my-project")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
rec := httptest.NewRecorder()
handler.Add(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("expected status 201, got %d. Body: %s", rec.Code, rec.Body.String())
}
// Verify operation was created and completed
if opRepo.count() != 1 {
t.Fatalf("expected 1 operation, got %d", opRepo.count())
}
// Verify operation status is completed
for _, op := range opRepo.operations {
if op.Status != domain.OperationStatusCompleted {
t.Errorf("expected operation status completed, got %s", op.Status)
}
if op.ProjectID != "my-project" {
t.Errorf("expected project_id my-project, got %s", op.ProjectID)
}
if op.Type != domain.OperationTypeComponentAdd {
t.Errorf("expected operation type component.add, got %s", op.Type)
}
if op.RequestID != "req-456" {
t.Errorf("expected request_id req-456, got %s", op.RequestID)
}
}
// Verify response contains operation_id
var resp struct {
Data map[string]any `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
opID, ok := resp.Data["operation_id"]
if !ok {
t.Error("response missing operation_id field")
}
if opID == "" {
t.Error("operation_id should not be empty")
}
}
func TestComponentsHandler_AddFailsOperationOnError(t *testing.T) {
opRepo := newMockOperationRepo()
opSvc := service.NewOperationService(opRepo, slog.Default())
mock := &mockComponentService{
addComponent: func(ctx context.Context, projectID string, req port.AddComponentRequest) (*domain.Component, error) {
return nil, domain.ErrInvalidComponentType
},
}
handler := NewComponentsHandler(mock, slog.Default())
handler.SetOperationService(opSvc)
body, _ := json.Marshal(AddComponentRequest{Type: "invalid", Name: "auth-api"})
req := httptest.NewRequest(http.MethodPost, "/projects/my-project/components", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", "my-project")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
rec := httptest.NewRecorder()
handler.Add(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", rec.Code)
}
// Operation should be created and marked as failed
if opRepo.count() != 1 {
t.Fatalf("expected 1 operation, got %d", opRepo.count())
}
for _, op := range opRepo.operations {
if op.Status != domain.OperationStatusFailed {
t.Errorf("expected operation status failed, got %s", op.Status)
}
if op.Error == "" {
t.Error("expected error message on failed operation")
}
}
}

View File

@ -0,0 +1,146 @@
package handlers
import (
"context"
"sync"
"time"
"github.com/orchard9/rdev/internal/domain"
)
// mockOperationRepo is a mock implementation of port.OperationRepository for testing.
type mockOperationRepo struct {
mu sync.Mutex
operations map[string]*domain.Operation
}
func newMockOperationRepo() *mockOperationRepo {
return &mockOperationRepo{
operations: make(map[string]*domain.Operation),
}
}
func (m *mockOperationRepo) Create(_ context.Context, op *domain.Operation) error {
m.mu.Lock()
defer m.mu.Unlock()
m.operations[op.ID] = op
return nil
}
func (m *mockOperationRepo) Update(_ context.Context, op *domain.Operation) error {
m.mu.Lock()
defer m.mu.Unlock()
m.operations[op.ID] = op
return nil
}
func (m *mockOperationRepo) Get(_ context.Context, id string) (*domain.Operation, error) {
m.mu.Lock()
defer m.mu.Unlock()
op, ok := m.operations[id]
if !ok {
return nil, domain.ErrOperationNotFound
}
return op, nil
}
func (m *mockOperationRepo) GetByCommitSHA(_ context.Context, projectID, sha string) (*domain.Operation, error) {
m.mu.Lock()
defer m.mu.Unlock()
for _, op := range m.operations {
if op.ProjectID == projectID && op.CommitSHA == sha {
return op, nil
}
}
return nil, domain.ErrOperationNotFound
}
func (m *mockOperationRepo) List(_ context.Context, filter domain.OperationFilters) ([]*domain.Operation, error) {
m.mu.Lock()
defer m.mu.Unlock()
var result []*domain.Operation
for _, op := range m.operations {
if filter.ProjectID != "" && op.ProjectID != filter.ProjectID {
continue
}
result = append(result, op)
}
return result, nil
}
func (m *mockOperationRepo) AddStep(_ context.Context, operationID string, step domain.OperationStep) error {
m.mu.Lock()
defer m.mu.Unlock()
op, ok := m.operations[operationID]
if !ok {
return domain.ErrOperationNotFound
}
op.Steps = append(op.Steps, step)
return nil
}
func (m *mockOperationRepo) UpdateStep(_ context.Context, operationID string, step domain.OperationStep) error {
m.mu.Lock()
defer m.mu.Unlock()
op, ok := m.operations[operationID]
if !ok {
return domain.ErrOperationNotFound
}
for i, s := range op.Steps {
if s.Name == step.Name {
op.Steps[i] = step
return nil
}
}
return nil
}
func (m *mockOperationRepo) Complete(_ context.Context, operationID string, status domain.OperationStatus, output map[string]any, errMsg, errDetail string) error {
m.mu.Lock()
defer m.mu.Unlock()
op, ok := m.operations[operationID]
if !ok {
return domain.ErrOperationNotFound
}
op.Status = status
now := time.Now()
op.CompletedAt = &now
op.DurationMs = now.Sub(op.StartedAt).Milliseconds()
op.Output = output
op.Error = errMsg
op.ErrorDetail = errDetail
return nil
}
func (m *mockOperationRepo) SetCommitSHA(_ context.Context, operationID, sha string) error {
m.mu.Lock()
defer m.mu.Unlock()
op, ok := m.operations[operationID]
if !ok {
return domain.ErrOperationNotFound
}
op.CommitSHA = sha
return nil
}
func (m *mockOperationRepo) SetTriggeredBy(_ context.Context, operationID, parentID string) error {
m.mu.Lock()
defer m.mu.Unlock()
op, ok := m.operations[operationID]
if !ok {
return domain.ErrOperationNotFound
}
op.TriggeredBy = parentID
return nil
}
func (m *mockOperationRepo) DeleteOlderThan(_ context.Context, _ time.Time) (int64, error) {
return 0, nil
}
// count returns the number of operations stored.
func (m *mockOperationRepo) count() int {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.operations)
}

View File

@ -15,8 +15,9 @@ import (
// ProjectManagementHandler handles project lifecycle operations. // ProjectManagementHandler handles project lifecycle operations.
type ProjectManagementHandler struct { type ProjectManagementHandler struct {
infraService *service.ProjectInfraService infraService *service.ProjectInfraService
logger *slog.Logger operationService *service.OperationService
logger *slog.Logger
} }
// NewProjectManagementHandler creates a new project management handler. // NewProjectManagementHandler creates a new project management handler.
@ -30,6 +31,14 @@ func NewProjectManagementHandler(infraService *service.ProjectInfraService, logg
} }
} }
// SetOperationService sets the operation tracking service.
func (h *ProjectManagementHandler) SetOperationService(svc *service.OperationService) *ProjectManagementHandler {
if svc != nil {
h.operationService = svc
}
return h
}
// Mount registers the project management routes. // Mount registers the project management routes.
func (h *ProjectManagementHandler) Mount(r api.Router) { func (h *ProjectManagementHandler) Mount(r api.Router) {
r.Route("/project", func(r chi.Router) { r.Route("/project", func(r chi.Router) {
@ -75,6 +84,15 @@ func (h *ProjectManagementHandler) Create(w http.ResponseWriter, r *http.Request
return return
} }
// Start operation tracking
var operationID string
if h.operationService != nil {
operationID, _ = h.operationService.StartOperation(ctx, req.Name,
domain.OperationTypeProjectCreate,
map[string]any{"name": req.Name, "description": req.Description, "template": req.Template},
r.Header.Get("X-Request-ID"))
}
result, err := h.infraService.CreateProject(ctx, service.CreateProjectRequest{ result, err := h.infraService.CreateProject(ctx, service.CreateProjectRequest{
Name: req.Name, Name: req.Name,
Description: req.Description, Description: req.Description,
@ -82,6 +100,11 @@ func (h *ProjectManagementHandler) Create(w http.ResponseWriter, r *http.Request
Template: req.Template, Template: req.Template,
}) })
if err != nil { if err != nil {
if h.operationService != nil && operationID != "" {
if opErr := h.operationService.FailOperation(ctx, operationID, err.Error(), ""); opErr != nil {
h.logger.Error("failed to record operation failure", "error", opErr, "operation_id", operationID)
}
}
// Check for validation errors (user input) vs internal errors // Check for validation errors (user input) vs internal errors
if errors.Is(err, domain.ErrInvalidProjectName) { if errors.Is(err, domain.ErrInvalidProjectName) {
api.WriteBadRequest(w, r, err.Error()) api.WriteBadRequest(w, r, err.Error())
@ -93,7 +116,16 @@ func (h *ProjectManagementHandler) Create(w http.ResponseWriter, r *http.Request
return return
} }
api.WriteCreated(w, r, map[string]any{ if h.operationService != nil && operationID != "" {
if opErr := h.operationService.CompleteOperation(ctx, operationID, map[string]any{
"project_id": result.ProjectID,
"git_url": result.CloneHTTP,
}); opErr != nil {
h.logger.Error("failed to record operation completion", "error", opErr, "operation_id", operationID)
}
}
resp := map[string]any{
"project_id": result.ProjectID, "project_id": result.ProjectID,
"name": result.Name, "name": result.Name,
"description": result.Description, "description": result.Description,
@ -107,7 +139,11 @@ func (h *ProjectManagementHandler) Create(w http.ResponseWriter, r *http.Request
"domain": result.Domain, "domain": result.Domain,
"url": result.URL, "url": result.URL,
"next_steps": result.NextSteps, "next_steps": result.NextSteps,
}) }
if operationID != "" {
resp["operation_id"] = operationID
}
api.WriteCreated(w, r, resp)
} }
// List returns all projects. // List returns all projects.

View File

@ -9,6 +9,8 @@ import (
"testing" "testing"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/service"
) )
func TestProjectManagementHandler_NilService(t *testing.T) { func TestProjectManagementHandler_NilService(t *testing.T) {
@ -84,3 +86,113 @@ func TestNewProjectManagementHandler_NilLogger(t *testing.T) {
t.Error("logger should default to slog.Default() when nil") t.Error("logger should default to slog.Default() when nil")
} }
} }
func TestProjectManagementHandler_SetOperationService(t *testing.T) {
h := NewProjectManagementHandler(nil, slog.Default())
t.Run("sets non-nil service", func(t *testing.T) {
repo := newMockOperationRepo()
opSvc := service.NewOperationService(repo, slog.Default())
result := h.SetOperationService(opSvc)
if h.operationService != opSvc {
t.Error("expected operation service to be set")
}
if result != h {
t.Error("expected fluent return of handler")
}
})
t.Run("ignores nil service", func(t *testing.T) {
repo := newMockOperationRepo()
opSvc := service.NewOperationService(repo, slog.Default())
h.SetOperationService(opSvc) // set first
h.SetOperationService(nil) // should not clear
if h.operationService != opSvc {
t.Error("nil should not clear operation service")
}
})
}
func TestProjectManagementHandler_CreateTracksOperation(t *testing.T) {
// This test verifies that the Create handler starts and completes operations
// when an operationService is configured. Since we can't easily mock
// ProjectInfraService (concrete struct), we test that nil infraService
// still doesn't panic when operationService is set.
repo := newMockOperationRepo()
opSvc := service.NewOperationService(repo, slog.Default())
h := NewProjectManagementHandler(nil, slog.Default()).
SetOperationService(opSvc)
r := chi.NewRouter()
h.Mount(r)
body, _ := json.Marshal(CreateRequest{Name: "test-project"})
req := httptest.NewRequest("POST", "/project", bytes.NewReader(body))
req.Header.Set("X-Request-ID", "req-123")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
// Handler returns 500 because infraService is nil, but operation should NOT
// have been started because the nil-service check happens before operation tracking.
if rec.Code != http.StatusInternalServerError {
t.Errorf("status = %d, want %d", rec.Code, http.StatusInternalServerError)
}
// Operation should not be created because infraService nil check comes first
if repo.count() != 0 {
t.Errorf("expected no operations (nil service returns early), got %d", repo.count())
}
}
func TestProjectManagementHandler_OperationIDInResponse(t *testing.T) {
// Verify the response shape includes operation_id field when it would be set.
// We test the response structure by examining what Create() writes.
// Since we can't mock the concrete ProjectInfraService, this is a structural test
// verifying the handler properly sets the operationService field.
h := NewProjectManagementHandler(nil, slog.Default())
if h.operationService != nil {
t.Error("operationService should be nil by default")
}
repo := newMockOperationRepo()
opSvc := service.NewOperationService(repo, slog.Default())
h.SetOperationService(opSvc)
if h.operationService == nil {
t.Error("operationService should be set after SetOperationService")
}
}
func TestProjectManagementHandler_OperationFailsOnError(t *testing.T) {
// When operationService is set but create fails, the operation should be marked failed.
// Since nil infraService returns 500 before reaching operation tracking, we verify
// that operations are not leaked when the handler exits early.
repo := newMockOperationRepo()
opSvc := service.NewOperationService(repo, slog.Default())
h := &ProjectManagementHandler{
infraService: nil, // will cause 500
operationService: opSvc,
logger: slog.Default(),
}
r := chi.NewRouter()
r.Post("/project", h.Create)
body, _ := json.Marshal(CreateRequest{Name: "fail-project"})
req := httptest.NewRequest("POST", "/project", bytes.NewReader(body))
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
// The nil infraService check happens before operation tracking starts,
// so no operation should exist
if repo.count() != 0 {
// If an operation was created, verify it was marked as failed
for _, op := range repo.operations {
if op.Status != domain.OperationStatusFailed {
t.Errorf("expected operation status failed, got %s", op.Status)
}
}
}
}

View File

@ -15,14 +15,16 @@ import (
"github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port" "github.com/orchard9/rdev/internal/port"
"github.com/orchard9/rdev/internal/service"
"github.com/orchard9/rdev/pkg/api" "github.com/orchard9/rdev/pkg/api"
) )
// WoodpeckerWebhookHandler handles webhooks from Woodpecker CI. // WoodpeckerWebhookHandler handles webhooks from Woodpecker CI.
type WoodpeckerWebhookHandler struct { type WoodpeckerWebhookHandler struct {
deployer port.Deployer deployer port.Deployer
dns port.DNSProvider dns port.DNSProvider
logger *slog.Logger operationService *service.OperationService
logger *slog.Logger
// Config // Config
webhookSecret string webhookSecret string
@ -61,6 +63,14 @@ func NewWoodpeckerWebhookHandler(
} }
} }
// SetOperationService sets the operation tracking service.
func (h *WoodpeckerWebhookHandler) SetOperationService(svc *service.OperationService) *WoodpeckerWebhookHandler {
if svc != nil {
h.operationService = svc
}
return h
}
// Mount registers the webhook routes. // Mount registers the webhook routes.
func (h *WoodpeckerWebhookHandler) Mount(r api.Router) { func (h *WoodpeckerWebhookHandler) Mount(r api.Router) {
// Woodpecker webhook endpoint - no API key auth, uses HMAC signature // Woodpecker webhook endpoint - no API key auth, uses HMAC signature
@ -206,6 +216,41 @@ func (h *WoodpeckerWebhookHandler) HandleWebhook(w http.ResponseWriter, r *http.
} }
} }
// Track build operation
var operationID string
if h.operationService != nil {
operationID, _ = h.operationService.StartOperation(ctx, projectName,
domain.OperationTypeBuild,
map[string]any{
"repo": payload.Repo.FullName,
"branch": payload.Build.Branch,
"commit": payload.Build.Commit,
"build_number": payload.Build.Number,
}, "")
if operationID != "" {
// Set external reference to build number
if opErr := h.operationService.SetExternalRef(ctx, operationID, fmt.Sprintf("build#%d", payload.Build.Number)); opErr != nil {
h.logger.Error("failed to set external ref", "error", opErr, "operation_id", operationID)
}
// Link to parent operation via commit SHA
if parent, err := h.operationService.FindByCommit(ctx, projectName, payload.Build.Commit); err == nil && parent != nil {
if opErr := h.operationService.LinkToParent(ctx, operationID, parent.ID); opErr != nil {
h.logger.Error("failed to link to parent operation", "error", opErr, "operation_id", operationID)
}
}
// Build webhook only fires for successful builds
if opErr := h.operationService.CompleteOperation(ctx, operationID, map[string]any{
"image": imageTag,
"commit": payload.Build.Commit,
}); opErr != nil {
h.logger.Error("failed to record operation completion", "error", opErr, "operation_id", operationID)
}
}
}
// Note: Project-level deployment is skipped for composable projects. // Note: Project-level deployment is skipped for composable projects.
// Component deployments are created by createInitialComponentDeployment // Component deployments are created by createInitialComponentDeployment
// and updated by the CI pipeline's kubectl set image commands. // and updated by the CI pipeline's kubectl set image commands.
@ -214,13 +259,17 @@ func (h *WoodpeckerWebhookHandler) HandleWebhook(w http.ResponseWriter, r *http.
"commit", payload.Build.Commit, "commit", payload.Build.Commit,
) )
api.WriteSuccess(w, r, map[string]any{ resp := map[string]any{
"status": "success", "status": "success",
"project": projectName, "project": projectName,
"image": imageTag, "image": imageTag,
"commit": payload.Build.Commit, "commit": payload.Build.Commit,
"note": "component deployments managed by CI pipeline", "note": "component deployments managed by CI pipeline",
}) }
if operationID != "" {
resp["operation_id"] = operationID
}
api.WriteSuccess(w, r, resp)
} }
// verifySignature verifies the HMAC-SHA256 signature of the webhook payload. // verifySignature verifies the HMAC-SHA256 signature of the webhook payload.

View File

@ -4,7 +4,15 @@ import (
"crypto/hmac" "crypto/hmac"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing" "testing"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/service"
) )
func TestVerifySignature_ValidSignature(t *testing.T) { func TestVerifySignature_ValidSignature(t *testing.T) {
@ -83,3 +91,195 @@ func TestVerifySignature_TamperedBody(t *testing.T) {
t.Error("expected tampered body to fail verification") t.Error("expected tampered body to fail verification")
} }
} }
func TestWoodpeckerWebhookHandler_SetOperationService(t *testing.T) {
h := &WoodpeckerWebhookHandler{logger: slog.Default()}
t.Run("sets non-nil service", func(t *testing.T) {
repo := newMockOperationRepo()
opSvc := service.NewOperationService(repo, slog.Default())
result := h.SetOperationService(opSvc)
if h.operationService != opSvc {
t.Error("expected operation service to be set")
}
if result != h {
t.Error("expected fluent return of handler")
}
})
t.Run("ignores nil service", func(t *testing.T) {
repo := newMockOperationRepo()
opSvc := service.NewOperationService(repo, slog.Default())
h.SetOperationService(opSvc)
h.SetOperationService(nil)
if h.operationService != opSvc {
t.Error("nil should not clear operation service")
}
})
}
func TestWoodpeckerWebhookHandler_TracksOperation(t *testing.T) {
opRepo := newMockOperationRepo()
opSvc := service.NewOperationService(opRepo, slog.Default())
h := &WoodpeckerWebhookHandler{
operationService: opSvc,
logger: slog.Default(),
registryURL: "registry.test:5000",
defaultDomain: "test.ai",
clusterIP: "1.2.3.4",
}
payload := WoodpeckerPayload{
Event: "push",
Repo: WoodpeckerRepo{
Name: "my-project",
FullName: "org/my-project",
},
Build: WoodpeckerBuild{
Number: 42,
Status: "success",
Branch: "main",
Commit: "abc12345def67890",
},
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPost, "/webhooks/woodpecker", strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.HandleWebhook(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. Body: %s", rec.Code, rec.Body.String())
}
// Verify operation was created
if opRepo.count() != 1 {
t.Fatalf("expected 1 operation, got %d", opRepo.count())
}
// Verify operation details
for _, op := range opRepo.operations {
if op.Type != domain.OperationTypeBuild {
t.Errorf("expected operation type build, got %s", op.Type)
}
if op.ProjectID != "my-project" {
t.Errorf("expected project_id my-project, got %s", op.ProjectID)
}
if op.Status != domain.OperationStatusCompleted {
t.Errorf("expected operation status completed, got %s", op.Status)
}
if op.ExternalRef != "build#42" {
t.Errorf("expected external_ref build#42, got %s", op.ExternalRef)
}
}
// Verify response contains operation_id
var resp struct {
Data map[string]any `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
opID, ok := resp.Data["operation_id"]
if !ok {
t.Error("response missing operation_id field")
}
if opID == "" {
t.Error("operation_id should not be empty")
}
}
func TestWoodpeckerWebhookHandler_LinksToParentOperation(t *testing.T) {
opRepo := newMockOperationRepo()
opSvc := service.NewOperationService(opRepo, slog.Default())
// Create a parent operation (component.add) that has the same commit SHA
parentID, _ := opSvc.StartOperation(
t.Context(),
"my-project",
domain.OperationTypeComponentAdd,
map[string]any{"type": "service", "name": "auth-api"},
"",
)
// Set the commit SHA on the parent (simulates component add creating a commit)
opSvc.SetCommitSHA(t.Context(), parentID, "abc12345def67890")
opSvc.CompleteOperation(t.Context(), parentID, nil)
h := &WoodpeckerWebhookHandler{
operationService: opSvc,
logger: slog.Default(),
registryURL: "registry.test:5000",
}
payload := WoodpeckerPayload{
Event: "push",
Repo: WoodpeckerRepo{
Name: "my-project",
FullName: "org/my-project",
},
Build: WoodpeckerBuild{
Number: 43,
Status: "success",
Branch: "main",
Commit: "abc12345def67890",
},
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPost, "/webhooks/woodpecker", strings.NewReader(string(body)))
rec := httptest.NewRecorder()
h.HandleWebhook(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. Body: %s", rec.Code, rec.Body.String())
}
// Should now have 2 operations: parent (component.add) and child (build)
if opRepo.count() != 2 {
t.Fatalf("expected 2 operations, got %d", opRepo.count())
}
// Find the build operation and verify it's linked to parent
for _, op := range opRepo.operations {
if op.Type == domain.OperationTypeBuild {
if op.TriggeredBy != parentID {
t.Errorf("expected triggered_by %s, got %s", parentID, op.TriggeredBy)
}
return
}
}
t.Error("build operation not found")
}
func TestWoodpeckerWebhookHandler_IgnoresNonSuccessBuilds(t *testing.T) {
opRepo := newMockOperationRepo()
opSvc := service.NewOperationService(opRepo, slog.Default())
h := &WoodpeckerWebhookHandler{
operationService: opSvc,
logger: slog.Default(),
}
payload := WoodpeckerPayload{
Event: "push",
Repo: WoodpeckerRepo{Name: "my-project"},
Build: WoodpeckerBuild{
Status: "failure",
Branch: "main",
Commit: "abc123",
},
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPost, "/webhooks/woodpecker", strings.NewReader(string(body)))
rec := httptest.NewRecorder()
h.HandleWebhook(rec, req)
// Non-success builds are ignored, so no operation should be created
if opRepo.count() != 0 {
t.Errorf("expected no operations for failed build, got %d", opRepo.count())
}
}

108
pkg/api/bind.go Normal file
View File

@ -0,0 +1,108 @@
package api
import (
"errors"
"net/http"
"github.com/orchard9/rdev/internal/validate"
)
// Bind decodes JSON from the request body and validates it.
// This combines DecodeJSON and validation in a single call for convenience.
//
// Usage:
//
// func CreateUser(w http.ResponseWriter, r *http.Request) error {
// var req CreateUserRequest
// if err := api.Bind(r, &req); err != nil {
// return err // Returns typed HTTPError
// }
// // req is now decoded and validated
// }
//
// The struct should have validation tags:
//
// type CreateUserRequest struct {
// Name string `json:"name" validate:"required,min=1,max=100"`
// Email string `json:"email" validate:"required,email"`
// }
//
// Bind uses the internal/validate package for validation. For struct validation
// with go-playground/validator tags, see BindWithValidator.
func Bind(r *http.Request, v any) error {
// Decode JSON
if err := DecodeJSON(r, v); err != nil {
if errors.Is(err, ErrEmptyBody) {
return BadRequest("request body is required")
}
return BadRequest("invalid request body")
}
return nil
}
// BindStrict decodes JSON from the request body with strict field checking.
// Unknown fields in the JSON will cause an error.
func BindStrict(r *http.Request, v any) error {
if err := DecodeJSONStrict(r, v); err != nil {
if errors.Is(err, ErrEmptyBody) {
return BadRequest("request body is required")
}
return BadRequest("invalid request body")
}
return nil
}
// BindAndValidate decodes JSON and validates using the validate package.
// Returns a validation error with field-level details if validation fails.
//
// Usage:
//
// func CreateUser(w http.ResponseWriter, r *http.Request) error {
// var req CreateUserRequest
// if err := api.BindAndValidate(r, &req, func(v *validate.Validator, req *CreateUserRequest) {
// v.Required(req.Name, "name")
// v.Required(req.Email, "email")
// v.StringLength(req.Name, "name", 1, 100)
// }); err != nil {
// return err
// }
// }
func BindAndValidate[T any](r *http.Request, v *T, validateFn func(*validate.Validator, *T)) error {
// Decode JSON
if err := DecodeJSON(r, v); err != nil {
if errors.Is(err, ErrEmptyBody) {
return BadRequest("request body is required")
}
return BadRequest("invalid request body")
}
// Run validation
validator := validate.New()
validateFn(validator, v)
if validator.HasErrors() {
return WithDetails(Validation("validation failed"), formatValidateErrors(validator.Errors()))
}
return nil
}
// ValidationDetail is the structure for field-level validation errors.
type ValidationDetail struct {
Field string `json:"field"`
Message string `json:"message"`
}
// formatValidateErrors converts validation errors to API-friendly details.
func formatValidateErrors(errs validate.ValidationErrors) []ValidationDetail {
details := make([]ValidationDetail, 0, len(errs))
for _, e := range errs {
details = append(details, ValidationDetail{
Field: e.Field,
Message: e.Message,
})
}
return details
}

305
pkg/api/error.go Normal file
View File

@ -0,0 +1,305 @@
package api
import (
"errors"
"fmt"
"net/http"
)
// HTTPError is a typed error that maps to an HTTP response.
// It implements the error interface and provides sentinel error matching via errors.Is().
type HTTPError struct {
Status int // HTTP status code
Code string // Machine-readable error code
Message string // Human-readable message
Details any // Optional additional details
cause error // Underlying error for wrapping
}
// Error implements the error interface.
func (e *HTTPError) Error() string {
if e.cause != nil {
return fmt.Sprintf("%s: %v", e.Message, e.cause)
}
return e.Message
}
// Unwrap returns the underlying error for errors.Unwrap().
func (e *HTTPError) Unwrap() error {
return e.cause
}
// Is implements errors.Is() matching for sentinel errors.
// Matches if both errors are HTTPErrors with the same status code.
func (e *HTTPError) Is(target error) bool {
var t *HTTPError
if errors.As(target, &t) {
return e.Status == t.Status
}
return false
}
// WithCause returns a copy of the error with the given underlying cause.
func (e *HTTPError) WithCause(cause error) *HTTPError {
return &HTTPError{
Status: e.Status,
Code: e.Code,
Message: e.Message,
Details: e.Details,
cause: cause,
}
}
// -----------------------------------------------------------------------------
// Sentinel Errors
// -----------------------------------------------------------------------------
// Sentinel errors for errors.Is() matching.
// Use these with errors.Is() to check error types:
//
// if errors.Is(err, api.ErrNotFound) {
// // handle not found
// }
var (
ErrBadRequest = &HTTPError{Status: http.StatusBadRequest, Code: "BAD_REQUEST", Message: "bad request"}
ErrUnauthorized = &HTTPError{Status: http.StatusUnauthorized, Code: "UNAUTHORIZED", Message: "unauthorized"}
ErrForbidden = &HTTPError{Status: http.StatusForbidden, Code: "FORBIDDEN", Message: "forbidden"}
ErrNotFound = &HTTPError{Status: http.StatusNotFound, Code: "NOT_FOUND", Message: "not found"}
ErrConflict = &HTTPError{Status: http.StatusConflict, Code: "CONFLICT", Message: "conflict"}
ErrUnprocessableEntity = &HTTPError{Status: http.StatusUnprocessableEntity, Code: "UNPROCESSABLE_ENTITY", Message: "unprocessable entity"}
ErrTooManyRequests = &HTTPError{Status: http.StatusTooManyRequests, Code: "TOO_MANY_REQUESTS", Message: "too many requests"}
ErrInternal = &HTTPError{Status: http.StatusInternalServerError, Code: "INTERNAL_ERROR", Message: "internal error"}
ErrServiceUnavailable = &HTTPError{Status: http.StatusServiceUnavailable, Code: "SERVICE_UNAVAILABLE", Message: "service unavailable"}
ErrValidation = &HTTPError{Status: http.StatusBadRequest, Code: "VALIDATION_ERROR", Message: "validation failed"}
)
// -----------------------------------------------------------------------------
// Factory Functions
// -----------------------------------------------------------------------------
// BadRequest creates a 400 Bad Request error.
func BadRequest(msg string) error {
return &HTTPError{
Status: http.StatusBadRequest,
Code: "BAD_REQUEST",
Message: msg,
}
}
// BadRequestf creates a 400 Bad Request error with formatted message.
func BadRequestf(format string, args ...any) error {
return BadRequest(fmt.Sprintf(format, args...))
}
// Unauthorized creates a 401 Unauthorized error.
func Unauthorized(msg string) error {
return &HTTPError{
Status: http.StatusUnauthorized,
Code: "UNAUTHORIZED",
Message: msg,
}
}
// Unauthorizedf creates a 401 Unauthorized error with formatted message.
func Unauthorizedf(format string, args ...any) error {
return Unauthorized(fmt.Sprintf(format, args...))
}
// Forbidden creates a 403 Forbidden error.
func Forbidden(msg string) error {
return &HTTPError{
Status: http.StatusForbidden,
Code: "FORBIDDEN",
Message: msg,
}
}
// Forbiddenf creates a 403 Forbidden error with formatted message.
func Forbiddenf(format string, args ...any) error {
return Forbidden(fmt.Sprintf(format, args...))
}
// NotFound creates a 404 Not Found error.
func NotFound(msg string) error {
return &HTTPError{
Status: http.StatusNotFound,
Code: "NOT_FOUND",
Message: msg,
}
}
// NotFoundf creates a 404 Not Found error with formatted message.
func NotFoundf(format string, args ...any) error {
return NotFound(fmt.Sprintf(format, args...))
}
// Conflict creates a 409 Conflict error.
func Conflict(msg string) error {
return &HTTPError{
Status: http.StatusConflict,
Code: "CONFLICT",
Message: msg,
}
}
// Conflictf creates a 409 Conflict error with formatted message.
func Conflictf(format string, args ...any) error {
return Conflict(fmt.Sprintf(format, args...))
}
// Internal creates a 500 Internal Server Error.
func Internal(msg string) error {
return &HTTPError{
Status: http.StatusInternalServerError,
Code: "INTERNAL_ERROR",
Message: msg,
}
}
// Internalf creates a 500 Internal Server Error with formatted message.
func Internalf(format string, args ...any) error {
return Internal(fmt.Sprintf(format, args...))
}
// Validation creates a 400 validation error.
func Validation(msg string) error {
return &HTTPError{
Status: http.StatusBadRequest,
Code: "VALIDATION_ERROR",
Message: msg,
}
}
// Validationf creates a 400 validation error with formatted message.
func Validationf(format string, args ...any) error {
return Validation(fmt.Sprintf(format, args...))
}
// UnprocessableEntity creates a 422 Unprocessable Entity error.
// Use for validation failures when the request is syntactically correct
// but semantically invalid.
func UnprocessableEntity(msg string) error {
return &HTTPError{
Status: http.StatusUnprocessableEntity,
Code: "UNPROCESSABLE_ENTITY",
Message: msg,
}
}
// UnprocessableEntityf creates a 422 Unprocessable Entity error with formatted message.
func UnprocessableEntityf(format string, args ...any) error {
return UnprocessableEntity(fmt.Sprintf(format, args...))
}
// TooManyRequests creates a 429 Too Many Requests error.
func TooManyRequests(msg string) error {
return &HTTPError{
Status: http.StatusTooManyRequests,
Code: "TOO_MANY_REQUESTS",
Message: msg,
}
}
// TooManyRequestsf creates a 429 Too Many Requests error with formatted message.
func TooManyRequestsf(format string, args ...any) error {
return TooManyRequests(fmt.Sprintf(format, args...))
}
// ServiceUnavailable creates a 503 Service Unavailable error.
func ServiceUnavailable(msg string) error {
return &HTTPError{
Status: http.StatusServiceUnavailable,
Code: "SERVICE_UNAVAILABLE",
Message: msg,
}
}
// ServiceUnavailablef creates a 503 Service Unavailable error with formatted message.
func ServiceUnavailablef(format string, args ...any) error {
return ServiceUnavailable(fmt.Sprintf(format, args...))
}
// -----------------------------------------------------------------------------
// Error Wrapping
// -----------------------------------------------------------------------------
// WithDetails returns an error with additional details attached.
// If the error is not an HTTPError, it wraps it as an internal error.
func WithDetails(err error, details any) error {
var httpErr *HTTPError
if errors.As(err, &httpErr) {
return &HTTPError{
Status: httpErr.Status,
Code: httpErr.Code,
Message: httpErr.Message,
Details: details,
cause: httpErr.cause,
}
}
// Not an HTTPError, wrap as internal error with details
return &HTTPError{
Status: http.StatusInternalServerError,
Code: "INTERNAL_ERROR",
Message: err.Error(),
Details: details,
}
}
// WithCode returns an error with a custom error code.
// Useful for domain-specific error codes like "KEY_REVOKED".
func WithCode(err error, code string) error {
var httpErr *HTTPError
if errors.As(err, &httpErr) {
return &HTTPError{
Status: httpErr.Status,
Code: code,
Message: httpErr.Message,
Details: httpErr.Details,
cause: httpErr.cause,
}
}
return &HTTPError{
Status: http.StatusInternalServerError,
Code: code,
Message: err.Error(),
}
}
// WrapError wraps an underlying error with an HTTPError.
// The underlying error is accessible via errors.Unwrap().
func WrapError(httpErr *HTTPError, cause error) error {
return httpErr.WithCause(cause)
}
// -----------------------------------------------------------------------------
// Error Checking
// -----------------------------------------------------------------------------
// IsHTTPError checks if an error is an HTTPError.
func IsHTTPError(err error) bool {
var httpErr *HTTPError
return errors.As(err, &httpErr)
}
// AsHTTPError extracts an HTTPError from an error chain.
// Returns nil if the error is not an HTTPError.
func AsHTTPError(err error) *HTTPError {
var httpErr *HTTPError
if errors.As(err, &httpErr) {
return httpErr
}
return nil
}
// StatusCode returns the HTTP status code for an error.
// Returns 200 for nil errors (success case).
// Returns 500 for non-HTTPErrors.
func StatusCode(err error) int {
if err == nil {
return http.StatusOK
}
if httpErr := AsHTTPError(err); httpErr != nil {
return httpErr.Status
}
return http.StatusInternalServerError
}

308
pkg/api/error_test.go Normal file
View File

@ -0,0 +1,308 @@
package api
import (
"errors"
"net/http"
"testing"
)
func TestHTTPError_Error(t *testing.T) {
tests := []struct {
name string
err *HTTPError
expected string
}{
{
name: "simple error",
err: &HTTPError{Status: 404, Code: "NOT_FOUND", Message: "user not found"},
expected: "user not found",
},
{
name: "error with cause",
err: &HTTPError{
Status: 500,
Code: "INTERNAL_ERROR",
Message: "database error",
cause: errors.New("connection refused"),
},
expected: "database error: connection refused",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.err.Error(); got != tt.expected {
t.Errorf("HTTPError.Error() = %q, want %q", got, tt.expected)
}
})
}
}
func TestHTTPError_Is(t *testing.T) {
tests := []struct {
name string
err error
target error
expected bool
}{
{
name: "matches ErrNotFound",
err: NotFound("user not found"),
target: ErrNotFound,
expected: true,
},
{
name: "matches ErrBadRequest",
err: BadRequest("invalid input"),
target: ErrBadRequest,
expected: true,
},
{
name: "does not match different status",
err: NotFound("not found"),
target: ErrBadRequest,
expected: false,
},
{
name: "wrapped error matches",
err: WrapError(ErrNotFound, errors.New("db error")),
target: ErrNotFound,
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := errors.Is(tt.err, tt.target); got != tt.expected {
t.Errorf("errors.Is() = %v, want %v", got, tt.expected)
}
})
}
}
func TestHTTPError_Unwrap(t *testing.T) {
cause := errors.New("underlying error")
err := WrapError(ErrInternal, cause)
unwrapped := errors.Unwrap(err)
if unwrapped != cause {
t.Errorf("Unwrap() = %v, want %v", unwrapped, cause)
}
}
func TestFactoryFunctions(t *testing.T) {
tests := []struct {
name string
err error
wantStatus int
wantCode string
wantContains string
}{
{
name: "BadRequest",
err: BadRequest("invalid input"),
wantStatus: http.StatusBadRequest,
wantCode: "BAD_REQUEST",
wantContains: "invalid input",
},
{
name: "BadRequestf",
err: BadRequestf("field %s is invalid", "email"),
wantStatus: http.StatusBadRequest,
wantCode: "BAD_REQUEST",
wantContains: "field email is invalid",
},
{
name: "Unauthorized",
err: Unauthorized("token expired"),
wantStatus: http.StatusUnauthorized,
wantCode: "UNAUTHORIZED",
wantContains: "token expired",
},
{
name: "Forbidden",
err: Forbidden("access denied"),
wantStatus: http.StatusForbidden,
wantCode: "FORBIDDEN",
wantContains: "access denied",
},
{
name: "NotFound",
err: NotFound("user not found"),
wantStatus: http.StatusNotFound,
wantCode: "NOT_FOUND",
wantContains: "user not found",
},
{
name: "NotFoundf",
err: NotFoundf("user %s not found", "123"),
wantStatus: http.StatusNotFound,
wantCode: "NOT_FOUND",
wantContains: "user 123 not found",
},
{
name: "Conflict",
err: Conflict("already exists"),
wantStatus: http.StatusConflict,
wantCode: "CONFLICT",
wantContains: "already exists",
},
{
name: "Internal",
err: Internal("something went wrong"),
wantStatus: http.StatusInternalServerError,
wantCode: "INTERNAL_ERROR",
wantContains: "something went wrong",
},
{
name: "Validation",
err: Validation("validation failed"),
wantStatus: http.StatusBadRequest,
wantCode: "VALIDATION_ERROR",
wantContains: "validation failed",
},
{
name: "UnprocessableEntity",
err: UnprocessableEntity("semantic error"),
wantStatus: http.StatusUnprocessableEntity,
wantCode: "UNPROCESSABLE_ENTITY",
wantContains: "semantic error",
},
{
name: "TooManyRequests",
err: TooManyRequests("rate limited"),
wantStatus: http.StatusTooManyRequests,
wantCode: "TOO_MANY_REQUESTS",
wantContains: "rate limited",
},
{
name: "ServiceUnavailable",
err: ServiceUnavailable("maintenance"),
wantStatus: http.StatusServiceUnavailable,
wantCode: "SERVICE_UNAVAILABLE",
wantContains: "maintenance",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
httpErr := AsHTTPError(tt.err)
if httpErr == nil {
t.Fatal("expected HTTPError")
}
if httpErr.Status != tt.wantStatus {
t.Errorf("Status = %d, want %d", httpErr.Status, tt.wantStatus)
}
if httpErr.Code != tt.wantCode {
t.Errorf("Code = %q, want %q", httpErr.Code, tt.wantCode)
}
if httpErr.Message != tt.wantContains {
t.Errorf("Message = %q, want %q", httpErr.Message, tt.wantContains)
}
})
}
}
func TestWithDetails(t *testing.T) {
details := map[string]string{"field": "email", "error": "invalid format"}
err := WithDetails(BadRequest("validation failed"), details)
httpErr := AsHTTPError(err)
if httpErr == nil {
t.Fatal("expected HTTPError")
}
if httpErr.Details == nil {
t.Fatal("expected Details to be set")
}
detailsMap, ok := httpErr.Details.(map[string]string)
if !ok {
t.Fatalf("expected map[string]string, got %T", httpErr.Details)
}
if detailsMap["field"] != "email" {
t.Errorf("Details[field] = %q, want %q", detailsMap["field"], "email")
}
}
func TestWithCode(t *testing.T) {
err := WithCode(Forbidden("access denied"), "KEY_REVOKED")
httpErr := AsHTTPError(err)
if httpErr == nil {
t.Fatal("expected HTTPError")
}
if httpErr.Code != "KEY_REVOKED" {
t.Errorf("Code = %q, want %q", httpErr.Code, "KEY_REVOKED")
}
if httpErr.Status != http.StatusForbidden {
t.Errorf("Status = %d, want %d", httpErr.Status, http.StatusForbidden)
}
}
func TestStatusCode(t *testing.T) {
tests := []struct {
name string
err error
wantStatus int
}{
{
name: "HTTPError",
err: NotFound("not found"),
wantStatus: http.StatusNotFound,
},
{
name: "regular error",
err: errors.New("some error"),
wantStatus: http.StatusInternalServerError,
},
{
name: "nil error returns 200 OK",
err: nil,
wantStatus: http.StatusOK,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := StatusCode(tt.err); got != tt.wantStatus {
t.Errorf("StatusCode() = %d, want %d", got, tt.wantStatus)
}
})
}
}
func TestIsHTTPError(t *testing.T) {
tests := []struct {
name string
err error
expected bool
}{
{
name: "HTTPError",
err: NotFound("not found"),
expected: true,
},
{
name: "regular error",
err: errors.New("some error"),
expected: false,
},
{
name: "nil",
err: nil,
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsHTTPError(tt.err); got != tt.expected {
t.Errorf("IsHTTPError() = %v, want %v", got, tt.expected)
}
})
}
}

133
pkg/api/handler.go Normal file
View File

@ -0,0 +1,133 @@
package api
import (
"errors"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5/middleware"
)
// HandlerFunc is a handler function that returns an error.
// Use with Wrap() to automatically handle error responses.
//
// Example:
//
// func GetUser(w http.ResponseWriter, r *http.Request) error {
// user, err := svc.Get(ctx, id)
// if err != nil {
// return api.NotFoundf("user %s not found", id)
// }
// api.WriteSuccess(w, r, user)
// return nil
// }
type HandlerFunc func(w http.ResponseWriter, r *http.Request) error
// Wrap converts a HandlerFunc to http.HandlerFunc, automatically handling
// error responses. HTTPErrors are written with their status code and details;
// other errors are logged and returned as 500 Internal Server Error.
//
// Example:
//
// r.Get("/users/{id}", api.Wrap(handlers.GetUser))
//
// func (h *Handlers) GetUser(w http.ResponseWriter, r *http.Request) error {
// id := chi.URLParam(r, "id")
// user, err := h.userSvc.Get(r.Context(), id)
// if err != nil {
// if errors.Is(err, ErrUserNotFound) {
// return api.NotFoundf("user %s not found", id)
// }
// return err // Will be logged and returned as 500
// }
// api.WriteSuccess(w, r, user)
// return nil
// }
func Wrap(h HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := h(w, r); err != nil {
writeErrorFromErr(w, r, err)
}
}
}
// WrapWithLogger is like Wrap but accepts a logger for internal error logging.
// Use this when you want to log internal errors that aren't HTTPErrors.
//
// Example:
//
// r.Get("/users/{id}", api.WrapWithLogger(handlers.GetUser, logger))
func WrapWithLogger(h HandlerFunc, logger *slog.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := h(w, r); err != nil {
writeErrorFromErrWithLogger(w, r, err, logger)
}
}
}
// writeErrorFromErr writes an error response based on the error type.
// HTTPErrors are written with their status code; other errors become 500.
func writeErrorFromErr(w http.ResponseWriter, r *http.Request, err error) {
writeErrorFromErrWithLogger(w, r, err, nil)
}
// writeErrorFromErrWithLogger writes an error response and optionally logs internal errors.
func writeErrorFromErrWithLogger(w http.ResponseWriter, r *http.Request, err error, logger *slog.Logger) {
var httpErr *HTTPError
if errors.As(err, &httpErr) {
// Write the HTTPError directly
WriteError(w, r, httpErr.Status, httpErr.Code, httpErr.Message, httpErr.Details)
return
}
// For non-HTTP errors, log and return generic 500
if logger != nil {
reqID := middleware.GetReqID(r.Context())
logger.Error("internal error",
"error", err,
"request_id", reqID,
"method", r.Method,
"path", r.URL.Path,
)
}
WriteInternalError(w, r, "internal error")
}
// -----------------------------------------------------------------------------
// Middleware Helpers
// -----------------------------------------------------------------------------
// MiddlewareFunc is a middleware that returns an error.
// Use with WrapMiddleware() to automatically handle error responses.
type MiddlewareFunc func(next http.Handler) func(w http.ResponseWriter, r *http.Request) error
// WrapMiddleware converts a MiddlewareFunc to a standard http middleware.
//
// Example:
//
// func AuthMiddleware(next http.Handler) func(w http.ResponseWriter, r *http.Request) error {
// return func(w http.ResponseWriter, r *http.Request) error {
// token := r.Header.Get("Authorization")
// if token == "" {
// return api.Unauthorized("missing authorization header")
// }
// user, err := validateToken(token)
// if err != nil {
// return api.Unauthorized("invalid token")
// }
// ctx := context.WithValue(r.Context(), userKey, user)
// next.ServeHTTP(w, r.WithContext(ctx))
// return nil
// }
// }
//
// r.Use(api.WrapMiddleware(AuthMiddleware))
func WrapMiddleware(m MiddlewareFunc) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := m(next)(w, r); err != nil {
writeErrorFromErr(w, r, err)
}
})
}
}

174
pkg/api/handler_test.go Normal file
View File

@ -0,0 +1,174 @@
package api
import (
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
)
func TestWrap(t *testing.T) {
tests := []struct {
name string
handler HandlerFunc
wantStatus int
wantCode string
wantHasError bool
wantHasData bool
}{
{
name: "success response",
handler: func(w http.ResponseWriter, r *http.Request) error {
WriteSuccess(w, r, map[string]string{"message": "hello"})
return nil
},
wantStatus: http.StatusOK,
wantHasData: true,
wantHasError: false,
},
{
name: "HTTPError returned",
handler: func(w http.ResponseWriter, r *http.Request) error {
return NotFound("user not found")
},
wantStatus: http.StatusNotFound,
wantCode: "NOT_FOUND",
wantHasError: true,
},
{
name: "generic error returned",
handler: func(w http.ResponseWriter, r *http.Request) error {
return errors.New("something went wrong")
},
wantStatus: http.StatusInternalServerError,
wantCode: "INTERNAL_ERROR",
wantHasError: true,
},
{
name: "validation error with details",
handler: func(w http.ResponseWriter, r *http.Request) error {
return WithDetails(Validation("validation failed"), []ValidationDetail{
{Field: "email", Message: "is required"},
})
},
wantStatus: http.StatusBadRequest,
wantCode: "VALIDATION_ERROR",
wantHasError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/test", nil)
rec := httptest.NewRecorder()
wrapped := Wrap(tt.handler)
wrapped(rec, req)
if rec.Code != tt.wantStatus {
t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus)
}
var resp Response
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
hasError := resp.Error != nil
if hasError != tt.wantHasError {
t.Errorf("hasError = %v, want %v", hasError, tt.wantHasError)
}
hasData := resp.Data != nil
if hasData != tt.wantHasData {
t.Errorf("hasData = %v, want %v", hasData, tt.wantHasData)
}
if tt.wantCode != "" && resp.Error != nil {
if resp.Error.Code != tt.wantCode {
t.Errorf("error code = %q, want %q", resp.Error.Code, tt.wantCode)
}
}
})
}
}
func TestWrapWithLogger(t *testing.T) {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
// Handler that returns a generic error (should be logged)
h := func(w http.ResponseWriter, r *http.Request) error {
return errors.New("database connection failed")
}
req := httptest.NewRequest(http.MethodGet, "/test", nil)
rec := httptest.NewRecorder()
wrapped := WrapWithLogger(h, logger)
wrapped(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Errorf("status = %d, want %d", rec.Code, http.StatusInternalServerError)
}
// Verify response doesn't leak internal error details
var resp Response
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.Error == nil {
t.Fatal("expected error in response")
}
// Message should be generic, not the actual error
if resp.Error.Message != "internal error" {
t.Errorf("error message = %q, want %q", resp.Error.Message, "internal error")
}
}
func TestWrapMiddleware(t *testing.T) {
authMiddleware := func(next http.Handler) func(w http.ResponseWriter, r *http.Request) error {
return func(w http.ResponseWriter, r *http.Request) error {
token := r.Header.Get("Authorization")
if token == "" {
return Unauthorized("missing authorization header")
}
next.ServeHTTP(w, r)
return nil
}
}
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
WriteSuccess(w, r, map[string]string{"status": "ok"})
})
middleware := WrapMiddleware(authMiddleware)
wrapped := middleware(handler)
t.Run("unauthorized without token", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/test", nil)
rec := httptest.NewRecorder()
wrapped.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
}
})
t.Run("success with token", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/test", nil)
req.Header.Set("Authorization", "Bearer token123")
rec := httptest.NewRecorder()
wrapped.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
}
})
}

214
pkg/api/health.go Normal file
View File

@ -0,0 +1,214 @@
package api
import (
"context"
"net/http"
"sync"
"time"
)
// HealthChecker is a function that checks the health of a dependency.
// Returns nil if healthy, an error describing the issue otherwise.
type HealthChecker func(ctx context.Context) error
// HealthCheckResult represents the result of a single health check.
type HealthCheckResult struct {
Name string `json:"name"`
Status string `json:"status"` // "healthy" or "unhealthy"
Latency string `json:"latency,omitempty"`
Error string `json:"error,omitempty"`
}
// HealthResponse is the response structure for health endpoints.
type HealthResponse struct {
Status string `json:"status"` // "healthy" or "unhealthy"
Service string `json:"service"`
Checks []HealthCheckResult `json:"checks,omitempty"`
Duration string `json:"duration,omitempty"`
}
// HealthConfig configures health check behavior.
type HealthConfig struct {
// Service name for identification
Service string
// Timeout for individual health checks (default: 5s)
Timeout time.Duration
// Checks is a map of check names to checker functions
Checks map[string]HealthChecker
}
// NewHealthHandler creates an HTTP handler that runs health checks concurrently.
// Returns 200 if all checks pass, 503 if any check fails.
//
// Example:
//
// healthHandler := api.NewHealthHandler(api.HealthConfig{
// Service: "my-service",
// Timeout: 5 * time.Second,
// Checks: map[string]api.HealthChecker{
// "database": func(ctx context.Context) error {
// return db.PingContext(ctx)
// },
// "redis": func(ctx context.Context) error {
// return redis.Ping(ctx).Err()
// },
// },
// })
//
// r.Get("/health", healthHandler)
func NewHealthHandler(cfg HealthConfig) http.HandlerFunc {
if cfg.Timeout == 0 {
cfg.Timeout = 5 * time.Second
}
return func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// If no checks configured, return simple healthy response
if len(cfg.Checks) == 0 {
WriteSuccess(w, r, HealthResponse{
Status: "healthy",
Service: cfg.Service,
})
return
}
// Create context with timeout
ctx, cancel := context.WithTimeout(r.Context(), cfg.Timeout)
defer cancel()
// Run checks concurrently
results := make([]HealthCheckResult, 0, len(cfg.Checks))
var mu sync.Mutex
var wg sync.WaitGroup
for name, checker := range cfg.Checks {
wg.Add(1)
go func(name string, checker HealthChecker) {
defer wg.Done()
checkStart := time.Now()
err := checker(ctx)
latency := time.Since(checkStart)
result := HealthCheckResult{
Name: name,
Status: "healthy",
Latency: latency.Round(time.Millisecond).String(),
}
if err != nil {
result.Status = "unhealthy"
result.Error = err.Error()
}
mu.Lock()
results = append(results, result)
mu.Unlock()
}(name, checker)
}
wg.Wait()
// Determine overall status
status := "healthy"
httpStatus := http.StatusOK
for _, result := range results {
if result.Status == "unhealthy" {
status = "unhealthy"
httpStatus = http.StatusServiceUnavailable
break
}
}
resp := HealthResponse{
Status: status,
Service: cfg.Service,
Checks: results,
Duration: time.Since(start).Round(time.Millisecond).String(),
}
WriteJSON(w, r, httpStatus, resp)
}
}
// NewLivenessHandler creates a simple liveness probe handler.
// Always returns 200 OK if the process is running.
// Use for Kubernetes liveness probes.
//
// Example:
//
// r.Get("/health/live", api.NewLivenessHandler("my-service"))
func NewLivenessHandler(service string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
WriteSuccess(w, r, map[string]string{
"status": "ok",
"service": service,
})
}
}
// NewReadinessHandler creates a readiness probe handler with dependency checks.
// Returns 200 if all checks pass, 503 if any check fails.
// Use for Kubernetes readiness probes.
//
// Example:
//
// r.Get("/health/ready", api.NewReadinessHandler(api.HealthConfig{
// Service: "my-service",
// Checks: map[string]api.HealthChecker{
// "database": dbHealthCheck,
// },
// }))
func NewReadinessHandler(cfg HealthConfig) http.HandlerFunc {
return NewHealthHandler(cfg)
}
// -----------------------------------------------------------------------------
// Common Health Checkers
// -----------------------------------------------------------------------------
// PingChecker creates a health checker from a Ping function.
// Many database clients have a Ping or PingContext method.
//
// Example:
//
// checks := map[string]api.HealthChecker{
// "postgres": api.PingChecker(db.PingContext),
// }
func PingChecker(pingFn func(context.Context) error) HealthChecker {
return pingFn
}
// HTTPChecker creates a health checker that makes an HTTP GET request.
// Returns error if status is not 2xx.
//
// Example:
//
// checks := map[string]api.HealthChecker{
// "external-api": api.HTTPChecker("https://api.example.com/health"),
// }
func HTTPChecker(url string) HealthChecker {
return func(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return Internalf("unhealthy: status %d", resp.StatusCode)
}
return nil
}
}

231
pkg/api/health_test.go Normal file
View File

@ -0,0 +1,231 @@
package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestNewHealthHandler(t *testing.T) {
tests := []struct {
name string
config HealthConfig
wantStatus int
wantHealth string
}{
{
name: "all checks healthy",
config: HealthConfig{
Service: "test-service",
Timeout: 5 * time.Second,
Checks: map[string]HealthChecker{
"db": func(ctx context.Context) error {
return nil
},
"cache": func(ctx context.Context) error {
return nil
},
},
},
wantStatus: http.StatusOK,
wantHealth: "healthy",
},
{
name: "one check unhealthy",
config: HealthConfig{
Service: "test-service",
Timeout: 5 * time.Second,
Checks: map[string]HealthChecker{
"db": func(ctx context.Context) error {
return nil
},
"cache": func(ctx context.Context) error {
return errors.New("connection refused")
},
},
},
wantStatus: http.StatusServiceUnavailable,
wantHealth: "unhealthy",
},
{
name: "no checks configured",
config: HealthConfig{
Service: "test-service",
},
wantStatus: http.StatusOK,
wantHealth: "healthy",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
handler := NewHealthHandler(tt.config)
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rec := httptest.NewRecorder()
handler(rec, req)
if rec.Code != tt.wantStatus {
t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus)
}
var resp Response
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
data, ok := resp.Data.(map[string]any)
if !ok {
t.Fatalf("expected map in data, got %T", resp.Data)
}
if status := data["status"]; status != tt.wantHealth {
t.Errorf("health status = %q, want %q", status, tt.wantHealth)
}
if service := data["service"]; service != tt.config.Service {
t.Errorf("service = %q, want %q", service, tt.config.Service)
}
})
}
}
func TestNewHealthHandler_Timeout(t *testing.T) {
config := HealthConfig{
Service: "test-service",
Timeout: 100 * time.Millisecond,
Checks: map[string]HealthChecker{
"slow": func(ctx context.Context) error {
select {
case <-time.After(1 * time.Second):
return nil
case <-ctx.Done():
return ctx.Err()
}
},
},
}
handler := NewHealthHandler(config)
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rec := httptest.NewRecorder()
start := time.Now()
handler(rec, req)
duration := time.Since(start)
// Should return before the full second
if duration > 500*time.Millisecond {
t.Errorf("took %v, expected less than 500ms", duration)
}
// Should be unhealthy due to timeout
if rec.Code != http.StatusServiceUnavailable {
t.Errorf("status = %d, want %d", rec.Code, http.StatusServiceUnavailable)
}
}
func TestNewLivenessHandler(t *testing.T) {
handler := NewLivenessHandler("test-service")
req := httptest.NewRequest(http.MethodGet, "/health/live", nil)
rec := httptest.NewRecorder()
handler(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
}
var resp Response
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
data, ok := resp.Data.(map[string]any)
if !ok {
t.Fatalf("expected map in data, got %T", resp.Data)
}
if status := data["status"]; status != "ok" {
t.Errorf("status = %q, want %q", status, "ok")
}
if service := data["service"]; service != "test-service" {
t.Errorf("service = %q, want %q", service, "test-service")
}
}
func TestPingChecker(t *testing.T) {
t.Run("healthy ping", func(t *testing.T) {
pingFn := func(ctx context.Context) error {
return nil
}
checker := PingChecker(pingFn)
err := checker(context.Background())
if err != nil {
t.Errorf("expected nil, got %v", err)
}
})
t.Run("unhealthy ping", func(t *testing.T) {
pingErr := errors.New("connection refused")
pingFn := func(ctx context.Context) error {
return pingErr
}
checker := PingChecker(pingFn)
err := checker(context.Background())
if err != pingErr {
t.Errorf("expected %v, got %v", pingErr, err)
}
})
}
func TestHTTPChecker(t *testing.T) {
t.Run("healthy endpoint", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
checker := HTTPChecker(server.URL)
err := checker(context.Background())
if err != nil {
t.Errorf("expected nil, got %v", err)
}
})
t.Run("unhealthy endpoint", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
}))
defer server.Close()
checker := HTTPChecker(server.URL)
err := checker(context.Background())
if err == nil {
t.Error("expected error, got nil")
}
})
t.Run("connection refused", func(t *testing.T) {
checker := HTTPChecker("http://localhost:99999")
err := checker(context.Background())
if err == nil {
t.Error("expected error, got nil")
}
})
}

View File

@ -22,13 +22,21 @@ type OpenAPIServer struct {
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
} }
// OpenAPIComponents contains reusable schema definitions.
type OpenAPIComponents struct {
Schemas map[string]any `json:"schemas,omitempty"`
SecuritySchemes map[string]any `json:"securitySchemes,omitempty"`
}
// OpenAPISpec represents a minimal OpenAPI 3.0 specification. // OpenAPISpec represents a minimal OpenAPI 3.0 specification.
type OpenAPISpec struct { type OpenAPISpec struct {
OpenAPI string `json:"openapi"` OpenAPI string `json:"openapi"`
Info OpenAPIInfo `json:"info"` Info OpenAPIInfo `json:"info"`
Servers []OpenAPIServer `json:"servers,omitempty"` Servers []OpenAPIServer `json:"servers,omitempty"`
Paths map[string]map[string]interface{} `json:"paths"` Paths map[string]map[string]any `json:"paths"`
Tags []OpenAPITag `json:"tags,omitempty"` Tags []OpenAPITag `json:"tags,omitempty"`
Components *OpenAPIComponents `json:"components,omitempty"`
Security []map[string][]string `json:"security,omitempty"`
mu sync.RWMutex mu sync.RWMutex
} }
@ -47,7 +55,7 @@ func NewOpenAPISpec(title, version string) *OpenAPISpec {
Title: title, Title: title,
Version: version, Version: version,
}, },
Paths: make(map[string]map[string]interface{}), Paths: make(map[string]map[string]any),
} }
} }
@ -77,17 +85,88 @@ func (s *OpenAPISpec) WithTag(name, description string) *OpenAPISpec {
// AddPath adds an operation to the spec. // AddPath adds an operation to the spec.
// method should be lowercase (get, post, put, patch, delete). // method should be lowercase (get, post, put, patch, delete).
func (s *OpenAPISpec) AddPath(path, method string, operation map[string]interface{}) *OpenAPISpec { func (s *OpenAPISpec) AddPath(path, method string, operation map[string]any) *OpenAPISpec {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
if s.Paths[path] == nil { if s.Paths[path] == nil {
s.Paths[path] = make(map[string]interface{}) s.Paths[path] = make(map[string]any)
} }
s.Paths[path][method] = operation s.Paths[path][method] = operation
return s return s
} }
// ensureComponents initializes the Components field if nil.
// Must be called while holding s.mu.
func (s *OpenAPISpec) ensureComponents() {
if s.Components == nil {
s.Components = &OpenAPIComponents{
Schemas: make(map[string]any),
SecuritySchemes: make(map[string]any),
}
}
}
// WithSchema adds a reusable schema to components/schemas.
func (s *OpenAPISpec) WithSchema(name string, schema Schema) *OpenAPISpec {
s.mu.Lock()
defer s.mu.Unlock()
s.ensureComponents()
if s.Components.Schemas == nil {
s.Components.Schemas = make(map[string]any)
}
s.Components.Schemas[name] = schema
return s
}
// WithAPIKeySecurity adds API key security scheme.
func (s *OpenAPISpec) WithAPIKeySecurity(name, headerName, description string) *OpenAPISpec {
s.mu.Lock()
defer s.mu.Unlock()
s.ensureComponents()
if s.Components.SecuritySchemes == nil {
s.Components.SecuritySchemes = make(map[string]any)
}
s.Components.SecuritySchemes[name] = map[string]any{
"type": "apiKey",
"in": "header",
"name": headerName,
"description": description,
}
return s
}
// WithBearerSecurity adds Bearer token security scheme.
func (s *OpenAPISpec) WithBearerSecurity(name, description string) *OpenAPISpec {
s.mu.Lock()
defer s.mu.Unlock()
s.ensureComponents()
if s.Components.SecuritySchemes == nil {
s.Components.SecuritySchemes = make(map[string]any)
}
s.Components.SecuritySchemes[name] = map[string]any{
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT",
"description": description,
}
return s
}
// WithGlobalSecurity sets global security requirements.
func (s *OpenAPISpec) WithGlobalSecurity(schemeName string) *OpenAPISpec {
s.mu.Lock()
defer s.mu.Unlock()
s.Security = append(s.Security, map[string][]string{
schemeName: {},
})
return s
}
// JSON returns the spec as JSON bytes. // JSON returns the spec as JSON bytes.
func (s *OpenAPISpec) JSON() ([]byte, error) { func (s *OpenAPISpec) JSON() ([]byte, error) {
s.mu.RLock() s.mu.RLock()

209
pkg/api/openapi_params.go Normal file
View File

@ -0,0 +1,209 @@
package api
import "strings"
// Parameter represents an OpenAPI parameter.
type Parameter map[string]any
// PathParam creates a required path parameter.
//
// Example:
//
// PathParam("id", "User identifier")
func PathParam(name, description string) Parameter {
return Parameter{
"name": name,
"in": "path",
"required": true,
"description": description,
"schema": String(),
}
}
// PathParamWithSchema creates a required path parameter with a custom schema.
func PathParamWithSchema(name, description string, schema Schema) Parameter {
return Parameter{
"name": name,
"in": "path",
"required": true,
"description": description,
"schema": schema,
}
}
// QueryParam creates an optional query parameter.
//
// Example:
//
// QueryParam("page", "Page number", false)
func QueryParam(name, description string, required bool) Parameter {
return Parameter{
"name": name,
"in": "query",
"required": required,
"description": description,
"schema": String(),
}
}
// QueryParamWithSchema creates a query parameter with a custom schema.
func QueryParamWithSchema(name, description string, required bool, schema Schema) Parameter {
return Parameter{
"name": name,
"in": "query",
"required": required,
"description": description,
"schema": schema,
}
}
// HeaderParam creates a header parameter.
func HeaderParam(name, description string, required bool) Parameter {
return Parameter{
"name": name,
"in": "header",
"required": required,
"description": description,
"schema": String(),
}
}
// CookieParam creates a cookie parameter.
func CookieParam(name, description string, required bool) Parameter {
return Parameter{
"name": name,
"in": "cookie",
"required": required,
"description": description,
"schema": String(),
}
}
// WithExample adds an example to a parameter.
func (p Parameter) WithExample(example any) Parameter {
p["example"] = example
return p
}
// WithDefault adds a default value to a parameter.
func (p Parameter) WithDefault(value any) Parameter {
if schema, ok := p["schema"].(Schema); ok {
schema["default"] = value
p["schema"] = schema
}
return p
}
// WithDeprecated marks a parameter as deprecated.
func (p Parameter) WithDeprecated(deprecated bool) Parameter {
p["deprecated"] = deprecated
return p
}
// -----------------------------------------------------------------------------
// Common Parameter Presets
// -----------------------------------------------------------------------------
// IDParam creates a standard ID path parameter.
func IDParam() Parameter {
return PathParamWithSchema("id", "Resource identifier", UUID())
}
// PageParam creates a standard pagination page parameter.
func PageParam() Parameter {
return QueryParamWithSchema("page", "Page number (1-indexed)", false, Int().WithDefault(1))
}
// PerPageParam creates a standard items-per-page parameter.
func PerPageParam() Parameter {
return QueryParamWithSchema("per_page", "Items per page (max 100)", false, IntWithMinMax(1, 100).WithDefault(20))
}
// SortParam creates a sort parameter.
// If allowedFields are provided, they're listed in the description.
func SortParam(allowedFields ...string) Parameter {
desc := "Sort field and direction (e.g., name:asc, created_at:desc)"
if len(allowedFields) > 0 {
desc = "Sort by: " + strings.Join(allowedFields, ", ") + " (append :asc or :desc)"
}
return QueryParamWithSchema("sort", desc, false,
String().WithExample("created_at:desc"))
}
// SearchParam creates a search query parameter.
func SearchParam() Parameter {
return QueryParam("q", "Search query", false).WithExample("keyword")
}
// APIKeyHeader creates the X-API-Key header parameter.
func APIKeyHeader() Parameter {
return HeaderParam("X-API-Key", "API key for authentication", true)
}
// AuthorizationHeader creates the Authorization header parameter.
func AuthorizationHeader() Parameter {
return HeaderParam("Authorization", "Bearer token for authentication", true).
WithExample("Bearer eyJhbGciOiJIUzI1NiIs...")
}
// -----------------------------------------------------------------------------
// Request Body Helpers
// -----------------------------------------------------------------------------
// RequestBody creates a JSON request body.
func RequestBody(schema Schema, required bool) map[string]any {
return map[string]any{
"required": required,
"content": map[string]any{
"application/json": map[string]any{
"schema": schema,
},
},
}
}
// -----------------------------------------------------------------------------
// Response Helpers
// -----------------------------------------------------------------------------
// OpResponse creates a response definition for an OpenAPI operation.
func OpResponse(description string, schema Schema) map[string]any {
return map[string]any{
"description": description,
"content": map[string]any{
"application/json": map[string]any{
"schema": schema,
},
},
}
}
// OpResponseNoContent creates a 204 No Content response.
func OpResponseNoContent() map[string]any {
return map[string]any{
"description": "No content",
}
}
// OpResponses creates a responses map for an operation.
func OpResponses(responses map[string]map[string]any) map[string]any {
result := make(map[string]any, len(responses))
for code, resp := range responses {
result[code] = resp
}
return result
}
// OpStandardResponses returns common error responses to include in operations.
func OpStandardResponses() map[string]map[string]any {
return map[string]map[string]any{
"400": OpResponse("Bad request", ErrorResponseSchema()),
"401": OpResponse("Unauthorized", ErrorResponseSchema()),
"403": OpResponse("Forbidden", ErrorResponseSchema()),
"404": OpResponse("Not found", ErrorResponseSchema()),
"422": OpResponse("Unprocessable entity", ErrorResponseSchema()),
"429": OpResponse("Too many requests", ErrorResponseSchema()),
"500": OpResponse("Internal server error", ErrorResponseSchema()),
"503": OpResponse("Service unavailable", ErrorResponseSchema()),
}
}

240
pkg/api/openapi_schema.go Normal file
View File

@ -0,0 +1,240 @@
package api
// Schema represents a JSON Schema for OpenAPI.
type Schema map[string]any
// String creates a string schema.
func String() Schema {
return Schema{"type": "string"}
}
// StringWithFormat creates a string schema with a format.
// Common formats: email, uri, uuid, date, date-time, password
func StringWithFormat(format string) Schema {
return Schema{"type": "string", "format": format}
}
// StringEnum creates a string schema restricted to specific values.
func StringEnum(values ...string) Schema {
return Schema{"type": "string", "enum": values}
}
// StringWithMinMax creates a string schema with length constraints.
func StringWithMinMax(min, max int) Schema {
s := Schema{"type": "string"}
if min > 0 {
s["minLength"] = min
}
if max > 0 {
s["maxLength"] = max
}
return s
}
// Int creates an integer schema.
func Int() Schema {
return Schema{"type": "integer"}
}
// IntWithMinMax creates an integer schema with constraints.
func IntWithMinMax(min, max int) Schema {
s := Schema{"type": "integer"}
if min != 0 {
s["minimum"] = min
}
if max != 0 {
s["maximum"] = max
}
return s
}
// Int64 creates a 64-bit integer schema.
func Int64() Schema {
return Schema{"type": "integer", "format": "int64"}
}
// Number creates a number (float) schema.
func Number() Schema {
return Schema{"type": "number"}
}
// Bool creates a boolean schema.
func Bool() Schema {
return Schema{"type": "boolean"}
}
// Array creates an array schema with the given item type.
func Array(items Schema) Schema {
return Schema{
"type": "array",
"items": items,
}
}
// Object creates an object schema with the given properties.
// Required fields can be specified separately.
func Object(props map[string]Schema, required ...string) Schema {
properties := make(map[string]any, len(props))
for k, v := range props {
properties[k] = v
}
s := Schema{
"type": "object",
"properties": properties,
}
if len(required) > 0 {
s["required"] = required
}
return s
}
// Ref creates a $ref to a schema in components/schemas.
func Ref(name string) Schema {
return Schema{"$ref": "#/components/schemas/" + name}
}
// RefArray creates an array of $ref items.
func RefArray(name string) Schema {
return Array(Ref(name))
}
// Nullable makes a schema nullable using oneOf pattern.
func Nullable(s Schema) Schema {
return Schema{
"oneOf": []Schema{
s,
{"type": "null"},
},
}
}
// WithDescription adds a description to a schema.
func (s Schema) WithDescription(desc string) Schema {
s["description"] = desc
return s
}
// WithExample adds an example to a schema.
func (s Schema) WithExample(example any) Schema {
s["example"] = example
return s
}
// WithDefault adds a default value to a schema.
func (s Schema) WithDefault(value any) Schema {
s["default"] = value
return s
}
// WithPattern adds a regex pattern to a string schema.
func (s Schema) WithPattern(pattern string) Schema {
s["pattern"] = pattern
return s
}
// Format sets the format for a schema.
// Common formats: email, uri, uuid, date, date-time, password, int32, int64
func (s Schema) Format(format string) Schema {
s["format"] = format
return s
}
// Description is an alias for WithDescription for cleaner chaining.
func (s Schema) Description(desc string) Schema {
return s.WithDescription(desc)
}
// Example is an alias for WithExample for cleaner chaining.
func (s Schema) Example(example any) Schema {
return s.WithExample(example)
}
// Default is an alias for WithDefault for cleaner chaining.
func (s Schema) Default(value any) Schema {
return s.WithDefault(value)
}
// Pattern is an alias for WithPattern for cleaner chaining.
func (s Schema) Pattern(pattern string) Schema {
return s.WithPattern(pattern)
}
// -----------------------------------------------------------------------------
// Common Schema Presets
// -----------------------------------------------------------------------------
// UUID creates a UUID string schema.
func UUID() Schema {
return StringWithFormat("uuid").WithExample("550e8400-e29b-41d4-a716-446655440000")
}
// Email creates an email string schema.
func Email() Schema {
return StringWithFormat("email").WithExample("user@example.com")
}
// URL creates a URL string schema.
func URL() Schema {
return StringWithFormat("uri").WithExample("https://example.com")
}
// DateTime creates a date-time string schema.
func DateTime() Schema {
return StringWithFormat("date-time").WithExample("2024-01-15T10:30:00Z")
}
// Password creates a password string schema (hidden in docs).
func Password() Schema {
return StringWithFormat("password")
}
// Pagination creates a common pagination object schema.
func Pagination() Schema {
return Object(map[string]Schema{
"page": Int().WithDescription("Current page number").WithExample(1),
"per_page": Int().WithDescription("Items per page").WithExample(20),
"total": Int().WithDescription("Total number of items").WithExample(100),
"total_pages": Int().WithDescription("Total number of pages").WithExample(5),
})
}
// -----------------------------------------------------------------------------
// Response Schema Helpers
// -----------------------------------------------------------------------------
// ResponseSchema creates the standard response envelope schema.
func ResponseSchema(dataSchema Schema) Schema {
return Object(map[string]Schema{
"data": dataSchema,
"meta": Object(map[string]Schema{
"request_id": String().WithDescription("Request correlation ID"),
"timestamp": DateTime().WithDescription("Response timestamp"),
}),
})
}
// ErrorResponseSchema creates the standard error response schema.
func ErrorResponseSchema() Schema {
return Object(map[string]Schema{
"error": Object(map[string]Schema{
"code": String().WithDescription("Machine-readable error code").WithExample("BAD_REQUEST"),
"message": String().WithDescription("Human-readable error message").WithExample("Invalid request"),
"details": Schema{"type": "object"}.WithDescription("Additional error details"),
}, "code", "message"),
"meta": Object(map[string]Schema{
"request_id": String().WithDescription("Request correlation ID"),
"timestamp": DateTime().WithDescription("Response timestamp"),
}),
})
}
// ValidationErrorSchema creates a validation error details schema.
func ValidationErrorSchema() Schema {
return Array(Object(map[string]Schema{
"field": String().WithDescription("Field that failed validation").WithExample("email"),
"message": String().WithDescription("Validation error message").WithExample("is required"),
}, "field", "message"))
}